Merge branch 'sipeed:main' into main

This commit is contained in:
XYDT-ChenBo 2026-03-03 17:29:14 +08:00 committed by GitHub
commit 6e5526bfd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 4934 additions and 1154 deletions

View file

@ -7,7 +7,6 @@ linters:
- containedctx - containedctx
- cyclop - cyclop
- depguard - depguard
- dupl
- dupword - dupword
- err113 - err113
- exhaustruct - exhaustruct

View file

@ -269,8 +269,8 @@ Once your PR is submitted, you can reach out to the assigned reviewers listed in
|Function| Reviewer| |Function| Reviewer|
|--- |--- | |--- |--- |
|Provider|@yinwm | |Provider|@yinwm |
|Channel |@yinwm | |Channel |@yinwm/@alexhoshina |
|Agent |@lxowalle| |Agent |@lxowalle/@Zhaoyikaiii|
|Tools |@lxowalle| |Tools |@lxowalle|
|SKill || |SKill ||
|MCP || |MCP ||

View file

@ -268,8 +268,8 @@ Release 分支的保护级别高于 `main`,在任何情况下均不允许直
|Function| Reviewer| |Function| Reviewer|
|--- |--- | |--- |--- |
|Provider|@yinwm | |Provider|@yinwm |
|Channel |@yinwm | |Channel |@yinwm/@alexhoshina |
|Agent |@lxowalle| |Agent |@lxowalle/@Zhaoyikaiii|
|Tools |@lxowalle| |Tools |@lxowalle|
|SKill || |SKill ||
|MCP || |MCP ||

View file

@ -168,11 +168,11 @@ clean:
@echo "Clean complete" @echo "Clean complete"
## vet: Run go vet for static analysis ## vet: Run go vet for static analysis
vet: vet: generate
@$(GO) vet ./... @$(GO) vet ./...
## test: Test Go code ## test: Test Go code
test: test: generate
@$(GO) test ./... @$(GO) test ./...
## fmt: Format Go code ## fmt: Format Go code
@ -204,6 +204,44 @@ check: deps fmt vet test
run: build run: build
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS) @$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
## docker-build: Build Docker image (minimal Alpine-based)
docker-build:
@echo "Building minimal Docker image (Alpine-based)..."
docker compose -f docker/docker-compose.yml build picoclaw-agent picoclaw-gateway
## docker-build-full: Build Docker image with full MCP support (Node.js 24)
docker-build-full:
@echo "Building full-featured Docker image (Node.js 24)..."
docker compose -f docker/docker-compose.full.yml build picoclaw-agent picoclaw-gateway
## docker-test: Test MCP tools in Docker container
docker-test:
@echo "Testing MCP tools in Docker..."
@chmod +x scripts/test-docker-mcp.sh
@./scripts/test-docker-mcp.sh
## docker-run: Run picoclaw gateway in Docker (Alpine-based)
docker-run:
docker compose -f docker/docker-compose.yml --profile gateway up
## docker-run-full: Run picoclaw gateway in Docker (full-featured)
docker-run-full:
docker compose -f docker/docker-compose.full.yml --profile gateway up
## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based)
docker-run-agent:
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured)
docker-run-agent-full:
docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent
## docker-clean: Clean Docker images and volumes
docker-clean:
docker compose -f docker/docker-compose.yml down -v
docker compose -f docker/docker-compose.full.yml down -v
docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
## help: Show this help message ## help: Show this help message
help: help:
@echo "picoclaw Makefile" @echo "picoclaw Makefile"
@ -219,6 +257,8 @@ help:
@echo " make install # Install to ~/.local/bin" @echo " make install # Install to ~/.local/bin"
@echo " make uninstall # Remove from /usr/local/bin" @echo " make uninstall # Remove from /usr/local/bin"
@echo " make install-skills # Install skills to workspace" @echo " make install-skills # Install skills to workspace"
@echo " make docker-build # Build minimal Docker image"
@echo " make docker-test # Test MCP tools in Docker"
@echo "" @echo ""
@echo "Environment Variables:" @echo "Environment Variables:"
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)" @echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"

View file

@ -288,7 +288,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
| **QQ** | Facile (AppID + AppSecret) | | **QQ** | Facile (AppID + AppSecret) |
| **DingTalk** | Moyen (identifiants de l'application) | | **DingTalk** | Moyen (identifiants de l'application) |
| **LINE** | Moyen (identifiants + URL de webhook) | | **LINE** | Moyen (identifiants + URL de webhook) |
| **WeCom** | Moyen (CorpID + configuration webhook) | | **WeCom AI Bot** | Moyen (Token + clé AES) |
<details> <details>
<summary><b>Telegram</b> (Recommandé)</summary> <summary><b>Telegram</b> (Recommandé)</summary>
@ -491,12 +491,13 @@ picoclaw gateway
<details> <details>
<summary><b>WeCom (WeChat Work)</b></summary> <summary><b>WeCom (WeChat Work)</b></summary>
PicoClaw prend en charge deux types d'intégration WeCom : PicoClaw prend en charge trois types d'intégration WeCom :
**Option 1 : WeCom Bot (Robot Intelligent)** - Configuration plus facile, prend en charge les discussions de groupe **Option 1 : WeCom Bot (Robot)** - Configuration plus facile, prend en charge les discussions de groupe
**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive **Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement
**Option 3 : WeCom AI Bot (Bot Intelligent)** - Bot IA officiel, réponses en streaming, prend en charge groupe et privé
Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour des instructions détaillées. Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour des instructions détaillées.
**Configuration Rapide - WeCom Bot :** **Configuration Rapide - WeCom Bot :**
@ -563,6 +564,39 @@ picoclaw gateway
> **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production. > **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production.
**Configuration Rapide - WeCom AI Bot :**
**1. Créer un AI Bot**
* Accédez à la Console d'Administration WeCom → Gestion des Applications → AI Bot
* Configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot`
* Copiez le **Token** et générez l'**EncodingAESKey**
**2. Configurer**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Bonjour ! Comment puis-je vous aider ?"
}
}
}
```
**3. Lancer**
```bash
picoclaw gateway
```
> **Note** : WeCom AI Bot utilise le protocole pull en streaming — pas de problème de timeout. Les tâches longues (>5,5 min) basculent automatiquement vers la livraison via `response_url`.
</details> </details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Rejoignez le Réseau Social d'Agents ## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Rejoignez le Réseau Social d'Agents

View file

@ -257,7 +257,7 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
| **QQ** | 簡単AppID + AppSecret | | **QQ** | 簡単AppID + AppSecret |
| **DingTalk** | 普通(アプリ認証情報) | | **DingTalk** | 普通(アプリ認証情報) |
| **LINE** | 普通(認証情報 + Webhook URL | | **LINE** | 普通(認証情報 + Webhook URL |
| **WeCom** | 普通CorpID + Webhook設定 | | **WeCom AI Bot** | 普通Token + AES キー |
<details> <details>
<summary><b>Telegram</b>(推奨)</summary> <summary><b>Telegram</b>(推奨)</summary>
@ -456,12 +456,13 @@ picoclaw gateway
<details> <details>
<summary><b>WeCom (企業微信)</b></summary> <summary><b>WeCom (企業微信)</b></summary>
PicoClaw は2種類の WeCom 統合をサポートしています: PicoClaw は3種類の WeCom 統合をサポートしています:
**オプション1: WeCom Bot (智能ロボット)** - 簡単な設定、グループチャット対応 **オプション1: WeCom Bot (ロボット)** - 簡単な設定、グループチャット対応
**オプション2: WeCom App (自作アプリ)** - より多機能、アクティブメッセージング対応 **オプション2: WeCom App (カスタムアプリ)** - より多機能、アクティブメッセージング対応、プライベートチャットのみ
**オプション3: WeCom AI Bot (スマートボット)** - 公式 AI Bot、ストリーミング返信、グループ・プライベート両対応
詳細な設定手順は [WeCom App Configuration Guide](docs/wecom-app-configuration.md) を参照してください。 詳細な設定手順は [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) を参照してください。
**クイックセットアップ - WeCom Bot:** **クイックセットアップ - WeCom Bot:**
@ -530,6 +531,39 @@ picoclaw gateway
> **注意**: WeCom App の Webhook コールバックは共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は HTTPS 用のリバースプロキシを設定してください。 > **注意**: WeCom App の Webhook コールバックは共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は HTTPS 用のリバースプロキシを設定してください。
**クイックセットアップ - WeCom AI Bot:**
**1. AI Bot を作成**
* WeCom 管理コンソール → アプリ管理 → AI Bot
* コールバック URL を設定: `http://your-server:18791/webhook/wecom-aibot`
* **Token** をコピーし、**EncodingAESKey** を生成
**2. 設定**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "こんにちは!何かお手伝いできますか?"
}
}
}
```
**3. 起動**
```bash
picoclaw gateway
```
> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>30秒は自動的に `response_url` によるプッシュ配信に切り替わります。
</details> </details>
## ⚙️ 設定 ## ⚙️ 設定

View file

@ -305,7 +305,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
| **QQ** | Easy (AppID + AppSecret) | | **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) | | **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) | | **LINE** | Medium (credentials + webhook URL) |
| **WeCom** | Medium (CorpID + webhook setup) | | **WeCom AI Bot** | Medium (Token + AES key) |
<details> <details>
<summary><b>Telegram</b> (Recommended)</summary> <summary><b>Telegram</b> (Recommended)</summary>
@ -557,12 +557,13 @@ picoclaw gateway
<details> <details>
<summary><b>WeCom (企业微信)</b></summary> <summary><b>WeCom (企业微信)</b></summary>
PicoClaw supports two types of WeCom integration: PicoClaw supports three types of WeCom integration:
**Option 1: WeCom Bot (智能机器人)** - Easier setup, supports group chats **Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
**Option 2: WeCom App (自建应用)** - More features, proactive messaging **Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions. See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
**Quick Setup - WeCom Bot:** **Quick Setup - WeCom Bot:**
@ -631,6 +632,39 @@ picoclaw gateway
> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS. > **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.
**Quick Setup - WeCom AI Bot:**
**1. Create an AI Bot**
* Go to WeCom Admin Console → App Management → AI Bot
* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot`
* Copy **Token** and click "Random Generate" for **EncodingAESKey**
**2. Configure**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Hello! How can I help you?"
}
}
}
```
**3. Run**
```bash
picoclaw gateway
```
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
</details> </details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network ## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
@ -891,7 +925,7 @@ This design also enables **multi-agent support** with flexible provider selectio
#### 📋 All Supported Vendors #### 📋 All Supported Vendors
| Vendor | `model` Prefix | Default API Base | Protocol | API Key | | Vendor | `model` Prefix | Default API Base | Protocol | API Key |
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | | ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
@ -903,6 +937,7 @@ This design also enables **multi-agent support** with flexible provider selectio
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1 | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
@ -1004,6 +1039,19 @@ This design also enables **multi-agent support** with flexible provider selectio
} }
``` ```
**LiteLLM Proxy**
```json
{
"model_name": "lite-gpt4",
"model": "litellm/lite-gpt4",
"api_base": "http://localhost:4000/v1",
"api_key": "sk-..."
}
```
PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
#### Load Balancing #### Load Balancing
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them: Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:

View file

@ -282,7 +282,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
| **QQ** | Fácil (AppID + AppSecret) | | **QQ** | Fácil (AppID + AppSecret) |
| **DingTalk** | Médio (credenciais do app) | | **DingTalk** | Médio (credenciais do app) |
| **LINE** | Médio (credenciais + webhook URL) | | **LINE** | Médio (credenciais + webhook URL) |
| **WeCom** | Médio (CorpID + configuração webhook) | | **WeCom AI Bot** | Médio (Token + chave AES) |
<details> <details>
<summary><b>Telegram</b> (Recomendado)</summary> <summary><b>Telegram</b> (Recomendado)</summary>
@ -485,12 +485,13 @@ picoclaw gateway
<details> <details>
<summary><b>WeCom (WeChat Work)</b></summary> <summary><b>WeCom (WeChat Work)</b></summary>
O PicoClaw suporta dois tipos de integração WeCom: O PicoClaw suporta três tipos de integração WeCom:
**Opção 1: WeCom Bot (Robô Inteligente)** - Configuração mais fácil, suporta chats em grupo **Opção 1: WeCom Bot (Robô)** - Configuração mais fácil, suporta chats em grupo
**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas **Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas, somente chat privado
**Opção 3: WeCom AI Bot (Robô Inteligente)** - Bot IA oficial, respostas em streaming, suporta grupo e privado
Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para instruções detalhadas. Veja o [Guia de Configuração WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas.
**Configuração Rápida - WeCom Bot:** **Configuração Rápida - WeCom Bot:**
@ -559,6 +560,39 @@ picoclaw gateway
> **Nota**: O WeCom App (callbacks de webhook) é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Em produção use um proxy reverso HTTPS para expor a porta do Gateway, ou atualize `PICOCLAW_GATEWAY_HOST` para `0.0.0.0` se necessário. > **Nota**: O WeCom App (callbacks de webhook) é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Em produção use um proxy reverso HTTPS para expor a porta do Gateway, ou atualize `PICOCLAW_GATEWAY_HOST` para `0.0.0.0` se necessário.
**Configuração Rápida - WeCom AI Bot:**
**1. Criar um AI Bot**
* Acesse o Console de Administração WeCom → Gerenciamento de Aplicativos → AI Bot
* Configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot`
* Copie o **Token** e gere o **EncodingAESKey**
**2. Configurar**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Olá! Como posso ajudá-lo?"
}
}
}
```
**3. Executar**
```bash
picoclaw gateway
```
> **Nota**: O WeCom AI Bot usa protocolo de pull em streaming — sem preocupações com timeout de resposta. Tarefas longas (>5,5 min) alternam automaticamente para entrega via `response_url`.
</details> </details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Junte-se a Rede Social de Agentes ## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Junte-se a Rede Social de Agentes

View file

@ -256,7 +256,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
| **QQ** | Dễ (AppID + AppSecret) | | **QQ** | Dễ (AppID + AppSecret) |
| **DingTalk** | Trung bình (app credentials) | | **DingTalk** | Trung bình (app credentials) |
| **LINE** | Trung bình (credentials + webhook URL) | | **LINE** | Trung bình (credentials + webhook URL) |
| **WeCom** | Trung bình (CorpID + cấu hình webhook) | | **WeCom AI Bot** | Trung bình (Token + khóa AES) |
<details> <details>
<summary><b>Telegram</b> (Khuyên dùng)</summary> <summary><b>Telegram</b> (Khuyên dùng)</summary>
@ -457,12 +457,13 @@ picoclaw gateway
<details> <details>
<summary><b>WeCom (WeChat Work)</b></summary> <summary><b>WeCom (WeChat Work)</b></summary>
PicoClaw hỗ trợ hai loại tích hợp WeCom: PicoClaw hỗ trợ ba loại tích hợp WeCom:
**Tùy chọn 1: WeCom Bot (Robot Thông minh)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm **Tùy chọn 1: WeCom Bot (Robot)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm
**Tùy chọn 2: WeCom App (Ứng dụng Tự xây dựng)** - Nhiều tính năng hơn, nhắn tin chủ động **Tùy chọn 2: WeCom App (Ứng dụng Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng tư
**Tùy chọn 3: WeCom AI Bot (Bot Thông Minh)** - Bot AI chính thức, phản hồi streaming, hỗ trợ nhóm và riêng tư
Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) để biết hướng dẫn chi tiết. Xem [Hướng dẫn Cấu hình WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) để biết hướng dẫn chi tiết.
**Thiết lập Nhanh - WeCom Bot:** **Thiết lập Nhanh - WeCom Bot:**
@ -531,6 +532,39 @@ picoclaw gateway
> **Lưu ý**: WeCom App callback webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). Sử dụng proxy ngược để cung cấp HTTPS trong môi trường production nếu cần. > **Lưu ý**: WeCom App callback webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). Sử dụng proxy ngược để cung cấp HTTPS trong môi trường production nếu cần.
**Thiết lập Nhanh - WeCom AI Bot:**
**1. Tạo AI Bot**
* Truy cập Bảng điều khiển Quản trị WeCom → Quản lý Ứng dụng → AI Bot
* Cấu hình URL callback: `http://your-server:18791/webhook/wecom-aibot`
* Sao chép **Token** và tạo **EncodingAESKey**
**2. Cấu hình**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Xin chào! Tôi có thể giúp gì cho bạn?"
}
}
}
```
**3. Chạy**
```bash
picoclaw gateway
```
> **Lưu ý**: WeCom AI Bot sử dụng giao thức pull streaming — không lo timeout phản hồi. Tác vụ dài (>5,5 phút) tự động chuyển sang gửi qua `response_url`.
</details> </details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Tham gia Mạng xã hội Agent ## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Tham gia Mạng xã hội Agent

View file

@ -301,7 +301,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) | | **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
| **QQ** | ⭐⭐ 中等 | 官方机器人 API适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) | | **QQ** | ⭐⭐ 中等 | 官方机器人 API适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) | | **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) | | **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) |
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) | | **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) | | **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |

View file

@ -10,8 +10,8 @@ import (
picoclawconfig "github.com/sipeed/picoclaw/pkg/config" picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
) )
func (s *appState) channelMenu() tview.Primitive { func (s *appState) buildChannelMenuItems() []MenuItem {
items := []MenuItem{ return []MenuItem{
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, {Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
channelItem( channelItem(
"Telegram", "Telegram",
@ -86,8 +86,10 @@ func (s *appState) channelMenu() tview.Primitive {
func() { s.push("channel-wecomapp", s.wecomAppForm()) }, func() { s.push("channel-wecomapp", s.wecomAppForm()) },
), ),
} }
}
menu := NewMenu("Channels", items) func (s *appState) channelMenu() tview.Primitive {
menu := NewMenu("Channels", s.buildChannelMenuItems())
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc { if event.Key() == tcell.KeyEsc {
s.pop() s.pop()
@ -103,199 +105,72 @@ func (s *appState) channelMenu() tview.Primitive {
} }
func refreshChannelMenuFromState(menu *Menu, s *appState) { func refreshChannelMenuFromState(menu *Menu, s *appState) {
items := []MenuItem{ menu.applyItems(s.buildChannelMenuItems())
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
channelItem(
"Telegram",
"Telegram bot settings",
s.config.Channels.Telegram.Enabled,
func() { s.push("channel-telegram", s.telegramForm()) },
),
channelItem(
"Discord",
"Discord bot settings",
s.config.Channels.Discord.Enabled,
func() { s.push("channel-discord", s.discordForm()) },
),
channelItem(
"QQ",
"QQ bot settings",
s.config.Channels.QQ.Enabled,
func() { s.push("channel-qq", s.qqForm()) },
),
channelItem(
"MaixCam",
"MaixCam gateway",
s.config.Channels.MaixCam.Enabled,
func() { s.push("channel-maixcam", s.maixcamForm()) },
),
channelItem(
"WhatsApp",
"WhatsApp bridge",
s.config.Channels.WhatsApp.Enabled,
func() { s.push("channel-whatsapp", s.whatsappForm()) },
),
channelItem(
"Feishu",
"Feishu bot settings",
s.config.Channels.Feishu.Enabled,
func() { s.push("channel-feishu", s.feishuForm()) },
),
channelItem(
"DingTalk",
"DingTalk bot settings",
s.config.Channels.DingTalk.Enabled,
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
),
channelItem(
"Slack",
"Slack bot settings",
s.config.Channels.Slack.Enabled,
func() { s.push("channel-slack", s.slackForm()) },
),
channelItem(
"LINE",
"LINE bot settings",
s.config.Channels.LINE.Enabled,
func() { s.push("channel-line", s.lineForm()) },
),
channelItem(
"OneBot",
"OneBot settings",
s.config.Channels.OneBot.Enabled,
func() { s.push("channel-onebot", s.onebotForm()) },
),
channelItem(
"WeCom",
"WeCom bot settings",
s.config.Channels.WeCom.Enabled,
func() { s.push("channel-wecom", s.wecomForm()) },
),
channelItem(
"WeCom App",
"WeCom App settings",
s.config.Channels.WeComApp.Enabled,
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
),
}
menu.applyItems(items)
} }
func (s *appState) telegramForm() tview.Primitive { func (s *appState) telegramForm() tview.Primitive {
cfg := &s.config.Channels.Telegram cfg := &s.config.Channels.Telegram
form := baseChannelForm("Telegram", cfg.Enabled, func(v bool) { form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text) cfg.Token = strings.TrimSpace(text)
}) })
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) { form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
cfg.Proxy = strings.TrimSpace(text) cfg.Proxy = strings.TrimSpace(text)
}) })
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) discordForm() tview.Primitive { func (s *appState) discordForm() tview.Primitive {
cfg := &s.config.Channels.Discord cfg := &s.config.Channels.Discord
form := baseChannelForm("Discord", cfg.Enabled, func(v bool) { form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text) cfg.Token = strings.TrimSpace(text)
}) })
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) { form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
cfg.MentionOnly = checked cfg.MentionOnly = checked
}) })
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) qqForm() tview.Primitive { func (s *appState) qqForm() tview.Primitive {
cfg := &s.config.Channels.QQ cfg := &s.config.Channels.QQ
form := baseChannelForm("QQ", cfg.Enabled, func(v bool) { form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
cfg.AppID = strings.TrimSpace(text) cfg.AppID = strings.TrimSpace(text)
}) })
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) { form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
cfg.AppSecret = strings.TrimSpace(text) cfg.AppSecret = strings.TrimSpace(text)
}) })
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) maixcamForm() tview.Primitive { func (s *appState) maixcamForm() tview.Primitive {
cfg := &s.config.Channels.MaixCam cfg := &s.config.Channels.MaixCam
form := baseChannelForm("MaixCam", cfg.Enabled, func(v bool) { form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("Host", cfg.Host, 64, nil, func(text string) { form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
cfg.Host = strings.TrimSpace(text) cfg.Host = strings.TrimSpace(text)
}) })
addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value }) addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) whatsappForm() tview.Primitive { func (s *appState) whatsappForm() tview.Primitive {
cfg := &s.config.Channels.WhatsApp cfg := &s.config.Channels.WhatsApp
form := baseChannelForm("WhatsApp", cfg.Enabled, func(v bool) { form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) { form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
cfg.BridgeURL = strings.TrimSpace(text) cfg.BridgeURL = strings.TrimSpace(text)
}) })
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) feishuForm() tview.Primitive { func (s *appState) feishuForm() tview.Primitive {
cfg := &s.config.Channels.Feishu cfg := &s.config.Channels.Feishu
form := baseChannelForm("Feishu", cfg.Enabled, func(v bool) { form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
cfg.AppID = strings.TrimSpace(text) cfg.AppID = strings.TrimSpace(text)
}) })
@ -308,66 +183,39 @@ func (s *appState) feishuForm() tview.Primitive {
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) { form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
cfg.VerificationToken = strings.TrimSpace(text) cfg.VerificationToken = strings.TrimSpace(text)
}) })
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) dingtalkForm() tview.Primitive { func (s *appState) dingtalkForm() tview.Primitive {
cfg := &s.config.Channels.DingTalk cfg := &s.config.Channels.DingTalk
form := baseChannelForm("DingTalk", cfg.Enabled, func(v bool) { form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) { form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
cfg.ClientID = strings.TrimSpace(text) cfg.ClientID = strings.TrimSpace(text)
}) })
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) { form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
cfg.ClientSecret = strings.TrimSpace(text) cfg.ClientSecret = strings.TrimSpace(text)
}) })
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) slackForm() tview.Primitive { func (s *appState) slackForm() tview.Primitive {
cfg := &s.config.Channels.Slack cfg := &s.config.Channels.Slack
form := baseChannelForm("Slack", cfg.Enabled, func(v bool) { form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) { form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
cfg.BotToken = strings.TrimSpace(text) cfg.BotToken = strings.TrimSpace(text)
}) })
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) { form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
cfg.AppToken = strings.TrimSpace(text) cfg.AppToken = strings.TrimSpace(text)
}) })
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) lineForm() tview.Primitive { func (s *appState) lineForm() tview.Primitive {
cfg := &s.config.Channels.LINE cfg := &s.config.Channels.LINE
form := baseChannelForm("LINE", cfg.Enabled, func(v bool) { form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) { form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
cfg.ChannelSecret = strings.TrimSpace(text) cfg.ChannelSecret = strings.TrimSpace(text)
}) })
@ -381,22 +229,13 @@ func (s *appState) lineForm() tview.Primitive {
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text) cfg.WebhookPath = strings.TrimSpace(text)
}) })
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) onebotForm() tview.Primitive { func (s *appState) onebotForm() tview.Primitive {
cfg := &s.config.Channels.OneBot cfg := &s.config.Channels.OneBot
form := baseChannelForm("OneBot", cfg.Enabled, func(v bool) { form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) { form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
cfg.WSUrl = strings.TrimSpace(text) cfg.WSUrl = strings.TrimSpace(text)
}) })
@ -418,22 +257,13 @@ func (s *appState) onebotForm() tview.Primitive {
cfg.GroupTriggerPrefix = splitCSV(text) cfg.GroupTriggerPrefix = splitCSV(text)
}, },
) )
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) wecomForm() tview.Primitive { func (s *appState) wecomForm() tview.Primitive {
cfg := &s.config.Channels.WeCom cfg := &s.config.Channels.WeCom
form := baseChannelForm("WeCom", cfg.Enabled, func(v bool) { form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text) cfg.Token = strings.TrimSpace(text)
}) })
@ -450,9 +280,7 @@ func (s *appState) wecomForm() tview.Primitive {
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text) cfg.WebhookPath = strings.TrimSpace(text)
}) })
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
addIntField( addIntField(
form, form,
"Reply Timeout", "Reply Timeout",
@ -464,14 +292,7 @@ func (s *appState) wecomForm() tview.Primitive {
func (s *appState) wecomAppForm() tview.Primitive { func (s *appState) wecomAppForm() tview.Primitive {
cfg := &s.config.Channels.WeComApp cfg := &s.config.Channels.WeComApp
form := baseChannelForm("WeCom App", cfg.Enabled, func(v bool) { form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
cfg.Enabled = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
})
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) { form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
cfg.CorpID = strings.TrimSpace(text) cfg.CorpID = strings.TrimSpace(text)
}) })
@ -492,9 +313,7 @@ func (s *appState) wecomAppForm() tview.Primitive {
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text) cfg.WebhookPath = strings.TrimSpace(text)
}) })
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { addAllowFromField(form, &cfg.AllowFrom)
cfg.AllowFrom = splitCSV(text)
})
addIntField( addIntField(
form, form,
"Reply Timeout", "Reply Timeout",
@ -504,6 +323,23 @@ func (s *appState) wecomAppForm() tview.Primitive {
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {
return func(v bool) {
*enabledPtr = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
}
}
func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {
form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) {
*allowFrom = splitCSV(text)
})
}
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form { func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
form := tview.NewForm() form := tview.NewForm()
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title)) form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))

View file

@ -5,6 +5,19 @@ import (
"github.com/rivo/tview" "github.com/rivo/tview"
) )
const (
colorBlue = "[#3e5db9]"
colorRed = "[#d54646]"
banner = "\r\n[::b]" +
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"[:]"
)
func applyStyles() { func applyStyles() {
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22) tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53) tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
@ -24,14 +37,7 @@ func bannerView() *tview.TextView {
text.SetDynamicColors(true) text.SetDynamicColors(true)
text.SetTextAlign(tview.AlignCenter) text.SetTextAlign(tview.AlignCenter)
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor) text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
text.SetText( text.SetText(banner)
"[::b][#84aaff]██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
"[#84aaff]██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
"[#84aaff]██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
"[#84aaff]██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
"[#84aaff]██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
"[#84aaff]╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝",
)
text.SetBorder(false) text.SetBorder(false)
return text return text
} }

View file

@ -48,7 +48,21 @@ func NewPicoclawCommand() *cobra.Command {
return cmd return cmd
} }
const (
colorBlue = "\033[1;38;2;62;93;185m"
colorRed = "\033[1;38;2;213;70;70m"
banner = "\r\n" +
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"\033[0m\r\n"
)
func main() { func main() {
fmt.Printf("%s", banner)
cmd := NewPicoclawCommand() cmd := NewPicoclawCommand()
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
os.Exit(1) os.Exit(1)

View file

@ -49,6 +49,7 @@
"telegram": { "telegram": {
"enabled": false, "enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN", "token": "YOUR_TELEGRAM_BOT_TOKEN",
"base_url": "",
"proxy": "", "proxy": "",
"allow_from": [ "allow_from": [
"YOUR_USER_ID" "YOUR_USER_ID"
@ -127,7 +128,7 @@
"reasoning_channel_id": "" "reasoning_channel_id": ""
}, },
"wecom": { "wecom": {
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats", "_comment": "WeCom Bot - Easier setup, supports group chats",
"enabled": false, "enabled": false,
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
@ -138,7 +139,7 @@
"reasoning_channel_id": "" "reasoning_channel_id": ""
}, },
"wecom_app": { "wecom_app": {
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md", "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.",
"enabled": false, "enabled": false,
"corp_id": "YOUR_CORP_ID", "corp_id": "YOUR_CORP_ID",
"corp_secret": "YOUR_CORP_SECRET", "corp_secret": "YOUR_CORP_SECRET",
@ -149,6 +150,16 @@
"allow_from": [], "allow_from": [],
"reply_timeout": 5, "reply_timeout": 5,
"reasoning_channel_id": "" "reasoning_channel_id": ""
},
"wecom_aibot": {
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
"enabled": false,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"max_steps": 10,
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
"reasoning_channel_id": ""
} }
}, },
"providers": { "providers": {
@ -233,6 +244,71 @@
"cron": { "cron": {
"exec_timeout_minutes": 5 "exec_timeout_minutes": 5
}, },
"mcp": {
"enabled": false,
"servers": {
"context7": {
"enabled": false,
"type": "http",
"url": "https://mcp.context7.com/mcp",
"headers": {
"CONTEXT7_API_KEY": "ctx7sk-xx"
}
},
"filesystem": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp"
]
},
"github": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
}
},
"brave-search": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-brave-search"
],
"env": {
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
}
},
"postgres": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"postgresql://user:password@localhost/dbname"
]
},
"slack": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-slack"
],
"env": {
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
}
}
}
},
"exec": { "exec": {
"enable_deny_patterns": false, "enable_deny_patterns": false,
"custom_deny_patterns": [] "custom_deny_patterns": []

44
docker/Dockerfile.full Normal file
View file

@ -0,0 +1,44 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
FROM golang:1.26.0-alpine AS builder
RUN apk add --no-cache git make
WORKDIR /src
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN make build
# ============================================================
# Stage 2: Node.js-based runtime with full MCP support
# ============================================================
FROM node:24-alpine3.23
# Install runtime dependencies
RUN apk add --no-cache \
ca-certificates \
curl \
git \
python3 \
py3-pip
# Install uv and symlink to system path
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \
uv --version
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
# Create picoclaw home directory
RUN /usr/local/bin/picoclaw onboard
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]

View file

@ -0,0 +1,44 @@
services:
# ─────────────────────────────────────────────
# PicoClaw Agent (one-shot query) - Full MCP Support
# docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent -m "Hello"
# ─────────────────────────────────────────────
picoclaw-agent:
build:
context: ..
dockerfile: docker/Dockerfile.full
container_name: picoclaw-agent-full
profiles:
- agent
volumes:
- ../config/config.json:/root/.picoclaw/config.json:ro
- picoclaw-workspace:/root/.picoclaw/workspace
- picoclaw-npm-cache:/root/.npm # npm cache for faster MCP server installs
entrypoint: ["picoclaw", "agent"]
stdin_open: true
tty: true
# ─────────────────────────────────────────────
# PicoClaw Gateway (Long-running Bot) - Full MCP Support
# docker compose -f docker/docker-compose.full.yml --profile gateway up
# ─────────────────────────────────────────────
picoclaw-gateway:
build:
context: ..
dockerfile: docker/Dockerfile.full
container_name: picoclaw-gateway-full
restart: unless-stopped
profiles:
- gateway
volumes:
# Configuration file
- ../config/config.json:/root/.picoclaw/config.json:ro
# Persistent workspace (sessions, memory, logs)
- picoclaw-workspace:/root/.picoclaw/workspace
# NPM cache for faster MCP server installs
- picoclaw-npm-cache:/root/.npm
command: ["gateway"]
volumes:
picoclaw-workspace:
picoclaw-npm-cache: # Cache npm packages to speed up MCP server installations

View file

@ -0,0 +1,116 @@
# 企业微信智能机器人 (AI Bot)
企业微信智能机器人AI Bot是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议,并支持超时后通过 `response_url` 主动推送最终回复。
## 与其他 WeCom 通道的对比
| 特性 | WeCom Bot | WeCom App | **WeCom AI Bot** |
|------|-----------|-----------|-----------------|
| 私聊 | ✅ | ✅ | ✅ |
| 群聊 | ✅ | ❌ | ✅ |
| 流式输出 | ❌ | ❌ | ✅ |
| 超时主动推送 | ❌ | ✅ | ✅ |
| 配置复杂度 | 低 | 高 | 中 |
## 配置
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "你好!有什么可以帮助你的吗?",
"max_steps": 10
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ---------------- | ------ | ---- | -------------------------------------------------- |
| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 |
| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 |
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot |
| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 |
| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 |
| reply_timeout | int | 否 | 回复超时时间默认5 |
| max_steps | int | 否 | Agent 最大执行步骤数默认10 |
## 设置流程
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
3. 在 AI Bot 配置页面,填写"消息接收"信息:
- **URL**`http://<your-server-ip>:18791/webhook/wecom-aibot`
- **Token**:随机生成或自定义
- **EncodingAESKey**:点击"随机生成",得到 43 字符密钥
4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存(企业微信会发送验证请求)
> [!TIP]
> 服务器需要能被企业微信服务器访问。如在内网/本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。
## 流式响应协议
WeCom AI Bot 使用"流式拉取"协议,区别于普通 Webhook 的一次性回复:
```
用户发消息
PicoClaw 立即返回 {finish: false}Agent 开始处理)
企业微信每隔约 1 秒拉取一次 {msgtype: "stream", stream: {id: "..."}}
├─ Agent 未完成 → 返回 {finish: false}(继续等待)
└─ Agent 完成 → 返回 {finish: true, content: "回答内容"}
```
**超时处理**(任务超过 30 秒):
若 Agent 处理时间超过约 30 秒(企业微信最大轮询窗口为 6 分钟PicoClaw 会:
1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」
2. Agent 继续在后台运行
3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户
> `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。
## 欢迎语
配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件PicoClaw 会自动回复该欢迎语。留空则静默忽略。
```json
"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?"
```
## 常见问题
### 回调 URL 验证失败
- 确认服务器防火墙已开放对应端口(默认 18791
- 确认 `token``encoding_aes_key` 填写正确
- 检查 PicoClaw 日志是否收到了来自企业微信的 GET 请求
### 消息没有回复
- 检查 `allow_from` 是否意外限制了发送者
- 查看日志中是否出现 `context canceled` 或 Agent 错误
- 确认 Agent 配置(`model_name` 等)正确
### 超长任务没有收到最终推送
- 确认消息回调中携带了 `response_url`(仅企业微信新版 AI Bot 支持)
- 确认服务器能主动访问外网(需向 `response_url` POST 请求)
- 查看日志关键词 `response_url mode``Sending reply via response_url`
## 参考文档
- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/100719)
- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719)
- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138)

View file

@ -8,6 +8,7 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
{ {
"tools": { "tools": {
"web": { ... }, "web": { ... },
"mcp": { ... },
"exec": { ... }, "exec": { ... },
"cron": { ... }, "cron": { ... },
"skills": { ... } "skills": { ... }
@ -22,7 +23,7 @@ Web tools are used for web search and fetching.
### Brave ### Brave
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ------------- | ------ | ------- | ------------------------- |
| `enabled` | bool | false | Enable Brave search | | `enabled` | bool | false | Enable Brave search |
| `api_key` | string | - | Brave Search API key | | `api_key` | string | - | Brave Search API key |
| `max_results` | int | 5 | Maximum number of results | | `max_results` | int | 5 | Maximum number of results |
@ -30,14 +31,14 @@ Web tools are used for web search and fetching.
### DuckDuckGo ### DuckDuckGo
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ------------- | ---- | ------- | ------------------------- |
| `enabled` | bool | true | Enable DuckDuckGo search | | `enabled` | bool | true | Enable DuckDuckGo search |
| `max_results` | int | 5 | Maximum number of results | | `max_results` | int | 5 | Maximum number of results |
### Perplexity ### Perplexity
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ------------- | ------ | ------- | ------------------------- |
| `enabled` | bool | false | Enable Perplexity search | | `enabled` | bool | false | Enable Perplexity search |
| `api_key` | string | - | Perplexity API key | | `api_key` | string | - | Perplexity API key |
| `max_results` | int | 5 | Maximum number of results | | `max_results` | int | 5 | Maximum number of results |
@ -47,7 +48,7 @@ Web tools are used for web search and fetching.
The exec tool is used to execute shell commands. The exec tool is used to execute shell commands.
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ---------------------- | ----- | ------- | ------------------------------------------ |
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | | `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | | `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
@ -80,10 +81,7 @@ By default, PicoClaw blocks the following dangerous commands:
"tools": { "tools": {
"exec": { "exec": {
"enable_deny_patterns": true, "enable_deny_patterns": true,
"custom_deny_patterns": [ "custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"]
"\\brm\\s+-r\\b",
"\\bkillall\\s+python"
]
} }
} }
} }
@ -94,9 +92,84 @@ By default, PicoClaw blocks the following dangerous commands:
The cron tool is used for scheduling periodic tasks. The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ---------------------- | ---- | ------- | ---------------------------------------------- |
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | | `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
## MCP Tool
The MCP tool enables integration with external Model Context Protocol servers.
### Global Config
| Config | Type | Default | Description |
| --------- | ------ | ------- | ----------------------------------- |
| `enabled` | bool | false | Enable MCP integration globally |
| `servers` | object | `{}` | Map of server name to server config |
### Per-Server Config
| Config | Type | Required | Description |
| ---------- | ------ | -------- | ------------------------------------------ |
| `enabled` | bool | yes | Enable this MCP server |
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
| `command` | string | stdio | Executable command for stdio transport |
| `args` | array | no | Command arguments for stdio transport |
| `env` | object | no | Environment variables for stdio process |
| `env_file` | string | no | Path to environment file for stdio process |
| `url` | string | sse/http | Endpoint URL for `sse`/`http` transport |
| `headers` | object | no | HTTP headers for `sse`/`http` transport |
### Transport Behavior
- If `type` is omitted, transport is auto-detected:
- `url` is set → `sse`
- `command` is set → `stdio`
- `http` and `sse` both use `url` + optional `headers`.
- `env` and `env_file` are only applied to `stdio` servers.
### Configuration Examples
#### 1) Stdio MCP server
```json
{
"tools": {
"mcp": {
"enabled": true,
"servers": {
"filesystem": {
"enabled": true,
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}
}
}
```
#### 2) Remote SSE/HTTP MCP server
```json
{
"tools": {
"mcp": {
"enabled": true,
"servers": {
"remote-mcp": {
"enabled": true,
"type": "sse",
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_TOKEN"
}
}
}
}
}
}
```
## Skills Tool ## Skills Tool
The skills tool configures skill discovery and installation via registries like ClawHub. The skills tool configures skill discovery and installation via registries like ClawHub.
@ -104,7 +177,7 @@ The skills tool configures skill discovery and installation via registries like
### Registries ### Registries
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ---------------------------------- | ------ | -------------------- | ----------------------- |
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | | `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | | `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
@ -136,8 +209,10 @@ The skills tool configures skill discovery and installation via registries like
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`: All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`:
For example: For example:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
Note: Array-type environment variables are not currently supported and must be set via the config file. Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than environment variables.

View file

@ -1,115 +0,0 @@
# 企业微信自建应用 (WeCom App) 配置指南
本文档介绍如何在 PicoClaw 中配置企业微信自建应用 (wecom-app) 通道。
## 功能特性
| 功能 | 支持状态 |
|------|---------|
| 被动接收消息 | ✅ |
| 主动发送消息 | ✅ |
| 私聊 | ✅ |
| 群聊 | ❌ |
## 配置步骤
### 1. 企业微信后台配置
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
2. 进入"应用管理" → 选择自建应用
3. 记录以下信息:
- **AgentId**: 应用详情页显示
- **Secret**: 点击"查看"获取
4. 进入"我的企业"页面,记录 **企业ID** (CorpID)
### 2. 接收消息配置
1. 在应用详情页,点击"接收消息"的"设置API接收"
2. 填写以下信息:
- **URL**: `http://your-server:18790/webhook/wecom-app`
- **Token**: 随机生成或自定义(用于签名验证)
- **EncodingAESKey**: 点击"随机生成"生成43字符的密钥
3. 点击"保存"时,企业微信会发送验证请求
### 3. PicoClaw 配置
`config.json` 中添加以下配置:
```json
{
"channels": {
"wecom_app": {
"enabled": true,
"corp_id": "wwxxxxxxxxxxxxxxxx", // 企业ID
"corp_secret": "xxxxxxxxxxxxxxxxxxxxxxxx", // 应用Secret
"agent_id": 1000002, // 应用AgentId
"token": "your_token", // 接收消息配置的Token
"encoding_aes_key": "your_encoding_aes_key", // 接收消息配置的EncodingAESKey
"webhook_path": "/webhook/wecom-app",
"allow_from": [],
"reply_timeout": 5
}
}
}
```
## 常见问题
### 1. 回调URL验证失败
**症状**: 企业微信保存API接收消息时提示验证失败
**检查项**:
- 确认服务器防火墙已开放 Gateway 端口(默认 18790
- 确认 `corp_id``token``encoding_aes_key` 配置正确
- 查看 PicoClaw 日志是否有请求到达
### 2. 中文消息解密失败
**症状**: 发送中文消息时出现 `invalid padding size` 错误
**原因**: 企业微信使用非标准的 PKCS7 填充32字节块大小
**解决**: 确保使用最新版本的 PicoClaw已修复此问题。
### 3. 端口冲突
**症状**: 启动时提示端口已被占用
**解决**: 修改 `gateway.port` 为其他端口(所有 Webhook 渠道共享同一个 Gateway HTTP 服务器)
## 技术细节
### 加密算法
- **算法**: AES-256-CBC
- **密钥**: EncodingAESKey Base64解码后的32字节
- **IV**: AESKey的前16字节
- **填充**: PKCS7块大小为32字节非标准16字节
- **消息格式**: XML
### 消息结构
解密后的消息格式:
```
random(16B) + msg_len(4B) + msg + receiveid
```
其中 `receiveid` 对于自建应用是 `corp_id`
## 调试
启用调试模式查看详细日志:
```bash
picoclaw gateway --debug
```
关键日志标识:
- `wecom_app`: WeCom App 通道相关日志
- `wecom_common`: 加密解密相关日志
## 参考文档
- [企业微信官方文档 - 接收消息](https://developer.work.weixin.qq.com/document/path/96211)
- [企业微信官方加解密库](https://github.com/sbzhu/weworkapi_golang)

6
go.mod
View file

@ -8,13 +8,16 @@ require (
github.com/bwmarrin/discordgo v0.29.0 github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/chzyer/readline v1.5.1 github.com/chzyer/readline v1.5.1
github.com/gdamore/tcell/v2 v2.13.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1 github.com/mdp/qrterminal/v3 v3.2.1
github.com/modelcontextprotocol/go-sdk v1.3.0
github.com/mymmrac/telego v1.6.0 github.com/mymmrac/telego v1.6.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0 github.com/openai/openai-go/v3 v3.22.0
github.com/rivo/tview v0.42.0
github.com/slack-go/slack v0.17.3 github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
@ -34,7 +37,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.13.8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
@ -43,7 +45,6 @@ require (
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/tview v0.42.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.34.0 // indirect github.com/rs/zerolog v1.34.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
@ -81,6 +82,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/fastjson v1.6.7 // indirect github.com/valyala/fastjson v1.6.7 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.24.0 // indirect golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.50.0 // indirect

6
go.sum
View file

@ -66,6 +66,8 @@ github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncV
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@ -130,6 +132,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@ -212,6 +216,8 @@ github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTd
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=

View file

@ -1,9 +1,11 @@
package agent package agent
import ( import (
"fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
@ -48,18 +50,24 @@ func NewAgentInstance(
fallbacks := resolveAgentFallbacks(agentCfg, defaults) fallbacks := resolveAgentFallbacks(agentCfg, defaults)
restrict := defaults.RestrictToWorkspace restrict := defaults.RestrictToWorkspace
readRestrict := restrict && !defaults.AllowReadOutsideWorkspace
// Compile path whitelist patterns from config.
allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths)
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
toolsRegistry := tools.NewToolRegistry() toolsRegistry := tools.NewToolRegistry()
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths))
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict)) toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg) execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
if err != nil { if err != nil {
log.Fatalf("Critical error: unable to initialize exec tool: %v", err) log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
} }
toolsRegistry.Register(execTool) toolsRegistry.Register(execTool)
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
sessionsDir := filepath.Join(workspace, "sessions") sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir) sessionsManager := session.NewSessionManager(sessionsDir)
@ -189,6 +197,19 @@ func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentD
return defaults.ModelFallbacks return defaults.ModelFallbacks
} }
func compilePatterns(patterns []string) []*regexp.Regexp {
compiled := make([]*regexp.Regexp, 0, len(patterns))
for _, p := range patterns {
re, err := regexp.Compile(p)
if err != nil {
fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err)
continue
}
compiled = append(compiled, re)
}
return compiled
}
func expandHome(path string) string { func expandHome(path string) string {
if path == "" { if path == "" {
return path return path

View file

@ -95,6 +95,34 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
} }
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
tests := []struct {
name string
aliasName string
modelName string
apiBase string
wantProvider string
wantModel string
}{
{
name: "alias with provider prefix",
aliasName: "step-3.5-flash",
modelName: "openrouter/stepfun/step-3.5-flash:free",
apiBase: "https://openrouter.ai/api/v1",
wantProvider: "openrouter",
wantModel: "stepfun/step-3.5-flash:free",
},
{
name: "alias without provider prefix",
aliasName: "glm-5",
modelName: "glm-5",
apiBase: "https://api.z.ai/api/coding/paas/v4",
wantProvider: "openai",
wantModel: "glm-5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
@ -105,14 +133,14 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "step-3.5-flash", Model: tt.aliasName,
}, },
}, },
ModelList: []config.ModelConfig{ ModelList: []config.ModelConfig{
{ {
ModelName: "step-3.5-flash", ModelName: tt.aliasName,
Model: "openrouter/stepfun/step-3.5-flash:free", Model: tt.modelName,
APIBase: "https://openrouter.ai/api/v1", APIBase: tt.apiBase,
}, },
}, },
} }
@ -123,47 +151,12 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
if len(agent.Candidates) != 1 { if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
} }
if agent.Candidates[0].Provider != "openrouter" { if agent.Candidates[0].Provider != tt.wantProvider {
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter") t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider)
} }
if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" { if agent.Candidates[0].Model != tt.wantModel {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free") t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel)
} }
} })
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "glm-5",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "glm-5",
Model: "glm-5",
APIBase: "https://api.z.ai/api/coding/paas/v4",
},
},
}
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
if agent.Candidates[0].Provider != "openai" {
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
}
if agent.Candidates[0].Model != "glm-5" {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
} }
} }

