🧹 Remove WeCom channel support

🎯 What:
I have removed all WeCom-related channel variants (`wecom`, `wecom_app`, `wecom_aibot`) from the codebase.

💡 Why:
This streamlines our channel offerings and reduces maintenance overhead for unused or deprecated integrations.

 Verification:
- Removed the directories for `pkg/channels/wecom/` and `docs/channels/wecom/`.
- Cleared out all references from `pkg/config/`, `pkg/channels/`, and `pkg/agent/`.
- Updated the CLI, TUI, and Web Dashboard (frontend/backend) to remove all WeCom options.
- Verified that all main packages compile correctly and ran the relevant unit tests.
- Conducted a comprehensive codebase search to ensure no "wecom" references remain.

 Result:
Complete removal of WeCom support, reducing binary size and code complexity.

Co-authored-by: hobbyistlabs-coder <267281733+hobbyistlabs-coder@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-03-13 20:45:45 +00:00
parent 9970032d53
commit c9ddb829e0
38 changed files with 6 additions and 5670 deletions

View file

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

View file

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

113
README.md
View file

@ -337,7 +337,6 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
| **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 AI Bot** | Medium (Token + AES key) |
<details> <details>
<summary><b>Telegram</b> (Recommended)</summary> <summary><b>Telegram</b> (Recommended)</summary>
@ -627,118 +626,6 @@ picoclaw gateway
</details> </details>
<details>
<summary><b>WeCom (企业微信)</b></summary>
PicoClaw supports three types of WeCom integration:
**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.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_path": "/webhook/wecom",
"allow_from": []
}
}
}
```
> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
**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:18790/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_path": "/webhook/wecom-app",
"allow_from": []
}
}
}
```
**4. Run**
```bash
picoclaw gateway
```
> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.
**Quick Setup - WeCom AI Bot:**
**1. Create an AI Bot**
* Go to WeCom Admin Console → App Management → AI Bot
* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot`
* Copy **Token** and click "Random Generate" for **EncodingAESKey**
**2. Configure**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Hello! How can I help you?"
}
}
}
```
**3. Run**
```bash
picoclaw gateway
```
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
</details>
## <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

View file

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

View file

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

View file

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

BIN
backend Executable file

Binary file not shown.

View file

@ -144,8 +144,6 @@ func (s *appState) countChannels() (enabled int, total int) {
c.Matrix.Enabled, c.Matrix.Enabled,
c.LINE.Enabled, c.LINE.Enabled,
c.OneBot.Enabled, c.OneBot.Enabled,
c.WeCom.Enabled,
c.WeComApp.Enabled,
} }
total = len(entries) total = len(entries)
for _, v := range entries { for _, v := range entries {
@ -439,7 +437,7 @@ func (s *appState) hasEnabledChannel() bool {
c := s.config.Channels c := s.config.Channels
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled || return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled || c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled
} }
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) { func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {

View file

@ -78,18 +78,6 @@ func (s *appState) buildChannelMenuItems() []MenuItem {
s.config.Channels.OneBot.Enabled, s.config.Channels.OneBot.Enabled,
func() { s.push("channel-onebot", s.onebotForm()) }, func() { s.push("channel-onebot", s.onebotForm()) },
), ),
channelItem(
"WeCom",
"WeCom bot settings",
s.config.Channels.WeCom.Enabled,
func() { s.push("channel-wecom", s.wecomForm()) },
),
channelItem(
"WeCom App",
"WeCom App settings",
s.config.Channels.WeComApp.Enabled,
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
),
} }
} }
@ -284,68 +272,6 @@ func (s *appState) onebotForm() tview.Primitive {
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) wecomForm() tview.Primitive {
cfg := &s.config.Channels.WeCom
form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
cfg.EncodingAESKey = strings.TrimSpace(text)
})
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
cfg.WebhookURL = strings.TrimSpace(text)
})
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
cfg.WebhookHost = strings.TrimSpace(text)
})
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
addIntField(
form,
"Reply Timeout",
cfg.ReplyTimeout,
func(value int) { cfg.ReplyTimeout = value },
)
return wrapWithBack(form, s)
}
func (s *appState) wecomAppForm() tview.Primitive {
cfg := &s.config.Channels.WeComApp
form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
cfg.CorpID = strings.TrimSpace(text)
})
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
cfg.CorpSecret = strings.TrimSpace(text)
})
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
cfg.EncodingAESKey = strings.TrimSpace(text)
})
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
cfg.WebhookHost = strings.TrimSpace(text)
})
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
addIntField(
form,
"Reply Timeout",
cfg.ReplyTimeout,
func(value int) { cfg.ReplyTimeout = value },
)
return wrapWithBack(form, s)
}
func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) { func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {
return func(v bool) { return func(v bool) {
*enabledPtr = v *enabledPtr = v

View file

@ -25,7 +25,6 @@ import (
_ "jane/pkg/channels/qq" _ "jane/pkg/channels/qq"
_ "jane/pkg/channels/slack" _ "jane/pkg/channels/slack"
_ "jane/pkg/channels/telegram" _ "jane/pkg/channels/telegram"
_ "jane/pkg/channels/wecom"
_ "jane/pkg/channels/whatsapp" _ "jane/pkg/channels/whatsapp"
_ "jane/pkg/channels/whatsapp_native" _ "jane/pkg/channels/whatsapp_native"
"jane/pkg/config" "jane/pkg/config"

View file

@ -154,40 +154,6 @@
"allow_from": [], "allow_from": [],
"reasoning_channel_id": "" "reasoning_channel_id": ""
}, },
"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_path": "/webhook/wecom",
"allow_from": [],
"reply_timeout": 5,
"reasoning_channel_id": ""
},
"wecom_app": {
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.",
"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_path": "/webhook/wecom-app",
"allow_from": [],
"reply_timeout": 5,
"reasoning_channel_id": ""
},
"wecom_aibot": {
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
"enabled": false,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"max_steps": 10,
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
"reasoning_channel_id": ""
},
"irc": { "irc": {
"enabled": false, "enabled": false,
"server": "irc.libera.chat:6697", "server": "irc.libera.chat:6697",

View file

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

View file

@ -1,45 +0,0 @@
# 企业微信自建应用
企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。
## 配置
```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_path": "/webhook/wecom-app",
"allow_from": [],
"reply_timeout": 5
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ---------------- | ------ | ---- | ---------------------------------------- |
| corp_id | string | 是 | 企业 ID |
| corp_secret | string | 是 | 应用程序密钥 |
| agent_id | int | 是 | 应用程序代理 ID |
| token | string | 是 | 回调验证令牌 |
| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app |
| allow_from | array | 否 | 用户 ID 白名单 |
| reply_timeout | int | 否 | 回复超时时间(秒) |
## 设置流程
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/)
2. 进入“应用管理” -> “创建应用”
3. 获取企业 ID (CorpID) 和应用 Secret
4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
5. 设置回调 URL 为 `http://<your-server-ip>:<port>/webhook/wecom-app`
6. 将 CorpID, Secret, AgentID 等信息填入配置文件
注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调请把外部域名反向代理到 Gateway默认端口 18790

View file

@ -1,39 +0,0 @@
# 企业微信机器人
企业微信机器人是企业微信提供的一种快速接入方式,可以通过 Webhook URL 接收消息。
## 配置
```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_path": "/webhook/wecom",
"allow_from": [],
"reply_timeout": 5
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ---------------- | ------ | ---- | -------------------------------------------- |
| token | string | 是 | 签名验证代币 |
| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 |
| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL |
| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom |
| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) |
| reply_timeout | int | 否 | 回复超时时间单位默认值5 |
## 设置流程
1. 在企业微信群中添加机器人
2. 获取 Webhook URL
3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey
4. 将相关信息填入配置文件
注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调请把外部域名反向代理到 Gateway默认端口 18790

BIN
picoclaw-launcher-tui Executable file

Binary file not shown.

View file

@ -854,8 +854,6 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
"slack": "rid-slack", "slack": "rid-slack",
"line": "rid-line", "line": "rid-line",
"onebot": "rid-onebot", "onebot": "rid-onebot",
"wecom": "rid-wecom",
"wecom_app": "rid-wecom-app",
} { } {
chManager.RegisterChannel(name, &fakeChannel{id: id}) chManager.RegisterChannel(name, &fakeChannel{id: id})
} }
@ -874,8 +872,6 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
{channel: "slack", wantID: "rid-slack"}, {channel: "slack", wantID: "rid-slack"},
{channel: "line", wantID: "rid-line"}, {channel: "line", wantID: "rid-line"},
{channel: "onebot", wantID: "rid-onebot"}, {channel: "onebot", wantID: "rid-onebot"},
{channel: "wecom", wantID: "rid-wecom"},
{channel: "wecom_app", wantID: "rid-wecom-app"},
{channel: "unknown", wantID: ""}, {channel: "unknown", wantID: ""},
} }

View file

@ -60,7 +60,7 @@ pkg/channels/
├── discord/ ├── discord/
│ ├── init.go │ ├── init.go
│ └── discord.go │ └── discord.go
├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/ ├── slack/ line/ onebot/ dingtalk/ feishu/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
│ └── ... │ └── ...
pkg/bus/ pkg/bus/
@ -1255,8 +1255,6 @@ make test # Full test suite
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender | | `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
| `pkg/channels/dingtalk/` | `"dingtalk"` | — | | `pkg/channels/dingtalk/` | `"dingtalk"` | — |
| `pkg/channels/feishu/` | `"feishu"` | — (architecture-specific build tags: `feishu_32.go` / `feishu_64.go`) | | `pkg/channels/feishu/` | `"feishu"` | — (architecture-specific build tags: `feishu_32.go` / `feishu_64.go`) |
| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker |
| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
| `pkg/channels/qq/` | `"qq"` | — | | `pkg/channels/qq/` | `"qq"` | — |
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) | | `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) |
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) | | `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) |
@ -1371,7 +1369,6 @@ agentLoop.Stop() // Stop Agent
2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`). Feishu uses the SDK's WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`. 2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`). Feishu uses the SDK's WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`.
3. **WeCom has two factories**: `"wecom"` (Bot mode, webhook only) and `"wecom_app"` (App mode, supports MediaSender) are registered separately. Both implement `WebhookHandler` and `HealthChecker`.
4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via WebSocket webhook (`/pico/ws`). 4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via WebSocket webhook (`/pico/ws`).
@ -1381,4 +1378,4 @@ agentLoop.Stop() // Stop Agent
7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in 6 channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico) can actually use placeholder message editing. The rest are reserved fields. 7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in 6 channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico) can actually use placeholder message editing. The rest are reserved fields.
8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom, WeComApp). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method. 8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method.

