From ba6c4b748f7c8dcb3ea58ff149475d21d28888b2 Mon Sep 17 00:00:00 2001 From: Anunay Aatipamula Date: Mon, 2 Feb 2026 18:41:17 +0530 Subject: [PATCH 01/13] feat(discord): add Discord channel support - Implement Discord channel functionality with websocket integration. - Update configuration schema to include Discord settings. - Enhance README with setup instructions for Discord integration. - Modify channel manager to initialize Discord channel if enabled. - Update CLI status command to display Discord channel status. --- README.md | 48 ++++++- nanobot/channels/discord.py | 252 ++++++++++++++++++++++++++++++++++++ nanobot/channels/manager.py | 11 ++ nanobot/cli/commands.py | 14 ++ nanobot/config/schema.py | 10 ++ 5 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 nanobot/channels/discord.py diff --git a/README.md b/README.md index 167ae22..9ad90b0 100644 --- a/README.md +++ b/README.md @@ -155,11 +155,12 @@ 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, Discord, or WhatsApp — anytime, anywhere. | Channel | Setup | |---------|-------| | **Telegram** | Easy (just a token) | +| **Discord** | Easy (bot token + intents) | | **WhatsApp** | Medium (scan QR) |
@@ -194,6 +195,46 @@ 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. Configure** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +**4. 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 + +**5. Run** + +```bash +nanobot gateway +``` + +
+
WhatsApp @@ -254,6 +295,11 @@ nanobot gateway "token": "123456:ABC...", "allowFrom": ["123456789"] }, + "discord": { + "enabled": false, + "token": "YOUR_DISCORD_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + }, "whatsapp": { "enabled": false } diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py new file mode 100644 index 0000000..124e9cf --- /dev/null +++ b/nanobot/channels/discord.py @@ -0,0 +1,252 @@ +"""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" +DEFAULT_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB + + +class DiscordChannel(BaseChannel): + """ + Discord channel using Gateway websocket. + + Handles: + - Gateway connection + heartbeat + - MESSAGE_CREATE events + - REST API for outbound messages + """ + + 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._session_id: str | None = None + self._heartbeat_task: asyncio.Task | None = None + self._http: httpx.AsyncClient | None = None + self._max_attachment_bytes = DEFAULT_MAX_ATTACHMENT_BYTES + + 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 + 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}"} + + 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) + + 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": + self._session_id = payload.get("session_id") + 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] = [] + + attachments = payload.get("attachments") or [] + for attachment in attachments: + 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 > self._max_attachment_bytes: + content_parts.append(f"[attachment: {filename} - too large]") + continue + try: + media_dir = Path.home() / ".nanobot" / "media" + media_dir.mkdir(parents=True, exist_ok=True) + safe_name = filename.replace("/", "_") + file_path = media_dir / f"{attachment.get('id', 'file')}_{safe_name}" + response = await self._http.get(url) + response.raise_for_status() + file_path.write_bytes(response.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]") + + message_id = str(payload.get("id", "")) + guild_id = payload.get("guild_id") + referenced = payload.get("referenced_message") or {} + reply_to_id = referenced.get("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": message_id, + "guild_id": guild_id, + "channel_id": channel_id, + "author": { + "id": author.get("id"), + "username": author.get("username"), + "discriminator": author.get("discriminator"), + }, + "mentions": payload.get("mentions", []), + "reply_to": reply_to_id, + }, + ) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 04abf5f..c72068b 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -53,6 +53,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}") 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 6e37aec..943ab0b 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -362,6 +362,20 @@ def channels_status(): "✓" if wa.enabled else "✗", wa.bridge_url ) + + tg = config.channels.telegram + table.add_row( + "Telegram", + "✓" if tg.enabled else "✗", + "polling" + ) + + dc = config.channels.discord + table.add_row( + "Discord", + "✓" if dc.enabled else "✗", + dc.gateway_url + ) console.print(table) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 0db887e..e73e083 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -19,10 +19,20 @@ class TelegramConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames +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) class AgentDefaults(BaseModel): From 884690e3c72d8eca1427c520500a33d2df1dccfc Mon Sep 17 00:00:00 2001 From: Anunay Aatipamula Date: Mon, 2 Feb 2026 18:53:47 +0530 Subject: [PATCH 02/13] docs: update README to include limitations of current implementation - Added section outlining current limitations such as global allowlist, lack of per-guild/channel rules, and restrictions on outbound message types. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 9ad90b0..9247f21 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,11 @@ nanobot gateway } ``` +**Limitations (current implementation)** +- Global allowlist only (`allowFrom`); no `groupPolicy`, `dm.policy`, or per-guild/per-channel rules +- No `requireMention` or per-channel enable/disable +- Outbound messages are text only (no file uploads) + **4. Invite the bot** - OAuth2 → URL Generator - Scopes: `bot` From bab464df5fb7808a19618af07ed928dea5ea880f Mon Sep 17 00:00:00 2001 From: Anunay Aatipamula Date: Mon, 2 Feb 2026 19:01:46 +0530 Subject: [PATCH 03/13] feat(discord): implement typing indicator functionality - Add methods to manage typing indicators in Discord channels. - Introduce periodic typing notifications while sending messages. - Ensure proper cleanup of typing tasks on channel closure. --- nanobot/channels/discord.py | 69 ++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 124e9cf..be7ac9e 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -38,6 +38,7 @@ class DiscordChannel(BaseChannel): self._seq: int | None = None self._session_id: str | None = None self._heartbeat_task: asyncio.Task | None = None + self._typing_tasks: dict[str, asyncio.Task] = {} self._http: httpx.AsyncClient | None = None self._max_attachment_bytes = DEFAULT_MAX_ATTACHMENT_BYTES @@ -70,6 +71,9 @@ class DiscordChannel(BaseChannel): 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 @@ -92,22 +96,25 @@ class DiscordChannel(BaseChannel): headers = {"Authorization": f"Bot {self.config.token}"} - 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) + 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.""" @@ -232,6 +239,8 @@ class DiscordChannel(BaseChannel): referenced = payload.get("referenced_message") or {} reply_to_id = referenced.get("id") + await self._start_typing(channel_id) + await self._handle_message( sender_id=sender_id, chat_id=channel_id, @@ -250,3 +259,31 @@ class DiscordChannel(BaseChannel): "reply_to": reply_to_id, }, ) + + async def _send_typing(self, channel_id: str) -> None: + """Send a typing indicator to Discord.""" + if not self._http: + return + url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing" + headers = {"Authorization": f"Bot {self.config.token}"} + try: + await self._http.post(url, headers=headers) + except Exception as e: + logger.debug(f"Discord typing indicator failed: {e}") + + 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: + while self._running: + await self._send_typing(channel_id) + 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() From 22156d3a40884d79a9e914c76fc7778d2a22f7de Mon Sep 17 00:00:00 2001 From: Shukfan Law <410070474@qq.com> Date: Wed, 4 Feb 2026 22:17:35 +0800 Subject: [PATCH 04/13] feat: added runtime environment summary to system prompt --- nanobot/agent/context.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index f70103d..3c95b66 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,7 @@ 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()) + runtime_summary = self._get_runtime_environment_summary() return f"""# nanobot 🐈 @@ -87,6 +89,9 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you ## Current Time {now} +## Runtime Environment +{runtime_summary} + ## Workspace Your workspace is at: {workspace_path} - Memory files: {workspace_path}/memory/MEMORY.md @@ -99,6 +104,19 @@ For normal conversation, just respond with text - do not call the message tool. Always be helpful, accurate, and concise. When using tools, explain what you're doing. When remembering something, write to {workspace_path}/memory/MEMORY.md""" + + def _get_runtime_environment_summary(self) -> str: + system = platform.system() + system_map = {"Darwin": "MacOS", "Windows": "Windows", "Linux": "Linux"} + system_label = system_map.get(system, system) + release = platform.release() + machine = platform.machine() + python_version = platform.python_version() + node = platform.node() + return ( + f"Runtime environment: OS {system_label} {release} ({machine}), " + f"Python {python_version}, Hostname {node}." + ) def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" From d5ee8f3e554c9340dd243121b19ce30229571625 Mon Sep 17 00:00:00 2001 From: Devin <99658722+DeeJ4yNg@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:45:36 +0800 Subject: [PATCH 05/13] Update context.py Add doc string. --- nanobot/agent/context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 3c95b66..16c7769 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -106,6 +106,7 @@ Always be helpful, accurate, and concise. When using tools, explain what you're When remembering something, write to {workspace_path}/memory/MEMORY.md""" def _get_runtime_environment_summary(self) -> str: + """Get runtime environment information.""" system = platform.system() system_map = {"Darwin": "MacOS", "Windows": "Windows", "Linux": "Linux"} system_label = system_map.get(system, system) From 764c6d02a1f2640380227c075be8aa342ab18ca7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Feb 2026 03:26:39 +0000 Subject: [PATCH 06/13] refactor: simplify runtime environment info in system prompt --- nanobot/agent/context.py | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index a4086f5..3ea6c04 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -75,7 +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()) - runtime_summary = self._get_runtime_environment_summary() + system = platform.system() + runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" return f"""# nanobot 🐈 @@ -89,8 +90,8 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you ## Current Time {now} -## Runtime Environment -{runtime_summary} +## Runtime +{runtime} ## Workspace Your workspace is at: {workspace_path} @@ -104,20 +105,6 @@ For normal conversation, just respond with text - do not call the message tool. Always be helpful, accurate, and concise. When using tools, explain what you're doing. When remembering something, write to {workspace_path}/memory/MEMORY.md""" - - def _get_runtime_environment_summary(self) -> str: - """Get runtime environment information.""" - system = platform.system() - system_map = {"Darwin": "MacOS", "Windows": "Windows", "Linux": "Linux"} - system_label = system_map.get(system, system) - release = platform.release() - machine = platform.machine() - python_version = platform.python_version() - node = platform.node() - return ( - f"Runtime environment: OS {system_label} {release} ({machine}), " - f"Python {python_version}, Hostname {node}." - ) def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" From 16f6fdf5d3e46a4a889c9c468dc3df4e2b4c8758 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Fri, 6 Feb 2026 14:14:28 +0800 Subject: [PATCH 07/13] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 17fa8eb..376930f 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ ## ðŸ“Ē 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-02** 🎉 nanobot launched! Welcome to try 🐈 nanobot! +- **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: From 8a1d7c76d23cb7dd99f54a82fb89c49c25771591 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Feb 2026 07:04:10 +0000 Subject: [PATCH 08/13] refactor: simplify discord channel and improve setup docs --- README.md | 15 ++++---- nanobot/channels/discord.py | 68 +++++++++++-------------------------- 2 files changed, 27 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 6cded80..4444d5b 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,11 @@ nanobot gateway - 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. Configure** +**3. Get your User ID** +- Discord Settings → Advanced → enable **Developer Mode** +- Right-click your avatar → **Copy User ID** + +**4. Configure** ```json { @@ -228,18 +232,13 @@ nanobot gateway } ``` -**Limitations (current implementation)** -- Global allowlist only (`allowFrom`); no `groupPolicy`, `dm.policy`, or per-guild/per-channel rules -- No `requireMention` or per-channel enable/disable -- Outbound messages are text only (no file uploads) - -**4. Invite the bot** +**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 -**5. Run** +**6. Run** ```bash nanobot gateway diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index be7ac9e..a76d6ac 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -16,18 +16,11 @@ from nanobot.config.schema import DiscordConfig DISCORD_API_BASE = "https://discord.com/api/v10" -DEFAULT_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB +MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB class DiscordChannel(BaseChannel): - """ - Discord channel using Gateway websocket. - - Handles: - - Gateway connection + heartbeat - - MESSAGE_CREATE events - - REST API for outbound messages - """ + """Discord channel using Gateway websocket.""" name = "discord" @@ -36,11 +29,9 @@ class DiscordChannel(BaseChannel): self.config: DiscordConfig = config self._ws: websockets.WebSocketClientProtocol | None = None self._seq: int | None = None - self._session_id: str | None = None self._heartbeat_task: asyncio.Task | None = None self._typing_tasks: dict[str, asyncio.Task] = {} self._http: httpx.AsyncClient | None = None - self._max_attachment_bytes = DEFAULT_MAX_ATTACHMENT_BYTES async def start(self) -> None: """Start the Discord gateway connection.""" @@ -142,7 +133,6 @@ class DiscordChannel(BaseChannel): await self._start_heartbeat(interval_ms / 1000) await self._identify() elif op == 0 and event_type == "READY": - self._session_id = payload.get("session_id") logger.info("Discord gateway READY") elif op == 0 and event_type == "MESSAGE_CREATE": await self._handle_message_create(payload) @@ -209,75 +199,57 @@ class DiscordChannel(BaseChannel): content_parts = [content] if content else [] media_paths: list[str] = [] + media_dir = Path.home() / ".nanobot" / "media" - attachments = payload.get("attachments") or [] - for attachment in attachments: + 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 > self._max_attachment_bytes: + if size and size > MAX_ATTACHMENT_BYTES: content_parts.append(f"[attachment: {filename} - too large]") continue try: - media_dir = Path.home() / ".nanobot" / "media" media_dir.mkdir(parents=True, exist_ok=True) - safe_name = filename.replace("/", "_") - file_path = media_dir / f"{attachment.get('id', 'file')}_{safe_name}" - response = await self._http.get(url) - response.raise_for_status() - file_path.write_bytes(response.content) + 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]") - message_id = str(payload.get("id", "")) - guild_id = payload.get("guild_id") - referenced = payload.get("referenced_message") or {} - reply_to_id = referenced.get("id") + 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]", + content="\n".join(p for p in content_parts if p) or "[empty message]", media=media_paths, metadata={ - "message_id": message_id, - "guild_id": guild_id, - "channel_id": channel_id, - "author": { - "id": author.get("id"), - "username": author.get("username"), - "discriminator": author.get("discriminator"), - }, - "mentions": payload.get("mentions", []), - "reply_to": reply_to_id, + "message_id": str(payload.get("id", "")), + "guild_id": payload.get("guild_id"), + "reply_to": reply_to, }, ) - async def _send_typing(self, channel_id: str) -> None: - """Send a typing indicator to Discord.""" - if not self._http: - return - url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing" - headers = {"Authorization": f"Bot {self.config.token}"} - try: - await self._http.post(url, headers=headers) - except Exception as e: - logger.debug(f"Discord typing indicator failed: {e}") - 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: - await self._send_typing(channel_id) + 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()) From e680b734b1711f2d7efe38017ea5fd6b8877265c Mon Sep 17 00:00:00 2001 From: mengjiechen Date: Fri, 6 Feb 2026 15:15:15 +0800 Subject: [PATCH 09/13] feat: add Moonshot provider support - Add moonshot to ProvidersConfig schema - Add MOONSHOT_API_BASE environment variable for custom endpoint - Handle kimi-k2.5 model temperature restriction (must be 1.0) - Fix is_vllm detection to exclude moonshot provider Co-Authored-By: Claude Opus 4.6 --- nanobot/config/schema.py | 8 ++++-- nanobot/providers/litellm_provider.py | 40 ++++++++++++++++++++------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 7f8c495..5e8e46c 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -77,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): @@ -122,7 +123,7 @@ class Config(BaseSettings): 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.""" + """Get API key in priority order: OpenRouter > DeepSeek > Anthropic > OpenAI > Gemini > Zhipu > Groq > Moonshot > vLLM.""" return ( self.providers.openrouter.api_key or self.providers.deepseek.api_key or @@ -131,16 +132,19 @@ class Config(BaseSettings): self.providers.gemini.api_key or self.providers.zhipu.api_key or self.providers.groq.api_key or + self.providers.moonshot.api_key or self.providers.vllm.api_key or None ) def get_api_base(self) -> str | None: - """Get API base URL if using OpenRouter, Zhipu or vLLM.""" + """Get API base URL if using OpenRouter, Zhipu, Moonshot or vLLM.""" if self.providers.openrouter.api_key: return self.providers.openrouter.api_base or "https://openrouter.ai/api/v1" if self.providers.zhipu.api_key: return self.providers.zhipu.api_base + if self.providers.moonshot.api_key: + return self.providers.moonshot.api_base if self.providers.vllm.api_base: return self.providers.vllm.api_base return None diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index d010d81..c2cdda7 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -31,9 +31,15 @@ class LiteLLMProvider(LLMProvider): (api_key and api_key.startswith("sk-or-")) or (api_base and "openrouter" in api_base) ) - + + # Detect Moonshot by api_base or model name + self.is_moonshot = ( + (api_base and "moonshot" in api_base) or + ("moonshot" in default_model or "kimi" in default_model) + ) + # Track if using custom endpoint (vLLM, etc.) - self.is_vllm = bool(api_base) and not self.is_openrouter + self.is_vllm = bool(api_base) and not self.is_openrouter and not self.is_moonshot # Configure LiteLLM based on provider if api_key: @@ -55,8 +61,12 @@ class LiteLLMProvider(LLMProvider): os.environ.setdefault("ZHIPUAI_API_KEY", api_key) elif "groq" in default_model: os.environ.setdefault("GROQ_API_KEY", api_key) - - if api_base: + elif "moonshot" in default_model or "kimi" in default_model: + os.environ.setdefault("MOONSHOT_API_KEY", api_key) + if api_base: + os.environ["MOONSHOT_API_BASE"] = api_base + + if api_base and not self.is_moonshot: litellm.api_base = api_base # Disable LiteLLM logging noise @@ -97,23 +107,33 @@ 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}" - kwargs: dict[str, Any] = { "model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature, } - + + # kimi-k2.5 only supports temperature=1.0 + if "kimi-k2.5" in model.lower(): + kwargs["temperature"] = 1.0 + # Pass api_base directly for custom endpoints (vLLM, etc.) if self.api_base: kwargs["api_base"] = self.api_base From 77d4892b0d6c8ec7f7f024cc5b8c08eea8b2244c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Feb 2026 07:28:39 +0000 Subject: [PATCH 10/13] docs: add core agent line count script and update README with real-time stats --- README.md | 6 ++++-- core_agent_lines.sh | 21 +++++++++++++++++++++ pyproject.toml | 4 +--- test_docker.sh => tests/test_docker.sh | 1 + 4 files changed, 27 insertions(+), 5 deletions(-) create mode 100755 core_agent_lines.sh rename test_docker.sh => tests/test_docker.sh (97%) mode change 100755 => 100644 diff --git a/README.md b/README.md index 36cb65b..300fedb 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ 🐈 **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [Clawdbot](https://github.com/openclaw/openclaw) -⚡ïļ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. +⚡ïļ Delivers core agent functionality in just **~3,400** lines of code — **99% smaller** than Clawdbot's 430k+ lines. + +📏 Real-time line count: **3,359 lines** (run `bash core_agent_lines.sh` to verify anytime) ## ðŸ“Ē News @@ -25,7 +27,7 @@ ## 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. 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/pyproject.toml b/pyproject.toml index 0c59f66..2a952a1 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" From 7965af723c39e4b20061d1e7656e02e6321ba66d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Feb 2026 07:30:28 +0000 Subject: [PATCH 11/13] docs: update line count --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 300fedb..55f36fb 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ 🐈 **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [Clawdbot](https://github.com/openclaw/openclaw) -⚡ïļ Delivers core agent functionality in just **~3,400** lines of code — **99% smaller** than Clawdbot's 430k+ lines. +⚡ïļ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. 📏 Real-time line count: **3,359 lines** (run `bash core_agent_lines.sh` to verify anytime) From 760a369004dcd22e5468f033deb6d43889551da1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Feb 2026 08:01:20 +0000 Subject: [PATCH 12/13] feat: fix API key matching by model name --- nanobot/config/schema.py | 69 +++++++++++++++++++-------- nanobot/providers/litellm_provider.py | 27 ++++------- 2 files changed, 58 insertions(+), 38 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 5e8e46c..353ca4b 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -122,30 +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 > Moonshot > 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.moonshot.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, Moonshot 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.moonshot.api_key: - return self.providers.moonshot.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 c2cdda7..2125b15 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -31,15 +31,9 @@ class LiteLLMProvider(LLMProvider): (api_key and api_key.startswith("sk-or-")) or (api_base and "openrouter" in api_base) ) - - # Detect Moonshot by api_base or model name - self.is_moonshot = ( - (api_base and "moonshot" in api_base) or - ("moonshot" in default_model or "kimi" in default_model) - ) - + # Track if using custom endpoint (vLLM, etc.) - self.is_vllm = bool(api_base) and not self.is_openrouter and not self.is_moonshot + self.is_vllm = bool(api_base) and not self.is_openrouter # Configure LiteLLM based on provider if api_key: @@ -63,10 +57,9 @@ class LiteLLMProvider(LLMProvider): 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) - if api_base: - os.environ["MOONSHOT_API_BASE"] = api_base - - if api_base and not self.is_moonshot: + os.environ.setdefault("MOONSHOT_API_BASE", api_base or "https://api.moonshot.cn/v1") + + if api_base: litellm.api_base = api_base # Disable LiteLLM logging noise @@ -123,17 +116,17 @@ class LiteLLMProvider(LLMProvider): if self.is_vllm: model = f"hosted_vllm/{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, "max_tokens": max_tokens, "temperature": temperature, } - - # kimi-k2.5 only supports temperature=1.0 - if "kimi-k2.5" in model.lower(): - kwargs["temperature"] = 1.0 - + # Pass api_base directly for custom endpoints (vLLM, etc.) if self.api_base: kwargs["api_base"] = self.api_base From 4600f7cbd920c49296dc341d6d5378fc8af09aa9 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Feb 2026 08:02:55 +0000 Subject: [PATCH 13/13] docs: update line count --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 55f36fb..03020eb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡ïļ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,359 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,390 lines** (run `bash core_agent_lines.sh` to verify anytime) ## ðŸ“Ē News