View file

@ -23,6 +23,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
@ -58,7 +59,11 @@ type processOptions struct {
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop { func NewAgentLoop(
cfg *config.Config,
msgBus *bus.MessageBus,
provider providers.LLMProvider,
) *AgentLoop {
registry := NewAgentRegistry(cfg, provider) registry := NewAgentRegistry(cfg, provider)
// Register shared tools to all agents // Register shared tools to all agents
@ -170,6 +175,72 @@ func registerSharedTools(
func (al *AgentLoop) Run(ctx context.Context) error { func (al *AgentLoop) Run(ctx context.Context) error {
al.running.Store(true) al.running.Store(true)
// Initialize MCP servers for all agents
if al.cfg.Tools.MCP.Enabled {
mcpManager := mcp.NewManager()
// Ensure MCP connections are cleaned up on exit, regardless of initialization success
// This fixes resource leak when LoadFromMCPConfig partially succeeds then fails
defer func() {
if err := mcpManager.Close(); err != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": err.Error(),
})
}
}()
defaultAgent := al.registry.GetDefaultAgent()
var workspacePath string
if defaultAgent != nil && defaultAgent.Workspace != "" {
workspacePath = defaultAgent.Workspace
} else {
workspacePath = al.cfg.WorkspacePath()
}
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
map[string]any{
"error": err.Error(),
})
} else {
// Register MCP tools for all agents
servers := mcpManager.GetServers()
uniqueTools := 0
totalRegistrations := 0
agentIDs := al.registry.ListAgentIDs()
agentCount := len(agentIDs)
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
agent.Tools.Register(mcpTool)
totalRegistrations++
logger.DebugCF("agent", "Registered MCP tool",
map[string]any{
"agent_id": agentID,
"server": serverName,
"tool": tool.Name,
"name": mcpTool.Name(),
})
}
}
}
logger.InfoCF("agent", "MCP tools registered successfully",
map[string]any{
"server_count": len(servers),
"unique_tools": uniqueTools,
"total_registrations": totalRegistrations,
"agent_count": agentCount,
})
}
}
for al.running.Load() { for al.running.Load() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -310,7 +381,10 @@ func (al *AgentLoop) RecordLastChatID(chatID string) error {
return al.state.SetLastChatID(chatID) return al.state.SetLastChatID(chatID)
} }
func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) { func (al *AgentLoop) ProcessDirect(
ctx context.Context,
content, sessionKey string,
) (string, error) {
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
} }
@ -331,7 +405,10 @@ func (al *AgentLoop) ProcessDirectWithChannel(
// ProcessHeartbeat processes a heartbeat request without session history. // ProcessHeartbeat processes a heartbeat request without session history.
// Each heartbeat is independent and doesn't accumulate context. // Each heartbeat is independent and doesn't accumulate context.
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { func (al *AgentLoop) ProcessHeartbeat(
ctx context.Context,
content, channel, chatID string,
) (string, error) {
agent := al.registry.GetDefaultAgent() agent := al.registry.GetDefaultAgent()
if agent == nil { if agent == nil {
return "", fmt.Errorf("no default agent for heartbeat") return "", fmt.Errorf("no default agent for heartbeat")
@ -356,13 +433,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
} else { } else {
logContent = utils.Truncate(msg.Content, 80) logContent = utils.Truncate(msg.Content, 80)
} }
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), logger.InfoCF(
"agent",
fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
map[string]any{ map[string]any{
"channel": msg.Channel, "channel": msg.Channel,
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
"sender_id": msg.SenderID, "sender_id": msg.SenderID,
"session_key": msg.SessionKey, "session_key": msg.SessionKey,
}) },
)
// Route system messages to processSystemMessage // Route system messages to processSystemMessage
if msg.Channel == "system" { if msg.Channel == "system" {
@ -423,9 +503,15 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}) })
} }
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { func (al *AgentLoop) processSystemMessage(
ctx context.Context,
msg bus.InboundMessage,
) (string, error) {
if msg.Channel != "system" { if msg.Channel != "system" {
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel) return "", fmt.Errorf(
"processSystemMessage called with non-system message channel: %s",
msg.Channel,
)
} }
logger.InfoCF("agent", "Processing system message", logger.InfoCF("agent", "Processing system message",
@ -483,14 +569,22 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
} }
// runAgentLoop is the core message processing logic. // runAgentLoop is the core message processing logic.
func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) { func (al *AgentLoop) runAgentLoop(
ctx context.Context,
agent *AgentInstance,
opts processOptions,
) (string, error) {
// 0. Record last channel for heartbeat notifications (skip internal channels) // 0. Record last channel for heartbeat notifications (skip internal channels)
if opts.Channel != "" && opts.ChatID != "" { if opts.Channel != "" && opts.ChatID != "" {
// Don't record internal channels (cli, system, subagent) // Don't record internal channels (cli, system, subagent)
if !constants.IsInternalChannel(opts.Channel) { if !constants.IsInternalChannel(opts.Channel) {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil { if err := al.RecordLastChannel(channelKey); err != nil {
logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()}) logger.WarnCF(
"agent",
"Failed to record last channel",
map[string]any{"error": err.Error()},
)
} }
} }
} }
@ -572,7 +666,10 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string
return "" return ""
} }
func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) { func (al *AgentLoop) handleReasoning(
ctx context.Context,
reasoningContent, channelName, channelID string,
) {
if reasoningContent == "" || channelName == "" || channelID == "" { if reasoningContent == "" || channelName == "" || channelID == "" {
return return
} }
@ -665,22 +762,33 @@ func (al *AgentLoop) runLLMIteration(
callLLM := func() (*providers.LLMResponse, error) { callLLM := func() (*providers.LLMResponse, error) {
if len(agent.Candidates) > 1 && al.fallback != nil { if len(agent.Candidates) > 1 && al.fallback != nil {
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates, fbResult, fbErr := al.fallback.Execute(
ctx,
agent.Candidates,
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{ return agent.Provider.Chat(
ctx,
messages,
providerToolDefs,
model,
map[string]any{
"max_tokens": agent.MaxTokens, "max_tokens": agent.MaxTokens,
"temperature": agent.Temperature, "temperature": agent.Temperature,
"prompt_cache_key": agent.ID, "prompt_cache_key": agent.ID,
}) },
)
}, },
) )
if fbErr != nil { if fbErr != nil {
return nil, fbErr return nil, fbErr
} }
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", logger.InfoCF(
"agent",
fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
map[string]any{"agent_id": agent.ID, "iteration": iteration}) map[string]any{"agent_id": agent.ID, "iteration": iteration},
)
} }
return fbResult.Response, nil return fbResult.Response, nil
} }
@ -731,10 +839,14 @@ func (al *AgentLoop) runLLMIteration(
} }
if isContextError && retry < maxRetries { if isContextError && retry < maxRetries {
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ logger.WarnCF(
"agent",
"Context window error detected, attempting compression",
map[string]any{
"error": err.Error(), "error": err.Error(),
"retry": retry, "retry": retry,
}) },
)
if retry == 0 && !constants.IsInternalChannel(opts.Channel) { if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
@ -766,7 +878,12 @@ func (al *AgentLoop) runLLMIteration(
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
} }
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel)) go al.handleReasoning(
ctx,
response.Reasoning,
opts.Channel,
al.targetReasoningChannelID(opts.Channel),
)
logger.DebugCF("agent", "LLM response", logger.DebugCF("agent", "LLM response",
map[string]any{ map[string]any{
@ -1068,7 +1185,11 @@ func formatMessagesForLog(messages []providers.Message) string {
for _, tc := range msg.ToolCalls { for _, tc := range msg.ToolCalls {
fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
if tc.Function != nil { if tc.Function != nil {
fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200)) fmt.Fprintf(
&sb,
" Arguments: %s\n",
utils.Truncate(tc.Function.Arguments, 200),
)
} }
} }
} }
@ -1097,7 +1218,11 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
if len(tool.Function.Parameters) > 0 { if len(tool.Function.Parameters) > 0 {
fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200)) fmt.Fprintf(
&sb,
" Parameters: %s\n",
utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200),
)
} }
} }
sb.WriteString("]") sb.WriteString("]")
@ -1194,7 +1319,9 @@ func (al *AgentLoop) summarizeBatch(
existingSummary string, existingSummary string,
) (string, error) { ) (string, error) {
var sb strings.Builder var sb strings.Builder
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") sb.WriteString(
"Provide a concise summary of this conversation segment, preserving core context and key points.\n",
)
if existingSummary != "" { if existingSummary != "" {
sb.WriteString("Existing context: ") sb.WriteString("Existing context: ")
sb.WriteString(existingSummary) sb.WriteString(existingSummary)

View file

@ -27,16 +27,15 @@ func (f *fakeChannel) IsAllowed(string) bool {
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
func (f *fakeChannel) ReasoningChannelID() string { return f.id } func (f *fakeChannel) ReasoningChannelID() string { return f.id }
func TestRecordLastChannel(t *testing.T) { func newTestAgentLoop(
// Create temp workspace t *testing.T,
) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
} }
defer os.RemoveAll(tmpDir) cfg = &config.Config{
// Create test config
cfg := &config.Config{
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
@ -46,74 +45,43 @@ func TestRecordLastChannel(t *testing.T) {
}, },
}, },
} }
msgBus = bus.NewMessageBus()
provider = &mockProvider{}
al = NewAgentLoop(cfg, msgBus, provider)
return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }
}
// Create agent loop func TestRecordLastChannel(t *testing.T) {
msgBus := bus.NewMessageBus() al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
provider := &mockProvider{} defer cleanup()
al := NewAgentLoop(cfg, msgBus, provider)
// Test RecordLastChannel
testChannel := "test-channel" testChannel := "test-channel"
err = al.RecordLastChannel(testChannel) if err := al.RecordLastChannel(testChannel); err != nil {
if err != nil {
t.Fatalf("RecordLastChannel failed: %v", err) t.Fatalf("RecordLastChannel failed: %v", err)
} }
if got := al.state.GetLastChannel(); got != testChannel {
// Verify channel was saved t.Errorf("Expected channel '%s', got '%s'", testChannel, got)
lastChannel := al.state.GetLastChannel()
if lastChannel != testChannel {
t.Errorf("Expected channel '%s', got '%s'", testChannel, lastChannel)
} }
// Verify persistence by creating a new agent loop
al2 := NewAgentLoop(cfg, msgBus, provider) al2 := NewAgentLoop(cfg, msgBus, provider)
if al2.state.GetLastChannel() != testChannel { if got := al2.state.GetLastChannel(); got != testChannel {
t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, al2.state.GetLastChannel()) t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, got)
} }
} }
func TestRecordLastChatID(t *testing.T) { func TestRecordLastChatID(t *testing.T) {
// Create temp workspace al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
tmpDir, err := os.MkdirTemp("", "agent-test-*") defer cleanup()
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test config
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
// Create agent loop
msgBus := bus.NewMessageBus()
provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
// Test RecordLastChatID
testChatID := "test-chat-id-123" testChatID := "test-chat-id-123"
err = al.RecordLastChatID(testChatID) if err := al.RecordLastChatID(testChatID); err != nil {
if err != nil {
t.Fatalf("RecordLastChatID failed: %v", err) t.Fatalf("RecordLastChatID failed: %v", err)
} }
if got := al.state.GetLastChatID(); got != testChatID {
// Verify chat ID was saved t.Errorf("Expected chat ID '%s', got '%s'", testChatID, got)
lastChatID := al.state.GetLastChatID()
if lastChatID != testChatID {
t.Errorf("Expected chat ID '%s', got '%s'", testChatID, lastChatID)
} }
// Verify persistence by creating a new agent loop
al2 := NewAgentLoop(cfg, msgBus, provider) al2 := NewAgentLoop(cfg, msgBus, provider)
if al2.state.GetLastChatID() != testChatID { if got := al2.state.GetLastChatID(); got != testChatID {
t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, al2.state.GetLastChatID()) t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got)
} }
} }

View file

@ -255,6 +255,10 @@ func (m *Manager) initChannels() error {
m.initChannel("wecom", "WeCom") m.initChannel("wecom", "WeCom")
} }
if m.config.Channels.WeComAIBot.Enabled && m.config.Channels.WeComAIBot.Token != "" {
m.initChannel("wecom_aibot", "WeCom AI Bot")
}
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" { if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
m.initChannel("wecom_app", "WeCom App") m.initChannel("wecom_app", "WeCom App")
} }
@ -539,86 +543,88 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork
}) })
} }
func (m *Manager) dispatchOutbound(ctx context.Context) { func dispatchLoop[M any](
logger.InfoC("channels", "Outbound dispatcher started") ctx context.Context,
m *Manager,
subscribe func(context.Context) (M, bool),
getChannel func(M) string,
enqueue func(context.Context, *channelWorker, M) bool,
startMsg, stopMsg, unknownMsg, noWorkerMsg string,
) {
logger.InfoC("channels", startMsg)
for { for {
msg, ok := m.bus.SubscribeOutbound(ctx) msg, ok := subscribe(ctx)
if !ok { if !ok {
logger.InfoC("channels", "Outbound dispatcher stopped") logger.InfoC("channels", stopMsg)
return return
} }
channel := getChannel(msg)
// Silently skip internal channels // Silently skip internal channels
if constants.IsInternalChannel(msg.Channel) { if constants.IsInternalChannel(channel) {
continue continue
} }
m.mu.RLock() m.mu.RLock()
_, exists := m.channels[msg.Channel] _, exists := m.channels[channel]
w, wExists := m.workers[msg.Channel] w, wExists := m.workers[channel]
m.mu.RUnlock() m.mu.RUnlock()
if !exists { if !exists {
logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{ logger.WarnCF("channels", unknownMsg, map[string]any{"channel": channel})
"channel": msg.Channel,
})
continue continue
} }
if wExists && w != nil { if wExists && w != nil {
select { if !enqueue(ctx, w, msg) {
case w.queue <- msg:
case <-ctx.Done():
return return
} }
} else if exists { } else if exists {
logger.WarnCF("channels", "Channel has no active worker, skipping message", map[string]any{ logger.WarnCF("channels", noWorkerMsg, map[string]any{"channel": channel})
"channel": msg.Channel,
})
} }
} }
} }
func (m *Manager) dispatchOutbound(ctx context.Context) {
dispatchLoop(
ctx, m,
m.bus.SubscribeOutbound,
func(msg bus.OutboundMessage) string { return msg.Channel },
func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool {
select {
case w.queue <- msg:
return true
case <-ctx.Done():
return false
}
},
"Outbound dispatcher started",
"Outbound dispatcher stopped",
"Unknown channel for outbound message",
"Channel has no active worker, skipping message",
)
}
func (m *Manager) dispatchOutboundMedia(ctx context.Context) { func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
logger.InfoC("channels", "Outbound media dispatcher started") dispatchLoop(
ctx, m,
for { m.bus.SubscribeOutboundMedia,
msg, ok := m.bus.SubscribeOutboundMedia(ctx) func(msg bus.OutboundMediaMessage) string { return msg.Channel },
if !ok { func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool {
logger.InfoC("channels", "Outbound media dispatcher stopped")
return
}
// Silently skip internal channels
if constants.IsInternalChannel(msg.Channel) {
continue
}
m.mu.RLock()
_, exists := m.channels[msg.Channel]
w, wExists := m.workers[msg.Channel]
m.mu.RUnlock()
if !exists {
logger.WarnCF("channels", "Unknown channel for outbound media message", map[string]any{
"channel": msg.Channel,
})
continue
}
if wExists && w != nil {
select { select {
case w.mediaQueue <- msg: case w.mediaQueue <- msg:
return true
case <-ctx.Done(): case <-ctx.Done():
return return false
}
} else if exists {
logger.WarnCF("channels", "Channel has no active worker, skipping media message", map[string]any{
"channel": msg.Channel,
})
}
} }
},
"Outbound media dispatcher started",
"Outbound media dispatcher stopped",
"Unknown channel for outbound media message",
"Channel has no active worker, skipping media message",
)
} }
// runMediaWorker processes outbound media messages for a single channel. // runMediaWorker processes outbound media messages for a single channel.

View file

@ -7,12 +7,12 @@ import (
"net/url" "net/url"
"os" "os"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegohandler"
th "github.com/mymmrac/telego/telegohandler" th "github.com/mymmrac/telego/telegohandler"
tu "github.com/mymmrac/telego/telegoutil" tu "github.com/mymmrac/telego/telegoutil"
@ -41,7 +41,7 @@ var (
type TelegramChannel struct { type TelegramChannel struct {
*channels.BaseChannel *channels.BaseChannel
bot *telego.Bot bot *telego.Bot
bh *telegohandler.BotHandler bh *th.BotHandler
commands TelegramCommander commands TelegramCommander
config *config.Config config *config.Config
chatIDs map[string]int64 chatIDs map[string]int64
@ -72,6 +72,10 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
})) }))
} }
if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
opts = append(opts, telego.WithAPIServer(baseURL))
}
bot, err := telego.NewBot(telegramCfg.Token, opts...) bot, err := telego.NewBot(telegramCfg.Token, opts...)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err) return nil, fmt.Errorf("failed to create telegram bot: %w", err)
@ -101,6 +105,12 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
c.ctx, c.cancel = context.WithCancel(ctx) c.ctx, c.cancel = context.WithCancel(ctx)
if err := c.initBotCommands(c.ctx); err != nil {
logger.WarnCF("telegram", "Failed to initialize bot commands", map[string]any{
"error": err.Error(),
})
}
updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{ updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
Timeout: 30, Timeout: 30,
}) })
@ -109,20 +119,19 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to start long polling: %w", err) return fmt.Errorf("failed to start long polling: %w", err)
} }
bh, err := telegohandler.NewBotHandler(c.bot, updates) bh, err := th.NewBotHandler(c.bot, updates)
if err != nil { if err != nil {
c.cancel() c.cancel()
return fmt.Errorf("failed to create bot handler: %w", err) return fmt.Errorf("failed to create bot handler: %w", err)
} }
c.bh = bh c.bh = bh
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
c.commands.Help(ctx, message)
return nil
}, th.CommandEqual("help"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.commands.Start(ctx, message) return c.commands.Start(ctx, message)
}, th.CommandEqual("start")) }, th.CommandEqual("start"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.commands.Help(ctx, message)
}, th.CommandEqual("help"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.commands.Show(ctx, message) return c.commands.Show(ctx, message)
@ -141,7 +150,13 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
"username": c.bot.Username(), "username": c.bot.Username(),
}) })
go bh.Start() go func() {
if err = bh.Start(); err != nil {
logger.ErrorCF("telegram", "Bot handler failed", map[string]any{
"error": err.Error(),
})
}
}()
return nil return nil
} }
@ -152,7 +167,7 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
// Stop the bot handler // Stop the bot handler
if c.bh != nil { if c.bh != nil {
c.bh.Stop() _ = c.bh.StopWithContext(ctx)
} }
// Cancel our context (stops long polling) // Cancel our context (stops long polling)
@ -163,6 +178,51 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
return nil return nil
} }
func (c *TelegramChannel) initBotCommands(ctx context.Context) error {
currentCommands, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{
Scope: tu.ScopeDefault(),
})
if err != nil {
return fmt.Errorf("get commands: %w", err)
}
commands := []telego.BotCommand{
{
Command: "start",
Description: "Start the bot",
},
{
Command: "help",
Description: "Show a help message",
},
{
Command: "show",
Description: "Show current configuration",
},
{
Command: "list",
Description: "List available options",
},
}
// Setting commands on each start will hit the rate limit very quickly, that's why we check if an update is needed
if !slices.Equal(currentCommands, commands) {
logger.InfoC("telegram", "Updating bot commands")
err = c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
Commands: commands,
Scope: tu.ScopeDefault(),
})
if err != nil {
return fmt.Errorf("set commands: %w", err)
}
} else {
logger.DebugC("telegram", "Bot commands are up to date")
}
return nil
}
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() { if !c.IsRunning() {
return channels.ErrNotRunning return channels.ErrNotRunning

1014
pkg/channels/wecom/aibot.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,210 @@
package wecom
import (
"context"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewWeComAIBotChannel(t *testing.T) {
t.Run("success with valid config", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
WebhookPath: "/webhook/test",
}
messageBus := bus.NewMessageBus()
ch, err := NewWeComAIBotChannel(cfg, messageBus)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if ch == nil {
t.Fatal("Expected channel to be created")
}
if ch.Name() != "wecom_aibot" {
t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
}
})
t.Run("error with missing token", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}
messageBus := bus.NewMessageBus()
_, err := NewWeComAIBotChannel(cfg, messageBus)
if err == nil {
t.Fatal("Expected error for missing token, got nil")
}
})
t.Run("error with missing encoding key", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
}
messageBus := bus.NewMessageBus()
_, err := NewWeComAIBotChannel(cfg, messageBus)
if err == nil {
t.Fatal("Expected error for missing encoding key, got nil")
}
})
}
func TestWeComAIBotChannelStartStop(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}
messageBus := bus.NewMessageBus()
ch, err := NewWeComAIBotChannel(cfg, messageBus)
if err != nil {
t.Fatalf("Failed to create channel: %v", err)
}
ctx := context.Background()
// Test Start
if err := ch.Start(ctx); err != nil {
t.Fatalf("Failed to start channel: %v", err)
}
if !ch.IsRunning() {
t.Error("Expected channel to be running")
}
// Test Stop
if err := ch.Stop(ctx); err != nil {
t.Fatalf("Failed to stop channel: %v", err)
}
if ch.IsRunning() {
t.Error("Expected channel to be stopped")
}
}
func TestWeComAIBotChannelWebhookPath(t *testing.T) {
t.Run("default path", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
expectedPath := "/webhook/wecom-aibot"
if ch.WebhookPath() != expectedPath {
t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, ch.WebhookPath())
}
})
t.Run("custom path", func(t *testing.T) {
customPath := "/custom/webhook"
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
WebhookPath: customPath,
}
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
if ch.WebhookPath() != customPath {
t.Errorf("Expected webhook path '%s', got '%s'", customPath, ch.WebhookPath())
}
})
}
func TestGenerateStreamID(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
// Generate multiple IDs and check they are unique
ids := make(map[string]bool)
for i := 0; i < 100; i++ {
id := ch.generateStreamID()
if len(id) != 10 {
t.Errorf("Expected stream ID length 10, got %d", len(id))
}
if ids[id] {
t.Errorf("Duplicate stream ID generated: %s", id)
}
ids[id] = true
}
}
func TestEncryptDecrypt(t *testing.T) {
// Use a valid 43-character base64 key (企业微信标准格式)
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters
}
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
plaintext := "Hello, World!"
receiveid := ""
// Encrypt
encrypted, err := ch.encryptMessage(plaintext, receiveid)
if err != nil {
t.Fatalf("Failed to encrypt message: %v", err)
}
if encrypted == "" {
t.Fatal("Encrypted message is empty")
}
// Decrypt
decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid)
if err != nil {
t.Fatalf("Failed to decrypt message: %v", err)
}
if decrypted != plaintext {
t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted)
}
}
func TestGenerateSignature(t *testing.T) {
token := "test_token"
timestamp := "1234567890"
nonce := "test_nonce"
encrypt := "encrypted_msg"
signature := computeSignature(token, timestamp, nonce, encrypt)
if signature == "" {
t.Error("Generated signature is empty")
}
// Verify signature using verifySignature function
if !verifySignature(token, signature, timestamp, nonce, encrypt) {
t.Error("Generated signature does not verify correctly")
}
}

View file

@ -38,8 +38,7 @@ type WeComAppChannel struct {
tokenMu sync.RWMutex tokenMu sync.RWMutex
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
processedMsgs map[string]bool // Message deduplication: msg_id -> processed processedMsgs *MessageDeduplicator
msgMu sync.RWMutex
} }
// WeComXMLMessage represents the XML message structure from WeCom // WeComXMLMessage represents the XML message structure from WeCom
@ -144,7 +143,7 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
client: &http.Client{Timeout: clientTimeout}, client: &http.Client{Timeout: clientTimeout},
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
processedMsgs: make(map[string]bool), processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
}, nil }, nil
} }
@ -342,18 +341,11 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp
return result.MediaID, nil return result.MediaID, nil
} }
// sendImageMessage sends an image message using a media_id. // sendWeComMessage marshals payload and POSTs it to the WeCom message API.
func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error { func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken string, payload any) error {
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
msg := WeComImageMessage{ jsonData, err := json.Marshal(payload)
ToUser: userID,
MsgType: "image",
AgentID: c.config.AgentID,
}
msg.Image.MediaID = mediaID
jsonData, err := json.Marshal(msg)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal message: %w", err) return fmt.Errorf("failed to marshal message: %w", err)
} }
@ -400,6 +392,17 @@ func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, use
return nil return nil
} }
// sendImageMessage sends an image message using a media_id.
func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error {
msg := WeComImageMessage{
ToUser: userID,
MsgType: "image",
AgentID: c.config.AgentID,
}
msg.Image.MediaID = mediaID
return c.sendWeComMessage(ctx, accessToken, msg)
}
// WebhookPath returns the path for registering on the shared HTTP server. // WebhookPath returns the path for registering on the shared HTTP server.
func (c *WeComAppChannel) WebhookPath() string { func (c *WeComAppChannel) WebhookPath() string {
if c.config.WebhookPath != "" { if c.config.WebhookPath != "" {
@ -603,23 +606,12 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
// Message deduplication: Use msg_id to prevent duplicate processing // Message deduplication: Use msg_id to prevent duplicate processing
// As per WeCom documentation, use msg_id for deduplication // As per WeCom documentation, use msg_id for deduplication
msgID := fmt.Sprintf("%d", msg.MsgId) msgID := fmt.Sprintf("%d", msg.MsgId)
c.msgMu.Lock() if !c.processedMsgs.MarkMessageProcessed(msgID) {
if c.processedMsgs[msgID] {
c.msgMu.Unlock()
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{ logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
"msg_id": msgID, "msg_id": msgID,
}) })
return return
} }
c.processedMsgs[msgID] = true
// Clean up old messages while still holding the lock to avoid a data race
// on len(). Reset the map but re-insert the current msgID so it remains
// deduplicated.
if len(c.processedMsgs) > 1000 {
c.processedMsgs = make(map[string]bool)
c.processedMsgs[msgID] = true
}
c.msgMu.Unlock()
senderID := msg.FromUserName senderID := msg.FromUserName
chatID := senderID // WeCom App uses user ID as chat ID for direct messages chatID := senderID // WeCom App uses user ID as chat ID for direct messages
@ -722,63 +714,15 @@ func (c *WeComAppChannel) getAccessToken() string {
return c.accessToken return c.accessToken
} }
// sendTextMessage sends a text message to a user // sendTextMessage sends a text message to a user.
func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error { func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error {
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
msg := WeComTextMessage{ msg := WeComTextMessage{
ToUser: userID, ToUser: userID,
MsgType: "text", MsgType: "text",
AgentID: c.config.AgentID, AgentID: c.config.AgentID,
} }
msg.Text.Content = content msg.Text.Content = content
return c.sendWeComMessage(ctx, accessToken, msg)
jsonData, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
// Use configurable timeout (default 5 seconds)
timeout := c.config.ReplyTimeout
if timeout <= 0 {
timeout = 5
}
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var sendResp WeComSendMessageResponse
if err := json.Unmarshal(body, &sendResp); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if sendResp.ErrCode != 0 {
return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
}
return nil
} }
// handleHealth handles health check requests // handleHealth handles health check requests

View file

@ -323,60 +323,6 @@ func TestWeComAppDecryptMessage(t *testing.T) {
}) })
} }
func TestWeComAppPKCS7Unpad(t *testing.T) {
tests := []struct {
name string
input []byte
expected []byte
}{
{
name: "empty input",
input: []byte{},
expected: []byte{},
},
{
name: "valid padding 3 bytes",
input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...),
expected: []byte("hello"),
},
{
name: "valid padding 16 bytes (full block)",
input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...),
expected: []byte("123456789012345"),
},
{
name: "invalid padding larger than data",
input: []byte{20},
expected: nil, // should return error
},
{
name: "invalid padding zero",
input: append([]byte("test"), byte(0)),
expected: nil, // should return error
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := pkcs7Unpad(tt.input)
if tt.expected == nil {
// This case should return an error
if err == nil {
t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result)
}
return
}
if err != nil {
t.Errorf("pkcs7Unpad() unexpected error: %v", err)
return
}
if !bytes.Equal(result, tt.expected) {
t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected)
}
})
}
}
func TestWeComAppHandleVerification(t *testing.T) { func TestWeComAppHandleVerification(t *testing.T) {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
aesKey := generateTestAESKeyApp() aesKey := generateTestAESKeyApp()

View file

@ -9,7 +9,6 @@ import (
"io" "io"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
@ -28,8 +27,7 @@ type WeComBotChannel struct {
client *http.Client client *http.Client
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
processedMsgs map[string]bool // Message deduplication: msg_id -> processed processedMsgs *MessageDeduplicator
msgMu sync.RWMutex
} }
// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT) // WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
@ -108,7 +106,7 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
client: &http.Client{Timeout: clientTimeout}, client: &http.Client{Timeout: clientTimeout},
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
processedMsgs: make(map[string]bool), processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
}, nil }, nil
} }
@ -330,23 +328,12 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
// Message deduplication: Use msg_id to prevent duplicate processing // Message deduplication: Use msg_id to prevent duplicate processing
msgID := msg.MsgID msgID := msg.MsgID
c.msgMu.Lock() if !c.processedMsgs.MarkMessageProcessed(msgID) {
if c.processedMsgs[msgID] {
c.msgMu.Unlock()
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{ logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
"msg_id": msgID, "msg_id": msgID,
}) })
return return
} }
c.processedMsgs[msgID] = true
// Clean up old messages while still holding the lock to avoid a data race
// on len(). Reset the map but re-insert the current msgID so it remains
// deduplicated.
if len(c.processedMsgs) > 1000 {
c.processedMsgs = make(map[string]bool)
c.processedMsgs[msgID] = true
}
c.msgMu.Unlock()
senderID := msg.From.UserID senderID := msg.From.UserID

View file

@ -412,22 +412,9 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
} }
ch, _ := NewWeComBotChannel(cfg, msgBus) ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("valid direct message callback", func(t *testing.T) { runBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder {
// Create JSON message for direct chat (single) t.Helper()
jsonMsg := `{
"msgid": "test_msg_id_123",
"aibotid": "test_aibot_id",
"chattype": "single",
"from": {"userid": "user123"},
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
"msgtype": "text",
"text": {"content": "Hello World"}
}`
// Encrypt message
encrypted, _ := encryptTestMessage(jsonMsg, aesKey) encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
// Create encrypted XML wrapper
encryptedWrapper := struct { encryptedWrapper := struct {
XMLName xml.Name `xml:"xml"` XMLName xml.Name `xml:"xml"`
Encrypt string `xml:"Encrypt"` Encrypt string `xml:"Encrypt"`
@ -435,20 +422,29 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
Encrypt: encrypted, Encrypt: encrypted,
} }
wrapperData, _ := xml.Marshal(encryptedWrapper) wrapperData, _ := xml.Marshal(encryptedWrapper)
timestamp := "1234567890" timestamp := "1234567890"
nonce := "test_nonce" nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encrypted) signature := generateSignature("test_token", timestamp, nonce, encrypted)
req := httptest.NewRequest( req := httptest.NewRequest(
http.MethodPost, http.MethodPost,
"/webhook/wecom?msg_signature="+signature+"&timestamp="+timestamp+"&nonce="+nonce, "/webhook/wecom?msg_signature="+signature+"&timestamp="+timestamp+"&nonce="+nonce,
bytes.NewReader(wrapperData), bytes.NewReader(wrapperData),
) )
w := httptest.NewRecorder() w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req) ch.handleMessageCallback(context.Background(), w, req)
return w
}
t.Run("valid direct message callback", func(t *testing.T) {
w := runBotMessageCallback(t, `{
"msgid": "test_msg_id_123",
"aibotid": "test_aibot_id",
"chattype": "single",
"from": {"userid": "user123"},
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
"msgtype": "text",
"text": {"content": "Hello World"}
}`)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
} }
@ -458,8 +454,7 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
}) })
t.Run("valid group message callback", func(t *testing.T) { t.Run("valid group message callback", func(t *testing.T) {
// Create JSON message for group chat w := runBotMessageCallback(t, `{
jsonMsg := `{
"msgid": "test_msg_id_456", "msgid": "test_msg_id_456",
"aibotid": "test_aibot_id", "aibotid": "test_aibot_id",
"chatid": "group_chat_id_123", "chatid": "group_chat_id_123",
@ -468,33 +463,7 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
"msgtype": "text", "msgtype": "text",
"text": {"content": "Hello Group"} "text": {"content": "Hello Group"}
}` }`)
// Encrypt message
encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
// Create encrypted XML wrapper
encryptedWrapper := struct {
XMLName xml.Name `xml:"xml"`
Encrypt string `xml:"Encrypt"`
}{
Encrypt: encrypted,
}
wrapperData, _ := xml.Marshal(encryptedWrapper)
timestamp := "1234567890"
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encrypted)
req := httptest.NewRequest(
http.MethodPost,
"/webhook/wecom?msg_signature="+signature+"&timestamp="+timestamp+"&nonce="+nonce,
bytes.NewReader(wrapperData),
)
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
} }

View file

@ -1,12 +1,15 @@
package wecom package wecom
import ( import (
"bytes"
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/rand"
"crypto/sha1" "crypto/sha1"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"math/big"
"sort" "sort"
"strings" "strings"
) )
@ -14,25 +17,23 @@ import (
// blockSize is the PKCS7 block size used by WeCom (32) // blockSize is the PKCS7 block size used by WeCom (32)
const blockSize = 32 const blockSize = 32
// computeSignature computes the WeCom message signature from the given parameters.
// It sorts [token, timestamp, nonce, encrypt], concatenates them and returns the SHA1 hex digest.
func computeSignature(token, timestamp, nonce, encrypt string) string {
params := []string{token, timestamp, nonce, encrypt}
sort.Strings(params)
str := strings.Join(params, "")
hash := sha1.Sum([]byte(str))
return fmt.Sprintf("%x", hash)
}
// verifySignature verifies the message signature for WeCom // verifySignature verifies the message signature for WeCom
// This is a common function used by both WeCom Bot and WeCom App // This is a common function used by both WeCom Bot and WeCom App
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
if token == "" { if token == "" {
return true // Skip verification if token is not set return true // Skip verification if token is not set
} }
return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature
// Sort parameters
params := []string{token, timestamp, nonce, msgEncrypt}
sort.Strings(params)
// Concatenate
str := strings.Join(params, "")
// SHA1 hash
hash := sha1.Sum([]byte(str))
expectedSignature := fmt.Sprintf("%x", hash)
return expectedSignature == msgSignature
} }
// decryptMessage decrypts the encrypted message using AES // decryptMessage decrypts the encrypted message using AES
@ -53,64 +54,128 @@ func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (s
return string(decoded), nil return string(decoded), nil
} }
// Decode AES key (base64) aesKey, err := decodeWeComAESKey(encodingAESKey)
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil { if err != nil {
return "", fmt.Errorf("failed to decode AES key: %w", err) return "", err
} }
// Decode encrypted message
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg) cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to decode message: %w", err) return "", fmt.Errorf("failed to decode message: %w", err)
} }
// AES decrypt plainText, err := decryptAESCBC(aesKey, cipherText)
if err != nil {
return "", err
}
return unpackWeComFrame(plainText, receiveid)
}
// decodeWeComAESKey base64-decodes the 43-character EncodingAESKey (trailing "=" is
// appended automatically) and validates that the result is exactly 32 bytes.
// It is the single place that handles this repeated pattern in both encrypt and decrypt paths.
func decodeWeComAESKey(encodingAESKey string) ([]byte, error) {
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
return nil, fmt.Errorf("failed to decode AES key: %w", err)
}
if len(aesKey) != 32 {
return nil, fmt.Errorf("invalid AES key length: %d", len(aesKey))
}
return aesKey, nil
}
// encryptAESCBC encrypts plaintext using AES-CBC with the given key, mirroring
// decryptAESCBC. IV = aesKey[:aes.BlockSize]. The caller must PKCS7-pad the
// plaintext to a multiple of aes.BlockSize before calling.
func encryptAESCBC(aesKey, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(aesKey) block, err := aes.NewCipher(aesKey)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err) return nil, fmt.Errorf("failed to create cipher: %w", err)
} }
if len(cipherText) < aes.BlockSize {
return "", fmt.Errorf("ciphertext too short")
}
// IV is the first 16 bytes of AESKey
iv := aesKey[:aes.BlockSize] iv := aesKey[:aes.BlockSize]
mode := cipher.NewCBCDecrypter(block, iv) ciphertext := make([]byte, len(plaintext))
plainText := make([]byte, len(cipherText)) cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plaintext)
mode.CryptBlocks(plainText, cipherText) return ciphertext, nil
}
// Remove PKCS7 padding // packWeComFrame builds the WeCom wire format:
plainText, err = pkcs7Unpad(plainText) //
// random(16 ASCII digits) + msg_len(4, big-endian) + msg + receiveid
func packWeComFrame(msg, receiveid string) ([]byte, error) {
randomBytes := make([]byte, 16)
for i := range 16 {
n, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to unpad: %w", err) return nil, fmt.Errorf("failed to generate random: %w", err)
}
randomBytes[i] = byte('0' + n.Int64())
}
msgBytes := []byte(msg)
msgLenBytes := make([]byte, 4)
binary.BigEndian.PutUint32(msgLenBytes, uint32(len(msgBytes)))
var buf bytes.Buffer
buf.Write(randomBytes)
buf.Write(msgLenBytes)
buf.Write(msgBytes)
buf.WriteString(receiveid)
return buf.Bytes(), nil
} }
// Parse message structure // unpackWeComFrame parses the WeCom wire format produced by packWeComFrame.
// Format: random(16) + msg_len(4) + msg + receiveid // If receiveid is non-empty it verifies the frame's trailing receiveid field.
if len(plainText) < 20 { func unpackWeComFrame(data []byte, receiveid string) (string, error) {
return "", fmt.Errorf("decrypted message too short") if len(data) < 20 {
return "", fmt.Errorf("decrypted frame too short: %d bytes", len(data))
} }
msgLen := binary.BigEndian.Uint32(data[16:20])
msgLen := binary.BigEndian.Uint32(plainText[16:20]) if int(msgLen) > len(data)-20 {
if int(msgLen) > len(plainText)-20 { return "", fmt.Errorf("invalid message length: %d", msgLen)
return "", fmt.Errorf("invalid message length")
} }
msg := data[20 : 20+msgLen]
msg := plainText[20 : 20+msgLen] if receiveid != "" && len(data) > 20+int(msgLen) {
actualReceiveID := string(data[20+msgLen:])
// Verify receiveid if provided
if receiveid != "" && len(plainText) > 20+int(msgLen) {
actualReceiveID := string(plainText[20+msgLen:])
if actualReceiveID != receiveid { if actualReceiveID != receiveid {
return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID) return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
} }
} }
return string(msg), nil return string(msg), nil
} }
// decryptAESCBC decrypts ciphertext using AES-CBC with the given key.
// IV = aesKey[:aes.BlockSize]. PKCS7 padding is stripped from the returned plaintext.
func decryptAESCBC(aesKey, ciphertext []byte) ([]byte, error) {
if len(ciphertext) == 0 {
return nil, fmt.Errorf("ciphertext is empty")
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size", len(ciphertext))
}
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
iv := aesKey[:aes.BlockSize]
plaintext := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plaintext, ciphertext)
plaintext, err = pkcs7Unpad(plaintext)
if err != nil {
return nil, fmt.Errorf("failed to unpad: %w", err)
}
return plaintext, nil
}
// pkcs7Pad adds PKCS7 padding
func pkcs7Pad(data []byte, blockSize int) []byte {
padding := blockSize - (len(data) % blockSize)
if padding == 0 {
padding = blockSize
}
padText := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padText...)
}
// pkcs7Unpad removes PKCS7 padding with validation // pkcs7Unpad removes PKCS7 padding with validation
func pkcs7Unpad(data []byte) ([]byte, error) { func pkcs7Unpad(data []byte) ([]byte, error) {
if len(data) == 0 { if len(data) == 0 {

View file

@ -0,0 +1,54 @@
package wecom
import "sync"
const wecomMaxProcessedMessages = 1000
// MessageDeduplicator provides thread-safe message deduplication using a circular queue (ring buffer)
// combined with a hash map. This ensures fast O(1) lookups while naturally evicting the oldest
// messages without causing "amnesia cliffs" when the limit is reached.
type MessageDeduplicator struct {
mu sync.Mutex
msgs map[string]bool
ring []string
idx int
max int
}
// NewMessageDeduplicator creates a new deduplicator with the specified capacity.
func NewMessageDeduplicator(maxEntries int) *MessageDeduplicator {
if maxEntries <= 0 {
maxEntries = wecomMaxProcessedMessages
}
return &MessageDeduplicator{
msgs: make(map[string]bool, maxEntries),
ring: make([]string, maxEntries),
max: maxEntries,
}
}
// MarkMessageProcessed marks msgID as processed and returns false for duplicates.
func (d *MessageDeduplicator) MarkMessageProcessed(msgID string) bool {
d.mu.Lock()
defer d.mu.Unlock()
// 1. Check for duplicate
if d.msgs[msgID] {
return false
}
// 2. Evict the oldest message at our current ring position (if any)
oldestID := d.ring[d.idx]
if oldestID != "" {
delete(d.msgs, oldestID)
}
// 3. Store the new message
d.msgs[msgID] = true
d.ring[d.idx] = msgID
// 4. Advance the circle queue index
d.idx = (d.idx + 1) % d.max
return true
}

View file

@ -0,0 +1,83 @@
package wecom
import (
"sync"
"testing"
)
func TestMessageDeduplicator_DuplicateDetection(t *testing.T) {
d := NewMessageDeduplicator(wecomMaxProcessedMessages)
if ok := d.MarkMessageProcessed("msg-1"); !ok {
t.Fatalf("first message should be accepted")
}
if ok := d.MarkMessageProcessed("msg-1"); ok {
t.Fatalf("duplicate message should be rejected")
}
}
func TestMessageDeduplicator_ConcurrentSameMessage(t *testing.T) {
d := NewMessageDeduplicator(wecomMaxProcessedMessages)
const goroutines = 64
var wg sync.WaitGroup
wg.Add(goroutines)
results := make(chan bool, goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
results <- d.MarkMessageProcessed("msg-concurrent")
}()
}
wg.Wait()
close(results)
successes := 0
for ok := range results {
if ok {
successes++
}
}
if successes != 1 {
t.Fatalf("expected exactly 1 successful mark, got %d", successes)
}
}
func TestMessageDeduplicator_CircularQueueEviction(t *testing.T) {
// Create a deduplicator with a very small capacity to test eviction easily.
capacity := 3
d := NewMessageDeduplicator(capacity)
// Fill the queue.
d.MarkMessageProcessed("msg-1")
d.MarkMessageProcessed("msg-2")
d.MarkMessageProcessed("msg-3")
// At this point, the queue is full. msg-1 is the oldest.
if len(d.msgs) != 3 {
t.Fatalf("expected map size to be 3, got %d", len(d.msgs))
}
// This should evict msg-1 and add msg-4.
if ok := d.MarkMessageProcessed("msg-4"); !ok {
t.Fatalf("msg-4 should be accepted")
}
if len(d.msgs) != 3 {
t.Fatalf("expected map size to remain at max capacity (3), got %d", len(d.msgs))
}
// msg-1 should now be forgotten (evicted).
if ok := d.MarkMessageProcessed("msg-1"); !ok {
t.Fatalf("msg-1 should be accepted again because it was evicted")
}
// msg-2 should have been evicted when we added msg-1 back.
if ok := d.MarkMessageProcessed("msg-2"); !ok {
t.Fatalf("msg-2 should be accepted again because it was evicted")
}
}

View file

@ -13,4 +13,7 @@ func init() {
channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewWeComAppChannel(cfg.Channels.WeComApp, b) return NewWeComAppChannel(cfg.Channels.WeComApp, b)
}) })
channels.RegisterFactory("wecom_aibot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b)
})
} }

View file

@ -170,6 +170,7 @@ type SessionConfig struct {
type AgentDefaults struct { type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
@ -203,6 +204,7 @@ type ChannelsConfig struct {
OneBot OneBotConfig `json:"onebot"` OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"` WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"` WeComApp WeComAppConfig `json:"wecom_app"`
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Pico PicoConfig `json:"pico"` Pico PicoConfig `json:"pico"`
} }
@ -235,6 +237,7 @@ type WhatsAppConfig struct {
type TelegramConfig struct { type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
@ -359,6 +362,18 @@ type WeComAppConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
} }
type WeComAIBotConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
}
type PicoConfig struct { type PicoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
@ -385,6 +400,7 @@ type DevicesConfig struct {
type ProvidersConfig struct { type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"` Anthropic ProviderConfig `json:"anthropic"`
OpenAI OpenAIProviderConfig `json:"openai"` OpenAI OpenAIProviderConfig `json:"openai"`
LiteLLM ProviderConfig `json:"litellm"`
OpenRouter ProviderConfig `json:"openrouter"` OpenRouter ProviderConfig `json:"openrouter"`
Groq ProviderConfig `json:"groq"` Groq ProviderConfig `json:"groq"`
Zhipu ProviderConfig `json:"zhipu"` Zhipu ProviderConfig `json:"zhipu"`
@ -408,6 +424,7 @@ type ProvidersConfig struct {
func (p ProvidersConfig) IsEmpty() bool { func (p ProvidersConfig) IsEmpty() bool {
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" && return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" && p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" &&
p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" && p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
p.Groq.APIKey == "" && p.Groq.APIBase == "" && p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" && p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
@ -534,6 +551,7 @@ type CronToolsConfig struct {
type ExecConfig struct { type ExecConfig struct {
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
CustomAllowPatterns []string `json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
} }
type MediaCleanupConfig struct { type MediaCleanupConfig struct {
@ -543,11 +561,14 @@ type MediaCleanupConfig struct {
} }
type ToolsConfig struct { type ToolsConfig struct {
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
Web WebToolsConfig `json:"web"` Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"` Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"` Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"` Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
MCP MCPConfig `json:"mcp"`
} }
type SkillsToolsConfig struct { type SkillsToolsConfig struct {
@ -577,6 +598,34 @@ type ClawHubRegistryConfig struct {
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"` MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
} }
// MCPServerConfig defines configuration for a single MCP server
type MCPServerConfig struct {
// Enabled indicates whether this MCP server is active
Enabled bool `json:"enabled"`
// Command is the executable to run (e.g., "npx", "python", "/path/to/server")
Command string `json:"command"`
// Args are the arguments to pass to the command
Args []string `json:"args,omitempty"`
// Env are environment variables to set for the server process (stdio only)
Env map[string]string `json:"env,omitempty"`
// EnvFile is the path to a file containing environment variables (stdio only)
EnvFile string `json:"env_file,omitempty"`
// Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set)
Type string `json:"type,omitempty"`
// URL is used for SSE/HTTP transport
URL string `json:"url,omitempty"`
// Headers are HTTP headers to send with requests (sse/http only)
Headers map[string]string `json:"headers,omitempty"`
}
// MCPConfig defines configuration for all MCP servers
type MCPConfig struct {
// Enabled globally enables/disables MCP integration
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_MCP_ENABLED"`
// Servers is a map of server name to server configuration
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
}
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig() cfg := DefaultConfig()
@ -633,7 +682,8 @@ func (c *Config) migrateChannelConfigs() {
} }
// OneBot: group_trigger_prefix -> group_trigger.prefixes // OneBot: group_trigger_prefix -> group_trigger.prefixes
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
} }
} }
@ -743,25 +793,7 @@ func (c *Config) findMatches(modelName string) []ModelConfig {
// HasProvidersConfig checks if any provider in the old providers config has configuration. // HasProvidersConfig checks if any provider in the old providers config has configuration.
func (c *Config) HasProvidersConfig() bool { func (c *Config) HasProvidersConfig() bool {
v := c.Providers return !c.Providers.IsEmpty()
return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" ||
v.OpenAI.APIKey != "" || v.OpenAI.APIBase != "" ||
v.OpenRouter.APIKey != "" || v.OpenRouter.APIBase != "" ||
v.Groq.APIKey != "" || v.Groq.APIBase != "" ||
v.Zhipu.APIKey != "" || v.Zhipu.APIBase != "" ||
v.VLLM.APIKey != "" || v.VLLM.APIBase != "" ||
v.Gemini.APIKey != "" || v.Gemini.APIBase != "" ||
v.Nvidia.APIKey != "" || v.Nvidia.APIBase != "" ||
v.Ollama.APIKey != "" || v.Ollama.APIBase != "" ||
v.Moonshot.APIKey != "" || v.Moonshot.APIBase != "" ||
v.ShengSuanYun.APIKey != "" || v.ShengSuanYun.APIBase != "" ||
v.DeepSeek.APIKey != "" || v.DeepSeek.APIBase != "" ||
v.Cerebras.APIKey != "" || v.Cerebras.APIBase != "" ||
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
v.Qwen.APIKey != "" || v.Qwen.APIBase != "" ||
v.Mistral.APIKey != "" || v.Mistral.APIBase != ""
} }
// ValidateModelList validates all ModelConfig entries in the model_list. // ValidateModelList validates all ModelConfig entries in the model_list.

View file

@ -137,6 +137,16 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5, ReplyTimeout: 5,
}, },
WeComAIBot: WeComAIBotConfig{
Enabled: false,
Token: "",
EncodingAESKey: "",
WebhookPath: "/webhook/wecom-aibot",
AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5,
MaxSteps: 10,
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
},
Pico: PicoConfig{ Pico: PicoConfig{
Enabled: false, Enabled: false,
Token: "", Token: "",
@ -351,6 +361,10 @@ func DefaultConfig() *Config {
TTLSeconds: 300, TTLSeconds: 300,
}, },
}, },
MCP: MCPConfig{
Enabled: false,
Servers: map[string]MCPServerConfig{},
},
}, },
Heartbeat: HeartbeatConfig{ Heartbeat: HeartbeatConfig{
Enabled: true, Enabled: true,

View file

@ -88,6 +88,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true }, true
}, },
}, },
{
providerNames: []string{"litellm"},
protocol: "litellm",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "litellm",
Model: "litellm/auto",
APIKey: p.LiteLLM.APIKey,
APIBase: p.LiteLLM.APIBase,
Proxy: p.LiteLLM.Proxy,
RequestTimeout: p.LiteLLM.RequestTimeout,
}, true
},
},
{ {
providerNames: []string{"openrouter"}, providerNames: []string{"openrouter"},
protocol: "openrouter", protocol: "openrouter",

View file

@ -63,6 +63,33 @@ func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
} }
} }
func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
LiteLLM: ProviderConfig{
APIKey: "litellm-key",
APIBase: "http://localhost:4000/v1",
},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
if result[0].ModelName != "litellm" {
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "litellm")
}
if result[0].Model != "litellm/auto" {
t.Errorf("Model = %q, want %q", result[0].Model, "litellm/auto")
}
if result[0].APIBase != "http://localhost:4000/v1" {
t.Errorf("APIBase = %q, want %q", result[0].APIBase, "http://localhost:4000/v1")
}
}
func TestConvertProvidersToModelList_Multiple(t *testing.T) { func TestConvertProvidersToModelList_Multiple(t *testing.T) {
cfg := &Config{ cfg := &Config{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
@ -115,6 +142,7 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
cfg := &Config{ cfg := &Config{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}}, OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}},
LiteLLM: ProviderConfig{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"},
Anthropic: ProviderConfig{APIKey: "key2"}, Anthropic: ProviderConfig{APIKey: "key2"},
OpenRouter: ProviderConfig{APIKey: "key3"}, OpenRouter: ProviderConfig{APIKey: "key3"},
Groq: ProviderConfig{APIKey: "key4"}, Groq: ProviderConfig{APIKey: "key4"},
@ -137,9 +165,9 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
result := ConvertProvidersToModelList(cfg) result := ConvertProvidersToModelList(cfg)
// All 18 providers should be converted // All 19 providers should be converted
if len(result) != 18 { if len(result) != 19 {
t.Errorf("len(result) = %d, want 18", len(result)) t.Errorf("len(result) = %d, want 19", len(result))
} }
} }

View file

@ -47,45 +47,38 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
} }
} }
func TestExecuteHeartbeat_Error(t *testing.T) { func TestExecuteHeartbeat_ResultLogging(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") tests := []struct {
if err != nil { name string
t.Fatalf("Failed to create temp dir: %v", err) result *tools.ToolResult
} wantLog string
defer os.RemoveAll(tmpDir) }{
{
hs := NewHeartbeatService(tmpDir, 30, true) name: "error result",
hs.stopChan = make(chan struct{}) // Enable for testing result: &tools.ToolResult{
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
return &tools.ToolResult{
ForLLM: "Heartbeat failed: connection error", ForLLM: "Heartbeat failed: connection error",
ForUser: "", ForUser: "",
Silent: false, Silent: false,
IsError: true, IsError: true,
Async: false, Async: false,
} },
}) wantLog: "error message",
},
// Create HEARTBEAT.md {
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) name: "silent result",
result: &tools.ToolResult{
hs.executeHeartbeat() ForLLM: "Heartbeat completed successfully",
ForUser: "",
// Check log file for error message Silent: true,
logFile := filepath.Join(tmpDir, "heartbeat.log") IsError: false,
data, err := os.ReadFile(logFile) Async: false,
if err != nil { },
t.Fatalf("Failed to read log file: %v", err) wantLog: "completion message",
},
} }
logContent := string(data) for _, tt := range tests {
if logContent == "" { t.Run(tt.name, func(t *testing.T) {
t.Error("Expected log file to contain error message")
}
}
func TestExecuteHeartbeat_Silent(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
@ -96,30 +89,21 @@ func TestExecuteHeartbeat_Silent(t *testing.T) {
hs.stopChan = make(chan struct{}) // Enable for testing hs.stopChan = make(chan struct{}) // Enable for testing
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
return &tools.ToolResult{ return tt.result
ForLLM: "Heartbeat completed successfully",
ForUser: "",
Silent: true,
IsError: false,
Async: false,
}
}) })
// Create HEARTBEAT.md
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat() hs.executeHeartbeat()
// Check log file for completion message
logFile := filepath.Join(tmpDir, "heartbeat.log") logFile := filepath.Join(tmpDir, "heartbeat.log")
data, err := os.ReadFile(logFile) data, err := os.ReadFile(logFile)
if err != nil { if err != nil {
t.Fatalf("Failed to read log file: %v", err) t.Fatalf("Failed to read log file: %v", err)
} }
if string(data) == "" {
logContent := string(data) t.Errorf("Expected log file to contain %s", tt.wantLog)
if logContent == "" { }
t.Error("Expected log file to contain completion message") })
} }
} }

532
pkg/mcp/manager.go Normal file
View file