View file

@ -60,7 +60,7 @@ pkg/channels/
├── discord/ ├── discord/
│ ├── init.go │ ├── init.go
│ └── discord.go │ └── discord.go
├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/ ├── slack/ line/ onebot/ dingtalk/ feishu/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
│ └── ... │ └── ...
pkg/bus/ pkg/bus/
@ -1254,8 +1254,6 @@ make test # 全量测试
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender | | `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
| `pkg/channels/dingtalk/` | `"dingtalk"` | — | | `pkg/channels/dingtalk/` | `"dingtalk"` | — |
| `pkg/channels/feishu/` | `"feishu"` | — (架构特定 build tags: `feishu_32.go` / `feishu_64.go`) | | `pkg/channels/feishu/` | `"feishu"` | — (架构特定 build tags: `feishu_32.go` / `feishu_64.go`) |
| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker |
| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
| `pkg/channels/qq/` | `"qq"` | — | | `pkg/channels/qq/` | `"qq"` | — |
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) | | `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) |
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) | | `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) |
@ -1370,7 +1368,6 @@ agentLoop.Stop() // 停止 Agent
2. **Feishu 架构特定编译**Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`。Feishu 使用 SDK 的 WebSocket 模式(非 HTTP webhook因此不实现 `WebhookHandler` 2. **Feishu 架构特定编译**Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`。Feishu 使用 SDK 的 WebSocket 模式(非 HTTP webhook因此不实现 `WebhookHandler`
3. **WeCom 有两个工厂**`"wecom"`Bot 模式,纯 webhook`"wecom_app"`(应用模式,支持 MediaSender分别注册。两者都实现了 `WebhookHandler``HealthChecker`
4. **Pico Protocol**`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel通过 WebSocket webhook (`/pico/ws`) 接收消息。 4. **Pico Protocol**`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel通过 WebSocket webhook (`/pico/ws`) 接收消息。
@ -1380,4 +1377,4 @@ agentLoop.Stop() // 停止 Agent
7. **PlaceholderConfig 的配置与实现**`PlaceholderConfig` 出现在 6 个 channel config 中Telegram、Discord、Slack、LINE、OneBot、Pico但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channelTelegram、Discord、Pico能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。 7. **PlaceholderConfig 的配置与实现**`PlaceholderConfig` 出现在 6 个 channel config 中Telegram、Discord、Slack、LINE、OneBot、Pico但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channelTelegram、Discord、Pico能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。
8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链reasoning/thinking路由到指定 channelWhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。 8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链reasoning/thinking路由到指定 channelWhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot。注意`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。

View file

@ -287,18 +287,6 @@ func (m *Manager) initChannels() error {
m.initChannel("onebot", "OneBot") m.initChannel("onebot", "OneBot")
} }
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
m.initChannel("wecom", "WeCom")
}
if m.config.Channels.WeComAIBot.Enabled && m.config.Channels.WeComAIBot.Token != "" {
m.initChannel("wecom_aibot", "WeCom AI Bot")
}
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
m.initChannel("wecom_app", "WeCom App")
}
if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token != "" { if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token != "" {
m.initChannel("pico", "Pico") m.initChannel("pico", "Pico")
} }

View file

@ -7,7 +7,7 @@ import "net/http"
// and registers them on the shared HTTP server. // and registers them on the shared HTTP server.
type WebhookHandler interface { type WebhookHandler interface {
// WebhookPath returns the path to mount this handler on the shared server. // WebhookPath returns the path to mount this handler on the shared server.
// Examples: "/webhook/line", "/webhook/wecom" // Examples: "/webhook/line"
WebhookPath() string WebhookPath() string
http.Handler // ServeHTTP(w http.ResponseWriter, r *http.Request) http.Handler // ServeHTTP(w http.ResponseWriter, r *http.Request)
} }

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,756 +0,0 @@
package wecom
import (
"bytes"
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"jane/pkg/bus"
"jane/pkg/channels"
"jane/pkg/config"
"jane/pkg/identity"
"jane/pkg/logger"
"jane/pkg/utils"
)
const (
wecomAPIBase = "https://qyapi.weixin.qq.com"
)
// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)
type WeComAppChannel struct {
*channels.BaseChannel
config config.WeComAppConfig
client *http.Client
accessToken string
tokenExpiry time.Time
tokenMu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
processedMsgs *MessageDeduplicator
}
// 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 := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(2048),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
// Client timeout must be >= the configured ReplyTimeout so the
// per-request context deadline is always the effective limit.
clientTimeout := 30 * time.Second
if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout {
clientTimeout = d
}
ctx, cancel := context.WithCancel(context.Background())
return &WeComAppChannel{
BaseChannel: base,
config: cfg,
client: &http.Client{Timeout: clientTimeout},
ctx: ctx,
cancel: cancel,
processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
}, nil
}
// Name returns the channel name
func (c *WeComAppChannel) Name() string {
return "wecom_app"
}
// Start initializes the WeCom App channel
func (c *WeComAppChannel) Start(ctx context.Context) error {
logger.InfoC("wecom_app", "Starting WeCom App channel...")
// Cancel the context created in the constructor to avoid a resource leak.
if c.cancel != nil {
c.cancel()
}
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]any{
"error": err.Error(),
})
}
// Start token refresh goroutine
go c.tokenRefreshLoop()
c.SetRunning(true)
logger.InfoC("wecom_app", "WeCom App channel started")
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()
}
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 channels.ErrNotRunning
}
accessToken := c.getAccessToken()
if accessToken == "" {
return fmt.Errorf("no valid access token available")
}
logger.DebugCF("wecom_app", "Sending message", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)
}
// SendMedia implements the channels.MediaSender interface.
func (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
accessToken := c.getAccessToken()
if accessToken == "" {
return fmt.Errorf("no valid access token available: %w", channels.ErrTemporary)
}
store := c.GetMediaStore()
if store == nil {
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to resolve media ref", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
continue
}
// Map part type to WeCom media type
var mediaType string
switch part.Type {
case "image":
mediaType = "image"
case "audio":
mediaType = "voice"
case "video":
mediaType = "video"
default:
mediaType = "file"
}
// Upload media to get media_id
mediaID, err := c.uploadMedia(ctx, accessToken, mediaType, localPath)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to upload media", map[string]any{
"type": mediaType,
"error": err.Error(),
})
// Fallback: send caption as text
if part.Caption != "" {
_ = c.sendTextMessage(ctx, accessToken, msg.ChatID, part.Caption)
}
continue
}
// Send media message using the media_id
if mediaType == "image" {
err = c.sendImageMessage(ctx, accessToken, msg.ChatID, mediaID)
} else {
// For non-image types, send as text fallback with caption
caption := part.Caption
if caption == "" {
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
}
err = c.sendTextMessage(ctx, accessToken, msg.ChatID, caption)
}
if err != nil {
return err
}
}
return nil
}
// uploadMedia uploads a local file to WeCom temporary media storage.
func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaType, localPath string) (string, error) {
apiURL := fmt.Sprintf("%s/cgi-bin/media/upload?access_token=%s&type=%s",
wecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaType))
file, err := os.Open(localPath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
filename := filepath.Base(localPath)
formFile, err := writer.CreateFormFile("media", filename)
if err != nil {
return "", fmt.Errorf("failed to create form file: %w", err)
}
if _, err = io.Copy(formFile, file); err != nil {
return "", fmt.Errorf("failed to copy file content: %w", err)
}
writer.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.client.Do(req)
if err != nil {
return "", channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return "", channels.ClassifySendError(
resp.StatusCode,
fmt.Errorf("reading wecom upload error response: %w", readErr),
)
}
return "", channels.ClassifySendError(
resp.StatusCode,
fmt.Errorf("wecom upload error: %s", string(respBody)),
)
}
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
MediaID string `json:"media_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to parse upload response: %w", err)
}
if result.ErrCode != 0 {
return "", fmt.Errorf("upload API error: %s (code: %d)", result.ErrMsg, result.ErrCode)
}
return result.MediaID, nil
}
// sendWeComMessage marshals payload and POSTs it to the WeCom message API.
func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken string, payload any) error {
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
timeout := c.config.ReplyTimeout
if timeout <= 0 {
timeout = 5
}
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return channels.ClassifySendError(
resp.StatusCode,
fmt.Errorf("reading wecom_app error response: %w", readErr),
)
}
return channels.ClassifySendError(
resp.StatusCode,
fmt.Errorf("wecom_app API error: %s", string(respBody)),
)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var sendResp WeComSendMessageResponse
if err := json.Unmarshal(respBody, &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
}
// sendImageMessage sends an image message using a media_id.
func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error {
msg := WeComImageMessage{
ToUser: userID,
MsgType: "image",
AgentID: c.config.AgentID,
}
msg.Image.MediaID = mediaID
return c.sendWeComMessage(ctx, accessToken, msg)
}
// WebhookPath returns the path for registering on the shared HTTP server.
func (c *WeComAppChannel) WebhookPath() string {
if c.config.WebhookPath != "" {
return c.config.WebhookPath
}
return "/webhook/wecom-app"
}
// ServeHTTP implements http.Handler for the shared HTTP server.
func (c *WeComAppChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.handleWebhook(w, r)
}
// HealthPath returns the health check endpoint path.
func (c *WeComAppChannel) HealthPath() string {
return "/health/wecom-app"
}
// HealthHandler handles health check requests.
func (c *WeComAppChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
c.handleHealth(w, r)
}
// 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]any{
"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]any{
"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]any{
"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 !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
"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]any{
"encoding_aes_key": c.config.EncodingAESKey,
"corp_id": c.config.CorpID,
})
decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
"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]any{
"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]any{
"error": err.Error(),
})
http.Error(w, "Invalid XML", http.StatusBadRequest)
return
}
// Verify signature
if !verifySignature(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 := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
"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]any{
"error": err.Error(),
})
http.Error(w, "Invalid message format", http.StatusBadRequest)
return
}
// Process the message with the channel's long-lived context (not the HTTP
// request context, which is canceled as soon as we return the response).
go c.processMessage(c.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]any{
"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)
if !c.processedMsgs.MarkMessageProcessed(msgID) {
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
"msg_id": msgID,
})
return
}
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)
peer := bus.Peer{Kind: "direct", ID: senderID}
messageID := fmt.Sprintf("%d", msg.MsgId)
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),
}
content := msg.Content
logger.DebugCF("wecom_app", "Received message", map[string]any{
"sender_id": senderID,
"msg_type": msg.MsgType,
"preview": utils.Truncate(content, 50),
})
// Build sender info
appSender := bus.SenderInfo{
Platform: "wecom",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("wecom", senderID),
}
// Handle the message through the base channel
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender)
}
// 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]any{
"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 {
msg := WeComTextMessage{
ToUser: userID,
MsgType: "text",
AgentID: c.config.AgentID,
}
msg.Text.Content = content
return c.sendWeComMessage(ctx, accessToken, msg)
}
// handleHealth handles health check requests
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
status := map[string]any{
"status": "ok",
"running": c.IsRunning(),
"has_token": c.getAccessToken() != "",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}

File diff suppressed because it is too large Load diff

View file

@ -1,499 +0,0 @@
package wecom
import (
"bytes"
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"time"
"jane/pkg/bus"
"jane/pkg/channels"
"jane/pkg/config"
"jane/pkg/identity"
"jane/pkg/logger"
"jane/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 {
*channels.BaseChannel
config config.WeComConfig
client *http.Client
ctx context.Context
cancel context.CancelFunc
processedMsgs *MessageDeduplicator
}
// 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 := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(2048),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
// Client timeout must be >= the configured ReplyTimeout so the
// per-request context deadline is always the effective limit.
clientTimeout := 30 * time.Second
if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout {
clientTimeout = d
}
ctx, cancel := context.WithCancel(context.Background())
return &WeComBotChannel{
BaseChannel: base,
config: cfg,
client: &http.Client{Timeout: clientTimeout},
ctx: ctx,
cancel: cancel,
processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
}, nil
}
// Name returns the channel name
func (c *WeComBotChannel) Name() string {
return "wecom"
}
// Start initializes the WeCom Bot channel
func (c *WeComBotChannel) Start(ctx context.Context) error {
logger.InfoC("wecom", "Starting WeCom Bot channel...")
// Cancel the context created in the constructor to avoid a resource leak.
if c.cancel != nil {
c.cancel()
}
c.ctx, c.cancel = context.WithCancel(ctx)
c.SetRunning(true)
logger.InfoC("wecom", "WeCom Bot channel started")
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()
}
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 channels.ErrNotRunning
}
logger.DebugCF("wecom", "Sending message via webhook", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
return c.sendWebhookReply(ctx, msg.ChatID, msg.Content)
}
// WebhookPath returns the path for registering on the shared HTTP server.
func (c *WeComBotChannel) WebhookPath() string {
if c.config.WebhookPath != "" {
return c.config.WebhookPath
}
return "/webhook/wecom"
}
// ServeHTTP implements http.Handler for the shared HTTP server.
func (c *WeComBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.handleWebhook(w, r)
}
// HealthPath returns the health check endpoint path.
func (c *WeComBotChannel) HealthPath() string {
return "/health/wecom"
}
// HealthHandler handles health check requests.
func (c *WeComBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
c.handleHealth(w, r)
}
// 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 !verifySignature(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 := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
"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]any{
"error": err.Error(),
})
http.Error(w, "Invalid XML", http.StatusBadRequest)
return
}
// Verify signature
if !verifySignature(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 := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
"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]any{
"error": err.Error(),
})
http.Error(w, "Invalid message format", http.StatusBadRequest)
return
}
// Process the message with the channel's long-lived context (not the HTTP
// request context, which is canceled as soon as we return the response).
go c.processMessage(c.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]any{
"msg_type": msg.MsgType,
})
return
}
// Message deduplication: Use msg_id to prevent duplicate processing
msgID := msg.MsgID
if !c.processedMsgs.MarkMessageProcessed(msgID) {
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
"msg_id": msgID,
})
return
}
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
peer := bus.Peer{Kind: peerKind, ID: peerID}
// In group chats, apply unified group trigger filtering
if isGroupChat {
respond, cleaned := c.ShouldRespondInGroup(false, content)
if !respond {
return
}
content = cleaned
}
metadata := map[string]string{
"msg_type": msg.MsgType,
"msg_id": msg.MsgID,
"platform": "wecom",
"response_url": msg.ResponseURL,
}
if isGroupChat {
metadata["chat_id"] = msg.ChatID
metadata["sender_id"] = senderID
}
logger.DebugCF("wecom", "Received message", map[string]any{
"sender_id": senderID,
"msg_type": msg.MsgType,
"peer_kind": peerKind,
"is_group_chat": isGroupChat,
"preview": utils.Truncate(content, 50),
})
// Build sender info
sender := bus.SenderInfo{
Platform: "wecom",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("wecom", senderID),
}
if !c.IsAllowedSender(sender) {
return
}
// Handle the message through the base channel
c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender)
}
// 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")
resp, err := c.client.Do(req)
if err != nil {
return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return channels.ClassifySendError(
resp.StatusCode,
fmt.Errorf("reading webhook error response: %w", readErr),
)
}
return channels.ClassifySendError(
resp.StatusCode,
fmt.Errorf("webhook API error: %s", string(body)),
)
}
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]any{
"status": "ok",
"running": c.IsRunning(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}

View file

@ -1,750 +0,0 @@
package wecom
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"
"jane/pkg/bus"
"jane/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 := range 16 {
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 !verifySignature(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 verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification")
}
})
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
cfgEmpty := config.WeComConfig{
Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
chEmpty := &WeComBotChannel{
config: cfgEmpty,
}
if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should reject verification (fail-closed)")
}
})
}
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 := decryptMessage(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 := decryptMessage(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 := decryptMessage("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 := decryptMessage(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 := pkcs7Unpad(tt.input)
if tt.expected == nil {
// This case should return an error
if err == nil {
t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result)
}
return
}
if err != nil {
t.Errorf("pkcs7Unpad() unexpected error: %v", err)
return
}
if !bytes.Equal(result, tt.expected) {
t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected)
}
})
}
}
func 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+"&timestamp="+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&timestamp=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&timestamp="+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)
runBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder {
t.Helper()
encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
encryptedWrapper := struct {
XMLName xml.Name `xml:"xml"`
Encrypt string `xml:"Encrypt"`
}{
Encrypt: encrypted,
}
wrapperData, _ := xml.Marshal(encryptedWrapper)
timestamp := "1234567890"
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encrypted)
req := httptest.NewRequest(
http.MethodPost,
"/webhook/wecom?msg_signature="+signature+"&timestamp="+timestamp+"&nonce="+nonce,
bytes.NewReader(wrapperData),
)
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
return w
}
t.Run("valid direct message callback", func(t *testing.T) {
w := runBotMessageCallback(t, `{
"msgid": "test_msg_id_123",
"aibotid": "test_aibot_id",
"chattype": "single",
"from": {"userid": "user123"},
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
"msgtype": "text",
"text": {"content": "Hello World"}
}`)
if w.Code != http.StatusOK {
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) {
w := runBotMessageCallback(t, `{
"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"}
}`)
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+"&timestamp="+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&timestamp="+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+"&timestamp="+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+"&timestamp="+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")
}
}

View file

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

View file

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

View file

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

View file

@ -1,19 +0,0 @@
package wecom
import (
"jane/pkg/bus"
"jane/pkg/channels"
"jane/pkg/config"
)
func init() {
channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewWeComBotChannel(cfg.Channels.WeCom, b)
})
channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewWeComAppChannel(cfg.Channels.WeComApp, b)
})
channels.RegisterFactory("wecom_aibot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b)
})
}

View file

@ -266,9 +266,6 @@ type ChannelsConfig struct {
Matrix MatrixConfig `json:"matrix"` Matrix MatrixConfig `json:"matrix"`
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"`
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Pico PicoConfig `json:"pico"` Pico PicoConfig `json:"pico"`
IRC IRCConfig `json:"irc"` IRC IRCConfig `json:"irc"`
} }
@ -416,48 +413,6 @@ type OneBotConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
} }
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"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
}
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"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
}
type WeComAIBotConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
}
type PicoConfig struct { type PicoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`

View file

@ -132,40 +132,6 @@ 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,
},
WeComAIBot: WeComAIBotConfig{
Enabled: false,
Token: "",
EncodingAESKey: "",
WebhookPath: "/webhook/wecom-aibot",
AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5,
MaxSteps: 10,
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
},
Pico: PicoConfig{ Pico: PicoConfig{
Enabled: false, Enabled: false,
Token: "", Token: "",

View file

@ -24,6 +24,4 @@ var supportedChannels = map[string]bool{
"matrix": true, "matrix": true,
"line": true, "line": true,
"onebot": true, "onebot": true,
"wecom": true,
"wecom_app": true,
} }

View file

@ -20,9 +20,6 @@ var channelCatalog = []channelCatalogItem{
{Name: "line", ConfigKey: "line"}, {Name: "line", ConfigKey: "line"},
{Name: "qq", ConfigKey: "qq"}, {Name: "qq", ConfigKey: "qq"},
{Name: "onebot", ConfigKey: "onebot"}, {Name: "onebot", ConfigKey: "onebot"},
{Name: "wecom", ConfigKey: "wecom"},
{Name: "wecom_app", ConfigKey: "wecom_app"},
{Name: "wecom_aibot", ConfigKey: "wecom_aibot"},
{Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"}, {Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"},
{Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"}, {Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"},
{Name: "pico", ConfigKey: "pico"}, {Name: "pico", ConfigKey: "pico"},

View file

@ -142,14 +142,6 @@ function isConfigured(
) )
case "onebot": case "onebot":
return asString(config.ws_url) !== "" return asString(config.ws_url) !== ""
case "wecom":
return asString(config.token) !== ""
case "wecom_app":
return (
asString(config.corp_id) !== "" && asString(config.corp_secret) !== ""
)
case "wecom_aibot":
return asString(config.token) !== ""
case "whatsapp": case "whatsapp":
return asString(config.bridge_url) !== "" return asString(config.bridge_url) !== ""
case "whatsapp_native": case "whatsapp_native":
@ -189,12 +181,6 @@ function getRequiredFieldKeys(channelName: string): string[] {
return ["app_id", "app_secret"] return ["app_id", "app_secret"]
case "onebot": case "onebot":
return ["ws_url"] return ["ws_url"]
case "wecom":
return ["token"]
case "wecom_app":
return ["corp_id", "corp_secret"]
case "wecom_aibot":
return ["token"]
case "whatsapp": case "whatsapp":
return ["bridge_url"] return ["bridge_url"]
case "pico": case "pico":
@ -229,7 +215,6 @@ function getChannelDocSlug(channelName: string): string {
const CHANNELS_WITHOUT_DOCS = new Set([ const CHANNELS_WITHOUT_DOCS = new Set([
"pico", "pico",
"wecom",
"matrix", "matrix",
"irc", "irc",
"whatsapp", "whatsapp",

View file

@ -34,9 +34,6 @@ const CHANNEL_IMPORTANCE_ORDER = [
"telegram", "telegram",
"slack", "slack",
"line", "line",
"wecom",
"wecom_app",
"wecom_aibot",
"dingtalk", "dingtalk",
"qq", "qq",
"onebot", "onebot",
@ -75,9 +72,6 @@ const CHANNEL_ICON_MAP: Record<
dingtalk: IconBrandDingtalk, dingtalk: IconBrandDingtalk,
line: IconBrandLine, line: IconBrandLine,
qq: IconBrandQq, qq: IconBrandQq,
wecom: IconBrandWechat,
wecom_app: IconBrandWechat,
wecom_aibot: IconBrandWechat,
whatsapp: IconBrandWhatsapp, whatsapp: IconBrandWhatsapp,
whatsapp_native: IconBrandWhatsapp, whatsapp_native: IconBrandWhatsapp,
matrix: IconBrandMatrix, matrix: IconBrandMatrix,

View file

@ -228,9 +228,6 @@
"line": "LINE", "line": "LINE",
"qq": "QQ", "qq": "QQ",
"onebot": "OneBot", "onebot": "OneBot",
"wecom": "WeCom",
"wecom_app": "WeCom App",
"wecom_aibot": "WeCom AI Bot",
"whatsapp": "WhatsApp", "whatsapp": "WhatsApp",
"whatsapp_native": "WhatsApp Native", "whatsapp_native": "WhatsApp Native",
"pico": "Web", "pico": "Web",

View file

@ -228,9 +228,6 @@
"line": "LINE", "line": "LINE",
"qq": "QQ", "qq": "QQ",
"onebot": "OneBot", "onebot": "OneBot",
"wecom": "企业微信",
"wecom_app": "企业微信应用",
"wecom_aibot": "企业微信 AI 机器人",
"whatsapp": "WhatsApp", "whatsapp": "WhatsApp",
"whatsapp_native": "WhatsApp Native", "whatsapp_native": "WhatsApp Native",
"pico": "Web", "pico": "Web",