commit
1ef33c90ed
15 changed files with 3808 additions and 7 deletions
84
README.fr.md
84
README.fr.md
|
|
@ -262,7 +262,7 @@ Et voilà ! Vous avez un assistant IA fonctionnel en 2 minutes.
|
||||||
|
|
||||||
## 💬 Applications de Chat
|
## 💬 Applications de Chat
|
||||||
|
|
||||||
Discutez avec votre PicoClaw via Telegram, Discord, DingTalk ou LINE
|
Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
|
||||||
|
|
||||||
| Canal | Configuration |
|
| Canal | Configuration |
|
||||||
| ------------ | -------------------------------------- |
|
| ------------ | -------------------------------------- |
|
||||||
|
|
@ -271,6 +271,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk ou LINE
|
||||||
| **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) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommandé)</summary>
|
<summary><b>Telegram</b> (Recommandé)</summary>
|
||||||
|
|
@ -470,6 +471,87 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WeCom (WeChat Work)</b></summary>
|
||||||
|
|
||||||
|
PicoClaw prend en charge deux types d'intégration WeCom :
|
||||||
|
|
||||||
|
**Option 1 : WeCom Bot (Robot Intelligent)** - Configuration plus facile, prend en charge les discussions de groupe
|
||||||
|
**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive
|
||||||
|
|
||||||
|
Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour des instructions détaillées.
|
||||||
|
|
||||||
|
**Configuration Rapide - WeCom Bot :**
|
||||||
|
|
||||||
|
**1. Créer un bot**
|
||||||
|
|
||||||
|
* Accédez à la Console d'Administration WeCom → Discussion de Groupe → Ajouter un Bot de Groupe
|
||||||
|
* Copiez l'URL du webhook (format : `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||||
|
|
||||||
|
**2. Configurer**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18793,
|
||||||
|
"webhook_path": "/webhook/wecom",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration Rapide - WeCom App :**
|
||||||
|
|
||||||
|
**1. Créer une application**
|
||||||
|
|
||||||
|
* Accédez à la Console d'Administration WeCom → Gestion des Applications → Créer une Application
|
||||||
|
* Copiez l'**AgentId** et le **Secret**
|
||||||
|
* Accédez à la page "Mon Entreprise", copiez le **CorpID**
|
||||||
|
|
||||||
|
**2. Configurer la réception des messages**
|
||||||
|
|
||||||
|
* Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API"
|
||||||
|
* Définissez l'URL sur `http://your-server:18792/webhook/wecom-app`
|
||||||
|
* Générez le **Token** et l'**EncodingAESKey**
|
||||||
|
|
||||||
|
**3. Configurer**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_app": {
|
||||||
|
"enabled": true,
|
||||||
|
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||||
|
"corp_secret": "YOUR_CORP_SECRET",
|
||||||
|
"agent_id": 1000002,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18792,
|
||||||
|
"webhook_path": "/webhook/wecom-app",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Lancer**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note** : WeCom App nécessite l'ouverture du port 18792 pour les callbacks webhook. Utilisez un proxy inverse pour HTTPS en production.
|
||||||
|
|
||||||
|
</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
|
||||||
|
|
||||||
Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée.
|
Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée.
|
||||||
|
|
|
||||||
84
README.ja.md
84
README.ja.md
|
|
@ -226,7 +226,7 @@ picoclaw agent -m "What is 2+2?"
|
||||||
|
|
||||||
## 💬 チャットアプリ
|
## 💬 チャットアプリ
|
||||||
|
|
||||||
Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
|
Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話できます
|
||||||
|
|
||||||
| チャネル | セットアップ |
|
| チャネル | セットアップ |
|
||||||
|---------|------------|
|
|---------|------------|
|
||||||
|
|
@ -235,6 +235,7 @@ Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
|
||||||
| **QQ** | 簡単(AppID + AppSecret) |
|
| **QQ** | 簡単(AppID + AppSecret) |
|
||||||
| **DingTalk** | 普通(アプリ認証情報) |
|
| **DingTalk** | 普通(アプリ認証情報) |
|
||||||
| **LINE** | 普通(認証情報 + Webhook URL) |
|
| **LINE** | 普通(認証情報 + Webhook URL) |
|
||||||
|
| **WeCom** | 普通(CorpID + Webhook設定) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b>(推奨)</summary>
|
<summary><b>Telegram</b>(推奨)</summary>
|
||||||
|
|
@ -430,6 +431,87 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WeCom (企業微信)</b></summary>
|
||||||
|
|
||||||
|
PicoClaw は2種類の WeCom 統合をサポートしています:
|
||||||
|
|
||||||
|
**オプション1: WeCom Bot (智能ロボット)** - 簡単な設定、グループチャット対応
|
||||||
|
**オプション2: WeCom App (自作アプリ)** - より多機能、アクティブメッセージング対応
|
||||||
|
|
||||||
|
詳細な設定手順は [WeCom App Configuration Guide](docs/wecom-app-configuration.md) を参照してください。
|
||||||
|
|
||||||
|
**クイックセットアップ - WeCom Bot:**
|
||||||
|
|
||||||
|
**1. ボットを作成**
|
||||||
|
|
||||||
|
* WeCom 管理コンソール → グループチャット → グループボットを追加
|
||||||
|
* Webhook URL をコピー(形式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||||
|
|
||||||
|
**2. 設定**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18793,
|
||||||
|
"webhook_path": "/webhook/wecom",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**クイックセットアップ - WeCom App:**
|
||||||
|
|
||||||
|
**1. アプリを作成**
|
||||||
|
|
||||||
|
* WeCom 管理コンソール → アプリ管理 → アプリを作成
|
||||||
|
* **AgentId** と **Secret** をコピー
|
||||||
|
* "マイ会社" ページで **CorpID** をコピー
|
||||||
|
|
||||||
|
**2. メッセージ受信を設定**
|
||||||
|
|
||||||
|
* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック
|
||||||
|
* URL を `http://your-server:18792/webhook/wecom-app` に設定
|
||||||
|
* **Token** と **EncodingAESKey** を生成
|
||||||
|
|
||||||
|
**3. 設定**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_app": {
|
||||||
|
"enabled": true,
|
||||||
|
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||||
|
"corp_secret": "YOUR_CORP_SECRET",
|
||||||
|
"agent_id": 1000002,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18792,
|
||||||
|
"webhook_path": "/webhook/wecom-app",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 起動**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**: WeCom App は Webhook コールバック用にポート 18792 を開放する必要があります。本番環境では HTTPS 用のリバースプロキシを使用してください。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## ⚙️ 設定
|
## ⚙️ 設定
|
||||||
|
|
||||||
設定ファイル: `~/.picoclaw/config.json`
|
設定ファイル: `~/.picoclaw/config.json`
|
||||||
|
|
|
||||||
84
README.md
84
README.md
|
|
@ -264,7 +264,7 @@ That's it! You have a working AI assistant in 2 minutes.
|
||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
|
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
| ------------ | ---------------------------------- |
|
| ------------ | ---------------------------------- |
|
||||||
|
|
@ -273,6 +273,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
|
||||||
| **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) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<summary><b>Telegram</b> (Recommended)</summary>
|
||||||
|
|
@ -477,6 +478,87 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WeCom (企业微信)</b></summary>
|
||||||
|
|
||||||
|
PicoClaw supports two types of WeCom integration:
|
||||||
|
|
||||||
|
**Option 1: WeCom Bot (智能机器人)** - Easier setup, supports group chats
|
||||||
|
**Option 2: WeCom App (自建应用)** - More features, proactive messaging
|
||||||
|
|
||||||
|
See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions.
|
||||||
|
|
||||||
|
**Quick Setup - WeCom Bot:**
|
||||||
|
|
||||||
|
**1. Create a bot**
|
||||||
|
|
||||||
|
* Go to WeCom Admin Console → Group Chat → Add Group Bot
|
||||||
|
* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18793,
|
||||||
|
"webhook_path": "/webhook/wecom",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Quick Setup - WeCom App:**
|
||||||
|
|
||||||
|
**1. Create an app**
|
||||||
|
|
||||||
|
* Go to WeCom Admin Console → App Management → Create App
|
||||||
|
* Copy **AgentId** and **Secret**
|
||||||
|
* Go to "My Company" page, copy **CorpID**
|
||||||
|
|
||||||
|
**2. Configure receive message**
|
||||||
|
|
||||||
|
* In App details, click "Receive Message" → "Set API"
|
||||||
|
* Set URL to `http://your-server:18792/webhook/wecom-app`
|
||||||
|
* Generate **Token** and **EncodingAESKey**
|
||||||
|
|
||||||
|
**3. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_app": {
|
||||||
|
"enabled": true,
|
||||||
|
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||||
|
"corp_secret": "YOUR_CORP_SECRET",
|
||||||
|
"agent_id": 1000002,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18792,
|
||||||
|
"webhook_path": "/webhook/wecom-app",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: WeCom App requires opening port 18792 for webhook callbacks. Use a reverse proxy for HTTPS.
|
||||||
|
|
||||||
|
</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
|
||||||
|
|
||||||
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
|
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,7 @@ Pronto! Você tem um assistente de IA funcionando em 2 minutos.
|
||||||
|
|
||||||
## 💬 Integração com Apps de Chat
|
## 💬 Integração com Apps de Chat
|
||||||
|
|
||||||
Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE.
|
Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
|
||||||
|
|
||||||
| Canal | Nível de Configuração |
|
| Canal | Nível de Configuração |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|
@ -272,6 +272,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE.
|
||||||
| **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) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recomendado)</summary>
|
<summary><b>Telegram</b> (Recomendado)</summary>
|
||||||
|
|
@ -471,6 +472,87 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WeCom (WeChat Work)</b></summary>
|
||||||
|
|
||||||
|
O PicoClaw suporta dois tipos de integração WeCom:
|
||||||
|
|
||||||
|
**Opção 1: WeCom Bot (Robô Inteligente)** - Configuração mais fácil, suporta chats em grupo
|
||||||
|
**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas
|
||||||
|
|
||||||
|
Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para instruções detalhadas.
|
||||||
|
|
||||||
|
**Configuração Rápida - WeCom Bot:**
|
||||||
|
|
||||||
|
**1. Criar um bot**
|
||||||
|
|
||||||
|
* Acesse o Console de Administração WeCom → Chat em Grupo → Adicionar Bot de Grupo
|
||||||
|
* Copie a URL do webhook (formato: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||||
|
|
||||||
|
**2. Configurar**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18793,
|
||||||
|
"webhook_path": "/webhook/wecom",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuração Rápida - WeCom App:**
|
||||||
|
|
||||||
|
**1. Criar um aplicativo**
|
||||||
|
|
||||||
|
* Acesse o Console de Administração WeCom → Gerenciamento de Aplicativos → Criar Aplicativo
|
||||||
|
* Copie o **AgentId** e o **Secret**
|
||||||
|
* Acesse a página "Minha Empresa", copie o **CorpID**
|
||||||
|
|
||||||
|
**2. Configurar recebimento de mensagens**
|
||||||
|
|
||||||
|
* Nos detalhes do aplicativo, clique em "Receber Mensagens" → "Configurar API"
|
||||||
|
* Defina a URL como `http://your-server:18792/webhook/wecom-app`
|
||||||
|
* Gere o **Token** e o **EncodingAESKey**
|
||||||
|
|
||||||
|
**3. Configurar**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_app": {
|
||||||
|
"enabled": true,
|
||||||
|
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||||
|
"corp_secret": "YOUR_CORP_SECRET",
|
||||||
|
"agent_id": 1000002,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18792,
|
||||||
|
"webhook_path": "/webhook/wecom-app",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Executar**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Nota**: O WeCom App requer a abertura da porta 18792 para callbacks de webhook. Use um proxy reverso para HTTPS em produção.
|
||||||
|
|
||||||
|
</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
|
||||||
|
|
||||||
Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
|
Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
|
||||||
|
|
|
||||||
84
README.vi.md
84
README.vi.md
|
|
@ -243,7 +243,7 @@ Vậy là xong! Bạn đã có một trợ lý AI hoạt động chỉ trong 2 p
|
||||||
|
|
||||||
## 💬 Tích hợp ứng dụng Chat
|
## 💬 Tích hợp ứng dụng Chat
|
||||||
|
|
||||||
Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk hoặc LINE.
|
Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
|
||||||
|
|
||||||
| Kênh | Mức độ thiết lập |
|
| Kênh | Mức độ thiết lập |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|
@ -252,6 +252,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk hoặc LINE.
|
||||||
| **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) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Khuyên dùng)</summary>
|
<summary><b>Telegram</b> (Khuyên dùng)</summary>
|
||||||
|
|
@ -451,6 +452,87 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WeCom (WeChat Work)</b></summary>
|
||||||
|
|
||||||
|
PicoClaw hỗ trợ hai 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 2: WeCom App (Ứng dụng Tự xây dựng)** - Nhiều tính năng hơn, nhắn tin chủ động
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Thiết lập Nhanh - WeCom Bot:**
|
||||||
|
|
||||||
|
**1. Tạo bot**
|
||||||
|
|
||||||
|
* Truy cập Bảng điều khiển Quản trị WeCom → Chat Nhóm → Thêm Bot Nhóm
|
||||||
|
* Sao chép URL webhook (định dạng: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||||
|
|
||||||
|
**2. Cấu hình**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18793,
|
||||||
|
"webhook_path": "/webhook/wecom",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Thiết lập Nhanh - WeCom App:**
|
||||||
|
|
||||||
|
**1. Tạo ứng dụng**
|
||||||
|
|
||||||
|
* Truy cập Bảng điều khiển Quản trị WeCom → Quản lý Ứng dụng → Tạo Ứng dụng
|
||||||
|
* Sao chép **AgentId** và **Secret**
|
||||||
|
* Truy cập trang "Công ty của tôi", sao chép **CorpID**
|
||||||
|
|
||||||
|
**2. Cấu hình nhận tin nhắn**
|
||||||
|
|
||||||
|
* Trong chi tiết ứng dụng, nhấp vào "Nhận Tin nhắn" → "Thiết lập API"
|
||||||
|
* Đặt URL thành `http://your-server:18792/webhook/wecom-app`
|
||||||
|
* Tạo **Token** và **EncodingAESKey**
|
||||||
|
|
||||||
|
**3. Cấu hình**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_app": {
|
||||||
|
"enabled": true,
|
||||||
|
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||||
|
"corp_secret": "YOUR_CORP_SECRET",
|
||||||
|
"agent_id": 1000002,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18792,
|
||||||
|
"webhook_path": "/webhook/wecom-app",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Chạy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Lưu ý**: WeCom App yêu cầu mở cổng 18792 cho callback webhook. Sử dụng proxy ngược cho HTTPS trong môi trường sản xuất.
|
||||||
|
|
||||||
|
</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
|
||||||
|
|
||||||
Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp.
|
Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp.
|
||||||
|
|
|
||||||
87
README.zh.md
87
README.zh.md
|
|
@ -273,14 +273,15 @@ picoclaw agent -m "2+2 等于几?"
|
||||||
|
|
||||||
## 💬 聊天应用集成 (Chat Apps)
|
## 💬 聊天应用集成 (Chat Apps)
|
||||||
|
|
||||||
通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。
|
通过 Telegram, Discord, 钉钉或企业微信与您的 PicoClaw 对话。
|
||||||
|
|
||||||
| 渠道 | 设置难度 |
|
| 渠道 | 设置难度 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Telegram** | 简单 (仅需 token) |
|
| **Telegram** | 简单 (仅需 token) |
|
||||||
| **Discord** | 简单 (bot token + intents) |
|
| **Discord** | 简单 (bot token + intents) |
|
||||||
| **QQ** | 简单 (AppID + AppSecret) |
|
| **QQ** | 简单 (AppID + AppSecret) |
|
||||||
| **钉钉 (DingTalk)** | 中等 (app credentials) |
|
| **钉钉 (DingTalk)** | 中等 (应用凭证) |
|
||||||
|
| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (推荐)</summary>
|
<summary><b>Telegram</b> (推荐)</summary>
|
||||||
|
|
@ -438,6 +439,88 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>企业微信 (WeCom)</b></summary>
|
||||||
|
|
||||||
|
PicoClaw 支持两种企业微信集成方式:
|
||||||
|
|
||||||
|
**选项1: 智能机器人 (WeCom Bot)** - 设置更简单,支持群聊
|
||||||
|
**选项2: 自建应用 (WeCom App)** - 功能更丰富,支持主动推送消息
|
||||||
|
|
||||||
|
详见 [企业微信自建应用配置指南](docs/wecom-app-configuration.md)。
|
||||||
|
|
||||||
|
**快速设置 - 智能机器人:**
|
||||||
|
|
||||||
|
**1. 创建机器人**
|
||||||
|
|
||||||
|
* 前往企业微信管理后台 → 群聊 → 添加群机器人
|
||||||
|
* 复制 Webhook URL (格式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||||
|
|
||||||
|
**2. 配置**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18793,
|
||||||
|
"webhook_path": "/webhook/wecom",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**快速设置 - 自建应用:**
|
||||||
|
|
||||||
|
**1. 创建应用**
|
||||||
|
|
||||||
|
* 前往企业微信管理后台 → 应用管理 → 创建应用
|
||||||
|
* 复制 **AgentId** 和 **Secret**
|
||||||
|
* 前往"我的企业"页面,复制 **CorpID**
|
||||||
|
|
||||||
|
**2. 配置接收消息**
|
||||||
|
|
||||||
|
* 在应用详情页,点击"接收消息" → "设置API"
|
||||||
|
* 设置 URL 为 `http://your-server:18792/webhook/wecom-app`
|
||||||
|
* 生成 **Token** 和 **EncodingAESKey**
|
||||||
|
|
||||||
|
**3. 配置**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wecom_app": {
|
||||||
|
"enabled": true,
|
||||||
|
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||||
|
"corp_secret": "YOUR_CORP_SECRET",
|
||||||
|
"agent_id": 1000002,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18792,
|
||||||
|
"webhook_path": "/webhook/wecom-app",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**: 自建应用需要开放 18792 端口用于接收 Webhook 回调。生产环境建议使用反向代理配置 HTTPS。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,32 @@
|
||||||
"reconnect_interval": 5,
|
"reconnect_interval": 5,
|
||||||
"group_trigger_prefix": [],
|
"group_trigger_prefix": [],
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
},
|
||||||
|
"wecom": {
|
||||||
|
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
||||||
|
"enabled": false,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18793,
|
||||||
|
"webhook_path": "/webhook/wecom",
|
||||||
|
"allow_from": [],
|
||||||
|
"reply_timeout": 5
|
||||||
|
},
|
||||||
|
"wecom_app": {
|
||||||
|
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
|
||||||
|
"enabled": false,
|
||||||
|
"corp_id": "YOUR_CORP_ID",
|
||||||
|
"corp_secret": "YOUR_CORP_SECRET",
|
||||||
|
"agent_id": 1000002,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18792,
|
||||||
|
"webhook_path": "/webhook/wecom-app",
|
||||||
|
"allow_from": [],
|
||||||
|
"reply_timeout": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
117
docs/wecom-app-configuration.md
Normal file
117
docs/wecom-app-configuration.md
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
# 企业微信自建应用 (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:18792/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_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18792,
|
||||||
|
"webhook_path": "/webhook/wecom-app",
|
||||||
|
"allow_from": [],
|
||||||
|
"reply_timeout": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. 回调URL验证失败
|
||||||
|
|
||||||
|
**症状**: 企业微信保存API接收消息时提示验证失败
|
||||||
|
|
||||||
|
**检查项**:
|
||||||
|
- 确认服务器防火墙已开放 18792 端口
|
||||||
|
- 确认 `corp_id`、`token`、`encoding_aes_key` 配置正确
|
||||||
|
- 查看 PicoClaw 日志是否有请求到达
|
||||||
|
|
||||||
|
### 2. 中文消息解密失败
|
||||||
|
|
||||||
|
**症状**: 发送中文消息时出现 `invalid padding size` 错误
|
||||||
|
|
||||||
|
**原因**: 企业微信使用非标准的 PKCS7 填充(32字节块大小)
|
||||||
|
|
||||||
|
**解决**: 确保使用最新版本的 PicoClaw,已修复此问题。
|
||||||
|
|
||||||
|
### 3. 端口冲突
|
||||||
|
|
||||||
|
**症状**: 启动时提示端口已被占用
|
||||||
|
|
||||||
|
**解决**: 修改 `webhook_port` 为其他端口,如 18794
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
### 加密算法
|
||||||
|
|
||||||
|
- **算法**: 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)
|
||||||
|
|
@ -176,6 +176,32 @@ func (m *Manager) initChannels() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
|
||||||
|
logger.DebugC("channels", "Attempting to initialize WeCom channel")
|
||||||
|
wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.channels["wecom"] = wecom
|
||||||
|
logger.InfoC("channels", "WeCom channel enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
|
||||||
|
logger.DebugC("channels", "Attempting to initialize WeCom App channel")
|
||||||
|
wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.channels["wecom_app"] = wecomApp
|
||||||
|
logger.InfoC("channels", "WeCom App channel enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
604
pkg/channels/wecom.go
Normal file
604
pkg/channels/wecom.go
Normal file
|
|
@ -0,0 +1,604 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// WeCom Bot (企业微信智能机器人) channel implementation
|
||||||
|
// Uses webhook callback mode for receiving messages and webhook API for sending replies
|
||||||
|
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人)
|
||||||
|
// Uses webhook callback mode - simpler than WeCom App but only supports passive replies
|
||||||
|
type WeComBotChannel struct {
|
||||||
|
*BaseChannel
|
||||||
|
config config.WeComConfig
|
||||||
|
server *http.Server
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
||||||
|
msgMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
|
||||||
|
type WeComBotMessage struct {
|
||||||
|
MsgID string `json:"msgid"`
|
||||||
|
AIBotID string `json:"aibotid"`
|
||||||
|
ChatID string `json:"chatid"` // Session ID, only present for group chats
|
||||||
|
ChatType string `json:"chattype"` // "single" for DM, "group" for group chat
|
||||||
|
From struct {
|
||||||
|
UserID string `json:"userid"`
|
||||||
|
} `json:"from"`
|
||||||
|
ResponseURL string `json:"response_url"`
|
||||||
|
MsgType string `json:"msgtype"` // text, image, voice, file, mixed
|
||||||
|
Text struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"text"`
|
||||||
|
Image struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"image"`
|
||||||
|
Voice struct {
|
||||||
|
Content string `json:"content"` // Voice to text content
|
||||||
|
} `json:"voice"`
|
||||||
|
File struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"file"`
|
||||||
|
Mixed struct {
|
||||||
|
MsgItem []struct {
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
Text struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"text"`
|
||||||
|
Image struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"image"`
|
||||||
|
} `json:"msg_item"`
|
||||||
|
} `json:"mixed"`
|
||||||
|
Quote struct {
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
Text struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"text"`
|
||||||
|
} `json:"quote"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComBotReplyMessage represents the reply message structure
|
||||||
|
type WeComBotReplyMessage struct {
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
Text struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWeComBotChannel creates a new WeCom Bot channel instance
|
||||||
|
func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) {
|
||||||
|
if cfg.Token == "" || cfg.WebhookURL == "" {
|
||||||
|
return nil, fmt.Errorf("wecom token and webhook_url are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
|
return &WeComBotChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
processedMsgs: make(map[string]bool),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the channel name
|
||||||
|
func (c *WeComBotChannel) Name() string {
|
||||||
|
return "wecom"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initializes the WeCom Bot channel with HTTP webhook server
|
||||||
|
func (c *WeComBotChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoC("wecom", "Starting WeCom Bot channel...")
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
// Setup HTTP server for webhook
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
webhookPath := c.config.WebhookPath
|
||||||
|
if webhookPath == "" {
|
||||||
|
webhookPath = "/webhook/wecom"
|
||||||
|
}
|
||||||
|
mux.HandleFunc(webhookPath, c.handleWebhook)
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
mux.HandleFunc("/health/wecom", c.handleHealth)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
|
||||||
|
c.server = &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(true)
|
||||||
|
logger.InfoCF("wecom", "WeCom Bot channel started", map[string]interface{}{
|
||||||
|
"address": addr,
|
||||||
|
"path": webhookPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start server in goroutine
|
||||||
|
go func() {
|
||||||
|
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.ErrorCF("wecom", "HTTP server error", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully stops the WeCom Bot channel
|
||||||
|
func (c *WeComBotChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("wecom", "Stopping WeCom Bot channel...")
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.server != nil {
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
c.server.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(false)
|
||||||
|
logger.InfoC("wecom", "WeCom Bot channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a message to WeCom user via webhook API
|
||||||
|
// Note: WeCom Bot can only reply within the configured timeout (default 5 seconds) of receiving a message
|
||||||
|
// For delayed responses, we use the webhook URL
|
||||||
|
func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return fmt.Errorf("wecom channel not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("wecom", "Sending message via webhook", map[string]interface{}{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"preview": utils.Truncate(msg.Content, 100),
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.sendWebhookReply(ctx, msg.ChatID, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebhook handles incoming webhook requests from WeCom
|
||||||
|
func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
// Handle verification request
|
||||||
|
c.handleVerification(ctx, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
// Handle message callback
|
||||||
|
c.handleMessageCallback(ctx, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVerification handles the URL verification request from WeCom
|
||||||
|
func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
msgSignature := query.Get("msg_signature")
|
||||||
|
timestamp := query.Get("timestamp")
|
||||||
|
nonce := query.Get("nonce")
|
||||||
|
echostr := query.Get("echostr")
|
||||||
|
|
||||||
|
if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" {
|
||||||
|
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
||||||
|
logger.WarnC("wecom", "Signature verification failed")
|
||||||
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt echostr
|
||||||
|
// For AIBOT (智能机器人), receiveid should be empty string ""
|
||||||
|
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||||
|
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove BOM and whitespace as per WeCom documentation
|
||||||
|
// The response must be plain text without quotes, BOM, or newlines
|
||||||
|
decryptedEchoStr = strings.TrimSpace(decryptedEchoStr)
|
||||||
|
decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM
|
||||||
|
w.Write([]byte(decryptedEchoStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessageCallback handles incoming messages from WeCom
|
||||||
|
func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
msgSignature := query.Get("msg_signature")
|
||||||
|
timestamp := query.Get("timestamp")
|
||||||
|
nonce := query.Get("nonce")
|
||||||
|
|
||||||
|
if msgSignature == "" || timestamp == "" || nonce == "" {
|
||||||
|
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Parse XML to get encrypted message
|
||||||
|
var encryptedMsg struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
ToUserName string `xml:"ToUserName"`
|
||||||
|
Encrypt string `xml:"Encrypt"`
|
||||||
|
AgentID string `xml:"AgentID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||||
|
logger.ErrorCF("wecom", "Failed to parse XML", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||||
|
logger.WarnC("wecom", "Message signature verification failed")
|
||||||
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt message
|
||||||
|
// For AIBOT (智能机器人), receiveid should be empty string ""
|
||||||
|
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||||
|
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse decrypted JSON message (AIBOT uses JSON format)
|
||||||
|
var msg WeComBotMessage
|
||||||
|
if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
||||||
|
logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the message asynchronously with context
|
||||||
|
go c.processMessage(ctx, msg)
|
||||||
|
|
||||||
|
// Return success response immediately
|
||||||
|
// WeCom Bot requires response within configured timeout (default 5 seconds)
|
||||||
|
w.Write([]byte("success"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// processMessage processes the received message
|
||||||
|
func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) {
|
||||||
|
// Skip unsupported message types
|
||||||
|
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && msg.MsgType != "mixed" {
|
||||||
|
logger.DebugCF("wecom", "Skipping non-supported message type", map[string]interface{}{
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message deduplication: Use msg_id to prevent duplicate processing
|
||||||
|
msgID := msg.MsgID
|
||||||
|
c.msgMu.Lock()
|
||||||
|
if c.processedMsgs[msgID] {
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
logger.DebugCF("wecom", "Skipping duplicate message", map[string]interface{}{
|
||||||
|
"msg_id": msgID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.processedMsgs[msgID] = true
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
|
||||||
|
// Clean up old messages periodically (keep last 1000)
|
||||||
|
if len(c.processedMsgs) > 1000 {
|
||||||
|
c.msgMu.Lock()
|
||||||
|
c.processedMsgs = make(map[string]bool)
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
senderID := msg.From.UserID
|
||||||
|
|
||||||
|
// Determine if this is a group chat or direct message
|
||||||
|
// ChatType: "single" for DM, "group" for group chat
|
||||||
|
isGroupChat := msg.ChatType == "group"
|
||||||
|
|
||||||
|
var chatID, peerKind, peerID string
|
||||||
|
if isGroupChat {
|
||||||
|
// Group chat: use ChatID as chatID and peer_id
|
||||||
|
chatID = msg.ChatID
|
||||||
|
peerKind = "group"
|
||||||
|
peerID = msg.ChatID
|
||||||
|
} else {
|
||||||
|
// Direct message: use senderID as chatID and peer_id
|
||||||
|
chatID = senderID
|
||||||
|
peerKind = "direct"
|
||||||
|
peerID = senderID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract content based on message type
|
||||||
|
var content string
|
||||||
|
switch msg.MsgType {
|
||||||
|
case "text":
|
||||||
|
content = msg.Text.Content
|
||||||
|
case "voice":
|
||||||
|
content = msg.Voice.Content // Voice to text content
|
||||||
|
case "mixed":
|
||||||
|
// For mixed messages, concatenate text items
|
||||||
|
for _, item := range msg.Mixed.MsgItem {
|
||||||
|
if item.MsgType == "text" {
|
||||||
|
content += item.Text.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "image", "file":
|
||||||
|
// For image and file, we don't have text content
|
||||||
|
content = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build metadata
|
||||||
|
metadata := map[string]string{
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
"msg_id": msg.MsgID,
|
||||||
|
"platform": "wecom",
|
||||||
|
"peer_kind": peerKind,
|
||||||
|
"peer_id": peerID,
|
||||||
|
"response_url": msg.ResponseURL,
|
||||||
|
}
|
||||||
|
if isGroupChat {
|
||||||
|
metadata["chat_id"] = msg.ChatID
|
||||||
|
metadata["sender_id"] = senderID
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("wecom", "Received message", map[string]interface{}{
|
||||||
|
"sender_id": senderID,
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
"peer_kind": peerKind,
|
||||||
|
"is_group_chat": isGroupChat,
|
||||||
|
"preview": utils.Truncate(content, 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle the message through the base channel
|
||||||
|
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWebhookReply sends a reply using the webhook URL
|
||||||
|
func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error {
|
||||||
|
reply := WeComBotReplyMessage{
|
||||||
|
MsgType: "text",
|
||||||
|
}
|
||||||
|
reply.Text.Content = content
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(reply)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal reply: %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, c.config.WebhookURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send webhook reply: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
var result struct {
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ErrCode != 0 {
|
||||||
|
return fmt.Errorf("webhook API error: %s (code: %d)", result.ErrMsg, result.ErrCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHealth handles health check requests
|
||||||
|
func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"running": c.IsRunning(),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeCom common utilities for both WeCom Bot and WeCom App
|
||||||
|
// The following functions were moved from wecom_common.go
|
||||||
|
|
||||||
|
// WeComVerifySignature verifies the message signature for WeCom
|
||||||
|
// This is a common function used by both WeCom Bot and WeCom App
|
||||||
|
func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
|
||||||
|
if token == "" {
|
||||||
|
return true // Skip verification if token is not set
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComDecryptMessage decrypts the encrypted message using AES
|
||||||
|
// This is a common function used by both WeCom Bot and WeCom App
|
||||||
|
// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
|
||||||
|
func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
|
||||||
|
return WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComDecryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
|
||||||
|
// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
|
||||||
|
func WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
|
||||||
|
if encodingAESKey == "" {
|
||||||
|
// No encryption, return as is (base64 decode)
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(decoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode AES key (base64)
|
||||||
|
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode AES key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode encrypted message
|
||||||
|
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES decrypt
|
||||||
|
block, err := aes.NewCipher(aesKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", 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]
|
||||||
|
mode := cipher.NewCBCDecrypter(block, iv)
|
||||||
|
plainText := make([]byte, len(cipherText))
|
||||||
|
mode.CryptBlocks(plainText, cipherText)
|
||||||
|
|
||||||
|
// Remove PKCS7 padding
|
||||||
|
plainText, err = pkcs7UnpadWeCom(plainText)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to unpad: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse message structure
|
||||||
|
// Format: random(16) + msg_len(4) + msg + receiveid
|
||||||
|
if len(plainText) < 20 {
|
||||||
|
return "", fmt.Errorf("decrypted message too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
msgLen := binary.BigEndian.Uint32(plainText[16:20])
|
||||||
|
if int(msgLen) > len(plainText)-20 {
|
||||||
|
return "", fmt.Errorf("invalid message length")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := plainText[20 : 20+msgLen]
|
||||||
|
|
||||||
|
// Verify receiveid if provided
|
||||||
|
if receiveid != "" && len(plainText) > 20+int(msgLen) {
|
||||||
|
actualReceiveID := string(plainText[20+msgLen:])
|
||||||
|
if actualReceiveID != receiveid {
|
||||||
|
return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pkcs7UnpadWeCom removes PKCS7 padding with validation
|
||||||
|
// WeCom uses block size of 32 (not standard AES block size of 16)
|
||||||
|
const wecomBlockSize = 32
|
||||||
|
|
||||||
|
func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
padding := int(data[len(data)-1])
|
||||||
|
// WeCom uses 32-byte block size for PKCS7 padding
|
||||||
|
if padding == 0 || padding > wecomBlockSize {
|
||||||
|
return nil, fmt.Errorf("invalid padding size: %d", padding)
|
||||||
|
}
|
||||||
|
if padding > len(data) {
|
||||||
|
return nil, fmt.Errorf("padding size larger than data")
|
||||||
|
}
|
||||||
|
// Verify all padding bytes
|
||||||
|
for i := 0; i < padding; i++ {
|
||||||
|
if data[len(data)-1-i] != byte(padding) {
|
||||||
|
return nil, fmt.Errorf("invalid padding byte at position %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data[:len(data)-padding], nil
|
||||||
|
}
|
||||||
639
pkg/channels/wecom_app.go
Normal file
639
pkg/channels/wecom_app.go
Normal file
|
|
@ -0,0 +1,639 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// WeCom App (企业微信自建应用) channel implementation
|
||||||
|
// Supports receiving messages via webhook callback and sending messages proactively
|
||||||
|
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wecomAPIBase = "https://qyapi.weixin.qq.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)
|
||||||
|
type WeComAppChannel struct {
|
||||||
|
*BaseChannel
|
||||||
|
config config.WeComAppConfig
|
||||||
|
server *http.Server
|
||||||
|
accessToken string
|
||||||
|
tokenExpiry time.Time
|
||||||
|
tokenMu sync.RWMutex
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
||||||
|
msgMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComXMLMessage represents the XML message structure from WeCom
|
||||||
|
type WeComXMLMessage struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
ToUserName string `xml:"ToUserName"`
|
||||||
|
FromUserName string `xml:"FromUserName"`
|
||||||
|
CreateTime int64 `xml:"CreateTime"`
|
||||||
|
MsgType string `xml:"MsgType"`
|
||||||
|
Content string `xml:"Content"`
|
||||||
|
MsgId int64 `xml:"MsgId"`
|
||||||
|
AgentID int64 `xml:"AgentID"`
|
||||||
|
PicUrl string `xml:"PicUrl"`
|
||||||
|
MediaId string `xml:"MediaId"`
|
||||||
|
Format string `xml:"Format"`
|
||||||
|
ThumbMediaId string `xml:"ThumbMediaId"`
|
||||||
|
LocationX float64 `xml:"Location_X"`
|
||||||
|
LocationY float64 `xml:"Location_Y"`
|
||||||
|
Scale int `xml:"Scale"`
|
||||||
|
Label string `xml:"Label"`
|
||||||
|
Title string `xml:"Title"`
|
||||||
|
Description string `xml:"Description"`
|
||||||
|
Url string `xml:"Url"`
|
||||||
|
Event string `xml:"Event"`
|
||||||
|
EventKey string `xml:"EventKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComTextMessage represents text message for sending
|
||||||
|
type WeComTextMessage struct {
|
||||||
|
ToUser string `json:"touser"`
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
AgentID int64 `json:"agentid"`
|
||||||
|
Text struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"text"`
|
||||||
|
Safe int `json:"safe,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComMarkdownMessage represents markdown message for sending
|
||||||
|
type WeComMarkdownMessage struct {
|
||||||
|
ToUser string `json:"touser"`
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
AgentID int64 `json:"agentid"`
|
||||||
|
Markdown struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"markdown"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComImageMessage represents image message for sending
|
||||||
|
type WeComImageMessage struct {
|
||||||
|
ToUser string `json:"touser"`
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
AgentID int64 `json:"agentid"`
|
||||||
|
Image struct {
|
||||||
|
MediaID string `json:"media_id"`
|
||||||
|
} `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComAccessTokenResponse represents the access token API response
|
||||||
|
type WeComAccessTokenResponse struct {
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComSendMessageResponse represents the send message API response
|
||||||
|
type WeComSendMessageResponse struct {
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
InvalidUser string `json:"invaliduser"`
|
||||||
|
InvalidParty string `json:"invalidparty"`
|
||||||
|
InvalidTag string `json:"invalidtag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PKCS7Padding adds PKCS7 padding
|
||||||
|
type PKCS7Padding struct{}
|
||||||
|
|
||||||
|
// NewWeComAppChannel creates a new WeCom App channel instance
|
||||||
|
func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) {
|
||||||
|
if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 {
|
||||||
|
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
|
return &WeComAppChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
processedMsgs: make(map[string]bool),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the channel name
|
||||||
|
func (c *WeComAppChannel) Name() string {
|
||||||
|
return "wecom_app"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initializes the WeCom App channel with HTTP webhook server
|
||||||
|
func (c *WeComAppChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoC("wecom_app", "Starting WeCom App channel...")
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
// Get initial access token
|
||||||
|
if err := c.refreshAccessToken(); err != nil {
|
||||||
|
logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start token refresh goroutine
|
||||||
|
go c.tokenRefreshLoop()
|
||||||
|
|
||||||
|
// Setup HTTP server for webhook
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
webhookPath := c.config.WebhookPath
|
||||||
|
if webhookPath == "" {
|
||||||
|
webhookPath = "/webhook/wecom-app"
|
||||||
|
}
|
||||||
|
mux.HandleFunc(webhookPath, c.handleWebhook)
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
mux.HandleFunc("/health/wecom-app", c.handleHealth)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
|
||||||
|
c.server = &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(true)
|
||||||
|
logger.InfoCF("wecom_app", "WeCom App channel started", map[string]interface{}{
|
||||||
|
"address": addr,
|
||||||
|
"path": webhookPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start server in goroutine
|
||||||
|
go func() {
|
||||||
|
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.ErrorCF("wecom_app", "HTTP server error", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully stops the WeCom App channel
|
||||||
|
func (c *WeComAppChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("wecom_app", "Stopping WeCom App channel...")
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.server != nil {
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
c.server.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(false)
|
||||||
|
logger.InfoC("wecom_app", "WeCom App channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a message to WeCom user proactively using access token
|
||||||
|
func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return fmt.Errorf("wecom_app channel not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := c.getAccessToken()
|
||||||
|
if accessToken == "" {
|
||||||
|
return fmt.Errorf("no valid access token available")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("wecom_app", "Sending message", map[string]interface{}{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"preview": utils.Truncate(msg.Content, 100),
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebhook handles incoming webhook requests from WeCom
|
||||||
|
func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Log all incoming requests for debugging
|
||||||
|
logger.DebugCF("wecom_app", "Received webhook request", map[string]interface{}{
|
||||||
|
"method": r.Method,
|
||||||
|
"url": r.URL.String(),
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"query": r.URL.RawQuery,
|
||||||
|
})
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
// Handle verification request
|
||||||
|
c.handleVerification(ctx, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
// Handle message callback
|
||||||
|
c.handleMessageCallback(ctx, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.WarnCF("wecom_app", "Method not allowed", map[string]interface{}{
|
||||||
|
"method": r.Method,
|
||||||
|
})
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVerification handles the URL verification request from WeCom
|
||||||
|
func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
msgSignature := query.Get("msg_signature")
|
||||||
|
timestamp := query.Get("timestamp")
|
||||||
|
nonce := query.Get("nonce")
|
||||||
|
echostr := query.Get("echostr")
|
||||||
|
|
||||||
|
logger.DebugCF("wecom_app", "Handling verification request", map[string]interface{}{
|
||||||
|
"msg_signature": msgSignature,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"nonce": nonce,
|
||||||
|
"echostr": echostr,
|
||||||
|
"corp_id": c.config.CorpID,
|
||||||
|
})
|
||||||
|
|
||||||
|
if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" {
|
||||||
|
logger.ErrorC("wecom_app", "Missing parameters in verification request")
|
||||||
|
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
||||||
|
logger.WarnCF("wecom_app", "Signature verification failed", map[string]interface{}{
|
||||||
|
"token": c.config.Token,
|
||||||
|
"msg_signature": msgSignature,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"nonce": nonce,
|
||||||
|
})
|
||||||
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugC("wecom_app", "Signature verification passed")
|
||||||
|
|
||||||
|
// Decrypt echostr with CorpID verification
|
||||||
|
// For WeCom App (自建应用), receiveid should be corp_id
|
||||||
|
logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]interface{}{
|
||||||
|
"encoding_aes_key": c.config.EncodingAESKey,
|
||||||
|
"corp_id": c.config.CorpID,
|
||||||
|
})
|
||||||
|
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"encoding_aes_key": c.config.EncodingAESKey,
|
||||||
|
"corp_id": c.config.CorpID,
|
||||||
|
})
|
||||||
|
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]interface{}{
|
||||||
|
"decrypted": decryptedEchoStr,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove BOM and whitespace as per WeCom documentation
|
||||||
|
// The response must be plain text without quotes, BOM, or newlines
|
||||||
|
decryptedEchoStr = strings.TrimSpace(decryptedEchoStr)
|
||||||
|
decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM
|
||||||
|
w.Write([]byte(decryptedEchoStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessageCallback handles incoming messages from WeCom
|
||||||
|
func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
msgSignature := query.Get("msg_signature")
|
||||||
|
timestamp := query.Get("timestamp")
|
||||||
|
nonce := query.Get("nonce")
|
||||||
|
|
||||||
|
if msgSignature == "" || timestamp == "" || nonce == "" {
|
||||||
|
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Parse XML to get encrypted message
|
||||||
|
var encryptedMsg struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
ToUserName string `xml:"ToUserName"`
|
||||||
|
Encrypt string `xml:"Encrypt"`
|
||||||
|
AgentID string `xml:"AgentID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||||
|
logger.WarnC("wecom_app", "Message signature verification failed")
|
||||||
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt message with CorpID verification
|
||||||
|
// For WeCom App (自建应用), receiveid should be corp_id
|
||||||
|
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse decrypted XML message
|
||||||
|
var msg WeComXMLMessage
|
||||||
|
if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the message with context
|
||||||
|
go c.processMessage(ctx, msg)
|
||||||
|
|
||||||
|
// Return success response immediately
|
||||||
|
// WeCom App requires response within configured timeout (default 5 seconds)
|
||||||
|
w.Write([]byte("success"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// processMessage processes the received message
|
||||||
|
func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) {
|
||||||
|
// Skip non-text messages for now (can be extended)
|
||||||
|
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" {
|
||||||
|
logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]interface{}{
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message deduplication: Use msg_id to prevent duplicate processing
|
||||||
|
// As per WeCom documentation, use msg_id for deduplication
|
||||||
|
msgID := fmt.Sprintf("%d", msg.MsgId)
|
||||||
|
c.msgMu.Lock()
|
||||||
|
if c.processedMsgs[msgID] {
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]interface{}{
|
||||||
|
"msg_id": msgID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.processedMsgs[msgID] = true
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
|
||||||
|
// Clean up old messages periodically (keep last 1000)
|
||||||
|
if len(c.processedMsgs) > 1000 {
|
||||||
|
c.msgMu.Lock()
|
||||||
|
c.processedMsgs = make(map[string]bool)
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
senderID := msg.FromUserName
|
||||||
|
chatID := senderID // WeCom App uses user ID as chat ID for direct messages
|
||||||
|
|
||||||
|
// Build metadata
|
||||||
|
// WeCom App only supports direct messages (private chat)
|
||||||
|
metadata := map[string]string{
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
"msg_id": fmt.Sprintf("%d", msg.MsgId),
|
||||||
|
"agent_id": fmt.Sprintf("%d", msg.AgentID),
|
||||||
|
"platform": "wecom_app",
|
||||||
|
"media_id": msg.MediaId,
|
||||||
|
"create_time": fmt.Sprintf("%d", msg.CreateTime),
|
||||||
|
"peer_kind": "direct",
|
||||||
|
"peer_id": senderID,
|
||||||
|
}
|
||||||
|
|
||||||
|
content := msg.Content
|
||||||
|
|
||||||
|
logger.DebugCF("wecom_app", "Received message", map[string]interface{}{
|
||||||
|
"sender_id": senderID,
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
"preview": utils.Truncate(content, 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle the message through the base channel
|
||||||
|
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenRefreshLoop periodically refreshes the access token
|
||||||
|
func (c *WeComAppChannel) tokenRefreshLoop() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.refreshAccessToken(); err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshAccessToken gets a new access token from WeCom API
|
||||||
|
func (c *WeComAppChannel) refreshAccessToken() error {
|
||||||
|
apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
|
||||||
|
wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret))
|
||||||
|
|
||||||
|
resp, err := http.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to request access token: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp WeComAccessTokenResponse
|
||||||
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenResp.ErrCode != 0 {
|
||||||
|
return fmt.Errorf("API error: %s (code: %d)", tokenResp.ErrMsg, tokenResp.ErrCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.tokenMu.Lock()
|
||||||
|
c.accessToken = tokenResp.AccessToken
|
||||||
|
c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) // Refresh 5 minutes early
|
||||||
|
c.tokenMu.Unlock()
|
||||||
|
|
||||||
|
logger.DebugC("wecom_app", "Access token refreshed successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAccessToken returns the current valid access token
|
||||||
|
func (c *WeComAppChannel) getAccessToken() string {
|
||||||
|
c.tokenMu.RLock()
|
||||||
|
defer c.tokenMu.RUnlock()
|
||||||
|
|
||||||
|
if time.Now().After(c.tokenExpiry) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendTextMessage sends a text message to a user
|
||||||
|
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{
|
||||||
|
ToUser: userID,
|
||||||
|
MsgType: "text",
|
||||||
|
AgentID: c.config.AgentID,
|
||||||
|
}
|
||||||
|
msg.Text.Content = content
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send message: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMarkdownMessage sends a markdown message to a user
|
||||||
|
func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, userID, content string) error {
|
||||||
|
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
|
||||||
|
|
||||||
|
msg := WeComMarkdownMessage{
|
||||||
|
ToUser: userID,
|
||||||
|
MsgType: "markdown",
|
||||||
|
AgentID: c.config.AgentID,
|
||||||
|
}
|
||||||
|
msg.Markdown.Content = content
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send message: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
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
|
||||||
|
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"running": c.IsRunning(),
|
||||||
|
"has_token": c.getAccessToken() != "",
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(status)
|
||||||
|
}
|
||||||
1089
pkg/channels/wecom_app_test.go
Normal file
1089
pkg/channels/wecom_app_test.go
Normal file
File diff suppressed because it is too large
Load diff
755
pkg/channels/wecom_test.go
Normal file
755
pkg/channels/wecom_test.go
Normal file
|
|
@ -0,0 +1,755 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// WeCom Bot (企业微信智能机器人) channel tests
|
||||||
|
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateTestAESKey generates a valid test AES key
|
||||||
|
func generateTestAESKey() string {
|
||||||
|
// AES key needs to be 32 bytes (256 bits) for AES-256
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte(i)
|
||||||
|
}
|
||||||
|
// Return base64 encoded key without padding
|
||||||
|
return base64.StdEncoding.EncodeToString(key)[:43]
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptTestMessage encrypts a message for testing (AIBOT JSON format)
|
||||||
|
func encryptTestMessage(message, aesKey string) (string, error) {
|
||||||
|
// Decode AES key
|
||||||
|
key, err := base64.StdEncoding.DecodeString(aesKey + "=")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare message: random(16) + msg_len(4) + msg + receiveid
|
||||||
|
random := make([]byte, 0, 16)
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
random = append(random, byte(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBytes := []byte(message)
|
||||||
|
receiveID := []byte("test_aibot_id")
|
||||||
|
|
||||||
|
msgLen := uint32(len(msgBytes))
|
||||||
|
lenBytes := make([]byte, 4)
|
||||||
|
binary.BigEndian.PutUint32(lenBytes, msgLen)
|
||||||
|
|
||||||
|
plainText := append(random, lenBytes...)
|
||||||
|
plainText = append(plainText, msgBytes...)
|
||||||
|
plainText = append(plainText, receiveID...)
|
||||||
|
|
||||||
|
// PKCS7 padding
|
||||||
|
blockSize := aes.BlockSize
|
||||||
|
padding := blockSize - len(plainText)%blockSize
|
||||||
|
padText := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||||
|
plainText = append(plainText, padText...)
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize])
|
||||||
|
cipherText := make([]byte, len(plainText))
|
||||||
|
mode.CryptBlocks(cipherText, plainText)
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(cipherText), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSignature generates a signature for testing
|
||||||
|
func generateSignature(token, timestamp, nonce, msgEncrypt string) string {
|
||||||
|
params := []string{token, timestamp, nonce, msgEncrypt}
|
||||||
|
sort.Strings(params)
|
||||||
|
str := strings.Join(params, "")
|
||||||
|
hash := sha1.Sum([]byte(str))
|
||||||
|
return fmt.Sprintf("%x", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWeComBotChannel(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
|
t.Run("missing token", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
_, err := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing token, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing webhook_url", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "",
|
||||||
|
}
|
||||||
|
_, err := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing webhook_url, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid config", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
AllowFrom: []string{"user1", "user2"},
|
||||||
|
}
|
||||||
|
ch, err := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if ch.Name() != "wecom" {
|
||||||
|
t.Errorf("Name() = %q, want %q", ch.Name(), "wecom")
|
||||||
|
}
|
||||||
|
if ch.IsRunning() {
|
||||||
|
t.Error("new channel should not be running")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotChannelIsAllowed(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
|
t.Run("empty allowlist allows all", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
AllowFrom: []string{},
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
if !ch.IsAllowed("any_user") {
|
||||||
|
t.Error("empty allowlist should allow all users")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allowlist restricts users", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
AllowFrom: []string{"allowed_user"},
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
if !ch.IsAllowed("allowed_user") {
|
||||||
|
t.Error("allowed user should pass allowlist check")
|
||||||
|
}
|
||||||
|
if ch.IsAllowed("blocked_user") {
|
||||||
|
t.Error("non-allowed user should be blocked")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotVerifySignature(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
t.Run("valid signature", func(t *testing.T) {
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
msgEncrypt := "test_message"
|
||||||
|
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
|
||||||
|
|
||||||
|
if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
|
||||||
|
t.Error("valid signature should pass verification")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid signature", func(t *testing.T) {
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
msgEncrypt := "test_message"
|
||||||
|
|
||||||
|
if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
|
||||||
|
t.Error("invalid signature should fail verification")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty token skips verification", func(t *testing.T) {
|
||||||
|
// Create a channel manually with empty token to test the behavior
|
||||||
|
cfgEmpty := config.WeComConfig{
|
||||||
|
Token: "",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
base := NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom)
|
||||||
|
chEmpty := &WeComBotChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfgEmpty,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
||||||
|
t.Error("empty token should skip verification and return true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotDecryptMessage(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
|
t.Run("decrypt without AES key", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
EncodingAESKey: "",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
// Without AES key, message should be base64 decoded only
|
||||||
|
plainText := "hello world"
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
||||||
|
|
||||||
|
result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result != plainText {
|
||||||
|
t.Errorf("decryptMessage() = %q, want %q", result, plainText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("decrypt with AES key", func(t *testing.T) {
|
||||||
|
aesKey := generateTestAESKey()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
EncodingAESKey: aesKey,
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
originalMsg := "<xml><Content>Hello</Content></xml>"
|
||||||
|
encrypted, err := encryptTestMessage(originalMsg, aesKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encrypt test message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result != originalMsg {
|
||||||
|
t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid base64", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
EncodingAESKey: "",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
_, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid base64, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid AES key", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
EncodingAESKey: "invalid_key",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
_, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid AES key, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotPKCS7Unpad(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 := pkcs7UnpadWeCom(tt.input)
|
||||||
|
if tt.expected == nil {
|
||||||
|
// This case should return an error
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("pkcs7UnpadWeCom() expected error for invalid padding, got result: %v", result)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("pkcs7UnpadWeCom() unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !bytes.Equal(result, tt.expected) {
|
||||||
|
t.Errorf("pkcs7UnpadWeCom() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotHandleVerification(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
aesKey := generateTestAESKey()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: aesKey,
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
t.Run("valid verification request", func(t *testing.T) {
|
||||||
|
echostr := "test_echostr_123"
|
||||||
|
encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleVerification(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if w.Body.String() != echostr {
|
||||||
|
t.Errorf("response body = %q, want %q", w.Body.String(), echostr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing parameters", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=sig×tamp=ts", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleVerification(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid signature", func(t *testing.T) {
|
||||||
|
echostr := "test_echostr"
|
||||||
|
encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleVerification(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotHandleMessageCallback(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
aesKey := generateTestAESKey()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: aesKey,
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
t.Run("valid direct message callback", func(t *testing.T) {
|
||||||
|
// Create JSON message for direct chat (single)
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if w.Body.String() != "success" {
|
||||||
|
t.Errorf("response body = %q, want %q", w.Body.String(), "success")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid group message callback", func(t *testing.T) {
|
||||||
|
// Create JSON message for group chat
|
||||||
|
jsonMsg := `{
|
||||||
|
"msgid": "test_msg_id_456",
|
||||||
|
"aibotid": "test_aibot_id",
|
||||||
|
"chatid": "group_chat_id_123",
|
||||||
|
"chattype": "group",
|
||||||
|
"from": {"userid": "user456"},
|
||||||
|
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
"msgtype": "text",
|
||||||
|
"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 {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if w.Body.String() != "success" {
|
||||||
|
t.Errorf("response body = %q, want %q", w.Body.String(), "success")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing parameters", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=sig", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid XML", func(t *testing.T) {
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
signature := generateSignature("test_token", timestamp, nonce, "")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid signature", func(t *testing.T) {
|
||||||
|
encryptedWrapper := struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
Encrypt string `xml:"Encrypt"`
|
||||||
|
}{
|
||||||
|
Encrypt: "encrypted_data",
|
||||||
|
}
|
||||||
|
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||||
|
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotProcessMessage(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
t.Run("process direct text message", func(t *testing.T) {
|
||||||
|
msg := WeComBotMessage{
|
||||||
|
MsgID: "test_msg_id_123",
|
||||||
|
AIBotID: "test_aibot_id",
|
||||||
|
ChatType: "single",
|
||||||
|
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
MsgType: "text",
|
||||||
|
}
|
||||||
|
msg.From.UserID = "user123"
|
||||||
|
msg.Text.Content = "Hello World"
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
ch.processMessage(context.Background(), msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("process group text message", func(t *testing.T) {
|
||||||
|
msg := WeComBotMessage{
|
||||||
|
MsgID: "test_msg_id_456",
|
||||||
|
AIBotID: "test_aibot_id",
|
||||||
|
ChatID: "group_chat_id_123",
|
||||||
|
ChatType: "group",
|
||||||
|
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
MsgType: "text",
|
||||||
|
}
|
||||||
|
msg.From.UserID = "user456"
|
||||||
|
msg.Text.Content = "Hello Group"
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
ch.processMessage(context.Background(), msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("process voice message", func(t *testing.T) {
|
||||||
|
msg := WeComBotMessage{
|
||||||
|
MsgID: "test_msg_id_789",
|
||||||
|
AIBotID: "test_aibot_id",
|
||||||
|
ChatType: "single",
|
||||||
|
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
MsgType: "voice",
|
||||||
|
}
|
||||||
|
msg.From.UserID = "user123"
|
||||||
|
msg.Voice.Content = "Voice message text"
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
ch.processMessage(context.Background(), msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("skip unsupported message type", func(t *testing.T) {
|
||||||
|
msg := WeComBotMessage{
|
||||||
|
MsgID: "test_msg_id_000",
|
||||||
|
AIBotID: "test_aibot_id",
|
||||||
|
ChatType: "single",
|
||||||
|
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
MsgType: "video",
|
||||||
|
}
|
||||||
|
msg.From.UserID = "user123"
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
ch.processMessage(context.Background(), msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotHandleWebhook(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
t.Run("GET request calls verification", func(t *testing.T) {
|
||||||
|
echostr := "test_echostr"
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(echostr))
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
signature := generateSignature("test_token", timestamp, nonce, encoded)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleWebhook(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("POST request calls message callback", func(t *testing.T) {
|
||||||
|
encryptedWrapper := struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
Encrypt string `xml:"Encrypt"`
|
||||||
|
}{
|
||||||
|
Encrypt: base64.StdEncoding.EncodeToString([]byte("test")),
|
||||||
|
}
|
||||||
|
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||||
|
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleWebhook(w, req)
|
||||||
|
|
||||||
|
// Should not be method not allowed
|
||||||
|
if w.Code == http.StatusMethodNotAllowed {
|
||||||
|
t.Error("POST request should not return Method Not Allowed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unsupported method", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/webhook/wecom", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleWebhook(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotHandleHealth(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleHealth(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := w.Header().Get("Content-Type")
|
||||||
|
if contentType != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want %q", contentType, "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "status") || !strings.Contains(body, "running") {
|
||||||
|
t.Errorf("response body should contain status and running fields, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotReplyMessage(t *testing.T) {
|
||||||
|
msg := WeComBotReplyMessage{
|
||||||
|
MsgType: "text",
|
||||||
|
}
|
||||||
|
msg.Text.Content = "Hello World"
|
||||||
|
|
||||||
|
if msg.MsgType != "text" {
|
||||||
|
t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
|
||||||
|
}
|
||||||
|
if msg.Text.Content != "Hello World" {
|
||||||
|
t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotMessageStructure(t *testing.T) {
|
||||||
|
jsonData := `{
|
||||||
|
"msgid": "test_msg_id_123",
|
||||||
|
"aibotid": "test_aibot_id",
|
||||||
|
"chatid": "group_chat_id_123",
|
||||||
|
"chattype": "group",
|
||||||
|
"from": {"userid": "user123"},
|
||||||
|
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
"msgtype": "text",
|
||||||
|
"text": {"content": "Hello World"}
|
||||||
|
}`
|
||||||
|
|
||||||
|
var msg WeComBotMessage
|
||||||
|
err := json.Unmarshal([]byte(jsonData), &msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.MsgID != "test_msg_id_123" {
|
||||||
|
t.Errorf("MsgID = %q, want %q", msg.MsgID, "test_msg_id_123")
|
||||||
|
}
|
||||||
|
if msg.AIBotID != "test_aibot_id" {
|
||||||
|
t.Errorf("AIBotID = %q, want %q", msg.AIBotID, "test_aibot_id")
|
||||||
|
}
|
||||||
|
if msg.ChatID != "group_chat_id_123" {
|
||||||
|
t.Errorf("ChatID = %q, want %q", msg.ChatID, "group_chat_id_123")
|
||||||
|
}
|
||||||
|
if msg.ChatType != "group" {
|
||||||
|
t.Errorf("ChatType = %q, want %q", msg.ChatType, "group")
|
||||||
|
}
|
||||||
|
if msg.From.UserID != "user123" {
|
||||||
|
t.Errorf("From.UserID = %q, want %q", msg.From.UserID, "user123")
|
||||||
|
}
|
||||||
|
if msg.MsgType != "text" {
|
||||||
|
t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
|
||||||
|
}
|
||||||
|
if msg.Text.Content != "Hello World" {
|
||||||
|
t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -190,6 +190,8 @@ type ChannelsConfig struct {
|
||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
LINE LINEConfig `json:"line"`
|
LINE LINEConfig `json:"line"`
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
|
WeCom WeComConfig `json:"wecom"`
|
||||||
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
|
|
@ -268,6 +270,32 @@ type OneBotConfig struct {
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WeComConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
|
||||||
|
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
|
||||||
|
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
|
||||||
|
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
|
||||||
|
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
|
||||||
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeComAppConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
|
||||||
|
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
|
||||||
|
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
|
||||||
|
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
|
||||||
|
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
|
||||||
|
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
|
||||||
|
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
|
||||||
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
|
||||||
|
}
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,30 @@ func DefaultConfig() *Config {
|
||||||
GroupTriggerPrefix: []string{},
|
GroupTriggerPrefix: []string{},
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
|
WeCom: WeComConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Token: "",
|
||||||
|
EncodingAESKey: "",
|
||||||
|
WebhookURL: "",
|
||||||
|
WebhookHost: "0.0.0.0",
|
||||||
|
WebhookPort: 18793,
|
||||||
|
WebhookPath: "/webhook/wecom",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
ReplyTimeout: 5,
|
||||||
|
},
|
||||||
|
WeComApp: WeComAppConfig{
|
||||||
|
Enabled: false,
|
||||||
|
CorpID: "",
|
||||||
|
CorpSecret: "",
|
||||||
|
AgentID: 0,
|
||||||
|
Token: "",
|
||||||
|
EncodingAESKey: "",
|
||||||
|
WebhookHost: "0.0.0.0",
|
||||||
|
WebhookPort: 18792,
|
||||||
|
WebhookPath: "/webhook/wecom-app",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
ReplyTimeout: 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue