diff --git a/README.fr.md b/README.fr.md
index f25dc0157..8535c699c 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -291,7 +291,6 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
| **QQ** | Facile (AppID + AppSecret) |
| **DingTalk** | Moyen (identifiants de l'application) |
| **LINE** | Moyen (identifiants + URL de webhook) |
-| **WeCom AI Bot** | Moyen (Token + clé AES) |
Telegram (Recommandé)
@@ -491,116 +490,6 @@ picoclaw gateway
-
-WeCom (WeChat Work)
-
-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`.
-
-
##
Rejoignez le Réseau Social d'Agents
diff --git a/README.ja.md b/README.ja.md
index a0e9291eb..053666b5a 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -260,7 +260,6 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
| **QQ** | 簡単(AppID + AppSecret) |
| **DingTalk** | 普通(アプリ認証情報) |
| **LINE** | 普通(認証情報 + Webhook URL) |
-| **WeCom AI Bot** | 普通(Token + AES キー) |
Telegram(推奨)
@@ -456,118 +455,6 @@ picoclaw gateway
-
-WeCom (企業微信)
-
-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` によるプッシュ配信に切り替わります。
-
-
## ⚙️ 設定
diff --git a/README.md b/README.md
index b2fb79ad3..1640b5d1f 100644
--- a/README.md
+++ b/README.md
@@ -337,7 +337,6 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
-| **WeCom AI Bot** | Medium (Token + AES key) |
Telegram (Recommended)
@@ -627,118 +626,6 @@ picoclaw gateway
-
-WeCom (企业微信)
-
-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.
-
-
##
Join the Agent Social Network
diff --git a/README.pt-br.md b/README.pt-br.md
index f638e4aee..cbff2fc19 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -285,7 +285,6 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
| **QQ** | Fácil (AppID + AppSecret) |
| **DingTalk** | Médio (credenciais do app) |
| **LINE** | Médio (credenciais + webhook URL) |
-| **WeCom AI Bot** | Médio (Token + chave AES) |
Telegram (Recomendado)
@@ -485,118 +484,6 @@ picoclaw gateway
-
-WeCom (WeChat Work)
-
-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`.
-
-
##
Junte-se a Rede Social de Agentes
diff --git a/README.vi.md b/README.vi.md
index 2b255439a..d691eadbe 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -259,7 +259,6 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
| **QQ** | Dễ (AppID + AppSecret) |
| **DingTalk** | Trung bình (app credentials) |
| **LINE** | Trung bình (credentials + webhook URL) |
-| **WeCom AI Bot** | Trung bình (Token + khóa AES) |
Telegram (Khuyên dùng)
@@ -457,118 +456,6 @@ picoclaw gateway
-
-WeCom (WeChat Work)
-
-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`.
-
-
##
Tham gia Mạng xã hội Agent
diff --git a/README.zh.md b/README.zh.md
index c347c04a2..f6f75d358 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -305,7 +305,6 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) |
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/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) |
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
diff --git a/backend b/backend
new file mode 100755
index 000000000..f19485323
Binary files /dev/null and b/backend differ
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go
index b015fc480..9e0d5dd4a 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/app.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/app.go
@@ -143,8 +143,6 @@ func (s *appState) countChannels() (enabled int, total int) {
c.Matrix.Enabled,
c.LINE.Enabled,
c.OneBot.Enabled,
- c.WeCom.Enabled,
- c.WeComApp.Enabled,
}
total = len(entries)
for _, v := range entries {
@@ -438,7 +436,7 @@ func (s *appState) hasEnabledChannel() bool {
c := s.config.Channels
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
c.WhatsApp.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()) {
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go
index a8f116eb5..5fa8c9b21 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/channel.go
@@ -72,18 +72,6 @@ func (s *appState) buildChannelMenuItems() []MenuItem {
s.config.Channels.OneBot.Enabled,
func() { s.push("channel-onebot", s.onebotForm()) },
),
- channelItem(
- "WeCom",
- "WeCom bot settings",
- s.config.Channels.WeCom.Enabled,
- func() { s.push("channel-wecom", s.wecomForm()) },
- ),
- channelItem(
- "WeCom App",
- "WeCom App settings",
- s.config.Channels.WeComApp.Enabled,
- func() { s.push("channel-wecomapp", s.wecomAppForm()) },
- ),
}
}
@@ -259,68 +247,6 @@ func (s *appState) onebotForm() tview.Primitive {
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) {
return func(v bool) {
*enabledPtr = v
diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go
index 168306181..91a5eeead 100644
--- a/cmd/picoclaw/internal/gateway/helpers.go
+++ b/cmd/picoclaw/internal/gateway/helpers.go
@@ -24,7 +24,6 @@ import (
_ "jane/pkg/channels/qq"
_ "jane/pkg/channels/slack"
_ "jane/pkg/channels/telegram"
- _ "jane/pkg/channels/wecom"
_ "jane/pkg/channels/whatsapp"
_ "jane/pkg/channels/whatsapp_native"
"jane/pkg/config"
diff --git a/config/config.example.json b/config/config.example.json
index aef8dc063..e35ac853e 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -144,40 +144,6 @@
"allow_from": [],
"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": {
"enabled": false,
"server": "irc.libera.chat:6697",
diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md
deleted file mode 100644
index d210528af..000000000
--- a/docs/channels/wecom/wecom_aibot/README.zh.md
+++ /dev/null
@@ -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://: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)
diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md
deleted file mode 100644
index 0a9858107..000000000
--- a/docs/channels/wecom/wecom_app/README.zh.md
+++ /dev/null
@@ -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://:/webhook/wecom-app`
-6. 将 CorpID, Secret, AgentID 等信息填入配置文件
-
- 注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。
diff --git a/docs/channels/wecom/wecom_bot/README.zh.md b/docs/channels/wecom/wecom_bot/README.zh.md
deleted file mode 100644
index 63d9b84d6..000000000
--- a/docs/channels/wecom/wecom_bot/README.zh.md
+++ /dev/null
@@ -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)。
diff --git a/picoclaw-launcher-tui b/picoclaw-launcher-tui
new file mode 100755
index 000000000..bd6977d8b
Binary files /dev/null and b/picoclaw-launcher-tui differ
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 1a8a13bf7..d3b5f304f 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -853,8 +853,6 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
"slack": "rid-slack",
"line": "rid-line",
"onebot": "rid-onebot",
- "wecom": "rid-wecom",
- "wecom_app": "rid-wecom-app",
} {
chManager.RegisterChannel(name, &fakeChannel{id: id})
}
@@ -872,8 +870,6 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
{channel: "slack", wantID: "rid-slack"},
{channel: "line", wantID: "rid-line"},
{channel: "onebot", wantID: "rid-onebot"},
- {channel: "wecom", wantID: "rid-wecom"},
- {channel: "wecom_app", wantID: "rid-wecom-app"},
{channel: "unknown", wantID: ""},
}
diff --git a/pkg/channels/README.md b/pkg/channels/README.md
index 95602b5fb..678fedb1f 100644
--- a/pkg/channels/README.md
+++ b/pkg/channels/README.md
@@ -60,7 +60,7 @@ pkg/channels/
├── discord/
│ ├── init.go
│ └── discord.go
-├── slack/ line/ onebot/ dingtalk/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
+├── slack/ line/ onebot/ dingtalk/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
│ └── ...
pkg/bus/
@@ -1254,8 +1254,6 @@ make test # Full test suite
| `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler |
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
| `pkg/channels/dingtalk/` | `"dingtalk"` | — |
-| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker |
-| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
| `pkg/channels/qq/` | `"qq"` | — |
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) |
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) |
@@ -1369,7 +1367,6 @@ agentLoop.Stop() // Stop Agent
1. **Media cleanup temporarily disabled**: The `ReleaseAll` call in the Agent loop is commented out (`refactor(loop): disable media cleanup to prevent premature file deletion`) because session boundaries are not yet clearly defined. TTL cleanup remains active.
-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`).
@@ -1379,4 +1376,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.
-8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, 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.
\ No newline at end of file
+8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, 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.
diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md
index 08330cc6a..bc914fcc9 100644
--- a/pkg/channels/README.zh.md
+++ b/pkg/channels/README.zh.md
@@ -60,7 +60,7 @@ pkg/channels/
├── discord/
│ ├── init.go
│ └── discord.go
-├── slack/ line/ onebot/ dingtalk/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
+├── slack/ line/ onebot/ dingtalk/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
│ └── ...
pkg/bus/
@@ -1253,8 +1253,6 @@ make test # 全量测试
| `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler |
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
| `pkg/channels/dingtalk/` | `"dingtalk"` | — |
-| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker |
-| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
| `pkg/channels/qq/` | `"qq"` | — |
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) |
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) |
@@ -1368,7 +1366,6 @@ agentLoop.Stop() // 停止 Agent
1. **媒体清理暂时禁用**:Agent loop 中的 `ReleaseAll` 调用被注释掉了(`refactor(loop): disable media cleanup to prevent premature file deletion`),因为会话边界尚未明确定义。TTL 清理仍然有效。
-3. **WeCom 有两个工厂**:`"wecom"`(Bot 模式,纯 webhook)和 `"wecom_app"`(应用模式,支持 MediaSender)分别注册。两者都实现了 `WebhookHandler` 和 `HealthChecker`。
4. **Pico Protocol**:`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel,通过 WebSocket webhook (`/pico/ws`) 接收消息。
@@ -1378,4 +1375,4 @@ agentLoop.Stop() // 停止 Agent
7. **PlaceholderConfig 的配置与实现**:`PlaceholderConfig` 出现在 6 个 channel config 中(Telegram、Discord、Slack、LINE、OneBot、Pico),但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channel(Telegram、Discord、Pico)能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。
-8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。
\ No newline at end of file
+8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 0f3c23ae4..12924cd56 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -283,18 +283,6 @@ func (m *Manager) initChannels() error {
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 != "" {
m.initChannel("pico", "Pico")
}
diff --git a/pkg/channels/webhook.go b/pkg/channels/webhook.go
index 3cf27baf6..b180cd24e 100644
--- a/pkg/channels/webhook.go
+++ b/pkg/channels/webhook.go
@@ -7,7 +7,7 @@ import "net/http"
// and registers them on the shared HTTP server.
type WebhookHandler interface {
// WebhookPath returns the path to mount this handler on the shared server.
- // Examples: "/webhook/line", "/webhook/wecom"
+ // Examples: "/webhook/line"
WebhookPath() string
http.Handler // ServeHTTP(w http.ResponseWriter, r *http.Request)
}
diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go
deleted file mode 100644
index a0565dd3c..000000000
--- a/pkg/channels/wecom/aibot.go
+++ /dev/null
@@ -1,1017 +0,0 @@
-package wecom
-
-import (
- "bytes"
- "context"
- "crypto/rand"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "io"
- "math/big"
- "net/http"
- "strings"
- "sync"
- "time"
-
- "jane/pkg/bus"
- "jane/pkg/channels"
- "jane/pkg/config"
- "jane/pkg/identity"
- "jane/pkg/logger"
- "jane/pkg/utils"
-)
-
-// WeComAIBotChannel implements the Channel interface for WeCom AI Bot (企业微信智能机器人)
-type WeComAIBotChannel struct {
- *channels.BaseChannel
- config config.WeComAIBotConfig
- ctx context.Context
- cancel context.CancelFunc
- streamTasks map[string]*streamTask // streamID -> task (for poll lookups)
- chatTasks map[string][]*streamTask // chatID -> in-flight tasks queue (FIFO)
- taskMu sync.RWMutex
-}
-
-// streamTask represents a streaming task for AI Bot.
-//
-// Mutable fields (Finished, StreamClosed, StreamClosedAt) must be read/written
-// while holding WeComAIBotChannel.taskMu. Immutable fields (StreamID, ChatID,
-// ResponseURL, Question, CreatedTime, Deadline, answerCh, ctx, cancel) are set
-// once at creation and never modified, so they are safe to read without a lock.
-type streamTask struct {
- // immutable after creation
- StreamID string
- ChatID string // used by Send() to find this task
- ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once)
- Question string
- CreatedTime time.Time
- Deadline time.Time // ~30s, we close the stream here and switch to response_url
- answerCh chan string // receives agent reply from Send()
- ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine
- cancel context.CancelFunc // call on task removal to cancel ctx
-
- // mutable — guarded by WeComAIBotChannel.taskMu
- StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url
- StreamClosedAt time.Time // set when StreamClosed becomes true; used for accelerated cleanup
- Finished bool // fully done
-}
-
-// WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot
-// Ref: https://developer.work.weixin.qq.com/document/path/100719
-type WeComAIBotMessage struct {
- MsgID string `json:"msgid"`
- AIBotID string `json:"aibotid"`
- ChatID string `json:"chatid"` // only for group chat
- ChatType string `json:"chattype"` // "single" or "group"
- From struct {
- UserID string `json:"userid"`
- } `json:"from"`
- ResponseURL string `json:"response_url"` // temporary URL for proactive reply
- MsgType string `json:"msgtype"`
- // text message
- Text *struct {
- Content string `json:"content"`
- } `json:"text,omitempty"`
- // stream polling refresh
- Stream *struct {
- ID string `json:"id"`
- } `json:"stream,omitempty"`
- // image message
- Image *struct {
- URL string `json:"url"`
- } `json:"image,omitempty"`
- // mixed message (text + image)
- Mixed *struct {
- MsgItem []struct {
- MsgType string `json:"msgtype"`
- Text *struct {
- Content string `json:"content"`
- } `json:"text,omitempty"`
- Image *struct {
- URL string `json:"url"`
- } `json:"image,omitempty"`
- } `json:"msg_item"`
- } `json:"mixed,omitempty"`
- // event field
- Event *struct {
- EventType string `json:"eventtype"`
- } `json:"event,omitempty"`
-}
-
-// WeComAIBotMsgItemImage holds the image payload inside a stream message item.
-type WeComAIBotMsgItemImage struct {
- Base64 string `json:"base64"`
- MD5 string `json:"md5"`
-}
-
-// WeComAIBotMsgItem is a single item inside a stream's msg_item list.
-type WeComAIBotMsgItem struct {
- MsgType string `json:"msgtype"`
- Image *WeComAIBotMsgItemImage `json:"image,omitempty"`
-}
-
-// WeComAIBotStreamInfo represents the detailed stream content in streaming responses.
-type WeComAIBotStreamInfo struct {
- ID string `json:"id"`
- Finish bool `json:"finish"`
- Content string `json:"content,omitempty"`
- MsgItem []WeComAIBotMsgItem `json:"msg_item,omitempty"`
-}
-
-// WeComAIBotStreamResponse represents the streaming response format
-type WeComAIBotStreamResponse struct {
- MsgType string `json:"msgtype"`
- Stream WeComAIBotStreamInfo `json:"stream"`
-}
-
-// WeComAIBotEncryptedResponse represents the encrypted response wrapper
-// Fields match WXBizJsonMsgCrypt.generate() in Python SDK
-type WeComAIBotEncryptedResponse struct {
- Encrypt string `json:"encrypt"`
- MsgSignature string `json:"msgsignature"`
- Timestamp string `json:"timestamp"`
- Nonce string `json:"nonce"`
-}
-
-// NewWeComAIBotChannel creates a new WeCom AI Bot channel instance
-func NewWeComAIBotChannel(
- cfg config.WeComAIBotConfig,
- messageBus *bus.MessageBus,
-) (*WeComAIBotChannel, error) {
- if cfg.Token == "" || cfg.EncodingAESKey == "" {
- return nil, fmt.Errorf("token and encoding_aes_key are required for WeCom AI Bot")
- }
-
- base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom,
- channels.WithMaxMessageLength(2048),
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
- )
-
- return &WeComAIBotChannel{
- BaseChannel: base,
- config: cfg,
- streamTasks: make(map[string]*streamTask),
- chatTasks: make(map[string][]*streamTask),
- }, nil
-}
-
-// Name returns the channel name
-func (c *WeComAIBotChannel) Name() string {
- return "wecom_aibot"
-}
-
-// Start initializes the WeCom AI Bot channel
-func (c *WeComAIBotChannel) Start(ctx context.Context) error {
- logger.InfoC("wecom_aibot", "Starting WeCom AI Bot channel...")
-
- c.ctx, c.cancel = context.WithCancel(ctx)
-
- // Start cleanup goroutine for old tasks
- go c.cleanupLoop()
-
- c.SetRunning(true)
- logger.InfoC("wecom_aibot", "WeCom AI Bot channel started")
-
- return nil
-}
-
-// Stop gracefully stops the WeCom AI Bot channel
-func (c *WeComAIBotChannel) Stop(ctx context.Context) error {
- logger.InfoC("wecom_aibot", "Stopping WeCom AI Bot channel...")
-
- if c.cancel != nil {
- c.cancel()
- }
-
- c.SetRunning(false)
- logger.InfoC("wecom_aibot", "WeCom AI Bot channel stopped")
- return nil
-}
-
-// Send delivers the agent reply into the active streamTask for msg.ChatID.
-// It writes into the earliest unfinished task in the queue (FIFO per chatID).
-// If the stream has already closed (deadline passed), it posts directly to response_url.
-func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
- c.taskMu.Lock()
- queue := c.chatTasks[msg.ChatID]
- // Only compact Finished tasks at the head of the queue.
- // Tasks that are Finished in the middle are NOT removed here: doing a full
- // scan on every Send() call would be O(n) and is unnecessary given that
- // removeTask() always splices the task out of the queue immediately.
- // Any Finished task left stranded in the middle (e.g. due to an unexpected
- // code path) will be collected by cleanupOldTasks.
- for len(queue) > 0 && queue[0].Finished {
- queue = queue[1:]
- }
- c.chatTasks[msg.ChatID] = queue
- var task *streamTask
- var streamClosed bool
- var responseURL string
- if len(queue) > 0 {
- task = queue[0]
- // Read mutable fields while holding c.taskMu to avoid data races.
- streamClosed = task.StreamClosed
- responseURL = task.ResponseURL
- }
- c.taskMu.Unlock()
-
- if task == nil {
- logger.DebugCF(
- "wecom_aibot",
- "Send: no active task for chat (may have timed out)",
- map[string]any{
- "chat_id": msg.ChatID,
- },
- )
- return nil
- }
-
- if streamClosed {
- // Stream already ended with a "please wait" notice; send the real reply via response_url.
- // Note: task.StreamID and task.ChatID are immutable, safe to read without a lock.
- logger.InfoCF("wecom_aibot", "Sending reply via response_url", map[string]any{
- "stream_id": task.StreamID,
- "chat_id": msg.ChatID,
- })
- if responseURL != "" {
- if err := c.sendViaResponseURL(responseURL, msg.Content); err != nil {
- logger.ErrorCF("wecom_aibot", "Failed to send via response_url", map[string]any{
- "error": err,
- "stream_id": task.StreamID,
- })
- c.removeTask(task)
- return fmt.Errorf("response_url delivery failed: %w", channels.ErrSendFailed)
- }
- } else {
- logger.WarnCF("wecom_aibot", "Stream closed but no response_url available", map[string]any{
- "stream_id": task.StreamID,
- })
- }
- c.removeTask(task)
- return nil
- }
-
- // Stream still open: deliver via answerCh for the next poll response.
- select {
- case task.answerCh <- msg.Content:
- case <-task.ctx.Done():
- // Task was canceled (cleanup removed it); silently drop the reply.
- return nil
- case <-ctx.Done():
- return ctx.Err()
- }
- return nil
-}
-
-// WebhookPath returns the path for registering on the shared HTTP server
-func (c *WeComAIBotChannel) WebhookPath() string {
- if c.config.WebhookPath == "" {
- return "/webhook/wecom-aibot"
- }
- return c.config.WebhookPath
-}
-
-// ServeHTTP implements http.Handler for the shared HTTP server
-func (c *WeComAIBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- c.handleWebhook(w, r)
-}
-
-// HealthPath returns the health check endpoint path
-func (c *WeComAIBotChannel) HealthPath() string {
- return c.WebhookPath() + "/health"
-}
-
-// HealthHandler handles health check requests
-func (c *WeComAIBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
- c.handleHealth(w, r)
-}
-
-// handleWebhook handles incoming webhook requests from WeCom AI Bot
-func (c *WeComAIBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
-
- // Log all incoming requests for debugging
- logger.DebugCF("wecom_aibot", "Received webhook request", map[string]any{
- "method": r.Method,
- "path": r.URL.Path,
- "query": r.URL.RawQuery,
- })
-
- switch r.Method {
- case http.MethodGet:
- // URL verification
- c.handleVerification(ctx, w, r)
- case http.MethodPost:
- // Message callback
- c.handleMessageCallback(ctx, w, r)
- default:
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-// handleVerification handles the URL verification request from WeCom
-func (c *WeComAIBotChannel) handleVerification(
- ctx context.Context,
- w http.ResponseWriter,
- r *http.Request,
-) {
- msgSignature := r.URL.Query().Get("msg_signature")
- timestamp := r.URL.Query().Get("timestamp")
- nonce := r.URL.Query().Get("nonce")
- echostr := r.URL.Query().Get("echostr")
-
- logger.DebugCF("wecom_aibot", "URL verification request", map[string]any{
- "msg_signature": msgSignature,
- "timestamp": timestamp,
- "nonce": nonce,
- })
-
- // Verify signature
- if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
- logger.ErrorC("wecom_aibot", "Signature verification failed")
- http.Error(w, "Signature verification failed", http.StatusUnauthorized)
- return
- }
-
- // Decrypt echostr
- // For WeCom AI Bot (智能机器人), receiveid should be empty string
- decrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
- if err != nil {
- logger.ErrorCF("wecom_aibot", "Failed to decrypt echostr", map[string]any{
- "error": err,
- })
- http.Error(w, "Decryption failed", http.StatusInternalServerError)
- return
- }
-
- // Remove BOM and whitespace as per WeCom documentation
- decrypted = strings.TrimPrefix(decrypted, "\ufeff")
- decrypted = strings.TrimSpace(decrypted)
-
- logger.InfoC("wecom_aibot", "URL verification successful")
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- w.WriteHeader(http.StatusOK)
- w.Write([]byte(decrypted))
-}
-
-// handleMessageCallback handles incoming messages from WeCom AI Bot
-func (c *WeComAIBotChannel) handleMessageCallback(
- ctx context.Context,
- w http.ResponseWriter,
- r *http.Request,
-) {
- msgSignature := r.URL.Query().Get("msg_signature")
- timestamp := r.URL.Query().Get("timestamp")
- nonce := r.URL.Query().Get("nonce")
-
- // Read request body (limit to 4 MB to prevent memory exhaustion)
- const maxBodySize = 4 << 20 // 4 MB
- body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1))
- if err != nil {
- logger.ErrorCF("wecom_aibot", "Failed to read request body", map[string]any{
- "error": err,
- })
- http.Error(w, "Failed to read body", http.StatusBadRequest)
- return
- }
- if len(body) > maxBodySize {
- http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
- return
- }
-
- // Parse JSON body to get encrypted message
- // Format: {"encrypt": "base64_encrypted_string"}
- var encryptedMsg struct {
- Encrypt string `json:"encrypt"`
- }
- if unmarshalErr := json.Unmarshal(body, &encryptedMsg); unmarshalErr != nil {
- logger.ErrorCF("wecom_aibot", "Failed to parse JSON body", map[string]any{
- "error": unmarshalErr,
- "body": string(body),
- })
- http.Error(w, "Failed to parse JSON", http.StatusBadRequest)
- return
- }
-
- // Verify signature
- if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
- logger.ErrorC("wecom_aibot", "Signature verification failed")
- http.Error(w, "Signature verification failed", http.StatusUnauthorized)
- return
- }
-
- // Decrypt message
- // For WeCom AI Bot (智能机器人), receiveid is empty string
- decrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
- if err != nil {
- logger.ErrorCF("wecom_aibot", "Failed to decrypt message", map[string]any{
- "error": err,
- })
- http.Error(w, "Decryption failed", http.StatusInternalServerError)
- return
- }
-
- // Parse decrypted JSON message
- var msg WeComAIBotMessage
- if unmarshalErr := json.Unmarshal([]byte(decrypted), &msg); unmarshalErr != nil {
- logger.ErrorCF("wecom_aibot", "Failed to parse decrypted JSON", map[string]any{
- "error": unmarshalErr,
- "decrypted": decrypted,
- })
- http.Error(w, "Failed to parse message", http.StatusInternalServerError)
- return
- }
-
- logger.DebugCF("wecom_aibot", "Decrypted message", map[string]any{
- "msgtype": msg.MsgType,
- })
-
- // Process the message and get streaming response
- response := c.processMessage(ctx, msg, timestamp, nonce)
-
- // Check if response is empty (e.g. due to unsupported message type)
- if response == "" {
- response = c.encryptEmptyResponse(timestamp, nonce)
- }
-
- // Return encrypted JSON response
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- w.WriteHeader(http.StatusOK)
- w.Write([]byte(response))
-}
-
-// processMessage processes the received message and returns encrypted response
-func (c *WeComAIBotChannel) processMessage(
- ctx context.Context,
- msg WeComAIBotMessage,
- timestamp, nonce string,
-) string {
- logger.DebugCF("wecom_aibot", "Processing message", map[string]any{
- "msgtype": msg.MsgType,
- })
-
- switch msg.MsgType {
- case "text":
- return c.handleTextMessage(ctx, msg, timestamp, nonce)
- case "stream":
- return c.handleStreamMessage(ctx, msg, timestamp, nonce)
- case "image":
- return c.handleImageMessage(ctx, msg, timestamp, nonce)
- case "mixed":
- return c.handleMixedMessage(ctx, msg, timestamp, nonce)
- case "event":
- return c.handleEventMessage(ctx, msg, timestamp, nonce)
- default:
- logger.WarnCF("wecom_aibot", "Unsupported message type", map[string]any{
- "msgtype": msg.MsgType,
- })
- return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
- MsgType: "stream",
- Stream: WeComAIBotStreamInfo{
- ID: c.generateStreamID(),
- Finish: true,
- Content: "Unsupported message type: " + msg.MsgType,
- },
- })
- }
-}
-
-// handleTextMessage handles text messages by starting a new streaming task
-func (c *WeComAIBotChannel) handleTextMessage(
- ctx context.Context,
- msg WeComAIBotMessage,
- timestamp, nonce string,
-) string {
- if msg.Text == nil {
- logger.ErrorC("wecom_aibot", "text message missing text field")
- return c.encryptEmptyResponse(timestamp, nonce)
- }
-
- content := msg.Text.Content
- userID := msg.From.UserID
- if userID == "" {
- userID = "unknown"
- }
-
- // chatID: group chat uses chatid, single chat uses userid
- chatID := msg.ChatID
- if chatID == "" {
- chatID = userID
- }
-
- streamID := c.generateStreamID()
-
- // WeCom stops sending stream-refresh callbacks after 6 minutes.
- // Set a slightly shorter deadline so we can send a timeout notice before it gives up.
- deadline := time.Now().Add(30 * time.Second)
-
- // Each task gets its own context derived from the channel lifetime context.
- // Canceling taskCancel interrupts the agent goroutine when the task is removed.
- taskCtx, taskCancel := context.WithCancel(c.ctx)
-
- task := &streamTask{
- StreamID: streamID,
- ChatID: chatID,
- ResponseURL: msg.ResponseURL,
- Question: content,
- CreatedTime: time.Now(),
- Deadline: deadline,
- Finished: false,
- answerCh: make(chan string, 1),
- ctx: taskCtx,
- cancel: taskCancel,
- }
-
- c.taskMu.Lock()
- c.streamTasks[streamID] = task
- c.chatTasks[chatID] = append(c.chatTasks[chatID], task)
- c.taskMu.Unlock()
-
- // Publish to agent asynchronously; agent will call Send() with reply.
- // Use task.ctx (not c.ctx) so the agent goroutine is canceled when the task is removed.
- go func() {
- sender := bus.SenderInfo{
- Platform: "wecom_aibot",
- PlatformID: userID,
- CanonicalID: identity.BuildCanonicalID("wecom_aibot", userID),
- DisplayName: userID,
- }
- peerKind := "direct"
- if msg.ChatType == "group" {
- peerKind = "group"
- }
- peer := bus.Peer{Kind: peerKind, ID: chatID}
- metadata := map[string]string{
- "channel": "wecom_aibot",
- "chat_type": msg.ChatType,
- "msg_type": "text",
- "msgid": msg.MsgID,
- "aibotid": msg.AIBotID,
- "stream_id": streamID,
- "response_url": msg.ResponseURL,
- }
- c.HandleMessage(task.ctx, peer, msg.MsgID, userID, chatID,
- content, nil, metadata, sender)
- }()
-
- // Return first streaming response immediately (finish=false, content empty)
- return c.getStreamResponse(task, timestamp, nonce)
-}
-
-// handleStreamMessage handles stream polling requests
-func (c *WeComAIBotChannel) handleStreamMessage(
- ctx context.Context,
- msg WeComAIBotMessage,
- timestamp, nonce string,
-) string {
- if msg.Stream == nil {
- logger.ErrorC("wecom_aibot", "Stream message missing stream field")
- return c.encryptEmptyResponse(timestamp, nonce)
- }
-
- streamID := msg.Stream.ID
-
- c.taskMu.RLock()
- task, exists := c.streamTasks[streamID]
- c.taskMu.RUnlock()
-
- if !exists {
- logger.DebugCF(
- "wecom_aibot",
- "Stream task not found (may be from previous session)",
- map[string]any{
- "stream_id": streamID,
- },
- )
- return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{
- MsgType: "stream",
- Stream: WeComAIBotStreamInfo{
- ID: streamID,
- Finish: true,
- Content: "Task not found or already finished. Please resend your message to start a new session.",
- },
- })
- }
-
- // Get next response
- return c.getStreamResponse(task, timestamp, nonce)
-}
-
-// handleImageMessage handles image messages
-func (c *WeComAIBotChannel) handleImageMessage(
- ctx context.Context,
- msg WeComAIBotMessage,
- timestamp, nonce string,
-) string {
- logger.WarnC("wecom_aibot", "Image message type not yet fully implemented")
- if msg.Image == nil {
- logger.ErrorC("wecom_aibot", "Image message missing image field")
- return c.encryptEmptyResponse(timestamp, nonce)
- }
-
- imageURL := msg.Image.URL
-
- // For now, just acknowledge receipt without echoing the image
- return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
- MsgType: "stream",
- Stream: WeComAIBotStreamInfo{
- ID: c.generateStreamID(),
- Finish: true,
- Content: fmt.Sprintf(
- "Image received (URL: %s), but image messages are not yet supported",
- imageURL,
- ),
- },
- })
-}
-
-// handleMixedMessage handles mixed (text + image) messages
-func (c *WeComAIBotChannel) handleMixedMessage(
- ctx context.Context,
- msg WeComAIBotMessage,
- timestamp, nonce string,
-) string {
- logger.WarnC("wecom_aibot", "Mixed message type not yet fully implemented")
- return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
- MsgType: "stream",
- Stream: WeComAIBotStreamInfo{
- ID: c.generateStreamID(),
- Finish: true,
- Content: "Mixed message type is not yet supported",
- },
- })
-}
-
-// handleEventMessage handles event messages
-func (c *WeComAIBotChannel) handleEventMessage(
- ctx context.Context,
- msg WeComAIBotMessage,
- timestamp, nonce string,
-) string {
- eventType := ""
- if msg.Event != nil {
- eventType = msg.Event.EventType
- }
- logger.DebugCF("wecom_aibot", "Received event", map[string]any{
- "event_type": eventType,
- })
-
- // Send welcome message when user opens the chat window
- if eventType == "enter_chat" && c.config.WelcomeMessage != "" {
- streamID := c.generateStreamID()
- return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{
- MsgType: "stream",
- Stream: WeComAIBotStreamInfo{
- ID: streamID,
- Finish: true,
- Content: c.config.WelcomeMessage,
- },
- })
- }
-
- return c.encryptEmptyResponse(timestamp, nonce)
-}
-
-// getStreamResponse gets the next streaming response for a task.
-// - If agent replied: return finish=true with the real answer.
-// - If deadline passed: return finish=true with a "please wait" notice, keep task alive for response_url.
-// - Otherwise: return finish=false (empty), client will poll again.
-func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce string) string {
- var content string
- var finish bool
- var closeStreamOnly bool // close stream but do NOT remove task (response_url still pending)
-
- select {
- case answer := <-task.answerCh:
- // Agent replied before deadline — normal finish.
- content = answer
- finish = true
- default:
- if time.Now().After(task.Deadline) {
- // Deadline reached: close the stream with a notice, then wait for agent via response_url.
- content = "⏳ Processing, please wait. The results will be sent shortly."
- finish = true
- closeStreamOnly = true
- logger.InfoCF(
- "wecom_aibot",
- "Stream deadline reached, switching to response_url mode",
- map[string]any{
- "stream_id": task.StreamID,
- "chat_id": task.ChatID,
- "response_url": task.ResponseURL != "",
- },
- )
- }
- // else: still waiting, return finish=false
- }
-
- if finish && !closeStreamOnly {
- // Normal finish: remove from all maps.
- c.removeTask(task)
- } else if closeStreamOnly {
- // Mark stream as closed and remove from streamTasks under a single lock
- // to keep StreamClosed/StreamClosedAt consistent with map membership.
- c.taskMu.Lock()
- task.StreamClosed = true
- task.StreamClosedAt = time.Now()
- delete(c.streamTasks, task.StreamID)
- c.taskMu.Unlock()
- }
-
- response := WeComAIBotStreamResponse{
- MsgType: "stream",
- Stream: WeComAIBotStreamInfo{
- ID: task.StreamID,
- Finish: finish,
- Content: content,
- },
- }
-
- return c.encryptResponse(task.StreamID, timestamp, nonce, response)
-}
-
-// removeTask removes a task from both streamTasks and chatTasks, marks it finished,
-// and cancels its context to interrupt the associated agent goroutine.
-func (c *WeComAIBotChannel) removeTask(task *streamTask) {
- // Cancel first so the agent goroutine stops as soon as possible,
- // before we acquire the write lock.
- task.cancel()
-
- c.taskMu.Lock()
- task.Finished = true // written under c.taskMu, consistent with all readers
- delete(c.streamTasks, task.StreamID)
- queue := c.chatTasks[task.ChatID]
- for i, t := range queue {
- if t == task {
- c.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...)
- break
- }
- }
- if len(c.chatTasks[task.ChatID]) == 0 {
- delete(c.chatTasks, task.ChatID)
- }
- c.taskMu.Unlock()
-}
-
-// sendViaResponseURL posts a markdown reply to the WeCom response_url.
-// response_url is valid for 1 hour and can only be used once per callback.
-// Returned errors are wrapped with channels.ErrRateLimit, channels.ErrTemporary,
-// or channels.ErrSendFailed so the manager can apply the right retry policy.
-func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) error {
- payload := map[string]any{
- "msgtype": "markdown",
- "markdown": map[string]string{
- "content": content,
- },
- }
- body, err := json.Marshal(payload)
- if err != nil {
- return fmt.Errorf("failed to marshal payload: %w", err)
- }
-
- ctx, cancel := context.WithTimeout(c.ctx, 15*time.Second)
- defer cancel()
-
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, responseURL, bytes.NewBuffer(body))
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("Content-Type", "application/json; charset=utf-8")
-
- client := &http.Client{Timeout: 15 * time.Second}
- resp, err := client.Do(req)
- if err != nil {
- return fmt.Errorf("post to response_url failed: %w: %w", channels.ErrTemporary, err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode == http.StatusOK {
- return nil
- }
-
- respBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err)
- }
- switch {
- case resp.StatusCode == http.StatusTooManyRequests:
- return fmt.Errorf("response_url rate limited (%d): %s: %w",
- resp.StatusCode, respBody, channels.ErrRateLimit)
- case resp.StatusCode >= 500:
- return fmt.Errorf("response_url server error (%d): %s: %w",
- resp.StatusCode, respBody, channels.ErrTemporary)
- default:
- return fmt.Errorf("response_url returned %d: %s: %w",
- resp.StatusCode, respBody, channels.ErrSendFailed)
- }
-}
-
-// encryptResponse encrypts a streaming response
-func (c *WeComAIBotChannel) encryptResponse(
- streamID, timestamp, nonce string,
- response WeComAIBotStreamResponse,
-) string {
- // Marshal response to JSON
- plaintext, err := json.Marshal(response)
- if err != nil {
- logger.ErrorCF("wecom_aibot", "Failed to marshal response", map[string]any{
- "error": err,
- })
- return ""
- }
-
- logger.DebugCF("wecom_aibot", "Encrypting response", map[string]any{
- "stream_id": streamID,
- "finish": response.Stream.Finish,
- "preview": utils.Truncate(response.Stream.Content, 100),
- })
-
- // Encrypt message
- encrypted, err := c.encryptMessage(string(plaintext), "")
- if err != nil {
- logger.ErrorCF("wecom_aibot", "Failed to encrypt message", map[string]any{
- "error": err,
- })
- return ""
- }
-
- // Generate signature
- signature := computeSignature(c.config.Token, timestamp, nonce, encrypted)
-
- // Build encrypted response
- encryptedResp := WeComAIBotEncryptedResponse{
- Encrypt: encrypted,
- MsgSignature: signature,
- Timestamp: timestamp,
- Nonce: nonce,
- }
-
- respJSON, err := json.Marshal(encryptedResp)
- if err != nil {
- logger.ErrorCF("wecom_aibot", "Failed to marshal encrypted response", map[string]any{
- "error": err,
- })
- return ""
- }
-
- logger.DebugCF("wecom_aibot", "Response encrypted", map[string]any{
- "stream_id": streamID,
- })
-
- return string(respJSON)
-}
-
-// encryptEmptyResponse returns a minimal valid encrypted response
-func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string {
- // Construct a zero-value stream response and encrypt it so that
- // WeCom always receives a syntactically valid encrypted JSON object.
- emptyResp := WeComAIBotStreamResponse{}
- return c.encryptResponse("", timestamp, nonce, emptyResp)
-}
-
-// encryptMessage encrypts a plain text message for WeCom AI Bot
-func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) {
- aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey)
- if err != nil {
- return "", err
- }
-
- frame, err := packWeComFrame(plaintext, receiveid)
- if err != nil {
- return "", err
- }
-
- // PKCS7 padding then AES-CBC encrypt
- paddedFrame := pkcs7Pad(frame, blockSize)
- ciphertext, err := encryptAESCBC(aesKey, paddedFrame)
- if err != nil {
- return "", err
- }
-
- return base64.StdEncoding.EncodeToString(ciphertext), nil
-}
-
-// generateStreamID generates a random stream ID
-func (c *WeComAIBotChannel) generateStreamID() string {
- const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
- b := make([]byte, 10)
- for i := range b {
- n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
- b[i] = letters[n.Int64()]
- }
- return string(b)
-}
-
-// cleanupLoop periodically cleans up old streaming tasks
-func (c *WeComAIBotChannel) cleanupLoop() {
- ticker := time.NewTicker(5 * time.Minute)
- defer ticker.Stop()
-
- for {
- select {
- case <-ticker.C:
- c.cleanupOldTasks()
- case <-c.ctx.Done():
- return
- }
- }
-}
-
-// cleanupOldTasks removes tasks that have exceeded their expected lifetime:
-// - Active tasks (in streamTasks): cleaned up after 1 hour (response_url validity window).
-// - StreamClosed tasks (in chatTasks only): cleaned up after streamClosedGracePeriod.
-// These tasks are waiting for the agent to call Send() via response_url. If the agent
-// crashes or times out without calling Send(), we must not let them accumulate indefinitely.
-// The grace period is generous enough to cover typical LLM latency but far shorter than 1 hour,
-// preventing chatTasks from filling up when many requests time out in quick succession.
-const (
- streamClosedGracePeriod = 10 * time.Minute // max wait for agent after stream closes
- taskMaxLifetime = 1 * time.Hour // absolute max (≈ response_url validity)
-)
-
-func (c *WeComAIBotChannel) cleanupOldTasks() {
- c.taskMu.Lock()
- defer c.taskMu.Unlock()
-
- now := time.Now()
- cutoff := now.Add(-taskMaxLifetime)
- for id, task := range c.streamTasks {
- if task.CreatedTime.Before(cutoff) {
- delete(c.streamTasks, id)
- task.cancel() // interrupt agent goroutine still waiting for LLM
- queue := c.chatTasks[task.ChatID]
- for i, t := range queue {
- if t == task {
- c.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...)
- break
- }
- }
- if len(c.chatTasks[task.ChatID]) == 0 {
- delete(c.chatTasks, task.ChatID)
- }
- logger.DebugCF("wecom_aibot", "Cleaned up expired task", map[string]any{
- "stream_id": id,
- })
- }
- }
- // Clean up StreamClosed tasks from chatTasks.
- // Two expiry conditions are checked:
- // 1. Absolute expiry: task was created more than taskMaxLifetime ago.
- // 2. Grace expiry: stream closed more than streamClosedGracePeriod ago
- // (agent had enough time to reply; it is not coming back).
- for chatID, queue := range c.chatTasks {
- filtered := queue[:0]
- for i, t := range queue {
- absoluteExpired := t.CreatedTime.Before(cutoff)
- graceExpired := t.StreamClosed &&
- !t.StreamClosedAt.IsZero() &&
- t.StreamClosedAt.Before(now.Add(-streamClosedGracePeriod))
- if t.Finished {
- // Finished tasks should have been removed by removeTask().
- // Finding one here (especially not at position 0) means an
- // unexpected code path left it stranded, causing the queue to
- // grow silently. Log a warning so it is visible, then drop it.
- if i > 0 {
- logger.WarnCF("wecom_aibot",
- "Found stranded Finished task in the middle of chatTasks queue; "+
- "this should not happen — removeTask() should have spliced it out",
- map[string]any{
- "chat_id": chatID,
- "stream_id": t.StreamID,
- "position": i,
- })
- }
- // The task is already finished; its context was already canceled
- // by removeTask(), so no further action is required.
- continue
- } else if !absoluteExpired && !graceExpired {
- filtered = append(filtered, t)
- } else {
- t.cancel() // cancel any lingering agent goroutine
- }
- }
- if len(filtered) == 0 {
- delete(c.chatTasks, chatID)
- } else {
- c.chatTasks[chatID] = filtered
- }
- }
-}
-
-// handleHealth handles health check requests
-func (c *WeComAIBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
- status := "ok"
- if !c.IsRunning() {
- status = "not running"
- }
-
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- json.NewEncoder(w).Encode(map[string]string{
- "status": status,
- })
-}
diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go
deleted file mode 100644
index 6abb9b886..000000000
--- a/pkg/channels/wecom/aibot_test.go
+++ /dev/null
@@ -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")
- }
-}
diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go
deleted file mode 100644
index 99939a09d..000000000
--- a/pkg/channels/wecom/app.go
+++ /dev/null
@@ -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)
-}
diff --git a/pkg/channels/wecom/app_test.go b/pkg/channels/wecom/app_test.go
deleted file mode 100644
index 648c038a8..000000000
--- a/pkg/channels/wecom/app_test.go
+++ /dev/null
@@ -1,1069 +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"
- "time"
-
- "jane/pkg/bus"
- "jane/pkg/config"
-)
-
-// generateTestAESKeyApp generates a valid test AES key for WeCom App
-func generateTestAESKeyApp() 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 + 1)
- }
- // Return base64 encoded key without padding
- return base64.StdEncoding.EncodeToString(key)[:43]
-}
-
-// encryptTestMessageApp encrypts a message for testing WeCom App
-func encryptTestMessageApp(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 + corp_id
- random := make([]byte, 0, 16)
- for i := range 16 {
- random = append(random, byte(i+1))
- }
-
- msgBytes := []byte(message)
- corpID := []byte("test_corp_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, corpID...)
-
- // 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
-}
-
-// generateSignatureApp generates a signature for testing WeCom App
-func generateSignatureApp(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 TestNewWeComAppChannel(t *testing.T) {
- msgBus := bus.NewMessageBus()
-
- t.Run("missing corp_id", func(t *testing.T) {
- cfg := config.WeComAppConfig{
- CorpID: "",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- }
- _, err := NewWeComAppChannel(cfg, msgBus)
- if err == nil {
- t.Error("expected error for missing corp_id, got nil")
- }
- })
-
- t.Run("missing corp_secret", func(t *testing.T) {
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "",
- AgentID: 1000002,
- }
- _, err := NewWeComAppChannel(cfg, msgBus)
- if err == nil {
- t.Error("expected error for missing corp_secret, got nil")
- }
- })
-
- t.Run("missing agent_id", func(t *testing.T) {
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 0,
- }
- _, err := NewWeComAppChannel(cfg, msgBus)
- if err == nil {
- t.Error("expected error for missing agent_id, got nil")
- }
- })
-
- t.Run("valid config", func(t *testing.T) {
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- AllowFrom: []string{"user1", "user2"},
- }
- ch, err := NewWeComAppChannel(cfg, msgBus)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if ch.Name() != "wecom_app" {
- t.Errorf("Name() = %q, want %q", ch.Name(), "wecom_app")
- }
- if ch.IsRunning() {
- t.Error("new channel should not be running")
- }
- })
-}
-
-func TestWeComAppChannelIsAllowed(t *testing.T) {
- msgBus := bus.NewMessageBus()
-
- t.Run("empty allowlist allows all", func(t *testing.T) {
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- AllowFrom: []string{},
- }
- ch, _ := NewWeComAppChannel(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.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- AllowFrom: []string{"allowed_user"},
- }
- ch, _ := NewWeComAppChannel(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 TestWeComAppVerifySignature(t *testing.T) {
- msgBus := bus.NewMessageBus()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- Token: "test_token",
- }
- ch, _ := NewWeComAppChannel(cfg, msgBus)
-
- t.Run("valid signature", func(t *testing.T) {
- timestamp := "1234567890"
- nonce := "test_nonce"
- msgEncrypt := "test_message"
- expectedSig := generateSignatureApp("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.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- Token: "",
- }
- chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
-
- if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
- t.Error("empty token should reject verification (fail-closed)")
- }
- })
-}
-
-func TestWeComAppDecryptMessage(t *testing.T) {
- msgBus := bus.NewMessageBus()
-
- t.Run("decrypt without AES key", func(t *testing.T) {
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- EncodingAESKey: "",
- }
- ch, _ := NewWeComAppChannel(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 := generateTestAESKeyApp()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- EncodingAESKey: aesKey,
- }
- ch, _ := NewWeComAppChannel(cfg, msgBus)
-
- originalMsg := "Hello"
- encrypted, err := encryptTestMessageApp(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.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- EncodingAESKey: "",
- }
- ch, _ := NewWeComAppChannel(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.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- EncodingAESKey: "invalid_key",
- }
- ch, _ := NewWeComAppChannel(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")
- }
- })
-
- t.Run("ciphertext too short", func(t *testing.T) {
- aesKey := generateTestAESKeyApp()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- EncodingAESKey: aesKey,
- }
- ch, _ := NewWeComAppChannel(cfg, msgBus)
-
- // Encrypt a very short message that results in ciphertext less than block size
- shortData := make([]byte, 8)
- _, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)
- if err == nil {
- t.Error("expected error for short ciphertext, got nil")
- }
- })
-}
-
-func TestWeComAppHandleVerification(t *testing.T) {
- msgBus := bus.NewMessageBus()
- aesKey := generateTestAESKeyApp()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- Token: "test_token",
- EncodingAESKey: aesKey,
- }
- ch, _ := NewWeComAppChannel(cfg, msgBus)
-
- t.Run("valid verification request", func(t *testing.T) {
- echostr := "test_echostr_123"
- encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey)
- timestamp := "1234567890"
- nonce := "test_nonce"
- signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr)
-
- req := httptest.NewRequest(
- http.MethodGet,
- "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
- nil,
- )
- w := httptest.NewRecorder()
-
- ch.handleVerification(context.Background(), w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
- }
- if w.Body.String() != echostr {
- t.Errorf("response body = %q, want %q", w.Body.String(), echostr)
- }
- })
-
- t.Run("missing parameters", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=sig×tamp=ts", nil)
- w := httptest.NewRecorder()
-
- ch.handleVerification(context.Background(), w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
- }
- })
-
- t.Run("invalid signature", func(t *testing.T) {
- echostr := "test_echostr"
- encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey)
- timestamp := "1234567890"
- nonce := "test_nonce"
-
- req := httptest.NewRequest(
- http.MethodGet,
- "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
- nil,
- )
- w := httptest.NewRecorder()
-
- ch.handleVerification(context.Background(), w, req)
-
- if w.Code != http.StatusForbidden {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
- }
- })
-}
-
-func TestWeComAppHandleMessageCallback(t *testing.T) {
- msgBus := bus.NewMessageBus()
- aesKey := generateTestAESKeyApp()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- Token: "test_token",
- EncodingAESKey: aesKey,
- }
- ch, _ := NewWeComAppChannel(cfg, msgBus)
-
- t.Run("valid message callback", func(t *testing.T) {
- // Create XML message
- xmlMsg := WeComXMLMessage{
- ToUserName: "corp_id",
- FromUserName: "user123",
- CreateTime: 1234567890,
- MsgType: "text",
- Content: "Hello World",
- MsgId: 123456,
- AgentID: 1000002,
- }
- xmlData, _ := xml.Marshal(xmlMsg)
-
- // Encrypt message
- encrypted, _ := encryptTestMessageApp(string(xmlData), aesKey)
-
- // Create encrypted XML wrapper
- encryptedWrapper := struct {
- XMLName xml.Name `xml:"xml"`
- Encrypt string `xml:"Encrypt"`
- }{
- Encrypt: encrypted,
- }
- wrapperData, _ := xml.Marshal(encryptedWrapper)
-
- timestamp := "1234567890"
- nonce := "test_nonce"
- signature := generateSignatureApp("test_token", timestamp, nonce, encrypted)
-
- req := httptest.NewRequest(
- http.MethodPost,
- "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
- bytes.NewReader(wrapperData),
- )
- w := httptest.NewRecorder()
-
- ch.handleMessageCallback(context.Background(), w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
- }
- if w.Body.String() != "success" {
- t.Errorf("response body = %q, want %q", w.Body.String(), "success")
- }
- })
-
- t.Run("missing parameters", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?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 := generateSignatureApp("test_token", timestamp, nonce, "")
-
- req := httptest.NewRequest(
- http.MethodPost,
- "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
- strings.NewReader("invalid xml"),
- )
- w := httptest.NewRecorder()
-
- ch.handleMessageCallback(context.Background(), w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
- }
- })
-
- t.Run("invalid signature", func(t *testing.T) {
- encryptedWrapper := struct {
- XMLName xml.Name `xml:"xml"`
- Encrypt string `xml:"Encrypt"`
- }{
- Encrypt: "encrypted_data",
- }
- wrapperData, _ := xml.Marshal(encryptedWrapper)
-
- timestamp := "1234567890"
- nonce := "test_nonce"
-
- req := httptest.NewRequest(
- http.MethodPost,
- "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce,
- bytes.NewReader(wrapperData),
- )
- w := httptest.NewRecorder()
-
- ch.handleMessageCallback(context.Background(), w, req)
-
- if w.Code != http.StatusForbidden {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
- }
- })
-}
-
-func TestWeComAppProcessMessage(t *testing.T) {
- msgBus := bus.NewMessageBus()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- }
- ch, _ := NewWeComAppChannel(cfg, msgBus)
-
- t.Run("process text message", func(t *testing.T) {
- msg := WeComXMLMessage{
- ToUserName: "corp_id",
- FromUserName: "user123",
- CreateTime: 1234567890,
- MsgType: "text",
- Content: "Hello World",
- MsgId: 123456,
- AgentID: 1000002,
- }
-
- // Should not panic
- ch.processMessage(context.Background(), msg)
- })
-
- t.Run("process image message", func(t *testing.T) {
- msg := WeComXMLMessage{
- ToUserName: "corp_id",
- FromUserName: "user123",
- CreateTime: 1234567890,
- MsgType: "image",
- PicUrl: "https://example.com/image.jpg",
- MediaId: "media_123",
- MsgId: 123456,
- AgentID: 1000002,
- }
-
- // Should not panic
- ch.processMessage(context.Background(), msg)
- })
-
- t.Run("process voice message", func(t *testing.T) {
- msg := WeComXMLMessage{
- ToUserName: "corp_id",
- FromUserName: "user123",
- CreateTime: 1234567890,
- MsgType: "voice",
- MediaId: "media_123",
- Format: "amr",
- MsgId: 123456,
- AgentID: 1000002,
- }
-
- // Should not panic
- ch.processMessage(context.Background(), msg)
- })
-
- t.Run("skip unsupported message type", func(t *testing.T) {
- msg := WeComXMLMessage{
- ToUserName: "corp_id",
- FromUserName: "user123",
- CreateTime: 1234567890,
- MsgType: "video",
- MsgId: 123456,
- AgentID: 1000002,
- }
-
- // Should not panic
- ch.processMessage(context.Background(), msg)
- })
-
- t.Run("process event message", func(t *testing.T) {
- msg := WeComXMLMessage{
- ToUserName: "corp_id",
- FromUserName: "user123",
- CreateTime: 1234567890,
- MsgType: "event",
- Event: "subscribe",
- MsgId: 123456,
- AgentID: 1000002,
- }
-
- // Should not panic
- ch.processMessage(context.Background(), msg)
- })
-}
-
-func TestWeComAppHandleWebhook(t *testing.T) {
- msgBus := bus.NewMessageBus()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- Token: "test_token",
- }
- ch, _ := NewWeComAppChannel(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 := generateSignatureApp("test_token", timestamp, nonce, encoded)
-
- req := httptest.NewRequest(
- http.MethodGet,
- "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded,
- nil,
- )
- w := httptest.NewRecorder()
-
- ch.handleWebhook(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
- }
- })
-
- t.Run("POST request calls message callback", func(t *testing.T) {
- encryptedWrapper := struct {
- XMLName xml.Name `xml:"xml"`
- Encrypt string `xml:"Encrypt"`
- }{
- Encrypt: base64.StdEncoding.EncodeToString([]byte("test")),
- }
- wrapperData, _ := xml.Marshal(encryptedWrapper)
-
- timestamp := "1234567890"
- nonce := "test_nonce"
- signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
-
- req := httptest.NewRequest(
- http.MethodPost,
- "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
- bytes.NewReader(wrapperData),
- )
- w := httptest.NewRecorder()
-
- ch.handleWebhook(w, req)
-
- // Should not be method not allowed
- if w.Code == http.StatusMethodNotAllowed {
- t.Error("POST request should not return Method Not Allowed")
- }
- })
-
- t.Run("unsupported method", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodPut, "/webhook/wecom-app", 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 TestWeComAppHandleHealth(t *testing.T) {
- msgBus := bus.NewMessageBus()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- }
- ch, _ := NewWeComAppChannel(cfg, msgBus)
-
- req := httptest.NewRequest(http.MethodGet, "/health/wecom-app", 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") || !strings.Contains(body, "has_token") {
- t.Errorf("response body should contain status, running, and has_token fields, got: %s", body)
- }
-}
-
-func TestWeComAppAccessToken(t *testing.T) {
- msgBus := bus.NewMessageBus()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- }
- ch, _ := NewWeComAppChannel(cfg, msgBus)
-
- t.Run("get empty access token initially", func(t *testing.T) {
- token := ch.getAccessToken()
- if token != "" {
- t.Errorf("getAccessToken() = %q, want empty string", token)
- }
- })
-
- t.Run("set and get access token", func(t *testing.T) {
- ch.tokenMu.Lock()
- ch.accessToken = "test_token_123"
- ch.tokenExpiry = time.Now().Add(1 * time.Hour)
- ch.tokenMu.Unlock()
-
- token := ch.getAccessToken()
- if token != "test_token_123" {
- t.Errorf("getAccessToken() = %q, want %q", token, "test_token_123")
- }
- })
-
- t.Run("expired token returns empty", func(t *testing.T) {
- ch.tokenMu.Lock()
- ch.accessToken = "expired_token"
- ch.tokenExpiry = time.Now().Add(-1 * time.Hour)
- ch.tokenMu.Unlock()
-
- token := ch.getAccessToken()
- if token != "" {
- t.Errorf("getAccessToken() = %q, want empty string for expired token", token)
- }
- })
-}
-
-func TestWeComAppMessageStructures(t *testing.T) {
- t.Run("WeComTextMessage structure", func(t *testing.T) {
- msg := WeComTextMessage{
- ToUser: "user123",
- MsgType: "text",
- AgentID: 1000002,
- }
- msg.Text.Content = "Hello World"
-
- if msg.ToUser != "user123" {
- t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123")
- }
- if msg.MsgType != "text" {
- t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
- }
- if msg.AgentID != 1000002 {
- t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002)
- }
- if msg.Text.Content != "Hello World" {
- t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
- }
-
- // Test JSON marshaling
- jsonData, err := json.Marshal(msg)
- if err != nil {
- t.Fatalf("failed to marshal JSON: %v", err)
- }
-
- var unmarshaled WeComTextMessage
- err = json.Unmarshal(jsonData, &unmarshaled)
- if err != nil {
- t.Fatalf("failed to unmarshal JSON: %v", err)
- }
-
- if unmarshaled.ToUser != msg.ToUser {
- t.Errorf("JSON round-trip failed for ToUser")
- }
- })
-
- t.Run("WeComMarkdownMessage structure", func(t *testing.T) {
- msg := WeComMarkdownMessage{
- ToUser: "user123",
- MsgType: "markdown",
- AgentID: 1000002,
- }
- msg.Markdown.Content = "# Hello\nWorld"
-
- if msg.Markdown.Content != "# Hello\nWorld" {
- t.Errorf("Markdown.Content = %q, want %q", msg.Markdown.Content, "# Hello\nWorld")
- }
-
- // Test JSON marshaling
- jsonData, err := json.Marshal(msg)
- if err != nil {
- t.Fatalf("failed to marshal JSON: %v", err)
- }
-
- if !bytes.Contains(jsonData, []byte("markdown")) {
- t.Error("JSON should contain 'markdown' field")
- }
- })
-
- t.Run("WeComImageMessage structure", func(t *testing.T) {
- msg := WeComImageMessage{
- ToUser: "user123",
- MsgType: "image",
- AgentID: 1000002,
- }
- msg.Image.MediaID = "media_123456"
-
- if msg.Image.MediaID != "media_123456" {
- t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456")
- }
- if msg.ToUser != "user123" {
- t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123")
- }
- if msg.MsgType != "image" {
- t.Errorf("MsgType = %q, want %q", msg.MsgType, "image")
- }
- if msg.AgentID != 1000002 {
- t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002)
- }
- })
-
- t.Run("WeComAccessTokenResponse structure", func(t *testing.T) {
- jsonData := `{
- "errcode": 0,
- "errmsg": "ok",
- "access_token": "test_access_token",
- "expires_in": 7200
- }`
-
- var resp WeComAccessTokenResponse
- err := json.Unmarshal([]byte(jsonData), &resp)
- if err != nil {
- t.Fatalf("failed to unmarshal JSON: %v", err)
- }
-
- if resp.ErrCode != 0 {
- t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0)
- }
- if resp.ErrMsg != "ok" {
- t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok")
- }
- if resp.AccessToken != "test_access_token" {
- t.Errorf("AccessToken = %q, want %q", resp.AccessToken, "test_access_token")
- }
- if resp.ExpiresIn != 7200 {
- t.Errorf("ExpiresIn = %d, want %d", resp.ExpiresIn, 7200)
- }
- })
-
- t.Run("WeComSendMessageResponse structure", func(t *testing.T) {
- jsonData := `{
- "errcode": 0,
- "errmsg": "ok",
- "invaliduser": "",
- "invalidparty": "",
- "invalidtag": ""
- }`
-
- var resp WeComSendMessageResponse
- err := json.Unmarshal([]byte(jsonData), &resp)
- if err != nil {
- t.Fatalf("failed to unmarshal JSON: %v", err)
- }
-
- if resp.ErrCode != 0 {
- t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0)
- }
- if resp.ErrMsg != "ok" {
- t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok")
- }
- })
-}
-
-func TestWeComAppXMLMessageStructure(t *testing.T) {
- xmlData := `
-
-
-
- 1234567890
-
-
- 1234567890123456
- 1000002
-`
-
- var msg WeComXMLMessage
- err := xml.Unmarshal([]byte(xmlData), &msg)
- if err != nil {
- t.Fatalf("failed to unmarshal XML: %v", err)
- }
-
- if msg.ToUserName != "corp_id" {
- t.Errorf("ToUserName = %q, want %q", msg.ToUserName, "corp_id")
- }
- if msg.FromUserName != "user123" {
- t.Errorf("FromUserName = %q, want %q", msg.FromUserName, "user123")
- }
- if msg.CreateTime != 1234567890 {
- t.Errorf("CreateTime = %d, want %d", msg.CreateTime, 1234567890)
- }
- if msg.MsgType != "text" {
- t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
- }
- if msg.Content != "Hello World" {
- t.Errorf("Content = %q, want %q", msg.Content, "Hello World")
- }
- if msg.MsgId != 1234567890123456 {
- t.Errorf("MsgId = %d, want %d", msg.MsgId, 1234567890123456)
- }
- if msg.AgentID != 1000002 {
- t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002)
- }
-}
-
-func TestWeComAppXMLMessageImage(t *testing.T) {
- xmlData := `
-
-
-
- 1234567890
-
-
-
- 1234567890123456
- 1000002
-`
-
- var msg WeComXMLMessage
- err := xml.Unmarshal([]byte(xmlData), &msg)
- if err != nil {
- t.Fatalf("failed to unmarshal XML: %v", err)
- }
-
- if msg.MsgType != "image" {
- t.Errorf("MsgType = %q, want %q", msg.MsgType, "image")
- }
- if msg.PicUrl != "https://example.com/image.jpg" {
- t.Errorf("PicUrl = %q, want %q", msg.PicUrl, "https://example.com/image.jpg")
- }
- if msg.MediaId != "media_123" {
- t.Errorf("MediaId = %q, want %q", msg.MediaId, "media_123")
- }
-}
-
-func TestWeComAppXMLMessageVoice(t *testing.T) {
- xmlData := `
-
-
-
- 1234567890
-
-
-
- 1234567890123456
- 1000002
-`
-
- var msg WeComXMLMessage
- err := xml.Unmarshal([]byte(xmlData), &msg)
- if err != nil {
- t.Fatalf("failed to unmarshal XML: %v", err)
- }
-
- if msg.MsgType != "voice" {
- t.Errorf("MsgType = %q, want %q", msg.MsgType, "voice")
- }
- if msg.Format != "amr" {
- t.Errorf("Format = %q, want %q", msg.Format, "amr")
- }
-}
-
-func TestWeComAppXMLMessageLocation(t *testing.T) {
- xmlData := `
-
-
-
- 1234567890
-
- 39.9042
- 116.4074
- 16
-
- 1234567890123456
- 1000002
-`
-
- var msg WeComXMLMessage
- err := xml.Unmarshal([]byte(xmlData), &msg)
- if err != nil {
- t.Fatalf("failed to unmarshal XML: %v", err)
- }
-
- if msg.MsgType != "location" {
- t.Errorf("MsgType = %q, want %q", msg.MsgType, "location")
- }
- if msg.LocationX != 39.9042 {
- t.Errorf("LocationX = %f, want %f", msg.LocationX, 39.9042)
- }
- if msg.LocationY != 116.4074 {
- t.Errorf("LocationY = %f, want %f", msg.LocationY, 116.4074)
- }
- if msg.Scale != 16 {
- t.Errorf("Scale = %d, want %d", msg.Scale, 16)
- }
- if msg.Label != "Beijing" {
- t.Errorf("Label = %q, want %q", msg.Label, "Beijing")
- }
-}
-
-func TestWeComAppXMLMessageLink(t *testing.T) {
- xmlData := `
-
-
-
- 1234567890
-
-
-
-
- 1234567890123456
- 1000002
-`
-
- var msg WeComXMLMessage
- err := xml.Unmarshal([]byte(xmlData), &msg)
- if err != nil {
- t.Fatalf("failed to unmarshal XML: %v", err)
- }
-
- if msg.MsgType != "link" {
- t.Errorf("MsgType = %q, want %q", msg.MsgType, "link")
- }
- if msg.Title != "Link Title" {
- t.Errorf("Title = %q, want %q", msg.Title, "Link Title")
- }
- if msg.Description != "Link Description" {
- t.Errorf("Description = %q, want %q", msg.Description, "Link Description")
- }
- if msg.Url != "https://example.com" {
- t.Errorf("Url = %q, want %q", msg.Url, "https://example.com")
- }
-}
-
-func TestWeComAppXMLMessageEvent(t *testing.T) {
- xmlData := `
-
-
-
- 1234567890
-
-
-
- 1000002
-`
-
- var msg WeComXMLMessage
- err := xml.Unmarshal([]byte(xmlData), &msg)
- if err != nil {
- t.Fatalf("failed to unmarshal XML: %v", err)
- }
-
- if msg.MsgType != "event" {
- t.Errorf("MsgType = %q, want %q", msg.MsgType, "event")
- }
- if msg.Event != "subscribe" {
- t.Errorf("Event = %q, want %q", msg.Event, "subscribe")
- }
- if msg.EventKey != "event_key_123" {
- t.Errorf("EventKey = %q, want %q", msg.EventKey, "event_key_123")
- }
-}
diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go
deleted file mode 100644
index 27351d531..000000000
--- a/pkg/channels/wecom/bot.go
+++ /dev/null
@@ -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)
-}
diff --git a/pkg/channels/wecom/bot_test.go b/pkg/channels/wecom/bot_test.go
deleted file mode 100644
index adfdd6d56..000000000
--- a/pkg/channels/wecom/bot_test.go
+++ /dev/null
@@ -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 := "Hello"
- 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+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
- nil,
- )
- w := httptest.NewRecorder()
-
- ch.handleVerification(context.Background(), w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
- }
- if w.Body.String() != echostr {
- t.Errorf("response body = %q, want %q", w.Body.String(), echostr)
- }
- })
-
- t.Run("missing parameters", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=sig×tamp=ts", nil)
- w := httptest.NewRecorder()
-
- ch.handleVerification(context.Background(), w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
- }
- })
-
- t.Run("invalid signature", func(t *testing.T) {
- echostr := "test_echostr"
- encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
- timestamp := "1234567890"
- nonce := "test_nonce"
-
- req := httptest.NewRequest(
- http.MethodGet,
- "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
- nil,
- )
- w := httptest.NewRecorder()
-
- ch.handleVerification(context.Background(), w, req)
-
- if w.Code != http.StatusForbidden {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
- }
- })
-}
-
-func TestWeComBotHandleMessageCallback(t *testing.T) {
- msgBus := bus.NewMessageBus()
- aesKey := generateTestAESKey()
- cfg := config.WeComConfig{
- Token: "test_token",
- EncodingAESKey: aesKey,
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
- ch, _ := NewWeComBotChannel(cfg, msgBus)
-
- 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+"×tamp="+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+"×tamp="+timestamp+"&nonce="+nonce,
- strings.NewReader("invalid xml"),
- )
- w := httptest.NewRecorder()
-
- ch.handleMessageCallback(context.Background(), w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
- }
- })
-
- t.Run("invalid signature", func(t *testing.T) {
- encryptedWrapper := struct {
- XMLName xml.Name `xml:"xml"`
- Encrypt string `xml:"Encrypt"`
- }{
- Encrypt: "encrypted_data",
- }
- wrapperData, _ := xml.Marshal(encryptedWrapper)
-
- timestamp := "1234567890"
- nonce := "test_nonce"
-
- req := httptest.NewRequest(
- http.MethodPost,
- "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce,
- bytes.NewReader(wrapperData),
- )
- w := httptest.NewRecorder()
-
- ch.handleMessageCallback(context.Background(), w, req)
-
- if w.Code != http.StatusForbidden {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
- }
- })
-}
-
-func TestWeComBotProcessMessage(t *testing.T) {
- msgBus := bus.NewMessageBus()
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
- ch, _ := NewWeComBotChannel(cfg, msgBus)
-
- t.Run("process direct text message", func(t *testing.T) {
- msg := WeComBotMessage{
- MsgID: "test_msg_id_123",
- AIBotID: "test_aibot_id",
- ChatType: "single",
- ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- MsgType: "text",
- }
- msg.From.UserID = "user123"
- msg.Text.Content = "Hello World"
-
- // Should not panic
- ch.processMessage(context.Background(), msg)
- })
-
- t.Run("process group text message", func(t *testing.T) {
- msg := WeComBotMessage{
- MsgID: "test_msg_id_456",
- AIBotID: "test_aibot_id",
- ChatID: "group_chat_id_123",
- ChatType: "group",
- ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- MsgType: "text",
- }
- msg.From.UserID = "user456"
- msg.Text.Content = "Hello Group"
-
- // Should not panic
- ch.processMessage(context.Background(), msg)
- })
-
- t.Run("process voice message", func(t *testing.T) {
- msg := WeComBotMessage{
- MsgID: "test_msg_id_789",
- AIBotID: "test_aibot_id",
- ChatType: "single",
- ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- MsgType: "voice",
- }
- msg.From.UserID = "user123"
- msg.Voice.Content = "Voice message text"
-
- // Should not panic
- ch.processMessage(context.Background(), msg)
- })
-
- t.Run("skip unsupported message type", func(t *testing.T) {
- msg := WeComBotMessage{
- MsgID: "test_msg_id_000",
- AIBotID: "test_aibot_id",
- ChatType: "single",
- ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- MsgType: "video",
- }
- msg.From.UserID = "user123"
-
- // Should not panic
- ch.processMessage(context.Background(), msg)
- })
-}
-
-func TestWeComBotHandleWebhook(t *testing.T) {
- msgBus := bus.NewMessageBus()
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
- ch, _ := NewWeComBotChannel(cfg, msgBus)
-
- t.Run("GET request calls verification", func(t *testing.T) {
- echostr := "test_echostr"
- encoded := base64.StdEncoding.EncodeToString([]byte(echostr))
- timestamp := "1234567890"
- nonce := "test_nonce"
- signature := generateSignature("test_token", timestamp, nonce, encoded)
-
- req := httptest.NewRequest(
- http.MethodGet,
- "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded,
- nil,
- )
- w := httptest.NewRecorder()
-
- ch.handleWebhook(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
- }
- })
-
- t.Run("POST request calls message callback", func(t *testing.T) {
- encryptedWrapper := struct {
- XMLName xml.Name `xml:"xml"`
- Encrypt string `xml:"Encrypt"`
- }{
- Encrypt: base64.StdEncoding.EncodeToString([]byte("test")),
- }
- wrapperData, _ := xml.Marshal(encryptedWrapper)
-
- timestamp := "1234567890"
- nonce := "test_nonce"
- signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
-
- req := httptest.NewRequest(
- http.MethodPost,
- "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
- bytes.NewReader(wrapperData),
- )
- w := httptest.NewRecorder()
-
- ch.handleWebhook(w, req)
-
- // Should not be method not allowed
- if w.Code == http.StatusMethodNotAllowed {
- t.Error("POST request should not return Method Not Allowed")
- }
- })
-
- t.Run("unsupported method", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodPut, "/webhook/wecom", nil)
- w := httptest.NewRecorder()
-
- ch.handleWebhook(w, req)
-
- if w.Code != http.StatusMethodNotAllowed {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed)
- }
- })
-}
-
-func TestWeComBotHandleHealth(t *testing.T) {
- msgBus := bus.NewMessageBus()
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
- ch, _ := NewWeComBotChannel(cfg, msgBus)
-
- req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil)
- w := httptest.NewRecorder()
-
- ch.handleHealth(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
- }
-
- contentType := w.Header().Get("Content-Type")
- if contentType != "application/json" {
- t.Errorf("Content-Type = %q, want %q", contentType, "application/json")
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "status") || !strings.Contains(body, "running") {
- t.Errorf("response body should contain status and running fields, got: %s", body)
- }
-}
-
-func TestWeComBotReplyMessage(t *testing.T) {
- msg := WeComBotReplyMessage{
- MsgType: "text",
- }
- msg.Text.Content = "Hello World"
-
- if msg.MsgType != "text" {
- t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
- }
- if msg.Text.Content != "Hello World" {
- t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
- }
-}
-
-func TestWeComBotMessageStructure(t *testing.T) {
- jsonData := `{
- "msgid": "test_msg_id_123",
- "aibotid": "test_aibot_id",
- "chatid": "group_chat_id_123",
- "chattype": "group",
- "from": {"userid": "user123"},
- "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- "msgtype": "text",
- "text": {"content": "Hello World"}
- }`
-
- var msg WeComBotMessage
- err := json.Unmarshal([]byte(jsonData), &msg)
- if err != nil {
- t.Fatalf("failed to unmarshal JSON: %v", err)
- }
-
- if msg.MsgID != "test_msg_id_123" {
- t.Errorf("MsgID = %q, want %q", msg.MsgID, "test_msg_id_123")
- }
- if msg.AIBotID != "test_aibot_id" {
- t.Errorf("AIBotID = %q, want %q", msg.AIBotID, "test_aibot_id")
- }
- if msg.ChatID != "group_chat_id_123" {
- t.Errorf("ChatID = %q, want %q", msg.ChatID, "group_chat_id_123")
- }
- if msg.ChatType != "group" {
- t.Errorf("ChatType = %q, want %q", msg.ChatType, "group")
- }
- if msg.From.UserID != "user123" {
- t.Errorf("From.UserID = %q, want %q", msg.From.UserID, "user123")
- }
- if msg.MsgType != "text" {
- t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
- }
- if msg.Text.Content != "Hello World" {
- t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
- }
-}
diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go
deleted file mode 100644
index 9a622a2fc..000000000
--- a/pkg/channels/wecom/common.go
+++ /dev/null
@@ -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
-}
diff --git a/pkg/channels/wecom/dedupe.go b/pkg/channels/wecom/dedupe.go
deleted file mode 100644
index 865be668e..000000000
--- a/pkg/channels/wecom/dedupe.go
+++ /dev/null
@@ -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
-}
diff --git a/pkg/channels/wecom/dedupe_test.go b/pkg/channels/wecom/dedupe_test.go
deleted file mode 100644
index 10dff4cfe..000000000
--- a/pkg/channels/wecom/dedupe_test.go
+++ /dev/null
@@ -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")
- }
-}
diff --git a/pkg/channels/wecom/init.go b/pkg/channels/wecom/init.go
deleted file mode 100644
index 8f2513399..000000000
--- a/pkg/channels/wecom/init.go
+++ /dev/null
@@ -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)
- })
-}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 0bafb23eb..93e37d5fc 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -265,9 +265,6 @@ type ChannelsConfig struct {
Matrix MatrixConfig `json:"matrix"`
LINE LINEConfig `json:"line"`
OneBot OneBotConfig `json:"onebot"`
- WeCom WeComConfig `json:"wecom"`
- WeComApp WeComAppConfig `json:"wecom_app"`
- WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Pico PicoConfig `json:"pico"`
IRC IRCConfig `json:"irc"`
}
@@ -402,48 +399,6 @@ type OneBotConfig struct {
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 {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 855d13073..4424b0eb1 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -131,40 +131,6 @@ func DefaultConfig() *Config {
GroupTriggerPrefix: []string{},
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{
Enabled: false,
Token: "",
diff --git a/pkg/migrate/sources/openclaw/common.go b/pkg/migrate/sources/openclaw/common.go
index 8fbd0e533..eb6cc0391 100644
--- a/pkg/migrate/sources/openclaw/common.go
+++ b/pkg/migrate/sources/openclaw/common.go
@@ -23,6 +23,4 @@ var supportedChannels = map[string]bool{
"matrix": true,
"line": true,
"onebot": true,
- "wecom": true,
- "wecom_app": true,
}
diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go
index 84b0c8662..8d65a3b0f 100644
--- a/web/backend/api/channels.go
+++ b/web/backend/api/channels.go
@@ -19,9 +19,6 @@ var channelCatalog = []channelCatalogItem{
{Name: "line", ConfigKey: "line"},
{Name: "qq", ConfigKey: "qq"},
{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_native", ConfigKey: "whatsapp", Variant: "native"},
{Name: "pico", ConfigKey: "pico"},
diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx
index d7925c340..1063173b4 100644
--- a/web/frontend/src/components/channels/channel-config-page.tsx
+++ b/web/frontend/src/components/channels/channel-config-page.tsx
@@ -137,14 +137,6 @@ function isConfigured(
)
case "onebot":
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":
return asString(config.bridge_url) !== ""
case "whatsapp_native":
@@ -182,12 +174,6 @@ function getRequiredFieldKeys(channelName: string): string[] {
return ["app_id", "app_secret"]
case "onebot":
return ["ws_url"]
- case "wecom":
- return ["token"]
- case "wecom_app":
- return ["corp_id", "corp_secret"]
- case "wecom_aibot":
- return ["token"]
case "whatsapp":
return ["bridge_url"]
case "pico":
@@ -222,7 +208,6 @@ function getChannelDocSlug(channelName: string): string {
const CHANNELS_WITHOUT_DOCS = new Set([
"pico",
- "wecom",
"matrix",
"irc",
"whatsapp",
diff --git a/web/frontend/src/hooks/use-sidebar-channels.ts b/web/frontend/src/hooks/use-sidebar-channels.ts
index 2d85653d2..e5e9186a5 100644
--- a/web/frontend/src/hooks/use-sidebar-channels.ts
+++ b/web/frontend/src/hooks/use-sidebar-channels.ts
@@ -33,9 +33,6 @@ const CHANNEL_IMPORTANCE_ORDER = [
"telegram",
"slack",
"line",
- "wecom",
- "wecom_app",
- "wecom_aibot",
"dingtalk",
"qq",
"onebot",
@@ -60,9 +57,6 @@ const CHANNEL_ICON_MAP: Record<
dingtalk: IconBrandDingtalk,
line: IconBrandLine,
qq: IconBrandQq,
- wecom: IconBrandWechat,
- wecom_app: IconBrandWechat,
- wecom_aibot: IconBrandWechat,
whatsapp: IconBrandWhatsapp,
whatsapp_native: IconBrandWhatsapp,
matrix: IconBrandMatrix,
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index 7266358d0..1e4ccfe73 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -227,9 +227,6 @@
"line": "LINE",
"qq": "QQ",
"onebot": "OneBot",
- "wecom": "WeCom",
- "wecom_app": "WeCom App",
- "wecom_aibot": "WeCom AI Bot",
"whatsapp": "WhatsApp",
"whatsapp_native": "WhatsApp Native",
"pico": "Web",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index 1a0c46f9a..31d13671d 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -227,9 +227,6 @@
"line": "LINE",
"qq": "QQ",
"onebot": "OneBot",
- "wecom": "企业微信",
- "wecom_app": "企业微信应用",
- "wecom_aibot": "企业微信 AI 机器人",
"whatsapp": "WhatsApp",
"whatsapp_native": "WhatsApp Native",
"pico": "Web",