@ -0,0 +1,532 @@
package mcp
import (
"bufio"
"context"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
// headerTransport is an http.RoundTripper that adds custom headers to requests
type headerTransport struct {
base http.RoundTripper
headers map[string]string
}
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
req = req.Clone(req.Context())
// Add custom headers
for key, value := range t.headers {
req.Header.Set(key, value)
}
// Use the base transport
base := t.base
if base == nil {
base = http.DefaultTransport
}
return base.RoundTrip(req)
}
// loadEnvFile loads environment variables from a file in .env format
// Each line should be in the format: KEY=value
// Lines starting with # are comments
// Empty lines are ignored
func loadEnvFile(path string) (map[string]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open env file: %w", err)
}
defer file.Close()
envVars := make(map[string]string)
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Parse KEY=value
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid format at line %d: %s", lineNum, line)
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if key == "" {
return nil, fmt.Errorf("invalid format at line %d: empty key", lineNum)
}
// Remove surrounding quotes if present
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
envVars[key] = value
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading env file: %w", err)
}
return envVars, nil
}
// ServerConnection represents a connection to an MCP server
type ServerConnection struct {
Name string
Client *mcp.Client
Session *mcp.ClientSession
Tools []*mcp.Tool
}
// Manager manages multiple MCP server connections
type Manager struct {
servers map[string]*ServerConnection
mu sync.RWMutex
closed atomic.Bool // changed from bool to atomic.Bool to avoid TOCTOU race
wg sync.WaitGroup // tracks in-flight CallTool calls
}
// NewManager creates a new MCP manager
func NewManager() *Manager {
return &Manager{
servers: make(map[string]*ServerConnection),
}
}
// LoadFromConfig loads MCP servers from configuration
func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error {
return m.LoadFromMCPConfig(ctx, cfg.Tools.MCP, cfg.WorkspacePath())
}
// LoadFromMCPConfig loads MCP servers from MCP configuration and workspace path.
// This is the minimal dependency version that doesn't require the full Config object.
func (m *Manager) LoadFromMCPConfig(
ctx context.Context,
mcpCfg config.MCPConfig,
workspacePath string,
) error {
if !mcpCfg.Enabled {
logger.InfoCF("mcp", "MCP integration is disabled", nil)
return nil
}
if len(mcpCfg.Servers) == 0 {
logger.InfoCF("mcp", "No MCP servers configured", nil)
return nil
}
logger.InfoCF("mcp", "Initializing MCP servers",
map[string]any{
"count": len(mcpCfg.Servers),
})
var wg sync.WaitGroup
errs := make(chan error, len(mcpCfg.Servers))
enabledCount := 0
for name, serverCfg := range mcpCfg.Servers {
if !serverCfg.Enabled {
logger.DebugCF("mcp", "Skipping disabled server",
map[string]any{
"server": name,
})
continue
}
enabledCount++
wg.Add(1)
go func(name string, serverCfg config.MCPServerConfig, workspace string) {
defer wg.Done()
// Resolve relative envFile paths relative to workspace
if serverCfg.EnvFile != "" && !filepath.IsAbs(serverCfg.EnvFile) {
if workspace == "" {
err := fmt.Errorf(
"workspace path is empty while resolving relative envFile %q for server %s",
serverCfg.EnvFile,
name,
)
logger.ErrorCF("mcp", "Invalid MCP server configuration",
map[string]any{
"server": name,
"env_file": serverCfg.EnvFile,
"error": err.Error(),
})
errs <- err
return
}
serverCfg.EnvFile = filepath.Join(workspace, serverCfg.EnvFile)
}
if err := m.ConnectServer(ctx, name, serverCfg); err != nil {
logger.ErrorCF("mcp", "Failed to connect to MCP server",
map[string]any{
"server": name,
"error": err.Error(),
})
errs <- fmt.Errorf("failed to connect to server %s: %w", name, err)
}
}(name, serverCfg, workspacePath)
}
wg.Wait()
close(errs)
// Collect errors
var allErrors []error
for err := range errs {
allErrors = append(allErrors, err)
}
connectedCount := len(m.GetServers())
// If all enabled servers failed to connect, return aggregated error
if enabledCount > 0 && connectedCount == 0 {
logger.ErrorCF("mcp", "All MCP servers failed to connect",
map[string]any{
"failed": len(allErrors),
"total": enabledCount,
})
return errors.Join(allErrors...)
}
if len(allErrors) > 0 {
logger.WarnCF("mcp", "Some MCP servers failed to connect",
map[string]any{
"failed": len(allErrors),
"connected": connectedCount,
"total": enabledCount,
})
// Don't fail completely if some servers successfully connected
}
logger.InfoCF("mcp", "MCP server initialization complete",
map[string]any{
"connected": connectedCount,
"total": enabledCount,
})
return nil
}
// ConnectServer connects to a single MCP server
func (m *Manager) ConnectServer(
ctx context.Context,
name string,
cfg config.MCPServerConfig,
) error {
logger.InfoCF("mcp", "Connecting to MCP server",
map[string]any{
"server": name,
"command": cfg.Command,
"args_count": len(cfg.Args),
})
// Create client
client := mcp.NewClient(&mcp.Implementation{
Name: "picoclaw",
Version: "1.0.0",
}, nil)
// Create transport based on configuration
// Auto-detect transport type if not explicitly specified
var transport mcp.Transport
transportType := cfg.Type
// Auto-detect: if URL is provided, use SSE; if command is provided, use stdio
if transportType == "" {
if cfg.URL != "" {
transportType = "sse"
} else if cfg.Command != "" {
transportType = "stdio"
} else {
return fmt.Errorf("either URL or command must be provided")
}
}
switch transportType {
case "sse", "http":
if cfg.URL == "" {
return fmt.Errorf("URL is required for SSE/HTTP transport")
}
logger.DebugCF("mcp", "Using SSE/HTTP transport",
map[string]any{
"server": name,
"url": cfg.URL,
})
sseTransport := &mcp.StreamableClientTransport{
Endpoint: cfg.URL,
}
// Add custom headers if provided
if len(cfg.Headers) > 0 {
// Create a custom HTTP client with header-injecting transport
sseTransport.HTTPClient = &http.Client{
Transport: &headerTransport{
base: http.DefaultTransport,
headers: cfg.Headers,
},
}
logger.DebugCF("mcp", "Added custom HTTP headers",
map[string]any{
"server": name,
"header_count": len(cfg.Headers),
})
}
transport = sseTransport
case "stdio":
if cfg.Command == "" {
return fmt.Errorf("command is required for stdio transport")
}
logger.DebugCF("mcp", "Using stdio transport",
map[string]any{
"server": name,
"command": cfg.Command,
})
// Create command with context
cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...)
// Build environment variables with proper override semantics
// Use a map to ensure config variables override file variables
envMap := make(map[string]string)
// Start with parent process environment
for _, e := range cmd.Environ() {
if idx := strings.Index(e, "="); idx > 0 {
envMap[e[:idx]] = e[idx+1:]
}
}
// Load environment variables from file if specified
if cfg.EnvFile != "" {
envVars, err := loadEnvFile(cfg.EnvFile)
if err != nil {
return fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err)
}
for k, v := range envVars {
envMap[k] = v
}
logger.DebugCF("mcp", "Loaded environment variables from file",
map[string]any{
"server": name,
"envFile": cfg.EnvFile,
"var_count": len(envVars),
})
}
// Environment variables from config override those from file
for k, v := range cfg.Env {
envMap[k] = v
}
// Convert map to slice
env := make([]string, 0, len(envMap))
for k, v := range envMap {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
cmd.Env = env
transport = &mcp.CommandTransport{Command: cmd}
default:
return fmt.Errorf(
"unsupported transport type: %s (supported: stdio, sse, http)",
transportType,
)
}
// Connect to server
session, err := client.Connect(ctx, transport, nil)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
// Get server info
initResult := session.InitializeResult()
logger.InfoCF("mcp", "Connected to MCP server",
map[string]any{
"server": name,
"serverName": initResult.ServerInfo.Name,
"serverVersion": initResult.ServerInfo.Version,
"protocol": initResult.ProtocolVersion,
})
// List available tools if supported
var tools []*mcp.Tool
if initResult.Capabilities.Tools != nil {
for tool, err := range session.Tools(ctx, nil) {
if err != nil {
logger.WarnCF("mcp", "Error listing tool",
map[string]any{
"server": name,
"error": err.Error(),
})
continue
}
tools = append(tools, tool)
}
logger.InfoCF("mcp", "Listed tools from MCP server",
map[string]any{
"server": name,
"toolCount": len(tools),
})
}
// Store connection
m.mu.Lock()
m.servers[name] = &ServerConnection{
Name: name,
Client: client,
Session: session,
Tools: tools,
}
m.mu.Unlock()
return nil
}
// GetServers returns all connected servers
func (m *Manager) GetServers() map[string]*ServerConnection {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]*ServerConnection, len(m.servers))
for k, v := range m.servers {
result[k] = v
}
return result
}
// GetServer returns a specific server connection
func (m *Manager) GetServer(name string) (*ServerConnection, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
conn, ok := m.servers[name]
return conn, ok
}
// CallTool calls a tool on a specific server
func (m *Manager) CallTool(
ctx context.Context,
serverName, toolName string,
arguments map[string]any,
) (*mcp.CallToolResult, error) {
// Check if closed before acquiring lock (fast path)
if m.closed.Load() {
return nil, fmt.Errorf("manager is closed")
}
m.mu.RLock()
// Double-check after acquiring lock to prevent TOCTOU race
if m.closed.Load() {
m.mu.RUnlock()
return nil, fmt.Errorf("manager is closed")
}
conn, ok := m.servers[serverName]
if ok {
m.wg.Add(1) // Add to WaitGroup while holding the lock
}
m.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("server %s not found", serverName)
}
defer m.wg.Done()
params := &mcp.CallToolParams{
Name: toolName,
Arguments: arguments,
}
result, err := conn.Session.CallTool(ctx, params)
if err != nil {
return nil, fmt.Errorf("failed to call tool: %w", err)
}
return result, nil
}
// Close closes all server connections
func (m *Manager) Close() error {
// Use Swap to atomically set closed=true and get the previous value
// This prevents TOCTOU race with CallTool's closed check
if m.closed.Swap(true) {
return nil // already closed
}
// Wait for all in-flight CallTool calls to finish before closing sessions
// After closed=true is set, no new CallTool can start (they check closed first)
m.wg.Wait()
m.mu.Lock()
defer m.mu.Unlock()
logger.InfoCF("mcp", "Closing all MCP server connections",
map[string]any{
"count": len(m.servers),
})
var errs []error
for name, conn := range m.servers {
if err := conn.Session.Close(); err != nil {
logger.ErrorCF("mcp", "Failed to close server connection",
map[string]any{
"server": name,
"error": err.Error(),
})
errs = append(errs, fmt.Errorf("server %s: %w", name, err))
}
}
m.servers = make(map[string]*ServerConnection)
if len(errs) > 0 {
return fmt.Errorf("failed to close %d server(s): %w", len(errs), errors.Join(errs...))
}
return nil
}
// GetAllTools returns all tools from all connected servers
func (m *Manager) GetAllTools() map[string][]*mcp.Tool {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string][]*mcp.Tool)
for name, conn := range m.servers {
if len(conn.Tools) > 0 {
result[name] = conn.Tools
}
}
return result
}

298
pkg/mcp/manager_test.go Normal file
View file

@ -0,0 +1,298 @@
package mcp
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestLoadEnvFile(t *testing.T) {
tests := []struct {
name string
content string
expected map[string]string
expectErr bool
}{
{
name: "basic env file",
content: `API_KEY=secret123
DATABASE_URL=postgres://localhost/db
PORT=8080`,
expected: map[string]string{
"API_KEY": "secret123",
"DATABASE_URL": "postgres://localhost/db",
"PORT": "8080",
},
expectErr: false,
},
{
name: "with comments and empty lines",
content: `# This is a comment
API_KEY=secret123
# Another comment
DATABASE_URL=postgres://localhost/db
PORT=8080`,
expected: map[string]string{
"API_KEY": "secret123",
"DATABASE_URL": "postgres://localhost/db",
"PORT": "8080",
},
expectErr: false,
},
{
name: "with quoted values",
content: `API_KEY="secret with spaces"
NAME='single quoted'
PLAIN=no-quotes`,
expected: map[string]string{
"API_KEY": "secret with spaces",
"NAME": "single quoted",
"PLAIN": "no-quotes",
},
expectErr: false,
},
{
name: "with spaces around equals",
content: `API_KEY = secret123
DATABASE_URL= postgres://localhost/db
PORT =8080`,
expected: map[string]string{
"API_KEY": "secret123",
"DATABASE_URL": "postgres://localhost/db",
"PORT": "8080",
},
expectErr: false,
},
{
name: "invalid format - no equals",
content: `INVALID_LINE`,
expectErr: true,
},
{
name: "empty file",
content: ``,
expected: map[string]string{},
expectErr: false,
},
{
name: "only comments",
content: `# Comment 1
# Comment 2`,
expected: map[string]string{},
expectErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
envFile := filepath.Join(tmpDir, ".env")
if err := os.WriteFile(envFile, []byte(tt.content), 0o644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
result, err := loadEnvFile(envFile)
if tt.expectErr {
if err == nil {
t.Errorf("Expected error but got none")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if len(result) != len(tt.expected) {
t.Errorf("Expected %d variables, got %d", len(tt.expected), len(result))
}
for key, expectedValue := range tt.expected {
if actualValue, ok := result[key]; !ok {
t.Errorf("Expected key %s not found", key)
} else if actualValue != expectedValue {
t.Errorf("For key %s: expected %q, got %q", key, expectedValue, actualValue)
}
}
})
}
}
func TestLoadEnvFileNotFound(t *testing.T) {
_, err := loadEnvFile("/nonexistent/file.env")
if err == nil {
t.Error("Expected error for nonexistent file")
}
}
func TestEnvFilePriority(t *testing.T) {
// Create a temporary .env file
tmpDir := t.TempDir()
envFile := filepath.Join(tmpDir, ".env")
envContent := `API_KEY=from_file
DATABASE_URL=from_file
SHARED_VAR=from_file`
if err := os.WriteFile(envFile, []byte(envContent), 0o644); err != nil {
t.Fatalf("Failed to create .env file: %v", err)
}
// Load envFile
envVars, err := loadEnvFile(envFile)
if err != nil {
t.Fatalf("Failed to load env file: %v", err)
}
// Verify envFile variables
if envVars["API_KEY"] != "from_file" {
t.Errorf("Expected API_KEY=from_file, got %s", envVars["API_KEY"])
}
// Simulate config.Env overriding envFile
configEnv := map[string]string{
"SHARED_VAR": "from_config",
"NEW_VAR": "from_config",
}
// Merge: envFile first, then config overrides
merged := make(map[string]string)
for k, v := range envVars {
merged[k] = v
}
for k, v := range configEnv {
merged[k] = v
}
// Verify priority: config.Env should override envFile
if merged["SHARED_VAR"] != "from_config" {
t.Errorf(
"Expected SHARED_VAR=from_config (config should override file), got %s",
merged["SHARED_VAR"],
)
}
if merged["API_KEY"] != "from_file" {
t.Errorf("Expected API_KEY=from_file, got %s", merged["API_KEY"])
}
if merged["NEW_VAR"] != "from_config" {
t.Errorf("Expected NEW_VAR=from_config, got %s", merged["NEW_VAR"])
}
}
func TestLoadFromMCPConfig_EmptyWorkspaceWithRelativeEnvFile(t *testing.T) {
mgr := NewManager()
mcpCfg := config.MCPConfig{
Enabled: true,
Servers: map[string]config.MCPServerConfig{
"test-server": {
Enabled: true,
Command: "echo",
Args: []string{"ok"},
EnvFile: ".env",
},
},
}
err := mgr.LoadFromMCPConfig(context.Background(), mcpCfg, "")
if err == nil {
t.Fatal("expected error for relative env_file with empty workspace path, got nil")
}
if !strings.Contains(err.Error(), "workspace path is empty") {
t.Fatalf("expected workspace path validation error, got: %v", err)
}
}
func TestNewManager_InitialState(t *testing.T) {
mgr := NewManager()
if mgr == nil {
t.Fatal("expected manager instance, got nil")
}
if len(mgr.GetServers()) != 0 {
t.Fatalf("expected no servers on new manager, got %d", len(mgr.GetServers()))
}
}
func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) {
mgr := NewManager()
err := mgr.LoadFromMCPConfig(context.Background(), config.MCPConfig{Enabled: false}, "/tmp")
if err != nil {
t.Fatalf("expected nil error when MCP disabled, got: %v", err)
}
err = mgr.LoadFromMCPConfig(context.Background(), config.MCPConfig{Enabled: true}, "/tmp")
if err != nil {
t.Fatalf("expected nil error when no servers configured, got: %v", err)
}
}
func TestGetServers_ReturnsCopy(t *testing.T) {
mgr := NewManager()
mgr.servers["s1"] = &ServerConnection{Name: "s1"}
servers := mgr.GetServers()
delete(servers, "s1")
if _, ok := mgr.GetServer("s1"); !ok {
t.Fatal("expected internal manager state to remain unchanged")
}
}
func TestGetAllTools_FiltersEmptyTools(t *testing.T) {
mgr := NewManager()
mgr.servers["empty"] = &ServerConnection{Name: "empty", Tools: nil}
mgr.servers["with-tools"] = &ServerConnection{Name: "with-tools", Tools: []*sdkmcp.Tool{{}}}
all := mgr.GetAllTools()
if _, ok := all["empty"]; ok {
t.Fatal("expected server without tools to be excluded")
}
if _, ok := all["with-tools"]; !ok {
t.Fatal("expected server with tools to be included")
}
}
func TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) {
t.Run("manager closed", func(t *testing.T) {
mgr := NewManager()
mgr.closed.Store(true)
_, err := mgr.CallTool(context.Background(), "s1", "tool", nil)
if err == nil || !strings.Contains(err.Error(), "manager is closed") {
t.Fatalf("expected manager closed error, got: %v", err)
}
})
t.Run("server missing", func(t *testing.T) {
mgr := NewManager()
_, err := mgr.CallTool(context.Background(), "missing", "tool", nil)
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected server not found error, got: %v", err)
}
})
}
func TestClose_IdempotentOnEmptyManager(t *testing.T) {
mgr := NewManager()
if err := mgr.Close(); err != nil {
t.Fatalf("first close should succeed, got: %v", err)
}
if err := mgr.Close(); err != nil {
t.Fatalf("second close should be idempotent, got: %v", err)
}
}

View file

