Merge branch 'sipeed:main' into main
This commit is contained in:
commit
cb9107d591
33 changed files with 2119 additions and 971 deletions
|
|
@ -7,7 +7,6 @@ linters:
|
||||||
- containedctx
|
- containedctx
|
||||||
- cyclop
|
- cyclop
|
||||||
- depguard
|
- depguard
|
||||||
- dupl
|
|
||||||
- dupword
|
- dupword
|
||||||
- err113
|
- err113
|
||||||
- exhaustruct
|
- exhaustruct
|
||||||
|
|
|
||||||
|
|
@ -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 ||
|
||||||
|
|
|
||||||
|
|
@ -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 ||
|
||||||
|
|
|
||||||
44
README.fr.md
44
README.fr.md
|
|
@ -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
|
||||||
|
|
|
||||||
44
README.ja.md
44
README.ja.md
|
|
@ -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>
|
||||||
|
|
||||||
## ⚙️ 設定
|
## ⚙️ 設定
|
||||||
|
|
|
||||||
44
README.md
44
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
44
README.vi.md
44
README.vi.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,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 +138,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 +149,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": {
|
||||||
|
|
|
||||||
116
docs/channels/wecom/wecom_aibot/README.zh.md
Normal file
116
docs/channels/wecom/wecom_aibot/README.zh.md
Normal 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)
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
1014
pkg/channels/wecom/aibot.go
Normal file
1014
pkg/channels/wecom/aibot.go
Normal file
File diff suppressed because it is too large
Load diff
210
pkg/channels/wecom/aibot_test.go
Normal file
210
pkg/channels/wecom/aibot_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -342,18 +342,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 +393,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 != "" {
|
||||||
|
|
@ -722,63 +726,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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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+"×tamp="+timestamp+"&nonce="+nonce,
|
"/webhook/wecom?msg_signature="+signature+"×tamp="+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+"×tamp="+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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
// Parse message structure
|
|
||||||
// Format: random(16) + msg_len(4) + msg + receiveid
|
|
||||||
if len(plainText) < 20 {
|
|
||||||
return "", fmt.Errorf("decrypted message too short")
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
msgLen := binary.BigEndian.Uint32(plainText[16:20])
|
// unpackWeComFrame parses the WeCom wire format produced by packWeComFrame.
|
||||||
if int(msgLen) > len(plainText)-20 {
|
// If receiveid is non-empty it verifies the frame's trailing receiveid field.
|
||||||
return "", fmt.Errorf("invalid message length")
|
func unpackWeComFrame(data []byte, receiveid string) (string, error) {
|
||||||
|
if len(data) < 20 {
|
||||||
|
return "", fmt.Errorf("decrypted frame too short: %d bytes", len(data))
|
||||||
}
|
}
|
||||||
|
msgLen := binary.BigEndian.Uint32(data[16:20])
|
||||||
msg := plainText[20 : 20+msgLen]
|
if int(msgLen) > len(data)-20 {
|
||||||
|
return "", fmt.Errorf("invalid message length: %d", msgLen)
|
||||||
// Verify receiveid if provided
|
}
|
||||||
if receiveid != "" && len(plainText) > 20+int(msgLen) {
|
msg := data[20 : 20+msgLen]
|
||||||
actualReceiveID := string(plainText[20+msgLen:])
|
if receiveid != "" && len(data) > 20+int(msgLen) {
|
||||||
|
actualReceiveID := string(data[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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,6 +361,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"`
|
||||||
|
|
@ -638,7 +651,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -748,25 +762,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.
|
||||||
|
|
|
||||||
|
|
@ -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: "",
|
||||||
|
|
|
||||||
|
|
@ -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")
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +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) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
force bool
|
||||||
|
wantActionType ActionType
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "backup when not forced",
|
||||||
|
force: false,
|
||||||
|
wantActionType: ActionBackup,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "copy when forced",
|
||||||
|
force: true,
|
||||||
|
wantActionType: ActionCopy,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
@ -140,42 +159,14 @@ func TestPlanWorkspaceMigrationWithExistingDestination(t *testing.T) {
|
||||||
dstWorkspace,
|
dstWorkspace,
|
||||||
[]string{"file1.txt"},
|
[]string{"file1.txt"},
|
||||||
[]string{},
|
[]string{},
|
||||||
false,
|
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, ActionBackup, actions[0].Type)
|
assert.Equal(t, tt.wantActionType, actions[0].Type)
|
||||||
}
|
})
|
||||||
|
}
|
||||||
func TestPlanWorkspaceMigrationForce(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
|
|
||||||
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
|
|
||||||
|
|
||||||
err := os.MkdirAll(srcWorkspace, 0o755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = os.MkdirAll(dstWorkspace, 0o755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
actions, err := PlanWorkspaceMigration(
|
|
||||||
srcWorkspace,
|
|
||||||
dstWorkspace,
|
|
||||||
[]string{"file1.txt"},
|
|
||||||
[]string{},
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.GreaterOrEqual(t, len(actions), 1)
|
|
||||||
assert.Equal(t, ActionCopy, actions[0].Type)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) {
|
func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,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"`
|
||||||
}
|
}
|
||||||
|
|
@ -313,6 +314,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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue