From 20227f1f04ea195ec681477f03905f4954cc9f9a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 08:55:21 +0000 Subject: [PATCH 01/35] feat: add Dockerfile with uv-based installation Add a Dockerfile using the official uv Python image (python3.12-bookworm-slim) for fast dependency installation. Includes Node.js 20 for the WhatsApp bridge, dependency layer caching, and exposes the gateway port (18790). https://claude.ai/code/session_011C1h1NERqqZp4ht3Pqpwkc --- .dockerignore | 13 +++++++++++++ Dockerfile | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..020b9ec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +*.egg-info +dist/ +build/ +.git +.env +.assets +node_modules/ +bridge/dist/ +workspace/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..21a502a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +# Install Node.js 20 for the WhatsApp bridge +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl ca-certificates gnupg && \ + mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends nodejs && \ + apt-get purge -y gnupg && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python dependencies first (cached layer) +COPY pyproject.toml README.md LICENSE ./ +RUN mkdir -p nanobot && touch nanobot/__init__.py && \ + uv pip install --system --no-cache . && \ + rm -rf nanobot + +# Copy the full source and install +COPY nanobot/ nanobot/ +COPY bridge/ bridge/ +RUN uv pip install --system --no-cache . + +# Build the WhatsApp bridge +WORKDIR /app/bridge +RUN npm install && npm run build +WORKDIR /app + +# Create config directory +RUN mkdir -p /root/.nanobot + +# Gateway default port +EXPOSE 18790 + +ENTRYPOINT ["nanobot"] +CMD ["gateway"] From 6df4a56586d5411389f985d63b1b4b17b6d5960b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 08:59:57 +0000 Subject: [PATCH 02/35] test: add script to verify Dockerfile builds and nanobot status works Builds the image, runs onboard + status inside the container, and validates that the expected output fields (Config, Workspace, Model, API keys) are present. https://claude.ai/code/session_011C1h1NERqqZp4ht3Pqpwkc --- test_docker.sh | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100755 test_docker.sh diff --git a/test_docker.sh b/test_docker.sh new file mode 100755 index 0000000..a90e080 --- /dev/null +++ b/test_docker.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_NAME="nanobot-test" + +echo "=== Building Docker image ===" +docker build -t "$IMAGE_NAME" . + +echo "" +echo "=== Running 'nanobot onboard' ===" +docker run --name nanobot-test-run "$IMAGE_NAME" onboard + +echo "" +echo "=== Running 'nanobot status' ===" +STATUS_OUTPUT=$(docker commit nanobot-test-run nanobot-test-onboarded > /dev/null && \ + docker run --rm nanobot-test-onboarded status 2>&1) || true + +echo "$STATUS_OUTPUT" + +echo "" +echo "=== Validating output ===" +PASS=true + +check() { + if echo "$STATUS_OUTPUT" | grep -q "$1"; then + echo " PASS: found '$1'" + else + echo " FAIL: missing '$1'" + PASS=false + fi +} + +check "nanobot Status" +check "Config:" +check "Workspace:" +check "Model:" +check "OpenRouter API:" +check "Anthropic API:" +check "OpenAI API:" + +echo "" +if $PASS; then + echo "=== All checks passed ===" +else + echo "=== Some checks FAILED ===" + exit 1 +fi + +# Cleanup +echo "" +echo "=== Cleanup ===" +docker rm -f nanobot-test-run 2>/dev/null || true +docker rmi -f nanobot-test-onboarded 2>/dev/null || true +docker rmi -f "$IMAGE_NAME" 2>/dev/null || true +echo "Done." From fa25856d8cefda9f0e8559d71006f72bdcb614ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 09:07:23 +0000 Subject: [PATCH 03/35] fix: create stub bridge/ dir in dependency caching layer Hatchling's force-include requires bridge/ to exist at build time. The dependency caching step now stubs both nanobot/ and bridge/. https://claude.ai/code/session_011C1h1NERqqZp4ht3Pqpwkc --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 21a502a..5244f1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,9 +16,9 @@ WORKDIR /app # Install Python dependencies first (cached layer) COPY pyproject.toml README.md LICENSE ./ -RUN mkdir -p nanobot && touch nanobot/__init__.py && \ +RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \ uv pip install --system --no-cache . && \ - rm -rf nanobot + rm -rf nanobot bridge # Copy the full source and install COPY nanobot/ nanobot/ From f7e8e73c5414c1e1bdb600da56ec1bc168b3b65b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 09:17:24 +0000 Subject: [PATCH 04/35] fix: add git to Dockerfile for npm bridge dependency install A bridge npm dependency requires git to be present at install time. https://claude.ai/code/session_011C1h1NERqqZp4ht3Pqpwkc --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5244f1a..4287944 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim # Install Node.js 20 for the WhatsApp bridge RUN apt-get update && \ - apt-get install -y --no-install-recommends curl ca-certificates gnupg && \ + apt-get install -y --no-install-recommends curl ca-certificates gnupg git && \ mkdir -p /etc/apt/keyrings && \ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \ From 7fced16e4c73b08e2cb632542735de9ec63fd6d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 09:22:13 +0000 Subject: [PATCH 05/35] docs: add Docker build/run instructions to README https://claude.ai/code/session_011C1h1NERqqZp4ht3Pqpwkc --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 167ae22..71d425b 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,16 @@ nanobot agent -m "What is 2+2?" That's it! You have a working AI assistant in 2 minutes. +## 🐳 Docker + +```bash +docker build -t nanobot . +docker run --rm nanobot onboard +docker run -v ~/.nanobot:/root/.nanobot -p 18790:18790 nanobot +``` + +Mount `~/.nanobot` so your config and workspace persist across runs. Edit `~/.nanobot/config.json` on the host to add API keys, then restart the container. + ## πŸ–₯️ Local Models (vLLM) Run nanobot with your own local models using vLLM or any OpenAI-compatible server. From eaf494ea31fc84b860efb3e2f0d96c5d6a1e940e Mon Sep 17 00:00:00 2001 From: Manus AI Date: Mon, 2 Feb 2026 04:30:15 -0500 Subject: [PATCH 06/35] docs: add uv installation instructions (fixes #5) --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 167ae22..ab1f947 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ ## πŸ“¦ Install +**Install with [uv](https://github.com/astral-sh/uv)** (recommended for speed) + +```bash +uv tool install nanobot-ai +``` + **Install from PyPi** ```bash From 42f62c0c1aeb4bae98949b670bd698bc40e62c11 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Mon, 2 Feb 2026 04:33:26 -0500 Subject: [PATCH 07/35] feat: add voice transcription support with groq (fixes #13) --- README.md | 1 + bridge/src/whatsapp.ts | 5 +++ nanobot/channels/manager.py | 4 ++ nanobot/channels/telegram.py | 20 ++++++++- nanobot/channels/whatsapp.py | 5 +++ nanobot/config/schema.py | 4 +- nanobot/providers/litellm_provider.py | 2 + nanobot/providers/transcription.py | 65 +++++++++++++++++++++++++++ 8 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 nanobot/providers/transcription.py diff --git a/README.md b/README.md index ab1f947..ec73b51 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,7 @@ nanobot/ ## πŸ—ΊοΈ Roadmap +- [x] **Voice Transcription** β€” Support for Groq Whisper (Issue #13) - [ ] **Multi-modal** β€” See and hear (images, voice, video) - [ ] **Long-term memory** β€” Never forget important context - [ ] **Better reasoning** β€” Multi-step planning and reflection diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index 4185632..a3a82fc 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -160,6 +160,11 @@ export class WhatsAppClient { return `[Document] ${message.documentMessage.caption}`; } + // Voice/Audio message + if (message.audioMessage) { + return `[Voice Message]`; + } + return null; } diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 04abf5f..c32aa3d 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -36,6 +36,8 @@ class ChannelManager: if self.config.channels.telegram.enabled: try: from nanobot.channels.telegram import TelegramChannel + # Inject parent config for access to providers + self.config.channels.telegram.parent = self.config self.channels["telegram"] = TelegramChannel( self.config.channels.telegram, self.bus ) @@ -47,6 +49,8 @@ class ChannelManager: if self.config.channels.whatsapp.enabled: try: from nanobot.channels.whatsapp import WhatsAppChannel + # Inject parent config for access to providers + self.config.channels.whatsapp.parent = self.config self.channels["whatsapp"] = WhatsAppChannel( self.config.channels.whatsapp, self.bus ) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 840c250..dc2f77c 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -247,7 +247,25 @@ class TelegramChannel(BaseChannel): await file.download_to_drive(str(file_path)) media_paths.append(str(file_path)) - content_parts.append(f"[{media_type}: {file_path}]") + + # Handle voice transcription + if media_type == "voice" or media_type == "audio": + from nanobot.providers.transcription import GroqTranscriptionProvider + # Try to get Groq API key from config + groq_key = None + if hasattr(self.config, 'parent') and hasattr(self.config.parent, 'providers'): + groq_key = self.config.parent.providers.groq.api_key + + transcriber = GroqTranscriptionProvider(api_key=groq_key) + transcription = await transcriber.transcribe(file_path) + if transcription: + logger.info(f"Transcribed {media_type}: {transcription[:50]}...") + content_parts.append(f"[transcription: {transcription}]") + else: + content_parts.append(f"[{media_type}: {file_path}]") + else: + content_parts.append(f"[{media_type}: {file_path}]") + logger.debug(f"Downloaded {media_type} to {file_path}") except Exception as e: logger.error(f"Failed to download media: {e}") diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index efbd3e1..c14a6c3 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -107,6 +107,11 @@ class WhatsAppChannel(BaseChannel): # Extract just the phone number as chat_id chat_id = sender.split("@")[0] if "@" in sender else sender + # Handle voice transcription if it's a voice message + if content == "[Voice Message]": + logger.info(f"Voice message received from {chat_id}, but direct download from bridge is not yet supported.") + content = "[Voice Message: Transcription not available for WhatsApp yet]" + await self._handle_message( sender_id=chat_id, chat_id=sender, # Use full JID for replies diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index e30fbb2..ee245f1 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -50,6 +50,7 @@ class ProvidersConfig(BaseModel): anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) + groq: ProviderConfig = Field(default_factory=ProviderConfig) vllm: ProviderConfig = Field(default_factory=ProviderConfig) @@ -89,11 +90,12 @@ class Config(BaseSettings): return Path(self.agents.defaults.workspace).expanduser() def get_api_key(self) -> str | None: - """Get API key in priority order: OpenRouter > Anthropic > OpenAI > vLLM.""" + """Get API key in priority order: OpenRouter > Anthropic > OpenAI > Groq > vLLM.""" return ( self.providers.openrouter.api_key or self.providers.anthropic.api_key or self.providers.openai.api_key or + self.providers.groq.api_key or self.providers.vllm.api_key or None ) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 4e7305b..f8e8456 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -47,6 +47,8 @@ class LiteLLMProvider(LLMProvider): os.environ.setdefault("ANTHROPIC_API_KEY", api_key) elif "openai" in default_model or "gpt" in default_model: os.environ.setdefault("OPENAI_API_KEY", api_key) + elif "groq" in default_model: + os.environ.setdefault("GROQ_API_KEY", api_key) if api_base: litellm.api_base = api_base diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py new file mode 100644 index 0000000..8ce909b --- /dev/null +++ b/nanobot/providers/transcription.py @@ -0,0 +1,65 @@ +"""Voice transcription provider using Groq.""" + +import os +from pathlib import Path +from typing import Any + +import httpx +from loguru import logger + + +class GroqTranscriptionProvider: + """ + Voice transcription provider using Groq's Whisper API. + + Groq offers extremely fast transcription with a generous free tier. + """ + + def __init__(self, api_key: str | None = None): + self.api_key = api_key or os.environ.get("GROQ_API_KEY") + self.api_url = "https://api.groq.com/openai/v1/audio/transcriptions" + + async def transcribe(self, file_path: str | Path) -> str: + """ + Transcribe an audio file using Groq. + + Args: + file_path: Path to the audio file. + + Returns: + Transcribed text. + """ + if not self.api_key: + logger.warning("Groq API key not configured for transcription") + return "" + + path = Path(file_path) + if not path.exists(): + logger.error(f"Audio file not found: {file_path}") + return "" + + try: + async with httpx.AsyncClient() as client: + with open(path, "rb") as f: + files = { + "file": (path.name, f), + "model": (None, "whisper-large-v3"), + } + headers = { + "Authorization": f"Bearer {self.api_key}", + } + + response = await client.post( + self.api_url, + headers=headers, + files=files, + timeout=60.0 + ) + + response.raise_for_status() + data = response.json() + return data.get("text", "") + + except Exception as e: + logger.error(f"Groq transcription error: {e}") + return "" From ae1830acddf6efe997bfac48af5b227caf80d0cd Mon Sep 17 00:00:00 2001 From: Peter van Eijk Date: Mon, 2 Feb 2026 16:36:22 +0700 Subject: [PATCH 08/35] feat: change default command to status --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 4287944..8132747 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,4 +37,4 @@ RUN mkdir -p /root/.nanobot EXPOSE 18790 ENTRYPOINT ["nanobot"] -CMD ["gateway"] +CMD ["status"] From 5c49bbc0b2ad235c7a04ae489e096e6dfce768d5 Mon Sep 17 00:00:00 2001 From: JunghwanNA <70629228+shaun0927@users.noreply.github.com> Date: Mon, 2 Feb 2026 20:31:49 +0900 Subject: [PATCH 09/35] feat: add Amazon Bedrock support Skip API key validation for bedrock/ model prefix since AWS Bedrock uses IAM credentials instead of API keys. Fixes #20 Co-Authored-By: Claude Opus 4.5 --- nanobot/cli/commands.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 8dcc460..6ded59b 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -178,11 +178,13 @@ def gateway( # Create components bus = MessageBus() - # Create provider (supports OpenRouter, Anthropic, OpenAI) + # Create provider (supports OpenRouter, Anthropic, OpenAI, Bedrock) api_key = config.get_api_key() api_base = config.get_api_base() - - if not api_key: + model = config.agents.defaults.model + is_bedrock = model.startswith("bedrock/") + + if not api_key and not is_bedrock: console.print("[red]Error: No API key configured.[/red]") console.print("Set one in ~/.nanobot/config.json under providers.openrouter.apiKey") raise typer.Exit(1) @@ -289,11 +291,13 @@ def agent( api_key = config.get_api_key() api_base = config.get_api_base() - - if not api_key: + model = config.agents.defaults.model + is_bedrock = model.startswith("bedrock/") + + if not api_key and not is_bedrock: console.print("[red]Error: No API key configured.[/red]") raise typer.Exit(1) - + bus = MessageBus() provider = LiteLLMProvider( api_key=api_key, From ea849650efee5c9df32834c7dd79284c6b147eb7 Mon Sep 17 00:00:00 2001 From: Cheng Wang Date: Mon, 2 Feb 2026 19:34:22 +0800 Subject: [PATCH 10/35] feat: improve web_fetch URL validation and security Add URL validation and redirect limits to web_fetch tool to prevent potential security issues: - Add _validate_url() function to validate URLs before fetching - Only allow http:// and https:// schemes (prevent file://, ftp://, etc.) - Verify URL has valid scheme and domain - Return descriptive error messages for invalid URLs - Limit HTTP redirects to 5 (down from default 20) to prevent DoS attacks - Add MAX_REDIRECTS constant for easy configuration - Explicitly configure httpx.AsyncClient with max_redirects parameter - Improve error handling with JSON error responses for validation failures This addresses security concerns identified in code review where web_fetch had no URL validation or redirect limits, potentially allowing: - Unsafe URL schemes (file://, etc.) - Redirect-based DoS attacks - Invalid URL formats causing unclear errors --- nanobot/agent/tools/web.py | 46 ++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index c9d989c..ad72604 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -5,6 +5,7 @@ import json import os import re from typing import Any +from urllib.parse import urlparse import httpx @@ -12,6 +13,7 @@ from nanobot.agent.tools.base import Tool # Shared constants USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36" +MAX_REDIRECTS = 5 # Limit redirects to prevent DoS attacks def _strip_tags(text: str) -> str: @@ -28,6 +30,33 @@ def _normalize(text: str) -> str: return re.sub(r'\n{3,}', '\n\n', text).strip() +def _validate_url(url: str) -> tuple[bool, str]: + """ + Validate URL for security. + + Returns: + (is_valid, error_message): Tuple of validation result and error message if invalid. + """ + try: + parsed = urlparse(url) + + # Check if scheme exists + if not parsed.scheme: + return False, "URL must include a scheme (http:// or https://)" + + # Only allow http and https schemes + if parsed.scheme.lower() not in ('http', 'https'): + return False, f"Invalid URL scheme '{parsed.scheme}'. Only http:// and https:// are allowed" + + # Check if netloc (domain) exists + if not parsed.netloc: + return False, "URL must include a valid domain" + + return True, "" + except Exception as e: + return False, f"Invalid URL format: {str(e)}" + + class WebSearchTool(Tool): """Search the web using Brave Search API.""" @@ -95,12 +124,21 @@ class WebFetchTool(Tool): async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> str: from readability import Document - + max_chars = maxChars or self.max_chars - + + # Validate URL before fetching + is_valid, error_msg = _validate_url(url) + if not is_valid: + return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}) + try: - async with httpx.AsyncClient() as client: - r = await client.get(url, headers={"User-Agent": USER_AGENT}, follow_redirects=True, timeout=30.0) + async with httpx.AsyncClient( + follow_redirects=True, + max_redirects=MAX_REDIRECTS, + timeout=30.0 + ) as client: + r = await client.get(url, headers={"User-Agent": USER_AGENT}) r.raise_for_status() ctype = r.headers.get("content-type", "") From 3ba0191cef221828713a144b8c9357a9e105cccb Mon Sep 17 00:00:00 2001 From: Cheng Wang Date: Mon, 2 Feb 2026 19:47:42 +0800 Subject: [PATCH 11/35] fix: correct heartbeat token matching logic The HEARTBEAT_OK_TOKEN comparison was broken because the token itself ("HEARTBEAT_OK" with underscore) was being compared against a response string that had underscores removed. This made the condition always fail, preventing the heartbeat service from recognizing "no tasks" responses. Now both sides of the comparison remove underscores consistently, allowing proper matching of the HEARTBEAT_OK token. --- nanobot/heartbeat/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 4cb469e..221ed27 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -115,7 +115,7 @@ class HeartbeatService: response = await self.on_heartbeat(HEARTBEAT_PROMPT) # Check if agent said "nothing to do" - if HEARTBEAT_OK_TOKEN in response.upper().replace("_", ""): + if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""): logger.info("Heartbeat: OK (no action needed)") else: logger.info(f"Heartbeat: completed task") From 2466d9e1dc40405ad996f75fbf181e172866ce10 Mon Sep 17 00:00:00 2001 From: Cheng Wang Date: Tue, 3 Feb 2026 00:37:55 +0800 Subject: [PATCH 12/35] fix: add Telegram channel to channels status command Previously, the `nanobot channels status` command only displayed WhatsApp channel status, completely omitting Telegram despite it being fully implemented in the codebase. Changes: - Added Telegram channel status display - Renamed "Bridge URL" column to "Configuration" for better generality - Show Telegram token (first 10 chars) or "not configured" message - Added comments to distinguish WhatsApp and Telegram sections Fixes the issue where users couldn't see Telegram channel status via CLI, even though the feature was working correctly. --- nanobot/cli/commands.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index d293564..3bddb62 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -348,21 +348,31 @@ app.add_typer(channels_app, name="channels") def channels_status(): """Show channel status.""" from nanobot.config.loader import load_config - + config = load_config() - + table = Table(title="Channel Status") table.add_column("Channel", style="cyan") table.add_column("Enabled", style="green") - table.add_column("Bridge URL", style="yellow") - + table.add_column("Configuration", style="yellow") + + # WhatsApp wa = config.channels.whatsapp table.add_row( "WhatsApp", "βœ“" if wa.enabled else "βœ—", wa.bridge_url ) - + + # Telegram + tg = config.channels.telegram + tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]" + table.add_row( + "Telegram", + "βœ“" if tg.enabled else "βœ—", + tg_config + ) + console.print(table) From cd2025207248ecc1383ded126533dc398e4b5549 Mon Sep 17 00:00:00 2001 From: Cheng Wang Date: Tue, 3 Feb 2026 00:45:52 +0800 Subject: [PATCH 13/35] fix: status command now respects workspace from config The status command was ignoring the workspace setting from the configuration file and always displaying the default path (~/.nanobot/workspace). This fix loads the config first and uses config.workspace_path when available, falling back to the default only when no config exists. This brings the status command in line with other commands that correctly use config.workspace_path. --- nanobot/cli/commands.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index d293564..6caa0a7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -609,17 +609,23 @@ def status(): """Show nanobot status.""" from nanobot.config.loader import load_config, get_config_path from nanobot.utils.helpers import get_workspace_path - + config_path = get_config_path() - workspace = get_workspace_path() - - console.print(f"{__logo__} nanobot Status\n") - - console.print(f"Config: {config_path} {'[green]βœ“[/green]' if config_path.exists() else '[red]βœ—[/red]'}") - console.print(f"Workspace: {workspace} {'[green]βœ“[/green]' if workspace.exists() else '[red]βœ—[/red]'}") - + + # Load config first to get the correct workspace path if config_path.exists(): config = load_config() + workspace = config.workspace_path + else: + config = None + workspace = get_workspace_path() + + console.print(f"{__logo__} nanobot Status\n") + + console.print(f"Config: {config_path} {'[green]βœ“[/green]' if config_path.exists() else '[red]βœ—[/red]'}") + console.print(f"Workspace: {workspace} {'[green]βœ“[/green]' if workspace.exists() else '[red]βœ—[/red]'}") + + if config is not None: console.print(f"Model: {config.agents.defaults.model}") # Check API keys From 7ef18c4e8a1b2aaa9df7e7e37ff2ffa1361d1621 Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Mon, 2 Feb 2026 20:39:08 +0300 Subject: [PATCH 14/35] Validate tool params and add tests --- nanobot/agent/tools/base.py | 73 +++++++++++++++++++++++++++ nanobot/agent/tools/registry.py | 5 +- tests/test_tool_validation.py | 88 +++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 tests/test_tool_validation.py diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 6fcfec6..355150f 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -42,6 +42,79 @@ class Tool(ABC): String result of the tool execution. """ pass + + def validate_params(self, params: dict[str, Any]) -> list[str]: + """ + Lightweight JSON schema validation for tool parameters. + + Returns a list of error strings (empty if valid). + Unknown params are ignored. + """ + schema = self.parameters or {} + if schema.get("type") != "object": + return [] + + return self._validate_schema(params, schema, path="") + + def _validate_schema(self, value: Any, schema: dict[str, Any], path: str) -> list[str]: + errors: list[str] = [] + expected_type = schema.get("type") + + type_map = { + "string": str, + "integer": int, + "number": (int, float), + "boolean": bool, + "array": list, + "object": dict, + } + + def label(p: str) -> str: + return p or "parameter" + + if expected_type in type_map and not isinstance(value, type_map[expected_type]): + errors.append(f"{label(path)} should be {expected_type}") + return errors + + if "enum" in schema and value not in schema["enum"]: + errors.append(f"{label(path)} must be one of {schema['enum']}") + + if expected_type in ("integer", "number"): + if "minimum" in schema and value < schema["minimum"]: + errors.append(f"{label(path)} must be >= {schema['minimum']}") + if "maximum" in schema and value > schema["maximum"]: + errors.append(f"{label(path)} must be <= {schema['maximum']}") + + if expected_type == "string": + if "minLength" in schema and len(value) < schema["minLength"]: + errors.append(f"{label(path)} must be at least {schema['minLength']} chars") + if "maxLength" in schema and len(value) > schema["maxLength"]: + errors.append(f"{label(path)} must be at most {schema['maxLength']} chars") + + if expected_type == "object": + properties = schema.get("properties", {}) or {} + required = set(schema.get("required", []) or []) + + for key in required: + if key not in value: + p = f"{path}.{key}" if path else key + errors.append(f"missing required {p}") + + for key, item in value.items(): + prop_schema = properties.get(key) + if not prop_schema: + continue # ignore unknown fields + p = f"{path}.{key}" if path else key + errors.extend(self._validate_schema(item, prop_schema, p)) + + if expected_type == "array": + items_schema = schema.get("items") + if items_schema: + for idx, item in enumerate(value): + p = f"{path}[{idx}]" if path else f"[{idx}]" + errors.extend(self._validate_schema(item, items_schema, p)) + + return errors def to_schema(self) -> dict[str, Any]: """Convert tool to OpenAI function schema format.""" diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index 1e8f56d..d9b33ff 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -52,8 +52,11 @@ class ToolRegistry: tool = self._tools.get(name) if not tool: return f"Error: Tool '{name}' not found" - + try: + errors = tool.validate_params(params) + if errors: + return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) return await tool.execute(**params) except Exception as e: return f"Error executing {name}: {str(e)}" diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py new file mode 100644 index 0000000..f11c667 --- /dev/null +++ b/tests/test_tool_validation.py @@ -0,0 +1,88 @@ +from typing import Any + +from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.registry import ToolRegistry + + +class SampleTool(Tool): + @property + def name(self) -> str: + return "sample" + + @property + def description(self) -> str: + return "sample tool" + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "query": {"type": "string", "minLength": 2}, + "count": {"type": "integer", "minimum": 1, "maximum": 10}, + "mode": {"type": "string", "enum": ["fast", "full"]}, + "meta": { + "type": "object", + "properties": { + "tag": {"type": "string"}, + "flags": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["tag"], + }, + }, + "required": ["query", "count"], + } + + async def execute(self, **kwargs: Any) -> str: + return "ok" + + +def test_validate_params_missing_required() -> None: + tool = SampleTool() + errors = tool.validate_params({"query": "hi"}) + assert "missing required count" in "; ".join(errors) + + +def test_validate_params_type_and_range() -> None: + tool = SampleTool() + errors = tool.validate_params({"query": "hi", "count": 0}) + assert any("count must be >= 1" in e for e in errors) + + errors = tool.validate_params({"query": "hi", "count": "2"}) + assert any("count should be integer" in e for e in errors) + + +def test_validate_params_enum_and_min_length() -> None: + tool = SampleTool() + errors = tool.validate_params({"query": "h", "count": 2, "mode": "slow"}) + assert any("query must be at least 2 chars" in e for e in errors) + assert any("mode must be one of" in e for e in errors) + + +def test_validate_params_nested_object_and_array() -> None: + tool = SampleTool() + errors = tool.validate_params( + { + "query": "hi", + "count": 2, + "meta": {"flags": [1, "ok"]}, + } + ) + assert any("missing required meta.tag" in e for e in errors) + assert any("meta.flags[0] should be string" in e for e in errors) + + +def test_validate_params_ignores_unknown_fields() -> None: + tool = SampleTool() + errors = tool.validate_params({"query": "hi", "count": 2, "extra": "x"}) + assert errors == [] + + +async def test_registry_returns_validation_error() -> None: + reg = ToolRegistry() + reg.register(SampleTool()) + result = await reg.execute("sample", {"query": "hi"}) + assert "Invalid parameters" in result From 9b09cb5c63a07b042fa8e02b04e1445f8e7618a3 Mon Sep 17 00:00:00 2001 From: Kiplangat Korir <153384040+kiplangatkorir@users.noreply.github.com> Date: Mon, 2 Feb 2026 20:52:30 +0300 Subject: [PATCH 15/35] Update nanobot/agent/tools/base.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- nanobot/agent/tools/base.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 355150f..5888a77 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -51,8 +51,14 @@ class Tool(ABC): Unknown params are ignored. """ schema = self.parameters or {} - if schema.get("type") != "object": - return [] + + # Default to an object schema if type is missing, and fail fast on unsupported top-level types. + if "type" not in schema: + schema = {"type": "object", **schema} + elif schema.get("type") != "object": + raise ValueError( + f"Tool parameter schemas must have top-level type 'object'; got {schema.get('type')!r}" + ) return self._validate_schema(params, schema, path="") From 00841309c16c96584df3bbd02a0c0ed8ef6c90a6 Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Mon, 2 Feb 2026 21:14:29 +0300 Subject: [PATCH 16/35] Harden exec tool with safety guard --- nanobot/agent/tools/shell.py | 65 +++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index bf7f064..ce00bca 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -2,6 +2,8 @@ import asyncio import os +import re +from pathlib import Path from typing import Any from nanobot.agent.tools.base import Tool @@ -10,9 +12,35 @@ from nanobot.agent.tools.base import Tool class ExecTool(Tool): """Tool to execute shell commands.""" - def __init__(self, timeout: int = 60, working_dir: str | None = None): + def __init__( + self, + timeout: int = 60, + working_dir: str | None = None, + deny_patterns: list[str] | None = None, + allow_patterns: list[str] | None = None, + restrict_to_working_dir: bool = False, + ): self.timeout = timeout self.working_dir = working_dir + self.deny_patterns = deny_patterns or [ + r"\brm\s+-rf\b", + r"\brm\s+-fr\b", + r"\brm\s+-r\b", + r"\bdel\s+/f\b", + r"\bdel\s+/q\b", + r"\brmdir\s+/s\b", + r"\bformat\b", + r"\bmkfs\b", + r"\bdd\s+if=", + r">\s*/dev/sd", + r"\bdiskpart\b", + r"\bshutdown\b", + r"\breboot\b", + r"\bpoweroff\b", + r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\};\s*:", + ] + self.allow_patterns = allow_patterns or [] + self.restrict_to_working_dir = restrict_to_working_dir @property def name(self) -> str: @@ -41,6 +69,9 @@ class ExecTool(Tool): async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str: cwd = working_dir or self.working_dir or os.getcwd() + guard_error = self._guard_command(command, cwd) + if guard_error: + return guard_error try: process = await asyncio.create_subprocess_shell( @@ -83,3 +114,35 @@ class ExecTool(Tool): except Exception as e: return f"Error executing command: {str(e)}" + + def _guard_command(self, command: str, cwd: str) -> str | None: + """Best-effort safety guard for potentially destructive commands.""" + cmd = command.strip() + lower = cmd.lower() + + for pattern in self.deny_patterns: + if re.search(pattern, lower): + return "Error: Command blocked by safety guard (dangerous pattern detected)" + + if self.allow_patterns: + if not any(re.search(p, lower) for p in self.allow_patterns): + return "Error: Command blocked by safety guard (not in allowlist)" + + if self.restrict_to_working_dir: + if "..\\" in cmd or "../" in cmd: + return "Error: Command blocked by safety guard (path traversal detected)" + + cwd_path = Path(cwd).resolve() + + win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd) + posix_paths = re.findall(r"/[^\\s\"']+", cmd) + + for raw in win_paths + posix_paths: + try: + p = Path(raw).resolve() + except Exception: + continue + if cwd_path not in p.parents and p != cwd_path: + return "Error: Command blocked by safety guard (path outside working dir)" + + return None From 43fe83755dabbe01dbe6b6cbf29de1df06a2d4f2 Mon Sep 17 00:00:00 2001 From: Pedro Perez Date: Mon, 2 Feb 2026 22:15:21 +0100 Subject: [PATCH 17/35] fix: Use correct 'zai/' prefix for Zhipu AI models in LiteLLM LiteLLM expects the 'zai/' provider prefix for Zhipu AI (Z.ai) models, not 'zhipu/'. This was causing 'LLM Provider NOT provided' errors when users configured models like 'glm-4.7' without an explicit prefix. According to LiteLLM docs, the correct format is: - model='zai/glm-4.7' (correct) - NOT model='zhipu/glm-4.7' (incorrect) This fix ensures auto-prefixed models use the correct 'zai/' format. Fixes: Error when using Zhipu AI models with shorthand names like 'glm-4.7' --- nanobot/providers/litellm_provider.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 42b4bf5..a0927c4 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -86,13 +86,13 @@ class LiteLLMProvider(LLMProvider): model = f"openrouter/{model}" # For Zhipu/Z.ai, ensure prefix is present - # Handle cases like "glm-4.7-flash" -> "zhipu/glm-4.7-flash" + # Handle cases like "glm-4.7-flash" -> "zai/glm-4.7-flash" if ("glm" in model.lower() or "zhipu" in model.lower()) and not ( model.startswith("zhipu/") or model.startswith("zai/") or model.startswith("openrouter/") ): - model = f"zhipu/{model}" + model = f"zai/{model}" # For vLLM, use hosted_vllm/ prefix per LiteLLM docs # Convert openai/ prefix to hosted_vllm/ if user specified it From 1af404c4d90501708338efb6f741ba45c93822be Mon Sep 17 00:00:00 2001 From: tlguszz1010 Date: Tue, 3 Feb 2026 14:08:36 +0900 Subject: [PATCH 18/35] docs: update news date from 2025 to 2026 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 358d23e..f7706d7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ## πŸ“’ News -- **2025-02-01** πŸŽ‰ nanobot launched! Welcome to try 🐈 nanobot! +- **2026-02-01** πŸŽ‰ nanobot launched! Welcome to try 🐈 nanobot! ## Key Features of nanobot: From 8989adc9aecd409309c6f472b1022d1eada9d58d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Feb 2026 06:36:58 +0000 Subject: [PATCH 19/35] refactor: use explicit dependency injection for groq_api_key --- nanobot/channels/manager.py | 8 +++----- nanobot/channels/telegram.py | 10 +++------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index c32aa3d..73c3334 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -36,10 +36,10 @@ class ChannelManager: if self.config.channels.telegram.enabled: try: from nanobot.channels.telegram import TelegramChannel - # Inject parent config for access to providers - self.config.channels.telegram.parent = self.config self.channels["telegram"] = TelegramChannel( - self.config.channels.telegram, self.bus + self.config.channels.telegram, + self.bus, + groq_api_key=self.config.providers.groq.api_key, ) logger.info("Telegram channel enabled") except ImportError as e: @@ -49,8 +49,6 @@ class ChannelManager: if self.config.channels.whatsapp.enabled: try: from nanobot.channels.whatsapp import WhatsAppChannel - # Inject parent config for access to providers - self.config.channels.whatsapp.parent = self.config self.channels["whatsapp"] = WhatsAppChannel( self.config.channels.whatsapp, self.bus ) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 75b9299..23e1de0 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -85,9 +85,10 @@ class TelegramChannel(BaseChannel): name = "telegram" - def __init__(self, config: TelegramConfig, bus: MessageBus): + def __init__(self, config: TelegramConfig, bus: MessageBus, groq_api_key: str = ""): super().__init__(config, bus) self.config: TelegramConfig = config + self.groq_api_key = groq_api_key self._app: Application | None = None self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies @@ -253,12 +254,7 @@ class TelegramChannel(BaseChannel): # Handle voice transcription if media_type == "voice" or media_type == "audio": from nanobot.providers.transcription import GroqTranscriptionProvider - # Try to get Groq API key from config - groq_key = None - if hasattr(self.config, 'parent') and hasattr(self.config.parent, 'providers'): - groq_key = self.config.parent.providers.groq.api_key - - transcriber = GroqTranscriptionProvider(api_key=groq_key) + transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key) transcription = await transcriber.transcribe(file_path) if transcription: logger.info(f"Transcribed {media_type}: {transcription[:50]}...") From 99339c7be93cdcc3cdee5dd0bdf48645bbcb12c7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Feb 2026 07:17:47 +0000 Subject: [PATCH 20/35] docs: improve README with provider info and Docker examples --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3440fdc..55c6091 100644 --- a/README.md +++ b/README.md @@ -130,16 +130,6 @@ nanobot agent -m "What is 2+2?" That's it! You have a working AI assistant in 2 minutes. -## 🐳 Docker - -```bash -docker build -t nanobot . -docker run --rm nanobot onboard -docker run -v ~/.nanobot:/root/.nanobot -p 18790:18790 nanobot -``` - -Mount `~/.nanobot` so your config and workspace persist across runs. Edit `~/.nanobot/config.json` on the host to add API keys, then restart the container. - ## πŸ–₯️ Local Models (vLLM) Run nanobot with your own local models using vLLM or any OpenAI-compatible server. @@ -257,6 +247,20 @@ nanobot gateway ## βš™οΈ Configuration +Config file: `~/.nanobot/config.json` + +### Providers + +| Provider | Purpose | Get API Key | +|----------|---------|-------------| +| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | + +> **Note**: Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. +
Full config example @@ -270,6 +274,9 @@ nanobot gateway "providers": { "openrouter": { "apiKey": "sk-or-v1-xxx" + }, + "groq": { + "apiKey": "gsk_xxx" } }, "channels": { @@ -323,6 +330,30 @@ nanobot cron remove
+## 🐳 Docker + +Build and run nanobot in a container: + +```bash +# Build the image +docker build -t nanobot . + +# Initialize config (first time only) +docker run -v ~/.nanobot:/root/.nanobot --rm nanobot onboard + +# Edit config on host to add API keys +vim ~/.nanobot/config.json + +# Run gateway (connects to Telegram/WhatsApp) +docker run -v ~/.nanobot:/root/.nanobot -p 18790:18790 nanobot gateway + +# Or run a single command +docker run -v ~/.nanobot:/root/.nanobot --rm nanobot agent -m "Hello!" +docker run -v ~/.nanobot:/root/.nanobot --rm nanobot status +``` + +> **Tip**: Mount `~/.nanobot` so your config and workspace persist across container restarts. + ## πŸ“ Project Structure ``` From 73a3934cc59de9a616271f6a121855709635406a Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Feb 2026 07:21:46 +0000 Subject: [PATCH 21/35] docs: unify note/tip format to GitHub Alerts --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 55c6091..cd6fc94 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,9 @@ Config file: `~/.nanobot/config.json` ### Providers +> [!NOTE] +> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. + | Provider | Purpose | Get API Key | |----------|---------|-------------| | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | @@ -259,7 +262,6 @@ Config file: `~/.nanobot/config.json` | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | -> **Note**: Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
Full config example @@ -332,6 +334,9 @@ nanobot cron remove ## 🐳 Docker +> [!TIP] +> The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts. + Build and run nanobot in a container: ```bash @@ -352,8 +357,6 @@ docker run -v ~/.nanobot:/root/.nanobot --rm nanobot agent -m "Hello!" docker run -v ~/.nanobot:/root/.nanobot --rm nanobot status ``` -> **Tip**: Mount `~/.nanobot` so your config and workspace persist across container restarts. - ## πŸ“ Project Structure ``` From a4269593fc1e5f11cc94e541bde17cafeadbd248 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Feb 2026 07:24:59 +0000 Subject: [PATCH 22/35] docs: improve install methods --- README.md | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index cd6fc94..046860d 100644 --- a/README.md +++ b/README.md @@ -60,19 +60,7 @@ ## πŸ“¦ Install -**Install with [uv](https://github.com/astral-sh/uv)** (recommended for speed) - -```bash -uv tool install nanobot-ai -``` - -**Install from PyPi** - -```bash -pip install nanobot-ai -``` - -**Install from source** (recommended for development) +**Install from source** (latest features, recommended for development) ```bash git clone https://github.com/HKUDS/nanobot.git @@ -80,12 +68,16 @@ cd nanobot pip install -e . ``` -**Install with uv** +**Install with [uv](https://github.com/astral-sh/uv)** (stable, fast) ```bash -uv venv -source .venv/bin/activate -uv pip install nanobot-ai +uv tool install nanobot-ai +``` + +**Install from PyPI** (stable) + +```bash +pip install nanobot-ai ``` ## πŸš€ Quick Start From c3b32afbbbab07a1f77ae7a76368606545d916bb Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Feb 2026 11:53:21 +0000 Subject: [PATCH 23/35] docs: improve README with disclaimer --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index f87e702..ddc5ccd 100644 --- a/README.md +++ b/README.md @@ -408,3 +408,9 @@ PRs welcome! The codebase is intentionally small and readable. πŸ€— Thanks for visiting ✨ nanobot!

Views

+ +--- + +

+ nanobot is for educational, research, and technical exchange purposes only +

From 6b7eebc46dc0334525510ba0cb13a26a6fc0b8ee Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Feb 2026 12:42:06 +0000 Subject: [PATCH 24/35] docs: add discord community --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ddc5ccd..93789d5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ License Feishu WeChat + Discord

From d9d744d536aab152a57827a2931b894badde8479 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Feb 2026 12:44:55 +0000 Subject: [PATCH 25/35] docs: optimize the structure --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 93789d5..f4b1df2 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,6 @@ PRs welcome! The codebase is intentionally small and readable. πŸ€— ---- ## ⭐ Star History @@ -410,7 +409,6 @@ PRs welcome! The codebase is intentionally small and readable. πŸ€— Views

----

nanobot is for educational, research, and technical exchange purposes only From 1a784fca1e8df195d0f1cb8ee3364bfdce9ac263 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 3 Feb 2026 17:13:30 +0000 Subject: [PATCH 26/35] refactor: simplify _validate_url function --- nanobot/agent/tools/web.py | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index ad72604..9de1d3c 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -31,30 +31,16 @@ def _normalize(text: str) -> str: def _validate_url(url: str) -> tuple[bool, str]: - """ - Validate URL for security. - - Returns: - (is_valid, error_message): Tuple of validation result and error message if invalid. - """ + """Validate URL: must be http(s) with valid domain.""" try: - parsed = urlparse(url) - - # Check if scheme exists - if not parsed.scheme: - return False, "URL must include a scheme (http:// or https://)" - - # Only allow http and https schemes - if parsed.scheme.lower() not in ('http', 'https'): - return False, f"Invalid URL scheme '{parsed.scheme}'. Only http:// and https:// are allowed" - - # Check if netloc (domain) exists - if not parsed.netloc: - return False, "URL must include a valid domain" - + p = urlparse(url) + if p.scheme not in ('http', 'https'): + return False, f"Only http/https allowed, got '{p.scheme or 'none'}'" + if not p.netloc: + return False, "Missing domain" return True, "" except Exception as e: - return False, f"Invalid URL format: {str(e)}" + return False, str(e) class WebSearchTool(Tool): From a20d887f9e8eae6bf34bb73480027a5140710df3 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 4 Feb 2026 03:45:26 +0000 Subject: [PATCH 27/35] feat: add parameter validation and safety guard for exec tool --- nanobot/agent/loop.py | 12 ++++++-- nanobot/agent/subagent.py | 9 +++++- nanobot/agent/tools/base.py | 57 +++++++++++++++--------------------- nanobot/agent/tools/shell.py | 31 ++++++++------------ nanobot/cli/commands.py | 6 ++-- nanobot/config/schema.py | 7 +++++ 6 files changed, 64 insertions(+), 58 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 4a96b84..bfe6e89 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -40,14 +40,17 @@ class AgentLoop: workspace: Path, model: str | None = None, max_iterations: int = 20, - brave_api_key: str | None = None + brave_api_key: str | None = None, + exec_config: "ExecToolConfig | None" = None, ): + from nanobot.config.schema import ExecToolConfig self.bus = bus self.provider = provider self.workspace = workspace self.model = model or provider.get_default_model() self.max_iterations = max_iterations self.brave_api_key = brave_api_key + self.exec_config = exec_config or ExecToolConfig() self.context = ContextBuilder(workspace) self.sessions = SessionManager(workspace) @@ -58,6 +61,7 @@ class AgentLoop: bus=bus, model=self.model, brave_api_key=brave_api_key, + exec_config=self.exec_config, ) self._running = False @@ -72,7 +76,11 @@ class AgentLoop: self.tools.register(ListDirTool()) # Shell tool - self.tools.register(ExecTool(working_dir=str(self.workspace))) + self.tools.register(ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.exec_config.restrict_to_workspace, + )) # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index d3b320c..05ffbb8 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -33,12 +33,15 @@ class SubagentManager: bus: MessageBus, model: str | None = None, brave_api_key: str | None = None, + exec_config: "ExecToolConfig | None" = None, ): + from nanobot.config.schema import ExecToolConfig self.provider = provider self.workspace = workspace self.bus = bus self.model = model or provider.get_default_model() self.brave_api_key = brave_api_key + self.exec_config = exec_config or ExecToolConfig() self._running_tasks: dict[str, asyncio.Task[None]] = {} async def spawn( @@ -96,7 +99,11 @@ class SubagentManager: tools.register(ReadFileTool()) tools.register(WriteFileTool()) tools.register(ListDirTool()) - tools.register(ExecTool(working_dir=str(self.workspace))) + tools.register(ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.exec_config.restrict_to_workspace, + )) tools.register(WebSearchTool(api_key=self.brave_api_key)) tools.register(WebFetchTool()) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 5888a77..cbaadbd 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -12,6 +12,15 @@ class Tool(ABC): the environment, such as reading files, executing commands, etc. """ + _TYPE_MAP = { + "string": str, + "integer": int, + "number": (int, float), + "boolean": bool, + "array": list, + "object": dict, + } + @property @abstractmethod def name(self) -> str: @@ -65,60 +74,40 @@ class Tool(ABC): def _validate_schema(self, value: Any, schema: dict[str, Any], path: str) -> list[str]: errors: list[str] = [] expected_type = schema.get("type") + label = path or "parameter" - type_map = { - "string": str, - "integer": int, - "number": (int, float), - "boolean": bool, - "array": list, - "object": dict, - } - - def label(p: str) -> str: - return p or "parameter" - - if expected_type in type_map and not isinstance(value, type_map[expected_type]): - errors.append(f"{label(path)} should be {expected_type}") - return errors + if expected_type in self._TYPE_MAP and not isinstance(value, self._TYPE_MAP[expected_type]): + return [f"{label} should be {expected_type}"] if "enum" in schema and value not in schema["enum"]: - errors.append(f"{label(path)} must be one of {schema['enum']}") + errors.append(f"{label} must be one of {schema['enum']}") if expected_type in ("integer", "number"): if "minimum" in schema and value < schema["minimum"]: - errors.append(f"{label(path)} must be >= {schema['minimum']}") + errors.append(f"{label} must be >= {schema['minimum']}") if "maximum" in schema and value > schema["maximum"]: - errors.append(f"{label(path)} must be <= {schema['maximum']}") + errors.append(f"{label} must be <= {schema['maximum']}") if expected_type == "string": if "minLength" in schema and len(value) < schema["minLength"]: - errors.append(f"{label(path)} must be at least {schema['minLength']} chars") + errors.append(f"{label} must be at least {schema['minLength']} chars") if "maxLength" in schema and len(value) > schema["maxLength"]: - errors.append(f"{label(path)} must be at most {schema['maxLength']} chars") + errors.append(f"{label} must be at most {schema['maxLength']} chars") if expected_type == "object": - properties = schema.get("properties", {}) or {} - required = set(schema.get("required", []) or []) - - for key in required: + properties = schema.get("properties", {}) + for key in schema.get("required", []): if key not in value: - p = f"{path}.{key}" if path else key - errors.append(f"missing required {p}") - + errors.append(f"missing required {path}.{key}" if path else f"missing required {key}") for key, item in value.items(): - prop_schema = properties.get(key) - if not prop_schema: - continue # ignore unknown fields - p = f"{path}.{key}" if path else key - errors.extend(self._validate_schema(item, prop_schema, p)) + if key in properties: + errors.extend(self._validate_schema(item, properties[key], f"{path}.{key}" if path else key)) if expected_type == "array": items_schema = schema.get("items") if items_schema: for idx, item in enumerate(value): - p = f"{path}[{idx}]" if path else f"[{idx}]" - errors.extend(self._validate_schema(item, items_schema, p)) + errors.extend(self._validate_schema(item, items_schema, f"{path}[{idx}]" if path else f"[{idx}]")) return errors diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index ce00bca..143d187 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -18,29 +18,22 @@ class ExecTool(Tool): working_dir: str | None = None, deny_patterns: list[str] | None = None, allow_patterns: list[str] | None = None, - restrict_to_working_dir: bool = False, + restrict_to_workspace: bool = False, ): self.timeout = timeout self.working_dir = working_dir self.deny_patterns = deny_patterns or [ - r"\brm\s+-rf\b", - r"\brm\s+-fr\b", - r"\brm\s+-r\b", - r"\bdel\s+/f\b", - r"\bdel\s+/q\b", - r"\brmdir\s+/s\b", - r"\bformat\b", - r"\bmkfs\b", - r"\bdd\s+if=", - r">\s*/dev/sd", - r"\bdiskpart\b", - r"\bshutdown\b", - r"\breboot\b", - r"\bpoweroff\b", - r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\};\s*:", + r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr + r"\bdel\s+/[fq]\b", # del /f, del /q + r"\brmdir\s+/s\b", # rmdir /s + r"\b(format|mkfs|diskpart)\b", # disk operations + r"\bdd\s+if=", # dd + r">\s*/dev/sd", # write to disk + r"\b(shutdown|reboot|poweroff)\b", # system power + r":\(\)\s*\{.*\};\s*:", # fork bomb ] self.allow_patterns = allow_patterns or [] - self.restrict_to_working_dir = restrict_to_working_dir + self.restrict_to_workspace = restrict_to_workspace @property def name(self) -> str: @@ -128,14 +121,14 @@ class ExecTool(Tool): if not any(re.search(p, lower) for p in self.allow_patterns): return "Error: Command blocked by safety guard (not in allowlist)" - if self.restrict_to_working_dir: + if self.restrict_to_workspace: if "..\\" in cmd or "../" in cmd: return "Error: Command blocked by safety guard (path traversal detected)" cwd_path = Path(cwd).resolve() win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd) - posix_paths = re.findall(r"/[^\\s\"']+", cmd) + posix_paths = re.findall(r"/[^\s\"']+", cmd) for raw in win_paths + posix_paths: try: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5ecc31b..6b95667 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -202,7 +202,8 @@ def gateway( workspace=config.workspace_path, model=config.agents.defaults.model, max_iterations=config.agents.defaults.max_tool_iterations, - brave_api_key=config.tools.web.search.api_key or None + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, ) # Create cron service @@ -309,7 +310,8 @@ def agent( bus=bus, provider=provider, workspace=config.workspace_path, - brave_api_key=config.tools.web.search.api_key or None + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, ) if message: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 71e3361..4c34834 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -73,9 +73,16 @@ class WebToolsConfig(BaseModel): search: WebSearchConfig = Field(default_factory=WebSearchConfig) +class ExecToolConfig(BaseModel): + """Shell exec tool configuration.""" + timeout: int = 60 + restrict_to_workspace: bool = False # If true, block commands accessing paths outside workspace + + class ToolsConfig(BaseModel): """Tools configuration.""" web: WebToolsConfig = Field(default_factory=WebToolsConfig) + exec: ExecToolConfig = Field(default_factory=ExecToolConfig) class Config(BaseSettings): From 9a0f8fcc73d49f0292d0caf86a79007068f01549 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 4 Feb 2026 03:50:39 +0000 Subject: [PATCH 28/35] refactor: simplify parameter validation logic --- nanobot/agent/tools/base.py | 77 ++++++++++++++----------------------- 1 file changed, 28 insertions(+), 49 deletions(-) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index cbaadbd..ca9bcc2 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -53,62 +53,41 @@ class Tool(ABC): pass def validate_params(self, params: dict[str, Any]) -> list[str]: - """ - Lightweight JSON schema validation for tool parameters. - - Returns a list of error strings (empty if valid). - Unknown params are ignored. - """ + """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" schema = self.parameters or {} + if schema.get("type", "object") != "object": + raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") + return self._validate(params, {**schema, "type": "object"}, "") - # Default to an object schema if type is missing, and fail fast on unsupported top-level types. - if "type" not in schema: - schema = {"type": "object", **schema} - elif schema.get("type") != "object": - raise ValueError( - f"Tool parameter schemas must have top-level type 'object'; got {schema.get('type')!r}" - ) - - return self._validate_schema(params, schema, path="") - - def _validate_schema(self, value: Any, schema: dict[str, Any], path: str) -> list[str]: - errors: list[str] = [] - expected_type = schema.get("type") - label = path or "parameter" - - if expected_type in self._TYPE_MAP and not isinstance(value, self._TYPE_MAP[expected_type]): - return [f"{label} should be {expected_type}"] - - if "enum" in schema and value not in schema["enum"]: + def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]: + t, label = schema.get("type"), path or "parameter" + if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]): + return [f"{label} should be {t}"] + + errors = [] + if "enum" in schema and val not in schema["enum"]: errors.append(f"{label} must be one of {schema['enum']}") - - if expected_type in ("integer", "number"): - if "minimum" in schema and value < schema["minimum"]: + if t in ("integer", "number"): + if "minimum" in schema and val < schema["minimum"]: errors.append(f"{label} must be >= {schema['minimum']}") - if "maximum" in schema and value > schema["maximum"]: + if "maximum" in schema and val > schema["maximum"]: errors.append(f"{label} must be <= {schema['maximum']}") - - if expected_type == "string": - if "minLength" in schema and len(value) < schema["minLength"]: + if t == "string": + if "minLength" in schema and len(val) < schema["minLength"]: errors.append(f"{label} must be at least {schema['minLength']} chars") - if "maxLength" in schema and len(value) > schema["maxLength"]: + if "maxLength" in schema and len(val) > schema["maxLength"]: errors.append(f"{label} must be at most {schema['maxLength']} chars") - - if expected_type == "object": - properties = schema.get("properties", {}) - for key in schema.get("required", []): - if key not in value: - errors.append(f"missing required {path}.{key}" if path else f"missing required {key}") - for key, item in value.items(): - if key in properties: - errors.extend(self._validate_schema(item, properties[key], f"{path}.{key}" if path else key)) - - if expected_type == "array": - items_schema = schema.get("items") - if items_schema: - for idx, item in enumerate(value): - errors.extend(self._validate_schema(item, items_schema, f"{path}[{idx}]" if path else f"[{idx}]")) - + if t == "object": + props = schema.get("properties", {}) + for k in schema.get("required", []): + if k not in val: + errors.append(f"missing required {path + '.' + k if path else k}") + for k, v in val.items(): + if k in props: + errors.extend(self._validate(v, props[k], path + '.' + k if path else k)) + if t == "array" and "items" in schema: + for i, item in enumerate(val): + errors.extend(self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]")) return errors def to_schema(self) -> dict[str, Any]: From 50fa024ab4d44c074157c9b5278717d769445e3a Mon Sep 17 00:00:00 2001 From: "tao.jun" <61566027@163.com> Date: Wed, 4 Feb 2026 14:07:45 +0800 Subject: [PATCH 29/35] feishu support --- README.md | 61 +++++++- nanobot/channels/feishu.py | 281 ++++++++++++++++++++++++++++++++++++ nanobot/channels/manager.py | 11 ++ nanobot/config/schema.py | 12 ++ pyproject.toml | 3 + 5 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 nanobot/channels/feishu.py diff --git a/README.md b/README.md index f4b1df2..efa6821 100644 --- a/README.md +++ b/README.md @@ -162,12 +162,13 @@ nanobot agent -m "Hello from my local LLM!" ## πŸ’¬ Chat Apps -Talk to your nanobot through Telegram or WhatsApp β€” anytime, anywhere. +Talk to your nanobot through Telegram, WhatsApp, or Feishu β€” anytime, anywhere. | Channel | Setup | |---------|-------| | **Telegram** | Easy (just a token) | | **WhatsApp** | Medium (scan QR) | +| **Feishu** | Medium (app credentials) |

Telegram (Recommended) @@ -238,6 +239,56 @@ nanobot gateway
+
+Feishu (飞书) + +Uses **WebSocket** long connection β€” no public IP required. + +Requires **lark-oapi** SDK: + +```bash +pip install lark-oapi +``` + +**1. Create a Feishu bot** +- Visit [Feishu Open Platform](https://open.feishu.cn/app) +- Create a new app (Custom App) +- Enable bot capability +- Add event subscription: `im.message.receive_v1` +- Get credentials: + - **App ID** and **App Secret** from "Credentials & Basic Info" + - **Verification Token** and **Encrypt Key** from "Event Subscriptions" + +**2. Configure** + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "appId": "cli_xxx", + "appSecret": "xxx", + "verificationToken": "xxx", + "encryptKey": "xxx", + "allowFrom": ["ou_xxx"] + } + } +} +``` + +> Get your Open ID by sending a message to the bot, or from Feishu admin console. + +**3. Run** + +```bash +nanobot gateway +``` + +> [!TIP] +> Feishu uses WebSocket to receive messages β€” no webhook or public IP needed! + +
+ ## βš™οΈ Configuration Config file: `~/.nanobot/config.json` @@ -282,6 +333,14 @@ Config file: `~/.nanobot/config.json` }, "whatsapp": { "enabled": false + }, + "feishu": { + "enabled": false, + "appId": "cli_xxx", + "appSecret": "xxx", + "verificationToken": "xxx", + "encryptKey": "xxx", + "allowFrom": ["ou_xxx"] } }, "tools": { diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py new file mode 100644 index 0000000..4326cf0 --- /dev/null +++ b/nanobot/channels/feishu.py @@ -0,0 +1,281 @@ +"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection.""" + +import asyncio +import json +import threading +from typing import Any + +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import FeishuConfig + +try: + import lark_oapi as lark + from lark_oapi.api.im.v1 import ( + CreateMessageRequest, + CreateMessageRequestBody, + CreateMessageReactionRequest, + CreateMessageReactionRequestBody, + P2ImMessageReceiveV1, + ) + FEISHU_AVAILABLE = True +except ImportError: + FEISHU_AVAILABLE = False + lark = None + + +class FeishuChannel(BaseChannel): + """ + Feishu/Lark channel using WebSocket long connection. + + Uses WebSocket to receive events - no public IP or webhook required. + + Requires: + - App ID and App Secret from Feishu Open Platform + - Bot capability enabled + - Event subscription enabled (im.message.receive_v1) + """ + + name = "feishu" + + def __init__(self, config: FeishuConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: FeishuConfig = config + self._client: Any = None + self._ws_client: Any = None + self._ws_thread: threading.Thread | None = None + self._processed_message_ids: set[str] = set() # Dedup message IDs + self._loop: asyncio.AbstractEventLoop | None = None + + async def start(self) -> None: + """Start the Feishu bot with WebSocket long connection.""" + if not FEISHU_AVAILABLE: + logger.error("Feishu SDK not installed. Run: pip install lark-oapi") + return + + if not self.config.app_id or not self.config.app_secret: + logger.error("Feishu app_id and app_secret not configured") + return + + self._running = True + self._loop = asyncio.get_event_loop() + + # Create Lark client for sending messages + self._client = lark.Client.builder() \ + .app_id(self.config.app_id) \ + .app_secret(self.config.app_secret) \ + .log_level(lark.LogLevel.INFO) \ + .build() + + # Create event handler (only register message receive, ignore other events) + event_handler = lark.EventDispatcherHandler.builder( + self.config.encrypt_key or "", + self.config.verification_token or "", + ).register_p2_im_message_receive_v1( + self._on_message_sync + ).build() + + # Create WebSocket client for long connection + self._ws_client = lark.ws.Client( + self.config.app_id, + self.config.app_secret, + event_handler=event_handler, + log_level=lark.LogLevel.INFO + ) + + # Start WebSocket client in a separate thread + def run_ws(): + try: + self._ws_client.start() + except Exception as e: + logger.error(f"Feishu WebSocket error: {e}") + + self._ws_thread = threading.Thread(target=run_ws, daemon=True) + self._ws_thread.start() + + logger.info("Feishu bot started with WebSocket long connection") + logger.info("No public IP required - using WebSocket to receive events") + + # Keep running until stopped + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop the Feishu bot.""" + self._running = False + logger.info("Feishu bot stopped") + + def _add_reaction(self, message_id: str, emoji_type: str = "SMILE") -> None: + """ + Add a reaction emoji to a message. + + Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART + """ + if not self._client: + logger.warning("Cannot add reaction: client not initialized") + return + + try: + from lark_oapi.api.im.v1 import Emoji + + request = CreateMessageReactionRequest.builder() \ + .message_id(message_id) \ + .request_body( + CreateMessageReactionRequestBody.builder() + .reaction_type(Emoji.builder().emoji_type(emoji_type).build()) + .build() + ).build() + + response = self._client.im.v1.message_reaction.create(request) + + if not response.success(): + logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}") + else: + logger.info(f"Added {emoji_type} reaction to message {message_id}") + except Exception as e: + logger.warning(f"Error adding reaction: {e}") + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through Feishu.""" + if not self._client: + logger.warning("Feishu client not initialized") + return + + try: + # Determine receive_id_type based on chat_id format + # open_id starts with "ou_", chat_id starts with "oc_" + if msg.chat_id.startswith("oc_"): + receive_id_type = "chat_id" + else: + receive_id_type = "open_id" + + # Build text message content + content = json.dumps({"text": msg.content}) + + request = CreateMessageRequest.builder() \ + .receive_id_type(receive_id_type) \ + .request_body( + CreateMessageRequestBody.builder() + .receive_id(msg.chat_id) + .msg_type("text") + .content(content) + .build() + ).build() + + response = self._client.im.v1.message.create(request) + + if not response.success(): + logger.error( + f"Failed to send Feishu message: code={response.code}, " + f"msg={response.msg}, log_id={response.get_log_id()}" + ) + else: + logger.debug(f"Feishu message sent to {msg.chat_id}") + + except Exception as e: + logger.error(f"Error sending Feishu message: {e}") + + def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None: + """ + Sync handler for incoming messages (called from WebSocket thread). + Schedules async handling in the main event loop. + """ + try: + if self._loop and self._loop.is_running(): + # Schedule the async handler in the main event loop + asyncio.run_coroutine_threadsafe( + self._on_message(data), + self._loop + ) + else: + # Fallback: run in new event loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self._on_message(data)) + finally: + loop.close() + except Exception as e: + logger.error(f"Error handling Feishu message: {e}") + + async def _on_message(self, data: "P2ImMessageReceiveV1") -> None: + """Handle incoming message from Feishu.""" + try: + event = data.event + message = event.message + sender = event.sender + + # Get message ID for deduplication + message_id = message.message_id + if message_id in self._processed_message_ids: + logger.debug(f"Skipping duplicate message: {message_id}") + return + self._processed_message_ids.add(message_id) + + # Limit dedup cache size + if len(self._processed_message_ids) > 1000: + self._processed_message_ids = set(list(self._processed_message_ids)[-500:]) + + # Extract sender info + sender_id = sender.sender_id.open_id if sender.sender_id else "unknown" + sender_type = sender.sender_type # "user" or "bot" + + # Skip bot messages + if sender_type == "bot": + return + + # Add reaction to user's message to indicate "seen" (πŸ‘ THUMBSUP) + self._add_reaction(message_id, "THUMBSUP") + + # Get chat_id for replies + chat_id = message.chat_id + chat_type = message.chat_type # "p2p" or "group" + + # Parse message content + content = "" + msg_type = message.message_type + + if msg_type == "text": + # Text message: {"text": "hello"} + try: + content_obj = json.loads(message.content) + content = content_obj.get("text", "") + except json.JSONDecodeError: + content = message.content or "" + elif msg_type == "image": + content = "[image]" + elif msg_type == "audio": + content = "[audio]" + elif msg_type == "file": + content = "[file]" + elif msg_type == "sticker": + content = "[sticker]" + else: + content = f"[{msg_type}]" + + if not content: + return + + logger.debug(f"Feishu message from {sender_id} in {chat_id}: {content[:50]}...") + + # Forward to message bus + # Use chat_id for group chats, sender's open_id for p2p + reply_to = chat_id if chat_type == "group" else sender_id + + await self._handle_message( + sender_id=sender_id, + chat_id=reply_to, + content=content, + metadata={ + "message_id": message_id, + "chat_type": chat_type, + "msg_type": msg_type, + "sender_type": sender_type, + } + ) + + except Exception as e: + logger.error(f"Error processing Feishu message: {e}") diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 73c3334..979d01e 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -55,6 +55,17 @@ class ChannelManager: logger.info("WhatsApp channel enabled") except ImportError as e: logger.warning(f"WhatsApp channel not available: {e}") + + # Feishu channel + if self.config.channels.feishu.enabled: + try: + from nanobot.channels.feishu import FeishuChannel + self.channels["feishu"] = FeishuChannel( + self.config.channels.feishu, self.bus + ) + logger.info("Feishu channel enabled") + except ImportError as e: + logger.warning(f"Feishu channel not available: {e}") async def start_all(self) -> None: """Start WhatsApp channel and the outbound dispatcher.""" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 4c34834..4492096 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -17,12 +17,24 @@ class TelegramConfig(BaseModel): enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames + proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + + +class FeishuConfig(BaseModel): + """Feishu/Lark channel configuration using WebSocket long connection.""" + enabled: bool = False + app_id: str = "" # App ID from Feishu Open Platform + app_secret: str = "" # App Secret from Feishu Open Platform + encrypt_key: str = "" # Encrypt Key for event subscription (optional) + verification_token: str = "" # Verification Token for event subscription (optional) + allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids class ChannelsConfig(BaseModel): """Configuration for chat channels.""" whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) + feishu: FeishuConfig = Field(default_factory=FeishuConfig) class AgentDefaults(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index d081dd7..e027097 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ dependencies = [ ] [project.optional-dependencies] +feishu = [ + "lark-oapi>=1.0.0", +] dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", From bf1dc7c0d3eafa6dd4e4f7c329b55bc6c9c7b226 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 4 Feb 2026 06:45:53 +0000 Subject: [PATCH 30/35] docs: fix incorrect references and add missing tool docs --- README.md | 8 +++-- nanobot/skills/skill-creator/SKILL.md | 44 +++++++++++++-------------- workspace/AGENTS.md | 1 + workspace/TOOLS.md | 28 ++++++++++++----- 4 files changed, 49 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index f4b1df2..e54bb8f 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,12 @@ nanobot onboard "model": "anthropic/claude-opus-4-5" } }, - "webSearch": { - "apiKey": "BSA-xxx" + "tools": { + "web": { + "search": { + "apiKey": "BSA-xxx" + } + } } } ``` diff --git a/nanobot/skills/skill-creator/SKILL.md b/nanobot/skills/skill-creator/SKILL.md index 4680d5e..9b5eb6f 100644 --- a/nanobot/skills/skill-creator/SKILL.md +++ b/nanobot/skills/skill-creator/SKILL.md @@ -9,9 +9,9 @@ This skill provides guidance for creating effective skills. ## About Skills -Skills are modular, self-contained packages that extend Codex's capabilities by providing +Skills are modular, self-contained packages that extend the agent's capabilities by providing specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific -domains or tasksβ€”they transform Codex from a general-purpose agent into a specialized agent +domains or tasksβ€”they transform the agent from a general-purpose agent into a specialized agent equipped with procedural knowledge that no model can fully possess. ### What Skills Provide @@ -25,9 +25,9 @@ equipped with procedural knowledge that no model can fully possess. ### Concise is Key -The context window is a public good. Skills share the context window with everything else Codex needs: system prompt, conversation history, other Skills' metadata, and the actual user request. +The context window is a public good. Skills share the context window with everything else the agent needs: system prompt, conversation history, other Skills' metadata, and the actual user request. -**Default assumption: Codex is already very smart.** Only add context Codex doesn't already have. Challenge each piece of information: "Does Codex really need this explanation?" and "Does this paragraph justify its token cost?" +**Default assumption: the agent is already very smart.** Only add context the agent doesn't already have. Challenge each piece of information: "Does the agent really need this explanation?" and "Does this paragraph justify its token cost?" Prefer concise examples over verbose explanations. @@ -41,7 +41,7 @@ Match the level of specificity to the task's fragility and variability: **Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed. -Think of Codex as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). +Think of the agent as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). ### Anatomy of a Skill @@ -64,7 +64,7 @@ skill-name/ Every SKILL.md consists of: -- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Codex reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. +- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that the agent reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. - **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all). #### Bundled Resources (optional) @@ -76,27 +76,27 @@ Executable code (Python/Bash/etc.) for tasks that require deterministic reliabil - **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed - **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks - **Benefits**: Token efficient, deterministic, may be executed without loading into context -- **Note**: Scripts may still need to be read by Codex for patching or environment-specific adjustments +- **Note**: Scripts may still need to be read by the agent for patching or environment-specific adjustments ##### References (`references/`) -Documentation and reference material intended to be loaded as needed into context to inform Codex's process and thinking. +Documentation and reference material intended to be loaded as needed into context to inform the agent's process and thinking. -- **When to include**: For documentation that Codex should reference while working +- **When to include**: For documentation that the agent should reference while working - **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications - **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides -- **Benefits**: Keeps SKILL.md lean, loaded only when Codex determines it's needed +- **Benefits**: Keeps SKILL.md lean, loaded only when the agent determines it's needed - **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md - **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skillβ€”this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. ##### Assets (`assets/`) -Files not intended to be loaded into context, but rather used within the output Codex produces. +Files not intended to be loaded into context, but rather used within the output the agent produces. - **When to include**: When the skill needs files that will be used in the final output - **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography - **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified -- **Benefits**: Separates output resources from documentation, enables Codex to use files without loading them into context +- **Benefits**: Separates output resources from documentation, enables the agent to use files without loading them into context #### What to Not Include in a Skill @@ -116,7 +116,7 @@ Skills use a three-level loading system to manage context efficiently: 1. **Metadata (name + description)** - Always in context (~100 words) 2. **SKILL.md body** - When skill triggers (<5k words) -3. **Bundled resources** - As needed by Codex (Unlimited because scripts can be executed without reading into context window) +3. **Bundled resources** - As needed by the agent (Unlimited because scripts can be executed without reading into context window) #### Progressive Disclosure Patterns @@ -141,7 +141,7 @@ Extract text with pdfplumber: - **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns ``` -Codex loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. +the agent loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. **Pattern 2: Domain-specific organization** @@ -157,7 +157,7 @@ bigquery-skill/ └── marketing.md (campaigns, attribution) ``` -When a user asks about sales metrics, Codex only reads sales.md. +When a user asks about sales metrics, the agent only reads sales.md. Similarly, for skills supporting multiple frameworks or variants, organize by variant: @@ -170,7 +170,7 @@ cloud-deploy/ └── azure.md (Azure deployment patterns) ``` -When the user chooses AWS, Codex only reads aws.md. +When the user chooses AWS, the agent only reads aws.md. **Pattern 3: Conditional details** @@ -191,12 +191,12 @@ For simple edits, modify the XML directly. **For OOXML details**: See [OOXML.md](OOXML.md) ``` -Codex reads REDLINING.md or OOXML.md only when the user needs those features. +the agent reads REDLINING.md or OOXML.md only when the user needs those features. **Important guidelines:** - **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md. -- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Codex can see the full scope when previewing. +- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so the agent can see the full scope when previewing. ## Skill Creation Process @@ -293,7 +293,7 @@ After initialization, customize the SKILL.md and add resources as needed. If you ### Step 4: Edit the Skill -When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Codex to use. Include information that would be beneficial and non-obvious to Codex. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Codex instance execute these tasks more effectively. +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of the agent to use. Include information that would be beneficial and non-obvious to the agent. Consider what procedural knowledge, domain-specific details, or reusable assets would help another the agent instance execute these tasks more effectively. #### Learn Proven Design Patterns @@ -321,10 +321,10 @@ If you used `--examples`, delete any placeholder files that are not needed for t Write the YAML frontmatter with `name` and `description`: - `name`: The skill name -- `description`: This is the primary triggering mechanism for your skill, and helps Codex understand when to use the skill. +- `description`: This is the primary triggering mechanism for your skill, and helps the agent understand when to use the skill. - Include both what the Skill does and specific triggers/contexts for when to use it. - - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Codex. - - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Codex needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" + - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to the agent. + - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when the agent needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" Do not include any other fields in YAML frontmatter. diff --git a/workspace/AGENTS.md b/workspace/AGENTS.md index a99a7b4..b4e5b5f 100644 --- a/workspace/AGENTS.md +++ b/workspace/AGENTS.md @@ -16,6 +16,7 @@ You have access to: - Shell commands (exec) - Web access (search, fetch) - Messaging (message) +- Background tasks (spawn) ## Memory diff --git a/workspace/TOOLS.md b/workspace/TOOLS.md index 9915561..0134a64 100644 --- a/workspace/TOOLS.md +++ b/workspace/TOOLS.md @@ -37,29 +37,31 @@ exec(command: str, working_dir: str = None) -> str ``` **Safety Notes:** -- Commands have a 60-second timeout +- Commands have a configurable timeout (default 60s) +- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.) - Output is truncated at 10,000 characters -- Use with caution for destructive operations +- Optional `restrictToWorkspace` config to limit paths ## Web Access ### web_search -Search the web using DuckDuckGo. +Search the web using Brave Search API. ``` -web_search(query: str) -> str +web_search(query: str, count: int = 5) -> str ``` -Returns top 5 search results with titles, URLs, and snippets. +Returns search results with titles, URLs, and snippets. Requires `tools.web.search.apiKey` in config. ### web_fetch Fetch and extract main content from a URL. ``` -web_fetch(url: str) -> str +web_fetch(url: str, extractMode: str = "markdown", maxChars: int = 50000) -> str ``` **Notes:** -- Content is extracted using trafilatura -- Output is truncated at 8,000 characters +- Content is extracted using readability +- Supports markdown or plain text extraction +- Output is truncated at 50,000 characters by default ## Communication @@ -69,6 +71,16 @@ Send a message to the user (used internally). message(content: str, channel: str = None, chat_id: str = None) -> str ``` +## Background Tasks + +### spawn +Spawn a subagent to handle a task in the background. +``` +spawn(task: str, label: str = None) -> str +``` + +Use for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done. + ## Scheduled Reminders (Cron) Use the `exec` tool to create scheduled reminders with `nanobot cron add`: From be8772355c8e563b2186e9c8b137422a0061973b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 4 Feb 2026 09:21:53 +0000 Subject: [PATCH 31/35] fix: correct bridge path for pip-installed package --- nanobot/cli/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 6b95667..c2241fb 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -400,7 +400,7 @@ def _get_bridge_dir() -> Path: raise typer.Exit(1) # Find source bridge: first check package data, then source dir - pkg_bridge = Path(__file__).parent / "bridge" # nanobot/bridge (installed) + pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) source = None From 795f8105a0a76e71a27c0ea37fb07a87f02fbe23 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 4 Feb 2026 09:27:37 +0000 Subject: [PATCH 32/35] bump version to 0.1.3.post4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d081dd7..d578a08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.3.post3" +version = "0.1.3.post4" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From 50a4c4ca1ab9104a945a552396ee42c8d6337e7d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Feb 2026 06:01:02 +0000 Subject: [PATCH 33/35] refactor: improve feishu channel implementation --- nanobot/channels/feishu.py | 114 ++++++++++++++++--------------------- 1 file changed, 48 insertions(+), 66 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4326cf0..01b808e 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -3,6 +3,7 @@ import asyncio import json import threading +from collections import OrderedDict from typing import Any from loguru import logger @@ -19,12 +20,22 @@ try: CreateMessageRequestBody, CreateMessageReactionRequest, CreateMessageReactionRequestBody, + Emoji, P2ImMessageReceiveV1, ) FEISHU_AVAILABLE = True except ImportError: FEISHU_AVAILABLE = False lark = None + Emoji = None + +# Message type display mapping +MSG_TYPE_MAP = { + "image": "[image]", + "audio": "[audio]", + "file": "[file]", + "sticker": "[sticker]", +} class FeishuChannel(BaseChannel): @@ -47,7 +58,7 @@ class FeishuChannel(BaseChannel): self._client: Any = None self._ws_client: Any = None self._ws_thread: threading.Thread | None = None - self._processed_message_ids: set[str] = set() # Dedup message IDs + self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache self._loop: asyncio.AbstractEventLoop | None = None async def start(self) -> None: @@ -61,7 +72,7 @@ class FeishuChannel(BaseChannel): return self._running = True - self._loop = asyncio.get_event_loop() + self._loop = asyncio.get_running_loop() # Create Lark client for sending messages self._client = lark.Client.builder() \ @@ -106,21 +117,16 @@ class FeishuChannel(BaseChannel): async def stop(self) -> None: """Stop the Feishu bot.""" self._running = False + if self._ws_client: + try: + self._ws_client.stop() + except Exception as e: + logger.warning(f"Error stopping WebSocket client: {e}") logger.info("Feishu bot stopped") - def _add_reaction(self, message_id: str, emoji_type: str = "SMILE") -> None: - """ - Add a reaction emoji to a message. - - Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART - """ - if not self._client: - logger.warning("Cannot add reaction: client not initialized") - return - + def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: + """Sync helper for adding reaction (runs in thread pool).""" try: - from lark_oapi.api.im.v1 import Emoji - request = CreateMessageReactionRequest.builder() \ .message_id(message_id) \ .request_body( @@ -134,9 +140,21 @@ class FeishuChannel(BaseChannel): if not response.success(): logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}") else: - logger.info(f"Added {emoji_type} reaction to message {message_id}") + logger.debug(f"Added {emoji_type} reaction to message {message_id}") except Exception as e: logger.warning(f"Error adding reaction: {e}") + + async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None: + """ + Add a reaction emoji to a message (non-blocking). + + Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART + """ + if not self._client or not Emoji: + return + + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type) async def send(self, msg: OutboundMessage) -> None: """Send a message through Feishu.""" @@ -183,23 +201,8 @@ class FeishuChannel(BaseChannel): Sync handler for incoming messages (called from WebSocket thread). Schedules async handling in the main event loop. """ - try: - if self._loop and self._loop.is_running(): - # Schedule the async handler in the main event loop - asyncio.run_coroutine_threadsafe( - self._on_message(data), - self._loop - ) - else: - # Fallback: run in new event loop - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(self._on_message(data)) - finally: - loop.close() - except Exception as e: - logger.error(f"Error handling Feishu message: {e}") + if self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop) async def _on_message(self, data: "P2ImMessageReceiveV1") -> None: """Handle incoming message from Feishu.""" @@ -208,63 +211,43 @@ class FeishuChannel(BaseChannel): message = event.message sender = event.sender - # Get message ID for deduplication + # Deduplication check message_id = message.message_id if message_id in self._processed_message_ids: - logger.debug(f"Skipping duplicate message: {message_id}") return - self._processed_message_ids.add(message_id) + self._processed_message_ids[message_id] = None - # Limit dedup cache size - if len(self._processed_message_ids) > 1000: - self._processed_message_ids = set(list(self._processed_message_ids)[-500:]) - - # Extract sender info - sender_id = sender.sender_id.open_id if sender.sender_id else "unknown" - sender_type = sender.sender_type # "user" or "bot" + # Trim cache: keep most recent 500 when exceeds 1000 + while len(self._processed_message_ids) > 1000: + self._processed_message_ids.popitem(last=False) # Skip bot messages + sender_type = sender.sender_type if sender_type == "bot": return - # Add reaction to user's message to indicate "seen" (πŸ‘ THUMBSUP) - self._add_reaction(message_id, "THUMBSUP") - - # Get chat_id for replies + sender_id = sender.sender_id.open_id if sender.sender_id else "unknown" chat_id = message.chat_id chat_type = message.chat_type # "p2p" or "group" - - # Parse message content - content = "" msg_type = message.message_type + # Add reaction to indicate "seen" + await self._add_reaction(message_id, "THUMBSUP") + + # Parse message content if msg_type == "text": - # Text message: {"text": "hello"} try: - content_obj = json.loads(message.content) - content = content_obj.get("text", "") + content = json.loads(message.content).get("text", "") except json.JSONDecodeError: content = message.content or "" - elif msg_type == "image": - content = "[image]" - elif msg_type == "audio": - content = "[audio]" - elif msg_type == "file": - content = "[file]" - elif msg_type == "sticker": - content = "[sticker]" else: - content = f"[{msg_type}]" + content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]") if not content: return - logger.debug(f"Feishu message from {sender_id} in {chat_id}: {content[:50]}...") - # Forward to message bus - # Use chat_id for group chats, sender's open_id for p2p reply_to = chat_id if chat_type == "group" else sender_id - await self._handle_message( sender_id=sender_id, chat_id=reply_to, @@ -273,7 +256,6 @@ class FeishuChannel(BaseChannel): "message_id": message_id, "chat_type": chat_type, "msg_type": msg_type, - "sender_type": sender_type, } ) From f341de075de7120019ab9033322dd50a101beca7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Feb 2026 06:05:09 +0000 Subject: [PATCH 34/35] docs: simplify Feishu configuration guide --- README.md | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5832380..467f8e3 100644 --- a/README.md +++ b/README.md @@ -248,20 +248,18 @@ nanobot gateway Uses **WebSocket** long connection β€” no public IP required. -Requires **lark-oapi** SDK: - ```bash -pip install lark-oapi +pip install nanobot-ai[feishu] ``` **1. Create a Feishu bot** - Visit [Feishu Open Platform](https://open.feishu.cn/app) -- Create a new app (Custom App) -- Enable bot capability -- Add event subscription: `im.message.receive_v1` -- Get credentials: - - **App ID** and **App Secret** from "Credentials & Basic Info" - - **Verification Token** and **Encrypt Key** from "Event Subscriptions" +- Create a new app β†’ Enable **Bot** capability +- **Permissions**: Add `im:message` (send messages) +- **Events**: Add `im.message.receive_v1` (receive messages) + - Select **Long Connection** mode (requires running nanobot first to establish connection) +- Get **App ID** and **App Secret** from "Credentials & Basic Info" +- Publish the app **2. Configure** @@ -272,15 +270,16 @@ pip install lark-oapi "enabled": true, "appId": "cli_xxx", "appSecret": "xxx", - "verificationToken": "xxx", - "encryptKey": "xxx", - "allowFrom": ["ou_xxx"] + "encryptKey": "", + "verificationToken": "", + "allowFrom": [] } } } ``` -> Get your Open ID by sending a message to the bot, or from Feishu admin console. +> `encryptKey` and `verificationToken` are optional for Long Connection mode. +> `allowFrom`: Leave empty to allow all users, or add `["ou_xxx"]` to restrict access. **3. Run** @@ -342,9 +341,9 @@ Config file: `~/.nanobot/config.json` "enabled": false, "appId": "cli_xxx", "appSecret": "xxx", - "verificationToken": "xxx", - "encryptKey": "xxx", - "allowFrom": ["ou_xxx"] + "encryptKey": "", + "verificationToken": "", + "allowFrom": [] } }, "tools": { From 1d74dd24d6b54f447ae8aab1ecf61ce137210719 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Feb 2026 06:09:37 +0000 Subject: [PATCH 35/35] docs: update contributors image --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 467f8e3..47f9315 100644 --- a/README.md +++ b/README.md @@ -450,7 +450,7 @@ PRs welcome! The codebase is intentionally small and readable. πŸ€— ### Contributors - +