@ -118,37 +118,26 @@ func TestPlanWorkspaceMigration(t *testing.T) {
assert.GreaterOrEqual(t, len(actions), 1) assert.GreaterOrEqual(t, len(actions), 1)
} }
func TestPlanWorkspaceMigrationWithExistingDestination(t *testing.T) { func TestPlanWorkspaceMigrationExistingFile(t *testing.T) {
tmpDir := t.TempDir() tests := []struct {
srcWorkspace := filepath.Join(tmpDir, "src", "workspace") name string
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace") force bool
wantActionType ActionType
err := os.MkdirAll(srcWorkspace, 0o755) }{
require.NoError(t, err) {
name: "backup when not forced",
err = os.MkdirAll(dstWorkspace, 0o755) force: false,
require.NoError(t, err) wantActionType: ActionBackup,
},
err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644) {
require.NoError(t, err) name: "copy when forced",
force: true,
err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644) wantActionType: ActionCopy,
require.NoError(t, err) },
actions, err := PlanWorkspaceMigration(
srcWorkspace,
dstWorkspace,
[]string{"file1.txt"},
[]string{},
false,
)
require.NoError(t, err)
require.GreaterOrEqual(t, len(actions), 1)
assert.Equal(t, ActionBackup, actions[0].Type)
} }
func TestPlanWorkspaceMigrationForce(t *testing.T) { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
srcWorkspace := filepath.Join(tmpDir, "src", "workspace") srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace") dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
@ -170,12 +159,14 @@ func TestPlanWorkspaceMigrationForce(t *testing.T) {
dstWorkspace, dstWorkspace,
[]string{"file1.txt"}, []string{"file1.txt"},
[]string{}, []string{},
true, tt.force,
) )
require.NoError(t, err) require.NoError(t, err)
require.GreaterOrEqual(t, len(actions), 1) require.GreaterOrEqual(t, len(actions), 1)
assert.Equal(t, ActionCopy, actions[0].Type) assert.Equal(t, tt.wantActionType, actions[0].Type)
})
}
} }
func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) { func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) {

View file

@ -100,44 +100,12 @@ func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDe
} }
if len(tools) > 0 { if len(tools) > 0 {
parts = append(parts, p.buildToolsPrompt(tools)) parts = append(parts, buildCLIToolsPrompt(tools))
} }
return strings.Join(parts, "\n\n") return strings.Join(parts, "\n\n")
} }
// buildToolsPrompt creates the tool definitions section for the system prompt.
func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
sb.WriteString("```json\n")
sb.WriteString(
`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
)
sb.WriteString("\n```\n\n")
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
sb.WriteString("### Tool Definitions:\n\n")
for _, tool := range tools {
if tool.Type != "function" {
continue
}
sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name))
if tool.Function.Description != "" {
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
}
if len(tool.Function.Parameters) > 0 {
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON)))
}
sb.WriteString("\n")
}
return sb.String()
}
// parseClaudeCliResponse parses the JSON output from the claude CLI. // parseClaudeCliResponse parses the JSON output from the claude CLI.
func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) { func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) {
var resp claudeCliJSONResponse var resp claudeCliJSONResponse

View file

@ -660,12 +660,11 @@ func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) {
// --- buildToolsPrompt tests --- // --- buildToolsPrompt tests ---
func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) { func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
tools := []ToolDefinition{ tools := []ToolDefinition{
{Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}}, {Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}},
{Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}}, {Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}},
} }
got := p.buildToolsPrompt(tools) got := buildCLIToolsPrompt(tools)
if strings.Contains(got, "skip_me") { if strings.Contains(got, "skip_me") {
t.Error("buildToolsPrompt() should skip non-function tools") t.Error("buildToolsPrompt() should skip non-function tools")
} }
@ -675,11 +674,10 @@ func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) {
} }
func TestBuildToolsPrompt_NoDescription(t *testing.T) { func TestBuildToolsPrompt_NoDescription(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
tools := []ToolDefinition{ tools := []ToolDefinition{
{Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}}, {Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}},
} }
got := p.buildToolsPrompt(tools) got := buildCLIToolsPrompt(tools)
if !strings.Contains(got, "bare_tool") { if !strings.Contains(got, "bare_tool") {
t.Error("should include tool name") t.Error("should include tool name")
} }
@ -689,14 +687,13 @@ func TestBuildToolsPrompt_NoDescription(t *testing.T) {
} }
func TestBuildToolsPrompt_NoParameters(t *testing.T) { func TestBuildToolsPrompt_NoParameters(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
tools := []ToolDefinition{ tools := []ToolDefinition{
{Type: "function", Function: ToolFunctionDefinition{ {Type: "function", Function: ToolFunctionDefinition{
Name: "no_params_tool", Name: "no_params_tool",
Description: "A tool with no parameters", Description: "A tool with no parameters",
}}, }},
} }
got := p.buildToolsPrompt(tools) got := buildCLIToolsPrompt(tools)
if strings.Contains(got, "Parameters:") { if strings.Contains(got, "Parameters:") {
t.Error("should not include Parameters: section when nil") t.Error("should not include Parameters: section when nil")
} }

View file

@ -115,7 +115,7 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio
} }
if len(tools) > 0 { if len(tools) > 0 {
sb.WriteString(p.buildToolsPrompt(tools)) sb.WriteString(buildCLIToolsPrompt(tools))
sb.WriteString("\n\n") sb.WriteString("\n\n")
} }
@ -128,38 +128,6 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio
return sb.String() return sb.String()
} }
// buildToolsPrompt creates a tool definitions section for the prompt.
func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
sb.WriteString("```json\n")
sb.WriteString(
`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
)
sb.WriteString("\n```\n\n")
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
sb.WriteString("### Tool Definitions:\n\n")
for _, tool := range tools {
if tool.Type != "function" {
continue
}
sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name))
if tool.Function.Description != "" {
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
}
if len(tool.Function.Parameters) > 0 {
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON)))
}
sb.WriteString("\n")
}
return sb.String()
}
// codexEvent represents a single JSONL event from `codex exec --json`. // codexEvent represents a single JSONL event from `codex exec --json`.
type codexEvent struct { type codexEvent struct {
Type string `json:"type"` Type string `json:"type"`

View file

@ -102,6 +102,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.apiBase = "https://openrouter.ai/api/v1" sel.apiBase = "https://openrouter.ai/api/v1"
} }
} }
case "litellm":
if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" {
sel.apiKey = cfg.Providers.LiteLLM.APIKey
sel.apiBase = cfg.Providers.LiteLLM.APIBase
sel.proxy = cfg.Providers.LiteLLM.Proxy
if sel.apiBase == "" {
sel.apiBase = "http://localhost:4000/v1"
}
}
case "zhipu", "glm": case "zhipu", "glm":
if cfg.Providers.Zhipu.APIKey != "" { if cfg.Providers.Zhipu.APIKey != "" {
sel.apiKey = cfg.Providers.Zhipu.APIKey sel.apiKey = cfg.Providers.Zhipu.APIKey

View file

@ -53,7 +53,7 @@ func ExtractProtocol(model string) (protocol, modelID string) {
// CreateProviderFromConfig creates a provider based on the ModelConfig. // CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create. // It uses the protocol prefix in the Model field to determine which provider to create.
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot // Supported protocols: openai, litellm, anthropic, antigravity, claude-cli, codex-cli, github-copilot
// Returns the provider, the model ID (without protocol prefix), and any error. // Returns the provider, the model ID (without protocol prefix), and any error.
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
if cfg == nil { if cfg == nil {
@ -92,7 +92,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout, cfg.RequestTimeout,
), modelID, nil ), modelID, nil
case "openrouter", "groq", "zhipu", "gemini", "nvidia", case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"volcengine", "vllm", "qwen", "mistral": "volcengine", "vllm", "qwen", "mistral":
// All other OpenAI-compatible HTTP providers // All other OpenAI-compatible HTTP providers
@ -180,6 +180,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://api.openai.com/v1" return "https://api.openai.com/v1"
case "openrouter": case "openrouter":
return "https://openrouter.ai/api/v1" return "https://openrouter.ai/api/v1"
case "litellm":
return "http://localhost:4000/v1"
case "groq": case "groq":
return "https://api.groq.com/openai/v1" return "https://api.groq.com/openai/v1"
case "zhipu": case "zhipu":

View file

@ -135,6 +135,32 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
} }
} }
func TestGetDefaultAPIBase_LiteLLM(t *testing.T) {
if got := getDefaultAPIBase("litellm"); got != "http://localhost:4000/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "litellm", got, "http://localhost:4000/v1")
}
}
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-litellm",
Model: "litellm/my-proxy-alias",
APIKey: "test-key",
APIBase: "http://localhost:4000/v1",
}
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "my-proxy-alias" {
t.Errorf("modelID = %q, want %q", modelID, "my-proxy-alias")
}
}
func TestCreateProviderFromConfig_Anthropic(t *testing.T) { func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
cfg := &config.ModelConfig{ cfg := &config.ModelConfig{
ModelName: "test-anthropic", ModelName: "test-anthropic",

View file

@ -17,6 +17,27 @@ func TestResolveProviderSelection(t *testing.T) {
wantProxy string wantProxy string
wantErrSubstr string wantErrSubstr string
}{ }{
{
name: "explicit litellm provider uses configured base",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "litellm"
cfg.Providers.LiteLLM.APIKey = "litellm-key"
cfg.Providers.LiteLLM.APIBase = "http://localhost:4000/v1"
cfg.Providers.LiteLLM.Proxy = "http://127.0.0.1:7890"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "http://localhost:4000/v1",
wantProxy: "http://127.0.0.1:7890",
},
{
name: "explicit litellm provider defaults base when only key is configured",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "litellm"
cfg.Providers.LiteLLM.APIKey = "litellm-key"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "http://localhost:4000/v1",
},
{ {
name: "explicit claude-cli provider routes to cli provider type", name: "explicit claude-cli provider routes to cli provider type",
setup: func(cfg *config.Config) { setup: func(cfg *config.Config) {

View file

@ -291,6 +291,7 @@ func parseResponse(body []byte) (*LLMResponse, error) {
type openaiMessage struct { type openaiMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"`
} }
@ -304,6 +305,7 @@ func stripSystemParts(messages []Message) []openaiMessage {
out[i] = openaiMessage{ out[i] = openaiMessage{
Role: m.Role, Role: m.Role,
Content: m.Content, Content: m.Content,
ReasoningContent: m.ReasoningContent,
ToolCalls: m.ToolCalls, ToolCalls: m.ToolCalls,
ToolCallID: m.ToolCallID, ToolCallID: m.ToolCallID,
} }
@ -323,7 +325,7 @@ func normalizeModel(model, apiBase string) string {
prefix := strings.ToLower(before) prefix := strings.ToLower(before)
switch prefix { switch prefix {
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral": case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
return after return after
default: default:
return model return model

View file

@ -146,6 +146,56 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) {
} }
} }
func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
// Simulate a multi-turn conversation where the assistant's previous
// reply included reasoning_content (e.g. from kimi-k2.5).
messages := []Message{
{Role: "user", Content: "What is 1+1?"},
{Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"},
{Role: "user", Content: "What about 2+2?"},
}
_, err := p.Chat(t.Context(), messages, nil, "kimi-k2.5", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
// Verify reasoning_content is preserved in the serialized request.
reqMessages, ok := requestBody["messages"].([]any)
if !ok {
t.Fatalf("messages is not []any: %T", requestBody["messages"])
}
assistantMsg, ok := reqMessages[1].(map[string]any)
if !ok {
t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1])
}
if assistantMsg["reasoning_content"] != "Let me think... 1+1=2" {
t.Errorf("reasoning_content not preserved in request, got %v", assistantMsg["reasoning_content"])
}
}
func TestProviderChat_HTTPError(t *testing.T) { func TestProviderChat_HTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
@ -206,6 +256,11 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) {
input string input string
wantModel string wantModel string
}{ }{
{
name: "strips litellm prefix and preserves proxy model name",
input: "litellm/my-proxy-alias",
wantModel: "my-proxy-alias",
},
{ {
name: "strips groq prefix and keeps nested model", name: "strips groq prefix and keeps nested model",
input: "groq/openai/gpt-oss-120b", input: "groq/openai/gpt-oss-120b",

View file

@ -5,7 +5,43 @@
package providers package providers
import "encoding/json" import (
"encoding/json"
"fmt"
"strings"
)
// buildCLIToolsPrompt creates the tool definitions section for a CLI provider system prompt.
func buildCLIToolsPrompt(tools []ToolDefinition) string {
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
sb.WriteString("```json\n")
sb.WriteString(
`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
)
sb.WriteString("\n```\n\n")
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
sb.WriteString("### Tool Definitions:\n\n")
for _, tool := range tools {
if tool.Type != "function" {
continue
}
sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name))
if tool.Function.Description != "" {
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
}
if len(tool.Function.Parameters) > 0 {
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON)))
}
sb.WriteString("\n")
}
return sb.String()
}
// NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated. // NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated.
// It handles cases where Name/Arguments might be in different locations (top-level vs Function) // It handles cases where Name/Arguments might be in different locations (top-level vs Function)

View file

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"regexp"
"strings" "strings"
) )
@ -15,14 +16,12 @@ type EditFileTool struct {
} }
// NewEditFileTool creates a new EditFileTool with optional directory restriction. // NewEditFileTool creates a new EditFileTool with optional directory restriction.
func NewEditFileTool(workspace string, restrict bool) *EditFileTool { func NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool {
var fs fileSystem var patterns []*regexp.Regexp
if restrict { if len(allowPaths) > 0 {
fs = &sandboxFs{workspace: workspace} patterns = allowPaths[0]
} else {
fs = &hostFs{}
} }
return &EditFileTool{fs: fs} return &EditFileTool{fs: buildFs(workspace, restrict, patterns)}
} }
func (t *EditFileTool) Name() string { func (t *EditFileTool) Name() string {
@ -80,14 +79,12 @@ type AppendFileTool struct {
fs fileSystem fs fileSystem
} }
func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool { func NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool {
var fs fileSystem var patterns []*regexp.Regexp
if restrict { if len(allowPaths) > 0 {
fs = &sandboxFs{workspace: workspace} patterns = allowPaths[0]
} else {
fs = &hostFs{}
} }
return &AppendFileTool{fs: fs} return &AppendFileTool{fs: buildFs(workspace, restrict, patterns)}
} }
func (t *AppendFileTool) Name() string { func (t *AppendFileTool) Name() string {

View file

@ -6,6 +6,7 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
@ -87,14 +88,12 @@ type ReadFileTool struct {
fs fileSystem fs fileSystem
} }
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool { func NewReadFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ReadFileTool {
var fs fileSystem var patterns []*regexp.Regexp
if restrict { if len(allowPaths) > 0 {
fs = &sandboxFs{workspace: workspace} patterns = allowPaths[0]
} else {
fs = &hostFs{}
} }
return &ReadFileTool{fs: fs} return &ReadFileTool{fs: buildFs(workspace, restrict, patterns)}
} }
func (t *ReadFileTool) Name() string { func (t *ReadFileTool) Name() string {
@ -135,14 +134,12 @@ type WriteFileTool struct {
fs fileSystem fs fileSystem
} }
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool { func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool {
var fs fileSystem var patterns []*regexp.Regexp
if restrict { if len(allowPaths) > 0 {
fs = &sandboxFs{workspace: workspace} patterns = allowPaths[0]
} else {
fs = &hostFs{}
} }
return &WriteFileTool{fs: fs} return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)}
} }
func (t *WriteFileTool) Name() string { func (t *WriteFileTool) Name() string {
@ -192,14 +189,12 @@ type ListDirTool struct {
fs fileSystem fs fileSystem
} }
func NewListDirTool(workspace string, restrict bool) *ListDirTool { func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool {
var fs fileSystem var patterns []*regexp.Regexp
if restrict { if len(allowPaths) > 0 {
fs = &sandboxFs{workspace: workspace} patterns = allowPaths[0]
} else {
fs = &hostFs{}
} }
return &ListDirTool{fs: fs} return &ListDirTool{fs: buildFs(workspace, restrict, patterns)}
} }
func (t *ListDirTool) Name() string { func (t *ListDirTool) Name() string {
@ -394,6 +389,57 @@ func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
return entries, err return entries, err
} }
// whitelistFs wraps a sandboxFs and allows access to specific paths outside
// the workspace when they match any of the provided patterns.
type whitelistFs struct {
sandbox *sandboxFs
host hostFs
patterns []*regexp.Regexp
}
func (w *whitelistFs) matches(path string) bool {
for _, p := range w.patterns {
if p.MatchString(path) {
return true
}
}
return false
}
func (w *whitelistFs) ReadFile(path string) ([]byte, error) {
if w.matches(path) {
return w.host.ReadFile(path)
}
return w.sandbox.ReadFile(path)
}
func (w *whitelistFs) WriteFile(path string, data []byte) error {
if w.matches(path) {
return w.host.WriteFile(path, data)
}
return w.sandbox.WriteFile(path, data)
}
func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) {
if w.matches(path) {
return w.host.ReadDir(path)
}
return w.sandbox.ReadDir(path)
}
// buildFs returns the appropriate fileSystem implementation based on restriction
// settings and optional path whitelist patterns.
func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem {
if !restrict {
return &hostFs{}
}
sandbox := &sandboxFs{workspace: workspace}
if len(patterns) > 0 {
return &whitelistFs{sandbox: sandbox, patterns: patterns}
}
return sandbox
}
// Helper to get a safe relative path for os.Root usage // Helper to get a safe relative path for os.Root usage
func getSafeRelPath(workspace, path string) (string, error) { func getSafeRelPath(workspace, path string) (string, error) {
if workspace == "" { if workspace == "" {

View file

@ -5,6 +5,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"testing" "testing"
@ -486,3 +487,36 @@ func TestRootRW_Write(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, newData, content) assert.Equal(t, newData, content)
} }
// TestWhitelistFs_AllowsMatchingPaths verifies that whitelistFs allows access to
// paths matching the whitelist patterns while blocking non-matching paths.
func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) {
workspace := t.TempDir()
outsideDir := t.TempDir()
outsideFile := filepath.Join(outsideDir, "allowed.txt")
os.WriteFile(outsideFile, []byte("outside content"), 0o644)
// Pattern allows access to the outsideDir.
patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(outsideDir))}
tool := NewReadFileTool(workspace, true, patterns)
// Read from whitelisted path should succeed.
result := tool.Execute(context.Background(), map[string]any{"path": outsideFile})
if result.IsError {
t.Errorf("expected whitelisted path to be readable, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "outside content") {
t.Errorf("expected file content, got: %s", result.ForLLM)
}
// Read from non-whitelisted path outside workspace should fail.
otherDir := t.TempDir()
otherFile := filepath.Join(otherDir, "blocked.txt")
os.WriteFile(otherFile, []byte("blocked"), 0o644)
result = tool.Execute(context.Background(), map[string]any{"path": otherFile})
if !result.IsError {
t.Errorf("expected non-whitelisted path to be blocked, got: %s", result.ForLLM)
}
}

246
pkg/tools/mcp_tool.go Normal file
View file

@ -0,0 +1,246 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// MCPManager defines the interface for MCP manager operations
// This allows for easier testing with mock implementations
type MCPManager interface {
CallTool(
ctx context.Context,
serverName, toolName string,
arguments map[string]any,
) (*mcp.CallToolResult, error)
}
// MCPTool wraps an MCP tool to implement the Tool interface
type MCPTool struct {
manager MCPManager
serverName string
tool *mcp.Tool
}
// NewMCPTool creates a new MCP tool wrapper
func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool {
return &MCPTool{
manager: manager,
serverName: serverName,
tool: tool,
}
}
// sanitizeIdentifierComponent normalizes a string so it can be safely used
// as part of a tool/function identifier for downstream providers.
// It:
// - lowercases the string
// - replaces any character not in [a-z0-9_-] with '_'
// - collapses multiple consecutive '_' into a single '_'
// - trims leading/trailing '_'
// - falls back to "unnamed" if the result is empty
// - truncates overly long components to a reasonable length
func sanitizeIdentifierComponent(s string) string {
const maxLen = 64
s = strings.ToLower(s)
var b strings.Builder
b.Grow(len(s))
prevUnderscore := false
for _, r := range s {
isAllowed := (r >= 'a' && r <= 'z') ||
(r >= '0' && r <= '9') ||
r == '_' || r == '-'
if !isAllowed {
// Normalize any disallowed character to '_'
if !prevUnderscore {
b.WriteRune('_')
prevUnderscore = true
}
continue
}
if r == '_' {
if prevUnderscore {
continue
}
prevUnderscore = true
} else {
prevUnderscore = false
}
b.WriteRune(r)
}
result := strings.Trim(b.String(), "_")
if result == "" {
result = "unnamed"
}
if len(result) > maxLen {
result = result[:maxLen]
}
return result
}
// Name returns the tool name, prefixed with the server name.
// The total length is capped at 64 characters (OpenAI-compatible API limit).
// A short hash of the original (unsanitized) server and tool names is appended
// whenever sanitization is lossy or the name is truncated, ensuring that two
// names which differ only in disallowed characters remain distinct after sanitization.
func (t *MCPTool) Name() string {
// Prefix with server name to avoid conflicts, and sanitize components
sanitizedServer := sanitizeIdentifierComponent(t.serverName)
sanitizedTool := sanitizeIdentifierComponent(t.tool.Name)
full := fmt.Sprintf("mcp_%s_%s", sanitizedServer, sanitizedTool)
// Check if sanitization was lossless (only lowercasing, no char replacement/truncation)
lossless := strings.ToLower(t.serverName) == sanitizedServer &&
strings.ToLower(t.tool.Name) == sanitizedTool
const maxTotal = 64
if lossless && len(full) <= maxTotal {
return full
}
// Sanitization was lossy or name too long: append hash of the ORIGINAL names
// (not the sanitized names) so different originals always yield different hashes.
h := fnv.New32a()
_, _ = h.Write([]byte(t.serverName + "\x00" + t.tool.Name))
suffix := fmt.Sprintf("%08x", h.Sum32()) // 8 chars
base := full
if len(base) > maxTotal-9 {
base = strings.TrimRight(full[:maxTotal-9], "_")
}
return base + "_" + suffix
}
// Description returns the tool description
func (t *MCPTool) Description() string {
desc := t.tool.Description
if desc == "" {
desc = fmt.Sprintf("MCP tool from %s server", t.serverName)
}
// Add server info to description
return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc)
}
// Parameters returns the tool parameters schema
func (t *MCPTool) Parameters() map[string]any {
// The InputSchema is already a JSON Schema object
schema := t.tool.InputSchema
// Handle nil schema
if schema == nil {
return map[string]any{
"type": "object",
"properties": map[string]any{},
"required": []string{},
}
}
// Try direct conversion first (fast path)
if schemaMap, ok := schema.(map[string]any); ok {
return schemaMap
}
// Handle json.RawMessage and []byte - unmarshal directly
var jsonData []byte
if rawMsg, ok := schema.(json.RawMessage); ok {
jsonData = rawMsg
} else if bytes, ok := schema.([]byte); ok {
jsonData = bytes
}
if jsonData != nil {
var result map[string]any
if err := json.Unmarshal(jsonData, &result); err == nil {
return result
}
// Fallback on error
return map[string]any{
"type": "object",
"properties": map[string]any{},
"required": []string{},
}
}
// For other types (structs, etc.), convert via JSON marshal/unmarshal
var err error
jsonData, err = json.Marshal(schema)
if err != nil {
// Fallback to empty schema if marshaling fails
return map[string]any{
"type": "object",
"properties": map[string]any{},
"required": []string{},
}
}
var result map[string]any
if err := json.Unmarshal(jsonData, &result); err != nil {
// Fallback to empty schema if unmarshaling fails
return map[string]any{
"type": "object",
"properties": map[string]any{},
"required": []string{},
}
}
return result
}
// Execute executes the MCP tool
func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args)
if err != nil {
return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err)
}
if result == nil {
nilErr := fmt.Errorf("MCP tool returned nil result without error")
return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr)
}
// Handle error result from server
if result.IsError {
errMsg := extractContentText(result.Content)
return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)).
WithError(fmt.Errorf("MCP tool error: %s", errMsg))
}
// Extract text content from result
output := extractContentText(result.Content)
return &ToolResult{
ForLLM: output,
IsError: false,
}
}
// extractContentText extracts text from MCP content array
func extractContentText(content []mcp.Content) string {
var parts []string
for _, c := range content {
switch v := c.(type) {
case *mcp.TextContent:
parts = append(parts, v.Text)
case *mcp.ImageContent:
// For images, just indicate that an image was returned
parts = append(parts, fmt.Sprintf("[Image: %s]", v.MIMEType))
default:
// For other content types, use string representation
parts = append(parts, fmt.Sprintf("[Content: %T]", v))
}
}
return strings.Join(parts, "\n")
}

