Compare commits

...
Author SHA1 Message Date
Re-bin 9fe2c09fd3 bump version to 0.1.3.post5 2026-02-07 18:01:14 +00:00
Re-bin d2fef6059d Merge PR #289: add Telegram proxy support and channel startup error handling 2026-02-07 17:56:45 +00:00
Re-bin d258f5beba Merge branch 'main' into pr-289 2026-02-07 17:54:33 +00:00
Re-bin d027964b77 Merge PR #287: fix WhatsApp LID access denied 2026-02-07 17:40:56 +00:00
Re-bin 544eefbc8a fix: correct variable references in WhatsApp LID handling 2026-02-07 17:40:46 +00:00
alan cf1663af13 feat: conditionally set telegram proxy 2026-02-07 22:18:43 +08:00
alan 3166c15cff feat: add telegram proxy support and add error handling for channel startup 2026-02-07 20:37:41 +08:00
Adrian Höhne b179a028c3 Fixes Access Denied because only the LID was used. 2026-02-07 12:13:13 +00:00
chaohuang-aiandGitHub 625fc60282 Update README.md 2026-02-07 17:52:29 +08:00
Re-bin 2ca15f2a9d feat: enhance Feishu markdown rendering 2026-02-07 09:46:53 +00:00
Re-bin 572eab8237 feat: add AiHubMix provider support and refactor provider matching 2026-02-07 08:10:05 +00:00
Re-bin 7bf2232537 Merge PR #145: fix Zhipu AI API key env var 2026-02-07 07:24:37 +00:00
Re-bin 394ebccb46 docs: update line count 2026-02-07 07:24:19 +00:00
Re-bin 9a98ab1747 Merge PR #145: fix Zhipu AI API key env var 2026-02-07 07:22:51 +00:00
Re-bin 18ec651b34 Merge PR #46: Add DashScope support 2026-02-07 02:52:40 +00:00
Re-bin 6bf09e06c2 docs: update README on key features 2026-02-07 02:44:39 +00:00
Re-bin 7c2aec99a0 resolve conflicts with main 2026-02-07 02:41:28 +00:00
Xubin RenandGitHub 771c918770 Merge pull request #205 from wcmolin/fix/zhipu-api-key
[Fix-204]: use correct ZAI_API_KEY for Zhipu/GLM models #204
2026-02-07 10:20:10 +08:00
Re-bin 4617043d2c docs: update 6 feb news 2026-02-06 17:01:18 +00:00
Re-bin 08686a63f4 Merge PR #42: fix: correct API key environment variable for vLLM mode 2026-02-06 16:57:46 +00:00
Re-bin 2096645ff1 resolve conflicts with main 2026-02-06 16:56:02 +00:00
Re-bin 9d5b227408 docs: add security config section and remove redundant full config example 2026-02-06 09:34:11 +00:00
Re-bin 943579b96a refactor(security): lift restrictToWorkspace to tools level 2026-02-06 09:28:08 +00:00
Re-bin b1782814fa docs: update line count after enhancing security 2026-02-06 09:18:05 +00:00
Re-bin 5f5536c0d1 Merge PR #77: add security hardening (SECURITY.md, workspace restriction for file tools) 2026-02-06 09:16:48 +00:00
Re-bin c5191eed1a refactor: unify workspace restriction for file tools, remove redundant checks, fix SECURITY.md 2026-02-06 09:16:20 +00:00
Re-bin 8a23d541e2 update gitignore 2026-02-06 08:46:06 +00:00
Re-bin 96e6f31387 resolve merge conflict in README 2026-02-06 08:45:38 +00:00
Re-bin 4600f7cbd9 docs: update line count 2026-02-06 08:02:55 +00:00
Re-bin 9a8e9bf108 Merge PR #202: add Moonshot provider support 2026-02-06 08:02:10 +00:00
Re-bin 760a369004 feat: fix API key matching by model name 2026-02-06 08:01:20 +00:00
wcmolin fea4a6bba8 fix: use correct ZAI_API_KEY for Zhipu/GLM models
LiteLLM's zai provider reads ZAI_API_KEY, not ZHIPUAI_API_KEY.
This fixes authentication errors when using Zhipu/GLM models.
2026-02-06 15:38:25 +08:00
Re-bin f5a50d08eb Merge branch 'main' into pr-202 2026-02-06 07:33:23 +00:00
Re-bin 7965af723c docs: update line count 2026-02-06 07:30:28 +00:00
Re-bin 77d4892b0d docs: add core agent line count script and update README with real-time stats 2026-02-06 07:28:39 +00:00
mengjiechenandClaude Opus 4.6 e680b734b1 feat: add Moonshot provider support
- Add moonshot to ProvidersConfig schema
- Add MOONSHOT_API_BASE environment variable for custom endpoint
- Handle kimi-k2.5 model temperature restriction (must be 1.0)
- Fix is_vllm detection to exclude moonshot provider

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:25:58 +08:00
Re-bin be0cbb7bdd Merge PR #24: add discord channel support 2026-02-06 07:08:40 +00:00
Re-bin 71fc73ecc4 resolve conflicts with main 2026-02-06 07:08:29 +00:00
Re-bin 8a1d7c76d2 refactor: simplify discord channel and improve setup docs 2026-02-06 07:04:10 +00:00
chaohuang-aiandGitHub 16f6fdf5d3 Update README.md 2026-02-06 14:14:28 +08:00
Re-bin 3db0042e0c Merge PR #107: add runtime environment to system prompt 2026-02-06 03:27:20 +00:00
Re-bin 764c6d02a1 refactor: simplify runtime environment info in system prompt 2026-02-06 03:26:39 +00:00
Re-bin 980c5992f4 Merge branch 'main' into pr-107 2026-02-06 03:21:44 +00:00
Dontrail CotlageandGitHub 6df2905c04 Merge branch 'main' into main 2026-02-05 18:35:19 -05:00
Xubin RenandGitHub 9f6b3f9209 Merge pull request #174 from vivganes/main
chore: change 'depoly' to 'deploy'
2026-02-06 02:33:06 +08:00
Vivek GanesanandGitHub dcae2c23a2 chore: change 'depoly' to 'deploy' 2026-02-05 23:37:10 +05:30
chaohuang-aiandGitHub cb800e8f21 Update README.md 2026-02-06 00:57:58 +08:00
Re-bin 9ac3944323 docs: add Feb 5 update news 2026-02-05 16:36:48 +00:00
Re-bin b1d6670ce0 feat: add cron tool for scheduling reminders and tasks 2026-02-05 15:09:51 +00:00
Dontrail CotlageandGitHub 4d225ed2d6 Merge branch 'main' into main 2026-02-05 08:06:55 -05:00
Dontrail CotlageandGitHub 93301d110e Merge pull request #3 from kingassune/copilot/remove-poetry-lock-file
Remove poetry.lock from repository and add to .gitignore
2026-02-05 08:05:58 -05:00
copilot-swe-agent[bot]andkingassune ef5ef07596 Remove poetry.lock from repository and add to .gitignore
Co-authored-by: kingassune <6126851+kingassune@users.noreply.github.com>
2026-02-05 13:03:29 +00:00
copilot-swe-agent[bot] 554d7bc4ff Initial plan 2026-02-05 13:02:19 +00:00
Re-bin dc20927ff0 docs: add v0.1.3 release news 2026-02-05 09:12:30 +00:00
Re-bin 5da74d8116 docs: add v0.1.3 release news 2026-02-05 09:11:52 +00:00
Re-bin dc92695ad9 Merge PR #38: add DeepSeek provider support 2026-02-05 08:55:47 +00:00
Re-bin 301fba568b refactor: remove redundant env var setting, add DeepSeek to docs 2026-02-05 08:55:41 +00:00
Re-bin ac45630116 resolve conflicts with main 2026-02-05 08:51:11 +00:00
Manus AI a0280a1e4a fix: update Zhipu AI API key env var and improve model prefixing 2026-02-05 03:35:46 -05:00
Re-bin 1d74dd24d6 docs: update contributors image 2026-02-05 06:09:37 +00:00
Re-bin 0649c9b30a Merge PR #84: add feishu channel support 2026-02-05 06:05:50 +00:00
Re-bin f341de075d docs: simplify Feishu configuration guide 2026-02-05 06:05:09 +00:00
Re-bin 50a4c4ca1a refactor: improve feishu channel implementation 2026-02-05 06:01:02 +00:00
Re-bin 1e0f87b356 Merge branch 'main' into pr-84 2026-02-05 05:01:02 +00:00
DevinandGitHub d5ee8f3e55 Update context.py
Add doc string.
2026-02-05 10:45:36 +08:00
Dontrail CotlageandGitHub bd4c2ca604 Merge branch 'main' into main 2026-02-04 09:59:33 -05:00
Shukfan Law 22156d3a40 feat: added runtime environment summary to system prompt 2026-02-04 22:17:35 +08:00
tao.jun 50fa024ab4 feishu support 2026-02-04 14:07:45 +08:00
Dontrail CotlageandGitHub 5ac298ba3a Merge pull request #2 from kingassune/copilot/clean-up-repo-security-exploit 2026-02-03 23:45:08 -05:00
copilot-swe-agent[bot]andkingassune f8711f6a49 Remove excessive POC infrastructure, keep elegant security fixes
Co-authored-by: kingassune <6126851+kingassune@users.noreply.github.com>
2026-02-04 04:31:40 +00:00
copilot-swe-agent[bot] def9ffd515 Initial plan 2026-02-04 04:29:46 +00:00
Dontrail CotlageandGitHub fcb2a6588a Merge branch 'main' into main 2026-02-03 21:26:41 -05:00
Dontrail CotlageandGitHub 5f308dd0d0 Merge pull request #1 from kingassune/copilot/run-security-audit
Security audit: Patch critical vulnerabilities and add input validation
2026-02-03 21:22:09 -05:00
Dontrail Cotlage 81f074a338 Remove mock LLM server and related configurations; update README and exploit tests for clarity 2026-02-04 02:21:22 +00:00
Dontrail Cotlage c58cea33c5 Refactor code structure for improved readability and maintainability 2026-02-04 02:15:47 +00:00
copilot-swe-agent[bot]andkingassune 56d301de3e Address code review feedback: improve function naming and consolidate patterns
Co-authored-by: kingassune <6126851+kingassune@users.noreply.github.com>
2026-02-03 22:12:01 +00:00
copilot-swe-agent[bot]andkingassune cbb99c64e5 Add comprehensive security documentation and improve command filtering
Co-authored-by: kingassune <6126851+kingassune@users.noreply.github.com>
2026-02-03 22:10:43 +00:00
copilot-swe-agent[bot]andkingassune 8b4e0a8868 Security audit: Fix critical dependency vulnerabilities and add security controls
Co-authored-by: kingassune <6126851+kingassune@users.noreply.github.com>
2026-02-03 22:08:33 +00:00
copilot-swe-agent[bot] 9d4c00ac6a Initial plan 2026-02-03 22:04:43 +00:00
Anunay AatipamulaandGitHub 7d2bebcfa3 Merge branch 'main' into feat/discord-support 2026-02-03 21:15:15 +05:30
ZJUCQR 1d258d2369 Merge branch 'main' into feature/add-dashscope-support 2026-02-03 16:38:32 +08:00
ZJUCQR 520923eb76 update readme 2026-02-03 16:28:21 +08:00
ZJUCQR 8499dbf132 add dashscope support 2026-02-03 16:27:15 +08:00
popcell 8cde0b3072 fix: correct API key environment variable for vLLM mode 2026-02-03 12:14:14 +08:00
Kyya Wang f23548f296 feat: add DeepSeek provider support 2026-02-03 03:09:13 +00:00
Anunay Aatipamula bab464df5f feat(discord): implement typing indicator functionality
- Add methods to manage typing indicators in Discord channels.
- Introduce periodic typing notifications while sending messages.
- Ensure proper cleanup of typing tasks on channel closure.
2026-02-02 19:01:46 +05:30
Anunay AatipamulaandGitHub 226cb5b46b Merge branch 'main' into feat/discord-support 2026-02-02 18:55:16 +05:30
Anunay Aatipamula 884690e3c7 docs: update README to include limitations of current implementation
- Added section outlining current limitations such as global allowlist, lack of per-guild/channel rules, and restrictions on outbound message types.
2026-02-02 18:53:47 +05:30
Anunay Aatipamula ba6c4b748f feat(discord): add Discord channel support
- Implement Discord channel functionality with websocket integration.
- Update configuration schema to include Discord settings.
- Enhance README with setup instructions for Discord integration.
- Modify channel manager to initialize Discord channel if enabled.
- Update CLI status command to display Discord channel status.
2026-02-02 18:41:17 +05:30
Anunay Aatipamula 1865ecda8f Merge branch 'main' of github.com:HKUDS/nanobot 2026-02-02 11:31:38 +05:30
24 changed files with 1462 additions and 188 deletions
+5 -1
View File
@@ -12,4 +12,8 @@ docs/
*.pyw
*.pyz
*.pywz
*.pyzz
*.pyzz
.venv/
__pycache__/
poetry.lock
.pytest_cache/
+119 -52
View File
@@ -16,19 +16,25 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
📏 Real-time line count: **3,422 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
- **2026-02-01** 🎉 nanobot launched! Welcome to try 🐈 nanobot!
- **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 [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
## Key Features of nanobot:
🪶 **Ultra-Lightweight**: Just ~4,000 lines of code — 99% smaller than Clawdbot - core functionality.
🪶 **Ultra-Lightweight**: Just ~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 +91,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 +101,7 @@ nanobot onboard
**2. Configure** (`~/.nanobot/config.json`)
For OpenRouter - recommended for global users:
```json
{
"providers": {
@@ -107,18 +113,10 @@ nanobot onboard
"defaults": {
"model": "anthropic/claude-opus-4-5"
}
},
"tools": {
"web": {
"search": {
"apiKey": "BSA-xxx"
}
}
}
}
```
**3. Chat**
```bash
@@ -166,12 +164,14 @@ nanobot agent -m "Hello from my local LLM!"
## 💬 Chat Apps
Talk to your nanobot through Telegram or WhatsApp — anytime, anywhere.
Talk to your nanobot through Telegram, Discord, 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) |
<details>
<summary><b>Telegram</b> (Recommended)</summary>
@@ -205,6 +205,50 @@ nanobot gateway
</details>
<details>
<summary><b>Discord</b></summary>
**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
```
</details>
<details>
<summary><b>WhatsApp</b></summary>
@@ -242,6 +286,55 @@ nanobot gateway
</details>
<details>
<summary><b>Feishu (飞书)</b></summary>
Uses **WebSocket** long connection — no public IP required.
```bash
pip install nanobot-ai[feishu]
```
**1. Create a Feishu bot**
- Visit [Feishu Open Platform](https://open.feishu.cn/app)
- 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**
```json
{
"channels": {
"feishu": {
"enabled": true,
"appId": "cli_xxx",
"appSecret": "xxx",
"encryptKey": "",
"verificationToken": "",
"allowFrom": []
}
}
}
```
> `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**
```bash
nanobot gateway
```
> [!TIP]
> Feishu uses WebSocket to receive messages — no webhook or public IP needed!
</details>
## ⚙️ Configuration
Config file: `~/.nanobot/config.json`
@@ -256,49 +349,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) |
<details>
<summary><b>Full config example</b></summary>
### 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
}
},
"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. |
</details>
## CLI Reference
@@ -392,7 +459,7 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
### Contributors
<a href="https://github.com/HKUDS/nanobot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot" />
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12" />
</a>
+264
View File
@@ -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.
+1 -1
View File
@@ -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"
},
+2
View File
@@ -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,
+21
View File
@@ -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/)"
+12
View File
@@ -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
+53 -16
View File
@@ -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,11 @@ 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,
):
from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
self.bus = bus
self.provider = provider
self.workspace = workspace
@@ -51,6 +55,8 @@ 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)
@@ -62,6 +68,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 +76,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 +101,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 +155,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 +170,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 +217,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 +231,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 +278,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 +320,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 +344,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
)
+9 -5
View File
@@ -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",
+114
View File
@@ -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"
+32 -12
View File
@@ -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)}"
+6
View File
@@ -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(
+261
View File
@@ -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()
+307
View File
@@ -0,0 +1,307 @@
"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
import asyncio
import json
import re
import threading
from collections import OrderedDict
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,
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):
"""
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: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
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_running_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
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_sync(self, message_id: str, emoji_type: str) -> None:
"""Sync helper for adding reaction (runs in thread pool)."""
try:
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.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)
# 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:
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 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("interactive")
.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.
"""
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."""
try:
event = data.event
message = event.message
sender = event.sender
# Deduplication check
message_id = message.message_id
if message_id in self._processed_message_ids:
return
self._processed_message_ids[message_id] = None
# 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
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"
msg_type = message.message_type
# Add reaction to indicate "seen"
await self._add_reaction(message_id, "THUMBSUP")
# Parse message content
if msg_type == "text":
try:
content = json.loads(message.content).get("text", "")
except json.JSONDecodeError:
content = message.content or ""
else:
content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")
if not content:
return
# Forward to message bus
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,
}
)
except Exception as e:
logger.error(f"Error processing Feishu message: {e}")
+32 -3
View File
@@ -55,9 +55,38 @@ 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:
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_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
@@ -65,11 +94,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)
+4 -5
View File
@@ -101,11 +101,10 @@ 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 message handler for text, photos, voice, documents
self._app.add_handler(
+10 -6
View File
@@ -100,21 +100,25 @@ class WhatsAppChannel(BaseChannel):
if msg_type == "message":
# Incoming message from WhatsApp
# Deprecated by whatsapp: old phone number style typically: <phone>@s.whatspp.net
pn = data.get("pn", "")
# New LID sytle typically:
sender = data.get("sender", "")
content = data.get("content", "")
# sender is typically: <phone>@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"),
+43 -42
View File
@@ -147,6 +147,23 @@ 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
p = config.get_provider()
model = config.agents.defaults.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
# ============================================================================
@@ -160,7 +177,6 @@ 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.agent.loop import AgentLoop
from nanobot.channels.manager import ChannelManager
from nanobot.cron.service import CronService
@@ -174,28 +190,14 @@ def gateway(
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
config = load_config()
# Create components
bus = MessageBus()
provider = _make_provider(config)
# 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/")
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)
# Create cron service first (callback set after agent creation)
cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path)
provider = LiteLLMProvider(
api_key=api_key,
api_base=api_base,
default_model=config.agents.defaults.model
)
# Create agent
# Create agent with cron service
agent = AgentLoop(
bus=bus,
provider=provider,
@@ -204,27 +206,28 @@ 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,
)
# 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:
@@ -285,26 +288,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.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/")
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,
api_base=api_base,
default_model=config.agents.defaults.model
)
provider = _make_provider(config)
agent_loop = AgentLoop(
bus=bus,
@@ -312,6 +301,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:
@@ -370,6 +360,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]"
@@ -642,12 +639,16 @@ 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]'}")
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}")
+11
View File
@@ -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):
+65 -21
View File
@@ -17,12 +17,34 @@ 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 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)
class AgentDefaults(BaseModel):
@@ -43,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):
@@ -50,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):
@@ -76,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):
@@ -98,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:
+45 -21
View File
@@ -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,32 @@ 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/",)),
]
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 +136,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"
+40
View File
@@ -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" |
+5 -3
View File
@@ -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"}
@@ -23,12 +23,14 @@ dependencies = [
"pydantic-settings>=2.0.0",
"websockets>=12.0",
"websocket-client>=1.6.0",
"httpx>=0.25.0",
"httpx[socks]>=0.25.0",
"loguru>=0.7.0",
"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]
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.." || exit 1
IMAGE_NAME="nanobot-test"