diff --git a/.gitignore b/.gitignore index fd59029..0f26d84 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ docs/ *.pywz *.pyzz .venv/ +venv/ __pycache__/ poetry.lock .pytest_cache/ diff --git a/README.md b/README.md index ef16273..207df82 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,562 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,582 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News +- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. - **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! - **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! diff --git a/SECURITY.md b/SECURITY.md index ac15ba4..af3448c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -95,8 +95,8 @@ File operations have path traversal protection, but: - Consider using a firewall to restrict outbound connections if needed **WhatsApp Bridge:** -- The bridge runs on `localhost:3001` by default -- If exposing to network, use proper authentication and TLS +- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network) +- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js - Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700) ### 6. Dependency Security @@ -224,7 +224,7 @@ If you suspect a security breach: ✅ **Secure Communication** - HTTPS for all external API calls - TLS for Telegram API -- WebSocket security for WhatsApp bridge +- WhatsApp bridge: localhost-only binding + optional token auth ## Known Limitations diff --git a/bridge/src/index.ts b/bridge/src/index.ts index 8db63ef..e8f3db9 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -25,11 +25,12 @@ import { join } from 'path'; const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10); const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth'); +const TOKEN = process.env.BRIDGE_TOKEN || undefined; console.log('🐈 nanobot WhatsApp Bridge'); console.log('========================\n'); -const server = new BridgeServer(PORT, AUTH_DIR); +const server = new BridgeServer(PORT, AUTH_DIR, TOKEN); // Handle graceful shutdown process.on('SIGINT', async () => { diff --git a/bridge/src/server.ts b/bridge/src/server.ts index c6fd599..7d48f5e 100644 --- a/bridge/src/server.ts +++ b/bridge/src/server.ts @@ -1,5 +1,6 @@ /** * WebSocket server for Python-Node.js bridge communication. + * Security: binds to 127.0.0.1 only; optional BRIDGE_TOKEN auth. */ import { WebSocketServer, WebSocket } from 'ws'; @@ -21,12 +22,13 @@ export class BridgeServer { private wa: WhatsAppClient | null = null; private clients: Set = new Set(); - constructor(private port: number, private authDir: string) {} + constructor(private port: number, private authDir: string, private token?: string) {} async start(): Promise { - // Create WebSocket server - this.wss = new WebSocketServer({ port: this.port }); - console.log(`🌉 Bridge server listening on ws://localhost:${this.port}`); + // Bind to localhost only — never expose to external network + this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port }); + console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`); + if (this.token) console.log('🔒 Token authentication enabled'); // Initialize WhatsApp client this.wa = new WhatsAppClient({ @@ -38,35 +40,58 @@ export class BridgeServer { // Handle WebSocket connections this.wss.on('connection', (ws) => { - console.log('🔗 Python client connected'); - this.clients.add(ws); - - ws.on('message', async (data) => { - try { - const cmd = JSON.parse(data.toString()) as SendCommand; - await this.handleCommand(cmd); - ws.send(JSON.stringify({ type: 'sent', to: cmd.to })); - } catch (error) { - console.error('Error handling command:', error); - ws.send(JSON.stringify({ type: 'error', error: String(error) })); - } - }); - - ws.on('close', () => { - console.log('🔌 Python client disconnected'); - this.clients.delete(ws); - }); - - ws.on('error', (error) => { - console.error('WebSocket error:', error); - this.clients.delete(ws); - }); + if (this.token) { + // Require auth handshake as first message + const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000); + ws.once('message', (data) => { + clearTimeout(timeout); + try { + const msg = JSON.parse(data.toString()); + if (msg.type === 'auth' && msg.token === this.token) { + console.log('🔗 Python client authenticated'); + this.setupClient(ws); + } else { + ws.close(4003, 'Invalid token'); + } + } catch { + ws.close(4003, 'Invalid auth message'); + } + }); + } else { + console.log('🔗 Python client connected'); + this.setupClient(ws); + } }); // Connect to WhatsApp await this.wa.connect(); } + private setupClient(ws: WebSocket): void { + this.clients.add(ws); + + ws.on('message', async (data) => { + try { + const cmd = JSON.parse(data.toString()) as SendCommand; + await this.handleCommand(cmd); + ws.send(JSON.stringify({ type: 'sent', to: cmd.to })); + } catch (error) { + console.error('Error handling command:', error); + ws.send(JSON.stringify({ type: 'error', error: String(error) })); + } + }); + + ws.on('close', () => { + console.log('🔌 Python client disconnected'); + this.clients.delete(ws); + }); + + ws.on('error', (error) => { + console.error('WebSocket error:', error); + this.clients.delete(ws); + }); + } + private async handleCommand(cmd: SendCommand): Promise { if (cmd.type === 'send' && this.wa) { await this.wa.sendMessage(cmd.to, cmd.text); diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index a660436..5f230b7 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -164,7 +164,20 @@ class AgentLoop: logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}") # Get or create session - session = self.sessions.get_or_create(session_key or msg.session_key) + key = session_key or msg.session_key + session = self.sessions.get_or_create(key) + + # Handle slash commands + cmd = msg.content.strip().lower() + if cmd == "/new": + await self._consolidate_memory(session, archive_all=True) + session.clear() + self.sessions.save(session) + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, + content="🐈 New session started. Memory consolidated.") + if cmd == "/help": + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, + content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") # Consolidate memory before processing if session is too large if len(session.messages) > self.memory_window: @@ -243,7 +256,10 @@ class AgentLoop: break if final_content is None: - final_content = "I've completed processing but have no response to give." + if iteration >= self.max_iterations: + final_content = f"Reached {self.max_iterations} iterations without completion." + else: + final_content = "I've completed processing but have no response to give." # Log response preview preview = final_content[:120] + "..." if len(final_content) > 120 else final_content @@ -363,11 +379,17 @@ class AgentLoop: content=final_content ) - async def _consolidate_memory(self, session) -> None: + async def _consolidate_memory(self, session, archive_all: bool = False) -> None: """Consolidate old messages into MEMORY.md + HISTORY.md, then trim session.""" + if not session.messages: + return memory = MemoryStore(self.workspace) - keep_count = min(10, max(2, self.memory_window // 2)) - old_messages = session.messages[:-keep_count] # Everything except recent ones + if archive_all: + old_messages = session.messages + keep_count = 0 + else: + keep_count = min(10, max(2, self.memory_window // 2)) + old_messages = session.messages[:-keep_count] if not old_messages: return logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}") @@ -404,12 +426,10 @@ Respond with ONLY valid JSON, no markdown fences.""" ], model=self.model, ) - import json as _json text = (response.content or "").strip() - # Strip markdown fences that LLMs often add despite instructions if text.startswith("```"): text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() - result = _json.loads(text) + result = json.loads(text) if entry := result.get("history_entry"): memory.append_history(entry) @@ -417,8 +437,7 @@ Respond with ONLY valid JSON, no markdown fences.""" if update != current_memory: memory.write_long_term(update) - # Trim session to recent messages - session.messages = session.messages[-keep_count:] + session.messages = session.messages[-keep_count:] if keep_count else [] self.sessions.save(session) logger.info(f"Memory consolidation done, session trimmed to {len(session.messages)} messages") except Exception as e: diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 23d1415..9017b40 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -166,6 +166,10 @@ class FeishuChannel(BaseChannel): re.MULTILINE, ) + _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE) + + _CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE) + @staticmethod def _parse_md_table(table_text: str) -> dict | None: """Parse a markdown table into a Feishu table element.""" @@ -185,17 +189,52 @@ class FeishuChannel(BaseChannel): } def _build_card_elements(self, content: str) -> list[dict]: - """Split content into markdown + table elements for Feishu card.""" + """Split content into div/markdown + table elements for Feishu card.""" elements, last_end = [], 0 for m in self._TABLE_RE.finditer(content): - before = content[last_end:m.start()].strip() - if before: - elements.append({"tag": "markdown", "content": before}) + before = content[last_end:m.start()] + if before.strip(): + elements.extend(self._split_headings(before)) elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}) last_end = m.end() - remaining = content[last_end:].strip() + remaining = content[last_end:] + if remaining.strip(): + elements.extend(self._split_headings(remaining)) + return elements or [{"tag": "markdown", "content": content}] + + def _split_headings(self, content: str) -> list[dict]: + """Split content by headings, converting headings to div elements.""" + protected = content + code_blocks = [] + for m in self._CODE_BLOCK_RE.finditer(content): + code_blocks.append(m.group(1)) + protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks)-1}\x00", 1) + + elements = [] + last_end = 0 + for m in self._HEADING_RE.finditer(protected): + before = protected[last_end:m.start()].strip() + if before: + elements.append({"tag": "markdown", "content": before}) + level = len(m.group(1)) + text = m.group(2).strip() + elements.append({ + "tag": "div", + "text": { + "tag": "lark_md", + "content": f"**{text}**", + }, + }) + last_end = m.end() + remaining = protected[last_end:].strip() if remaining: elements.append({"tag": "markdown", "content": remaining}) + + for i, cb in enumerate(code_blocks): + for el in elements: + if el.get("tag") == "markdown": + el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb) + return elements or [{"tag": "markdown", "content": content}] async def send(self, msg: OutboundMessage) -> None: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 464fa97..e860d26 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import Any, TYPE_CHECKING +from typing import Any from loguru import logger @@ -12,9 +12,6 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import Config -if TYPE_CHECKING: - from nanobot.session.manager import SessionManager - class ChannelManager: """ @@ -26,10 +23,9 @@ class ChannelManager: - Route outbound messages """ - def __init__(self, config: Config, bus: MessageBus, session_manager: "SessionManager | None" = None): + def __init__(self, config: Config, bus: MessageBus): self.config = config self.bus = bus - self.session_manager = session_manager self.channels: dict[str, BaseChannel] = {} self._dispatch_task: asyncio.Task | None = None @@ -46,7 +42,6 @@ class ChannelManager: self.config.channels.telegram, self.bus, groq_api_key=self.config.providers.groq.api_key, - session_manager=self.session_manager, ) logger.info("Telegram channel enabled") except ImportError as e: diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 1abd600..32f8c67 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -4,8 +4,6 @@ from __future__ import annotations import asyncio import re -from typing import TYPE_CHECKING - from loguru import logger from telegram import BotCommand, Update from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes @@ -16,9 +14,6 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import TelegramConfig -if TYPE_CHECKING: - from nanobot.session.manager import SessionManager - def _markdown_to_telegram_html(text: str) -> str: """ @@ -95,7 +90,7 @@ class TelegramChannel(BaseChannel): # Commands registered with Telegram's command menu BOT_COMMANDS = [ BotCommand("start", "Start the bot"), - BotCommand("reset", "Reset conversation history"), + BotCommand("new", "Start a new conversation"), BotCommand("help", "Show available commands"), ] @@ -104,12 +99,10 @@ class TelegramChannel(BaseChannel): config: TelegramConfig, bus: MessageBus, groq_api_key: str = "", - session_manager: SessionManager | None = None, ): super().__init__(config, bus) self.config: TelegramConfig = config self.groq_api_key = groq_api_key - self.session_manager = session_manager self._app: Application | None = None self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task @@ -132,8 +125,8 @@ class TelegramChannel(BaseChannel): # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) - self._app.add_handler(CommandHandler("reset", self._on_reset)) - self._app.add_handler(CommandHandler("help", self._on_help)) + self._app.add_handler(CommandHandler("new", self._forward_command)) + self._app.add_handler(CommandHandler("help", self._forward_command)) # Add message handler for text, photos, voice, documents self._app.add_handler( @@ -229,40 +222,15 @@ class TelegramChannel(BaseChannel): "Type /help to see available commands." ) - async def _on_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /reset command — clear conversation history.""" + async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Forward slash commands to the bus for unified handling in AgentLoop.""" if not update.message or not update.effective_user: return - - chat_id = str(update.message.chat_id) - session_key = f"{self.name}:{chat_id}" - - if self.session_manager is None: - logger.warning("/reset called but session_manager is not available") - await update.message.reply_text("⚠️ Session management is not available.") - return - - session = self.session_manager.get_or_create(session_key) - msg_count = len(session.messages) - session.clear() - self.session_manager.save(session) - - logger.info(f"Session reset for {session_key} (cleared {msg_count} messages)") - await update.message.reply_text("🔄 Conversation history cleared. Let's start fresh!") - - async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /help command — show available commands.""" - if not update.message: - return - - help_text = ( - "🐈 nanobot commands\n\n" - "/start — Start the bot\n" - "/reset — Reset conversation history\n" - "/help — Show this help message\n\n" - "Just send me a text message to chat!" + await self._handle_message( + sender_id=str(update.effective_user.id), + chat_id=str(update.message.chat_id), + content=update.message.text, ) - await update.message.reply_text(help_text, parse_mode="HTML") async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming messages (text, photos, voice, documents).""" diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 6e00e9d..0cf2dd7 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -42,6 +42,9 @@ class WhatsAppChannel(BaseChannel): try: async with websockets.connect(bridge_url) as ws: self._ws = ws + # Send auth token if configured + if self.config.bridge_token: + await ws.send(json.dumps({"type": "auth", "token": self.config.bridge_token})) self._connected = True logger.info("Connected to WhatsApp bridge") diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 4e61deb..d776871 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -375,7 +375,7 @@ def gateway( ) # Create channel manager - channels = ChannelManager(config, bus, session_manager=session_manager) + channels = ChannelManager(config, bus) if channels.enabled_channels: console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") @@ -416,7 +416,7 @@ def gateway( @app.command() def agent( message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"), - session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"), + session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"), markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"), logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), ): @@ -642,14 +642,20 @@ def _get_bridge_dir() -> Path: def channels_login(): """Link device via QR code.""" import subprocess + from nanobot.config.loader import load_config + config = load_config() bridge_dir = _get_bridge_dir() console.print(f"{__logo__} Starting bridge...") console.print("Scan the QR code to connect.\n") + env = {**os.environ} + if config.channels.whatsapp.bridge_token: + env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token + try: - subprocess.run(["npm", "start"], cwd=bridge_dir, check=True) + subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env) except subprocess.CalledProcessError as e: console.print(f"[red]Bridge failed: {e}[/red]") except FileNotFoundError: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fdf1868..ef999b7 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -9,6 +9,7 @@ class WhatsAppConfig(BaseModel): """WhatsApp channel configuration.""" enabled: bool = False bridge_url: str = "ws://localhost:3001" + bridge_token: str = "" # Shared token for bridge auth (optional, recommended) allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers diff --git a/pyproject.toml b/pyproject.toml index b1b3c81..80e54c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.3.post6" +version = "0.1.3.post7" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"}