492
pkg/tools/mcp_tool_test.go Normal file
View file

@ -0,0 +1,492 @@
package tools
import (
"context"
"fmt"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// MockMCPManager is a mock implementation of MCPManager interface for testing
type MockMCPManager struct {
callToolFunc func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error)
}
func (m *MockMCPManager) CallTool(
ctx context.Context,
serverName, toolName string,
arguments map[string]any,
) (*mcp.CallToolResult, error) {
if m.callToolFunc != nil {
return m.callToolFunc(ctx, serverName, toolName, arguments)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "mock result"},
},
IsError: false,
}, nil
}
// TestNewMCPTool verifies MCP tool creation
func TestNewMCPTool(t *testing.T) {
manager := &MockMCPManager{}
tool := &mcp.Tool{
Name: "test_tool",
Description: "A test tool",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"input": map[string]any{
"type": "string",
"description": "Test input",
},
},
},
}
mcpTool := NewMCPTool(manager, "test_server", tool)
if mcpTool == nil {
t.Fatal("NewMCPTool should not return nil")
}
// Verify tool properties we can access
if mcpTool.Name() != "mcp_test_server_test_tool" {
t.Errorf("Expected tool name with prefix, got '%s'", mcpTool.Name())
}
}
// TestMCPTool_Name verifies tool name with server prefix
func TestMCPTool_Name(t *testing.T) {
tests := []struct {
name string
serverName string
toolName string
expected string
}{
{
name: "simple name",
serverName: "github",
toolName: "create_issue",
expected: "mcp_github_create_issue",
},
{
name: "filesystem server",
serverName: "filesystem",
toolName: "read_file",
expected: "mcp_filesystem_read_file",
},
{
name: "remote server",
serverName: "remote-api",
toolName: "fetch_data",
expected: "mcp_remote-api_fetch_data",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &MockMCPManager{}
tool := &mcp.Tool{Name: tt.toolName}
mcpTool := NewMCPTool(manager, tt.serverName, tool)
result := mcpTool.Name()
if result != tt.expected {
t.Errorf("Expected name '%s', got '%s'", tt.expected, result)
}
})
}
}
// TestMCPTool_Description verifies tool description generation
func TestMCPTool_Description(t *testing.T) {
tests := []struct {
name string
serverName string
toolDescription string
expectContains []string
}{
{
name: "with description",
serverName: "github",
toolDescription: "Create a GitHub issue",
expectContains: []string{"[MCP:github]", "Create a GitHub issue"},
},
{
name: "empty description",
serverName: "filesystem",
toolDescription: "",
expectContains: []string{"[MCP:filesystem]", "MCP tool from filesystem server"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &MockMCPManager{}
tool := &mcp.Tool{
Name: "test_tool",
Description: tt.toolDescription,
}
mcpTool := NewMCPTool(manager, tt.serverName, tool)
result := mcpTool.Description()
for _, expected := range tt.expectContains {
if !strings.Contains(result, expected) {
t.Errorf("Description should contain '%s', got: %s", expected, result)
}
}
})
}
}
// TestMCPTool_Parameters verifies parameter schema conversion
func TestMCPTool_Parameters(t *testing.T) {
tests := []struct {
name string
inputSchema any
expectType string
checkProperty string
expectProperty bool
}{
{
name: "map schema",
inputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "Search query",
},
},
"required": []string{"query"},
},
expectType: "object",
checkProperty: "query",
expectProperty: true,
},
{
name: "nil schema",
inputSchema: nil,
expectType: "object",
expectProperty: false,
},
{
name: "json.RawMessage schema",
inputSchema: []byte(`{
"type": "object",
"properties": {
"repo": {
"type": "string",
"description": "Repository name"
},
"stars": {
"type": "integer",
"description": "Minimum stars"
}
},
"required": ["repo"]
}`),
expectType: "object",
checkProperty: "repo",
expectProperty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &MockMCPManager{}
tool := &mcp.Tool{
Name: "test_tool",
InputSchema: tt.inputSchema,
}
mcpTool := NewMCPTool(manager, "test_server", tool)
params := mcpTool.Parameters()
if params == nil {
t.Fatal("Parameters should not be nil")
}
if params["type"] != tt.expectType {
t.Errorf("Expected type '%s', got '%v'", tt.expectType, params["type"])
}
// Check if property exists when expected
if tt.checkProperty != "" {
properties, ok := params["properties"].(map[string]any)
if !ok && tt.expectProperty {
t.Errorf("Expected properties to be a map")
return
}
if ok {
_, hasProperty := properties[tt.checkProperty]
if hasProperty != tt.expectProperty {
t.Errorf("Expected property '%s' existence: %v, got: %v",
tt.checkProperty, tt.expectProperty, hasProperty)
}
}
}
})
}
}
// TestMCPTool_Execute_Success tests successful tool execution
func TestMCPTool_Execute_Success(t *testing.T) {
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
// Verify correct parameters passed
if serverName != "github" {
t.Errorf("Expected serverName 'github', got '%s'", serverName)
}
if toolName != "search_repos" {
t.Errorf("Expected toolName 'search_repos', got '%s'", toolName)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Found 3 repositories"},
},
IsError: false,
}, nil
},
}
tool := &mcp.Tool{
Name: "search_repos",
Description: "Search GitHub repositories",
}
mcpTool := NewMCPTool(manager, "github", tool)
ctx := context.Background()
args := map[string]any{
"query": "golang mcp",
}
result := mcpTool.Execute(ctx, args)
if result == nil {
t.Fatal("Result should not be nil")
}
if result.IsError {
t.Errorf("Expected no error, got error: %s", result.ForLLM)
}
if result.ForLLM != "Found 3 repositories" {
t.Errorf("Expected 'Found 3 repositories', got '%s'", result.ForLLM)
}
}
// TestMCPTool_Execute_ManagerError tests execution when manager returns error
func TestMCPTool_Execute_ManagerError(t *testing.T) {
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
return nil, fmt.Errorf("connection failed")
},
}
tool := &mcp.Tool{Name: "test_tool"}
mcpTool := NewMCPTool(manager, "test_server", tool)
ctx := context.Background()
result := mcpTool.Execute(ctx, map[string]any{})
if result == nil {
t.Fatal("Result should not be nil")
}
if !result.IsError {
t.Error("Expected IsError to be true")
}
if !strings.Contains(result.ForLLM, "MCP tool execution failed") {
t.Errorf("Error message should mention execution failure, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "connection failed") {
t.Errorf("Error message should include original error, got: %s", result.ForLLM)
}
}
// TestMCPTool_Execute_ServerError tests execution when server returns error
func TestMCPTool_Execute_ServerError(t *testing.T) {
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Invalid API key"},
},
IsError: true,
}, nil
},
}
tool := &mcp.Tool{Name: "test_tool"}
mcpTool := NewMCPTool(manager, "test_server", tool)
ctx := context.Background()
result := mcpTool.Execute(ctx, map[string]any{})
if result == nil {
t.Fatal("Result should not be nil")
}
if !result.IsError {
t.Error("Expected IsError to be true")
}
if !strings.Contains(result.ForLLM, "MCP tool returned error") {
t.Errorf("Error message should mention server error, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "Invalid API key") {
t.Errorf("Error message should include server message, got: %s", result.ForLLM)
}
}
// TestMCPTool_Execute_MultipleContent tests execution with multiple content items
func TestMCPTool_Execute_MultipleContent(t *testing.T) {
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "First line"},
&mcp.TextContent{Text: "Second line"},
&mcp.TextContent{Text: "Third line"},
},
IsError: false,
}, nil
},
}
tool := &mcp.Tool{Name: "multi_output"}
mcpTool := NewMCPTool(manager, "test_server", tool)
ctx := context.Background()
result := mcpTool.Execute(ctx, map[string]any{})
if result.IsError {
t.Errorf("Expected no error, got: %s", result.ForLLM)
}
expected := "First line\nSecond line\nThird line"
if result.ForLLM != expected {
t.Errorf("Expected '%s', got '%s'", expected, result.ForLLM)
}
}
// TestExtractContentText_TextContent tests text content extraction
func TestExtractContentText_TextContent(t *testing.T) {
content := []mcp.Content{
&mcp.TextContent{Text: "Hello World"},
&mcp.TextContent{Text: "Second message"},
}
result := extractContentText(content)
expected := "Hello World\nSecond message"
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
}
// TestExtractContentText_ImageContent tests image content extraction
func TestExtractContentText_ImageContent(t *testing.T) {
content := []mcp.Content{
&mcp.ImageContent{
Data: []byte("base64data"),
MIMEType: "image/png",
},
}
result := extractContentText(content)
if !strings.Contains(result, "[Image:") {
t.Errorf("Expected image indicator, got: %s", result)
}
if !strings.Contains(result, "image/png") {
t.Errorf("Expected MIME type in output, got: %s", result)
}
}
// TestExtractContentText_MixedContent tests mixed content types
func TestExtractContentText_MixedContent(t *testing.T) {
content := []mcp.Content{
&mcp.TextContent{Text: "Description"},
&mcp.ImageContent{
Data: []byte("data"),
MIMEType: "image/jpeg",
},
&mcp.TextContent{Text: "More text"},
}
result := extractContentText(content)
if !strings.Contains(result, "Description") {
t.Errorf("Should contain text content, got: %s", result)
}
if !strings.Contains(result, "[Image:") {
t.Errorf("Should contain image indicator, got: %s", result)
}
if !strings.Contains(result, "More text") {
t.Errorf("Should contain second text, got: %s", result)
}
}
// TestExtractContentText_EmptyContent tests empty content array
func TestExtractContentText_EmptyContent(t *testing.T) {
content := []mcp.Content{}
result := extractContentText(content)
if result != "" {
t.Errorf("Expected empty string for empty content, got: %s", result)
}
}
// TestMCPTool_InterfaceCompliance verifies MCPTool implements Tool interface
func TestMCPTool_InterfaceCompliance(t *testing.T) {
manager := &MockMCPManager{}
tool := &mcp.Tool{Name: "test"}
mcpTool := NewMCPTool(manager, "test_server", tool)
// Verify it implements Tool interface
var _ Tool = mcpTool
}
// TestMCPTool_Parameters_MapSchema tests schema that's already a map
func TestMCPTool_Parameters_MapSchema(t *testing.T) {
manager := &MockMCPManager{}
schema := map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"description": "The name parameter",
},
},
"required": []string{"name"},
}
tool := &mcp.Tool{
Name: "test_tool",
InputSchema: schema,
}
mcpTool := NewMCPTool(manager, "test_server", tool)
params := mcpTool.Parameters()
// Should return the schema as-is when it's already a map
if params["type"] != "object" {
t.Errorf("Expected type 'object', got '%v'", params["type"])
}
props, ok := params["properties"].(map[string]any)
if !ok {
t.Error("Properties should be a map")
}
nameParam, ok := props["name"].(map[string]any)
if !ok {
t.Error("Name parameter should exist")
}
if nameParam["type"] != "string" {
t.Errorf("Name type should be 'string', got '%v'", nameParam["type"])
}
}

View file

@ -25,7 +25,12 @@ func NewToolRegistry() *ToolRegistry {
func (r *ToolRegistry) Register(tool Tool) { func (r *ToolRegistry) Register(tool Tool) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
r.tools[tool.Name()] = tool name := tool.Name()
if _, exists := r.tools[name]; exists {
logger.WarnCF("tools", "Tool registration overwrites existing tool",
map[string]any{"name": name})
}
r.tools[name] = tool
} }
func (r *ToolRegistry) Get(name string) (Tool, bool) { func (r *ToolRegistry) Get(name string) (Tool, bool) {

View file

@ -21,6 +21,7 @@ type ExecTool struct {
timeout time.Duration timeout time.Duration
denyPatterns []*regexp.Regexp denyPatterns []*regexp.Regexp
allowPatterns []*regexp.Regexp allowPatterns []*regexp.Regexp
customAllowPatterns []*regexp.Regexp
restrictToWorkspace bool restrictToWorkspace bool
} }
@ -34,7 +35,10 @@ var (
`\b(format|mkfs|diskpart)\b\s`, `\b(format|mkfs|diskpart)\b\s`,
), ),
regexp.MustCompile(`\bdd\s+if=`), regexp.MustCompile(`\bdd\s+if=`),
regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) // Block writes to block devices (all common naming schemes).
regexp.MustCompile(
`>\s*/dev/(sd[a-z]|hd[a-z]|vd[a-z]|xvd[a-z]|nvme\d|mmcblk\d|loop\d|dm-\d|md\d|sr\d|nbd\d)`,
),
regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
regexp.MustCompile(`\$\([^)]+\)`), regexp.MustCompile(`\$\([^)]+\)`),
@ -45,7 +49,6 @@ var (
regexp.MustCompile(`;\s*rm\s+-[rf]`), regexp.MustCompile(`;\s*rm\s+-[rf]`),
regexp.MustCompile(`&&\s*rm\s+-[rf]`), regexp.MustCompile(`&&\s*rm\s+-[rf]`),
regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), regexp.MustCompile(`\|\|\s*rm\s+-[rf]`),
regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`),
regexp.MustCompile(`<<\s*EOF`), regexp.MustCompile(`<<\s*EOF`),
regexp.MustCompile(`\$\(\s*cat\s+`), regexp.MustCompile(`\$\(\s*cat\s+`),
regexp.MustCompile(`\$\(\s*curl\s+`), regexp.MustCompile(`\$\(\s*curl\s+`),
@ -75,6 +78,19 @@ var (
// absolutePathPattern matches absolute file paths in commands (Unix and Windows). // absolutePathPattern matches absolute file paths in commands (Unix and Windows).
absolutePathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`) absolutePathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
// safePaths are kernel pseudo-devices that are always safe to reference in
// commands, regardless of workspace restriction. They contain no user data
// and cannot cause destructive writes.
safePaths = map[string]bool{
"/dev/null": true,
"/dev/zero": true,
"/dev/random": true,
"/dev/urandom": true,
"/dev/stdin": true,
"/dev/stdout": true,
"/dev/stderr": true,
}
) )
func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) { func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
@ -83,6 +99,7 @@ func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) { func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) {
denyPatterns := make([]*regexp.Regexp, 0) denyPatterns := make([]*regexp.Regexp, 0)
customAllowPatterns := make([]*regexp.Regexp, 0)
if config != nil { if config != nil {
execConfig := config.Tools.Exec execConfig := config.Tools.Exec
@ -103,6 +120,13 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
// If deny patterns are disabled, we won't add any patterns, allowing all commands. // If deny patterns are disabled, we won't add any patterns, allowing all commands.
fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.") fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.")
} }
for _, pattern := range execConfig.CustomAllowPatterns {
re, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid custom allow pattern %q: %w", pattern, err)
}
customAllowPatterns = append(customAllowPatterns, re)
}
} else { } else {
denyPatterns = append(denyPatterns, defaultDenyPatterns...) denyPatterns = append(denyPatterns, defaultDenyPatterns...)
} }
@ -112,6 +136,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
timeout: 60 * time.Second, timeout: 60 * time.Second,
denyPatterns: denyPatterns, denyPatterns: denyPatterns,
allowPatterns: nil, allowPatterns: nil,
customAllowPatterns: customAllowPatterns,
restrictToWorkspace: restrict, restrictToWorkspace: restrict,
}, nil }, nil
} }
@ -266,11 +291,22 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
cmd := strings.TrimSpace(command) cmd := strings.TrimSpace(command)
lower := strings.ToLower(cmd) lower := strings.ToLower(cmd)
// Custom allow patterns exempt a command from deny checks.
explicitlyAllowed := false
for _, pattern := range t.customAllowPatterns {
if pattern.MatchString(lower) {
explicitlyAllowed = true
break
}
}
if !explicitlyAllowed {
for _, pattern := range t.denyPatterns { for _, pattern := range t.denyPatterns {
if pattern.MatchString(lower) { if pattern.MatchString(lower) {
return "Command blocked by safety guard (dangerous pattern detected)" return "Command blocked by safety guard (dangerous pattern detected)"
} }
} }
}
if len(t.allowPatterns) > 0 { if len(t.allowPatterns) > 0 {
allowed := false allowed := false
@ -303,6 +339,10 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
continue continue
} }
if safePaths[p] {
continue
}
rel, err := filepath.Rel(cwdPath, p) rel, err := filepath.Rel(cwdPath, p)
if err != nil { if err != nil {
continue continue

View file

@ -7,6 +7,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/sipeed/picoclaw/pkg/config"
) )
// TestShellTool_Success verifies successful command execution // TestShellTool_Success verifies successful command execution
@ -309,3 +311,115 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) {
) )
} }
} }
// TestShellTool_DevNullAllowed verifies that /dev/null redirections are not blocked (issue #964).
func TestShellTool_DevNullAllowed(t *testing.T) {
tmpDir := t.TempDir()
tool, err := NewExecTool(tmpDir, true)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
commands := []string{
"echo hello 2>/dev/null",
"echo hello >/dev/null",
"echo hello > /dev/null",
"echo hello 2> /dev/null",
"echo hello >/dev/null 2>&1",
"find " + tmpDir + " -name '*.go' 2>/dev/null",
}
for _, cmd := range commands {
result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if result.IsError && strings.Contains(result.ForLLM, "blocked") {
t.Errorf("command should not be blocked: %s\n error: %s", cmd, result.ForLLM)
}
}
}
// TestShellTool_BlockDevices verifies that writes to block devices are blocked (issue #965).
func TestShellTool_BlockDevices(t *testing.T) {
tool, err := NewExecTool("", false)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
blocked := []string{
"echo x > /dev/sda",
"echo x > /dev/hda",
"echo x > /dev/vda",
"echo x > /dev/xvda",
"echo x > /dev/nvme0n1",
"echo x > /dev/mmcblk0",
"echo x > /dev/loop0",
"echo x > /dev/dm-0",
"echo x > /dev/md0",
"echo x > /dev/sr0",
"echo x > /dev/nbd0",
}
for _, cmd := range blocked {
result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if !result.IsError {
t.Errorf("expected block device write to be blocked: %s", cmd)
}
}
}
// TestShellTool_SafePathsInWorkspaceRestriction verifies that safe kernel pseudo-devices
// are allowed even when workspace restriction is active.
func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) {
tmpDir := t.TempDir()
tool, err := NewExecTool(tmpDir, true)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
// These reference paths outside workspace but should be allowed via safePaths.
commands := []string{
"cat /dev/urandom | head -c 16 | od",
"echo test > /dev/null",
"dd if=/dev/zero bs=1 count=1",
}
for _, cmd := range commands {
result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("safe path should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM)
}
}
}
// TestShellTool_CustomAllowPatterns verifies that custom allow patterns exempt
// commands from deny pattern checks.
func TestShellTool_CustomAllowPatterns(t *testing.T) {
cfg := &config.Config{
Tools: config.ToolsConfig{
Exec: config.ExecConfig{
EnableDenyPatterns: true,
CustomAllowPatterns: []string{`\bgit\s+push\s+origin\b`},
},
},
}
tool, err := NewExecToolWithConfig("", false, cfg)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
// "git push origin main" should be allowed by custom allow pattern.
result := tool.Execute(context.Background(), map[string]any{
"command": "git push origin main",
})
if result.IsError && strings.Contains(result.ForLLM, "blocked") {
t.Errorf("custom allow pattern should exempt 'git push origin main', got: %s", result.ForLLM)
}
// "git push upstream main" should still be blocked (does not match allow pattern).
result = tool.Execute(context.Background(), map[string]any{
"command": "git push upstream main",
})
if !result.IsError {
t.Errorf("'git push upstream main' should still be blocked by deny pattern")
}
}

49
scripts/test-docker-mcp.sh Executable file
View file

@ -0,0 +1,49 @@
#!/bin/sh
# Test script for MCP tools in Docker (full-featured image)
set -e
COMPOSE_FILE="docker/docker-compose.full.yml"
SERVICE="picoclaw-agent"
echo "🧪 Testing MCP tools in Docker container (full-featured image)..."
echo ""
# Build the image
echo "📦 Building Docker image..."
docker compose -f "$COMPOSE_FILE" build "$SERVICE"
# Test npx
echo "✅ Testing npx..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx --version'
# Test npm
echo "✅ Testing npm..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npm --version'
# Test node
echo "✅ Testing Node.js..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'node --version'
# Test git
echo "✅ Testing git..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'git --version'
# Test python
echo "✅ Testing Python..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'python3 --version'
# Test uv
echo "✅ Testing uv..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'uv --version'
# Test MCP server installation (quick)
echo "✅ Testing @modelcontextprotocol/server-filesystem MCP server install with npx..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c '</dev/null timeout 5 npx -y @modelcontextprotocol/server-filesystem /tmp || true'
echo ""
echo "🎉 All MCP tools are working correctly!"
echo ""
echo "Next steps:"
echo " 1. Configure MCP servers in config/config.json"
echo " 2. Run: docker compose -f $COMPOSE_FILE --profile gateway up"