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`. - -
## ClawdChat 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. - -
## ClawdChat 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`. - -
## ClawdChat 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`. - -
## ClawdChat 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** | ⭐ 简单 | ## ClawdChat 加入 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 - - <![CDATA[Link Title]]> - - - 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) }