diff --git a/.gitignore b/.gitignore index 9720f3b..316e214 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,8 @@ docs/ *.pyw *.pyz *.pywz -*.pyzz \ No newline at end of file +*.pyzz +.venv/ +__pycache__/ +poetry.lock +.pytest_cache/ diff --git a/README.md b/README.md index 47f9315..ff827be 100644 --- a/README.md +++ b/README.md @@ -16,19 +16,26 @@ โšก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ€” **99% smaller** than Clawdbot's 430k+ lines. +๐Ÿ“ Real-time line count: **3,423 lines** (run `bash core_agent_lines.sh` to verify anytime) + ## ๐Ÿ“ข News -- **2026-02-01** ๐ŸŽ‰ nanobot launched! Welcome to try ๐Ÿˆ nanobot! +- **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. +- **2026-02-06** โœจ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! +- **2026-02-05** โœจ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! +- **2026-02-04** ๐Ÿš€ Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. +- **2026-02-03** โšก Integrated vLLM for local LLM support and improved natural language task scheduling! +- **2026-02-02** ๐ŸŽ‰ nanobot officially launched! Welcome to try ๐Ÿˆ nanobot! ## Key Features of nanobot: -๐Ÿชถ **Ultra-Lightweight**: Just ~4,000 lines of code โ€” 99% smaller than Clawdbot - core functionality. +๐Ÿชถ **Ultra-Lightweight**: Just ~4,000 lines of core agent code โ€” 99% smaller than Clawdbot. ๐Ÿ”ฌ **Research-Ready**: Clean, readable code that's easy to understand, modify, and extend for research. โšก๏ธ **Lightning Fast**: Minimal footprint means faster startup, lower resource usage, and quicker iterations. -๐Ÿ’Ž **Easy-to-Use**: One-click to depoly and you're ready to go. +๐Ÿ’Ž **Easy-to-Use**: One-click to deploy and you're ready to go. ## ๐Ÿ—๏ธ Architecture @@ -85,8 +92,7 @@ pip install nanobot-ai > [!TIP] > Set your API key in `~/.nanobot/config.json`. -> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) ยท [Brave Search](https://brave.com/search/api/) (optional, for web search) -> You can also change the model to `minimax/minimax-m2` for lower cost. +> Get API keys: [OpenRouter](https://openrouter.ai/keys) (Global) ยท [DashScope](https://dashscope.console.aliyun.com) (Qwen) ยท [Brave Search](https://brave.com/search/api/) (optional, for web search) **1. Initialize** @@ -96,6 +102,7 @@ nanobot onboard **2. Configure** (`~/.nanobot/config.json`) +For OpenRouter - recommended for global users: ```json { "providers": { @@ -107,18 +114,10 @@ nanobot onboard "defaults": { "model": "anthropic/claude-opus-4-5" } - }, - "tools": { - "web": { - "search": { - "apiKey": "BSA-xxx" - } - } } } ``` - **3. Chat** ```bash @@ -166,11 +165,12 @@ nanobot agent -m "Hello from my local LLM!" ## ๐Ÿ’ฌ Chat Apps -Talk to your nanobot through Telegram, WhatsApp, or Feishu โ€” anytime, anywhere. +Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu โ€” anytime, anywhere. | Channel | Setup | |---------|-------| | **Telegram** | Easy (just a token) | +| **Discord** | Easy (bot token + intents) | | **WhatsApp** | Medium (scan QR) | | **Feishu** | Medium (app credentials) | @@ -206,6 +206,50 @@ nanobot gateway +
+Discord + +**1. Create a bot** +- Go to https://discord.com/developers/applications +- Create an application โ†’ Bot โ†’ Add Bot +- Copy the bot token + +**2. Enable intents** +- In the Bot settings, enable **MESSAGE CONTENT INTENT** +- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data + +**3. Get your User ID** +- Discord Settings โ†’ Advanced โ†’ enable **Developer Mode** +- Right-click your avatar โ†’ **Copy User ID** + +**4. Configure** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Invite the bot** +- OAuth2 โ†’ URL Generator +- Scopes: `bot` +- Bot Permissions: `Send Messages`, `Read Message History` +- Open the generated invite URL and add the bot to your server + +**6. Run** + +```bash +nanobot gateway +``` + +
+
WhatsApp @@ -306,57 +350,23 @@ Config file: `~/.nanobot/config.json` | `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) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | +| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | -
-Full config example +### Security -```json -{ - "agents": { - "defaults": { - "model": "anthropic/claude-opus-4-5" - } - }, - "providers": { - "openrouter": { - "apiKey": "sk-or-v1-xxx" - }, - "groq": { - "apiKey": "gsk_xxx" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "123456:ABC...", - "allowFrom": ["123456789"] - }, - "whatsapp": { - "enabled": false - }, - "feishu": { - "enabled": false, - "appId": "cli_xxx", - "appSecret": "xxx", - "encryptKey": "", - "verificationToken": "", - "allowFrom": [] - } - }, - "tools": { - "web": { - "search": { - "apiKey": "BSA..." - } - } - } -} -``` +> [!TIP] +> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent. + +| Option | Default | Description | +|--------|---------|-------------| +| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. | +| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. | -
## CLI Reference diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ac15ba4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,264 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in nanobot, please report it by: + +1. **DO NOT** open a public GitHub issue +2. Create a private security advisory on GitHub or contact the repository maintainers +3. Include: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (if any) + +We aim to respond to security reports within 48 hours. + +## Security Best Practices + +### 1. API Key Management + +**CRITICAL**: Never commit API keys to version control. + +```bash +# โœ… Good: Store in config file with restricted permissions +chmod 600 ~/.nanobot/config.json + +# โŒ Bad: Hardcoding keys in code or committing them +``` + +**Recommendations:** +- Store API keys in `~/.nanobot/config.json` with file permissions set to `0600` +- Consider using environment variables for sensitive keys +- Use OS keyring/credential manager for production deployments +- Rotate API keys regularly +- Use separate API keys for development and production + +### 2. Channel Access Control + +**IMPORTANT**: Always configure `allowFrom` lists for production use. + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["123456789", "987654321"] + }, + "whatsapp": { + "enabled": true, + "allowFrom": ["+1234567890"] + } + } +} +``` + +**Security Notes:** +- Empty `allowFrom` list will **ALLOW ALL** users (open by default for personal use) +- Get your Telegram user ID from `@userinfobot` +- Use full phone numbers with country code for WhatsApp +- Review access logs regularly for unauthorized access attempts + +### 3. Shell Command Execution + +The `exec` tool can execute shell commands. While dangerous command patterns are blocked, you should: + +- โœ… Review all tool usage in agent logs +- โœ… Understand what commands the agent is running +- โœ… Use a dedicated user account with limited privileges +- โœ… Never run nanobot as root +- โŒ Don't disable security checks +- โŒ Don't run on systems with sensitive data without careful review + +**Blocked patterns:** +- `rm -rf /` - Root filesystem deletion +- Fork bombs +- Filesystem formatting (`mkfs.*`) +- Raw disk writes +- Other destructive operations + +### 4. File System Access + +File operations have path traversal protection, but: + +- โœ… Run nanobot with a dedicated user account +- โœ… Use filesystem permissions to protect sensitive directories +- โœ… Regularly audit file operations in logs +- โŒ Don't give unrestricted access to sensitive files + +### 5. Network Security + +**API Calls:** +- All external API calls use HTTPS by default +- Timeouts are configured to prevent hanging requests +- Consider using a firewall to restrict outbound connections if needed + +**WhatsApp Bridge:** +- The bridge runs on `localhost:3001` by default +- If exposing to network, use proper authentication and TLS +- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700) + +### 6. Dependency Security + +**Critical**: Keep dependencies updated! + +```bash +# Check for vulnerable dependencies +pip install pip-audit +pip-audit + +# Update to latest secure versions +pip install --upgrade nanobot-ai +``` + +For Node.js dependencies (WhatsApp bridge): +```bash +cd bridge +npm audit +npm audit fix +``` + +**Important Notes:** +- Keep `litellm` updated to the latest version for security fixes +- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability +- Run `pip-audit` or `npm audit` regularly +- Subscribe to security advisories for nanobot and its dependencies + +### 7. Production Deployment + +For production use: + +1. **Isolate the Environment** + ```bash + # Run in a container or VM + docker run --rm -it python:3.11 + pip install nanobot-ai + ``` + +2. **Use a Dedicated User** + ```bash + sudo useradd -m -s /bin/bash nanobot + sudo -u nanobot nanobot gateway + ``` + +3. **Set Proper Permissions** + ```bash + chmod 700 ~/.nanobot + chmod 600 ~/.nanobot/config.json + chmod 700 ~/.nanobot/whatsapp-auth + ``` + +4. **Enable Logging** + ```bash + # Configure log monitoring + tail -f ~/.nanobot/logs/nanobot.log + ``` + +5. **Use Rate Limiting** + - Configure rate limits on your API providers + - Monitor usage for anomalies + - Set spending limits on LLM APIs + +6. **Regular Updates** + ```bash + # Check for updates weekly + pip install --upgrade nanobot-ai + ``` + +### 8. Development vs Production + +**Development:** +- Use separate API keys +- Test with non-sensitive data +- Enable verbose logging +- Use a test Telegram bot + +**Production:** +- Use dedicated API keys with spending limits +- Restrict file system access +- Enable audit logging +- Regular security reviews +- Monitor for unusual activity + +### 9. Data Privacy + +- **Logs may contain sensitive information** - secure log files appropriately +- **LLM providers see your prompts** - review their privacy policies +- **Chat history is stored locally** - protect the `~/.nanobot` directory +- **API keys are in plain text** - use OS keyring for production + +### 10. Incident Response + +If you suspect a security breach: + +1. **Immediately revoke compromised API keys** +2. **Review logs for unauthorized access** + ```bash + grep "Access denied" ~/.nanobot/logs/nanobot.log + ``` +3. **Check for unexpected file modifications** +4. **Rotate all credentials** +5. **Update to latest version** +6. **Report the incident** to maintainers + +## Security Features + +### Built-in Security Controls + +โœ… **Input Validation** +- Path traversal protection on file operations +- Dangerous command pattern detection +- Input length limits on HTTP requests + +โœ… **Authentication** +- Allow-list based access control +- Failed authentication attempt logging +- Open by default (configure allowFrom for production use) + +โœ… **Resource Protection** +- Command execution timeouts (60s default) +- Output truncation (10KB limit) +- HTTP request timeouts (10-30s) + +โœ… **Secure Communication** +- HTTPS for all external API calls +- TLS for Telegram API +- WebSocket security for WhatsApp bridge + +## Known Limitations + +โš ๏ธ **Current Security Limitations:** + +1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed) +2. **Plain Text Config** - API keys stored in plain text (use keyring for production) +3. **No Session Management** - No automatic session expiry +4. **Limited Command Filtering** - Only blocks obvious dangerous patterns +5. **No Audit Trail** - Limited security event logging (enhance as needed) + +## Security Checklist + +Before deploying nanobot: + +- [ ] API keys stored securely (not in code) +- [ ] Config file permissions set to 0600 +- [ ] `allowFrom` lists configured for all channels +- [ ] Running as non-root user +- [ ] File system permissions properly restricted +- [ ] Dependencies updated to latest secure versions +- [ ] Logs monitored for security events +- [ ] Rate limits configured on API providers +- [ ] Backup and disaster recovery plan in place +- [ ] Security review of custom skills/tools + +## Updates + +**Last Updated**: 2026-02-03 + +For the latest security updates and announcements, check: +- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories +- Release Notes: https://github.com/HKUDS/nanobot/releases + +## License + +See LICENSE file for details. diff --git a/bridge/package.json b/bridge/package.json index e29fed8..e91517c 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@whiskeysockets/baileys": "7.0.0-rc.9", - "ws": "^8.17.0", + "ws": "^8.17.1", "qrcode-terminal": "^0.12.0", "pino": "^9.0.0" }, diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index a3a82fc..069d72b 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -20,6 +20,7 @@ const VERSION = '0.1.0'; export interface InboundMessage { id: string; sender: string; + pn: string; content: string; timestamp: number; isGroup: boolean; @@ -123,6 +124,7 @@ export class WhatsAppClient { this.options.onMessage({ id: msg.key.id || '', sender: msg.key.remoteJid || '', + pn: msg.key.remoteJidAlt || '', content, timestamp: msg.messageTimestamp as number, isGroup, diff --git a/core_agent_lines.sh b/core_agent_lines.sh new file mode 100755 index 0000000..3f5301a --- /dev/null +++ b/core_agent_lines.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Count core agent lines (excluding channels/, cli/, providers/ adapters) +cd "$(dirname "$0")" || exit 1 + +echo "nanobot core agent line count" +echo "================================" +echo "" + +for dir in agent agent/tools bus config cron heartbeat session utils; do + count=$(find "nanobot/$dir" -maxdepth 1 -name "*.py" -exec cat {} + | wc -l) + printf " %-16s %5s lines\n" "$dir/" "$count" +done + +root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l) +printf " %-16s %5s lines\n" "(root)" "$root" + +echo "" +total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" | xargs cat | wc -l) +echo " Core total: $total lines" +echo "" +echo " (excludes: channels/, cli/, providers/)" diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index f70103d..3ea6c04 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -2,6 +2,7 @@ import base64 import mimetypes +import platform from pathlib import Path from typing import Any @@ -74,6 +75,8 @@ Skills with available="false" need dependencies installed first - you can try in from datetime import datetime now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") workspace_path = str(self.workspace.expanduser().resolve()) + system = platform.system() + runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" return f"""# nanobot ๐Ÿˆ @@ -87,6 +90,9 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you ## Current Time {now} +## Runtime +{runtime} + ## Workspace Your workspace is at: {workspace_path} - Memory files: {workspace_path}/memory/MEMORY.md @@ -118,6 +124,8 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md""" current_message: str, skill_names: list[str] | None = None, media: list[str] | None = None, + channel: str | None = None, + chat_id: str | None = None, ) -> list[dict[str, Any]]: """ Build the complete message list for an LLM call. @@ -127,6 +135,8 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md""" current_message: The new user message. skill_names: Optional skills to include. media: Optional list of local file paths for images/media. + channel: Current channel (telegram, feishu, etc.). + chat_id: Current chat/user ID. Returns: List of messages including system prompt. @@ -135,6 +145,8 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md""" # System prompt system_prompt = self.build_system_prompt(skill_names) + if channel and chat_id: + system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}" messages.append({"role": "system", "content": system_prompt}) # History diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index bfe6e89..a65f3a5 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -17,6 +17,7 @@ from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.web import WebSearchTool, WebFetchTool from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.spawn import SpawnTool +from nanobot.agent.tools.cron import CronTool from nanobot.agent.subagent import SubagentManager from nanobot.session.manager import SessionManager @@ -42,8 +43,12 @@ class AgentLoop: max_iterations: int = 20, brave_api_key: str | None = None, exec_config: "ExecToolConfig | None" = None, + cron_service: "CronService | None" = None, + restrict_to_workspace: bool = False, + session_manager: SessionManager | None = None, ): from nanobot.config.schema import ExecToolConfig + from nanobot.cron.service import CronService self.bus = bus self.provider = provider self.workspace = workspace @@ -51,9 +56,11 @@ class AgentLoop: self.max_iterations = max_iterations self.brave_api_key = brave_api_key self.exec_config = exec_config or ExecToolConfig() + self.cron_service = cron_service + self.restrict_to_workspace = restrict_to_workspace self.context = ContextBuilder(workspace) - self.sessions = SessionManager(workspace) + self.sessions = session_manager or SessionManager(workspace) self.tools = ToolRegistry() self.subagents = SubagentManager( provider=provider, @@ -62,6 +69,7 @@ class AgentLoop: model=self.model, brave_api_key=brave_api_key, exec_config=self.exec_config, + restrict_to_workspace=restrict_to_workspace, ) self._running = False @@ -69,17 +77,18 @@ class AgentLoop: def _register_default_tools(self) -> None: """Register the default set of tools.""" - # File tools - self.tools.register(ReadFileTool()) - self.tools.register(WriteFileTool()) - self.tools.register(EditFileTool()) - self.tools.register(ListDirTool()) + # File tools (restrict to workspace if configured) + allowed_dir = self.workspace if self.restrict_to_workspace else None + self.tools.register(ReadFileTool(allowed_dir=allowed_dir)) + self.tools.register(WriteFileTool(allowed_dir=allowed_dir)) + self.tools.register(EditFileTool(allowed_dir=allowed_dir)) + self.tools.register(ListDirTool(allowed_dir=allowed_dir)) # Shell tool self.tools.register(ExecTool( working_dir=str(self.workspace), timeout=self.exec_config.timeout, - restrict_to_workspace=self.exec_config.restrict_to_workspace, + restrict_to_workspace=self.restrict_to_workspace, )) # Web tools @@ -93,6 +102,10 @@ class AgentLoop: # Spawn tool (for subagents) spawn_tool = SpawnTool(manager=self.subagents) self.tools.register(spawn_tool) + + # Cron tool (for scheduling) + if self.cron_service: + self.tools.register(CronTool(self.cron_service)) async def run(self) -> None: """Run the agent loop, processing messages from the bus.""" @@ -143,7 +156,8 @@ class AgentLoop: if msg.channel == "system": return await self._process_system_message(msg) - logger.info(f"Processing message from {msg.channel}:{msg.sender_id}") + preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content + logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}") # Get or create session session = self.sessions.get_or_create(msg.session_key) @@ -157,11 +171,17 @@ class AgentLoop: if isinstance(spawn_tool, SpawnTool): spawn_tool.set_context(msg.channel, msg.chat_id) + cron_tool = self.tools.get("cron") + if isinstance(cron_tool, CronTool): + cron_tool.set_context(msg.channel, msg.chat_id) + # Build initial messages (use get_history for LLM-formatted messages) messages = self.context.build_messages( history=session.get_history(), current_message=msg.content, media=msg.media if msg.media else None, + channel=msg.channel, + chat_id=msg.chat_id, ) # Agent loop @@ -198,8 +218,8 @@ class AgentLoop: # Execute tools for tool_call in response.tool_calls: - args_str = json.dumps(tool_call.arguments) - logger.debug(f"Executing tool: {tool_call.name} with arguments: {args_str}") + args_str = json.dumps(tool_call.arguments, ensure_ascii=False) + logger.info(f"Tool call: {tool_call.name}({args_str[:200]})") result = await self.tools.execute(tool_call.name, tool_call.arguments) messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result @@ -212,6 +232,10 @@ class AgentLoop: if final_content is None: final_content = "I've completed processing but have no response to give." + # Log response preview + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content + logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}") + # Save to session session.add_message("user", msg.content) session.add_message("assistant", final_content) @@ -255,10 +279,16 @@ class AgentLoop: if isinstance(spawn_tool, SpawnTool): spawn_tool.set_context(origin_channel, origin_chat_id) + cron_tool = self.tools.get("cron") + if isinstance(cron_tool, CronTool): + cron_tool.set_context(origin_channel, origin_chat_id) + # Build messages with the announce content messages = self.context.build_messages( history=session.get_history(), - current_message=msg.content + current_message=msg.content, + channel=origin_channel, + chat_id=origin_chat_id, ) # Agent loop (limited for announce handling) @@ -291,8 +321,8 @@ class AgentLoop: ) for tool_call in response.tool_calls: - args_str = json.dumps(tool_call.arguments) - logger.debug(f"Executing tool: {tool_call.name} with arguments: {args_str}") + args_str = json.dumps(tool_call.arguments, ensure_ascii=False) + logger.info(f"Tool call: {tool_call.name}({args_str[:200]})") result = await self.tools.execute(tool_call.name, tool_call.arguments) messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result @@ -315,21 +345,29 @@ class AgentLoop: content=final_content ) - async def process_direct(self, content: str, session_key: str = "cli:direct") -> str: + async def process_direct( + self, + content: str, + session_key: str = "cli:direct", + channel: str = "cli", + chat_id: str = "direct", + ) -> str: """ - Process a message directly (for CLI usage). + Process a message directly (for CLI or cron usage). Args: content: The message content. session_key: Session identifier. + channel: Source channel (for context). + chat_id: Source chat ID (for context). Returns: The agent's response. """ msg = InboundMessage( - channel="cli", + channel=channel, sender_id="user", - chat_id="direct", + chat_id=chat_id, content=content ) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 05ffbb8..6113efb 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -34,6 +34,7 @@ class SubagentManager: model: str | None = None, brave_api_key: str | None = None, exec_config: "ExecToolConfig | None" = None, + restrict_to_workspace: bool = False, ): from nanobot.config.schema import ExecToolConfig self.provider = provider @@ -42,6 +43,7 @@ class SubagentManager: self.model = model or provider.get_default_model() self.brave_api_key = brave_api_key self.exec_config = exec_config or ExecToolConfig() + self.restrict_to_workspace = restrict_to_workspace self._running_tasks: dict[str, asyncio.Task[None]] = {} async def spawn( @@ -96,13 +98,14 @@ class SubagentManager: try: # Build subagent tools (no message tool, no spawn tool) tools = ToolRegistry() - tools.register(ReadFileTool()) - tools.register(WriteFileTool()) - tools.register(ListDirTool()) + allowed_dir = self.workspace if self.restrict_to_workspace else None + tools.register(ReadFileTool(allowed_dir=allowed_dir)) + tools.register(WriteFileTool(allowed_dir=allowed_dir)) + tools.register(ListDirTool(allowed_dir=allowed_dir)) tools.register(ExecTool( working_dir=str(self.workspace), timeout=self.exec_config.timeout, - restrict_to_workspace=self.exec_config.restrict_to_workspace, + restrict_to_workspace=self.restrict_to_workspace, )) tools.register(WebSearchTool(api_key=self.brave_api_key)) tools.register(WebFetchTool()) @@ -149,7 +152,8 @@ class SubagentManager: # Execute tools for tool_call in response.tool_calls: - logger.debug(f"Subagent [{task_id}] executing: {tool_call.name}") + args_str = json.dumps(tool_call.arguments) + logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}") result = await tools.execute(tool_call.name, tool_call.arguments) messages.append({ "role": "tool", diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py new file mode 100644 index 0000000..ec0d2cd --- /dev/null +++ b/nanobot/agent/tools/cron.py @@ -0,0 +1,114 @@ +"""Cron tool for scheduling reminders and tasks.""" + +from typing import Any + +from nanobot.agent.tools.base import Tool +from nanobot.cron.service import CronService +from nanobot.cron.types import CronSchedule + + +class CronTool(Tool): + """Tool to schedule reminders and recurring tasks.""" + + def __init__(self, cron_service: CronService): + self._cron = cron_service + self._channel = "" + self._chat_id = "" + + def set_context(self, channel: str, chat_id: str) -> None: + """Set the current session context for delivery.""" + self._channel = channel + self._chat_id = chat_id + + @property + def name(self) -> str: + return "cron" + + @property + def description(self) -> str: + return "Schedule reminders and recurring tasks. Actions: add, list, remove." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["add", "list", "remove"], + "description": "Action to perform" + }, + "message": { + "type": "string", + "description": "Reminder message (for add)" + }, + "every_seconds": { + "type": "integer", + "description": "Interval in seconds (for recurring tasks)" + }, + "cron_expr": { + "type": "string", + "description": "Cron expression like '0 9 * * *' (for scheduled tasks)" + }, + "job_id": { + "type": "string", + "description": "Job ID (for remove)" + } + }, + "required": ["action"] + } + + async def execute( + self, + action: str, + message: str = "", + every_seconds: int | None = None, + cron_expr: str | None = None, + job_id: str | None = None, + **kwargs: Any + ) -> str: + if action == "add": + return self._add_job(message, every_seconds, cron_expr) + elif action == "list": + return self._list_jobs() + elif action == "remove": + return self._remove_job(job_id) + return f"Unknown action: {action}" + + def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None) -> str: + if not message: + return "Error: message is required for add" + if not self._channel or not self._chat_id: + return "Error: no session context (channel/chat_id)" + + # Build schedule + if every_seconds: + schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000) + elif cron_expr: + schedule = CronSchedule(kind="cron", expr=cron_expr) + else: + return "Error: either every_seconds or cron_expr is required" + + job = self._cron.add_job( + name=message[:30], + schedule=schedule, + message=message, + deliver=True, + channel=self._channel, + to=self._chat_id, + ) + return f"Created job '{job.name}' (id: {job.id})" + + def _list_jobs(self) -> str: + jobs = self._cron.list_jobs() + if not jobs: + return "No scheduled jobs." + lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs] + return "Scheduled jobs:\n" + "\n".join(lines) + + def _remove_job(self, job_id: str | None) -> str: + if not job_id: + return "Error: job_id is required for remove" + if self._cron.remove_job(job_id): + return f"Removed job {job_id}" + return f"Job {job_id} not found" diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index e141fab..6b3254a 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -6,9 +6,20 @@ from typing import Any from nanobot.agent.tools.base import Tool +def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path: + """Resolve path and optionally enforce directory restriction.""" + resolved = Path(path).expanduser().resolve() + if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())): + raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") + return resolved + + class ReadFileTool(Tool): """Tool to read file contents.""" + def __init__(self, allowed_dir: Path | None = None): + self._allowed_dir = allowed_dir + @property def name(self) -> str: return "read_file" @@ -32,7 +43,7 @@ class ReadFileTool(Tool): async def execute(self, path: str, **kwargs: Any) -> str: try: - file_path = Path(path).expanduser() + file_path = _resolve_path(path, self._allowed_dir) if not file_path.exists(): return f"Error: File not found: {path}" if not file_path.is_file(): @@ -40,8 +51,8 @@ class ReadFileTool(Tool): content = file_path.read_text(encoding="utf-8") return content - except PermissionError: - return f"Error: Permission denied: {path}" + except PermissionError as e: + return f"Error: {e}" except Exception as e: return f"Error reading file: {str(e)}" @@ -49,6 +60,9 @@ class ReadFileTool(Tool): class WriteFileTool(Tool): """Tool to write content to a file.""" + def __init__(self, allowed_dir: Path | None = None): + self._allowed_dir = allowed_dir + @property def name(self) -> str: return "write_file" @@ -76,12 +90,12 @@ class WriteFileTool(Tool): async def execute(self, path: str, content: str, **kwargs: Any) -> str: try: - file_path = Path(path).expanduser() + file_path = _resolve_path(path, self._allowed_dir) file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(content, encoding="utf-8") return f"Successfully wrote {len(content)} bytes to {path}" - except PermissionError: - return f"Error: Permission denied: {path}" + except PermissionError as e: + return f"Error: {e}" except Exception as e: return f"Error writing file: {str(e)}" @@ -89,6 +103,9 @@ class WriteFileTool(Tool): class EditFileTool(Tool): """Tool to edit a file by replacing text.""" + def __init__(self, allowed_dir: Path | None = None): + self._allowed_dir = allowed_dir + @property def name(self) -> str: return "edit_file" @@ -120,7 +137,7 @@ class EditFileTool(Tool): async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str: try: - file_path = Path(path).expanduser() + file_path = _resolve_path(path, self._allowed_dir) if not file_path.exists(): return f"Error: File not found: {path}" @@ -138,8 +155,8 @@ class EditFileTool(Tool): file_path.write_text(new_content, encoding="utf-8") return f"Successfully edited {path}" - except PermissionError: - return f"Error: Permission denied: {path}" + except PermissionError as e: + return f"Error: {e}" except Exception as e: return f"Error editing file: {str(e)}" @@ -147,6 +164,9 @@ class EditFileTool(Tool): class ListDirTool(Tool): """Tool to list directory contents.""" + def __init__(self, allowed_dir: Path | None = None): + self._allowed_dir = allowed_dir + @property def name(self) -> str: return "list_dir" @@ -170,7 +190,7 @@ class ListDirTool(Tool): async def execute(self, path: str, **kwargs: Any) -> str: try: - dir_path = Path(path).expanduser() + dir_path = _resolve_path(path, self._allowed_dir) if not dir_path.exists(): return f"Error: Directory not found: {path}" if not dir_path.is_dir(): @@ -185,7 +205,7 @@ class ListDirTool(Tool): return f"Directory {path} is empty" return "\n".join(items) - except PermissionError: - return f"Error: Permission denied: {path}" + except PermissionError as e: + return f"Error: {e}" except Exception as e: return f"Error listing directory: {str(e)}" diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 8f16399..30fcd1a 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -3,6 +3,8 @@ from abc import ABC, abstractmethod from typing import Any +from loguru import logger + from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.queue import MessageBus @@ -102,6 +104,10 @@ class BaseChannel(ABC): metadata: Optional channel-specific metadata. """ if not self.is_allowed(sender_id): + logger.warning( + f"Access denied for sender {sender_id} on channel {self.name}. " + f"Add them to allowFrom list in config to grant access." + ) return msg = InboundMessage( diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py new file mode 100644 index 0000000..a76d6ac --- /dev/null +++ b/nanobot/channels/discord.py @@ -0,0 +1,261 @@ +"""Discord channel implementation using Discord Gateway websocket.""" + +import asyncio +import json +from pathlib import Path +from typing import Any + +import httpx +import websockets +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import DiscordConfig + + +DISCORD_API_BASE = "https://discord.com/api/v10" +MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB + + +class DiscordChannel(BaseChannel): + """Discord channel using Gateway websocket.""" + + name = "discord" + + def __init__(self, config: DiscordConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: DiscordConfig = config + self._ws: websockets.WebSocketClientProtocol | None = None + self._seq: int | None = None + self._heartbeat_task: asyncio.Task | None = None + self._typing_tasks: dict[str, asyncio.Task] = {} + self._http: httpx.AsyncClient | None = None + + async def start(self) -> None: + """Start the Discord gateway connection.""" + if not self.config.token: + logger.error("Discord bot token not configured") + return + + self._running = True + self._http = httpx.AsyncClient(timeout=30.0) + + while self._running: + try: + logger.info("Connecting to Discord gateway...") + async with websockets.connect(self.config.gateway_url) as ws: + self._ws = ws + await self._gateway_loop() + except asyncio.CancelledError: + break + except Exception as e: + logger.warning(f"Discord gateway error: {e}") + if self._running: + logger.info("Reconnecting to Discord gateway in 5 seconds...") + await asyncio.sleep(5) + + async def stop(self) -> None: + """Stop the Discord channel.""" + self._running = False + if self._heartbeat_task: + self._heartbeat_task.cancel() + self._heartbeat_task = None + for task in self._typing_tasks.values(): + task.cancel() + self._typing_tasks.clear() + if self._ws: + await self._ws.close() + self._ws = None + if self._http: + await self._http.aclose() + self._http = None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through Discord REST API.""" + if not self._http: + logger.warning("Discord HTTP client not initialized") + return + + url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages" + payload: dict[str, Any] = {"content": msg.content} + + if msg.reply_to: + payload["message_reference"] = {"message_id": msg.reply_to} + payload["allowed_mentions"] = {"replied_user": False} + + headers = {"Authorization": f"Bot {self.config.token}"} + + try: + for attempt in range(3): + try: + response = await self._http.post(url, headers=headers, json=payload) + if response.status_code == 429: + data = response.json() + retry_after = float(data.get("retry_after", 1.0)) + logger.warning(f"Discord rate limited, retrying in {retry_after}s") + await asyncio.sleep(retry_after) + continue + response.raise_for_status() + return + except Exception as e: + if attempt == 2: + logger.error(f"Error sending Discord message: {e}") + else: + await asyncio.sleep(1) + finally: + await self._stop_typing(msg.chat_id) + + async def _gateway_loop(self) -> None: + """Main gateway loop: identify, heartbeat, dispatch events.""" + if not self._ws: + return + + async for raw in self._ws: + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning(f"Invalid JSON from Discord gateway: {raw[:100]}") + continue + + op = data.get("op") + event_type = data.get("t") + seq = data.get("s") + payload = data.get("d") + + if seq is not None: + self._seq = seq + + if op == 10: + # HELLO: start heartbeat and identify + interval_ms = payload.get("heartbeat_interval", 45000) + await self._start_heartbeat(interval_ms / 1000) + await self._identify() + elif op == 0 and event_type == "READY": + logger.info("Discord gateway READY") + elif op == 0 and event_type == "MESSAGE_CREATE": + await self._handle_message_create(payload) + elif op == 7: + # RECONNECT: exit loop to reconnect + logger.info("Discord gateway requested reconnect") + break + elif op == 9: + # INVALID_SESSION: reconnect + logger.warning("Discord gateway invalid session") + break + + async def _identify(self) -> None: + """Send IDENTIFY payload.""" + if not self._ws: + return + + identify = { + "op": 2, + "d": { + "token": self.config.token, + "intents": self.config.intents, + "properties": { + "os": "nanobot", + "browser": "nanobot", + "device": "nanobot", + }, + }, + } + await self._ws.send(json.dumps(identify)) + + async def _start_heartbeat(self, interval_s: float) -> None: + """Start or restart the heartbeat loop.""" + if self._heartbeat_task: + self._heartbeat_task.cancel() + + async def heartbeat_loop() -> None: + while self._running and self._ws: + payload = {"op": 1, "d": self._seq} + try: + await self._ws.send(json.dumps(payload)) + except Exception as e: + logger.warning(f"Discord heartbeat failed: {e}") + break + await asyncio.sleep(interval_s) + + self._heartbeat_task = asyncio.create_task(heartbeat_loop()) + + async def _handle_message_create(self, payload: dict[str, Any]) -> None: + """Handle incoming Discord messages.""" + author = payload.get("author") or {} + if author.get("bot"): + return + + sender_id = str(author.get("id", "")) + channel_id = str(payload.get("channel_id", "")) + content = payload.get("content") or "" + + if not sender_id or not channel_id: + return + + if not self.is_allowed(sender_id): + return + + content_parts = [content] if content else [] + media_paths: list[str] = [] + media_dir = Path.home() / ".nanobot" / "media" + + for attachment in payload.get("attachments") or []: + url = attachment.get("url") + filename = attachment.get("filename") or "attachment" + size = attachment.get("size") or 0 + if not url or not self._http: + continue + if size and size > MAX_ATTACHMENT_BYTES: + content_parts.append(f"[attachment: {filename} - too large]") + continue + try: + media_dir.mkdir(parents=True, exist_ok=True) + file_path = media_dir / f"{attachment.get('id', 'file')}_{filename.replace('/', '_')}" + resp = await self._http.get(url) + resp.raise_for_status() + file_path.write_bytes(resp.content) + media_paths.append(str(file_path)) + content_parts.append(f"[attachment: {file_path}]") + except Exception as e: + logger.warning(f"Failed to download Discord attachment: {e}") + content_parts.append(f"[attachment: {filename} - download failed]") + + reply_to = (payload.get("referenced_message") or {}).get("id") + + await self._start_typing(channel_id) + + await self._handle_message( + sender_id=sender_id, + chat_id=channel_id, + content="\n".join(p for p in content_parts if p) or "[empty message]", + media=media_paths, + metadata={ + "message_id": str(payload.get("id", "")), + "guild_id": payload.get("guild_id"), + "reply_to": reply_to, + }, + ) + + async def _start_typing(self, channel_id: str) -> None: + """Start periodic typing indicator for a channel.""" + await self._stop_typing(channel_id) + + async def typing_loop() -> None: + url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing" + headers = {"Authorization": f"Bot {self.config.token}"} + while self._running: + try: + await self._http.post(url, headers=headers) + except Exception: + pass + await asyncio.sleep(8) + + self._typing_tasks[channel_id] = asyncio.create_task(typing_loop()) + + async def _stop_typing(self, channel_id: str) -> None: + """Stop typing indicator for a channel.""" + task = self._typing_tasks.pop(channel_id, None) + if task: + task.cancel() diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 01b808e..1c176a2 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -2,6 +2,7 @@ import asyncio import json +import re import threading from collections import OrderedDict from typing import Any @@ -156,6 +157,44 @@ class FeishuChannel(BaseChannel): loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type) + # Regex to match markdown tables (header + separator + data rows) + _TABLE_RE = re.compile( + r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)", + re.MULTILINE, + ) + + @staticmethod + def _parse_md_table(table_text: str) -> dict | None: + """Parse a markdown table into a Feishu table element.""" + lines = [l.strip() for l in table_text.strip().split("\n") if l.strip()] + if len(lines) < 3: + return None + split = lambda l: [c.strip() for c in l.strip("|").split("|")] + headers = split(lines[0]) + rows = [split(l) for l in lines[2:]] + columns = [{"tag": "column", "name": f"c{i}", "display_name": h, "width": "auto"} + for i, h in enumerate(headers)] + return { + "tag": "table", + "page_size": len(rows) + 1, + "columns": columns, + "rows": [{f"c{i}": r[i] if i < len(r) else "" for i in range(len(headers))} for r in rows], + } + + def _build_card_elements(self, content: str) -> list[dict]: + """Split content into markdown + table elements for Feishu card.""" + elements, last_end = [], 0 + for m in self._TABLE_RE.finditer(content): + before = content[last_end:m.start()].strip() + if before: + elements.append({"tag": "markdown", "content": before}) + elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}) + last_end = m.end() + remaining = content[last_end:].strip() + if remaining: + elements.append({"tag": "markdown", "content": remaining}) + return elements or [{"tag": "markdown", "content": content}] + async def send(self, msg: OutboundMessage) -> None: """Send a message through Feishu.""" if not self._client: @@ -170,15 +209,20 @@ class FeishuChannel(BaseChannel): else: receive_id_type = "open_id" - # Build text message content - content = json.dumps({"text": msg.content}) + # Build card with markdown + table support + elements = self._build_card_elements(msg.content) + card = { + "config": {"wide_screen_mode": True}, + "elements": elements, + } + content = json.dumps(card, ensure_ascii=False) request = CreateMessageRequest.builder() \ .receive_id_type(receive_id_type) \ .request_body( CreateMessageRequestBody.builder() .receive_id(msg.chat_id) - .msg_type("text") + .msg_type("interactive") .content(content) .build() ).build() diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 979d01e..efb7db0 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -1,7 +1,9 @@ """Channel manager for coordinating chat channels.""" +from __future__ import annotations + import asyncio -from typing import Any +from typing import Any, TYPE_CHECKING from loguru import logger @@ -10,6 +12,9 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import Config +if TYPE_CHECKING: + from nanobot.session.manager import SessionManager + class ChannelManager: """ @@ -21,9 +26,10 @@ class ChannelManager: - Route outbound messages """ - def __init__(self, config: Config, bus: MessageBus): + def __init__(self, config: Config, bus: MessageBus, session_manager: "SessionManager | None" = None): self.config = config self.bus = bus + self.session_manager = session_manager self.channels: dict[str, BaseChannel] = {} self._dispatch_task: asyncio.Task | None = None @@ -40,6 +46,7 @@ class ChannelManager: self.config.channels.telegram, self.bus, groq_api_key=self.config.providers.groq.api_key, + session_manager=self.session_manager, ) logger.info("Telegram channel enabled") except ImportError as e: @@ -55,6 +62,17 @@ class ChannelManager: logger.info("WhatsApp channel enabled") except ImportError as e: logger.warning(f"WhatsApp channel not available: {e}") + + # Discord channel + if self.config.channels.discord.enabled: + try: + from nanobot.channels.discord import DiscordChannel + self.channels["discord"] = DiscordChannel( + self.config.channels.discord, self.bus + ) + logger.info("Discord channel enabled") + except ImportError as e: + logger.warning(f"Discord channel not available: {e}") # Feishu channel if self.config.channels.feishu.enabled: @@ -67,8 +85,15 @@ class ChannelManager: except ImportError as e: logger.warning(f"Feishu channel not available: {e}") + async def _start_channel(self, name: str, channel: BaseChannel) -> None: + """Start a channel and log any exceptions.""" + try: + await channel.start() + except Exception as e: + logger.error(f"Failed to start channel {name}: {e}") + async def start_all(self) -> None: - """Start WhatsApp channel and the outbound dispatcher.""" + """Start all channels and the outbound dispatcher.""" if not self.channels: logger.warning("No channels enabled") return @@ -76,11 +101,11 @@ class ChannelManager: # Start outbound dispatcher self._dispatch_task = asyncio.create_task(self._dispatch_outbound()) - # Start WhatsApp channel + # Start channels tasks = [] for name, channel in self.channels.items(): logger.info(f"Starting {name} channel...") - tasks.append(asyncio.create_task(channel.start())) + tasks.append(asyncio.create_task(self._start_channel(name, channel))) # Wait for all to complete (they should run forever) await asyncio.gather(*tasks, return_exceptions=True) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 23e1de0..4f62557 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -1,17 +1,23 @@ """Telegram channel implementation using python-telegram-bot.""" +from __future__ import annotations + import asyncio import re +from typing import TYPE_CHECKING from loguru import logger -from telegram import Update -from telegram.ext import Application, MessageHandler, filters, ContextTypes +from telegram import BotCommand, Update +from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import TelegramConfig +if TYPE_CHECKING: + from nanobot.session.manager import SessionManager + def _markdown_to_telegram_html(text: str) -> str: """ @@ -85,10 +91,24 @@ class TelegramChannel(BaseChannel): name = "telegram" - def __init__(self, config: TelegramConfig, bus: MessageBus, groq_api_key: str = ""): + # Commands registered with Telegram's command menu + BOT_COMMANDS = [ + BotCommand("start", "Start the bot"), + BotCommand("reset", "Reset conversation history"), + BotCommand("help", "Show available commands"), + ] + + def __init__( + self, + config: TelegramConfig, + bus: MessageBus, + groq_api_key: str = "", + session_manager: SessionManager | None = None, + ): super().__init__(config, bus) self.config: TelegramConfig = config self.groq_api_key = groq_api_key + self.session_manager = session_manager self._app: Application | None = None self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies @@ -101,11 +121,15 @@ class TelegramChannel(BaseChannel): self._running = True # Build the application - self._app = ( - Application.builder() - .token(self.config.token) - .build() - ) + builder = Application.builder().token(self.config.token) + if self.config.proxy: + builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) + self._app = builder.build() + + # Add command handlers + self._app.add_handler(CommandHandler("start", self._on_start)) + self._app.add_handler(CommandHandler("reset", self._on_reset)) + self._app.add_handler(CommandHandler("help", self._on_help)) # Add message handler for text, photos, voice, documents self._app.add_handler( @@ -116,20 +140,22 @@ class TelegramChannel(BaseChannel): ) ) - # Add /start command handler - from telegram.ext import CommandHandler - self._app.add_handler(CommandHandler("start", self._on_start)) - logger.info("Starting Telegram bot (polling mode)...") # Initialize and start polling await self._app.initialize() await self._app.start() - # Get bot info + # Get bot info and register command menu bot_info = await self._app.bot.get_me() logger.info(f"Telegram bot @{bot_info.username} connected") + try: + await self._app.bot.set_my_commands(self.BOT_COMMANDS) + logger.debug("Telegram bot commands registered") + except Exception as e: + logger.warning(f"Failed to register bot commands: {e}") + # Start polling (this runs until stopped) await self._app.updater.start_polling( allowed_updates=["message"], @@ -188,9 +214,45 @@ class TelegramChannel(BaseChannel): user = update.effective_user await update.message.reply_text( f"๐Ÿ‘‹ Hi {user.first_name}! I'm nanobot.\n\n" - "Send me a message and I'll respond!" + "Send me a message and I'll respond!\n" + "Type /help to see available commands." ) + async def _on_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /reset command โ€” clear conversation history.""" + if not update.message or not update.effective_user: + return + + chat_id = str(update.message.chat_id) + session_key = f"{self.name}:{chat_id}" + + if self.session_manager is None: + logger.warning("/reset called but session_manager is not available") + await update.message.reply_text("โš ๏ธ Session management is not available.") + return + + session = self.session_manager.get_or_create(session_key) + msg_count = len(session.messages) + session.clear() + self.session_manager.save(session) + + logger.info(f"Session reset for {session_key} (cleared {msg_count} messages)") + await update.message.reply_text("๐Ÿ”„ Conversation history cleared. Let's start fresh!") + + async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /help command โ€” show available commands.""" + if not update.message: + return + + help_text = ( + "๐Ÿˆ nanobot commands\n\n" + "/start โ€” Start the bot\n" + "/reset โ€” Reset conversation history\n" + "/help โ€” Show this help message\n\n" + "Just send me a text message to chat!" + ) + await update.message.reply_text(help_text, parse_mode="HTML") + async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming messages (text, photos, voice, documents).""" if not update.message or not update.effective_user: diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index c14a6c3..6e00e9d 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -100,21 +100,25 @@ class WhatsAppChannel(BaseChannel): if msg_type == "message": # Incoming message from WhatsApp + # Deprecated by whatsapp: old phone number style typically: @s.whatspp.net + pn = data.get("pn", "") + # New LID sytle typically: sender = data.get("sender", "") content = data.get("content", "") - # sender is typically: @s.whatsapp.net - # Extract just the phone number as chat_id - chat_id = sender.split("@")[0] if "@" in sender else sender + # Extract just the phone number or lid as chat_id + user_id = pn if pn else sender + sender_id = user_id.split("@")[0] if "@" in user_id else user_id + logger.info(f"Sender {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.") + logger.info(f"Voice message received from {sender_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 + sender_id=sender_id, + chat_id=sender, # Use full LID for replies content=content, metadata={ "message_id": data.get("id"), diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 727c451..d8b72bd 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -172,6 +172,33 @@ This file stores important information that should persist across sessions. console.print(" [dim]Created memory/MEMORY.md[/dim]") +def _make_provider(config): + """Create LiteLLMProvider from config. Exits if no API key found.""" + from nanobot.providers.litellm_provider import LiteLLMProvider + from nanobot.providers.openai_codex_provider import OpenAICodexProvider + from oauth_cli_kit import get_token as get_codex_token + + p = config.get_provider() + model = config.agents.defaults.model + if model.startswith("openai-codex/"): + try: + _ = get_codex_token() + except Exception: + console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]") + raise typer.Exit(1) + return OpenAICodexProvider(default_model=model) + if not (p and p.api_key) and not model.startswith("bedrock/"): + console.print("[red]Error: No API key configured.[/red]") + console.print("Set one in ~/.nanobot/config.json under providers section") + raise typer.Exit(1) + return LiteLLMProvider( + api_key=p.api_key if p else None, + api_base=config.get_api_base(), + default_model=model, + extra_headers=p.extra_headers if p else None, + ) + + # ============================================================================ # Gateway / Server # ============================================================================ @@ -185,11 +212,9 @@ def gateway( """Start the nanobot gateway.""" from nanobot.config.loader import load_config, get_data_dir from nanobot.bus.queue import MessageBus - from nanobot.providers.litellm_provider import LiteLLMProvider - from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from oauth_cli_kit import get_token as get_codex_token from nanobot.agent.loop import AgentLoop from nanobot.channels.manager import ChannelManager + from nanobot.session.manager import SessionManager from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -201,37 +226,15 @@ def gateway( console.print(f"{__logo__} Starting nanobot gateway on port {port}...") config = load_config() - - # Create components bus = MessageBus() + provider = _make_provider(config) + session_manager = SessionManager(config.workspace_path) - # Create provider (supports OpenRouter, Anthropic, OpenAI, Bedrock) - api_key = config.get_api_key() - api_base = config.get_api_base() - model = config.agents.defaults.model - is_bedrock = model.startswith("bedrock/") - is_codex = model.startswith("openai-codex/") - - if is_codex: - try: - _ = get_codex_token() - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]") - raise typer.Exit(1) - provider = OpenAICodexProvider(default_model=model) - else: - 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) - provider = LiteLLMProvider( - api_key=api_key, - api_base=api_base, - default_model=config.agents.defaults.model - ) + # Create cron service first (callback set after agent creation) + cron_store_path = get_data_dir() / "cron" / "jobs.json" + cron = CronService(cron_store_path) - # Create agent + # Create agent with cron service agent = AgentLoop( bus=bus, provider=provider, @@ -240,27 +243,29 @@ def gateway( max_iterations=config.agents.defaults.max_tool_iterations, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, + cron_service=cron, + restrict_to_workspace=config.tools.restrict_to_workspace, + session_manager=session_manager, ) - # Create cron service + # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" response = await agent.process_direct( job.payload.message, - session_key=f"cron:{job.id}" + session_key=f"cron:{job.id}", + channel=job.payload.channel or "cli", + chat_id=job.payload.to or "direct", ) - # Optionally deliver to channel if job.payload.deliver and job.payload.to: from nanobot.bus.events import OutboundMessage await bus.publish_outbound(OutboundMessage( - channel=job.payload.channel or "whatsapp", + channel=job.payload.channel or "cli", chat_id=job.payload.to, content=response or "" )) return response - - cron_store_path = get_data_dir() / "cron" / "jobs.json" - cron = CronService(cron_store_path, on_job=on_cron_job) + cron.on_job = on_cron_job # Create heartbeat service async def on_heartbeat(prompt: str) -> str: @@ -275,7 +280,7 @@ def gateway( ) # Create channel manager - channels = ChannelManager(config, bus) + channels = ChannelManager(config, bus, session_manager=session_manager) if channels.enabled_channels: console.print(f"[green]โœ“[/green] Channels enabled: {', '.join(channels.enabled_channels)}") @@ -321,40 +326,12 @@ def agent( """Interact with the agent directly.""" from nanobot.config.loader import load_config from nanobot.bus.queue import MessageBus - from nanobot.providers.litellm_provider import LiteLLMProvider - from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from oauth_cli_kit import get_token as get_codex_token from nanobot.agent.loop import AgentLoop config = load_config() - api_key = config.get_api_key() - api_base = config.get_api_base() - model = config.agents.defaults.model - is_bedrock = model.startswith("bedrock/") - is_codex = model.startswith("openai-codex/") - - if is_codex: - try: - _ = get_codex_token() - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]") - raise typer.Exit(1) - else: - if not api_key and not is_bedrock: - console.print("[red]Error: No API key configured.[/red]") - raise typer.Exit(1) - bus = MessageBus() - if is_codex: - provider = OpenAICodexProvider(default_model=config.agents.defaults.model) - else: - provider = LiteLLMProvider( - api_key=api_key, - api_base=api_base, - default_model=config.agents.defaults.model - ) + provider = _make_provider(config) agent_loop = AgentLoop( bus=bus, @@ -362,6 +339,7 @@ def agent( workspace=config.workspace_path, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, + restrict_to_workspace=config.tools.restrict_to_workspace, ) if message: @@ -420,6 +398,13 @@ def channels_status(): wa.bridge_url ) + dc = config.channels.discord + table.add_row( + "Discord", + "โœ“" if dc.enabled else "โœ—", + dc.gateway_url + ) + # Telegram tg = config.channels.telegram tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]" @@ -693,13 +678,17 @@ def status(): has_anthropic = bool(config.providers.anthropic.api_key) has_openai = bool(config.providers.openai.api_key) has_gemini = bool(config.providers.gemini.api_key) + has_zhipu = bool(config.providers.zhipu.api_key) has_vllm = bool(config.providers.vllm.api_base) + has_aihubmix = bool(config.providers.aihubmix.api_key) console.print(f"OpenRouter API: {'[green]โœ“[/green]' if has_openrouter else '[dim]not set[/dim]'}") console.print(f"Anthropic API: {'[green]โœ“[/green]' if has_anthropic else '[dim]not set[/dim]'}") console.print(f"OpenAI API: {'[green]โœ“[/green]' if has_openai else '[dim]not set[/dim]'}") console.print(f"Gemini API: {'[green]โœ“[/green]' if has_gemini else '[dim]not set[/dim]'}") - vllm_status = f"[green]๏ฟฝ?{config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]" + console.print(f"Zhipu AI API: {'[green]โœ“[/green]' if has_zhipu else '[dim]not set[/dim]'}") + console.print(f"AiHubMix API: {'[green]โœ“[/green]' if has_aihubmix else '[dim]not set[/dim]'}") + vllm_status = f"[green]โœ“ {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]" console.print(f"vLLM/Local: {vllm_status}") try: diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index f8de881..fd7d1e8 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -34,6 +34,7 @@ def load_config(config_path: Path | None = None) -> Config: try: with open(path) as f: data = json.load(f) + data = _migrate_config(data) return Config.model_validate(convert_keys(data)) except (json.JSONDecodeError, ValueError) as e: print(f"Warning: Failed to load config from {path}: {e}") @@ -61,6 +62,16 @@ def save_config(config: Config, config_path: Path | None = None) -> None: json.dump(data, f, indent=2) +def _migrate_config(data: dict) -> dict: + """Migrate old config formats to current.""" + # Move tools.exec.restrictToWorkspace โ†’ tools.restrictToWorkspace + tools = data.get("tools", {}) + exec_cfg = tools.get("exec", {}) + if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools: + tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace") + return data + + def convert_keys(data: Any) -> Any: """Convert camelCase keys to snake_case for Pydantic.""" if isinstance(data, dict): diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 4492096..7724288 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -30,10 +30,20 @@ class FeishuConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids +class DiscordConfig(BaseModel): + """Discord channel configuration.""" + enabled: bool = False + token: str = "" # Bot token from Discord Developer Portal + allow_from: list[str] = Field(default_factory=list) # Allowed user IDs + gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" + intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT + + class ChannelsConfig(BaseModel): """Configuration for chat channels.""" whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) + discord: DiscordConfig = Field(default_factory=DiscordConfig) feishu: FeishuConfig = Field(default_factory=FeishuConfig) @@ -55,6 +65,7 @@ class ProviderConfig(BaseModel): """LLM provider configuration.""" api_key: str = "" api_base: str | None = None + extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) class ProvidersConfig(BaseModel): @@ -62,10 +73,14 @@ class ProvidersConfig(BaseModel): anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) + deepseek: ProviderConfig = Field(default_factory=ProviderConfig) groq: ProviderConfig = Field(default_factory=ProviderConfig) zhipu: ProviderConfig = Field(default_factory=ProviderConfig) + dashscope: ProviderConfig = Field(default_factory=ProviderConfig) # ้˜ฟ้‡Œไบ‘้€šไน‰ๅƒ้—ฎ vllm: ProviderConfig = Field(default_factory=ProviderConfig) gemini: ProviderConfig = Field(default_factory=ProviderConfig) + moonshot: ProviderConfig = Field(default_factory=ProviderConfig) + aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway class GatewayConfig(BaseModel): @@ -88,13 +103,13 @@ class WebToolsConfig(BaseModel): 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) + restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory class Config(BaseSettings): @@ -110,27 +125,44 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - def get_api_key(self) -> str | None: - """Get API key in priority order: OpenRouter > Anthropic > OpenAI > Gemini > Zhipu > Groq > vLLM.""" - return ( - self.providers.openrouter.api_key or - self.providers.anthropic.api_key or - self.providers.openai.api_key or - self.providers.gemini.api_key or - self.providers.zhipu.api_key or - self.providers.groq.api_key or - self.providers.vllm.api_key or - None - ) + # Default base URLs for API gateways + _GATEWAY_DEFAULTS = {"openrouter": "https://openrouter.ai/api/v1", "aihubmix": "https://aihubmix.com/v1"} + + def get_provider(self, model: str | None = None) -> ProviderConfig | None: + """Get matched provider config (api_key, api_base, extra_headers). Falls back to first available.""" + model = (model or self.agents.defaults.model).lower() + p = self.providers + # Keyword โ†’ provider mapping (order matters: gateways first) + keyword_map = { + "aihubmix": p.aihubmix, "openrouter": p.openrouter, + "deepseek": p.deepseek, "anthropic": p.anthropic, "claude": p.anthropic, + "openai": p.openai, "gpt": p.openai, "gemini": p.gemini, + "zhipu": p.zhipu, "glm": p.zhipu, "zai": p.zhipu, + "dashscope": p.dashscope, "qwen": p.dashscope, + "groq": p.groq, "moonshot": p.moonshot, "kimi": p.moonshot, "vllm": p.vllm, + } + for kw, provider in keyword_map.items(): + if kw in model and provider.api_key: + return provider + # Fallback: gateways first (can serve any model), then specific providers + all_providers = [p.openrouter, p.aihubmix, p.anthropic, p.openai, p.deepseek, + p.gemini, p.zhipu, p.dashscope, p.moonshot, p.vllm, p.groq] + return next((pr for pr in all_providers if pr.api_key), None) + + def get_api_key(self, model: str | None = None) -> str | None: + """Get API key for the given model. Falls back to first available key.""" + p = self.get_provider(model) + return p.api_key if p else None - def get_api_base(self) -> str | None: - """Get API base URL if using OpenRouter, Zhipu or vLLM.""" - if self.providers.openrouter.api_key: - return self.providers.openrouter.api_base or "https://openrouter.ai/api/v1" - if self.providers.zhipu.api_key: - return self.providers.zhipu.api_base - if self.providers.vllm.api_base: - return self.providers.vllm.api_base + def get_api_base(self, model: str | None = None) -> str | None: + """Get API base URL for the given model. Applies default URLs for known gateways.""" + p = self.get_provider(model) + if p and p.api_base: + return p.api_base + # Default URLs for known gateways (openrouter, aihubmix) + for name, url in self._GATEWAY_DEFAULTS.items(): + if p == getattr(self.providers, name): + return url return None class Config: diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 8945412..415100c 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -21,10 +21,12 @@ class LiteLLMProvider(LLMProvider): self, api_key: str | None = None, api_base: str | None = None, - default_model: str = "anthropic/claude-opus-4-5" + default_model: str = "anthropic/claude-opus-4-5", + extra_headers: dict[str, str] | None = None, ): super().__init__(api_key, api_base) self.default_model = default_model + self.extra_headers = extra_headers or {} # Detect OpenRouter by api_key prefix or explicit api_base self.is_openrouter = ( @@ -32,17 +34,25 @@ class LiteLLMProvider(LLMProvider): (api_base and "openrouter" in api_base) ) + # Detect AiHubMix by api_base + self.is_aihubmix = bool(api_base and "aihubmix" in api_base) + # Track if using custom endpoint (vLLM, etc.) - self.is_vllm = bool(api_base) and not self.is_openrouter + self.is_vllm = bool(api_base) and not self.is_openrouter and not self.is_aihubmix # Configure LiteLLM based on provider if api_key: if self.is_openrouter: # OpenRouter mode - set key os.environ["OPENROUTER_API_KEY"] = api_key + elif self.is_aihubmix: + # AiHubMix gateway - OpenAI-compatible + os.environ["OPENAI_API_KEY"] = api_key elif self.is_vllm: # vLLM/custom endpoint - uses OpenAI-compatible API - os.environ["OPENAI_API_KEY"] = api_key + os.environ["HOSTED_VLLM_API_KEY"] = api_key + elif "deepseek" in default_model: + os.environ.setdefault("DEEPSEEK_API_KEY", api_key) elif "anthropic" in default_model: os.environ.setdefault("ANTHROPIC_API_KEY", api_key) elif "openai" in default_model or "gpt" in default_model: @@ -50,9 +60,15 @@ class LiteLLMProvider(LLMProvider): elif "gemini" in default_model.lower(): os.environ.setdefault("GEMINI_API_KEY", api_key) elif "zhipu" in default_model or "glm" in default_model or "zai" in default_model: + os.environ.setdefault("ZAI_API_KEY", api_key) os.environ.setdefault("ZHIPUAI_API_KEY", api_key) + elif "dashscope" in default_model or "qwen" in default_model.lower(): + os.environ.setdefault("DASHSCOPE_API_KEY", api_key) elif "groq" in default_model: os.environ.setdefault("GROQ_API_KEY", api_key) + elif "moonshot" in default_model or "kimi" in default_model: + os.environ.setdefault("MOONSHOT_API_KEY", api_key) + os.environ.setdefault("MOONSHOT_API_BASE", api_base or "https://api.moonshot.cn/v1") if api_base: litellm.api_base = api_base @@ -83,28 +99,33 @@ class LiteLLMProvider(LLMProvider): """ model = model or self.default_model - # For OpenRouter, prefix model name if not already prefixed + # Auto-prefix model names for known providers + # (keywords, target_prefix, skip_if_starts_with) + _prefix_rules = [ + (("glm", "zhipu"), "zai", ("zhipu/", "zai/", "openrouter/", "hosted_vllm/")), + (("qwen", "dashscope"), "dashscope", ("dashscope/", "openrouter/")), + (("moonshot", "kimi"), "moonshot", ("moonshot/", "openrouter/")), + (("gemini",), "gemini", ("gemini/",)), + ] + if not (self.is_vllm or self.is_openrouter or self.is_aihubmix): + model_lower = model.lower() + for keywords, prefix, skip in _prefix_rules: + if any(kw in model_lower for kw in keywords) and not any(model.startswith(s) for s in skip): + model = f"{prefix}/{model}" + break + + # Gateway/endpoint-specific prefixes (detected by api_base/api_key, not model name) if self.is_openrouter and not model.startswith("openrouter/"): model = f"openrouter/{model}" - - # For Zhipu/Z.ai, ensure prefix is present - # 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"zai/{model}" - - # For vLLM, use hosted_vllm/ prefix per LiteLLM docs - # Convert openai/ prefix to hosted_vllm/ if user specified it - if self.is_vllm: + elif self.is_aihubmix: + model = f"openai/{model.split('/')[-1]}" + elif self.is_vllm: model = f"hosted_vllm/{model}" - # For Gemini, ensure gemini/ prefix if not already present - if "gemini" in model.lower() and not model.startswith("gemini/"): - model = f"gemini/{model}" - + # kimi-k2.5 only supports temperature=1.0 + if "kimi-k2.5" in model.lower(): + temperature = 1.0 + kwargs: dict[str, Any] = { "model": model, "messages": messages, @@ -116,6 +137,10 @@ class LiteLLMProvider(LLMProvider): if self.api_base: kwargs["api_base"] = self.api_base + # Pass extra headers (e.g. APP-Code for AiHubMix) + if self.extra_headers: + kwargs["extra_headers"] = self.extra_headers + if tools: kwargs["tools"] = tools kwargs["tool_choice"] = "auto" diff --git a/nanobot/skills/cron/SKILL.md b/nanobot/skills/cron/SKILL.md new file mode 100644 index 0000000..c8beecb --- /dev/null +++ b/nanobot/skills/cron/SKILL.md @@ -0,0 +1,40 @@ +--- +name: cron +description: Schedule reminders and recurring tasks. +--- + +# Cron + +Use the `cron` tool to schedule reminders or recurring tasks. + +## Two Modes + +1. **Reminder** - message is sent directly to user +2. **Task** - message is a task description, agent executes and sends result + +## Examples + +Fixed reminder: +``` +cron(action="add", message="Time to take a break!", every_seconds=1200) +``` + +Dynamic task (agent executes each time): +``` +cron(action="add", message="Check HKUDS/nanobot GitHub stars and report", every_seconds=600) +``` + +List/remove: +``` +cron(action="list") +cron(action="remove", job_id="abc123") +``` + +## Time Expressions + +| User says | Parameters | +|-----------|------------| +| every 20 minutes | every_seconds: 1200 | +| every hour | every_seconds: 3600 | +| every day at 8am | cron_expr: "0 8 * * *" | +| weekdays at 5pm | cron_expr: "0 17 * * 1-5" | diff --git a/pyproject.toml b/pyproject.toml index a1931e5..4de4d53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.3.post4" +version = "0.1.3.post5" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} @@ -29,13 +29,12 @@ dependencies = [ "readability-lxml>=0.8.0", "rich>=13.0.0", "croniter>=2.0.0", - "python-telegram-bot>=21.0", + "python-telegram-bot[socks]>=21.0", + "lark-oapi>=1.0.0", + "socksio>=1.0.0", ] [project.optional-dependencies] -feishu = [ - "lark-oapi>=1.0.0", -] dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", diff --git a/test_docker.sh b/tests/test_docker.sh old mode 100755 new mode 100644 similarity index 97% rename from test_docker.sh rename to tests/test_docker.sh index a90e080..1e55133 --- a/test_docker.sh +++ b/tests/test_docker.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +cd "$(dirname "$0")/.." || exit 1 IMAGE_NAME="nanobot-test"