From 2e445b7b07bb1d4960bbad6e3a7427a9b1086bbd Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Wed, 4 Mar 2026 22:50:17 +0000
Subject: [PATCH] feat: remove unsupported messaging channels
Removed all instant messaging channels and documentation except for WhatsApp and Email. This simplifies the application and improves security by reducing the attack surface.
Changes:
- Deleted all channel packages under pkg/channels/ except whatsapp and whatsapp_native.
- Removed configs for deleted channels in config.go and defaults.go.
- Removed initialization logic in pkg/channels/manager.go and cmd/picoclaw/internal/gateway/helpers.go.
- Removed UI menus for deleted channels in cmd/picoclaw-launcher-tui/.
- Removed migration logic in pkg/migrate/sources/openclaw/openclaw_config.go.
- Updated documentation (READMEs and ROADMAP) to reflect the removal of channels.
Co-authored-by: tekewin <1421294+tekewin@users.noreply.github.com>
---
README.fr.md | 430 +------
README.ja.md | 377 +-----
README.md | 451 +------
README.pt-br.md | 425 +------
README.vi.md | 425 +------
README.zh.md | 25 +-
ROADMAP.md | 2 +-
cmd/picoclaw-launcher-tui/internal/ui/app.go | 8 +-
.../internal/ui/channel.go | 321 +----
cmd/picoclaw/internal/gateway/helpers.go | 11 -
go.mod | 30 +-
go.sum | 157 ---
pkg/channels/dingtalk/dingtalk.go | 227 ----
pkg/channels/dingtalk/init.go | 13 -
pkg/channels/discord/discord.go | 521 --------
pkg/channels/discord/discord_test.go | 91 --
pkg/channels/discord/init.go | 13 -
pkg/channels/feishu/common.go | 86 --
pkg/channels/feishu/common_test.go | 292 -----
pkg/channels/feishu/feishu_32.go | 61 -
pkg/channels/feishu/feishu_64.go | 818 ------------
pkg/channels/feishu/feishu_64_test.go | 256 ----
pkg/channels/feishu/init.go | 13 -
pkg/channels/line/init.go | 13 -
pkg/channels/line/line.go | 673 ----------
pkg/channels/maixcam/init.go | 13 -
pkg/channels/maixcam/maixcam.go | 289 -----
pkg/channels/manager.go | 52 -
pkg/channels/onebot/init.go | 13 -
pkg/channels/onebot/onebot.go | 1105 -----------------
pkg/channels/pico/init.go | 13 -
pkg/channels/pico/pico.go | 462 -------
pkg/channels/pico/protocol.go | 46 -
pkg/channels/qq/init.go | 13 -
pkg/channels/qq/qq.go | 290 -----
pkg/channels/slack/init.go | 13 -
pkg/channels/slack/slack.go | 532 --------
pkg/channels/slack/slack_test.go | 174 ---
pkg/channels/telegram/init.go | 13 -
pkg/channels/telegram/telegram.go | 769 ------------
pkg/channels/telegram/telegram_commands.go | 156 ---
pkg/channels/wecom/aibot.go | 1014 ---------------
pkg/channels/wecom/aibot_test.go | 210 ----
pkg/channels/wecom/app.go | 738 -----------
pkg/channels/wecom/app_test.go | 1069 ----------------
pkg/channels/wecom/bot.go | 490 --------
pkg/channels/wecom/bot_test.go | 751 -----------
pkg/channels/wecom/common.go | 199 ---
pkg/channels/wecom/dedupe.go | 54 -
pkg/channels/wecom/dedupe_test.go | 83 --
pkg/channels/wecom/init.go | 19 -
pkg/config/config.go | 181 +--
pkg/config/config_test.go | 11 +-
pkg/config/defaults.go | 109 --
.../sources/openclaw/openclaw_config.go | 45 -
.../sources/openclaw/openclaw_config_test.go | 18 -
56 files changed, 62 insertions(+), 14621 deletions(-)
delete mode 100644 pkg/channels/dingtalk/dingtalk.go
delete mode 100644 pkg/channels/dingtalk/init.go
delete mode 100644 pkg/channels/discord/discord.go
delete mode 100644 pkg/channels/discord/discord_test.go
delete mode 100644 pkg/channels/discord/init.go
delete mode 100644 pkg/channels/feishu/common.go
delete mode 100644 pkg/channels/feishu/common_test.go
delete mode 100644 pkg/channels/feishu/feishu_32.go
delete mode 100644 pkg/channels/feishu/feishu_64.go
delete mode 100644 pkg/channels/feishu/feishu_64_test.go
delete mode 100644 pkg/channels/feishu/init.go
delete mode 100644 pkg/channels/line/init.go
delete mode 100644 pkg/channels/line/line.go
delete mode 100644 pkg/channels/maixcam/init.go
delete mode 100644 pkg/channels/maixcam/maixcam.go
delete mode 100644 pkg/channels/onebot/init.go
delete mode 100644 pkg/channels/onebot/onebot.go
delete mode 100644 pkg/channels/pico/init.go
delete mode 100644 pkg/channels/pico/pico.go
delete mode 100644 pkg/channels/pico/protocol.go
delete mode 100644 pkg/channels/qq/init.go
delete mode 100644 pkg/channels/qq/qq.go
delete mode 100644 pkg/channels/slack/init.go
delete mode 100644 pkg/channels/slack/slack.go
delete mode 100644 pkg/channels/slack/slack_test.go
delete mode 100644 pkg/channels/telegram/init.go
delete mode 100644 pkg/channels/telegram/telegram.go
delete mode 100644 pkg/channels/telegram/telegram_commands.go
delete mode 100644 pkg/channels/wecom/aibot.go
delete mode 100644 pkg/channels/wecom/aibot_test.go
delete mode 100644 pkg/channels/wecom/app.go
delete mode 100644 pkg/channels/wecom/app_test.go
delete mode 100644 pkg/channels/wecom/bot.go
delete mode 100644 pkg/channels/wecom/bot_test.go
delete mode 100644 pkg/channels/wecom/common.go
delete mode 100644 pkg/channels/wecom/dedupe.go
delete mode 100644 pkg/channels/wecom/dedupe_test.go
delete mode 100644 pkg/channels/wecom/init.go
diff --git a/README.fr.md b/README.fr.md
index 320aa9e22..178980f54 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -235,10 +235,11 @@ picoclaw onboard
}
},
"channels": {
- "telegram": {
+ "whatsapp": {
"enabled": true,
- "token": "VOTRE_TOKEN_BOT",
- "allow_from": ["VOTRE_USER_ID"]
+ "use_native": true,
+ "session_store_path": "",
+ "allow_from": []
}
},
"tools": {
@@ -279,325 +280,17 @@ Et voilà ! Vous avez un assistant IA fonctionnel en 2 minutes.
## 💬 Applications de Chat
-Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
+Discutez avec votre PicoClaw via WhatsApp.
| Canal | Configuration |
| ------------ | -------------------------------------- |
-| **Telegram** | Facile (juste un token) |
-| **Discord** | Facile (token bot + intents) |
-| **QQ** | Facile (AppID + AppSecret) |
-| **DingTalk** | Moyen (identifiants de l'application) |
-| **LINE** | Moyen (identifiants + URL de webhook) |
-| **WeCom AI Bot** | Moyen (Token + clé AES) |
+| **WhatsApp** | Facile (natif: scan QR; ou bridge URL) |
-
-Telegram (Recommandé)
-**1. Créer un bot**
-* Ouvrez Telegram, recherchez `@BotFather`
-* Envoyez `/newbot`, suivez les instructions
-* Copiez le token
-**2. Configurer**
-```json
-{
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "VOTRE_TOKEN_BOT",
- "allow_from": ["VOTRE_USER_ID"]
- }
- }
-}
-```
-> Obtenez votre User ID via `@userinfobot` sur Telegram.
-
-**3. Lancer**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-Discord
-
-**1. Créer un bot**
-
-* Rendez-vous sur
-* Créez une application → Bot → Add Bot
-* Copiez le token du bot
-
-**2. Activer les intents**
-
-* Dans les paramètres du Bot, activez **MESSAGE CONTENT INTENT**
-* (Optionnel) Activez **SERVER MEMBERS INTENT** si vous souhaitez utiliser des listes d'autorisation basées sur les données des membres
-
-**3. Obtenir votre User ID**
-
-* Paramètres Discord → Avancé → activez le **Mode Développeur**
-* Clic droit sur votre avatar → **Copier l'identifiant**
-
-**4. Configurer**
-
-```json
-{
- "channels": {
- "discord": {
- "enabled": true,
- "token": "VOTRE_TOKEN_BOT",
- "allow_from": ["VOTRE_USER_ID"]
- }
- }
-}
-```
-
-**5. Inviter le bot**
-
-* OAuth2 → URL Generator
-* Scopes : `bot`
-* Permissions du Bot : `Send Messages`, `Read Message History`
-* Ouvrez l'URL d'invitation générée et ajoutez le bot à votre serveur
-
-**6. Lancer**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-QQ
-
-**1. Créer un bot**
-
-- Rendez-vous sur la [QQ Open Platform](https://q.qq.com/#)
-- Créez une application → Obtenez l'**AppID** et l'**AppSecret**
-
-**2. Configurer**
-
-```json
-{
- "channels": {
- "qq": {
- "enabled": true,
- "app_id": "VOTRE_APP_ID",
- "app_secret": "VOTRE_APP_SECRET",
- "allow_from": []
- }
- }
-}
-```
-
-> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des numéros QQ pour restreindre l'accès.
-
-**3. Lancer**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-DingTalk
-
-**1. Créer un bot**
-
-* Rendez-vous sur la [Open Platform](https://open.dingtalk.com/)
-* Créez une application interne
-* Copiez le Client ID et le Client Secret
-
-**2. Configurer**
-
-```json
-{
- "channels": {
- "dingtalk": {
- "enabled": true,
- "client_id": "VOTRE_CLIENT_ID",
- "client_secret": "VOTRE_CLIENT_SECRET",
- "allow_from": []
- }
- }
-}
-```
-
-> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des identifiants pour restreindre l'accès.
-
-**3. Lancer**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-LINE
-
-**1. Créer un Compte Officiel LINE**
-
-- Rendez-vous sur la [LINE Developers Console](https://developers.line.biz/)
-- Créez un provider → Créez un canal Messaging API
-- Copiez le **Channel Secret** et le **Channel Access Token**
-
-**2. Configurer**
-
-```json
-{
- "channels": {
- "line": {
- "enabled": true,
- "channel_secret": "VOTRE_CHANNEL_SECRET",
- "channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN",
- "webhook_path": "/webhook/line",
- "allow_from": []
- }
- }
-}
-```
-
-**3. Configurer l'URL du Webhook**
-
-LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel :
-
-```bash
-# Exemple avec ngrok (tunnel vers le serveur Gateway partagé)
-ngrok http 18790
-```
-
-Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**.
-
-> **Note** : Le webhook LINE est servi par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Si vous utilisez ngrok ou un proxy inverse, faites pointer le tunnel vers le port `18790`.
-
-**4. Lancer**
-
-```bash
-picoclaw gateway
-```
-
-> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original.
-
-> **Docker Compose** : Si vous avez besoin d'exposer le webhook LINE via Docker, mappez le port du Gateway partagé (par défaut `18790`) vers l'hôte, par exemple `ports: ["18790:18790"]`. Notez que le serveur Gateway sert les webhooks de tous les canaux à partir de ce port.
-
-
-
-
-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
@@ -841,114 +534,7 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique
| `cerebras` | LLM (Cerebras) | [cerebras.ai](https://api.cerebras.ai/v1) |
| `groq` | LLM + **Transcription vocale** (Whisper) | [console.groq.com](https://console.groq.com) |
-
-Configuration Zhipu
-**1. Obtenir la clé API**
-
-* Obtenez la [clé API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
-
-**2. Configurer**
-
-```json
-{
- "agents": {
- "defaults": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
- }
- },
- "providers": {
- "zhipu": {
- "api_key": "Votre Clé API",
- "api_base": "https://open.bigmodel.cn/api/paas/v4"
- }
- }
-}
-```
-
-**3. Lancer**
-
-```bash
-picoclaw agent -m "Bonjour, comment ça va ?"
-```
-
-
-
-
-Exemple de configuration complète
-
-```json
-{
- "agents": {
- "defaults": {
- "model": "anthropic/claude-opus-4-5"
- }
- },
- "providers": {
- "openrouter": {
- "api_key": "sk-or-v1-xxx"
- },
- "groq": {
- "api_key": "gsk_xxx"
- }
- },
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "123456:ABC...",
- "allow_from": ["123456789"]
- },
- "discord": {
- "enabled": true,
- "token": "",
- "allow_from": [""]
- },
- "whatsapp": {
- "enabled": false
- },
- "feishu": {
- "enabled": false,
- "app_id": "cli_xxx",
- "app_secret": "xxx",
- "encrypt_key": "",
- "verification_token": "",
- "allow_from": []
- },
- "qq": {
- "enabled": false,
- "app_id": "",
- "app_secret": "",
- "allow_from": []
- }
- },
- "tools": {
- "web": {
- "brave": {
- "enabled": false,
- "api_key": "BSA...",
- "max_results": 5
- },
- "duckduckgo": {
- "enabled": true,
- "max_results": 5
- }
- },
- "cron": {
- "exec_timeout_minutes": 5
- }
- },
- "heartbeat": {
- "enabled": true,
- "interval": 30
- }
-}
-```
-
-
### Configuration de Modèle (model_list)
@@ -1189,10 +775,6 @@ Ajoutez la clé dans `~/.picoclaw/config.json` si vous utilisez Brave :
Certains fournisseurs (comme Zhipu) disposent d'un filtrage de contenu. Essayez de reformuler votre requête ou utilisez un modèle différent.
-### Le bot Telegram affiche « Conflict: terminated by other getUpdates »
-
-Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assurez-vous qu'un seul `picoclaw gateway` fonctionne à la fois.
-
---
## 📝 Comparaison des Clés API
diff --git a/README.ja.md b/README.ja.md
index ea6bc7e72..f0f3d74dc 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -197,9 +197,10 @@ picoclaw onboard
}
},
"channels": {
- "telegram": {
+ "whatsapp": {
"enabled": true,
- "token": "YOUR_TELEGRAM_BOT_TOKEN",
+ "use_native": true,
+ "session_store_path": "",
"allow_from": []
}
},
@@ -248,323 +249,18 @@ picoclaw agent -m "What is 2+2?"
## 💬 チャットアプリ
-Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話できます
+WhatsApp で PicoClaw と会話できます。
| チャネル | セットアップ |
|---------|------------|
-| **Telegram** | 簡単(トークンのみ) |
-| **Discord** | 簡単(Bot トークン + Intents) |
-| **QQ** | 簡単(AppID + AppSecret) |
-| **DingTalk** | 普通(アプリ認証情報) |
-| **LINE** | 普通(認証情報 + Webhook URL) |
-| **WeCom AI Bot** | 普通(Token + AES キー) |
+| **WhatsApp** | 簡単 (ネイティブ: QR スキャン; または bridge URL) |
-
-Telegram(推奨)
-**1. Bot を作成**
-- Telegram を開き、`@BotFather` を検索
-- `/newbot` を送信、プロンプトに従う
-- トークンをコピー
-**2. 設定**
-```json
-{
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-```
-> ユーザー ID は Telegram の `@userinfobot` から取得できます。
-**3. 起動**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-Discord
-
-**1. Bot を作成**
-- https://discord.com/developers/applications にアクセス
-- アプリケーションを作成 → Bot → Add Bot
-- Bot トークンをコピー
-
-**2. Intents を有効化**
-- Bot の設定画面で **MESSAGE CONTENT INTENT** を有効化
-- (任意)**SERVER MEMBERS INTENT** も有効化
-
-**3. ユーザー ID を取得**
-- Discord 設定 → 詳細設定 → **開発者モード** を有効化
-- 自分のアバターを右クリック → **ユーザーIDをコピー**
-
-**4. 設定**
-
-```json
-{
- "channels": {
- "discord": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-```
-
-**5. Bot を招待**
-- OAuth2 → URL Generator
-- Scopes: `bot`
-- Bot Permissions: `Send Messages`, `Read Message History`
-- 生成された招待 URL を開き、サーバーに Bot を追加
-
-**6. 起動**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-QQ
-
-**1. Bot を作成**
-
-- [QQ オープンプラットフォーム](https://q.qq.com/#) にアクセス
-- アプリケーションを作成 → **AppID** と **AppSecret** を取得
-
-**2. 設定**
-
-```json
-{
- "channels": {
- "qq": {
- "enabled": true,
- "app_id": "YOUR_APP_ID",
- "app_secret": "YOUR_APP_SECRET",
- "allow_from": []
- }
- }
-}
-```
-
-> `allow_from` を空にすると全ユーザーを許可、QQ番号を指定してアクセス制限可能。
-
-**3. 起動**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-DingTalk
-
-**1. Bot を作成**
-
-- [オープンプラットフォーム](https://open.dingtalk.com/) にアクセス
-- 内部アプリを作成
-- Client ID と Client Secret をコピー
-
-**2. 設定**
-
-```json
-{
- "channels": {
- "dingtalk": {
- "enabled": true,
- "client_id": "YOUR_CLIENT_ID",
- "client_secret": "YOUR_CLIENT_SECRET",
- "allow_from": []
- }
- }
-}
-```
-
-> `allow_from` を空にすると全ユーザーを許可、ユーザーIDを指定してアクセス制限可能。
-
-**3. 起動**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-LINE
-
-**1. LINE 公式アカウントを作成**
-
-- [LINE Developers Console](https://developers.line.biz/) にアクセス
-- プロバイダーを作成 → Messaging API チャネルを作成
-- **チャネルシークレット** と **チャネルアクセストークン** をコピー
-
-**2. 設定**
-
-```json
-{
- "channels": {
- "line": {
- "enabled": true,
- "channel_secret": "YOUR_CHANNEL_SECRET",
- "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
- "webhook_path": "/webhook/line",
- "allow_from": []
- }
- }
-}
-```
-
-**3. Webhook URL を設定**
-
-LINE の Webhook には HTTPS が必要です。リバースプロキシまたはトンネルを使用してください:
-
-```bash
-# ngrok の例
-ngrok http 18790
-```
-
-LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
-
-> **注意**: LINE の Webhook は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、リバースプロキシを設定してください。
-
-**4. 起動**
-
-```bash
-picoclaw gateway
-```
-
-> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
-
-> **Docker Compose**: Gateway HTTP サーバーは共有の `127.0.0.1:18790` で Webhook を提供します。ホストからアクセスするには `picoclaw-gateway` サービスに `ports: ["18790:18790"]` を追加してください。
-
-
-
-
-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` によるプッシュ配信に切り替わります。
-
-
## ⚙️ 設定
@@ -831,65 +527,6 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
```
-
-完全な設定例
-
-```json
-{
- "agents": {
- "defaults": {
- "model": "anthropic/claude-opus-4-5"
- }
- },
- "providers": {
- "openrouter": {
- "api_key": "sk-or-v1-xxx"
- },
- "groq": {
- "api_key": "gsk_xxx"
- }
- },
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "123456:ABC...",
- "allow_from": ["123456789"]
- },
- "discord": {
- "enabled": true,
- "token": "",
- "allow_from": [""]
- },
- "whatsapp": {
- "enabled": false
- },
- "feishu": {
- "enabled": false,
- "app_id": "cli_xxx",
- "app_secret": "xxx",
- "encrypt_key": "",
- "verification_token": "",
- "allow_from": []
- }
- },
- "tools": {
- "web": {
- "search": {
- "api_key": "BSA..."
- }
- },
- "cron": {
- "exec_timeout_minutes": 5
- }
- },
- "heartbeat": {
- "enabled": true,
- "interval": 30
- }
-}
-```
-
-
### モデル設定 (model_list)
@@ -1109,10 +746,6 @@ Web 検索を有効にするには:
一部のプロバイダー(Zhipu など)にはコンテンツフィルタリングがあります。クエリを言い換えるか、別のモデルを使用してください。
-### Telegram Bot で「Conflict: terminated by other getUpdates」と表示される
-
-別のインスタンスが実行中の場合に発生します。`picoclaw gateway` が 1 つだけ実行されていることを確認してください。
-
---
## 📝 API キー比較
diff --git a/README.md b/README.md
index c1ef72141..c9e5c9f06 100644
--- a/README.md
+++ b/README.md
@@ -293,125 +293,11 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
-Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom
-
-> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
+Talk to your picoclaw through WhatsApp
| Channel | Setup |
| ------------ | ---------------------------------- |
-| **Telegram** | Easy (just a token) |
-| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
-| **QQ** | Easy (AppID + AppSecret) |
-| **DingTalk** | Medium (app credentials) |
-| **LINE** | Medium (credentials + webhook URL) |
-| **WeCom AI Bot** | Medium (Token + AES key) |
-
-
-Telegram (Recommended)
-
-**1. Create a bot**
-
-* Open Telegram, search `@BotFather`
-* Send `/newbot`, follow prompts
-* Copy the token
-
-**2. Configure**
-
-```json
-{
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-```
-
-> Get your user ID from `@userinfobot` on Telegram.
-
-**3. Run**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-Discord
-
-**1. Create a bot**
-
-* Go to
-* Create an application → Bot → Add Bot
-* Copy the bot token
-
-**2. Enable intents**
-
-* In the Bot settings, enable **MESSAGE CONTENT INTENT**
-* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
-
-**3. Get your User ID**
-* Discord Settings → Advanced → enable **Developer Mode**
-* Right-click your avatar → **Copy User ID**
-
-**4. Configure**
-
-```json
-{
- "channels": {
- "discord": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-```
-
-**5. Invite the bot**
-
-* OAuth2 → URL Generator
-* Scopes: `bot`
-* Bot Permissions: `Send Messages`, `Read Message History`
-* Open the generated invite URL and add the bot to your server
-
-**Optional: Group trigger mode**
-
-By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:
-
-```json
-{
- "channels": {
- "discord": {
- "group_trigger": { "mention_only": true }
- }
- }
-}
-```
-
-You can also trigger by keyword prefixes (e.g. `!bot`):
-
-```json
-{
- "channels": {
- "discord": {
- "group_trigger": { "prefixes": ["!bot"] }
- }
- }
-}
-```
-
-**6. Run**
-
-```bash
-picoclaw gateway
-```
-
-
WhatsApp (native via whatsmeow)
@@ -440,232 +326,12 @@ If `session_store_path` is empty, the session is stored in `<workspace>/wh
-
-QQ
-**1. Create a bot**
-- Go to [QQ Open Platform](https://q.qq.com/#)
-- Create an application → Get **AppID** and **AppSecret**
-**2. Configure**
-```json
-{
- "channels": {
- "qq": {
- "enabled": true,
- "app_id": "YOUR_APP_ID",
- "app_secret": "YOUR_APP_SECRET",
- "allow_from": []
- }
- }
-}
-```
-> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
-**3. Run**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-DingTalk
-
-**1. Create a bot**
-
-* Go to [Open Platform](https://open.dingtalk.com/)
-* Create an internal app
-* Copy Client ID and Client Secret
-
-**2. Configure**
-
-```json
-{
- "channels": {
- "dingtalk": {
- "enabled": true,
- "client_id": "YOUR_CLIENT_ID",
- "client_secret": "YOUR_CLIENT_SECRET",
- "allow_from": []
- }
- }
-}
-```
-
-> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.
-
-**3. Run**
-
-```bash
-picoclaw gateway
-```
-
-
-
-LINE
-
-**1. Create a LINE Official Account**
-
-- Go to [LINE Developers Console](https://developers.line.biz/)
-- Create a provider → Create a Messaging API channel
-- Copy **Channel Secret** and **Channel Access Token**
-
-**2. Configure**
-
-```json
-{
- "channels": {
- "line": {
- "enabled": true,
- "channel_secret": "YOUR_CHANNEL_SECRET",
- "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
- "webhook_path": "/webhook/line",
- "allow_from": []
- }
- }
-}
-```
-
-> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
-
-**3. Set up Webhook URL**
-
-LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
-
-```bash
-# Example with ngrok (gateway default port is 18790)
-ngrok http 18790
-```
-
-Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
-
-**4. Run**
-
-```bash
-picoclaw gateway
-```
-
-> In group chats, the bot responds only when @mentioned. Replies quote the original message.
-
-
-
-
-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
@@ -1143,118 +809,7 @@ PicoClaw routes providers by protocol family:
This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
-
-Zhipu
-**1. Get API key and base URL**
-
-* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
-
-**2. Configure**
-
-```json
-{
- "agents": {
- "defaults": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
- }
- },
- "providers": {
- "zhipu": {
- "api_key": "Your API Key",
- "api_base": "https://open.bigmodel.cn/api/paas/v4"
- }
- }
-}
-```
-
-**3. Run**
-
-```bash
-picoclaw agent -m "Hello"
-```
-
-
-
-
-Full config example
-
-```json
-{
- "agents": {
- "defaults": {
- "model": "anthropic/claude-opus-4-5"
- }
- },
- "providers": {
- "openrouter": {
- "api_key": "sk-or-v1-xxx"
- },
- "groq": {
- "api_key": "gsk_xxx"
- }
- },
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "123456:ABC...",
- "allow_from": ["123456789"]
- },
- "discord": {
- "enabled": true,
- "token": "",
- "allow_from": [""]
- },
- "whatsapp": {
- "enabled": false,
- "bridge_url": "ws://localhost:3001",
- "use_native": false,
- "session_store_path": "",
- "allow_from": []
- },
- "feishu": {
- "enabled": false,
- "app_id": "cli_xxx",
- "app_secret": "xxx",
- "encrypt_key": "",
- "verification_token": "",
- "allow_from": []
- },
- "qq": {
- "enabled": false,
- "app_id": "",
- "app_secret": "",
- "allow_from": []
- }
- },
- "tools": {
- "web": {
- "brave": {
- "enabled": false,
- "api_key": "BSA...",
- "max_results": 5
- },
- "duckduckgo": {
- "enabled": true,
- "max_results": 5
- }
- },
- "cron": {
- "exec_timeout_minutes": 5
- }
- },
- "heartbeat": {
- "enabled": true,
- "interval": 30
- }
-}
-```
-
-
## CLI Reference
@@ -1327,10 +882,6 @@ Add the key to `~/.picoclaw/config.json` if using Brave:
Some providers (like Zhipu) have content filtering. Try rephrasing your query or use a different model.
-### Telegram bot says "Conflict: terminated by other getUpdates"
-
-This happens when another instance of the bot is running. Make sure only one `picoclaw gateway` is running at a time.
-
---
## 📝 API Key Comparison
diff --git a/README.pt-br.md b/README.pt-br.md
index 67ce9e0d3..840165759 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -273,327 +273,17 @@ Pronto! Você tem um assistente de IA funcionando em 2 minutos.
## 💬 Integração com Apps de Chat
-Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
+Converse com seu PicoClaw via WhatsApp.
| Canal | Nível de Configuração |
| --- | --- |
-| **Telegram** | Fácil (apenas um token) |
-| **Discord** | Fácil (bot token + intents) |
-| **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) |
+| **WhatsApp** | Fácil (nativo: scan QR; ou bridge URL) |
-
-Telegram (Recomendado)
-**1. Criar o bot**
-* Abra o Telegram, busque `@BotFather`
-* Envie `/newbot`, siga as instruções
-* Copie o token
-**2. Configurar**
-```json
-{
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-```
-> Obtenha seu User ID pelo `@userinfobot` no Telegram.
-
-**3. Executar**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-Discord
-
-**1. Criar o bot**
-
-* Acesse
-* Crie um aplicativo → Bot → Add Bot
-* Copie o token do bot
-
-**2. Habilitar Intents**
-
-* Nas configurações do Bot, habilite **MESSAGE CONTENT INTENT**
-* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissões baseada em dados dos membros
-
-**3. Obter seu User ID**
-
-* Configurações do Discord → Avançado → habilite **Modo Desenvolvedor**
-* Clique com botão direito no seu avatar → **Copiar ID do Usuário**
-
-**4. Configurar**
-
-```json
-{
- "channels": {
- "discord": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-```
-
-**5. Convidar o bot**
-
-* OAuth2 → URL Generator
-* Scopes: `bot`
-* Bot Permissions: `Send Messages`, `Read Message History`
-* Abra a URL de convite gerada e adicione o bot ao seu servidor
-
-**6. Executar**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-QQ
-
-**1. Criar o bot**
-
-- Acesse a [QQ Open Platform](https://q.qq.com/#)
-- Crie um aplicativo → Obtenha **AppID** e **AppSecret**
-
-**2. Configurar**
-
-```json
-{
- "channels": {
- "qq": {
- "enabled": true,
- "app_id": "YOUR_APP_ID",
- "app_secret": "YOUR_APP_SECRET",
- "allow_from": []
- }
- }
-}
-```
-
-> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso.
-
-**3. Executar**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-DingTalk
-
-**1. Criar o bot**
-
-* Acesse a [Open Platform](https://open.dingtalk.com/)
-* Crie um app interno
-* Copie o Client ID e Client Secret
-
-**2. Configurar**
-
-```json
-{
- "channels": {
- "dingtalk": {
- "enabled": true,
- "client_id": "YOUR_CLIENT_ID",
- "client_secret": "YOUR_CLIENT_SECRET",
- "allow_from": []
- }
- }
-}
-```
-
-> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique IDs para restringir o acesso.
-
-**3. Executar**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-LINE
-
-**1. Criar uma Conta Oficial LINE**
-
-- Acesse o [LINE Developers Console](https://developers.line.biz/)
-- Crie um provider → Crie um canal Messaging API
-- Copie o **Channel Secret** e o **Channel Access Token**
-
-**2. Configurar**
-
-```json
-{
- "channels": {
- "line": {
- "enabled": true,
- "channel_secret": "YOUR_CHANNEL_SECRET",
- "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
- "webhook_path": "/webhook/line",
- "allow_from": []
- }
- }
-}
-```
-
-**3. Configurar URL do Webhook**
-
-O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel:
-
-```bash
-# Exemplo com ngrok
-ngrok http 18790
-```
-
-Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**.
-
-> **Nota**: O webhook do LINE é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel (como ngrok) para expor o Gateway de forma segura quando necessário.
-
-**4. Executar**
-
-```bash
-picoclaw gateway
-```
-
-> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
-
-> **Docker Compose**: Se você usa Docker Compose, exponha o Gateway (padrão 127.0.0.1:18790) se precisar acessar o webhook LINE externamente, por exemplo `ports: ["18790:18790"]`.
-
-
-
-
-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
@@ -837,114 +527,7 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
| `cerebras` | Cerebras | [cerebras.ai](https://cerebras.ai) |
| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
-
-Configuração Zhipu
-**1. Obter API key**
-
-* Obtenha a [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
-
-**2. Configurar**
-
-```json
-{
- "agents": {
- "defaults": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
- }
- },
- "providers": {
- "zhipu": {
- "api_key": "Sua API Key",
- "api_base": "https://open.bigmodel.cn/api/paas/v4"
- }
- }
-}
-```
-
-**3. Executar**
-
-```bash
-picoclaw agent -m "Ola, como vai?"
-```
-
-
-
-
-Exemplo de configuraçao completa
-
-```json
-{
- "agents": {
- "defaults": {
- "model": "anthropic/claude-opus-4-5"
- }
- },
- "providers": {
- "openrouter": {
- "api_key": "sk-or-v1-xxx"
- },
- "groq": {
- "api_key": "gsk_xxx"
- }
- },
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "123456:ABC...",
- "allow_from": ["123456789"]
- },
- "discord": {
- "enabled": true,
- "token": "",
- "allow_from": [""]
- },
- "whatsapp": {
- "enabled": false
- },
- "feishu": {
- "enabled": false,
- "app_id": "cli_xxx",
- "app_secret": "xxx",
- "encrypt_key": "",
- "verification_token": "",
- "allow_from": []
- },
- "qq": {
- "enabled": false,
- "app_id": "",
- "app_secret": "",
- "allow_from": []
- }
- },
- "tools": {
- "web": {
- "brave": {
- "enabled": false,
- "api_key": "BSA...",
- "max_results": 5
- },
- "duckduckgo": {
- "enabled": true,
- "max_results": 5
- }
- },
- "cron": {
- "exec_timeout_minutes": 5
- }
- },
- "heartbeat": {
- "enabled": true,
- "interval": 30
- }
-}
-```
-
-
### Configuração de Modelo (model_list)
@@ -1185,10 +768,6 @@ Adicione a key em `~/.picoclaw/config.json` se usar o Brave:
Alguns provedores (como Zhipu) possuem filtragem de conteúdo. Tente reformular sua pergunta ou use um modelo diferente.
-### Bot do Telegram diz "Conflict: terminated by other getUpdates"
-
-Isso acontece quando outra instância do bot está em execução. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez.
-
---
## 📝 Comparação de API Keys
diff --git a/README.vi.md b/README.vi.md
index 5755896ed..d2eb8f03c 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -216,9 +216,10 @@ picoclaw onboard
}
},
"channels": {
- "telegram": {
+ "whatsapp": {
"enabled": true,
- "token": "YOUR_TELEGRAM_BOT_TOKEN",
+ "use_native": true,
+ "session_store_path": "",
"allow_from": []
}
}
@@ -247,325 +248,17 @@ Vậy là xong! Bạn đã có một trợ lý AI hoạt động chỉ trong 2 p
## 💬 Tích hợp ứng dụng Chat
-Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
+Trò chuyện với PicoClaw qua WhatsApp.
| Kênh | Mức độ thiết lập |
| --- | --- |
-| **Telegram** | Dễ (chỉ cần token) |
-| **Discord** | Dễ (bot token + intents) |
-| **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) |
+| **WhatsApp** | Dễ (native: QR scan; or bridge URL) |
-
-Telegram (Khuyên dùng)
-**1. Tạo bot**
-* Mở Telegram, tìm `@BotFather`
-* Gửi `/newbot`, làm theo hướng dẫn
-* Sao chép token
-**2. Cấu hình**
-```json
-{
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-```
-> Lấy User ID từ `@userinfobot` trên Telegram.
-
-**3. Chạy**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-Discord
-
-**1. Tạo bot**
-
-* Truy cập
-* Create an application → Bot → Add Bot
-* Sao chép bot token
-
-**2. Bật Intents**
-
-* Trong phần Bot settings, bật **MESSAGE CONTENT INTENT**
-* (Tùy chọn) Bật **SERVER MEMBERS INTENT** nếu muốn dùng danh sách cho phép theo thông tin thành viên
-
-**3. Lấy User ID**
-
-* Discord Settings → Advanced → bật **Developer Mode**
-* Click chuột phải vào avatar → **Copy User ID**
-
-**4. Cấu hình**
-
-```json
-{
- "channels": {
- "discord": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-```
-
-**5. Mời bot vào server**
-
-* OAuth2 → URL Generator
-* Scopes: `bot`
-* Bot Permissions: `Send Messages`, `Read Message History`
-* Mở URL mời được tạo và thêm bot vào server của bạn
-
-**6. Chạy**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-QQ
-
-**1. Tạo bot**
-
-* Truy cập [QQ Open Platform](https://q.qq.com/#)
-* Tạo ứng dụng → Lấy **AppID** và **AppSecret**
-
-**2. Cấu hình**
-
-```json
-{
- "channels": {
- "qq": {
- "enabled": true,
- "app_id": "YOUR_APP_ID",
- "app_secret": "YOUR_APP_SECRET",
- "allow_from": []
- }
- }
-}
-```
-
-> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn quyền truy cập.
-
-**3. Chạy**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-DingTalk
-
-**1. Tạo bot**
-
-* Truy cập [Open Platform](https://open.dingtalk.com/)
-* Tạo ứng dụng nội bộ
-* Sao chép Client ID và Client Secret
-
-**2. Cấu hình**
-
-```json
-{
- "channels": {
- "dingtalk": {
- "enabled": true,
- "client_id": "YOUR_CLIENT_ID",
- "client_secret": "YOUR_CLIENT_SECRET",
- "allow_from": []
- }
- }
-}
-```
-
-> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định ID để giới hạn quyền truy cập.
-
-**3. Chạy**
-
-```bash
-picoclaw gateway
-```
-
-
-
-
-LINE
-
-**1. Tạo tài khoản LINE Official**
-
-- Truy cập [LINE Developers Console](https://developers.line.biz/)
-- Tạo provider → Tạo Messaging API channel
-- Sao chép **Channel Secret** và **Channel Access Token**
-
-**2. Cấu hình**
-
-```json
-{
- "channels": {
- "line": {
- "enabled": true,
- "channel_secret": "YOUR_CHANNEL_SECRET",
- "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
- "webhook_path": "/webhook/line",
- "allow_from": []
- }
- }
-}
-```
-
-**3. Thiết lập Webhook URL**
-
-LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel:
-
-```bash
-# Ví dụ với ngrok
-ngrok http 18790
-```
-
-Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.
-
-**4. Chạy**
-
-```bash
-picoclaw gateway
-```
-
-> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc.
-
-> **Docker Compose**: Nếu bạn cần mở port webhook cục bộ, hãy thêm một rule chuyển tiếp từ port Gateway (mặc định 18790) tới host. Lưu ý: LINE webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790).
-
-
-
-
-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
@@ -809,111 +502,7 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
| `qwen` | LLM (Qwen trực tiếp) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `cerebras` | LLM (Cerebras trực tiếp) | [cerebras.ai](https://cerebras.ai) |
-
-Cấu hình Zhipu
-**1. Lấy API key**
-
-* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
-
-**2. Cấu hình**
-
-```json
-{
- "agents": {
- "defaults": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
- }
- },
- "providers": {
- "zhipu": {
- "api_key": "Your API Key",
- "api_base": "https://open.bigmodel.cn/api/paas/v4"
- }
- }
-}
-```
-
-**3. Chạy**
-
-```bash
-picoclaw agent -m "Xin chào"
-```
-
-
-
-
-Ví dụ cấu hình đầy đủ
-
-```json
-{
- "agents": {
- "defaults": {
- "model": "anthropic/claude-opus-4-5"
- }
- },
- "providers": {
- "openrouter": {
- "api_key": "sk-or-v1-xxx"
- },
- "groq": {
- "api_key": "gsk_xxx"
- }
- },
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "123456:ABC...",
- "allow_from": ["123456789"]
- },
- "discord": {
- "enabled": true,
- "token": "",
- "allow_from": [""]
- },
- "whatsapp": {
- "enabled": false
- },
- "feishu": {
- "enabled": false,
- "app_id": "cli_xxx",
- "app_secret": "xxx",
- "encrypt_key": "",
- "verification_token": "",
- "allow_from": []
- },
- "qq": {
- "enabled": false,
- "app_id": "",
- "app_secret": "",
- "allow_from": []
- }
- },
- "tools": {
- "web": {
- "brave": {
- "enabled": false,
- "api_key": "BSA...",
- "max_results": 5
- },
- "duckduckgo": {
- "enabled": true,
- "max_results": 5
- }
- }
- },
- "heartbeat": {
- "enabled": true,
- "interval": 30
- }
-}
-```
-
-
### Cấu hình Mô hình (model_list)
@@ -1154,10 +743,6 @@ Thêm key vào `~/.picoclaw/config.json` nếu dùng Brave:
Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt. Thử diễn đạt lại câu hỏi hoặc sử dụng model khác.
-### Telegram bot báo "Conflict: terminated by other getUpdates"
-
-Điều này xảy ra khi có một instance bot khác đang chạy. Đảm bảo chỉ có một tiến trình `picoclaw gateway` chạy tại một thời điểm.
-
---
## 📝 So sánh API Key
diff --git a/README.zh.md b/README.zh.md
index bd90173f9..ceada5356 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -288,24 +288,11 @@ picoclaw agent -m "2+2 等于几?"
## 💬 聊天应用集成 (Chat Apps)
-PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
+PicoClaw 支持连接到 WhatsApp。
-> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。
-
-### 核心渠道
-
-| 渠道 | 设置难度 | 特性说明 | 文档链接 |
-| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
-| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
-| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
-| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/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) |
-| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/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) |
+| 渠道 | 设置难度 |
+| -------------------- | ----------- |
+| **WhatsApp** | ⭐ 简单 |
##
加入 Agent 社交网络
@@ -846,10 +833,6 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
-### Telegram bot 提示 "Conflict: terminated by other getUpdates"
-
-这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。
-
---
## 📝 API Key 对比
diff --git a/ROADMAP.md b/ROADMAP.md
index 8c5c0e252..2eaadf586 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -48,7 +48,7 @@
* **Channel**
- * **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ...
+ * **IM Matrix**: WhatsApp, Email
* **Standards**: Support for the **OneBot** protocol.
* [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments.
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go
index 4947d6aea..adfdb02f0 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/app.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/app.go
@@ -127,7 +127,7 @@ func (s *appState) refreshMenu(name string, menu *Menu) {
case "model":
refreshModelMenuFromState(menu, s)
case "channel":
- refreshChannelMenuFromState(menu, s)
+ // Removed menu-based refresh for channel list since it uses tview.List
}
}
@@ -169,7 +169,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
Label: rootChannelLabel(channelReady),
Description: rootChannelDescription(channelReady),
Action: func() {
- s.push("channel", s.channelMenu())
+ s.push("channel", makeChannelMenu(s))
},
MainColor: func() *tcell.Color {
if channelReady {
@@ -421,9 +421,7 @@ func (s *appState) isActiveModelValid() bool {
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.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
- c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
+ return c.WhatsApp.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 49a6ccc5d..9d38981cf 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/channel.go
@@ -10,152 +10,41 @@ import (
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
-func (s *appState) buildChannelMenuItems() []MenuItem {
- return []MenuItem{
- {Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
- channelItem(
- "Telegram",
- "Telegram bot settings",
- s.config.Channels.Telegram.Enabled,
- func() { s.push("channel-telegram", s.telegramForm()) },
- ),
- channelItem(
- "Discord",
- "Discord bot settings",
- s.config.Channels.Discord.Enabled,
- func() { s.push("channel-discord", s.discordForm()) },
- ),
- channelItem(
- "QQ",
- "QQ bot settings",
- s.config.Channels.QQ.Enabled,
- func() { s.push("channel-qq", s.qqForm()) },
- ),
- channelItem(
- "MaixCam",
- "MaixCam gateway",
- s.config.Channels.MaixCam.Enabled,
- func() { s.push("channel-maixcam", s.maixcamForm()) },
- ),
- channelItem(
- "WhatsApp",
- "WhatsApp bridge",
- s.config.Channels.WhatsApp.Enabled,
- func() { s.push("channel-whatsapp", s.whatsappForm()) },
- ),
- channelItem(
- "Feishu",
- "Feishu bot settings",
- s.config.Channels.Feishu.Enabled,
- func() { s.push("channel-feishu", s.feishuForm()) },
- ),
- channelItem(
- "DingTalk",
- "DingTalk bot settings",
- s.config.Channels.DingTalk.Enabled,
- func() { s.push("channel-dingtalk", s.dingtalkForm()) },
- ),
- channelItem(
- "Slack",
- "Slack bot settings",
- s.config.Channels.Slack.Enabled,
- func() { s.push("channel-slack", s.slackForm()) },
- ),
- channelItem(
- "LINE",
- "LINE bot settings",
- s.config.Channels.LINE.Enabled,
- func() { s.push("channel-line", s.lineForm()) },
- ),
- channelItem(
- "OneBot",
- "OneBot settings",
- s.config.Channels.OneBot.Enabled,
- func() { s.push("channel-onebot", s.onebotForm()) },
- ),
- channelItem(
- "WeCom",
- "WeCom bot settings",
- s.config.Channels.WeCom.Enabled,
- func() { s.push("channel-wecom", s.wecomForm()) },
- ),
- channelItem(
- "WeCom App",
- "WeCom App settings",
- s.config.Channels.WeComApp.Enabled,
- func() { s.push("channel-wecomapp", s.wecomAppForm()) },
- ),
- }
+func makeChannelMenu(s *appState) *tview.List {
+ menu := tview.NewList()
+ menu.SetBorder(true).SetTitle("Channels").SetTitleAlign(tview.AlignCenter)
+ menu.ShowSecondaryText(true)
+ menu.SetWrapAround(true)
+ menu.SetHighlightFullLine(true)
+
+ refreshChannelMenuFromState(menu, s)
+ return menu
}
-func (s *appState) channelMenu() tview.Primitive {
- menu := NewMenu("Channels", s.buildChannelMenuItems())
+func refreshChannelMenuFromState(menu *tview.List, s *appState) {
+ menu.Clear()
+
+ items := []MenuItem{
+ channelItem(
+ "WhatsApp",
+ "Configure WhatsApp Bridge",
+ s.config.Channels.WhatsApp.Enabled,
+ func() { s.push("whatsapp_form", s.whatsappForm()) },
+ ),
+ {Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
+ }
+
+ for _, item := range items {
+ menu.AddItem(item.Label, item.Description, 0, item.Action)
+ }
+
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
- if event.Rune() == 'q' {
- s.pop()
- return nil
- }
return event
})
- return menu
-}
-
-func refreshChannelMenuFromState(menu *Menu, s *appState) {
- menu.applyItems(s.buildChannelMenuItems())
-}
-
-func (s *appState) telegramForm() tview.Primitive {
- cfg := &s.config.Channels.Telegram
- form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
- cfg.Token = strings.TrimSpace(text)
- })
- form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
- cfg.Proxy = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) discordForm() tview.Primitive {
- cfg := &s.config.Channels.Discord
- form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
- cfg.Token = strings.TrimSpace(text)
- })
- form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
- cfg.MentionOnly = checked
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) qqForm() tview.Primitive {
- cfg := &s.config.Channels.QQ
- form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
- cfg.AppID = strings.TrimSpace(text)
- })
- form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
- cfg.AppSecret = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) maixcamForm() tview.Primitive {
- cfg := &s.config.Channels.MaixCam
- form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
- cfg.Host = strings.TrimSpace(text)
- })
- addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
}
func (s *appState) whatsappForm() tview.Primitive {
@@ -168,169 +57,11 @@ func (s *appState) whatsappForm() tview.Primitive {
return wrapWithBack(form, s)
}
-func (s *appState) feishuForm() tview.Primitive {
- cfg := &s.config.Channels.Feishu
- form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
- cfg.AppID = strings.TrimSpace(text)
- })
- form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
- cfg.AppSecret = strings.TrimSpace(text)
- })
- form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
- cfg.EncryptKey = strings.TrimSpace(text)
- })
- form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
- cfg.VerificationToken = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) dingtalkForm() tview.Primitive {
- cfg := &s.config.Channels.DingTalk
- form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
- cfg.ClientID = strings.TrimSpace(text)
- })
- form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
- cfg.ClientSecret = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) slackForm() tview.Primitive {
- cfg := &s.config.Channels.Slack
- form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
- cfg.BotToken = strings.TrimSpace(text)
- })
- form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
- cfg.AppToken = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) lineForm() tview.Primitive {
- cfg := &s.config.Channels.LINE
- form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
- cfg.ChannelSecret = strings.TrimSpace(text)
- })
- form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
- cfg.ChannelAccessToken = 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)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) onebotForm() tview.Primitive {
- cfg := &s.config.Channels.OneBot
- form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
- cfg.WSUrl = strings.TrimSpace(text)
- })
- form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
- cfg.AccessToken = strings.TrimSpace(text)
- })
- addIntField(
- form,
- "Reconnect Interval",
- cfg.ReconnectInterval,
- func(value int) { cfg.ReconnectInterval = value },
- )
- form.AddInputField(
- "Group Trigger Prefix",
- strings.Join(cfg.GroupTriggerPrefix, ","),
- 128,
- nil,
- func(text string) {
- cfg.GroupTriggerPrefix = splitCSV(text)
- },
- )
- addAllowFromField(form, &cfg.AllowFrom)
- 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
s.dirty = true
refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
}
}
diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go
index 5225340c7..ea919a617 100644
--- a/cmd/picoclaw/internal/gateway/helpers.go
+++ b/cmd/picoclaw/internal/gateway/helpers.go
@@ -13,17 +13,6 @@ import (
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
- _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
- _ "github.com/sipeed/picoclaw/pkg/channels/discord"
- _ "github.com/sipeed/picoclaw/pkg/channels/feishu"
- _ "github.com/sipeed/picoclaw/pkg/channels/line"
- _ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
- _ "github.com/sipeed/picoclaw/pkg/channels/onebot"
- _ "github.com/sipeed/picoclaw/pkg/channels/pico"
- _ "github.com/sipeed/picoclaw/pkg/channels/qq"
- _ "github.com/sipeed/picoclaw/pkg/channels/slack"
- _ "github.com/sipeed/picoclaw/pkg/channels/telegram"
- _ "github.com/sipeed/picoclaw/pkg/channels/wecom"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
"github.com/sipeed/picoclaw/pkg/config"
diff --git a/go.mod b/go.mod
index c1172937c..6ee09c851 100644
--- a/go.mod
+++ b/go.mod
@@ -5,25 +5,19 @@ go 1.25.7
require (
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.22.1
- github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.3.1
github.com/chzyer/readline v1.5.1
github.com/gdamore/tcell/v2 v2.13.8
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
- github.com/larksuite/oapi-sdk-go/v3 v3.5.3
+ github.com/h2non/filetype v1.1.3
github.com/mdp/qrterminal/v3 v3.2.1
github.com/modelcontextprotocol/go-sdk v1.3.0
- github.com/mymmrac/telego v1.6.0
- github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/rivo/tview v0.42.0
- github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
- github.com/tencent-connect/botgo v0.2.1
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
- golang.org/x/oauth2 v0.35.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.46.1
@@ -37,9 +31,8 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
- github.com/gdamore/tcell/v2 v2.13.8 // indirect
- github.com/h2non/filetype v1.1.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/kr/pretty v0.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -48,14 +41,17 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.mau.fi/util v0.9.6 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
+ golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
@@ -64,30 +60,14 @@ require (
)
require (
- github.com/andybalholm/brotli v1.2.0 // indirect
- github.com/bytedance/gopkg v0.1.3 // indirect
- github.com/bytedance/sonic v1.15.0 // indirect
- github.com/bytedance/sonic/loader v0.5.0 // indirect
- github.com/cloudwego/base64x v0.1.6 // indirect
github.com/github/copilot-sdk/go v0.1.23
- github.com/go-resty/resty/v2 v2.17.1 // indirect
- github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
- github.com/grbit/go-json v0.11.0 // indirect
- github.com/klauspost/compress v1.18.4 // indirect
- github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
- github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
- github.com/valyala/bytebufferpool v1.0.0 // indirect
- github.com/valyala/fasthttp v1.69.0 // indirect
- github.com/valyala/fastjson v1.6.7 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
- golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
- golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
)
diff --git a/go.sum b/go.sum
index 060594d06..424866e50 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,3 @@
-cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@@ -9,108 +8,54 @@ github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KO
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
-github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
-github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
-github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
-github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
-github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
-github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
-github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
-github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
-github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
-github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
-github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
-github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
-github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
-github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
-github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
-github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
-github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
-github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
-github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
-github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
-github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
-github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
-github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
-github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
-github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
-github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
-github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
@@ -119,8 +64,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
-github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -136,25 +79,12 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
-github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
-github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
-github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
-github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
-github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
-github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
-github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
-github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
-github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
-github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
-github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -173,28 +103,13 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
-github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
-github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
-github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
-github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -206,22 +121,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
-github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
-github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
-github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
-github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
-github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
-github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
-github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
-github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
-github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
@@ -229,65 +132,32 @@ go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
-go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
-go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
-golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
-golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
-golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
-golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -295,24 +165,18 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
-golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
@@ -320,25 +184,11 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -346,13 +196,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go
deleted file mode 100644
index 8642ad362..000000000
--- a/pkg/channels/dingtalk/dingtalk.go
+++ /dev/null
@@ -1,227 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// DingTalk channel implementation using Stream Mode
-
-package dingtalk
-
-import (
- "context"
- "fmt"
- "sync"
-
- "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
- "github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-// DingTalkChannel implements the Channel interface for DingTalk (钉钉)
-// It uses WebSocket for receiving messages via stream mode and API for sending
-type DingTalkChannel struct {
- *channels.BaseChannel
- config config.DingTalkConfig
- clientID string
- clientSecret string
- streamClient *client.StreamClient
- ctx context.Context
- cancel context.CancelFunc
- // Map to store session webhooks for each chat
- sessionWebhooks sync.Map // chatID -> sessionWebhook
-}
-
-// NewDingTalkChannel creates a new DingTalk channel instance
-func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
- if cfg.ClientID == "" || cfg.ClientSecret == "" {
- return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
- }
-
- base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
- channels.WithMaxMessageLength(20000),
- channels.WithGroupTrigger(cfg.GroupTrigger),
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
- )
-
- return &DingTalkChannel{
- BaseChannel: base,
- config: cfg,
- clientID: cfg.ClientID,
- clientSecret: cfg.ClientSecret,
- }, nil
-}
-
-// Start initializes the DingTalk channel with Stream Mode
-func (c *DingTalkChannel) Start(ctx context.Context) error {
- logger.InfoC("dingtalk", "Starting DingTalk channel (Stream Mode)...")
-
- c.ctx, c.cancel = context.WithCancel(ctx)
-
- // Create credential config
- cred := client.NewAppCredentialConfig(c.clientID, c.clientSecret)
-
- // Create the stream client with options
- c.streamClient = client.NewStreamClient(
- client.WithAppCredential(cred),
- client.WithAutoReconnect(true),
- )
-
- // Register chatbot callback handler (IChatBotMessageHandler is a function type)
- c.streamClient.RegisterChatBotCallbackRouter(c.onChatBotMessageReceived)
-
- // Start the stream client
- if err := c.streamClient.Start(c.ctx); err != nil {
- return fmt.Errorf("failed to start stream client: %w", err)
- }
-
- c.SetRunning(true)
- logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)")
- return nil
-}
-
-// Stop gracefully stops the DingTalk channel
-func (c *DingTalkChannel) Stop(ctx context.Context) error {
- logger.InfoC("dingtalk", "Stopping DingTalk channel...")
-
- if c.cancel != nil {
- c.cancel()
- }
-
- if c.streamClient != nil {
- c.streamClient.Close()
- }
-
- c.SetRunning(false)
- logger.InfoC("dingtalk", "DingTalk channel stopped")
- return nil
-}
-
-// Send sends a message to DingTalk via the chatbot reply API
-func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- // Get session webhook from storage
- sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID)
- if !ok {
- return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID)
- }
-
- sessionWebhook, ok := sessionWebhookRaw.(string)
- if !ok {
- return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID)
- }
-
- logger.DebugCF("dingtalk", "Sending message", map[string]any{
- "chat_id": msg.ChatID,
- "preview": utils.Truncate(msg.Content, 100),
- })
-
- // Use the session webhook to send the reply
- return c.SendDirectReply(ctx, sessionWebhook, msg.Content)
-}
-
-// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
-// This is called by the Stream SDK when a new message arrives
-// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error)
-func (c *DingTalkChannel) onChatBotMessageReceived(
- ctx context.Context,
- data *chatbot.BotCallbackDataModel,
-) ([]byte, error) {
- // Extract message content from Text field
- content := data.Text.Content
- if content == "" {
- // Try to extract from Content interface{} if Text is empty
- if contentMap, ok := data.Content.(map[string]any); ok {
- if textContent, ok := contentMap["content"].(string); ok {
- content = textContent
- }
- }
- }
-
- if content == "" {
- return nil, nil // Ignore empty messages
- }
-
- senderID := data.SenderStaffId
- senderNick := data.SenderNick
- chatID := senderID
- if data.ConversationType != "1" {
- // For group chats
- chatID = data.ConversationId
- }
-
- // Store the session webhook for this chat so we can reply later
- c.sessionWebhooks.Store(chatID, data.SessionWebhook)
-
- metadata := map[string]string{
- "sender_name": senderNick,
- "conversation_id": data.ConversationId,
- "conversation_type": data.ConversationType,
- "platform": "dingtalk",
- "session_webhook": data.SessionWebhook,
- }
-
- var peer bus.Peer
- if data.ConversationType == "1" {
- peer = bus.Peer{Kind: "direct", ID: senderID}
- } else {
- peer = bus.Peer{Kind: "group", ID: data.ConversationId}
- // In group chats, apply unified group trigger filtering
- respond, cleaned := c.ShouldRespondInGroup(false, content)
- if !respond {
- return nil, nil
- }
- content = cleaned
- }
-
- logger.DebugCF("dingtalk", "Received message", map[string]any{
- "sender_nick": senderNick,
- "sender_id": senderID,
- "preview": utils.Truncate(content, 50),
- })
-
- // Build sender info
- sender := bus.SenderInfo{
- Platform: "dingtalk",
- PlatformID: senderID,
- CanonicalID: identity.BuildCanonicalID("dingtalk", senderID),
- DisplayName: senderNick,
- }
-
- if !c.IsAllowedSender(sender) {
- return nil, nil
- }
-
- // Handle the message through the base channel
- c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata, sender)
-
- // Return nil to indicate we've handled the message asynchronously
- // The response will be sent through the message bus
- return nil, nil
-}
-
-// SendDirectReply sends a direct reply using the session webhook
-func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, content string) error {
- replier := chatbot.NewChatbotReplier()
-
- // Convert string content to []byte for the API
- contentBytes := []byte(content)
- titleBytes := []byte("PicoClaw")
-
- // Send markdown formatted reply
- err := replier.SimpleReplyMarkdown(
- ctx,
- sessionWebhook,
- titleBytes,
- contentBytes,
- )
- if err != nil {
- return fmt.Errorf("dingtalk send: %w", channels.ErrTemporary)
- }
-
- return nil
-}
diff --git a/pkg/channels/dingtalk/init.go b/pkg/channels/dingtalk/init.go
deleted file mode 100644
index 5f49bce8c..000000000
--- a/pkg/channels/dingtalk/init.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package dingtalk
-
-import (
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func init() {
- channels.RegisterFactory("dingtalk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewDingTalkChannel(cfg.Channels.DingTalk, b)
- })
-}
diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go
deleted file mode 100644
index 1de910c83..000000000
--- a/pkg/channels/discord/discord.go
+++ /dev/null
@@ -1,521 +0,0 @@
-package discord
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/url"
- "os"
- "strings"
- "sync"
- "time"
-
- "github.com/bwmarrin/discordgo"
- "github.com/gorilla/websocket"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/media"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-const (
- sendTimeout = 10 * time.Second
-)
-
-type DiscordChannel struct {
- *channels.BaseChannel
- session *discordgo.Session
- config config.DiscordConfig
- ctx context.Context
- cancel context.CancelFunc
- typingMu sync.Mutex
- typingStop map[string]chan struct{} // chatID → stop signal
- botUserID string // stored for mention checking
-}
-
-func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
- session, err := discordgo.New("Bot " + cfg.Token)
- if err != nil {
- return nil, fmt.Errorf("failed to create discord session: %w", err)
- }
-
- if err := applyDiscordProxy(session, cfg.Proxy); err != nil {
- return nil, err
- }
- base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom,
- channels.WithMaxMessageLength(2000),
- channels.WithGroupTrigger(cfg.GroupTrigger),
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
- )
-
- return &DiscordChannel{
- BaseChannel: base,
- session: session,
- config: cfg,
- ctx: context.Background(),
- typingStop: make(map[string]chan struct{}),
- }, nil
-}
-
-func (c *DiscordChannel) Start(ctx context.Context) error {
- logger.InfoC("discord", "Starting Discord bot")
-
- c.ctx, c.cancel = context.WithCancel(ctx)
-
- // Get bot user ID before opening session to avoid race condition
- botUser, err := c.session.User("@me")
- if err != nil {
- return fmt.Errorf("failed to get bot user: %w", err)
- }
- c.botUserID = botUser.ID
-
- c.session.AddHandler(c.handleMessage)
-
- if err := c.session.Open(); err != nil {
- return fmt.Errorf("failed to open discord session: %w", err)
- }
-
- c.SetRunning(true)
-
- logger.InfoCF("discord", "Discord bot connected", map[string]any{
- "username": botUser.Username,
- "user_id": botUser.ID,
- })
-
- return nil
-}
-
-func (c *DiscordChannel) Stop(ctx context.Context) error {
- logger.InfoC("discord", "Stopping Discord bot")
- c.SetRunning(false)
-
- // Stop all typing goroutines before closing session
- c.typingMu.Lock()
- for chatID, stop := range c.typingStop {
- close(stop)
- delete(c.typingStop, chatID)
- }
- c.typingMu.Unlock()
-
- // Cancel our context so typing goroutines using c.ctx.Done() exit
- if c.cancel != nil {
- c.cancel()
- }
-
- if err := c.session.Close(); err != nil {
- return fmt.Errorf("failed to close discord session: %w", err)
- }
-
- return nil
-}
-
-func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- channelID := msg.ChatID
- if channelID == "" {
- return fmt.Errorf("channel ID is empty")
- }
-
- if len([]rune(msg.Content)) == 0 {
- return nil
- }
-
- return c.sendChunk(ctx, channelID, msg.Content)
-}
-
-// SendMedia implements the channels.MediaSender interface.
-func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- channelID := msg.ChatID
- if channelID == "" {
- return fmt.Errorf("channel ID is empty")
- }
-
- store := c.GetMediaStore()
- if store == nil {
- return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
- }
-
- // Collect all files into a single ChannelMessageSendComplex call
- files := make([]*discordgo.File, 0, len(msg.Parts))
- var caption string
-
- for _, part := range msg.Parts {
- localPath, err := store.Resolve(part.Ref)
- if err != nil {
- logger.ErrorCF("discord", "Failed to resolve media ref", map[string]any{
- "ref": part.Ref,
- "error": err.Error(),
- })
- continue
- }
-
- file, err := os.Open(localPath)
- if err != nil {
- logger.ErrorCF("discord", "Failed to open media file", map[string]any{
- "path": localPath,
- "error": err.Error(),
- })
- continue
- }
- // Note: discordgo reads from the Reader and we can't close it before send
-
- filename := part.Filename
- if filename == "" {
- filename = "file"
- }
-
- files = append(files, &discordgo.File{
- Name: filename,
- ContentType: part.ContentType,
- Reader: file,
- })
-
- if part.Caption != "" && caption == "" {
- caption = part.Caption
- }
- }
-
- if len(files) == 0 {
- return nil
- }
-
- sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
- defer cancel()
-
- done := make(chan error, 1)
- go func() {
- _, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
- Content: caption,
- Files: files,
- })
- done <- err
- }()
-
- select {
- case err := <-done:
- // Close all file readers
- for _, f := range files {
- if closer, ok := f.Reader.(*os.File); ok {
- closer.Close()
- }
- }
- if err != nil {
- return fmt.Errorf("discord send media: %w", channels.ErrTemporary)
- }
- return nil
- case <-sendCtx.Done():
- // Close all file readers
- for _, f := range files {
- if closer, ok := f.Reader.(*os.File); ok {
- closer.Close()
- }
- }
- return sendCtx.Err()
- }
-}
-
-// EditMessage implements channels.MessageEditor.
-func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
- _, err := c.session.ChannelMessageEdit(chatID, messageID, content)
- return err
-}
-
-// SendPlaceholder implements channels.PlaceholderCapable.
-// It sends a placeholder message that will later be edited to the actual
-// response via EditMessage (channels.MessageEditor).
-func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
- if !c.config.Placeholder.Enabled {
- return "", nil
- }
-
- text := c.config.Placeholder.Text
- if text == "" {
- text = "Thinking... 💭"
- }
-
- msg, err := c.session.ChannelMessageSend(chatID, text)
- if err != nil {
- return "", err
- }
-
- return msg.ID, nil
-}
-
-func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
- // Use the passed ctx for timeout control
- sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
- defer cancel()
-
- done := make(chan error, 1)
- go func() {
- _, err := c.session.ChannelMessageSend(channelID, content)
- done <- err
- }()
-
- select {
- case err := <-done:
- if err != nil {
- return fmt.Errorf("discord send: %w", channels.ErrTemporary)
- }
- return nil
- case <-sendCtx.Done():
- return sendCtx.Err()
- }
-}
-
-// appendContent safely appends content to existing text
-func appendContent(content, suffix string) string {
- if content == "" {
- return suffix
- }
- return content + "\n" + suffix
-}
-
-func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
- if m == nil || m.Author == nil {
- return
- }
-
- if m.Author.ID == s.State.User.ID {
- return
- }
-
- // Check allowlist first to avoid downloading attachments for rejected users
- sender := bus.SenderInfo{
- Platform: "discord",
- PlatformID: m.Author.ID,
- CanonicalID: identity.BuildCanonicalID("discord", m.Author.ID),
- Username: m.Author.Username,
- }
- // Build display name
- displayName := m.Author.Username
- if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
- displayName += "#" + m.Author.Discriminator
- }
- sender.DisplayName = displayName
-
- if !c.IsAllowedSender(sender) {
- logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
- "user_id": m.Author.ID,
- })
- return
- }
-
- content := m.Content
-
- // In guild (group) channels, apply unified group trigger filtering
- // DMs (GuildID is empty) always get a response
- if m.GuildID != "" {
- isMentioned := false
- for _, mention := range m.Mentions {
- if mention.ID == c.botUserID {
- isMentioned = true
- break
- }
- }
- content = c.stripBotMention(content)
- respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
- if !respond {
- logger.DebugCF("discord", "Group message ignored by group trigger", map[string]any{
- "user_id": m.Author.ID,
- })
- return
- }
- content = cleaned
- } else {
- // DMs: just strip bot mention without filtering
- content = c.stripBotMention(content)
- }
-
- senderID := m.Author.ID
-
- mediaPaths := make([]string, 0, len(m.Attachments))
-
- scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)
-
- // Helper to register a local file with the media store
- storeMedia := func(localPath, filename string) string {
- if store := c.GetMediaStore(); store != nil {
- ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "discord",
- }, scope)
- if err == nil {
- return ref
- }
- }
- return localPath // fallback
- }
-
- for _, attachment := range m.Attachments {
- isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
-
- if isAudio {
- localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
- if localPath != "" {
- mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename))
- content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename))
- } else {
- logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
- "url": attachment.URL,
- "filename": attachment.Filename,
- })
- mediaPaths = append(mediaPaths, attachment.URL)
- content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
- }
- } else {
- mediaPaths = append(mediaPaths, attachment.URL)
- content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
- }
- }
-
- if content == "" && len(mediaPaths) == 0 {
- return
- }
-
- if content == "" {
- content = "[media only]"
- }
-
- logger.DebugCF("discord", "Received message", map[string]any{
- "sender_name": sender.DisplayName,
- "sender_id": senderID,
- "preview": utils.Truncate(content, 50),
- })
-
- peerKind := "channel"
- peerID := m.ChannelID
- if m.GuildID == "" {
- peerKind = "direct"
- peerID = senderID
- }
-
- peer := bus.Peer{Kind: peerKind, ID: peerID}
-
- metadata := map[string]string{
- "user_id": senderID,
- "username": m.Author.Username,
- "display_name": sender.DisplayName,
- "guild_id": m.GuildID,
- "channel_id": m.ChannelID,
- "is_dm": fmt.Sprintf("%t", m.GuildID == ""),
- }
-
- c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender)
-}
-
-// startTyping starts a continuous typing indicator loop for the given chatID.
-// It stops any existing typing loop for that chatID before starting a new one.
-func (c *DiscordChannel) startTyping(chatID string) {
- c.typingMu.Lock()
- // Stop existing loop for this chatID if any
- if stop, ok := c.typingStop[chatID]; ok {
- close(stop)
- }
- stop := make(chan struct{})
- c.typingStop[chatID] = stop
- c.typingMu.Unlock()
-
- go func() {
- if err := c.session.ChannelTyping(chatID); err != nil {
- logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
- }
- ticker := time.NewTicker(8 * time.Second)
- defer ticker.Stop()
- timeout := time.After(5 * time.Minute)
- for {
- select {
- case <-stop:
- return
- case <-timeout:
- return
- case <-c.ctx.Done():
- return
- case <-ticker.C:
- if err := c.session.ChannelTyping(chatID); err != nil {
- logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
- }
- }
- }
- }()
-}
-
-// stopTyping stops the typing indicator loop for the given chatID.
-func (c *DiscordChannel) stopTyping(chatID string) {
- c.typingMu.Lock()
- defer c.typingMu.Unlock()
- if stop, ok := c.typingStop[chatID]; ok {
- close(stop)
- delete(c.typingStop, chatID)
- }
-}
-
-// StartTyping implements channels.TypingCapable.
-// It starts a continuous typing indicator and returns an idempotent stop function.
-func (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
- c.startTyping(chatID)
- return func() { c.stopTyping(chatID) }, nil
-}
-
-func (c *DiscordChannel) downloadAttachment(url, filename string) string {
- return utils.DownloadFile(url, filename, utils.DownloadOptions{
- LoggerPrefix: "discord",
- ProxyURL: c.config.Proxy,
- })
-}
-
-func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
- var proxyFunc func(*http.Request) (*url.URL, error)
- if proxyAddr != "" {
- proxyURL, err := url.Parse(proxyAddr)
- if err != nil {
- return fmt.Errorf("invalid discord proxy URL %q: %w", proxyAddr, err)
- }
- proxyFunc = http.ProxyURL(proxyURL)
- } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
- proxyFunc = http.ProxyFromEnvironment
- }
-
- if proxyFunc == nil {
- return nil
- }
-
- transport := &http.Transport{Proxy: proxyFunc}
- session.Client = &http.Client{
- Timeout: sendTimeout,
- Transport: transport,
- }
-
- if session.Dialer != nil {
- dialerCopy := *session.Dialer
- dialerCopy.Proxy = proxyFunc
- session.Dialer = &dialerCopy
- } else {
- session.Dialer = &websocket.Dialer{Proxy: proxyFunc}
- }
-
- return nil
-}
-
-// stripBotMention removes the bot mention from the message content.
-// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
-func (c *DiscordChannel) stripBotMention(text string) string {
- if c.botUserID == "" {
- return text
- }
- // Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID>
- text = strings.ReplaceAll(text, fmt.Sprintf("<@%s>", c.botUserID), "")
- text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "")
- return strings.TrimSpace(text)
-}
diff --git a/pkg/channels/discord/discord_test.go b/pkg/channels/discord/discord_test.go
deleted file mode 100644
index 0cd5328f4..000000000
--- a/pkg/channels/discord/discord_test.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package discord
-
-import (
- "net/http"
- "net/url"
- "testing"
-
- "github.com/bwmarrin/discordgo"
-)
-
-func TestApplyDiscordProxy_CustomProxy(t *testing.T) {
- session, err := discordgo.New("Bot test-token")
- if err != nil {
- t.Fatalf("discordgo.New() error: %v", err)
- }
-
- if err = applyDiscordProxy(session, "http://127.0.0.1:7890"); err != nil {
- t.Fatalf("applyDiscordProxy() error: %v", err)
- }
-
- req, err := http.NewRequest("GET", "https://discord.com/api/v10/gateway", nil)
- if err != nil {
- t.Fatalf("http.NewRequest() error: %v", err)
- }
-
- restProxy := session.Client.Transport.(*http.Transport).Proxy
- restProxyURL, err := restProxy(req)
- if err != nil {
- t.Fatalf("rest proxy func error: %v", err)
- }
- if got, want := restProxyURL.String(), "http://127.0.0.1:7890"; got != want {
- t.Fatalf("REST proxy = %q, want %q", got, want)
- }
-
- wsProxyURL, err := session.Dialer.Proxy(req)
- if err != nil {
- t.Fatalf("ws proxy func error: %v", err)
- }
- if got, want := wsProxyURL.String(), "http://127.0.0.1:7890"; got != want {
- t.Fatalf("WS proxy = %q, want %q", got, want)
- }
-}
-
-func TestApplyDiscordProxy_FromEnvironment(t *testing.T) {
- t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888")
- t.Setenv("http_proxy", "http://127.0.0.1:8888")
- t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888")
- t.Setenv("https_proxy", "http://127.0.0.1:8888")
- t.Setenv("ALL_PROXY", "")
- t.Setenv("all_proxy", "")
- t.Setenv("NO_PROXY", "")
- t.Setenv("no_proxy", "")
-
- session, err := discordgo.New("Bot test-token")
- if err != nil {
- t.Fatalf("discordgo.New() error: %v", err)
- }
-
- if err = applyDiscordProxy(session, ""); err != nil {
- t.Fatalf("applyDiscordProxy() error: %v", err)
- }
-
- req, err := http.NewRequest("GET", "https://discord.com/api/v10/gateway", nil)
- if err != nil {
- t.Fatalf("http.NewRequest() error: %v", err)
- }
-
- gotURL, err := session.Dialer.Proxy(req)
- if err != nil {
- t.Fatalf("ws proxy func error: %v", err)
- }
-
- wantURL, err := url.Parse("http://127.0.0.1:8888")
- if err != nil {
- t.Fatalf("url.Parse() error: %v", err)
- }
- if gotURL.String() != wantURL.String() {
- t.Fatalf("WS proxy = %q, want %q", gotURL.String(), wantURL.String())
- }
-}
-
-func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) {
- session, err := discordgo.New("Bot test-token")
- if err != nil {
- t.Fatalf("discordgo.New() error: %v", err)
- }
-
- if err = applyDiscordProxy(session, "://bad-proxy"); err == nil {
- t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil")
- }
-}
diff --git a/pkg/channels/discord/init.go b/pkg/channels/discord/init.go
deleted file mode 100644
index 15a539804..000000000
--- a/pkg/channels/discord/init.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package discord
-
-import (
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func init() {
- channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewDiscordChannel(cfg.Channels.Discord, b)
- })
-}
diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go
deleted file mode 100644
index fbe085b73..000000000
--- a/pkg/channels/feishu/common.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package feishu
-
-import (
- "encoding/json"
- "regexp"
- "strings"
-
- larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
-)
-
-// mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions.
-var mentionPlaceholderRegex = regexp.MustCompile(`@_user_\d+`)
-
-// stringValue safely dereferences a *string pointer.
-func stringValue(v *string) string {
- if v == nil {
- return ""
- }
- return *v
-}
-
-// buildMarkdownCard builds a Feishu Interactive Card JSON 2.0 string with markdown content.
-// JSON 2.0 cards support full CommonMark standard markdown syntax.
-func buildMarkdownCard(content string) (string, error) {
- card := map[string]any{
- "schema": "2.0",
- "body": map[string]any{
- "elements": []map[string]any{
- {
- "tag": "markdown",
- "content": content,
- },
- },
- },
- }
- data, err := json.Marshal(card)
- if err != nil {
- return "", err
- }
- return string(data), nil
-}
-
-// extractJSONStringField unmarshals content as JSON and returns the value of the given string field.
-// Returns "" if the content is invalid JSON or the field is missing/empty.
-func extractJSONStringField(content, field string) string {
- var m map[string]json.RawMessage
- if err := json.Unmarshal([]byte(content), &m); err != nil {
- return ""
- }
- raw, ok := m[field]
- if !ok {
- return ""
- }
- var s string
- if err := json.Unmarshal(raw, &s); err != nil {
- return ""
- }
- return s
-}
-
-// extractImageKey extracts the image_key from a Feishu image message content JSON.
-// Format: {"image_key": "img_xxx"}
-func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") }
-
-// extractFileKey extracts the file_key from a Feishu file/audio message content JSON.
-// Format: {"file_key": "file_xxx", "file_name": "...", ...}
-func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") }
-
-// extractFileName extracts the file_name from a Feishu file message content JSON.
-func extractFileName(content string) string { return extractJSONStringField(content, "file_name") }
-
-// stripMentionPlaceholders removes @_user_N placeholders from the text content.
-// These are inserted by Feishu when users @mention someone in a message.
-func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string {
- if len(mentions) == 0 {
- return content
- }
- for _, m := range mentions {
- if m.Key != nil && *m.Key != "" {
- content = strings.ReplaceAll(content, *m.Key, "")
- }
- }
- // Also clean up any remaining @_user_N patterns
- content = mentionPlaceholderRegex.ReplaceAllString(content, "")
- return strings.TrimSpace(content)
-}
diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go
deleted file mode 100644
index fefc9f7c1..000000000
--- a/pkg/channels/feishu/common_test.go
+++ /dev/null
@@ -1,292 +0,0 @@
-package feishu
-
-import (
- "encoding/json"
- "testing"
-
- larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
-)
-
-func TestExtractJSONStringField(t *testing.T) {
- tests := []struct {
- name string
- content string
- field string
- want string
- }{
- {
- name: "valid field",
- content: `{"image_key": "img_v2_xxx"}`,
- field: "image_key",
- want: "img_v2_xxx",
- },
- {
- name: "missing field",
- content: `{"image_key": "img_v2_xxx"}`,
- field: "file_key",
- want: "",
- },
- {
- name: "invalid JSON",
- content: `not json at all`,
- field: "image_key",
- want: "",
- },
- {
- name: "empty content",
- content: "",
- field: "image_key",
- want: "",
- },
- {
- name: "non-string field value",
- content: `{"count": 42}`,
- field: "count",
- want: "",
- },
- {
- name: "empty string value",
- content: `{"image_key": ""}`,
- field: "image_key",
- want: "",
- },
- {
- name: "multiple fields",
- content: `{"file_key": "file_xxx", "file_name": "test.pdf"}`,
- field: "file_name",
- want: "test.pdf",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := extractJSONStringField(tt.content, tt.field)
- if got != tt.want {
- t.Errorf("extractJSONStringField(%q, %q) = %q, want %q", tt.content, tt.field, got, tt.want)
- }
- })
- }
-}
-
-func TestExtractImageKey(t *testing.T) {
- tests := []struct {
- name string
- content string
- want string
- }{
- {
- name: "normal",
- content: `{"image_key": "img_v2_abc123"}`,
- want: "img_v2_abc123",
- },
- {
- name: "missing key",
- content: `{"file_key": "file_xxx"}`,
- want: "",
- },
- {
- name: "malformed JSON",
- content: `{broken`,
- want: "",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := extractImageKey(tt.content)
- if got != tt.want {
- t.Errorf("extractImageKey(%q) = %q, want %q", tt.content, got, tt.want)
- }
- })
- }
-}
-
-func TestExtractFileKey(t *testing.T) {
- tests := []struct {
- name string
- content string
- want string
- }{
- {
- name: "normal",
- content: `{"file_key": "file_v2_abc123", "file_name": "test.doc"}`,
- want: "file_v2_abc123",
- },
- {
- name: "missing key",
- content: `{"image_key": "img_xxx"}`,
- want: "",
- },
- {
- name: "malformed JSON",
- content: `not json`,
- want: "",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := extractFileKey(tt.content)
- if got != tt.want {
- t.Errorf("extractFileKey(%q) = %q, want %q", tt.content, got, tt.want)
- }
- })
- }
-}
-
-func TestExtractFileName(t *testing.T) {
- tests := []struct {
- name string
- content string
- want string
- }{
- {
- name: "normal",
- content: `{"file_key": "file_xxx", "file_name": "report.pdf"}`,
- want: "report.pdf",
- },
- {
- name: "missing name",
- content: `{"file_key": "file_xxx"}`,
- want: "",
- },
- {
- name: "malformed JSON",
- content: `{bad`,
- want: "",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := extractFileName(tt.content)
- if got != tt.want {
- t.Errorf("extractFileName(%q) = %q, want %q", tt.content, got, tt.want)
- }
- })
- }
-}
-
-func TestBuildMarkdownCard(t *testing.T) {
- tests := []struct {
- name string
- content string
- }{
- {
- name: "normal content",
- content: "Hello **world**",
- },
- {
- name: "empty content",
- content: "",
- },
- {
- name: "special characters",
- content: `Code: "foo" & 'baz'`,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result, err := buildMarkdownCard(tt.content)
- if err != nil {
- t.Fatalf("buildMarkdownCard(%q) unexpected error: %v", tt.content, err)
- }
-
- // Verify valid JSON
- var parsed map[string]any
- if err := json.Unmarshal([]byte(result), &parsed); err != nil {
- t.Fatalf("buildMarkdownCard(%q) produced invalid JSON: %v", tt.content, err)
- }
-
- // Verify schema
- if parsed["schema"] != "2.0" {
- t.Errorf("schema = %v, want %q", parsed["schema"], "2.0")
- }
-
- // Verify body.elements[0].content == input
- body, ok := parsed["body"].(map[string]any)
- if !ok {
- t.Fatal("missing body in card JSON")
- }
- elements, ok := body["elements"].([]any)
- if !ok || len(elements) == 0 {
- t.Fatal("missing or empty elements in card JSON")
- }
- elem, ok := elements[0].(map[string]any)
- if !ok {
- t.Fatal("first element is not an object")
- }
- if elem["tag"] != "markdown" {
- t.Errorf("tag = %v, want %q", elem["tag"], "markdown")
- }
- if elem["content"] != tt.content {
- t.Errorf("content = %v, want %q", elem["content"], tt.content)
- }
- })
- }
-}
-
-func TestStripMentionPlaceholders(t *testing.T) {
- strPtr := func(s string) *string { return &s }
-
- tests := []struct {
- name string
- content string
- mentions []*larkim.MentionEvent
- want string
- }{
- {
- name: "no mentions",
- content: "Hello world",
- mentions: nil,
- want: "Hello world",
- },
- {
- name: "single mention",
- content: "@_user_1 hello",
- mentions: []*larkim.MentionEvent{
- {Key: strPtr("@_user_1")},
- },
- want: "hello",
- },
- {
- name: "multiple mentions",
- content: "@_user_1 @_user_2 hey",
- mentions: []*larkim.MentionEvent{
- {Key: strPtr("@_user_1")},
- {Key: strPtr("@_user_2")},
- },
- want: "hey",
- },
- {
- name: "empty content",
- content: "",
- mentions: []*larkim.MentionEvent{{Key: strPtr("@_user_1")}},
- want: "",
- },
- {
- name: "empty mentions slice",
- content: "@_user_1 test",
- mentions: []*larkim.MentionEvent{},
- want: "@_user_1 test",
- },
- {
- name: "mention with nil key",
- content: "@_user_1 test",
- mentions: []*larkim.MentionEvent{
- {Key: nil},
- },
- want: "test",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := stripMentionPlaceholders(tt.content, tt.mentions)
- if got != tt.want {
- t.Errorf("stripMentionPlaceholders(%q, ...) = %q, want %q", tt.content, got, tt.want)
- }
- })
- }
-}
diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go
deleted file mode 100644
index f5e3aa224..000000000
--- a/pkg/channels/feishu/feishu_32.go
+++ /dev/null
@@ -1,61 +0,0 @@
-//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64
-
-package feishu
-
-import (
- "context"
- "errors"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-// FeishuChannel is a stub implementation for 32-bit architectures
-type FeishuChannel struct {
- *channels.BaseChannel
-}
-
-var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures")
-
-// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
-func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
- return nil, errors.New(
- "feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
- )
-}
-
-// Start is a stub method to satisfy the Channel interface
-func (c *FeishuChannel) Start(ctx context.Context) error {
- return errUnsupported
-}
-
-// Stop is a stub method to satisfy the Channel interface
-func (c *FeishuChannel) Stop(ctx context.Context) error {
- return errUnsupported
-}
-
-// Send is a stub method to satisfy the Channel interface
-func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- return errUnsupported
-}
-
-// EditMessage is a stub method to satisfy MessageEditor
-func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
- return errUnsupported
-}
-
-// SendPlaceholder is a stub method to satisfy PlaceholderCapable
-func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
- return "", errUnsupported
-}
-
-// ReactToMessage is a stub method to satisfy ReactionCapable
-func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
- return func() {}, errUnsupported
-}
-
-// SendMedia is a stub method to satisfy MediaSender
-func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
- return errUnsupported
-}
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
deleted file mode 100644
index 00f73064d..000000000
--- a/pkg/channels/feishu/feishu_64.go
+++ /dev/null
@@ -1,818 +0,0 @@
-//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
-
-package feishu
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "sync"
- "sync/atomic"
-
- lark "github.com/larksuite/oapi-sdk-go/v3"
- larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
- larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
- larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
- larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/media"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-type FeishuChannel struct {
- *channels.BaseChannel
- config config.FeishuConfig
- client *lark.Client
- wsClient *larkws.Client
-
- botOpenID atomic.Value // stores string; populated lazily for @mention detection
-
- mu sync.Mutex
- cancel context.CancelFunc
-}
-
-func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
- base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
- channels.WithGroupTrigger(cfg.GroupTrigger),
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
- )
-
- ch := &FeishuChannel{
- BaseChannel: base,
- config: cfg,
- client: lark.NewClient(cfg.AppID, cfg.AppSecret),
- }
- ch.SetOwner(ch)
- return ch, nil
-}
-
-func (c *FeishuChannel) Start(ctx context.Context) error {
- if c.config.AppID == "" || c.config.AppSecret == "" {
- return fmt.Errorf("feishu app_id or app_secret is empty")
- }
-
- // Fetch bot open_id via API for reliable @mention detection.
- if err := c.fetchBotOpenID(ctx); err != nil {
- logger.ErrorCF("feishu", "Failed to fetch bot open_id, @mention detection may not work", map[string]any{
- "error": err.Error(),
- })
- }
-
- dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey).
- OnP2MessageReceiveV1(c.handleMessageReceive)
-
- runCtx, cancel := context.WithCancel(ctx)
-
- c.mu.Lock()
- c.cancel = cancel
- c.wsClient = larkws.NewClient(
- c.config.AppID,
- c.config.AppSecret,
- larkws.WithEventHandler(dispatcher),
- )
- wsClient := c.wsClient
- c.mu.Unlock()
-
- c.SetRunning(true)
- logger.InfoC("feishu", "Feishu channel started (websocket mode)")
-
- go func() {
- if err := wsClient.Start(runCtx); err != nil {
- logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{
- "error": err.Error(),
- })
- }
- }()
-
- return nil
-}
-
-func (c *FeishuChannel) Stop(ctx context.Context) error {
- c.mu.Lock()
- if c.cancel != nil {
- c.cancel()
- c.cancel = nil
- }
- c.wsClient = nil
- c.mu.Unlock()
-
- c.SetRunning(false)
- logger.InfoC("feishu", "Feishu channel stopped")
- return nil
-}
-
-// Send sends a message using Interactive Card format for markdown rendering.
-func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- if msg.ChatID == "" {
- return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
- }
-
- // Build interactive card with markdown content
- cardContent, err := buildMarkdownCard(msg.Content)
- if err != nil {
- return fmt.Errorf("feishu send: card build failed: %w", err)
- }
- return c.sendCard(ctx, msg.ChatID, cardContent)
-}
-
-// EditMessage implements channels.MessageEditor.
-// Uses Message.Patch to update an interactive card message.
-func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
- cardContent, err := buildMarkdownCard(content)
- if err != nil {
- return fmt.Errorf("feishu edit: card build failed: %w", err)
- }
-
- req := larkim.NewPatchMessageReqBuilder().
- MessageId(messageID).
- Body(larkim.NewPatchMessageReqBodyBuilder().Content(cardContent).Build()).
- Build()
-
- resp, err := c.client.Im.V1.Message.Patch(ctx, req)
- if err != nil {
- return fmt.Errorf("feishu edit: %w", err)
- }
- if !resp.Success() {
- return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg)
- }
- return nil
-}
-
-// SendPlaceholder implements channels.PlaceholderCapable.
-// Sends an interactive card with placeholder text and returns its message ID.
-func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
- if !c.config.Placeholder.Enabled {
- logger.DebugCF("feishu", "Placeholder disabled, skipping", map[string]any{
- "chat_id": chatID,
- })
- return "", nil
- }
-
- text := c.config.Placeholder.Text
- if text == "" {
- text = "Thinking..."
- }
-
- cardContent, err := buildMarkdownCard(text)
- if err != nil {
- return "", fmt.Errorf("feishu placeholder: card build failed: %w", err)
- }
-
- req := larkim.NewCreateMessageReqBuilder().
- ReceiveIdType(larkim.ReceiveIdTypeChatId).
- Body(larkim.NewCreateMessageReqBodyBuilder().
- ReceiveId(chatID).
- MsgType(larkim.MsgTypeInteractive).
- Content(cardContent).
- Build()).
- Build()
-
- resp, err := c.client.Im.V1.Message.Create(ctx, req)
- if err != nil {
- return "", fmt.Errorf("feishu placeholder send: %w", err)
- }
- if !resp.Success() {
- return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg)
- }
-
- if resp.Data != nil && resp.Data.MessageId != nil {
- return *resp.Data.MessageId, nil
- }
- return "", nil
-}
-
-// ReactToMessage implements channels.ReactionCapable.
-// Adds an "Pin" reaction and returns an undo function to remove it.
-func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
- req := larkim.NewCreateMessageReactionReqBuilder().
- MessageId(messageID).
- Body(larkim.NewCreateMessageReactionReqBodyBuilder().
- ReactionType(larkim.NewEmojiBuilder().EmojiType("Pin").Build()).
- Build()).
- Build()
-
- resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req)
- if err != nil {
- logger.ErrorCF("feishu", "Failed to add reaction", map[string]any{
- "message_id": messageID,
- "error": err.Error(),
- })
- return func() {}, fmt.Errorf("feishu react: %w", err)
- }
- if !resp.Success() {
- logger.ErrorCF("feishu", "Reaction API error", map[string]any{
- "message_id": messageID,
- "code": resp.Code,
- "msg": resp.Msg,
- })
- return func() {}, fmt.Errorf("feishu react api error (code=%d msg=%s)", resp.Code, resp.Msg)
- }
-
- var reactionID string
- if resp.Data != nil && resp.Data.ReactionId != nil {
- reactionID = *resp.Data.ReactionId
- }
- if reactionID == "" {
- return func() {}, nil
- }
-
- var undone atomic.Bool
- undo := func() {
- if !undone.CompareAndSwap(false, true) {
- return
- }
- delReq := larkim.NewDeleteMessageReactionReqBuilder().
- MessageId(messageID).
- ReactionId(reactionID).
- Build()
- _, _ = c.client.Im.V1.MessageReaction.Delete(context.Background(), delReq)
- }
- return undo, nil
-}
-
-// SendMedia implements channels.MediaSender.
-// Uploads images/files via Feishu API then sends as messages.
-func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- if msg.ChatID == "" {
- return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
- }
-
- store := c.GetMediaStore()
- if store == nil {
- return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
- }
-
- for _, part := range msg.Parts {
- if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-// sendMediaPart resolves and sends a single media part.
-func (c *FeishuChannel) sendMediaPart(
- ctx context.Context,
- chatID string,
- part bus.MediaPart,
- store media.MediaStore,
-) error {
- localPath, err := store.Resolve(part.Ref)
- if err != nil {
- logger.ErrorCF("feishu", "Failed to resolve media ref", map[string]any{
- "ref": part.Ref,
- "error": err.Error(),
- })
- return nil // skip this part
- }
-
- file, err := os.Open(localPath)
- if err != nil {
- logger.ErrorCF("feishu", "Failed to open media file", map[string]any{
- "path": localPath,
- "error": err.Error(),
- })
- return nil // skip this part
- }
- defer file.Close()
-
- switch part.Type {
- case "image":
- err = c.sendImage(ctx, chatID, file)
- default:
- filename := part.Filename
- if filename == "" {
- filename = "file"
- }
- err = c.sendFile(ctx, chatID, file, filename, part.Type)
- }
-
- if err != nil {
- logger.ErrorCF("feishu", "Failed to send media", map[string]any{
- "type": part.Type,
- "error": err.Error(),
- })
- return fmt.Errorf("feishu send media: %w", channels.ErrTemporary)
- }
- return nil
-}
-
-// --- Inbound message handling ---
-
-func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
- if event == nil || event.Event == nil || event.Event.Message == nil {
- return nil
- }
-
- message := event.Event.Message
- sender := event.Event.Sender
-
- chatID := stringValue(message.ChatId)
- if chatID == "" {
- return nil
- }
-
- senderID := extractFeishuSenderID(sender)
- if senderID == "" {
- senderID = "unknown"
- }
-
- messageType := stringValue(message.MessageType)
- messageID := stringValue(message.MessageId)
- rawContent := stringValue(message.Content)
-
- // Check allowlist early to avoid downloading media for rejected senders.
- // BaseChannel.HandleMessage will check again, but this avoids wasted network I/O.
- senderInfo := bus.SenderInfo{
- Platform: "feishu",
- PlatformID: senderID,
- CanonicalID: identity.BuildCanonicalID("feishu", senderID),
- }
- if !c.IsAllowedSender(senderInfo) {
- return nil
- }
-
- // Extract content based on message type
- content := extractContent(messageType, rawContent)
-
- // Handle media messages (download and store)
- var mediaRefs []string
- if store := c.GetMediaStore(); store != nil && messageID != "" {
- mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store)
- }
-
- // Append media tags to content (like Telegram does)
- content = appendMediaTags(content, messageType, mediaRefs)
-
- if content == "" {
- content = "[empty message]"
- }
-
- metadata := map[string]string{}
- if messageID != "" {
- metadata["message_id"] = messageID
- }
- if messageType != "" {
- metadata["message_type"] = messageType
- }
- chatType := stringValue(message.ChatType)
- if chatType != "" {
- metadata["chat_type"] = chatType
- }
- if sender != nil && sender.TenantKey != nil {
- metadata["tenant_key"] = *sender.TenantKey
- }
-
- var peer bus.Peer
- if chatType == "p2p" {
- peer = bus.Peer{Kind: "direct", ID: senderID}
- } else {
- peer = bus.Peer{Kind: "group", ID: chatID}
-
- // Check if bot was mentioned
- isMentioned := c.isBotMentioned(message)
-
- // Strip mention placeholders from content before group trigger check
- if len(message.Mentions) > 0 {
- content = stripMentionPlaceholders(content, message.Mentions)
- }
-
- // In group chats, apply unified group trigger filtering
- respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
- if !respond {
- return nil
- }
- content = cleaned
- }
-
- logger.InfoCF("feishu", "Feishu message received", map[string]any{
- "sender_id": senderID,
- "chat_id": chatID,
- "message_id": messageID,
- "preview": utils.Truncate(content, 80),
- })
-
- c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo)
- return nil
-}
-
-// --- Internal helpers ---
-
-// fetchBotOpenID calls the Feishu bot info API to retrieve and store the bot's open_id.
-func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error {
- resp, err := c.client.Do(ctx, &larkcore.ApiReq{
- HttpMethod: http.MethodGet,
- ApiPath: "/open-apis/bot/v3/info",
- SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
- })
- if err != nil {
- return fmt.Errorf("bot info request: %w", err)
- }
-
- var result struct {
- Code int `json:"code"`
- Bot struct {
- OpenID string `json:"open_id"`
- } `json:"bot"`
- }
- if err := json.Unmarshal(resp.RawBody, &result); err != nil {
- return fmt.Errorf("bot info parse: %w", err)
- }
- if result.Code != 0 {
- return fmt.Errorf("bot info api error (code=%d)", result.Code)
- }
- if result.Bot.OpenID == "" {
- return fmt.Errorf("bot info: empty open_id")
- }
-
- c.botOpenID.Store(result.Bot.OpenID)
- logger.InfoCF("feishu", "Fetched bot open_id from API", map[string]any{
- "open_id": result.Bot.OpenID,
- })
- return nil
-}
-
-// isBotMentioned checks if the bot was @mentioned in the message.
-func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool {
- if message.Mentions == nil {
- return false
- }
-
- knownID, _ := c.botOpenID.Load().(string)
- if knownID == "" {
- logger.DebugCF("feishu", "Bot open_id unknown, cannot detect @mention", nil)
- return false
- }
-
- for _, m := range message.Mentions {
- if m.Id == nil {
- continue
- }
- if m.Id.OpenId != nil && *m.Id.OpenId == knownID {
- return true
- }
- }
- return false
-}
-
-// extractContent extracts text content from different message types.
-func extractContent(messageType, rawContent string) string {
- if rawContent == "" {
- return ""
- }
-
- switch messageType {
- case larkim.MsgTypeText:
- var textPayload struct {
- Text string `json:"text"`
- }
- if err := json.Unmarshal([]byte(rawContent), &textPayload); err == nil {
- return textPayload.Text
- }
- return rawContent
-
- case larkim.MsgTypePost:
- // Pass raw JSON to LLM — structured rich text is more informative than flattened plain text
- return rawContent
-
- case larkim.MsgTypeImage:
- // Image messages don't have text content
- return ""
-
- case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
- // File/audio/video messages may have a filename
- name := extractFileName(rawContent)
- if name != "" {
- return name
- }
- return ""
-
- default:
- return rawContent
- }
-}
-
-// downloadInboundMedia downloads media from inbound messages and stores in MediaStore.
-func (c *FeishuChannel) downloadInboundMedia(
- ctx context.Context,
- chatID, messageID, messageType, rawContent string,
- store media.MediaStore,
-) []string {
- var refs []string
- scope := channels.BuildMediaScope("feishu", chatID, messageID)
-
- switch messageType {
- case larkim.MsgTypeImage:
- imageKey := extractImageKey(rawContent)
- if imageKey == "" {
- return nil
- }
- ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
- if ref != "" {
- refs = append(refs, ref)
- }
-
- case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
- fileKey := extractFileKey(rawContent)
- if fileKey == "" {
- return nil
- }
- // Derive a fallback extension from the message type.
- var ext string
- switch messageType {
- case larkim.MsgTypeAudio:
- ext = ".ogg"
- case larkim.MsgTypeMedia:
- ext = ".mp4"
- default:
- ext = "" // generic file — rely on resp.FileName
- }
- ref := c.downloadResource(ctx, messageID, fileKey, "file", ext, store, scope)
- if ref != "" {
- refs = append(refs, ref)
- }
- }
-
- return refs
-}
-
-// downloadResource downloads a message resource (image/file) from Feishu,
-// writes it to the project media directory, and stores the reference in MediaStore.
-// fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension.
-func (c *FeishuChannel) downloadResource(
- ctx context.Context,
- messageID, fileKey, resourceType, fallbackExt string,
- store media.MediaStore,
- scope string,
-) string {
- req := larkim.NewGetMessageResourceReqBuilder().
- MessageId(messageID).
- FileKey(fileKey).
- Type(resourceType).
- Build()
-
- resp, err := c.client.Im.V1.MessageResource.Get(ctx, req)
- if err != nil {
- logger.ErrorCF("feishu", "Failed to download resource", map[string]any{
- "message_id": messageID,
- "file_key": fileKey,
- "error": err.Error(),
- })
- return ""
- }
- if !resp.Success() {
- logger.ErrorCF("feishu", "Resource download api error", map[string]any{
- "code": resp.Code,
- "msg": resp.Msg,
- })
- return ""
- }
-
- if resp.File == nil {
- return ""
- }
- // Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body).
- if closer, ok := resp.File.(io.Closer); ok {
- defer closer.Close()
- }
-
- filename := resp.FileName
- if filename == "" {
- filename = fileKey
- }
- // If filename still has no extension, append the fallback (like Telegram's ext parameter).
- if filepath.Ext(filename) == "" && fallbackExt != "" {
- filename += fallbackExt
- }
-
- // Write to the shared picoclaw_media directory using a unique name to avoid collisions.
- mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
- if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
- logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{
- "error": mkdirErr.Error(),
- })
- return ""
- }
- ext := filepath.Ext(filename)
- localPath := filepath.Join(mediaDir, utils.SanitizeFilename(messageID+"-"+fileKey+ext))
-
- out, err := os.Create(localPath)
- if err != nil {
- logger.ErrorCF("feishu", "Failed to create local file for resource", map[string]any{
- "error": err.Error(),
- })
- return ""
- }
-
- if _, copyErr := io.Copy(out, resp.File); copyErr != nil {
- out.Close()
- os.Remove(localPath)
- logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{
- "error": copyErr.Error(),
- })
- return ""
- }
- out.Close()
-
- ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "feishu",
- }, scope)
- if err != nil {
- logger.ErrorCF("feishu", "Failed to store downloaded resource", map[string]any{
- "file_key": fileKey,
- "error": err.Error(),
- })
- os.Remove(localPath)
- return ""
- }
-
- return ref
-}
-
-// appendMediaTags appends media type tags to content (like Telegram's "[image: photo]").
-func appendMediaTags(content, messageType string, mediaRefs []string) string {
- if len(mediaRefs) == 0 {
- return content
- }
-
- var tag string
- switch messageType {
- case larkim.MsgTypeImage:
- tag = "[image: photo]"
- case larkim.MsgTypeAudio:
- tag = "[audio]"
- case larkim.MsgTypeMedia:
- tag = "[video]"
- case larkim.MsgTypeFile:
- tag = "[file]"
- default:
- tag = "[attachment]"
- }
-
- if content == "" {
- return tag
- }
- return content + " " + tag
-}
-
-// sendCard sends an interactive card message to a chat.
-func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error {
- req := larkim.NewCreateMessageReqBuilder().
- ReceiveIdType(larkim.ReceiveIdTypeChatId).
- Body(larkim.NewCreateMessageReqBodyBuilder().
- ReceiveId(chatID).
- MsgType(larkim.MsgTypeInteractive).
- Content(cardContent).
- Build()).
- Build()
-
- resp, err := c.client.Im.V1.Message.Create(ctx, req)
- if err != nil {
- return fmt.Errorf("feishu send card: %w", channels.ErrTemporary)
- }
-
- if !resp.Success() {
- return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
- }
-
- logger.DebugCF("feishu", "Feishu card message sent", map[string]any{
- "chat_id": chatID,
- })
-
- return nil
-}
-
-// sendImage uploads an image and sends it as a message.
-func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error {
- // Upload image to get image_key
- uploadReq := larkim.NewCreateImageReqBuilder().
- Body(larkim.NewCreateImageReqBodyBuilder().
- ImageType("message").
- Image(file).
- Build()).
- Build()
-
- uploadResp, err := c.client.Im.V1.Image.Create(ctx, uploadReq)
- if err != nil {
- return fmt.Errorf("feishu image upload: %w", err)
- }
- if !uploadResp.Success() {
- return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg)
- }
- if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil {
- return fmt.Errorf("feishu image upload: no image_key returned")
- }
-
- imageKey := *uploadResp.Data.ImageKey
-
- // Send image message
- content, _ := json.Marshal(map[string]string{"image_key": imageKey})
- req := larkim.NewCreateMessageReqBuilder().
- ReceiveIdType(larkim.ReceiveIdTypeChatId).
- Body(larkim.NewCreateMessageReqBodyBuilder().
- ReceiveId(chatID).
- MsgType(larkim.MsgTypeImage).
- Content(string(content)).
- Build()).
- Build()
-
- resp, err := c.client.Im.V1.Message.Create(ctx, req)
- if err != nil {
- return fmt.Errorf("feishu image send: %w", err)
- }
- if !resp.Success() {
- return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg)
- }
- return nil
-}
-
-// sendFile uploads a file and sends it as a message.
-func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.File, filename, fileType string) error {
- // Map part type to Feishu file type
- feishuFileType := "stream"
- switch fileType {
- case "audio":
- feishuFileType = "opus"
- case "video":
- feishuFileType = "mp4"
- }
-
- // Upload file to get file_key
- uploadReq := larkim.NewCreateFileReqBuilder().
- Body(larkim.NewCreateFileReqBodyBuilder().
- FileType(feishuFileType).
- FileName(filename).
- File(file).
- Build()).
- Build()
-
- uploadResp, err := c.client.Im.V1.File.Create(ctx, uploadReq)
- if err != nil {
- return fmt.Errorf("feishu file upload: %w", err)
- }
- if !uploadResp.Success() {
- return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg)
- }
- if uploadResp.Data == nil || uploadResp.Data.FileKey == nil {
- return fmt.Errorf("feishu file upload: no file_key returned")
- }
-
- fileKey := *uploadResp.Data.FileKey
-
- // Send file message
- content, _ := json.Marshal(map[string]string{"file_key": fileKey})
- req := larkim.NewCreateMessageReqBuilder().
- ReceiveIdType(larkim.ReceiveIdTypeChatId).
- Body(larkim.NewCreateMessageReqBodyBuilder().
- ReceiveId(chatID).
- MsgType(larkim.MsgTypeFile).
- Content(string(content)).
- Build()).
- Build()
-
- resp, err := c.client.Im.V1.Message.Create(ctx, req)
- if err != nil {
- return fmt.Errorf("feishu file send: %w", err)
- }
- if !resp.Success() {
- return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg)
- }
- return nil
-}
-
-func extractFeishuSenderID(sender *larkim.EventSender) string {
- if sender == nil || sender.SenderId == nil {
- return ""
- }
-
- if sender.SenderId.UserId != nil && *sender.SenderId.UserId != "" {
- return *sender.SenderId.UserId
- }
- if sender.SenderId.OpenId != nil && *sender.SenderId.OpenId != "" {
- return *sender.SenderId.OpenId
- }
- if sender.SenderId.UnionId != nil && *sender.SenderId.UnionId != "" {
- return *sender.SenderId.UnionId
- }
-
- return ""
-}
diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go
deleted file mode 100644
index dc3eab2e7..000000000
--- a/pkg/channels/feishu/feishu_64_test.go
+++ /dev/null
@@ -1,256 +0,0 @@
-//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
-
-package feishu
-
-import (
- "testing"
-
- larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
-)
-
-func TestExtractContent(t *testing.T) {
- tests := []struct {
- name string
- messageType string
- rawContent string
- want string
- }{
- {
- name: "text message",
- messageType: "text",
- rawContent: `{"text": "hello world"}`,
- want: "hello world",
- },
- {
- name: "text message invalid JSON",
- messageType: "text",
- rawContent: `not json`,
- want: "not json",
- },
- {
- name: "post message returns raw JSON",
- messageType: "post",
- rawContent: `{"title": "test post"}`,
- want: `{"title": "test post"}`,
- },
- {
- name: "image message returns empty",
- messageType: "image",
- rawContent: `{"image_key": "img_xxx"}`,
- want: "",
- },
- {
- name: "file message with filename",
- messageType: "file",
- rawContent: `{"file_key": "file_xxx", "file_name": "report.pdf"}`,
- want: "report.pdf",
- },
- {
- name: "file message without filename",
- messageType: "file",
- rawContent: `{"file_key": "file_xxx"}`,
- want: "",
- },
- {
- name: "audio message with filename",
- messageType: "audio",
- rawContent: `{"file_key": "file_xxx", "file_name": "recording.ogg"}`,
- want: "recording.ogg",
- },
- {
- name: "media message with filename",
- messageType: "media",
- rawContent: `{"file_key": "file_xxx", "file_name": "video.mp4"}`,
- want: "video.mp4",
- },
- {
- name: "unknown message type returns raw",
- messageType: "sticker",
- rawContent: `{"sticker_id": "sticker_xxx"}`,
- want: `{"sticker_id": "sticker_xxx"}`,
- },
- {
- name: "empty raw content",
- messageType: "text",
- rawContent: "",
- want: "",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := extractContent(tt.messageType, tt.rawContent)
- if got != tt.want {
- t.Errorf("extractContent(%q, %q) = %q, want %q", tt.messageType, tt.rawContent, got, tt.want)
- }
- })
- }
-}
-
-func TestAppendMediaTags(t *testing.T) {
- tests := []struct {
- name string
- content string
- messageType string
- mediaRefs []string
- want string
- }{
- {
- name: "no refs returns content unchanged",
- content: "hello",
- messageType: "image",
- mediaRefs: nil,
- want: "hello",
- },
- {
- name: "empty refs returns content unchanged",
- content: "hello",
- messageType: "image",
- mediaRefs: []string{},
- want: "hello",
- },
- {
- name: "image with content",
- content: "check this",
- messageType: "image",
- mediaRefs: []string{"ref1"},
- want: "check this [image: photo]",
- },
- {
- name: "image empty content",
- content: "",
- messageType: "image",
- mediaRefs: []string{"ref1"},
- want: "[image: photo]",
- },
- {
- name: "audio",
- content: "listen",
- messageType: "audio",
- mediaRefs: []string{"ref1"},
- want: "listen [audio]",
- },
- {
- name: "media/video",
- content: "watch",
- messageType: "media",
- mediaRefs: []string{"ref1"},
- want: "watch [video]",
- },
- {
- name: "file",
- content: "report.pdf",
- messageType: "file",
- mediaRefs: []string{"ref1"},
- want: "report.pdf [file]",
- },
- {
- name: "unknown type",
- content: "something",
- messageType: "sticker",
- mediaRefs: []string{"ref1"},
- want: "something [attachment]",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := appendMediaTags(tt.content, tt.messageType, tt.mediaRefs)
- if got != tt.want {
- t.Errorf(
- "appendMediaTags(%q, %q, %v) = %q, want %q",
- tt.content,
- tt.messageType,
- tt.mediaRefs,
- got,
- tt.want,
- )
- }
- })
- }
-}
-
-func TestExtractFeishuSenderID(t *testing.T) {
- strPtr := func(s string) *string { return &s }
-
- tests := []struct {
- name string
- sender *larkim.EventSender
- want string
- }{
- {
- name: "nil sender",
- sender: nil,
- want: "",
- },
- {
- name: "nil sender ID",
- sender: &larkim.EventSender{SenderId: nil},
- want: "",
- },
- {
- name: "userId preferred",
- sender: &larkim.EventSender{
- SenderId: &larkim.UserId{
- UserId: strPtr("u_abc123"),
- OpenId: strPtr("ou_def456"),
- UnionId: strPtr("on_ghi789"),
- },
- },
- want: "u_abc123",
- },
- {
- name: "openId fallback",
- sender: &larkim.EventSender{
- SenderId: &larkim.UserId{
- UserId: strPtr(""),
- OpenId: strPtr("ou_def456"),
- UnionId: strPtr("on_ghi789"),
- },
- },
- want: "ou_def456",
- },
- {
- name: "unionId fallback",
- sender: &larkim.EventSender{
- SenderId: &larkim.UserId{
- UserId: strPtr(""),
- OpenId: strPtr(""),
- UnionId: strPtr("on_ghi789"),
- },
- },
- want: "on_ghi789",
- },
- {
- name: "all empty strings",
- sender: &larkim.EventSender{
- SenderId: &larkim.UserId{
- UserId: strPtr(""),
- OpenId: strPtr(""),
- UnionId: strPtr(""),
- },
- },
- want: "",
- },
- {
- name: "nil userId pointer falls through",
- sender: &larkim.EventSender{
- SenderId: &larkim.UserId{
- UserId: nil,
- OpenId: strPtr("ou_def456"),
- UnionId: nil,
- },
- },
- want: "ou_def456",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := extractFeishuSenderID(tt.sender)
- if got != tt.want {
- t.Errorf("extractFeishuSenderID() = %q, want %q", got, tt.want)
- }
- })
- }
-}
diff --git a/pkg/channels/feishu/init.go b/pkg/channels/feishu/init.go
deleted file mode 100644
index 7e5a62dae..000000000
--- a/pkg/channels/feishu/init.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package feishu
-
-import (
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func init() {
- channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewFeishuChannel(cfg.Channels.Feishu, b)
- })
-}
diff --git a/pkg/channels/line/init.go b/pkg/channels/line/init.go
deleted file mode 100644
index 9265575cc..000000000
--- a/pkg/channels/line/init.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package line
-
-import (
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func init() {
- channels.RegisterFactory("line", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewLINEChannel(cfg.Channels.LINE, b)
- })
-}
diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go
deleted file mode 100644
index 398f12e6b..000000000
--- a/pkg/channels/line/line.go
+++ /dev/null
@@ -1,673 +0,0 @@
-package line
-
-import (
- "bytes"
- "context"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "strings"
- "sync"
- "time"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/media"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-const (
- lineAPIBase = "https://api.line.me/v2/bot"
- lineDataAPIBase = "https://api-data.line.me/v2/bot"
- lineReplyEndpoint = lineAPIBase + "/message/reply"
- linePushEndpoint = lineAPIBase + "/message/push"
- lineContentEndpoint = lineDataAPIBase + "/message/%s/content"
- lineBotInfoEndpoint = lineAPIBase + "/info"
- lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
- lineReplyTokenMaxAge = 25 * time.Second
-)
-
-type replyTokenEntry struct {
- token string
- timestamp time.Time
-}
-
-// LINEChannel implements the Channel interface for LINE Official Account
-// using the LINE Messaging API with HTTP webhook for receiving messages
-// and REST API for sending messages.
-type LINEChannel struct {
- *channels.BaseChannel
- config config.LINEConfig
- infoClient *http.Client // for bot info lookups (short timeout)
- apiClient *http.Client // for messaging API calls
- botUserID string // Bot's user ID
- botBasicID string // Bot's basic ID (e.g. @216ru...)
- botDisplayName string // Bot's display name for text-based mention detection
- replyTokens sync.Map // chatID -> replyTokenEntry
- quoteTokens sync.Map // chatID -> quoteToken (string)
- ctx context.Context
- cancel context.CancelFunc
-}
-
-// NewLINEChannel creates a new LINE channel instance.
-func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
- if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" {
- return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
- }
-
- base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom,
- channels.WithMaxMessageLength(5000),
- channels.WithGroupTrigger(cfg.GroupTrigger),
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
- )
-
- return &LINEChannel{
- BaseChannel: base,
- config: cfg,
- infoClient: &http.Client{Timeout: 10 * time.Second},
- apiClient: &http.Client{Timeout: 30 * time.Second},
- }, nil
-}
-
-// Start initializes the LINE channel.
-func (c *LINEChannel) Start(ctx context.Context) error {
- logger.InfoC("line", "Starting LINE channel (Webhook Mode)")
-
- c.ctx, c.cancel = context.WithCancel(ctx)
-
- // Fetch bot profile to get bot's userId for mention detection
- if err := c.fetchBotInfo(); err != nil {
- logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
- "error": err.Error(),
- })
- } else {
- logger.InfoCF("line", "Bot info fetched", map[string]any{
- "bot_user_id": c.botUserID,
- "basic_id": c.botBasicID,
- "display_name": c.botDisplayName,
- })
- }
-
- c.SetRunning(true)
- logger.InfoC("line", "LINE channel started (Webhook Mode)")
- return nil
-}
-
-// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API.
-func (c *LINEChannel) fetchBotInfo() error {
- req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil)
- if err != nil {
- return err
- }
- req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
-
- resp, err := c.infoClient.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("bot info API returned status %d", resp.StatusCode)
- }
-
- var info struct {
- UserID string `json:"userId"`
- BasicID string `json:"basicId"`
- DisplayName string `json:"displayName"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
- return err
- }
-
- c.botUserID = info.UserID
- c.botBasicID = info.BasicID
- c.botDisplayName = info.DisplayName
- return nil
-}
-
-// Stop gracefully stops the LINE channel.
-func (c *LINEChannel) Stop(ctx context.Context) error {
- logger.InfoC("line", "Stopping LINE channel")
-
- if c.cancel != nil {
- c.cancel()
- }
-
- c.SetRunning(false)
- logger.InfoC("line", "LINE channel stopped")
- return nil
-}
-
-// WebhookPath returns the path for registering on the shared HTTP server.
-func (c *LINEChannel) WebhookPath() string {
- if c.config.WebhookPath != "" {
- return c.config.WebhookPath
- }
- return "/webhook/line"
-}
-
-// ServeHTTP implements http.Handler for the shared HTTP server.
-func (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- c.webhookHandler(w, r)
-}
-
-// webhookHandler handles incoming LINE webhook requests.
-func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- body, err := io.ReadAll(r.Body)
- if err != nil {
- logger.ErrorCF("line", "Failed to read request body", map[string]any{
- "error": err.Error(),
- })
- http.Error(w, "Bad request", http.StatusBadRequest)
- return
- }
-
- signature := r.Header.Get("X-Line-Signature")
- if !c.verifySignature(body, signature) {
- logger.WarnC("line", "Invalid webhook signature")
- http.Error(w, "Forbidden", http.StatusForbidden)
- return
- }
-
- var payload struct {
- Events []lineEvent `json:"events"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{
- "error": err.Error(),
- })
- http.Error(w, "Bad request", http.StatusBadRequest)
- return
- }
-
- // Return 200 immediately, process events asynchronously
- w.WriteHeader(http.StatusOK)
-
- for _, event := range payload.Events {
- go c.processEvent(event)
- }
-}
-
-// verifySignature validates the X-Line-Signature using HMAC-SHA256.
-func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
- if signature == "" {
- return false
- }
-
- mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret))
- mac.Write(body)
- expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
-
- return hmac.Equal([]byte(expected), []byte(signature))
-}
-
-// LINE webhook event types
-type lineEvent struct {
- Type string `json:"type"`
- ReplyToken string `json:"replyToken"`
- Source lineSource `json:"source"`
- Message json.RawMessage `json:"message"`
- Timestamp int64 `json:"timestamp"`
-}
-
-type lineSource struct {
- Type string `json:"type"` // "user", "group", "room"
- UserID string `json:"userId"`
- GroupID string `json:"groupId"`
- RoomID string `json:"roomId"`
-}
-
-type lineMessage struct {
- ID string `json:"id"`
- Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker"
- Text string `json:"text"`
- QuoteToken string `json:"quoteToken"`
- Mention *struct {
- Mentionees []lineMentionee `json:"mentionees"`
- } `json:"mention"`
- ContentProvider struct {
- Type string `json:"type"`
- } `json:"contentProvider"`
-}
-
-type lineMentionee struct {
- Index int `json:"index"`
- Length int `json:"length"`
- Type string `json:"type"` // "user", "all"
- UserID string `json:"userId"`
-}
-
-func (c *LINEChannel) processEvent(event lineEvent) {
- if event.Type != "message" {
- logger.DebugCF("line", "Ignoring non-message event", map[string]any{
- "type": event.Type,
- })
- return
- }
-
- senderID := event.Source.UserID
- chatID := c.resolveChatID(event.Source)
- isGroup := event.Source.Type == "group" || event.Source.Type == "room"
-
- var msg lineMessage
- if err := json.Unmarshal(event.Message, &msg); err != nil {
- logger.ErrorCF("line", "Failed to parse message", map[string]any{
- "error": err.Error(),
- })
- return
- }
-
- // Store reply token for later use
- if event.ReplyToken != "" {
- c.replyTokens.Store(chatID, replyTokenEntry{
- token: event.ReplyToken,
- timestamp: time.Now(),
- })
- }
-
- // Store quote token for quoting the original message in reply
- if msg.QuoteToken != "" {
- c.quoteTokens.Store(chatID, msg.QuoteToken)
- }
-
- var content string
- var mediaPaths []string
-
- scope := channels.BuildMediaScope("line", chatID, msg.ID)
-
- // Helper to register a local file with the media store
- storeMedia := func(localPath, filename string) string {
- if store := c.GetMediaStore(); store != nil {
- ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "line",
- }, scope)
- if err == nil {
- return ref
- }
- }
- return localPath // fallback
- }
-
- switch msg.Type {
- case "text":
- content = msg.Text
- // Strip bot mention from text in group chats
- if isGroup {
- content = c.stripBotMention(content, msg)
- }
- case "image":
- localPath := c.downloadContent(msg.ID, "image.jpg")
- if localPath != "" {
- mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg"))
- content = "[image]"
- }
- case "audio":
- localPath := c.downloadContent(msg.ID, "audio.m4a")
- if localPath != "" {
- mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a"))
- content = "[audio]"
- }
- case "video":
- localPath := c.downloadContent(msg.ID, "video.mp4")
- if localPath != "" {
- mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4"))
- content = "[video]"
- }
- case "file":
- content = "[file]"
- case "sticker":
- content = "[sticker]"
- default:
- content = fmt.Sprintf("[%s]", msg.Type)
- }
-
- if strings.TrimSpace(content) == "" {
- return
- }
-
- // In group chats, apply unified group trigger filtering
- if isGroup {
- isMentioned := c.isBotMentioned(msg)
- respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
- if !respond {
- logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{
- "chat_id": chatID,
- })
- return
- }
- content = cleaned
- }
-
- metadata := map[string]string{
- "platform": "line",
- "source_type": event.Source.Type,
- }
-
- var peer bus.Peer
- if isGroup {
- peer = bus.Peer{Kind: "group", ID: chatID}
- } else {
- peer = bus.Peer{Kind: "direct", ID: senderID}
- }
-
- logger.DebugCF("line", "Received message", map[string]any{
- "sender_id": senderID,
- "chat_id": chatID,
- "message_type": msg.Type,
- "is_group": isGroup,
- "preview": utils.Truncate(content, 50),
- })
-
- sender := bus.SenderInfo{
- Platform: "line",
- PlatformID: senderID,
- CanonicalID: identity.BuildCanonicalID("line", senderID),
- }
-
- if !c.IsAllowedSender(sender) {
- return
- }
-
- c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender)
-}
-
-// isBotMentioned checks if the bot is mentioned in the message.
-// It first checks the mention metadata (userId match), then falls back
-// to text-based detection using the bot's display name, since LINE may
-// not include userId in mentionees for Official Accounts.
-func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
- // Check mention metadata
- if msg.Mention != nil {
- for _, m := range msg.Mention.Mentionees {
- if m.Type == "all" {
- return true
- }
- if c.botUserID != "" && m.UserID == c.botUserID {
- return true
- }
- }
- // Mention metadata exists with mentionees but bot not matched by userId.
- // The bot IS likely mentioned (LINE includes mention struct when bot is @-ed),
- // so check if any mentionee overlaps with bot display name in text.
- if c.botDisplayName != "" {
- for _, m := range msg.Mention.Mentionees {
- if m.Index >= 0 && m.Length > 0 {
- runes := []rune(msg.Text)
- end := m.Index + m.Length
- if end <= len(runes) {
- mentionText := string(runes[m.Index:end])
- if strings.Contains(mentionText, c.botDisplayName) {
- return true
- }
- }
- }
- }
- }
- }
-
- // Fallback: text-based detection with display name
- if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) {
- return true
- }
-
- return false
-}
-
-// stripBotMention removes the @BotName mention text from the message.
-func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {
- stripped := false
-
- // Try to strip using mention metadata indices
- if msg.Mention != nil {
- runes := []rune(text)
- for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
- m := msg.Mention.Mentionees[i]
- // Strip if userId matches OR if the mention text contains the bot display name
- shouldStrip := false
- if c.botUserID != "" && m.UserID == c.botUserID {
- shouldStrip = true
- } else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 {
- end := m.Index + m.Length
- if end <= len(runes) {
- mentionText := string(runes[m.Index:end])
- if strings.Contains(mentionText, c.botDisplayName) {
- shouldStrip = true
- }
- }
- }
- if shouldStrip {
- start := m.Index
- end := m.Index + m.Length
- if start >= 0 && end <= len(runes) {
- runes = append(runes[:start], runes[end:]...)
- stripped = true
- }
- }
- }
- if stripped {
- return strings.TrimSpace(string(runes))
- }
- }
-
- // Fallback: strip @DisplayName from text
- if c.botDisplayName != "" {
- text = strings.ReplaceAll(text, "@"+c.botDisplayName, "")
- }
-
- return strings.TrimSpace(text)
-}
-
-// resolveChatID determines the chat ID from the event source.
-// For group/room messages, use the group/room ID; for 1:1, use the user ID.
-func (c *LINEChannel) resolveChatID(source lineSource) string {
- switch source.Type {
- case "group":
- return source.GroupID
- case "room":
- return source.RoomID
- default:
- return source.UserID
- }
-}
-
-// Send sends a message to LINE. It first tries the Reply API (free)
-// using a cached reply token, then falls back to the Push API.
-func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- // Load and consume quote token for this chat
- var quoteToken string
- if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok {
- quoteToken = qt.(string)
- }
-
- // Try reply token first (free, valid for ~25 seconds)
- if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
- tokenEntry := entry.(replyTokenEntry)
- if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
- if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
- logger.DebugCF("line", "Message sent via Reply API", map[string]any{
- "chat_id": msg.ChatID,
- "quoted": quoteToken != "",
- })
- return nil
- }
- logger.DebugC("line", "Reply API failed, falling back to Push API")
- }
- }
-
- // Fall back to Push API
- return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
-}
-
-// SendMedia implements the channels.MediaSender interface.
-// LINE requires media to be accessible via public URL; since we only have local files,
-// we fall back to sending a text message with the filename/caption.
-// For full support, an external file hosting service would be needed.
-func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- store := c.GetMediaStore()
- if store == nil {
- return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
- }
-
- // LINE Messaging API requires publicly accessible URLs for media messages.
- // Since we only have local file paths, send caption text as fallback.
- for _, part := range msg.Parts {
- caption := part.Caption
- if caption == "" {
- caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
- }
-
- if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-// buildTextMessage creates a text message object, optionally with quoteToken.
-func buildTextMessage(content, quoteToken string) map[string]string {
- msg := map[string]string{
- "type": "text",
- "text": content,
- }
- if quoteToken != "" {
- msg["quoteToken"] = quoteToken
- }
- return msg
-}
-
-// sendReply sends a message using the LINE Reply API.
-func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
- payload := map[string]any{
- "replyToken": replyToken,
- "messages": []map[string]string{buildTextMessage(content, quoteToken)},
- }
-
- return c.callAPI(ctx, lineReplyEndpoint, payload)
-}
-
-// sendPush sends a message using the LINE Push API.
-func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
- payload := map[string]any{
- "to": to,
- "messages": []map[string]string{buildTextMessage(content, quoteToken)},
- }
-
- return c.callAPI(ctx, linePushEndpoint, payload)
-}
-
-// StartTyping implements channels.TypingCapable using LINE's loading animation.
-//
-// NOTE: The LINE loading animation API only works for 1:1 chats.
-// Group/room chat IDs (starting with "C" or "R") are detected automatically;
-// for these, a no-op stop function is returned without calling the API.
-func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
- if chatID == "" {
- return func() {}, nil
- }
-
- // Group/room chats: LINE loading animation is 1:1 only.
- if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") {
- return func() {}, nil
- }
-
- typingCtx, cancel := context.WithCancel(ctx)
- var once sync.Once
- stop := func() { once.Do(cancel) }
-
- // Send immediately, then refresh periodically for long-running tasks.
- if err := c.sendLoading(typingCtx, chatID); err != nil {
- stop()
- return stop, err
- }
-
- ticker := time.NewTicker(50 * time.Second)
- go func() {
- defer ticker.Stop()
- for {
- select {
- case <-typingCtx.Done():
- return
- case <-ticker.C:
- if err := c.sendLoading(typingCtx, chatID); err != nil {
- logger.DebugCF("line", "Failed to refresh loading indicator", map[string]any{
- "error": err.Error(),
- })
- }
- }
- }
- }()
-
- return stop, nil
-}
-
-// sendLoading sends a loading animation indicator to the chat.
-func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
- payload := map[string]any{
- "chatId": chatID,
- "loadingSeconds": 60,
- }
- return c.callAPI(ctx, lineLoadingEndpoint, payload)
-}
-
-// callAPI makes an authenticated POST request to the LINE API.
-func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error {
- body, err := json.Marshal(payload)
- if err != nil {
- return fmt.Errorf("failed to marshal payload: %w", err)
- }
-
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
-
- resp, err := c.apiClient.Do(req)
- if err != nil {
- return channels.ClassifyNetError(err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- respBody, _ := io.ReadAll(resp.Body)
- return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
- }
-
- return nil
-}
-
-// downloadContent downloads media content from the LINE API.
-func (c *LINEChannel) downloadContent(messageID, filename string) string {
- url := fmt.Sprintf(lineContentEndpoint, messageID)
- return utils.DownloadFile(url, filename, utils.DownloadOptions{
- LoggerPrefix: "line",
- ExtraHeaders: map[string]string{
- "Authorization": "Bearer " + c.config.ChannelAccessToken,
- },
- })
-}
diff --git a/pkg/channels/maixcam/init.go b/pkg/channels/maixcam/init.go
deleted file mode 100644
index 5a269b22b..000000000
--- a/pkg/channels/maixcam/init.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package maixcam
-
-import (
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func init() {
- channels.RegisterFactory("maixcam", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewMaixCamChannel(cfg.Channels.MaixCam, b)
- })
-}
diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go
deleted file mode 100644
index ff9a3ed1a..000000000
--- a/pkg/channels/maixcam/maixcam.go
+++ /dev/null
@@ -1,289 +0,0 @@
-package maixcam
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net"
- "sync"
- "time"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
-)
-
-type MaixCamChannel struct {
- *channels.BaseChannel
- config config.MaixCamConfig
- listener net.Listener
- ctx context.Context
- cancel context.CancelFunc
- clients map[net.Conn]bool
- clientsMux sync.RWMutex
-}
-
-type MaixCamMessage struct {
- Type string `json:"type"`
- Tips string `json:"tips"`
- Timestamp float64 `json:"timestamp"`
- Data map[string]any `json:"data"`
-}
-
-func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
- base := channels.NewBaseChannel(
- "maixcam",
- cfg,
- bus,
- cfg.AllowFrom,
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
- )
-
- return &MaixCamChannel{
- BaseChannel: base,
- config: cfg,
- clients: make(map[net.Conn]bool),
- }, nil
-}
-
-func (c *MaixCamChannel) Start(ctx context.Context) error {
- logger.InfoC("maixcam", "Starting MaixCam channel server")
-
- c.ctx, c.cancel = context.WithCancel(ctx)
-
- addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
- listener, err := net.Listen("tcp", addr)
- if err != nil {
- c.cancel()
- return fmt.Errorf("failed to listen on %s: %w", addr, err)
- }
-
- c.listener = listener
- c.SetRunning(true)
-
- logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{
- "host": c.config.Host,
- "port": c.config.Port,
- })
-
- go c.acceptConnections()
-
- return nil
-}
-
-func (c *MaixCamChannel) acceptConnections() {
- logger.DebugC("maixcam", "Starting connection acceptor")
-
- for {
- select {
- case <-c.ctx.Done():
- logger.InfoC("maixcam", "Stopping connection acceptor")
- return
- default:
- conn, err := c.listener.Accept()
- if err != nil {
- if c.IsRunning() {
- logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{
- "error": err.Error(),
- })
- }
- return
- }
-
- logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]any{
- "remote_addr": conn.RemoteAddr().String(),
- })
-
- c.clientsMux.Lock()
- c.clients[conn] = true
- c.clientsMux.Unlock()
-
- go c.handleConnection(conn)
- }
- }
-}
-
-func (c *MaixCamChannel) handleConnection(conn net.Conn) {
- logger.DebugC("maixcam", "Handling MaixCam connection")
-
- defer func() {
- conn.Close()
- c.clientsMux.Lock()
- delete(c.clients, conn)
- c.clientsMux.Unlock()
- logger.DebugC("maixcam", "Connection closed")
- }()
-
- decoder := json.NewDecoder(conn)
-
- for {
- select {
- case <-c.ctx.Done():
- return
- default:
- var msg MaixCamMessage
- if err := decoder.Decode(&msg); err != nil {
- if err.Error() != "EOF" {
- logger.ErrorCF("maixcam", "Failed to decode message", map[string]any{
- "error": err.Error(),
- })
- }
- return
- }
-
- c.processMessage(msg, conn)
- }
- }
-}
-
-func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) {
- switch msg.Type {
- case "person_detected":
- c.handlePersonDetection(msg)
- case "heartbeat":
- logger.DebugC("maixcam", "Received heartbeat")
- case "status":
- c.handleStatusUpdate(msg)
- default:
- logger.WarnCF("maixcam", "Unknown message type", map[string]any{
- "type": msg.Type,
- })
- }
-}
-
-func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
- logger.InfoCF("maixcam", "", map[string]any{
- "timestamp": msg.Timestamp,
- "data": msg.Data,
- })
-
- senderID := "maixcam"
- chatID := "default"
-
- classInfo, ok := msg.Data["class_name"].(string)
- if !ok {
- classInfo = "person"
- }
-
- score, _ := msg.Data["score"].(float64)
- x, _ := msg.Data["x"].(float64)
- y, _ := msg.Data["y"].(float64)
- w, _ := msg.Data["w"].(float64)
- h, _ := msg.Data["h"].(float64)
-
- content := fmt.Sprintf("📷 Person detected!\nClass: %s\nConfidence: %.2f%%\nPosition: (%.0f, %.0f)\nSize: %.0fx%.0f",
- classInfo, score*100, x, y, w, h)
-
- metadata := map[string]string{
- "timestamp": fmt.Sprintf("%.0f", msg.Timestamp),
- "class_id": fmt.Sprintf("%.0f", msg.Data["class_id"]),
- "score": fmt.Sprintf("%.2f", score),
- "x": fmt.Sprintf("%.0f", x),
- "y": fmt.Sprintf("%.0f", y),
- "w": fmt.Sprintf("%.0f", w),
- "h": fmt.Sprintf("%.0f", h),
- }
-
- sender := bus.SenderInfo{
- Platform: "maixcam",
- PlatformID: "maixcam",
- CanonicalID: identity.BuildCanonicalID("maixcam", "maixcam"),
- }
-
- if !c.IsAllowedSender(sender) {
- return
- }
-
- c.HandleMessage(
- c.ctx,
- bus.Peer{Kind: "channel", ID: "default"},
- "",
- senderID,
- chatID,
- content,
- []string{},
- metadata,
- sender,
- )
-}
-
-func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
- logger.InfoCF("maixcam", "Status update from MaixCam", map[string]any{
- "status": msg.Data,
- })
-}
-
-func (c *MaixCamChannel) Stop(ctx context.Context) error {
- logger.InfoC("maixcam", "Stopping MaixCam channel")
- c.SetRunning(false)
-
- // Cancel context first to signal goroutines to exit
- if c.cancel != nil {
- c.cancel()
- }
-
- if c.listener != nil {
- c.listener.Close()
- }
-
- c.clientsMux.Lock()
- defer c.clientsMux.Unlock()
-
- for conn := range c.clients {
- conn.Close()
- }
- c.clients = make(map[net.Conn]bool)
-
- logger.InfoC("maixcam", "MaixCam channel stopped")
- return nil
-}
-
-func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- // Check ctx before entering write path
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
-
- c.clientsMux.RLock()
- defer c.clientsMux.RUnlock()
-
- if len(c.clients) == 0 {
- logger.WarnC("maixcam", "No MaixCam devices connected")
- return fmt.Errorf("no connected MaixCam devices")
- }
-
- response := map[string]any{
- "type": "command",
- "timestamp": float64(0),
- "message": msg.Content,
- "chat_id": msg.ChatID,
- }
-
- data, err := json.Marshal(response)
- if err != nil {
- return fmt.Errorf("failed to marshal response: %w", err)
- }
-
- var sendErr error
- for conn := range c.clients {
- _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- if _, err := conn.Write(data); err != nil {
- logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{
- "client": conn.RemoteAddr().String(),
- "error": err.Error(),
- })
- sendErr = fmt.Errorf("maixcam send: %w", channels.ErrTemporary)
- }
- _ = conn.SetWriteDeadline(time.Time{})
- }
-
- return sendErr
-}
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index fdd6d0c1f..49d16bd70 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -206,10 +206,6 @@ func (m *Manager) initChannel(name, displayName string) {
func (m *Manager) initChannels() error {
logger.InfoC("channels", "Initializing channel manager")
- if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
- m.initChannel("telegram", "Telegram")
- }
-
if m.config.Channels.WhatsApp.Enabled {
waCfg := m.config.Channels.WhatsApp
if waCfg.UseNative {
@@ -219,54 +215,6 @@ func (m *Manager) initChannels() error {
}
}
- if m.config.Channels.Feishu.Enabled {
- m.initChannel("feishu", "Feishu")
- }
-
- if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
- m.initChannel("discord", "Discord")
- }
-
- if m.config.Channels.MaixCam.Enabled {
- m.initChannel("maixcam", "MaixCam")
- }
-
- if m.config.Channels.QQ.Enabled {
- m.initChannel("qq", "QQ")
- }
-
- if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" {
- m.initChannel("dingtalk", "DingTalk")
- }
-
- if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" {
- m.initChannel("slack", "Slack")
- }
-
- if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
- m.initChannel("line", "LINE")
- }
-
- if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
- 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")
- }
-
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})
diff --git a/pkg/channels/onebot/init.go b/pkg/channels/onebot/init.go
deleted file mode 100644
index 84c06dfd6..000000000
--- a/pkg/channels/onebot/init.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package onebot
-
-import (
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func init() {
- channels.RegisterFactory("onebot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewOneBotChannel(cfg.Channels.OneBot, b)
- })
-}
diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go
deleted file mode 100644
index 62a9eb34a..000000000
--- a/pkg/channels/onebot/onebot.go
+++ /dev/null
@@ -1,1105 +0,0 @@
-package onebot
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "strconv"
- "strings"
- "sync"
- "sync/atomic"
- "time"
-
- "github.com/gorilla/websocket"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/media"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-type OneBotChannel struct {
- *channels.BaseChannel
- config config.OneBotConfig
- conn *websocket.Conn
- ctx context.Context
- cancel context.CancelFunc
- dedup map[string]struct{}
- dedupRing []string
- dedupIdx int
- mu sync.Mutex
- writeMu sync.Mutex
- echoCounter int64
- selfID int64
- pending map[string]chan json.RawMessage
- pendingMu sync.Mutex
- lastMessageID sync.Map
-}
-
-type oneBotRawEvent struct {
- PostType string `json:"post_type"`
- MessageType string `json:"message_type"`
- SubType string `json:"sub_type"`
- MessageID json.RawMessage `json:"message_id"`
- UserID json.RawMessage `json:"user_id"`
- GroupID json.RawMessage `json:"group_id"`
- RawMessage string `json:"raw_message"`
- Message json.RawMessage `json:"message"`
- Sender json.RawMessage `json:"sender"`
- SelfID json.RawMessage `json:"self_id"`
- Time json.RawMessage `json:"time"`
- MetaEventType string `json:"meta_event_type"`
- NoticeType string `json:"notice_type"`
- Echo string `json:"echo"`
- RetCode json.RawMessage `json:"retcode"`
- Status json.RawMessage `json:"status"`
- Data json.RawMessage `json:"data"`
-}
-
-type BotStatus struct {
- Online bool `json:"online"`
- Good bool `json:"good"`
-}
-
-func isAPIResponse(raw json.RawMessage) bool {
- if len(raw) == 0 {
- return false
- }
- var s string
- if json.Unmarshal(raw, &s) == nil {
- return s == "ok" || s == "failed"
- }
- var bs BotStatus
- if json.Unmarshal(raw, &bs) == nil {
- return bs.Online || bs.Good
- }
- return false
-}
-
-type oneBotSender struct {
- UserID json.RawMessage `json:"user_id"`
- Nickname string `json:"nickname"`
- Card string `json:"card"`
-}
-
-type oneBotAPIRequest struct {
- Action string `json:"action"`
- Params any `json:"params"`
- Echo string `json:"echo,omitempty"`
-}
-
-type oneBotMessageSegment struct {
- Type string `json:"type"`
- Data map[string]any `json:"data"`
-}
-
-func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
- base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom,
- channels.WithGroupTrigger(cfg.GroupTrigger),
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
- )
-
- const dedupSize = 1024
- return &OneBotChannel{
- BaseChannel: base,
- config: cfg,
- dedup: make(map[string]struct{}, dedupSize),
- dedupRing: make([]string, dedupSize),
- dedupIdx: 0,
- pending: make(map[string]chan json.RawMessage),
- }, nil
-}
-
-func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
- go func() {
- _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{
- "message_id": messageID,
- "emoji_id": emojiID,
- "set": set,
- }, 5*time.Second)
- if err != nil {
- logger.DebugCF("onebot", "Failed to set emoji like", map[string]any{
- "message_id": messageID,
- "error": err.Error(),
- })
- }
- }()
-}
-
-// ReactToMessage implements channels.ReactionCapable.
-// It adds an emoji reaction (ID 289) to group messages and returns an undo function.
-// Private messages return a no-op since reactions are only meaningful in groups.
-func (c *OneBotChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
- // Only react in group chats
- if !strings.HasPrefix(chatID, "group:") {
- return func() {}, nil
- }
-
- c.setMsgEmojiLike(messageID, 289, true)
-
- return func() {
- c.setMsgEmojiLike(messageID, 289, false)
- }, nil
-}
-
-func (c *OneBotChannel) Start(ctx context.Context) error {
- if c.config.WSUrl == "" {
- return fmt.Errorf("OneBot ws_url not configured")
- }
-
- logger.InfoCF("onebot", "Starting OneBot channel", map[string]any{
- "ws_url": c.config.WSUrl,
- })
-
- c.ctx, c.cancel = context.WithCancel(ctx)
-
- if err := c.connect(); err != nil {
- logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]any{
- "error": err.Error(),
- })
- } else {
- go c.listen()
- c.fetchSelfID()
- }
-
- if c.config.ReconnectInterval > 0 {
- go c.reconnectLoop()
- } else {
- if c.conn == nil {
- return fmt.Errorf("failed to connect to OneBot and reconnect is disabled")
- }
- }
-
- c.SetRunning(true)
- logger.InfoC("onebot", "OneBot channel started successfully")
-
- return nil
-}
-
-func (c *OneBotChannel) connect() error {
- dialer := websocket.DefaultDialer
- dialer.HandshakeTimeout = 10 * time.Second
-
- header := make(map[string][]string)
- if c.config.AccessToken != "" {
- header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
- }
-
- conn, resp, err := dialer.Dial(c.config.WSUrl, header)
- if resp != nil {
- resp.Body.Close()
- }
- if err != nil {
- return err
- }
-
- conn.SetPongHandler(func(appData string) error {
- _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
- return nil
- })
- _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
-
- c.mu.Lock()
- c.conn = conn
- c.mu.Unlock()
-
- go c.pinger(conn)
-
- logger.InfoC("onebot", "WebSocket connected")
- return nil
-}
-
-func (c *OneBotChannel) pinger(conn *websocket.Conn) {
- ticker := time.NewTicker(30 * time.Second)
- defer ticker.Stop()
-
- for {
- select {
- case <-c.ctx.Done():
- return
- case <-ticker.C:
- c.writeMu.Lock()
- err := conn.WriteMessage(websocket.PingMessage, nil)
- c.writeMu.Unlock()
- if err != nil {
- logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]any{
- "error": err.Error(),
- })
- return
- }
- }
- }
-}
-
-func (c *OneBotChannel) fetchSelfID() {
- resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second)
- if err != nil {
- logger.WarnCF("onebot", "Failed to get_login_info", map[string]any{
- "error": err.Error(),
- })
- return
- }
-
- type loginInfo struct {
- UserID json.RawMessage `json:"user_id"`
- Nickname string `json:"nickname"`
- }
- for _, extract := range []func() (*loginInfo, error){
- func() (*loginInfo, error) {
- var w struct {
- Data loginInfo `json:"data"`
- }
- err := json.Unmarshal(resp, &w)
- return &w.Data, err
- },
- func() (*loginInfo, error) {
- var f loginInfo
- err := json.Unmarshal(resp, &f)
- return &f, err
- },
- } {
- info, err := extract()
- if err != nil || len(info.UserID) == 0 {
- continue
- }
- if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 {
- atomic.StoreInt64(&c.selfID, uid)
- logger.InfoCF("onebot", "Bot self ID retrieved", map[string]any{
- "self_id": uid,
- "nickname": info.Nickname,
- })
- return
- }
- }
-
- logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]any{
- "response": string(resp),
- })
-}
-
-func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.Duration) (json.RawMessage, error) {
- c.mu.Lock()
- conn := c.conn
- c.mu.Unlock()
-
- if conn == nil {
- return nil, fmt.Errorf("WebSocket not connected")
- }
-
- echo := fmt.Sprintf("api_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&c.echoCounter, 1))
-
- ch := make(chan json.RawMessage, 1)
- c.pendingMu.Lock()
- c.pending[echo] = ch
- c.pendingMu.Unlock()
-
- defer func() {
- c.pendingMu.Lock()
- delete(c.pending, echo)
- c.pendingMu.Unlock()
- }()
-
- req := oneBotAPIRequest{
- Action: action,
- Params: params,
- Echo: echo,
- }
-
- data, err := json.Marshal(req)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal API request: %w", err)
- }
-
- c.writeMu.Lock()
- _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- err = conn.WriteMessage(websocket.TextMessage, data)
- _ = conn.SetWriteDeadline(time.Time{})
- c.writeMu.Unlock()
-
- if err != nil {
- return nil, fmt.Errorf("failed to write API request: %w", err)
- }
-
- select {
- case resp := <-ch:
- if resp == nil {
- return nil, fmt.Errorf("API request %s: channel stopped", action)
- }
- return resp, nil
- case <-time.After(timeout):
- return nil, fmt.Errorf("API request %s timed out after %v", action, timeout)
- case <-c.ctx.Done():
- return nil, fmt.Errorf("context canceled")
- }
-}
-
-func (c *OneBotChannel) reconnectLoop() {
- interval := max(time.Duration(c.config.ReconnectInterval)*time.Second, 5*time.Second)
-
- for {
- select {
- case <-c.ctx.Done():
- return
- case <-time.After(interval):
- c.mu.Lock()
- conn := c.conn
- c.mu.Unlock()
-
- if conn == nil {
- logger.InfoC("onebot", "Attempting to reconnect...")
- if err := c.connect(); err != nil {
- logger.ErrorCF("onebot", "Reconnect failed", map[string]any{
- "error": err.Error(),
- })
- } else {
- go c.listen()
- c.fetchSelfID()
- }
- }
- }
- }
-}
-
-func (c *OneBotChannel) Stop(ctx context.Context) error {
- logger.InfoC("onebot", "Stopping OneBot channel")
- c.SetRunning(false)
-
- if c.cancel != nil {
- c.cancel()
- }
-
- c.pendingMu.Lock()
- for echo, ch := range c.pending {
- select {
- case ch <- nil: // non-blocking wake for blocked sendAPIRequest goroutines
- default:
- }
- delete(c.pending, echo)
- }
- c.pendingMu.Unlock()
-
- c.mu.Lock()
- if c.conn != nil {
- c.conn.Close()
- c.conn = nil
- }
- c.mu.Unlock()
-
- return nil
-}
-
-func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- // Check ctx before entering write path
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
-
- c.mu.Lock()
- conn := c.conn
- c.mu.Unlock()
-
- if conn == nil {
- return fmt.Errorf("OneBot WebSocket not connected")
- }
-
- action, params, err := c.buildSendRequest(msg)
- if err != nil {
- return err
- }
-
- echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1))
-
- req := oneBotAPIRequest{
- Action: action,
- Params: params,
- Echo: echo,
- }
-
- data, err := json.Marshal(req)
- if err != nil {
- return fmt.Errorf("failed to marshal OneBot request: %w", err)
- }
-
- c.writeMu.Lock()
- _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- err = conn.WriteMessage(websocket.TextMessage, data)
- _ = conn.SetWriteDeadline(time.Time{})
- c.writeMu.Unlock()
-
- if err != nil {
- logger.ErrorCF("onebot", "Failed to send message", map[string]any{
- "error": err.Error(),
- })
- return fmt.Errorf("onebot send: %w", channels.ErrTemporary)
- }
-
- return nil
-}
-
-// SendMedia implements the channels.MediaSender interface.
-func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
-
- c.mu.Lock()
- conn := c.conn
- c.mu.Unlock()
-
- if conn == nil {
- return fmt.Errorf("OneBot WebSocket not connected")
- }
-
- store := c.GetMediaStore()
- if store == nil {
- return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
- }
-
- // Build media segments
- var segments []oneBotMessageSegment
- for _, part := range msg.Parts {
- localPath, err := store.Resolve(part.Ref)
- if err != nil {
- logger.ErrorCF("onebot", "Failed to resolve media ref", map[string]any{
- "ref": part.Ref,
- "error": err.Error(),
- })
- continue
- }
-
- var segType string
- switch part.Type {
- case "image":
- segType = "image"
- case "video":
- segType = "video"
- case "audio":
- segType = "record"
- default:
- segType = "file"
- }
-
- segments = append(segments, oneBotMessageSegment{
- Type: segType,
- Data: map[string]any{"file": "file://" + localPath},
- })
-
- if part.Caption != "" {
- segments = append(segments, oneBotMessageSegment{
- Type: "text",
- Data: map[string]any{"text": part.Caption},
- })
- }
- }
-
- if len(segments) == 0 {
- return nil
- }
-
- chatID := msg.ChatID
- var action, idKey string
- var rawID string
- if rest, ok := strings.CutPrefix(chatID, "group:"); ok {
- action, idKey, rawID = "send_group_msg", "group_id", rest
- } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok {
- action, idKey, rawID = "send_private_msg", "user_id", rest
- } else {
- action, idKey, rawID = "send_private_msg", "user_id", chatID
- }
-
- id, err := strconv.ParseInt(rawID, 10, 64)
- if err != nil {
- return fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed)
- }
-
- echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1))
-
- req := oneBotAPIRequest{
- Action: action,
- Params: map[string]any{idKey: id, "message": segments},
- Echo: echo,
- }
-
- data, err := json.Marshal(req)
- if err != nil {
- return fmt.Errorf("failed to marshal OneBot request: %w", err)
- }
-
- c.writeMu.Lock()
- _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- err = conn.WriteMessage(websocket.TextMessage, data)
- _ = conn.SetWriteDeadline(time.Time{})
- c.writeMu.Unlock()
-
- if err != nil {
- logger.ErrorCF("onebot", "Failed to send media message", map[string]any{
- "error": err.Error(),
- })
- return fmt.Errorf("onebot send media: %w", channels.ErrTemporary)
- }
-
- return nil
-}
-
-func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment {
- var segments []oneBotMessageSegment
-
- if lastMsgID, ok := c.lastMessageID.Load(chatID); ok {
- if msgID, ok := lastMsgID.(string); ok && msgID != "" {
- segments = append(segments, oneBotMessageSegment{
- Type: "reply",
- Data: map[string]any{"id": msgID},
- })
- }
- }
-
- segments = append(segments, oneBotMessageSegment{
- Type: "text",
- Data: map[string]any{"text": content},
- })
-
- return segments
-}
-
-func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) {
- chatID := msg.ChatID
- segments := c.buildMessageSegments(chatID, msg.Content)
-
- var action, idKey string
- var rawID string
- if rest, ok := strings.CutPrefix(chatID, "group:"); ok {
- action, idKey, rawID = "send_group_msg", "group_id", rest
- } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok {
- action, idKey, rawID = "send_private_msg", "user_id", rest
- } else {
- action, idKey, rawID = "send_private_msg", "user_id", chatID
- }
-
- id, err := strconv.ParseInt(rawID, 10, 64)
- if err != nil {
- return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID)
- }
- return action, map[string]any{idKey: id, "message": segments}, nil
-}
-
-func (c *OneBotChannel) listen() {
- c.mu.Lock()
- conn := c.conn
- c.mu.Unlock()
-
- if conn == nil {
- logger.WarnC("onebot", "WebSocket connection is nil, listener exiting")
- return
- }
-
- for {
- select {
- case <-c.ctx.Done():
- return
- default:
- _, message, err := conn.ReadMessage()
- if err != nil {
- logger.ErrorCF("onebot", "WebSocket read error", map[string]any{
- "error": err.Error(),
- })
- c.mu.Lock()
- if c.conn == conn {
- c.conn.Close()
- c.conn = nil
- }
- c.mu.Unlock()
- return
- }
-
- _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
-
- var raw oneBotRawEvent
- if err := json.Unmarshal(message, &raw); err != nil {
- logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]any{
- "error": err.Error(),
- "payload": string(message),
- })
- continue
- }
-
- logger.DebugCF("onebot", "WebSocket event", map[string]any{
- "length": len(message),
- "post_type": raw.PostType,
- "sub_type": raw.SubType,
- })
-
- if raw.Echo != "" {
- c.pendingMu.Lock()
- ch, ok := c.pending[raw.Echo]
- c.pendingMu.Unlock()
-
- if ok {
- select {
- case ch <- message:
- default:
- }
- } else {
- logger.DebugCF("onebot", "Received API response (no waiter)", map[string]any{
- "echo": raw.Echo,
- "status": string(raw.Status),
- })
- }
- continue
- }
-
- if isAPIResponse(raw.Status) {
- logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]any{
- "status": string(raw.Status),
- })
- continue
- }
-
- c.handleRawEvent(&raw)
- }
- }
-}
-
-func parseJSONInt64(raw json.RawMessage) (int64, error) {
- if len(raw) == 0 {
- return 0, nil
- }
-
- var n int64
- if err := json.Unmarshal(raw, &n); err == nil {
- return n, nil
- }
-
- var s string
- if err := json.Unmarshal(raw, &s); err == nil {
- return strconv.ParseInt(s, 10, 64)
- }
- return 0, fmt.Errorf("cannot parse as int64: %s", string(raw))
-}
-
-func parseJSONString(raw json.RawMessage) string {
- if len(raw) == 0 {
- return ""
- }
- var s string
- if err := json.Unmarshal(raw, &s); err == nil {
- return s
- }
-
- return string(raw)
-}
-
-type parseMessageResult struct {
- Text string
- IsBotMentioned bool
- Media []string
- ReplyTo string
-}
-
-func (c *OneBotChannel) parseMessageSegments(
- raw json.RawMessage,
- selfID int64,
- store media.MediaStore,
- scope string,
-) parseMessageResult {
- if len(raw) == 0 {
- return parseMessageResult{}
- }
-
- var s string
- if err := json.Unmarshal(raw, &s); err == nil {
- mentioned := false
- if selfID > 0 {
- cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
- if strings.Contains(s, cqAt) {
- mentioned = true
- s = strings.ReplaceAll(s, cqAt, "")
- s = strings.TrimSpace(s)
- }
- }
- return parseMessageResult{Text: s, IsBotMentioned: mentioned}
- }
-
- var segments []map[string]any
- if err := json.Unmarshal(raw, &segments); err != nil {
- return parseMessageResult{}
- }
-
- var textParts []string
- mentioned := false
- selfIDStr := strconv.FormatInt(selfID, 10)
- var mediaRefs []string
- var replyTo string
-
- // Helper to register a local file with the media store
- storeFile := func(localPath, filename string) string {
- if store != nil {
- ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "onebot",
- }, scope)
- if err == nil {
- return ref
- }
- }
- return localPath // fallback
- }
-
- for _, seg := range segments {
- segType, _ := seg["type"].(string)
- data, _ := seg["data"].(map[string]any)
-
- switch segType {
- case "text":
- if data != nil {
- if t, ok := data["text"].(string); ok {
- textParts = append(textParts, t)
- }
- }
-
- case "at":
- if data != nil && selfID > 0 {
- qqVal := fmt.Sprintf("%v", data["qq"])
- if qqVal == selfIDStr || qqVal == "all" {
- mentioned = true
- }
- }
-
- case "image", "video", "file":
- if data != nil {
- url, _ := data["url"].(string)
- if url != "" {
- defaults := map[string]string{"image": "image.jpg", "video": "video.mp4", "file": "file"}
- filename := defaults[segType]
- if f, ok := data["file"].(string); ok && f != "" {
- filename = f
- } else if n, ok := data["name"].(string); ok && n != "" {
- filename = n
- }
- localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{
- LoggerPrefix: "onebot",
- })
- if localPath != "" {
- mediaRefs = append(mediaRefs, storeFile(localPath, filename))
- textParts = append(textParts, fmt.Sprintf("[%s]", segType))
- }
- }
- }
-
- case "record":
- if data != nil {
- url, _ := data["url"].(string)
- if url != "" {
- localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{
- LoggerPrefix: "onebot",
- })
- if localPath != "" {
- textParts = append(textParts, "[voice]")
- mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr"))
- }
- }
- }
-
- case "reply":
- if data != nil {
- if id, ok := data["id"]; ok {
- replyTo = fmt.Sprintf("%v", id)
- }
- }
-
- case "face":
- if data != nil {
- faceID, _ := data["id"]
- textParts = append(textParts, fmt.Sprintf("[face:%v]", faceID))
- }
-
- case "forward":
- textParts = append(textParts, "[forward message]")
-
- default:
- }
- }
-
- return parseMessageResult{
- Text: strings.TrimSpace(strings.Join(textParts, "")),
- IsBotMentioned: mentioned,
- Media: mediaRefs,
- ReplyTo: replyTo,
- }
-}
-
-func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
- switch raw.PostType {
- case "message":
- if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
- // Build minimal sender for allowlist check
- sender := bus.SenderInfo{
- Platform: "onebot",
- PlatformID: strconv.FormatInt(userID, 10),
- CanonicalID: identity.BuildCanonicalID("onebot", strconv.FormatInt(userID, 10)),
- }
- if !c.IsAllowedSender(sender) {
- logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{
- "user_id": userID,
- })
- return
- }
- }
- c.handleMessage(raw)
-
- case "message_sent":
- logger.DebugCF("onebot", "Bot sent message event", map[string]any{
- "message_type": raw.MessageType,
- "message_id": parseJSONString(raw.MessageID),
- })
-
- case "meta_event":
- c.handleMetaEvent(raw)
-
- case "notice":
- c.handleNoticeEvent(raw)
-
- case "request":
- logger.DebugCF("onebot", "Request event received", map[string]any{
- "sub_type": raw.SubType,
- })
-
- case "":
- logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]any{
- "echo": raw.Echo,
- "status": raw.Status,
- })
-
- default:
- logger.DebugCF("onebot", "Unknown post_type", map[string]any{
- "post_type": raw.PostType,
- })
- }
-}
-
-func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
- if raw.MetaEventType == "lifecycle" {
- logger.InfoCF("onebot", "Lifecycle event", map[string]any{"sub_type": raw.SubType})
- } else if raw.MetaEventType != "heartbeat" {
- logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil)
- }
-}
-
-func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) {
- fields := map[string]any{
- "notice_type": raw.NoticeType,
- "sub_type": raw.SubType,
- "group_id": parseJSONString(raw.GroupID),
- "user_id": parseJSONString(raw.UserID),
- "message_id": parseJSONString(raw.MessageID),
- }
- switch raw.NoticeType {
- case "group_recall", "group_increase", "group_decrease",
- "friend_add", "group_admin", "group_ban":
- logger.InfoCF("onebot", "Notice: "+raw.NoticeType, fields)
- default:
- logger.DebugCF("onebot", "Notice: "+raw.NoticeType, fields)
- }
-}
-
-func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
- // Parse fields from raw event
- userID, err := parseJSONInt64(raw.UserID)
- if err != nil {
- logger.WarnCF("onebot", "Failed to parse user_id", map[string]any{
- "error": err.Error(),
- "raw": string(raw.UserID),
- })
- return
- }
-
- groupID, _ := parseJSONInt64(raw.GroupID)
- selfID, _ := parseJSONInt64(raw.SelfID)
- messageID := parseJSONString(raw.MessageID)
-
- if selfID == 0 {
- selfID = atomic.LoadInt64(&c.selfID)
- }
-
- // Compute scope for media store before parsing (parsing may download files)
- var chatIDForScope string
- switch raw.MessageType {
- case "group":
- chatIDForScope = "group:" + strconv.FormatInt(groupID, 10)
- default:
- chatIDForScope = "private:" + strconv.FormatInt(userID, 10)
- }
- scope := channels.BuildMediaScope("onebot", chatIDForScope, messageID)
-
- parsed := c.parseMessageSegments(raw.Message, selfID, c.GetMediaStore(), scope)
- isBotMentioned := parsed.IsBotMentioned
-
- content := raw.RawMessage
- if content == "" {
- content = parsed.Text
- } else if selfID > 0 {
- cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
- if strings.Contains(content, cqAt) {
- isBotMentioned = true
- content = strings.ReplaceAll(content, cqAt, "")
- content = strings.TrimSpace(content)
- }
- }
-
- if parsed.Text != "" && content != parsed.Text && (len(parsed.Media) > 0 || parsed.ReplyTo != "") {
- content = parsed.Text
- }
-
- var sender oneBotSender
- if len(raw.Sender) > 0 {
- if err := json.Unmarshal(raw.Sender, &sender); err != nil {
- logger.WarnCF("onebot", "Failed to parse sender", map[string]any{
- "error": err.Error(),
- "sender": string(raw.Sender),
- })
- }
- }
-
- if c.isDuplicate(messageID) {
- logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{
- "message_id": messageID,
- })
- return
- }
-
- if content == "" {
- logger.DebugCF("onebot", "Received empty message, ignoring", map[string]any{
- "message_id": messageID,
- })
- return
- }
-
- senderID := strconv.FormatInt(userID, 10)
- var chatID string
-
- var peer bus.Peer
-
- metadata := map[string]string{}
-
- if parsed.ReplyTo != "" {
- metadata["reply_to_message_id"] = parsed.ReplyTo
- }
-
- switch raw.MessageType {
- case "private":
- chatID = "private:" + senderID
- peer = bus.Peer{Kind: "direct", ID: senderID}
-
- case "group":
- groupIDStr := strconv.FormatInt(groupID, 10)
- chatID = "group:" + groupIDStr
- peer = bus.Peer{Kind: "group", ID: groupIDStr}
- metadata["group_id"] = groupIDStr
-
- senderUserID, _ := parseJSONInt64(sender.UserID)
- if senderUserID > 0 {
- metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10)
- }
-
- if sender.Card != "" {
- metadata["sender_name"] = sender.Card
- } else if sender.Nickname != "" {
- metadata["sender_name"] = sender.Nickname
- }
-
- respond, strippedContent := c.ShouldRespondInGroup(isBotMentioned, content)
- if !respond {
- logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{
- "sender": senderID,
- "group": groupIDStr,
- "is_mentioned": isBotMentioned,
- "content": truncate(content, 100),
- })
- return
- }
- content = strippedContent
-
- default:
- logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]any{
- "type": raw.MessageType,
- "message_id": messageID,
- "user_id": userID,
- })
- return
- }
-
- logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]any{
- "sender": senderID,
- "chat_id": chatID,
- "message_id": messageID,
- "length": len(content),
- "content": truncate(content, 100),
- "media_count": len(parsed.Media),
- })
-
- if sender.Nickname != "" {
- metadata["nickname"] = sender.Nickname
- }
-
- c.lastMessageID.Store(chatID, messageID)
-
- senderInfo := bus.SenderInfo{
- Platform: "onebot",
- PlatformID: senderID,
- CanonicalID: identity.BuildCanonicalID("onebot", senderID),
- DisplayName: sender.Nickname,
- }
-
- if !c.IsAllowedSender(senderInfo) {
- logger.DebugCF("onebot", "Message rejected by allowlist (senderInfo)", map[string]any{
- "sender": senderID,
- })
- return
- }
-
- c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata, senderInfo)
-}
-
-func (c *OneBotChannel) isDuplicate(messageID string) bool {
- if messageID == "" || messageID == "0" {
- return false
- }
-
- c.mu.Lock()
- defer c.mu.Unlock()
-
- if _, exists := c.dedup[messageID]; exists {
- return true
- }
-
- if old := c.dedupRing[c.dedupIdx]; old != "" {
- delete(c.dedup, old)
- }
- c.dedupRing[c.dedupIdx] = messageID
- c.dedup[messageID] = struct{}{}
- c.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing)
-
- return false
-}
-
-func truncate(s string, n int) string {
- runes := []rune(s)
- if len(runes) <= n {
- return s
- }
- return string(runes[:n]) + "..."
-}
diff --git a/pkg/channels/pico/init.go b/pkg/channels/pico/init.go
deleted file mode 100644
index 96d764418..000000000
--- a/pkg/channels/pico/init.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package pico
-
-import (
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func init() {
- channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewPicoChannel(cfg.Channels.Pico, b)
- })
-}
diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go
deleted file mode 100644
index 8d8b62a67..000000000
--- a/pkg/channels/pico/pico.go
+++ /dev/null
@@ -1,462 +0,0 @@
-package pico
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "strings"
- "sync"
- "sync/atomic"
- "time"
-
- "github.com/google/uuid"
- "github.com/gorilla/websocket"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
-)
-
-// picoConn represents a single WebSocket connection.
-type picoConn struct {
- id string
- conn *websocket.Conn
- sessionID string
- writeMu sync.Mutex
- closed atomic.Bool
-}
-
-// writeJSON sends a JSON message to the connection with write locking.
-func (pc *picoConn) writeJSON(v any) error {
- if pc.closed.Load() {
- return fmt.Errorf("connection closed")
- }
- pc.writeMu.Lock()
- defer pc.writeMu.Unlock()
- return pc.conn.WriteJSON(v)
-}
-
-// close closes the connection.
-func (pc *picoConn) close() {
- if pc.closed.CompareAndSwap(false, true) {
- pc.conn.Close()
- }
-}
-
-// PicoChannel implements the native Pico Protocol WebSocket channel.
-// It serves as the reference implementation for all optional capability interfaces.
-type PicoChannel struct {
- *channels.BaseChannel
- config config.PicoConfig
- upgrader websocket.Upgrader
- connections sync.Map // connID → *picoConn
- connCount atomic.Int32
- ctx context.Context
- cancel context.CancelFunc
-}
-
-// NewPicoChannel creates a new Pico Protocol channel.
-func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
- if cfg.Token == "" {
- return nil, fmt.Errorf("pico token is required")
- }
-
- base := channels.NewBaseChannel("pico", cfg, messageBus, cfg.AllowFrom)
-
- allowOrigins := cfg.AllowOrigins
- checkOrigin := func(r *http.Request) bool {
- if len(allowOrigins) == 0 {
- return true // allow all if not configured
- }
- origin := r.Header.Get("Origin")
- for _, allowed := range allowOrigins {
- if allowed == "*" || allowed == origin {
- return true
- }
- }
- return false
- }
-
- return &PicoChannel{
- BaseChannel: base,
- config: cfg,
- upgrader: websocket.Upgrader{
- CheckOrigin: checkOrigin,
- ReadBufferSize: 1024,
- WriteBufferSize: 1024,
- },
- }, nil
-}
-
-// Start implements Channel.
-func (c *PicoChannel) Start(ctx context.Context) error {
- logger.InfoC("pico", "Starting Pico Protocol channel")
- c.ctx, c.cancel = context.WithCancel(ctx)
- c.SetRunning(true)
- logger.InfoC("pico", "Pico Protocol channel started")
- return nil
-}
-
-// Stop implements Channel.
-func (c *PicoChannel) Stop(ctx context.Context) error {
- logger.InfoC("pico", "Stopping Pico Protocol channel")
- c.SetRunning(false)
-
- // Close all connections
- c.connections.Range(func(key, value any) bool {
- if pc, ok := value.(*picoConn); ok {
- pc.close()
- }
- c.connections.Delete(key)
- return true
- })
-
- if c.cancel != nil {
- c.cancel()
- }
-
- logger.InfoC("pico", "Pico Protocol channel stopped")
- return nil
-}
-
-// WebhookPath implements channels.WebhookHandler.
-func (c *PicoChannel) WebhookPath() string { return "/pico/" }
-
-// ServeHTTP implements http.Handler for the shared HTTP server.
-func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- path := strings.TrimPrefix(r.URL.Path, "/pico")
-
- switch {
- case path == "/ws" || path == "/ws/":
- c.handleWebSocket(w, r)
- default:
- http.NotFound(w, r)
- }
-}
-
-// Send implements Channel — sends a message to the appropriate WebSocket connection.
-func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- outMsg := newMessage(TypeMessageCreate, map[string]any{
- "content": msg.Content,
- })
-
- return c.broadcastToSession(msg.ChatID, outMsg)
-}
-
-// EditMessage implements channels.MessageEditor.
-func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
- outMsg := newMessage(TypeMessageUpdate, map[string]any{
- "message_id": messageID,
- "content": content,
- })
- return c.broadcastToSession(chatID, outMsg)
-}
-
-// StartTyping implements channels.TypingCapable.
-func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
- startMsg := newMessage(TypeTypingStart, nil)
- if err := c.broadcastToSession(chatID, startMsg); err != nil {
- return func() {}, err
- }
- return func() {
- stopMsg := newMessage(TypeTypingStop, nil)
- c.broadcastToSession(chatID, stopMsg)
- }, nil
-}
-
-// SendPlaceholder implements channels.PlaceholderCapable.
-// It sends a placeholder message via the Pico Protocol that will later be
-// edited to the actual response via EditMessage (channels.MessageEditor).
-func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
- if !c.config.Placeholder.Enabled {
- return "", nil
- }
-
- text := c.config.Placeholder.Text
- if text == "" {
- text = "Thinking... 💭"
- }
-
- msgID := uuid.New().String()
- outMsg := newMessage(TypeMessageCreate, map[string]any{
- "content": text,
- "message_id": msgID,
- })
-
- if err := c.broadcastToSession(chatID, outMsg); err != nil {
- return "", err
- }
-
- return msgID, nil
-}
-
-// broadcastToSession sends a message to all connections with a matching session.
-func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
- // chatID format: "pico:"
- sessionID := strings.TrimPrefix(chatID, "pico:")
- msg.SessionID = sessionID
-
- var sent bool
- c.connections.Range(func(key, value any) bool {
- pc, ok := value.(*picoConn)
- if !ok {
- return true
- }
- if pc.sessionID == sessionID {
- if err := pc.writeJSON(msg); err != nil {
- logger.DebugCF("pico", "Write to connection failed", map[string]any{
- "conn_id": pc.id,
- "error": err.Error(),
- })
- } else {
- sent = true
- }
- }
- return true
- })
-
- if !sent {
- return fmt.Errorf("no active connections for session %s: %w", sessionID, channels.ErrSendFailed)
- }
- return nil
-}
-
-// handleWebSocket upgrades the HTTP connection and manages the WebSocket lifecycle.
-func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
- if !c.IsRunning() {
- http.Error(w, "channel not running", http.StatusServiceUnavailable)
- return
- }
-
- // Authenticate
- if !c.authenticate(r) {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- // Check connection limit
- maxConns := c.config.MaxConnections
- if maxConns <= 0 {
- maxConns = 100
- }
- if int(c.connCount.Load()) >= maxConns {
- http.Error(w, "too many connections", http.StatusServiceUnavailable)
- return
- }
-
- conn, err := c.upgrader.Upgrade(w, r, nil)
- if err != nil {
- logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{
- "error": err.Error(),
- })
- return
- }
-
- // Determine session ID from query param or generate one
- sessionID := r.URL.Query().Get("session_id")
- if sessionID == "" {
- sessionID = uuid.New().String()
- }
-
- pc := &picoConn{
- id: uuid.New().String(),
- conn: conn,
- sessionID: sessionID,
- }
-
- c.connections.Store(pc.id, pc)
- c.connCount.Add(1)
-
- logger.InfoCF("pico", "WebSocket client connected", map[string]any{
- "conn_id": pc.id,
- "session_id": sessionID,
- })
-
- go c.readLoop(pc)
-}
-
-// authenticate checks the Bearer token from the Authorization header.
-// Query parameter authentication is only allowed when AllowTokenQuery is explicitly enabled.
-func (c *PicoChannel) authenticate(r *http.Request) bool {
- token := c.config.Token
- if token == "" {
- return false
- }
-
- // Check Authorization header
- auth := r.Header.Get("Authorization")
- if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
- if after == token {
- return true
- }
- }
-
- // Check query parameter only when explicitly allowed
- if c.config.AllowTokenQuery {
- if r.URL.Query().Get("token") == token {
- return true
- }
- }
-
- return false
-}
-
-// readLoop reads messages from a WebSocket connection.
-func (c *PicoChannel) readLoop(pc *picoConn) {
- defer func() {
- pc.close()
- c.connections.Delete(pc.id)
- c.connCount.Add(-1)
- logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{
- "conn_id": pc.id,
- "session_id": pc.sessionID,
- })
- }()
-
- readTimeout := time.Duration(c.config.ReadTimeout) * time.Second
- if readTimeout <= 0 {
- readTimeout = 60 * time.Second
- }
-
- _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
- pc.conn.SetPongHandler(func(appData string) error {
- _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
- return nil
- })
-
- // Start ping ticker
- pingInterval := time.Duration(c.config.PingInterval) * time.Second
- if pingInterval <= 0 {
- pingInterval = 30 * time.Second
- }
- go c.pingLoop(pc, pingInterval)
-
- for {
- select {
- case <-c.ctx.Done():
- return
- default:
- }
-
- _, rawMsg, err := pc.conn.ReadMessage()
- if err != nil {
- if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
- logger.DebugCF("pico", "WebSocket read error", map[string]any{
- "conn_id": pc.id,
- "error": err.Error(),
- })
- }
- return
- }
-
- _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
-
- var msg PicoMessage
- if err := json.Unmarshal(rawMsg, &msg); err != nil {
- errMsg := newError("invalid_message", "failed to parse message")
- pc.writeJSON(errMsg)
- continue
- }
-
- c.handleMessage(pc, msg)
- }
-}
-
-// pingLoop sends periodic ping frames to keep the connection alive.
-func (c *PicoChannel) pingLoop(pc *picoConn, interval time.Duration) {
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
-
- for {
- select {
- case <-c.ctx.Done():
- return
- case <-ticker.C:
- if pc.closed.Load() {
- return
- }
- pc.writeMu.Lock()
- err := pc.conn.WriteMessage(websocket.PingMessage, nil)
- pc.writeMu.Unlock()
- if err != nil {
- return
- }
- }
- }
-}
-
-// handleMessage processes an inbound Pico Protocol message.
-func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) {
- switch msg.Type {
- case TypePing:
- pong := newMessage(TypePong, nil)
- pong.ID = msg.ID
- pc.writeJSON(pong)
-
- case TypeMessageSend:
- c.handleMessageSend(pc, msg)
-
- default:
- errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
- pc.writeJSON(errMsg)
- }
-}
-
-// handleMessageSend processes an inbound message.send from a client.
-func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
- content, _ := msg.Payload["content"].(string)
- if strings.TrimSpace(content) == "" {
- errMsg := newError("empty_content", "message content is empty")
- pc.writeJSON(errMsg)
- return
- }
-
- sessionID := msg.SessionID
- if sessionID == "" {
- sessionID = pc.sessionID
- }
-
- chatID := "pico:" + sessionID
- senderID := "pico-user"
-
- peer := bus.Peer{Kind: "direct", ID: "pico:" + sessionID}
-
- metadata := map[string]string{
- "platform": "pico",
- "session_id": sessionID,
- "conn_id": pc.id,
- }
-
- logger.DebugCF("pico", "Received message", map[string]any{
- "session_id": sessionID,
- "preview": truncate(content, 50),
- })
-
- sender := bus.SenderInfo{
- Platform: "pico",
- PlatformID: senderID,
- CanonicalID: identity.BuildCanonicalID("pico", senderID),
- }
-
- if !c.IsAllowedSender(sender) {
- return
- }
-
- c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender)
-}
-
-// truncate truncates a string to maxLen runes.
-func truncate(s string, maxLen int) string {
- runes := []rune(s)
- if len(runes) <= maxLen {
- return s
- }
- return string(runes[:maxLen]) + "..."
-}
diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go
deleted file mode 100644
index 0a630e193..000000000
--- a/pkg/channels/pico/protocol.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package pico
-
-import "time"
-
-// Protocol message types.
-const (
- // TypeMessageSend is sent from client to server.
- TypeMessageSend = "message.send"
- TypeMediaSend = "media.send"
- TypePing = "ping"
-
- // TypeMessageCreate is sent from server to client.
- TypeMessageCreate = "message.create"
- TypeMessageUpdate = "message.update"
- TypeMediaCreate = "media.create"
- TypeTypingStart = "typing.start"
- TypeTypingStop = "typing.stop"
- TypeError = "error"
- TypePong = "pong"
-)
-
-// PicoMessage is the wire format for all Pico Protocol messages.
-type PicoMessage struct {
- Type string `json:"type"`
- ID string `json:"id,omitempty"`
- SessionID string `json:"session_id,omitempty"`
- Timestamp int64 `json:"timestamp,omitempty"`
- Payload map[string]any `json:"payload,omitempty"`
-}
-
-// newMessage creates a PicoMessage with the given type and payload.
-func newMessage(msgType string, payload map[string]any) PicoMessage {
- return PicoMessage{
- Type: msgType,
- Timestamp: time.Now().UnixMilli(),
- Payload: payload,
- }
-}
-
-// newError creates an error PicoMessage.
-func newError(code, message string) PicoMessage {
- return newMessage(TypeError, map[string]any{
- "code": code,
- "message": message,
- })
-}
diff --git a/pkg/channels/qq/init.go b/pkg/channels/qq/init.go
deleted file mode 100644
index 15b955089..000000000
--- a/pkg/channels/qq/init.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package qq
-
-import (
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func init() {
- channels.RegisterFactory("qq", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewQQChannel(cfg.Channels.QQ, b)
- })
-}
diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go
deleted file mode 100644
index 112964143..000000000
--- a/pkg/channels/qq/qq.go
+++ /dev/null
@@ -1,290 +0,0 @@
-package qq
-
-import (
- "context"
- "fmt"
- "sync"
- "time"
-
- "github.com/tencent-connect/botgo"
- "github.com/tencent-connect/botgo/dto"
- "github.com/tencent-connect/botgo/event"
- "github.com/tencent-connect/botgo/openapi"
- "github.com/tencent-connect/botgo/token"
- "golang.org/x/oauth2"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
-)
-
-type QQChannel struct {
- *channels.BaseChannel
- config config.QQConfig
- api openapi.OpenAPI
- tokenSource oauth2.TokenSource
- ctx context.Context
- cancel context.CancelFunc
- sessionManager botgo.SessionManager
- processedIDs map[string]bool
- mu sync.RWMutex
-}
-
-func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
- base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
- channels.WithGroupTrigger(cfg.GroupTrigger),
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
- )
-
- return &QQChannel{
- BaseChannel: base,
- config: cfg,
- processedIDs: make(map[string]bool),
- }, nil
-}
-
-func (c *QQChannel) Start(ctx context.Context) error {
- if c.config.AppID == "" || c.config.AppSecret == "" {
- return fmt.Errorf("QQ app_id and app_secret not configured")
- }
-
- logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
-
- // create token source
- credentials := &token.QQBotCredentials{
- AppID: c.config.AppID,
- AppSecret: c.config.AppSecret,
- }
- c.tokenSource = token.NewQQBotTokenSource(credentials)
-
- // create child context
- c.ctx, c.cancel = context.WithCancel(ctx)
-
- // start auto-refresh token goroutine
- if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
- return fmt.Errorf("failed to start token refresh: %w", err)
- }
-
- // initialize OpenAPI client
- c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
-
- // register event handlers
- intent := event.RegisterHandlers(
- c.handleC2CMessage(),
- c.handleGroupATMessage(),
- )
-
- // get WebSocket endpoint
- wsInfo, err := c.api.WS(c.ctx, nil, "")
- if err != nil {
- return fmt.Errorf("failed to get websocket info: %w", err)
- }
-
- logger.InfoCF("qq", "Got WebSocket info", map[string]any{
- "shards": wsInfo.Shards,
- })
-
- // create and save sessionManager
- c.sessionManager = botgo.NewSessionManager()
-
- // start WebSocket connection in goroutine to avoid blocking
- go func() {
- if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
- logger.ErrorCF("qq", "WebSocket session error", map[string]any{
- "error": err.Error(),
- })
- c.SetRunning(false)
- }
- }()
-
- c.SetRunning(true)
- logger.InfoC("qq", "QQ bot started successfully")
-
- return nil
-}
-
-func (c *QQChannel) Stop(ctx context.Context) error {
- logger.InfoC("qq", "Stopping QQ bot")
- c.SetRunning(false)
-
- if c.cancel != nil {
- c.cancel()
- }
-
- return nil
-}
-
-func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- // construct message
- msgToCreate := &dto.MessageToCreate{
- Content: msg.Content,
- }
-
- // send C2C message
- _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
- if err != nil {
- logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
- "error": err.Error(),
- })
- return fmt.Errorf("qq send: %w", channels.ErrTemporary)
- }
-
- return nil
-}
-
-// handleC2CMessage handles QQ private messages
-func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
- return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
- // deduplication check
- if c.isDuplicate(data.ID) {
- return nil
- }
-
- // extract user info
- var senderID string
- if data.Author != nil && data.Author.ID != "" {
- senderID = data.Author.ID
- } else {
- logger.WarnC("qq", "Received message with no sender ID")
- return nil
- }
-
- // extract message content
- content := data.Content
- if content == "" {
- logger.DebugC("qq", "Received empty message, ignoring")
- return nil
- }
-
- logger.InfoCF("qq", "Received C2C message", map[string]any{
- "sender": senderID,
- "length": len(content),
- })
-
- // 转发到消息总线
- metadata := map[string]string{}
-
- sender := bus.SenderInfo{
- Platform: "qq",
- PlatformID: data.Author.ID,
- CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
- }
-
- if !c.IsAllowedSender(sender) {
- return nil
- }
-
- c.HandleMessage(c.ctx,
- bus.Peer{Kind: "direct", ID: senderID},
- data.ID,
- senderID,
- senderID,
- content,
- []string{},
- metadata,
- sender,
- )
-
- return nil
- }
-}
-
-// handleGroupATMessage handles QQ group @ messages
-func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
- return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
- // deduplication check
- if c.isDuplicate(data.ID) {
- return nil
- }
-
- // extract user info
- var senderID string
- if data.Author != nil && data.Author.ID != "" {
- senderID = data.Author.ID
- } else {
- logger.WarnC("qq", "Received group message with no sender ID")
- return nil
- }
-
- // extract message content (remove @ bot part)
- content := data.Content
- if content == "" {
- logger.DebugC("qq", "Received empty group message, ignoring")
- return nil
- }
-
- // GroupAT event means bot is always mentioned; apply group trigger filtering
- respond, cleaned := c.ShouldRespondInGroup(true, content)
- if !respond {
- return nil
- }
- content = cleaned
-
- logger.InfoCF("qq", "Received group AT message", map[string]any{
- "sender": senderID,
- "group": data.GroupID,
- "length": len(content),
- })
-
- // 转发到消息总线(使用 GroupID 作为 ChatID)
- metadata := map[string]string{
- "group_id": data.GroupID,
- }
-
- sender := bus.SenderInfo{
- Platform: "qq",
- PlatformID: data.Author.ID,
- CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
- }
-
- if !c.IsAllowedSender(sender) {
- return nil
- }
-
- c.HandleMessage(c.ctx,
- bus.Peer{Kind: "group", ID: data.GroupID},
- data.ID,
- senderID,
- data.GroupID,
- content,
- []string{},
- metadata,
- sender,
- )
-
- return nil
- }
-}
-
-// isDuplicate 检查消息是否重复
-func (c *QQChannel) isDuplicate(messageID string) bool {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- if c.processedIDs[messageID] {
- return true
- }
-
- c.processedIDs[messageID] = true
-
- // 简单清理:限制 map 大小
- if len(c.processedIDs) > 10000 {
- // 清空一半
- count := 0
- for id := range c.processedIDs {
- if count >= 5000 {
- break
- }
- delete(c.processedIDs, id)
- count++
- }
- }
-
- return false
-}
diff --git a/pkg/channels/slack/init.go b/pkg/channels/slack/init.go
deleted file mode 100644
index c131bb291..000000000
--- a/pkg/channels/slack/init.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package slack
-
-import (
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func init() {
- channels.RegisterFactory("slack", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewSlackChannel(cfg.Channels.Slack, b)
- })
-}
diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go
deleted file mode 100644
index 024b1b023..000000000
--- a/pkg/channels/slack/slack.go
+++ /dev/null
@@ -1,532 +0,0 @@
-package slack
-
-import (
- "context"
- "fmt"
- "strings"
- "sync"
-
- "github.com/slack-go/slack"
- "github.com/slack-go/slack/slackevents"
- "github.com/slack-go/slack/socketmode"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/media"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-type SlackChannel struct {
- *channels.BaseChannel
- config config.SlackConfig
- api *slack.Client
- socketClient *socketmode.Client
- botUserID string
- teamID string
- ctx context.Context
- cancel context.CancelFunc
- pendingAcks sync.Map
-}
-
-type slackMessageRef struct {
- ChannelID string
- Timestamp string
-}
-
-func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {
- if cfg.BotToken == "" || cfg.AppToken == "" {
- return nil, fmt.Errorf("slack bot_token and app_token are required")
- }
-
- api := slack.New(
- cfg.BotToken,
- slack.OptionAppLevelToken(cfg.AppToken),
- )
-
- socketClient := socketmode.New(api)
-
- base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom,
- channels.WithMaxMessageLength(40000),
- channels.WithGroupTrigger(cfg.GroupTrigger),
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
- )
-
- return &SlackChannel{
- BaseChannel: base,
- config: cfg,
- api: api,
- socketClient: socketClient,
- }, nil
-}
-
-func (c *SlackChannel) Start(ctx context.Context) error {
- logger.InfoC("slack", "Starting Slack channel (Socket Mode)")
-
- c.ctx, c.cancel = context.WithCancel(ctx)
-
- authResp, err := c.api.AuthTest()
- if err != nil {
- return fmt.Errorf("slack auth test failed: %w", err)
- }
- c.botUserID = authResp.UserID
- c.teamID = authResp.TeamID
-
- logger.InfoCF("slack", "Slack bot connected", map[string]any{
- "bot_user_id": c.botUserID,
- "team": authResp.Team,
- })
-
- go c.eventLoop()
-
- go func() {
- if err := c.socketClient.RunContext(c.ctx); err != nil {
- if c.ctx.Err() == nil {
- logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{
- "error": err.Error(),
- })
- }
- }
- }()
-
- c.SetRunning(true)
- logger.InfoC("slack", "Slack channel started (Socket Mode)")
- return nil
-}
-
-func (c *SlackChannel) Stop(ctx context.Context) error {
- logger.InfoC("slack", "Stopping Slack channel")
-
- if c.cancel != nil {
- c.cancel()
- }
-
- c.SetRunning(false)
- logger.InfoC("slack", "Slack channel stopped")
- return nil
-}
-
-func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- channelID, threadTS := parseSlackChatID(msg.ChatID)
- if channelID == "" {
- return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
- }
-
- opts := []slack.MsgOption{
- slack.MsgOptionText(msg.Content, false),
- }
-
- if threadTS != "" {
- opts = append(opts, slack.MsgOptionTS(threadTS))
- }
-
- _, _, err := c.api.PostMessageContext(ctx, channelID, opts...)
- if err != nil {
- return fmt.Errorf("slack send: %w", channels.ErrTemporary)
- }
-
- if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {
- msgRef := ref.(slackMessageRef)
- c.api.AddReaction("white_check_mark", slack.ItemRef{
- Channel: msgRef.ChannelID,
- Timestamp: msgRef.Timestamp,
- })
- }
-
- logger.DebugCF("slack", "Message sent", map[string]any{
- "channel_id": channelID,
- "thread_ts": threadTS,
- })
-
- return nil
-}
-
-// SendMedia implements the channels.MediaSender interface.
-func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- channelID, _ := parseSlackChatID(msg.ChatID)
- if channelID == "" {
- return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
- }
-
- 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("slack", "Failed to resolve media ref", map[string]any{
- "ref": part.Ref,
- "error": err.Error(),
- })
- continue
- }
-
- filename := part.Filename
- if filename == "" {
- filename = "file"
- }
-
- title := part.Caption
- if title == "" {
- title = filename
- }
-
- _, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
- Channel: channelID,
- File: localPath,
- Filename: filename,
- Title: title,
- })
- if err != nil {
- logger.ErrorCF("slack", "Failed to upload media", map[string]any{
- "filename": filename,
- "error": err.Error(),
- })
- return fmt.Errorf("slack send media: %w", channels.ErrTemporary)
- }
- }
-
- return nil
-}
-
-// ReactToMessage implements channels.ReactionCapable.
-// It adds an "eyes" (👀) reaction to the inbound message and returns an undo function
-// that removes the reaction.
-func (c *SlackChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
- channelID, _ := parseSlackChatID(chatID)
- if channelID == "" {
- return func() {}, nil
- }
-
- c.api.AddReaction("eyes", slack.ItemRef{
- Channel: channelID,
- Timestamp: messageID,
- })
-
- return func() {
- c.api.RemoveReaction("eyes", slack.ItemRef{
- Channel: channelID,
- Timestamp: messageID,
- })
- }, nil
-}
-
-func (c *SlackChannel) eventLoop() {
- for {
- select {
- case <-c.ctx.Done():
- return
- case event, ok := <-c.socketClient.Events:
- if !ok {
- return
- }
- switch event.Type {
- case socketmode.EventTypeEventsAPI:
- c.handleEventsAPI(event)
- case socketmode.EventTypeSlashCommand:
- c.handleSlashCommand(event)
- case socketmode.EventTypeInteractive:
- if event.Request != nil {
- c.socketClient.Ack(*event.Request)
- }
- }
- }
- }
-}
-
-func (c *SlackChannel) handleEventsAPI(event socketmode.Event) {
- if event.Request != nil {
- c.socketClient.Ack(*event.Request)
- }
-
- eventsAPIEvent, ok := event.Data.(slackevents.EventsAPIEvent)
- if !ok {
- return
- }
-
- switch ev := eventsAPIEvent.InnerEvent.Data.(type) {
- case *slackevents.MessageEvent:
- c.handleMessageEvent(ev)
- case *slackevents.AppMentionEvent:
- c.handleAppMention(ev)
- }
-}
-
-func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
- if ev.User == c.botUserID || ev.User == "" {
- return
- }
- if ev.BotID != "" {
- return
- }
- if ev.SubType != "" && ev.SubType != "file_share" {
- return
- }
-
- // check allowlist to avoid downloading attachments for rejected users
- sender := bus.SenderInfo{
- Platform: "slack",
- PlatformID: ev.User,
- CanonicalID: identity.BuildCanonicalID("slack", ev.User),
- }
- if !c.IsAllowedSender(sender) {
- logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
- "user_id": ev.User,
- })
- return
- }
-
- senderID := ev.User
- channelID := ev.Channel
- threadTS := ev.ThreadTimeStamp
- messageTS := ev.TimeStamp
-
- chatID := channelID
- if threadTS != "" {
- chatID = channelID + "/" + threadTS
- }
-
- c.pendingAcks.Store(chatID, slackMessageRef{
- ChannelID: channelID,
- Timestamp: messageTS,
- })
-
- content := ev.Text
- content = c.stripBotMention(content)
-
- // In non-DM channels, apply group trigger filtering
- if !strings.HasPrefix(channelID, "D") {
- respond, cleaned := c.ShouldRespondInGroup(false, content)
- if !respond {
- return
- }
- content = cleaned
- }
-
- var mediaPaths []string
-
- scope := channels.BuildMediaScope("slack", chatID, messageTS)
-
- // Helper to register a local file with the media store
- storeMedia := func(localPath, filename string) string {
- if store := c.GetMediaStore(); store != nil {
- ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "slack",
- }, scope)
- if err == nil {
- return ref
- }
- }
- return localPath // fallback
- }
-
- if ev.Message != nil && len(ev.Message.Files) > 0 {
- for _, file := range ev.Message.Files {
- localPath := c.downloadSlackFile(file)
- if localPath == "" {
- continue
- }
- mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
- content += fmt.Sprintf("\n[file: %s]", file.Name)
- }
- }
-
- if strings.TrimSpace(content) == "" {
- return
- }
-
- peerKind := "channel"
- peerID := channelID
- if strings.HasPrefix(channelID, "D") {
- peerKind = "direct"
- peerID = senderID
- }
-
- peer := bus.Peer{Kind: peerKind, ID: peerID}
-
- metadata := map[string]string{
- "message_ts": messageTS,
- "channel_id": channelID,
- "thread_ts": threadTS,
- "platform": "slack",
- "team_id": c.teamID,
- }
-
- logger.DebugCF("slack", "Received message", map[string]any{
- "sender_id": senderID,
- "chat_id": chatID,
- "preview": utils.Truncate(content, 50),
- "has_thread": threadTS != "",
- })
-
- c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata, sender)
-}
-
-func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
- if ev.User == c.botUserID {
- return
- }
-
- if !c.IsAllowedSender(bus.SenderInfo{
- Platform: "slack",
- PlatformID: ev.User,
- CanonicalID: identity.BuildCanonicalID("slack", ev.User),
- }) {
- logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
- "user_id": ev.User,
- })
- return
- }
-
- senderID := ev.User
- mentionSender := bus.SenderInfo{
- Platform: "slack",
- PlatformID: senderID,
- CanonicalID: identity.BuildCanonicalID("slack", senderID),
- }
- channelID := ev.Channel
- threadTS := ev.ThreadTimeStamp
- messageTS := ev.TimeStamp
-
- var chatID string
- if threadTS != "" {
- chatID = channelID + "/" + threadTS
- } else {
- chatID = channelID + "/" + messageTS
- }
-
- c.pendingAcks.Store(chatID, slackMessageRef{
- ChannelID: channelID,
- Timestamp: messageTS,
- })
-
- content := c.stripBotMention(ev.Text)
-
- if strings.TrimSpace(content) == "" {
- return
- }
-
- mentionPeerKind := "channel"
- mentionPeerID := channelID
- if strings.HasPrefix(channelID, "D") {
- mentionPeerKind = "direct"
- mentionPeerID = senderID
- }
-
- mentionPeer := bus.Peer{Kind: mentionPeerKind, ID: mentionPeerID}
-
- metadata := map[string]string{
- "message_ts": messageTS,
- "channel_id": channelID,
- "thread_ts": threadTS,
- "platform": "slack",
- "is_mention": "true",
- "team_id": c.teamID,
- }
-
- c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender)
-}
-
-func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
- cmd, ok := event.Data.(slack.SlashCommand)
- if !ok {
- return
- }
-
- if event.Request != nil {
- c.socketClient.Ack(*event.Request)
- }
-
- cmdSender := bus.SenderInfo{
- Platform: "slack",
- PlatformID: cmd.UserID,
- CanonicalID: identity.BuildCanonicalID("slack", cmd.UserID),
- }
- if !c.IsAllowedSender(cmdSender) {
- logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
- "user_id": cmd.UserID,
- })
- return
- }
-
- senderID := cmd.UserID
- channelID := cmd.ChannelID
- chatID := channelID
- content := cmd.Text
-
- if strings.TrimSpace(content) == "" {
- content = "help"
- }
-
- metadata := map[string]string{
- "channel_id": channelID,
- "platform": "slack",
- "is_command": "true",
- "trigger_id": cmd.TriggerID,
- "team_id": c.teamID,
- }
-
- logger.DebugCF("slack", "Slash command received", map[string]any{
- "sender_id": senderID,
- "command": cmd.Command,
- "text": utils.Truncate(content, 50),
- })
-
- c.HandleMessage(
- c.ctx,
- bus.Peer{Kind: "channel", ID: channelID},
- "",
- senderID,
- chatID,
- content,
- nil,
- metadata,
- cmdSender,
- )
-}
-
-func (c *SlackChannel) downloadSlackFile(file slack.File) string {
- downloadURL := file.URLPrivateDownload
- if downloadURL == "" {
- downloadURL = file.URLPrivate
- }
- if downloadURL == "" {
- logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID})
- return ""
- }
-
- return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{
- LoggerPrefix: "slack",
- ExtraHeaders: map[string]string{
- "Authorization": "Bearer " + c.config.BotToken,
- },
- })
-}
-
-func (c *SlackChannel) stripBotMention(text string) string {
- mention := fmt.Sprintf("<@%s>", c.botUserID)
- text = strings.ReplaceAll(text, mention, "")
- return strings.TrimSpace(text)
-}
-
-func parseSlackChatID(chatID string) (channelID, threadTS string) {
- parts := strings.SplitN(chatID, "/", 2)
- channelID = parts[0]
- if len(parts) > 1 {
- threadTS = parts[1]
- }
- return channelID, threadTS
-}
diff --git a/pkg/channels/slack/slack_test.go b/pkg/channels/slack/slack_test.go
deleted file mode 100644
index 30e0d2d73..000000000
--- a/pkg/channels/slack/slack_test.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package slack
-
-import (
- "testing"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func TestParseSlackChatID(t *testing.T) {
- tests := []struct {
- name string
- chatID string
- wantChanID string
- wantThread string
- }{
- {
- name: "channel only",
- chatID: "C123456",
- wantChanID: "C123456",
- wantThread: "",
- },
- {
- name: "channel with thread",
- chatID: "C123456/1234567890.123456",
- wantChanID: "C123456",
- wantThread: "1234567890.123456",
- },
- {
- name: "DM channel",
- chatID: "D987654",
- wantChanID: "D987654",
- wantThread: "",
- },
- {
- name: "empty string",
- chatID: "",
- wantChanID: "",
- wantThread: "",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- chanID, threadTS := parseSlackChatID(tt.chatID)
- if chanID != tt.wantChanID {
- t.Errorf("parseSlackChatID(%q) channelID = %q, want %q", tt.chatID, chanID, tt.wantChanID)
- }
- if threadTS != tt.wantThread {
- t.Errorf("parseSlackChatID(%q) threadTS = %q, want %q", tt.chatID, threadTS, tt.wantThread)
- }
- })
- }
-}
-
-func TestStripBotMention(t *testing.T) {
- ch := &SlackChannel{botUserID: "U12345BOT"}
-
- tests := []struct {
- name string
- input string
- want string
- }{
- {
- name: "mention at start",
- input: "<@U12345BOT> hello there",
- want: "hello there",
- },
- {
- name: "mention in middle",
- input: "hey <@U12345BOT> can you help",
- want: "hey can you help",
- },
- {
- name: "no mention",
- input: "hello world",
- want: "hello world",
- },
- {
- name: "empty string",
- input: "",
- want: "",
- },
- {
- name: "only mention",
- input: "<@U12345BOT>",
- want: "",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := ch.stripBotMention(tt.input)
- if got != tt.want {
- t.Errorf("stripBotMention(%q) = %q, want %q", tt.input, got, tt.want)
- }
- })
- }
-}
-
-func TestNewSlackChannel(t *testing.T) {
- msgBus := bus.NewMessageBus()
-
- t.Run("missing bot token", func(t *testing.T) {
- cfg := config.SlackConfig{
- BotToken: "",
- AppToken: "xapp-test",
- }
- _, err := NewSlackChannel(cfg, msgBus)
- if err == nil {
- t.Error("expected error for missing bot_token, got nil")
- }
- })
-
- t.Run("missing app token", func(t *testing.T) {
- cfg := config.SlackConfig{
- BotToken: "xoxb-test",
- AppToken: "",
- }
- _, err := NewSlackChannel(cfg, msgBus)
- if err == nil {
- t.Error("expected error for missing app_token, got nil")
- }
- })
-
- t.Run("valid config", func(t *testing.T) {
- cfg := config.SlackConfig{
- BotToken: "xoxb-test",
- AppToken: "xapp-test",
- AllowFrom: []string{"U123"},
- }
- ch, err := NewSlackChannel(cfg, msgBus)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if ch.Name() != "slack" {
- t.Errorf("Name() = %q, want %q", ch.Name(), "slack")
- }
- if ch.IsRunning() {
- t.Error("new channel should not be running")
- }
- })
-}
-
-func TestSlackChannelIsAllowed(t *testing.T) {
- msgBus := bus.NewMessageBus()
-
- t.Run("empty allowlist allows all", func(t *testing.T) {
- cfg := config.SlackConfig{
- BotToken: "xoxb-test",
- AppToken: "xapp-test",
- AllowFrom: []string{},
- }
- ch, _ := NewSlackChannel(cfg, msgBus)
- if !ch.IsAllowed("U_ANYONE") {
- t.Error("empty allowlist should allow all users")
- }
- })
-
- t.Run("allowlist restricts users", func(t *testing.T) {
- cfg := config.SlackConfig{
- BotToken: "xoxb-test",
- AppToken: "xapp-test",
- AllowFrom: []string{"U_ALLOWED"},
- }
- ch, _ := NewSlackChannel(cfg, msgBus)
- if !ch.IsAllowed("U_ALLOWED") {
- t.Error("allowed user should pass allowlist check")
- }
- if ch.IsAllowed("U_BLOCKED") {
- t.Error("non-allowed user should be blocked")
- }
- })
-}
diff --git a/pkg/channels/telegram/init.go b/pkg/channels/telegram/init.go
deleted file mode 100644
index ac87bb805..000000000
--- a/pkg/channels/telegram/init.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package telegram
-
-import (
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func init() {
- channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewTelegramChannel(cfg, b)
- })
-}
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
deleted file mode 100644
index f328f32b8..000000000
--- a/pkg/channels/telegram/telegram.go
+++ /dev/null
@@ -1,769 +0,0 @@
-package telegram
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/url"
- "os"
- "regexp"
- "slices"
- "strconv"
- "strings"
- "time"
-
- "github.com/mymmrac/telego"
- th "github.com/mymmrac/telego/telegohandler"
- tu "github.com/mymmrac/telego/telegoutil"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/media"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-var (
- reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
- reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
- reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
- reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
- reBoldUnder = regexp.MustCompile(`__(.+?)__`)
- reItalic = regexp.MustCompile(`_([^_]+)_`)
- reStrike = regexp.MustCompile(`~~(.+?)~~`)
- reListItem = regexp.MustCompile(`^[-*]\s+`)
- reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
- reInlineCode = regexp.MustCompile("`([^`]+)`")
-)
-
-type TelegramChannel struct {
- *channels.BaseChannel
- bot *telego.Bot
- bh *th.BotHandler
- commands TelegramCommander
- config *config.Config
- chatIDs map[string]int64
- ctx context.Context
- cancel context.CancelFunc
-}
-
-func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
- var opts []telego.BotOption
- telegramCfg := cfg.Channels.Telegram
-
- if telegramCfg.Proxy != "" {
- proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
- if parseErr != nil {
- return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr)
- }
- opts = append(opts, telego.WithHTTPClient(&http.Client{
- Transport: &http.Transport{
- Proxy: http.ProxyURL(proxyURL),
- },
- }))
- } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
- // Use environment proxy if configured
- opts = append(opts, telego.WithHTTPClient(&http.Client{
- Transport: &http.Transport{
- Proxy: http.ProxyFromEnvironment,
- },
- }))
- }
-
- if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
- opts = append(opts, telego.WithAPIServer(baseURL))
- }
-
- bot, err := telego.NewBot(telegramCfg.Token, opts...)
- if err != nil {
- return nil, fmt.Errorf("failed to create telegram bot: %w", err)
- }
-
- base := channels.NewBaseChannel(
- "telegram",
- telegramCfg,
- bus,
- telegramCfg.AllowFrom,
- channels.WithMaxMessageLength(4096),
- channels.WithGroupTrigger(telegramCfg.GroupTrigger),
- channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
- )
-
- return &TelegramChannel{
- BaseChannel: base,
- commands: NewTelegramCommands(bot, cfg),
- bot: bot,
- config: cfg,
- chatIDs: make(map[string]int64),
- }, nil
-}
-
-func (c *TelegramChannel) Start(ctx context.Context) error {
- logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
-
- c.ctx, c.cancel = context.WithCancel(ctx)
-
- if err := c.initBotCommands(c.ctx); err != nil {
- logger.WarnCF("telegram", "Failed to initialize bot commands", map[string]any{
- "error": err.Error(),
- })
- }
-
- updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
- Timeout: 30,
- })
- if err != nil {
- c.cancel()
- return fmt.Errorf("failed to start long polling: %w", err)
- }
-
- bh, err := th.NewBotHandler(c.bot, updates)
- if err != nil {
- c.cancel()
- return fmt.Errorf("failed to create bot handler: %w", err)
- }
- c.bh = bh
-
- bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
- return c.commands.Start(ctx, message)
- }, th.CommandEqual("start"))
- bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
- return c.commands.Help(ctx, message)
- }, th.CommandEqual("help"))
-
- bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
- return c.commands.Show(ctx, message)
- }, th.CommandEqual("show"))
-
- bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
- return c.commands.List(ctx, message)
- }, th.CommandEqual("list"))
-
- bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
- return c.handleMessage(ctx, &message)
- }, th.AnyMessage())
-
- c.SetRunning(true)
- logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
- "username": c.bot.Username(),
- })
-
- go func() {
- if err = bh.Start(); err != nil {
- logger.ErrorCF("telegram", "Bot handler failed", map[string]any{
- "error": err.Error(),
- })
- }
- }()
-
- return nil
-}
-
-func (c *TelegramChannel) Stop(ctx context.Context) error {
- logger.InfoC("telegram", "Stopping Telegram bot...")
- c.SetRunning(false)
-
- // Stop the bot handler
- if c.bh != nil {
- _ = c.bh.StopWithContext(ctx)
- }
-
- // Cancel our context (stops long polling)
- if c.cancel != nil {
- c.cancel()
- }
-
- return nil
-}
-
-func (c *TelegramChannel) initBotCommands(ctx context.Context) error {
- currentCommands, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{
- Scope: tu.ScopeDefault(),
- })
- if err != nil {
- return fmt.Errorf("get commands: %w", err)
- }
-
- commands := []telego.BotCommand{
- {
- Command: "start",
- Description: "Start the bot",
- },
- {
- Command: "help",
- Description: "Show a help message",
- },
- {
- Command: "show",
- Description: "Show current configuration",
- },
- {
- Command: "list",
- Description: "List available options",
- },
- }
-
- // Setting commands on each start will hit the rate limit very quickly, that's why we check if an update is needed
- if !slices.Equal(currentCommands, commands) {
- logger.InfoC("telegram", "Updating bot commands")
-
- err = c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
- Commands: commands,
- Scope: tu.ScopeDefault(),
- })
- if err != nil {
- return fmt.Errorf("set commands: %w", err)
- }
- } else {
- logger.DebugC("telegram", "Bot commands are up to date")
- }
-
- return nil
-}
-
-func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- chatID, err := parseChatID(msg.ChatID)
- if err != nil {
- return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
- }
-
- htmlContent := markdownToTelegramHTML(msg.Content)
-
- // Typing/placeholder handled by Manager.preSend — just send the message
- tgMsg := tu.Message(tu.ID(chatID), htmlContent)
- tgMsg.ParseMode = telego.ModeHTML
-
- if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
- logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
- "error": err.Error(),
- })
- tgMsg.ParseMode = ""
- if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
- return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
- }
- }
-
- return nil
-}
-
-// StartTyping implements channels.TypingCapable.
-// It sends ChatAction(typing) immediately and then repeats every 4 seconds
-// (Telegram's typing indicator expires after ~5s) in a background goroutine.
-// The returned stop function is idempotent and cancels the goroutine.
-func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
- cid, err := parseChatID(chatID)
- if err != nil {
- return func() {}, err
- }
-
- // Send the first typing action immediately
- _ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
-
- typingCtx, cancel := context.WithCancel(ctx)
- go func() {
- ticker := time.NewTicker(4 * time.Second)
- defer ticker.Stop()
- for {
- select {
- case <-typingCtx.Done():
- return
- case <-ticker.C:
- _ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
- }
- }
- }()
-
- return cancel, nil
-}
-
-// EditMessage implements channels.MessageEditor.
-func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
- cid, err := parseChatID(chatID)
- if err != nil {
- return err
- }
- mid, err := strconv.Atoi(messageID)
- if err != nil {
- return err
- }
- htmlContent := markdownToTelegramHTML(content)
- editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent)
- editMsg.ParseMode = telego.ModeHTML
- _, err = c.bot.EditMessageText(ctx, editMsg)
- return err
-}
-
-// SendPlaceholder implements channels.PlaceholderCapable.
-// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
-// edited to the actual response via EditMessage (channels.MessageEditor).
-func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
- phCfg := c.config.Channels.Telegram.Placeholder
- if !phCfg.Enabled {
- return "", nil
- }
-
- text := phCfg.Text
- if text == "" {
- text = "Thinking... 💭"
- }
-
- cid, err := parseChatID(chatID)
- if err != nil {
- return "", err
- }
-
- pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text))
- if err != nil {
- return "", err
- }
-
- return fmt.Sprintf("%d", pMsg.MessageID), nil
-}
-
-// SendMedia implements the channels.MediaSender interface.
-func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- chatID, err := parseChatID(msg.ChatID)
- if err != nil {
- return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
- }
-
- 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("telegram", "Failed to resolve media ref", map[string]any{
- "ref": part.Ref,
- "error": err.Error(),
- })
- continue
- }
-
- file, err := os.Open(localPath)
- if err != nil {
- logger.ErrorCF("telegram", "Failed to open media file", map[string]any{
- "path": localPath,
- "error": err.Error(),
- })
- continue
- }
-
- switch part.Type {
- case "image":
- params := &telego.SendPhotoParams{
- ChatID: tu.ID(chatID),
- Photo: telego.InputFile{File: file},
- Caption: part.Caption,
- }
- _, err = c.bot.SendPhoto(ctx, params)
- case "audio":
- params := &telego.SendAudioParams{
- ChatID: tu.ID(chatID),
- Audio: telego.InputFile{File: file},
- Caption: part.Caption,
- }
- _, err = c.bot.SendAudio(ctx, params)
- case "video":
- params := &telego.SendVideoParams{
- ChatID: tu.ID(chatID),
- Video: telego.InputFile{File: file},
- Caption: part.Caption,
- }
- _, err = c.bot.SendVideo(ctx, params)
- default: // "file" or unknown types
- params := &telego.SendDocumentParams{
- ChatID: tu.ID(chatID),
- Document: telego.InputFile{File: file},
- Caption: part.Caption,
- }
- _, err = c.bot.SendDocument(ctx, params)
- }
-
- file.Close()
-
- if err != nil {
- logger.ErrorCF("telegram", "Failed to send media", map[string]any{
- "type": part.Type,
- "error": err.Error(),
- })
- return fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
- }
- }
-
- return nil
-}
-
-func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
- if message == nil {
- return fmt.Errorf("message is nil")
- }
-
- user := message.From
- if user == nil {
- return fmt.Errorf("message sender (user) is nil")
- }
-
- platformID := fmt.Sprintf("%d", user.ID)
- sender := bus.SenderInfo{
- Platform: "telegram",
- PlatformID: platformID,
- CanonicalID: identity.BuildCanonicalID("telegram", platformID),
- Username: user.Username,
- DisplayName: user.FirstName,
- }
-
- // check allowlist to avoid downloading attachments for rejected users
- if !c.IsAllowedSender(sender) {
- logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
- "user_id": platformID,
- })
- return nil
- }
-
- chatID := message.Chat.ID
- c.chatIDs[platformID] = chatID
-
- content := ""
- mediaPaths := []string{}
-
- chatIDStr := fmt.Sprintf("%d", chatID)
- messageIDStr := fmt.Sprintf("%d", message.MessageID)
- scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
-
- // Helper to register a local file with the media store
- storeMedia := func(localPath, filename string) string {
- if store := c.GetMediaStore(); store != nil {
- ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "telegram",
- }, scope)
- if err == nil {
- return ref
- }
- }
- return localPath // fallback: use raw path
- }
-
- if message.Text != "" {
- content += message.Text
- }
-
- if message.Caption != "" {
- if content != "" {
- content += "\n"
- }
- content += message.Caption
- }
-
- if len(message.Photo) > 0 {
- photo := message.Photo[len(message.Photo)-1]
- photoPath := c.downloadPhoto(ctx, photo.FileID)
- if photoPath != "" {
- mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg"))
- if content != "" {
- content += "\n"
- }
- content += "[image: photo]"
- }
- }
-
- if message.Voice != nil {
- voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
- if voicePath != "" {
- mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
-
- if content != "" {
- content += "\n"
- }
- content += "[voice]"
- }
- }
-
- if message.Audio != nil {
- audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
- if audioPath != "" {
- mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3"))
- if content != "" {
- content += "\n"
- }
- content += "[audio]"
- }
- }
-
- if message.Document != nil {
- docPath := c.downloadFile(ctx, message.Document.FileID, "")
- if docPath != "" {
- mediaPaths = append(mediaPaths, storeMedia(docPath, "document"))
- if content != "" {
- content += "\n"
- }
- content += "[file]"
- }
- }
-
- if content == "" {
- content = "[empty message]"
- }
-
- // In group chats, apply unified group trigger filtering
- if message.Chat.Type != "private" {
- isMentioned := c.isBotMentioned(message)
- if isMentioned {
- content = c.stripBotMention(content)
- }
- respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
- if !respond {
- return nil
- }
- content = cleaned
- }
-
- logger.DebugCF("telegram", "Received message", map[string]any{
- "sender_id": sender.CanonicalID,
- "chat_id": fmt.Sprintf("%d", chatID),
- "preview": utils.Truncate(content, 50),
- })
-
- // Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable
-
- peerKind := "direct"
- peerID := fmt.Sprintf("%d", user.ID)
- if message.Chat.Type != "private" {
- peerKind = "group"
- peerID = fmt.Sprintf("%d", chatID)
- }
-
- peer := bus.Peer{Kind: peerKind, ID: peerID}
- messageID := fmt.Sprintf("%d", message.MessageID)
-
- metadata := map[string]string{
- "user_id": fmt.Sprintf("%d", user.ID),
- "username": user.Username,
- "first_name": user.FirstName,
- "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
- }
-
- c.HandleMessage(c.ctx,
- peer,
- messageID,
- platformID,
- fmt.Sprintf("%d", chatID),
- content,
- mediaPaths,
- metadata,
- sender,
- )
- return nil
-}
-
-func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
- file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
- if err != nil {
- logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{
- "error": err.Error(),
- })
- return ""
- }
-
- return c.downloadFileWithInfo(file, ".jpg")
-}
-
-func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string {
- if file.FilePath == "" {
- return ""
- }
-
- url := c.bot.FileDownloadURL(file.FilePath)
- logger.DebugCF("telegram", "File URL", map[string]any{"url": url})
-
- // Use FilePath as filename for better identification
- filename := file.FilePath + ext
- return utils.DownloadFile(url, filename, utils.DownloadOptions{
- LoggerPrefix: "telegram",
- })
-}
-
-func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
- file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
- if err != nil {
- logger.ErrorCF("telegram", "Failed to get file", map[string]any{
- "error": err.Error(),
- })
- return ""
- }
-
- return c.downloadFileWithInfo(file, ext)
-}
-
-func parseChatID(chatIDStr string) (int64, error) {
- var id int64
- _, err := fmt.Sscanf(chatIDStr, "%d", &id)
- return id, err
-}
-
-func markdownToTelegramHTML(text string) string {
- if text == "" {
- return ""
- }
-
- codeBlocks := extractCodeBlocks(text)
- text = codeBlocks.text
-
- inlineCodes := extractInlineCodes(text)
- text = inlineCodes.text
-
- text = reHeading.ReplaceAllString(text, "$1")
-
- text = reBlockquote.ReplaceAllString(text, "$1")
-
- text = escapeHTML(text)
-
- text = reLink.ReplaceAllString(text, `$1`)
-
- text = reBoldStar.ReplaceAllString(text, "$1")
-
- text = reBoldUnder.ReplaceAllString(text, "$1")
-
- text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
- match := reItalic.FindStringSubmatch(s)
- if len(match) < 2 {
- return s
- }
- return "" + match[1] + ""
- })
-
- text = reStrike.ReplaceAllString(text, "$1")
-
- text = reListItem.ReplaceAllString(text, "• ")
-
- for i, code := range inlineCodes.codes {
- escaped := escapeHTML(code)
- text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped))
- }
-
- for i, code := range codeBlocks.codes {
- escaped := escapeHTML(code)
- text = strings.ReplaceAll(
- text,
- fmt.Sprintf("\x00CB%d\x00", i),
- fmt.Sprintf("%s
", escaped),
- )
- }
-
- return text
-}
-
-type codeBlockMatch struct {
- text string
- codes []string
-}
-
-func extractCodeBlocks(text string) codeBlockMatch {
- matches := reCodeBlock.FindAllStringSubmatch(text, -1)
-
- codes := make([]string, 0, len(matches))
- for _, match := range matches {
- codes = append(codes, match[1])
- }
-
- i := 0
- text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
- placeholder := fmt.Sprintf("\x00CB%d\x00", i)
- i++
- return placeholder
- })
-
- return codeBlockMatch{text: text, codes: codes}
-}
-
-type inlineCodeMatch struct {
- text string
- codes []string
-}
-
-func extractInlineCodes(text string) inlineCodeMatch {
- matches := reInlineCode.FindAllStringSubmatch(text, -1)
-
- codes := make([]string, 0, len(matches))
- for _, match := range matches {
- codes = append(codes, match[1])
- }
-
- i := 0
- text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
- placeholder := fmt.Sprintf("\x00IC%d\x00", i)
- i++
- return placeholder
- })
-
- return inlineCodeMatch{text: text, codes: codes}
-}
-
-func escapeHTML(text string) string {
- text = strings.ReplaceAll(text, "&", "&")
- text = strings.ReplaceAll(text, "<", "<")
- text = strings.ReplaceAll(text, ">", ">")
- return text
-}
-
-// isBotMentioned checks if the bot is mentioned in the message via entities.
-func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
- botUsername := c.bot.Username()
- if botUsername == "" {
- return false
- }
-
- entities := message.Entities
- if entities == nil {
- entities = message.CaptionEntities
- }
-
- for _, entity := range entities {
- if entity.Type == "mention" {
- // Extract the mention text from the message
- text := message.Text
- if text == "" {
- text = message.Caption
- }
- runes := []rune(text)
- end := entity.Offset + entity.Length
- if end <= len(runes) {
- mention := string(runes[entity.Offset:end])
- if strings.EqualFold(mention, "@"+botUsername) {
- return true
- }
- }
- }
- if entity.Type == "text_mention" && entity.User != nil {
- if entity.User.Username == botUsername {
- return true
- }
- }
- }
- return false
-}
-
-// stripBotMention removes the @bot mention from the content.
-func (c *TelegramChannel) stripBotMention(content string) string {
- botUsername := c.bot.Username()
- if botUsername == "" {
- return content
- }
- // Case-insensitive replacement
- re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername))
- content = re.ReplaceAllString(content, "")
- return strings.TrimSpace(content)
-}
diff --git a/pkg/channels/telegram/telegram_commands.go b/pkg/channels/telegram/telegram_commands.go
deleted file mode 100644
index 496fc5e4f..000000000
--- a/pkg/channels/telegram/telegram_commands.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package telegram
-
-import (
- "context"
- "fmt"
- "strings"
-
- "github.com/mymmrac/telego"
-
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-type TelegramCommander interface {
- Help(ctx context.Context, message telego.Message) error
- Start(ctx context.Context, message telego.Message) error
- Show(ctx context.Context, message telego.Message) error
- List(ctx context.Context, message telego.Message) error
-}
-
-type cmd struct {
- bot *telego.Bot
- config *config.Config
-}
-
-func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander {
- return &cmd{
- bot: bot,
- config: cfg,
- }
-}
-
-func commandArgs(text string) string {
- parts := strings.SplitN(text, " ", 2)
- if len(parts) < 2 {
- return ""
- }
- return strings.TrimSpace(parts[1])
-}
-
-func (c *cmd) Help(ctx context.Context, message telego.Message) error {
- msg := `/start - Start the bot
-/help - Show this help message
-/show [model|channel] - Show current configuration
-/list [models|channels] - List available options
- `
- _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
- ChatID: telego.ChatID{ID: message.Chat.ID},
- Text: msg,
- ReplyParameters: &telego.ReplyParameters{
- MessageID: message.MessageID,
- },
- })
- return err
-}
-
-func (c *cmd) Start(ctx context.Context, message telego.Message) error {
- _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
- ChatID: telego.ChatID{ID: message.Chat.ID},
- Text: "Hello! I am PicoClaw 🦞",
- ReplyParameters: &telego.ReplyParameters{
- MessageID: message.MessageID,
- },
- })
- return err
-}
-
-func (c *cmd) Show(ctx context.Context, message telego.Message) error {
- args := commandArgs(message.Text)
- if args == "" {
- _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
- ChatID: telego.ChatID{ID: message.Chat.ID},
- Text: "Usage: /show [model|channel]",
- ReplyParameters: &telego.ReplyParameters{
- MessageID: message.MessageID,
- },
- })
- return err
- }
-
- var response string
- switch args {
- case "model":
- response = fmt.Sprintf("Current Model: %s (Provider: %s)",
- c.config.Agents.Defaults.GetModelName(),
- c.config.Agents.Defaults.Provider)
- case "channel":
- response = "Current Channel: telegram"
- default:
- response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args)
- }
-
- _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
- ChatID: telego.ChatID{ID: message.Chat.ID},
- Text: response,
- ReplyParameters: &telego.ReplyParameters{
- MessageID: message.MessageID,
- },
- })
- return err
-}
-
-func (c *cmd) List(ctx context.Context, message telego.Message) error {
- args := commandArgs(message.Text)
- if args == "" {
- _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
- ChatID: telego.ChatID{ID: message.Chat.ID},
- Text: "Usage: /list [models|channels]",
- ReplyParameters: &telego.ReplyParameters{
- MessageID: message.MessageID,
- },
- })
- return err
- }
-
- var response string
- switch args {
- case "models":
- provider := c.config.Agents.Defaults.Provider
- if provider == "" {
- provider = "configured default"
- }
- response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
- c.config.Agents.Defaults.GetModelName(), provider)
-
- case "channels":
- var enabled []string
- if c.config.Channels.Telegram.Enabled {
- enabled = append(enabled, "telegram")
- }
- if c.config.Channels.WhatsApp.Enabled {
- enabled = append(enabled, "whatsapp")
- }
- if c.config.Channels.Feishu.Enabled {
- enabled = append(enabled, "feishu")
- }
- if c.config.Channels.Discord.Enabled {
- enabled = append(enabled, "discord")
- }
- if c.config.Channels.Slack.Enabled {
- enabled = append(enabled, "slack")
- }
- response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- "))
-
- default:
- response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args)
- }
-
- _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
- ChatID: telego.ChatID{ID: message.Chat.ID},
- Text: response,
- ReplyParameters: &telego.ReplyParameters{
- MessageID: message.MessageID,
- },
- })
- return err
-}
diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go
deleted file mode 100644
index 6c5aca40b..000000000
--- a/pkg/channels/wecom/aibot.go
+++ /dev/null
@@ -1,1014 +0,0 @@
-package wecom
-
-import (
- "bytes"
- "context"
- "crypto/rand"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "io"
- "math/big"
- "net/http"
- "strings"
- "sync"
- "time"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/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, _ := io.ReadAll(resp.Body)
- 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 6f0664187..000000000
--- a/pkg/channels/wecom/aibot_test.go
+++ /dev/null
@@ -1,210 +0,0 @@
-package wecom
-
-import (
- "context"
- "testing"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func TestNewWeComAIBotChannel(t *testing.T) {
- t.Run("success with valid config", func(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
- WebhookPath: "/webhook/test",
- }
-
- messageBus := bus.NewMessageBus()
- ch, err := NewWeComAIBotChannel(cfg, messageBus)
- if err != nil {
- t.Fatalf("Expected no error, got %v", err)
- }
-
- if ch == nil {
- t.Fatal("Expected channel to be created")
- }
-
- if ch.Name() != "wecom_aibot" {
- t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
- }
- })
-
- t.Run("error with missing token", func(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
- }
-
- messageBus := bus.NewMessageBus()
- _, err := NewWeComAIBotChannel(cfg, messageBus)
-
- if err == nil {
- t.Fatal("Expected error for missing token, got nil")
- }
- })
-
- t.Run("error with missing encoding key", func(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- }
-
- messageBus := bus.NewMessageBus()
- _, err := NewWeComAIBotChannel(cfg, messageBus)
-
- if err == nil {
- t.Fatal("Expected error for missing encoding key, got nil")
- }
- })
-}
-
-func TestWeComAIBotChannelStartStop(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
- }
-
- messageBus := bus.NewMessageBus()
- ch, err := NewWeComAIBotChannel(cfg, messageBus)
- if err != nil {
- t.Fatalf("Failed to create channel: %v", err)
- }
-
- ctx := context.Background()
-
- // Test Start
- if err := ch.Start(ctx); err != nil {
- t.Fatalf("Failed to start channel: %v", err)
- }
-
- if !ch.IsRunning() {
- t.Error("Expected channel to be running")
- }
-
- // Test Stop
- if err := ch.Stop(ctx); err != nil {
- t.Fatalf("Failed to stop channel: %v", err)
- }
-
- if ch.IsRunning() {
- t.Error("Expected channel to be stopped")
- }
-}
-
-func TestWeComAIBotChannelWebhookPath(t *testing.T) {
- t.Run("default path", func(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
- }
-
- messageBus := bus.NewMessageBus()
- ch, _ := NewWeComAIBotChannel(cfg, messageBus)
-
- expectedPath := "/webhook/wecom-aibot"
- if ch.WebhookPath() != expectedPath {
- t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, ch.WebhookPath())
- }
- })
-
- t.Run("custom path", func(t *testing.T) {
- customPath := "/custom/webhook"
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
- WebhookPath: customPath,
- }
-
- messageBus := bus.NewMessageBus()
- ch, _ := NewWeComAIBotChannel(cfg, messageBus)
-
- if ch.WebhookPath() != customPath {
- t.Errorf("Expected webhook path '%s', got '%s'", customPath, ch.WebhookPath())
- }
- })
-}
-
-func TestGenerateStreamID(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
- }
-
- messageBus := bus.NewMessageBus()
- ch, _ := NewWeComAIBotChannel(cfg, messageBus)
-
- // Generate multiple IDs and check they are unique
- ids := make(map[string]bool)
- for i := 0; i < 100; i++ {
- id := ch.generateStreamID()
-
- if len(id) != 10 {
- t.Errorf("Expected stream ID length 10, got %d", len(id))
- }
-
- if ids[id] {
- t.Errorf("Duplicate stream ID generated: %s", id)
- }
- ids[id] = true
- }
-}
-
-func TestEncryptDecrypt(t *testing.T) {
- // Use a valid 43-character base64 key (企业微信标准格式)
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters
- }
-
- messageBus := bus.NewMessageBus()
- ch, _ := NewWeComAIBotChannel(cfg, messageBus)
-
- plaintext := "Hello, World!"
- receiveid := ""
-
- // Encrypt
- encrypted, err := ch.encryptMessage(plaintext, receiveid)
- if err != nil {
- t.Fatalf("Failed to encrypt message: %v", err)
- }
-
- if encrypted == "" {
- t.Fatal("Encrypted message is empty")
- }
-
- // Decrypt
- decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid)
- if err != nil {
- t.Fatalf("Failed to decrypt message: %v", err)
- }
-
- if decrypted != plaintext {
- t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted)
- }
-}
-
-func TestGenerateSignature(t *testing.T) {
- token := "test_token"
- timestamp := "1234567890"
- nonce := "test_nonce"
- encrypt := "encrypted_msg"
-
- signature := computeSignature(token, timestamp, nonce, encrypt)
-
- if signature == "" {
- t.Error("Generated signature is empty")
- }
-
- // Verify signature using verifySignature function
- if !verifySignature(token, signature, timestamp, nonce, encrypt) {
- t.Error("Generated signature does not verify correctly")
- }
-}
diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go
deleted file mode 100644
index 717815b9f..000000000
--- a/pkg/channels/wecom/app.go
+++ /dev/null
@@ -1,738 +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"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-const (
- wecomAPIBase = "https://qyapi.weixin.qq.com"
-)
-
-// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)
-type WeComAppChannel struct {
- *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, _ := io.ReadAll(resp.Body)
- 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, _ := io.ReadAll(resp.Body)
- 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 7f230494f..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"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/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 skips verification", 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 skip verification and return true")
- }
- })
-}
-
-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 9126a847d..000000000
--- a/pkg/channels/wecom/bot.go
+++ /dev/null
@@ -1,490 +0,0 @@
-package wecom
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "encoding/xml"
- "fmt"
- "io"
- "net/http"
- "strings"
- "time"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/identity"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人)
-// Uses webhook callback mode - simpler than WeCom App but only supports passive replies
-type WeComBotChannel struct {
- *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, _ := io.ReadAll(resp.Body)
- 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 c053578b1..000000000
--- a/pkg/channels/wecom/bot_test.go
+++ /dev/null
@@ -1,751 +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"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-// generateTestAESKey generates a valid test AES key
-func generateTestAESKey() string {
- // AES key needs to be 32 bytes (256 bits) for AES-256
- key := make([]byte, 32)
- for i := range key {
- key[i] = byte(i)
- }
- // Return base64 encoded key without padding
- return base64.StdEncoding.EncodeToString(key)[:43]
-}
-
-// encryptTestMessage encrypts a message for testing (AIBOT JSON format)
-func encryptTestMessage(message, aesKey string) (string, error) {
- // Decode AES key
- key, err := base64.StdEncoding.DecodeString(aesKey + "=")
- if err != nil {
- return "", err
- }
-
- // Prepare message: random(16) + msg_len(4) + msg + receiveid
- random := make([]byte, 0, 16)
- for i := 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 skips verification", func(t *testing.T) {
- // Create a channel manually with empty token to test the behavior
- cfgEmpty := config.WeComConfig{
- Token: "",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
- chEmpty := &WeComBotChannel{
- config: cfgEmpty,
- }
-
- if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
- t.Error("empty token should skip verification and return true")
- }
- })
-}
-
-func TestWeComBotDecryptMessage(t *testing.T) {
- msgBus := bus.NewMessageBus()
-
- t.Run("decrypt without AES key", func(t *testing.T) {
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- EncodingAESKey: "",
- }
- ch, _ := NewWeComBotChannel(cfg, msgBus)
-
- // Without AES key, message should be base64 decoded only
- plainText := "hello world"
- encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
-
- result, err := 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 6510e6f81..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 true // Skip verification if token is not set
- }
- 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 bc5a70fa3..000000000
--- a/pkg/channels/wecom/init.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package wecom
-
-import (
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/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 f40e05e1c..f1b1dfc65 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -204,20 +204,7 @@ func (d *AgentDefaults) GetModelName() string {
}
type ChannelsConfig struct {
- WhatsApp WhatsAppConfig `json:"whatsapp"`
- Telegram TelegramConfig `json:"telegram"`
- Feishu FeishuConfig `json:"feishu"`
- Discord DiscordConfig `json:"discord"`
- MaixCam MaixCamConfig `json:"maixcam"`
- QQ QQConfig `json:"qq"`
- DingTalk DingTalkConfig `json:"dingtalk"`
- Slack SlackConfig `json:"slack"`
- 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"`
+ WhatsApp WhatsAppConfig `json:"whatsapp"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
@@ -246,161 +233,6 @@ type WhatsAppConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
}
-type TelegramConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
- BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
- Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Typing TypingConfig `json:"typing,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
-}
-
-type FeishuConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
- EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
- VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
-}
-
-type DiscordConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
- Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
- MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Typing TypingConfig `json:"typing,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
-}
-
-type MaixCamConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
- Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
- Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
-}
-
-type QQConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
-}
-
-type DingTalkConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
- ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
- ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
-}
-
-type SlackConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
- BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
- AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Typing TypingConfig `json:"typing,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
-}
-
-type LINEConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
- ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
- ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Typing TypingConfig `json:"typing,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
-}
-
-type OneBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
- WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
- ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
- GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Typing TypingConfig `json:"typing,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- 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"`
- AllowTokenQuery bool `json:"allow_token_query,omitempty"`
- AllowOrigins []string `json:"allow_origins,omitempty"`
- PingInterval int `json:"ping_interval,omitempty"`
- ReadTimeout int `json:"read_timeout,omitempty"`
- WriteTimeout int `json:"write_timeout,omitempty"`
- MaxConnections int `json:"max_connections,omitempty"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
-}
-
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
@@ -701,16 +533,7 @@ func LoadConfig(path string) (*Config, error) {
}
func (c *Config) migrateChannelConfigs() {
- // Discord: mention_only -> group_trigger.mention_only
- if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
- c.Channels.Discord.GroupTrigger.MentionOnly = true
- }
-
- // OneBot: group_trigger_prefix -> group_trigger.prefixes
- if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
- len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
- c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
- }
+ // No channels remaining that need migration
}
func SaveConfig(path string, cfg *Config) error {
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 10ebc7c90..2dfbe3359 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -273,15 +273,8 @@ func TestDefaultConfig_Providers(t *testing.T) {
// TestDefaultConfig_Channels verifies channels are disabled by default
func TestDefaultConfig_Channels(t *testing.T) {
cfg := DefaultConfig()
-
- if cfg.Channels.Telegram.Enabled {
- t.Error("Telegram should be disabled by default")
- }
- if cfg.Channels.Discord.Enabled {
- t.Error("Discord should be disabled by default")
- }
- if cfg.Channels.Slack.Enabled {
- t.Error("Slack should be disabled by default")
+ if cfg.Channels.WhatsApp.Enabled {
+ t.Error("WhatsApp should be disabled by default")
}
}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 6f65dd469..2105fcea2 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -49,115 +49,6 @@ func DefaultConfig() *Config {
SessionStorePath: "",
AllowFrom: FlexibleStringSlice{},
},
- Telegram: TelegramConfig{
- Enabled: false,
- Token: "",
- AllowFrom: FlexibleStringSlice{},
- Typing: TypingConfig{Enabled: true},
- Placeholder: PlaceholderConfig{
- Enabled: true,
- Text: "Thinking... 💭",
- },
- },
- Feishu: FeishuConfig{
- Enabled: false,
- AppID: "",
- AppSecret: "",
- EncryptKey: "",
- VerificationToken: "",
- AllowFrom: FlexibleStringSlice{},
- },
- Discord: DiscordConfig{
- Enabled: false,
- Token: "",
- AllowFrom: FlexibleStringSlice{},
- MentionOnly: false,
- },
- MaixCam: MaixCamConfig{
- Enabled: false,
- Host: "0.0.0.0",
- Port: 18790,
- AllowFrom: FlexibleStringSlice{},
- },
- QQ: QQConfig{
- Enabled: false,
- AppID: "",
- AppSecret: "",
- AllowFrom: FlexibleStringSlice{},
- },
- DingTalk: DingTalkConfig{
- Enabled: false,
- ClientID: "",
- ClientSecret: "",
- AllowFrom: FlexibleStringSlice{},
- },
- Slack: SlackConfig{
- Enabled: false,
- BotToken: "",
- AppToken: "",
- AllowFrom: FlexibleStringSlice{},
- },
- LINE: LINEConfig{
- Enabled: false,
- ChannelSecret: "",
- ChannelAccessToken: "",
- WebhookHost: "0.0.0.0",
- WebhookPort: 18791,
- WebhookPath: "/webhook/line",
- AllowFrom: FlexibleStringSlice{},
- GroupTrigger: GroupTriggerConfig{MentionOnly: true},
- },
- OneBot: OneBotConfig{
- Enabled: false,
- WSUrl: "ws://127.0.0.1:3001",
- AccessToken: "",
- ReconnectInterval: 5,
- 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: "",
- PingInterval: 30,
- ReadTimeout: 60,
- WriteTimeout: 10,
- MaxConnections: 100,
- AllowFrom: FlexibleStringSlice{},
- },
},
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{WebSearch: true},
diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go
index 39ad48fad..281a79dae 100644
--- a/pkg/migrate/sources/openclaw/openclaw_config.go
+++ b/pkg/migrate/sources/openclaw/openclaw_config.go
@@ -983,51 +983,6 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
Enabled: c.WhatsApp.Enabled,
BridgeURL: c.WhatsApp.BridgeURL,
},
- Telegram: config.TelegramConfig{
- Enabled: c.Telegram.Enabled,
- Token: c.Telegram.Token,
- Proxy: c.Telegram.Proxy,
- },
- Feishu: config.FeishuConfig{
- Enabled: c.Feishu.Enabled,
- AppID: c.Feishu.AppID,
- AppSecret: c.Feishu.AppSecret,
- EncryptKey: c.Feishu.EncryptKey,
- VerificationToken: c.Feishu.VerificationToken,
- },
- Discord: config.DiscordConfig{
- Enabled: c.Discord.Enabled,
- Token: c.Discord.Token,
- MentionOnly: c.Discord.MentionOnly,
- },
- MaixCam: config.MaixCamConfig{
- Enabled: c.MaixCam.Enabled,
- Host: c.MaixCam.Host,
- Port: c.MaixCam.Port,
- },
- QQ: config.QQConfig{
- Enabled: c.QQ.Enabled,
- AppID: c.QQ.AppID,
- AppSecret: c.QQ.AppSecret,
- },
- DingTalk: config.DingTalkConfig{
- Enabled: c.DingTalk.Enabled,
- ClientID: c.DingTalk.ClientID,
- ClientSecret: c.DingTalk.ClientSecret,
- },
- Slack: config.SlackConfig{
- Enabled: c.Slack.Enabled,
- BotToken: c.Slack.BotToken,
- AppToken: c.Slack.AppToken,
- },
- LINE: config.LINEConfig{
- Enabled: c.LINE.Enabled,
- ChannelSecret: c.LINE.ChannelSecret,
- ChannelAccessToken: c.LINE.ChannelAccessToken,
- WebhookHost: c.LINE.WebhookHost,
- WebhookPort: c.LINE.WebhookPort,
- WebhookPath: c.LINE.WebhookPath,
- },
}
}
diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go
index 7d884522c..872af1127 100644
--- a/pkg/migrate/sources/openclaw/openclaw_config_test.go
+++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go
@@ -258,21 +258,10 @@ func TestConvertToPicoClaw(t *testing.T) {
t.Errorf("expected 2 skills for assistant agent")
}
- if !picoCfg.Channels.Telegram.Enabled {
- t.Error("telegram should be enabled")
- }
- if picoCfg.Channels.Telegram.Token != "test-token" {
- t.Errorf("expected telegram token 'test-token', got '%s'", picoCfg.Channels.Telegram.Token)
- }
-
if picoCfg.Channels.WhatsApp.BridgeURL != "http://localhost:3000" {
t.Errorf("expected whatsapp bridge URL 'http://localhost:3000', got '%s'", picoCfg.Channels.WhatsApp.BridgeURL)
}
- if picoCfg.Channels.Feishu.AppID != "app-id" {
- t.Errorf("expected feishu app ID 'app-id', got '%s'", picoCfg.Channels.Feishu.AppID)
- }
-
if len(picoCfg.ModelList) != 1 {
t.Errorf("expected 1 model config (no models.json provided), got %d", len(picoCfg.ModelList))
}
@@ -600,13 +589,6 @@ func TestToStandardConfig(t *testing.T) {
t.Errorf("expected api key 'sk-ant-test', got '%s'", foundAPIKey)
}
- if !stdCfg.Channels.Telegram.Enabled {
- t.Error("telegram should be enabled")
- }
- if stdCfg.Channels.Telegram.Token != "test-token" {
- t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token)
- }
-
if stdCfg.Gateway.Port != 8080 {
t.Errorf("expected gateway port 8080, got %d", stdCfg.Gateway.Port)
}