diff --git a/.env.example b/.env.example
index 06d43070c..e0a07236e 100644
--- a/.env.example
+++ b/.env.example
@@ -5,16 +5,17 @@
# ANTHROPIC_API_KEY=sk-ant-xxx
# OPENAI_API_KEY=sk-xxx
# GEMINI_API_KEY=xxx
-# CEREBRAS_API_KEY=xxx
-
+# CLAUDE_CODE_OAUTH=xxx
# ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC...
# DISCORD_BOT_TOKEN=xxx
-# LINE_CHANNEL_SECRET=xxx
-# LINE_CHANNEL_ACCESS_TOKEN=xxx
+# Feishu (飞书)
+# PICOCLAW_CHANNELS_FEISHU_APP_ID=cli_xxx
+# PICOCLAW_CHANNELS_FEISHU_APP_SECRET=xxx
+# PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI=Typing,OneSecond
# ── Web Search (optional) ────────────────
# BRAVE_SEARCH_API_KEY=BSA...
# ── Timezone ──────────────────────────────
-TZ=Asia/Tokyo
+TZ=Asia/Shanghai
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 8d3ca4ff9..a839715c9 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -27,6 +27,25 @@ jobs:
with:
version: v2.10.1
+ vuln_check:
+ name: Security Check
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+
+ - name: Run Govulncheck
+ uses: golang/govulncheck-action@v1
+ with:
+ go-package: ./...
+
test:
name: Tests
runs-on: ubuntu-latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 786c893ef..0edd29f22 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,6 +17,11 @@ on:
required: false
type: boolean
default: false
+ upload_tos:
+ description: "Upload to Volcengine TOS"
+ required: false
+ type: boolean
+ default: true
jobs:
create-tag:
@@ -100,3 +105,12 @@ jobs:
gh release edit "${{ inputs.tag }}" \
--draft=${{ inputs.draft }} \
--prerelease=${{ inputs.prerelease }}
+
+ upload-tos:
+ name: Upload to TOS
+ needs: release
+ if: ${{ inputs.upload_tos }}
+ uses: ./.github/workflows/upload-tos.yml
+ with:
+ tag: ${{ inputs.tag }}
+ secrets: inherit
diff --git a/.github/workflows/upload-tos.yml b/.github/workflows/upload-tos.yml
new file mode 100644
index 000000000..6d3916d53
--- /dev/null
+++ b/.github/workflows/upload-tos.yml
@@ -0,0 +1,49 @@
+name: Upload to Volcengine TOS
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Release tag to download and upload (e.g. v0.2.0)"
+ required: true
+ type: string
+ workflow_call:
+ inputs:
+ tag:
+ description: "Release tag to download and upload"
+ required: true
+ type: string
+
+jobs:
+ upload-tos:
+ name: Upload to Volcengine TOS
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download release assets
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ mkdir -p artifacts
+ gh release download "${{ inputs.tag }}" \
+ --repo "${{ github.repository }}" \
+ --dir artifacts \
+ --pattern "*.tar.gz" \
+ --pattern "*.zip" \
+ --pattern "*.rpm" \
+ --pattern "*.deb"
+
+ - name: Upload to Volcengine TOS
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.VOLC_TOS_ACCESS_KEY }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.VOLC_TOS_SECRET_KEY }}
+ AWS_DEFAULT_REGION: cn-beijing
+ run: |
+ aws configure set default.s3.addressing_style virtual
+ TOS_ENDPOINT="https://tos-s3-cn-beijing.volces.com"
+ # Upload to versioned directory
+ aws s3 sync artifacts/ "s3://picoclaw-downloads/${{ inputs.tag }}/" \
+ --endpoint-url "$TOS_ENDPOINT"
+ # Upload to latest (overwrite)
+ aws s3 sync artifacts/ "s3://picoclaw-downloads/latest/" \
+ --endpoint-url "$TOS_ENDPOINT" \
+ --delete
diff --git a/.gitignore b/.gitignore
index ac52098f0..d9fe0d5a9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,9 @@ ralph/
.ralph/
tasks/
+# Plans
+docs/plans/
+
# Editors
.vscode/
.idea/
diff --git a/.golangci.yaml b/.golangci.yaml
index d0ba90716..ea3107ec8 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -7,7 +7,6 @@ linters:
- containedctx
- cyclop
- depguard
- - dupl
- dupword
- err113
- exhaustruct
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 88227f493..ceff723d2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -269,8 +269,8 @@ Once your PR is submitted, you can reach out to the assigned reviewers listed in
|Function| Reviewer|
|--- |--- |
|Provider|@yinwm |
-|Channel |@yinwm |
-|Agent |@lxowalle|
+|Channel |@yinwm/@alexhoshina |
+|Agent |@lxowalle/@Zhaoyikaiii|
|Tools |@lxowalle|
|SKill ||
|MCP ||
diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md
index 01a1abfd5..196aecc65 100644
--- a/CONTRIBUTING.zh.md
+++ b/CONTRIBUTING.zh.md
@@ -268,8 +268,8 @@ Release 分支的保护级别高于 `main`,在任何情况下均不允许直
|Function| Reviewer|
|--- |--- |
|Provider|@yinwm |
-|Channel |@yinwm |
-|Agent |@lxowalle|
+|Channel |@yinwm/@alexhoshina |
+|Agent |@lxowalle/@Zhaoyikaiii|
|Tools |@lxowalle|
|SKill ||
|MCP ||
diff --git a/LICENSE b/LICENSE
index 410acae26..b38d9340d 100644
--- a/LICENSE
+++ b/LICENSE
@@ -19,7 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-
----
-
-PicoClaw is heavily inspired by and based on [nanobot](https://github.com/HKUDS/nanobot) by HKUDS.
diff --git a/Makefile b/Makefile
index b67c1c8c8..428e386c5 100644
--- a/Makefile
+++ b/Makefile
@@ -18,6 +18,28 @@ LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(G
GO?=CGO_ENABLED=0 go
GOFLAGS?=-v -tags stdjson
+# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
+#
+# Bytes (octal): \004 \024 \000 \160 → little-endian 0x70001404
+# 0x70000000 EF_MIPS_ARCH_32R2 MIPS32 Release 2
+# 0x00001000 EF_MIPS_ABI_O32 O32 ABI
+# 0x00000400 EF_MIPS_NAN2008 IEEE 754-2008 NaN encoding
+# 0x00000004 EF_MIPS_CPIC PIC calling sequence
+#
+# Go's GOMIPS=softfloat emits no FP instructions, so the NaN mode is irrelevant
+# at runtime — this is purely an ELF metadata fix to satisfy the kernel's check.
+# patchelf cannot modify e_flags; dd at a fixed offset is the most portable way.
+#
+# Ref: https://codebrowser.dev/linux/linux/arch/mips/include/asm/elf.h.html
+define PATCH_MIPS_FLAGS
+ @if [ -f "$(1)" ]; then \
+ printf '\004\024\000\160' | dd of=$(1) bs=1 seek=36 count=4 conv=notrunc 2>/dev/null || \
+ { echo "Error: failed to patch MIPS e_flags for $(1)"; exit 1; }; \
+ else \
+ echo "Error: $(1) not found, cannot patch MIPS e_flags"; exit 1; \
+ fi
+endef
+
# Golangci-lint
GOLANGCI_LINT?=golangci-lint
@@ -50,6 +72,8 @@ ifeq ($(UNAME_S),Linux)
ARCH=loong64
else ifeq ($(UNAME_M),riscv64)
ARCH=riscv64
+ else ifeq ($(UNAME_M),mipsel)
+ ARCH=mipsle
else
ARCH=$(UNAME_M)
endif
@@ -103,6 +127,8 @@ build-whatsapp-native: generate
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
+ GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
+ $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
@@ -123,6 +149,14 @@ build-linux-arm64: generate
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
+## build-linux-mipsle: Build for Linux MIPS32 LE
+build-linux-mipsle: generate
+ @echo "Building for linux/mipsle (softfloat)..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
+ $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
+ @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
+
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
build-pi-zero: build-linux-arm build-linux-arm64
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
@@ -136,6 +170,8 @@ build-all: generate
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
+ GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
+ $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
@@ -174,11 +210,11 @@ clean:
@echo "Clean complete"
## vet: Run go vet for static analysis
-vet:
+vet: generate
@$(GO) vet ./...
## test: Test Go code
-test:
+test: generate
@$(GO) test ./...
## fmt: Format Go code
@@ -210,6 +246,44 @@ check: deps fmt vet test
run: build
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
+## docker-build: Build Docker image (minimal Alpine-based)
+docker-build:
+ @echo "Building minimal Docker image (Alpine-based)..."
+ docker compose -f docker/docker-compose.yml build picoclaw-agent picoclaw-gateway
+
+## docker-build-full: Build Docker image with full MCP support (Node.js 24)
+docker-build-full:
+ @echo "Building full-featured Docker image (Node.js 24)..."
+ docker compose -f docker/docker-compose.full.yml build picoclaw-agent picoclaw-gateway
+
+## docker-test: Test MCP tools in Docker container
+docker-test:
+ @echo "Testing MCP tools in Docker..."
+ @chmod +x scripts/test-docker-mcp.sh
+ @./scripts/test-docker-mcp.sh
+
+## docker-run: Run picoclaw gateway in Docker (Alpine-based)
+docker-run:
+ docker compose -f docker/docker-compose.yml --profile gateway up
+
+## docker-run-full: Run picoclaw gateway in Docker (full-featured)
+docker-run-full:
+ docker compose -f docker/docker-compose.full.yml --profile gateway up
+
+## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based)
+docker-run-agent:
+ docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
+
+## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured)
+docker-run-agent-full:
+ docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent
+
+## docker-clean: Clean Docker images and volumes
+docker-clean:
+ docker compose -f docker/docker-compose.yml down -v
+ docker compose -f docker/docker-compose.full.yml down -v
+ docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
+
## help: Show this help message
help:
@echo "picoclaw Makefile"
@@ -225,6 +299,8 @@ help:
@echo " make install # Install to ~/.local/bin"
@echo " make uninstall # Remove from /usr/local/bin"
@echo " make install-skills # Install skills to workspace"
+ @echo " make docker-build # Build minimal Docker image"
+ @echo " make docker-test # Test MCP tools in Docker"
@echo ""
@echo "Environment Variables:"
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
diff --git a/README.fr.md b/README.fr.md
index 7d1ca3e57..08a1926b6 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -7,7 +7,7 @@
-
+
@@ -65,7 +65,7 @@
⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
-🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM et x86. Un clic et c'est parti !
+🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti !
🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
@@ -288,7 +288,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
| **QQ** | Facile (AppID + AppSecret) |
| **DingTalk** | Moyen (identifiants de l'application) |
| **LINE** | Moyen (identifiants + URL de webhook) |
-| **WeCom** | Moyen (CorpID + configuration webhook) |
+| **WeCom AI Bot** | Moyen (Token + clé AES) |
Telegram (Recommandé)
@@ -491,12 +491,13 @@ picoclaw gateway
WeCom (WeChat Work)
-PicoClaw prend en charge deux types d'intégration WeCom :
+PicoClaw prend en charge trois types d'intégration WeCom :
-**Option 1 : WeCom Bot (Robot Intelligent)** - Configuration plus facile, prend en charge les discussions de groupe
-**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive
+**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 App](docs/wecom-app-configuration.md) pour des instructions détaillées.
+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 :**
@@ -563,6 +564,39 @@ picoclaw gateway
> **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production.
+**Configuration Rapide - WeCom AI Bot :**
+
+**1. Créer un AI Bot**
+
+* Accédez à la Console d'Administration WeCom → Gestion des Applications → AI Bot
+* Configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot`
+* Copiez le **Token** et générez l'**EncodingAESKey**
+
+**2. Configurer**
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "allow_from": [],
+ "welcome_message": "Bonjour ! Comment puis-je vous aider ?"
+ }
+ }
+}
+```
+
+**3. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+> **Note** : WeCom AI Bot utilise le protocole pull en streaming — pas de problème de timeout. Les tâches longues (>5,5 min) basculent automatiquement vers la livraison via `response_url`.
+
##
Rejoignez le Réseau Social d'Agents
@@ -575,6 +609,31 @@ Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul mes
Fichier de configuration : `~/.picoclaw/config.json`
+### Variables d'Environnement
+
+Vous pouvez remplacer les chemins par défaut à l'aide de variables d'environnement. Ceci est utile pour les installations portables, les déploiements conteneurisés ou l'exécution de picoclaw en tant que service système. Ces variables sont indépendantes et contrôlent différents chemins.
+
+| Variable | Description | Chemin par Défaut |
+|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
+| `PICOCLAW_CONFIG` | Remplace le chemin du fichier de configuration. Cela indique directement à picoclaw quel `config.json` charger, en ignorant tous les autres emplacements. | `~/.picoclaw/config.json` |
+| `PICOCLAW_HOME` | Remplace le répertoire racine des données picoclaw. Cela modifie l'emplacement par défaut du `workspace` et des autres répertoires de données. | `~/.picoclaw` |
+
+**Exemples :**
+
+```bash
+# Exécuter picoclaw en utilisant un fichier de configuration spécifique
+# Le chemin du workspace sera lu à partir de ce fichier de configuration
+PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
+
+# Exécuter picoclaw avec toutes ses données stockées dans /opt/picoclaw
+# La configuration sera chargée à partir du fichier par défaut ~/.picoclaw/config.json
+# Le workspace sera créé dans /opt/picoclaw/workspace
+PICOCLAW_HOME=/opt/picoclaw picoclaw agent
+
+# Utiliser les deux pour une configuration entièrement personnalisée
+PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
+```
+
### Structure du Workspace
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
@@ -768,7 +827,7 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique
### Fournisseurs
> [!NOTE]
-> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages vocaux Telegram seront automatiquement transcrits.
+> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent.
| Fournisseur | Utilisation | Obtenir une Clé API |
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
diff --git a/README.ja.md b/README.ja.md
index 553e8ab63..c4c5b27a0 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -8,7 +8,7 @@
-
+
@@ -49,7 +49,7 @@
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
-🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go!
+🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go!
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
@@ -257,7 +257,7 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
| **QQ** | 簡単(AppID + AppSecret) |
| **DingTalk** | 普通(アプリ認証情報) |
| **LINE** | 普通(認証情報 + Webhook URL) |
-| **WeCom** | 普通(CorpID + Webhook設定) |
+| **WeCom AI Bot** | 普通(Token + AES キー) |
Telegram(推奨)
@@ -456,12 +456,13 @@ picoclaw gateway
WeCom (企業微信)
-PicoClaw は2種類の WeCom 統合をサポートしています:
+PicoClaw は3種類の WeCom 統合をサポートしています:
-**オプション1: WeCom Bot (智能ロボット)** - 簡単な設定、グループチャット対応
-**オプション2: WeCom App (自作アプリ)** - より多機能、アクティブメッセージング対応
+**オプション1: WeCom Bot (ロボット)** - 簡単な設定、グループチャット対応
+**オプション2: WeCom App (カスタムアプリ)** - より多機能、アクティブメッセージング対応、プライベートチャットのみ
+**オプション3: WeCom AI Bot (スマートボット)** - 公式 AI Bot、ストリーミング返信、グループ・プライベート両対応
-詳細な設定手順は [WeCom App Configuration Guide](docs/wecom-app-configuration.md) を参照してください。
+詳細な設定手順は [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) を参照してください。
**クイックセットアップ - WeCom Bot:**
@@ -530,12 +531,70 @@ 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` によるプッシュ配信に切り替わります。
+
## ⚙️ 設定
設定ファイル: `~/.picoclaw/config.json`
+### 環境変数
+
+環境変数を使用してデフォルトのパスを上書きできます。これは、ポータブルインストール、コンテナ化されたデプロイメント、または picoclaw をシステムサービスとして実行する場合に便利です。これらの変数は独立しており、異なるパスを制御します。
+
+| 変数 | 説明 | デフォルトパス |
+|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
+| `PICOCLAW_CONFIG` | 設定ファイルへのパスを上書きします。これにより、picoclaw は他のすべての場所を無視して、指定された `config.json` をロードします。 | `~/.picoclaw/config.json` |
+| `PICOCLAW_HOME` | picoclaw データのルートディレクトリを上書きします。これにより、`workspace` やその他のデータディレクトリのデフォルトの場所が変更されます。 | `~/.picoclaw` |
+
+**例:**
+
+```bash
+# 特定の設定ファイルを使用して picoclaw を実行する
+# ワークスペースのパスはその設定ファイル内から読み込まれます
+PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
+
+# すべてのデータを /opt/picoclaw に保存して picoclaw を実行する
+# 設定はデフォルトの ~/.picoclaw/config.json からロードされます
+# ワークスペースは /opt/picoclaw/workspace に作成されます
+PICOCLAW_HOME=/opt/picoclaw picoclaw agent
+
+# 両方を使用して完全にカスタマイズされたセットアップを行う
+PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
+```
+
### ワークスペース構成
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
@@ -726,7 +785,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
### プロバイダー
> [!NOTE]
-> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。
+> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、あらゆるチャンネルからの音声メッセージがエージェントレベルで自動的に文字起こしされます。
| プロバイダー | 用途 | API キー取得先 |
| --- | --- | --- |
diff --git a/README.md b/README.md
index 2a253401e..5cf9f6143 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
-
+
@@ -54,7 +54,7 @@
## 📢 News
-2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we can’t wait to have you on board!
+2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we can’t wait to have you on board!
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
@@ -69,7 +69,7 @@
⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core.
-🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, and x86, One-click to Go!
+🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go!
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
@@ -216,7 +216,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
> [!TIP]
> Set your API key in `~/.picoclaw/config.json`.
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
+> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
**1. Initialize**
@@ -265,6 +265,16 @@ picoclaw onboard
"duckduckgo": {
"enabled": true,
"max_results": 5
+ },
+ "perplexity": {
+ "enabled": false,
+ "api_key": "YOUR_PERPLEXITY_API_KEY",
+ "max_results": 5
+ },
+ "searxng": {
+ "enabled": false,
+ "base_url": "http://your-searxng-instance:8888",
+ "max_results": 5
}
}
}
@@ -277,7 +287,12 @@ picoclaw onboard
**3. Get API Keys**
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
-* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
+* **Web Search** (optional):
+ * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
+ * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
+ * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
+ * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
+ * DuckDuckGo - Built-in fallback (no API key required)
> **Note**: See `config.example.json` for a complete configuration template.
@@ -293,7 +308,7 @@ 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
+Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, 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.
@@ -302,10 +317,11 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
| **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
+| **Matrix** | Medium (homeserver + bot access token) |
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
-| **WeCom** | Medium (CorpID + webhook setup) |
+| **WeCom AI Bot** | Medium (Token + AES key) |
Telegram (Recommended)
@@ -338,6 +354,13 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
picoclaw gateway
```
+**4. Telegram command menu (auto-registered at startup)**
+
+PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
+Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
+
+If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
+
@@ -506,6 +529,40 @@ picoclaw gateway
```
+
+Matrix
+
+**1. Prepare bot account**
+
+* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)
+* Create a bot user and obtain its access token
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.org",
+ "user_id": "@your-bot:matrix.org",
+ "access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
+
+
+
LINE
@@ -557,12 +614,13 @@ picoclaw gateway
WeCom (企业微信)
-PicoClaw supports two types of WeCom integration:
+PicoClaw supports three types of WeCom integration:
-**Option 1: WeCom Bot (智能机器人)** - Easier setup, supports group chats
-**Option 2: WeCom App (自建应用)** - More features, proactive messaging
+**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 App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions.
+See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
**Quick Setup - WeCom Bot:**
@@ -631,6 +689,39 @@ picoclaw gateway
> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.
+**Quick Setup - WeCom AI Bot:**
+
+**1. Create an AI Bot**
+
+* Go to WeCom Admin Console → App Management → AI Bot
+* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot`
+* Copy **Token** and click "Random Generate" for **EncodingAESKey**
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "allow_from": [],
+ "welcome_message": "Hello! How can I help you?"
+ }
+ }
+}
+```
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
+
##
Join the Agent Social Network
@@ -643,6 +734,31 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message
Config file: `~/.picoclaw/config.json`
+### Environment Variables
+
+You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths.
+
+| Variable | Description | Default Path |
+|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
+| `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` |
+| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` |
+
+**Examples:**
+
+```bash
+# Run picoclaw using a specific config file
+# The workspace path will be read from within that config file
+PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
+
+# Run picoclaw with all its data stored in /opt/picoclaw
+# Config will be loaded from the default ~/.picoclaw/config.json
+# Workspace will be created at /opt/picoclaw/workspace
+PICOCLAW_HOME=/opt/picoclaw picoclaw agent
+
+# Use both for a fully customized setup
+PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
+```
+
### Workspace Layout
PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):
@@ -662,6 +778,26 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
└── USER.md # User preferences
```
+### Skill Sources
+
+By default, skills are loaded from:
+
+1. `~/.picoclaw/workspace/skills` (workspace)
+2. `~/.picoclaw/skills` (global)
+3. `/skills` (builtin)
+
+For advanced/test setups, you can override the builtin skills root with:
+
+```bash
+export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
+```
+
+### Unified Command Execution Policy
+
+- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
+- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
+- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
+- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
### 🔒 Security Sandbox
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
@@ -838,7 +974,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
### Providers
> [!NOTE]
-> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
+> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
| Provider | Purpose | Get API Key |
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
@@ -851,6 +987,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
+| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
### Model Configuration (model_list)
@@ -866,7 +1003,7 @@ This design also enables **multi-agent support** with flexible provider selectio
#### 📋 All Supported Vendors
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
-| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
+| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
@@ -878,10 +1015,12 @@ This design also enables **multi-agent support** with flexible provider selectio
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
+| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@@ -979,6 +1118,19 @@ This design also enables **multi-agent support** with flexible provider selectio
}
```
+**LiteLLM Proxy**
+
+```json
+{
+ "model_name": "lite-gpt4",
+ "model": "litellm/lite-gpt4",
+ "api_base": "http://localhost:4000/v1",
+ "api_key": "sk-..."
+}
+```
+
+PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
+
#### Load Balancing
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
@@ -1103,6 +1255,10 @@ picoclaw agent -m "Hello"
"model": "anthropic/claude-opus-4-5"
}
},
+ "session": {
+ "dm_scope": "per-channel-peer",
+ "backlog_limit": 20
+ },
"providers": {
"openrouter": {
"api_key": "sk-or-v1-xxx"
@@ -1154,6 +1310,16 @@ picoclaw agent -m "Hello"
"duckduckgo": {
"enabled": true,
"max_results": 5
+ },
+ "perplexity": {
+ "enabled": false,
+ "api_key": "",
+ "max_results": 5
+ },
+ "searxng": {
+ "enabled": false,
+ "base_url": "http://localhost:8888",
+ "max_results": 5
}
},
"cron": {
@@ -1211,10 +1377,69 @@ discord:
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
-To enable web search:
+#### Search Provider Priority
-1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
-2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
+PicoClaw automatically selects the best available search provider in this order:
+1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations
+2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries)
+3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free)
+4. **DuckDuckGo** (if enabled, default fallback) - No API key required (free)
+
+#### Web Search Configuration Options
+
+**Option 1 (Best Results)**: Perplexity AI Search
+```json
+{
+ "tools": {
+ "web": {
+ "perplexity": {
+ "enabled": true,
+ "api_key": "YOUR_PERPLEXITY_API_KEY",
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+**Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month)
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+**Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance
+```json
+{
+ "tools": {
+ "web": {
+ "searxng": {
+ "enabled": true,
+ "base_url": "http://your-server:8888",
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+Benefits of SearXNG:
+- **Zero cost**: No API fees or rate limits
+- **Privacy-focused**: Self-hosted, no tracking
+- **Aggregate results**: Queries 70+ search engines simultaneously
+- **Perfect for cloud VMs**: Solves datacenter IP blocking issues (Oracle Cloud, GCP, AWS, Azure)
+- **No API key needed**: Just deploy and configure the base URL
+
+**Option 4 (No Setup Required)**: DuckDuckGo is enabled by default as fallback (no API key needed)
Add the key to `~/.picoclaw/config.json` if using Brave:
@@ -1230,6 +1455,16 @@ Add the key to `~/.picoclaw/config.json` if using Brave:
"duckduckgo": {
"enabled": true,
"max_results": 5
+ },
+ "perplexity": {
+ "enabled": false,
+ "api_key": "YOUR_PERPLEXITY_API_KEY",
+ "max_results": 5
+ },
+ "searxng": {
+ "enabled": false,
+ "base_url": "http://your-searxng-instance:8888",
+ "max_results": 5
}
}
}
@@ -1248,10 +1483,11 @@ This happens when another instance of the bot is running. Make sure only one `pi
## 📝 API Key Comparison
-| Service | Free Tier | Use Case |
-| ---------------- | ------------------- | ------------------------------------- |
-| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
-| **Zhipu** | 200K tokens/month | Best for Chinese users |
-| **Brave Search** | 2000 queries/month | Web search functionality |
-| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
-| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
+| Service | Free Tier | Use Case |
+| ---------------- | ------------------------ | ------------------------------------- |
+| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
+| **Zhipu** | 200K tokens/month | Best for Chinese users |
+| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
+| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
+| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
+| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
diff --git a/README.pt-br.md b/README.pt-br.md
index 027970b97..5f37ba457 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -7,7 +7,7 @@
-
+
@@ -66,7 +66,7 @@
⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
-🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era!
+🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era!
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
@@ -282,7 +282,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
| **QQ** | Fácil (AppID + AppSecret) |
| **DingTalk** | Médio (credenciais do app) |
| **LINE** | Médio (credenciais + webhook URL) |
-| **WeCom** | Médio (CorpID + configuração webhook) |
+| **WeCom AI Bot** | Médio (Token + chave AES) |
Telegram (Recomendado)
@@ -485,12 +485,13 @@ picoclaw gateway
WeCom (WeChat Work)
-O PicoClaw suporta dois tipos de integração WeCom:
+O PicoClaw suporta três tipos de integração WeCom:
-**Opção 1: WeCom Bot (Robô Inteligente)** - Configuração mais fácil, suporta chats em grupo
-**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas
+**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 App](docs/wecom-app-configuration.md) para instruções detalhadas.
+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:**
@@ -559,6 +560,39 @@ picoclaw gateway
> **Nota**: O WeCom App (callbacks de webhook) é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Em produção use um proxy reverso HTTPS para expor a porta do Gateway, ou atualize `PICOCLAW_GATEWAY_HOST` para `0.0.0.0` se necessário.
+**Configuração Rápida - WeCom AI Bot:**
+
+**1. Criar um AI Bot**
+
+* Acesse o Console de Administração WeCom → Gerenciamento de Aplicativos → AI Bot
+* Configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot`
+* Copie o **Token** e gere o **EncodingAESKey**
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "allow_from": [],
+ "welcome_message": "Olá! Como posso ajudá-lo?"
+ }
+ }
+}
+```
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+> **Nota**: O WeCom AI Bot usa protocolo de pull em streaming — sem preocupações com timeout de resposta. Tarefas longas (>5,5 min) alternam automaticamente para entrega via `response_url`.
+
##
Junte-se a Rede Social de Agentes
@@ -571,6 +605,31 @@ Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única men
Arquivo de configuração: `~/.picoclaw/config.json`
+### Variáveis de Ambiente
+
+Você pode substituir os caminhos padrão usando variáveis de ambiente. Isso é útil para instalações portáteis, implantações em contêineres ou para executar o picoclaw como um serviço do sistema. Essas variáveis são independentes e controlam caminhos diferentes.
+
+| Variável | Descrição | Caminho Padrão |
+|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
+| `PICOCLAW_CONFIG` | Substitui o caminho para o arquivo de configuração. Isso informa diretamente ao picoclaw qual `config.json` carregar, ignorando todos os outros locais. | `~/.picoclaw/config.json` |
+| `PICOCLAW_HOME` | Substitui o diretório raiz dos dados do picoclaw. Isso altera o local padrão do `workspace` e de outros diretórios de dados. | `~/.picoclaw` |
+
+**Exemplos:**
+
+```bash
+# Executar o picoclaw usando um arquivo de configuração específico
+# O caminho do workspace será lido de dentro desse arquivo de configuração
+PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
+
+# Executar o picoclaw com todos os seus dados armazenados em /opt/picoclaw
+# A configuração será carregada do ~/.picoclaw/config.json padrão
+# O workspace será criado em /opt/picoclaw/workspace
+PICOCLAW_HOME=/opt/picoclaw picoclaw agent
+
+# Use ambos para uma configuração totalmente personalizada
+PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
+```
+
### Estrutura do Workspace
O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`):
@@ -764,7 +823,7 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
### Provedores
> [!NOTE]
-> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas.
+> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente.
| Provedor | Finalidade | Obter API Key |
| --- | --- | --- |
diff --git a/README.vi.md b/README.vi.md
index bfbacb0f4..92c6ecbae 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -7,7 +7,7 @@
-
+
@@ -65,7 +65,7 @@
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
-🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy!
+🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy!
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
@@ -256,7 +256,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
| **QQ** | Dễ (AppID + AppSecret) |
| **DingTalk** | Trung bình (app credentials) |
| **LINE** | Trung bình (credentials + webhook URL) |
-| **WeCom** | Trung bình (CorpID + cấu hình webhook) |
+| **WeCom AI Bot** | Trung bình (Token + khóa AES) |
Telegram (Khuyên dùng)
@@ -457,12 +457,13 @@ picoclaw gateway
WeCom (WeChat Work)
-PicoClaw hỗ trợ hai loại tích hợp WeCom:
+PicoClaw hỗ trợ ba loại tích hợp WeCom:
-**Tùy chọn 1: WeCom Bot (Robot Thông minh)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm
-**Tùy chọn 2: WeCom App (Ứng dụng Tự xây dựng)** - Nhiều tính năng hơn, nhắn tin chủ động
+**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 App](docs/wecom-app-configuration.md) để biết hướng dẫn chi tiế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:**
@@ -531,6 +532,39 @@ picoclaw gateway
> **Lưu ý**: WeCom App callback webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). Sử dụng proxy ngược để cung cấp HTTPS trong môi trường production nếu cần.
+**Thiết lập Nhanh - WeCom AI Bot:**
+
+**1. Tạo AI Bot**
+
+* Truy cập Bảng điều khiển Quản trị WeCom → Quản lý Ứng dụng → AI Bot
+* Cấu hình URL callback: `http://your-server:18791/webhook/wecom-aibot`
+* Sao chép **Token** và tạo **EncodingAESKey**
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "allow_from": [],
+ "welcome_message": "Xin chào! Tôi có thể giúp gì cho bạn?"
+ }
+ }
+}
+```
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+> **Lưu ý**: WeCom AI Bot sử dụng giao thức pull streaming — không lo timeout phản hồi. Tác vụ dài (>5,5 phút) tự động chuyển sang gửi qua `response_url`.
+
##
Tham gia Mạng xã hội Agent
@@ -543,6 +577,31 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một
File cấu hình: `~/.picoclaw/config.json`
+### Biến môi trường
+
+Bạn có thể ghi đè các đường dẫn mặc định bằng cách sử dụng các biến môi trường. Điều này hữu ích cho việc cài đặt di động, triển khai container hóa hoặc chạy picoclaw như một dịch vụ hệ thống. Các biến này độc lập và kiểm soát các đường dẫn khác nhau.
+
+| Biến | Mô tả | Đường dẫn mặc định |
+|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
+| `PICOCLAW_CONFIG` | Ghi đè đường dẫn đến file cấu hình. Điều này trực tiếp yêu cầu picoclaw tải file `config.json` nào, bỏ qua tất cả các vị trí khác. | `~/.picoclaw/config.json` |
+| `PICOCLAW_HOME` | Ghi đè thư mục gốc cho dữ liệu picoclaw. Điều này thay đổi vị trí mặc định của `workspace` và các thư mục dữ liệu khác. | `~/.picoclaw` |
+
+**Ví dụ:**
+
+```bash
+# Chạy picoclaw bằng một file cấu hình cụ thể
+# Đường dẫn workspace sẽ được đọc từ trong file cấu hình đó
+PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
+
+# Chạy picoclaw với tất cả dữ liệu được lưu trữ trong /opt/picoclaw
+# Cấu hình sẽ được tải từ ~/.picoclaw/config.json mặc định
+# Workspace sẽ được tạo tại /opt/picoclaw/workspace
+PICOCLAW_HOME=/opt/picoclaw picoclaw agent
+
+# Sử dụng cả hai để có thiết lập tùy chỉnh hoàn toàn
+PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
+```
+
### Cấu trúc Workspace
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
@@ -736,7 +795,7 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
### Nhà cung cấp (Providers)
> [!NOTE]
-> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản.
+> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển thành văn bản ở cấp độ agent.
| Nhà cung cấp | Mục đích | Lấy API Key |
| --- | --- | --- |
diff --git a/README.zh.md b/README.zh.md
index 1d8db583e..c744e0d20 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -7,7 +7,7 @@
-
+
@@ -67,7 +67,7 @@
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
-🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行!
+🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行!
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
@@ -299,14 +299,22 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **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) |
+| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) |
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
-| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/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) |
+### Telegram 命令注册(启动时自动同步)
+
+PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
+Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
+
+如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
+
##
加入 Agent 社交网络
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
@@ -317,6 +325,31 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
配置文件路径: `~/.picoclaw/config.json`
+### 环境变量
+
+你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。
+
+| 变量 | 描述 | 默认路径 |
+|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
+| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` |
+| `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` |
+
+**示例:**
+
+```bash
+# 使用特定的配置文件运行 picoclaw
+# 工作区路径将从该配置文件中读取
+PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
+
+# 在 /opt/picoclaw 中存储所有数据运行 picoclaw
+# 配置将从默认的 ~/.picoclaw/config.json 加载
+# 工作区将在 /opt/picoclaw/workspace 创建
+PICOCLAW_HOME=/opt/picoclaw picoclaw agent
+
+# 同时使用两者进行完全自定义设置
+PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
+```
+
### 工作区布局 (Workspace Layout)
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
@@ -337,6 +370,26 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
```
+### 技能来源 (Skill Sources)
+
+默认情况下,技能会按以下顺序加载:
+
+1. `~/.picoclaw/workspace/skills`(工作区)
+2. `~/.picoclaw/skills`(全局)
+3. `/skills`(内置)
+
+在高级/测试场景下,可通过以下环境变量覆盖内置技能目录:
+
+```bash
+export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
+```
+
+### 统一命令执行策略
+
+- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
+- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。
+- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
+- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
### 心跳 / 周期性任务 (Heartbeat)
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
@@ -420,7 +473,7 @@ Agent 读取 HEARTBEAT.md
### 提供商 (Providers)
> [!NOTE]
-> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
+> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。
| 提供商 | 用途 | 获取 API Key |
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
@@ -676,6 +729,10 @@ picoclaw agent -m "你好"
"model": "anthropic/claude-opus-4-5"
}
},
+ "session": {
+ "dm_scope": "per-channel-peer",
+ "backlog_limit": 20
+ },
"providers": {
"openrouter": {
"api_key": "sk-or-v1-xxx"
diff --git a/assets/wechat.png b/assets/wechat.png
index 1c0b88295..cc88186a8 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go
index 4947d6aea..8628afab3 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/app.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/app.go
@@ -423,7 +423,7 @@ func (s *appState) hasEnabledChannel() bool {
c := s.config.Channels
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
- c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
+ c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.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 ad9171424..16b7d053b 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/channel.go
@@ -10,8 +10,8 @@ import (
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
-func (s *appState) channelMenu() tview.Primitive {
- items := []MenuItem{
+func (s *appState) buildChannelMenuItems() []MenuItem {
+ return []MenuItem{
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
channelItem(
"Telegram",
@@ -61,6 +61,12 @@ func (s *appState) channelMenu() tview.Primitive {
s.config.Channels.Slack.Enabled,
func() { s.push("channel-slack", s.slackForm()) },
),
+ channelItem(
+ "Matrix",
+ "Matrix bot settings",
+ s.config.Channels.Matrix.Enabled,
+ func() { s.push("channel-matrix", s.matrixForm()) },
+ ),
channelItem(
"LINE",
"LINE bot settings",
@@ -86,8 +92,10 @@ func (s *appState) channelMenu() tview.Primitive {
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
),
}
+}
- menu := NewMenu("Channels", items)
+func (s *appState) channelMenu() tview.Primitive {
+ menu := NewMenu("Channels", s.buildChannelMenuItems())
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
@@ -103,199 +111,72 @@ func (s *appState) channelMenu() tview.Primitive {
}
func refreshChannelMenuFromState(menu *Menu, s *appState) {
- items := []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()) },
- ),
- }
- menu.applyItems(items)
+ menu.applyItems(s.buildChannelMenuItems())
}
func (s *appState) telegramForm() tview.Primitive {
cfg := &s.config.Channels.Telegram
- form := baseChannelForm("Telegram", cfg.Enabled, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ 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)
})
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = splitCSV(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, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ 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
})
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = splitCSV(text)
- })
+ addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) qqForm() tview.Primitive {
cfg := &s.config.Channels.QQ
- form := baseChannelForm("QQ", cfg.Enabled, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ 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)
})
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = splitCSV(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, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ 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 })
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = splitCSV(text)
- })
+ addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) whatsappForm() tview.Primitive {
cfg := &s.config.Channels.WhatsApp
- form := baseChannelForm("WhatsApp", cfg.Enabled, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
cfg.BridgeURL = strings.TrimSpace(text)
})
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = splitCSV(text)
- })
+ addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) feishuForm() tview.Primitive {
cfg := &s.config.Channels.Feishu
- form := baseChannelForm("Feishu", cfg.Enabled, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ 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)
})
@@ -308,66 +189,39 @@ func (s *appState) feishuForm() tview.Primitive {
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
cfg.VerificationToken = strings.TrimSpace(text)
})
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = splitCSV(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, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ 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)
})
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = splitCSV(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, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ 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)
})
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = splitCSV(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, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ 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)
})
@@ -381,22 +235,35 @@ func (s *appState) lineForm() tview.Primitive {
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = splitCSV(text)
+ addAllowFromField(form, &cfg.AllowFrom)
+ return wrapWithBack(form, s)
+}
+
+func (s *appState) matrixForm() tview.Primitive {
+ cfg := &s.config.Channels.Matrix
+ form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
+ form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
+ cfg.Homeserver = strings.TrimSpace(text)
})
+ form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
+ cfg.UserID = strings.TrimSpace(text)
+ })
+ form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
+ cfg.AccessToken = strings.TrimSpace(text)
+ })
+ form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
+ cfg.DeviceID = strings.TrimSpace(text)
+ })
+ form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
+ cfg.JoinOnInvite = checked
+ })
+ addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) onebotForm() tview.Primitive {
cfg := &s.config.Channels.OneBot
- form := baseChannelForm("OneBot", cfg.Enabled, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ 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)
})
@@ -418,22 +285,13 @@ func (s *appState) onebotForm() tview.Primitive {
cfg.GroupTriggerPrefix = splitCSV(text)
},
)
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = 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, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
@@ -450,9 +308,7 @@ func (s *appState) wecomForm() tview.Primitive {
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = splitCSV(text)
- })
+ addAllowFromField(form, &cfg.AllowFrom)
addIntField(
form,
"Reply Timeout",
@@ -464,14 +320,7 @@ func (s *appState) wecomForm() tview.Primitive {
func (s *appState) wecomAppForm() tview.Primitive {
cfg := &s.config.Channels.WeComApp
- form := baseChannelForm("WeCom App", cfg.Enabled, func(v bool) {
- cfg.Enabled = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- })
+ 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)
})
@@ -492,9 +341,7 @@ func (s *appState) wecomAppForm() tview.Primitive {
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
- form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
- cfg.AllowFrom = splitCSV(text)
- })
+ addAllowFromField(form, &cfg.AllowFrom)
addIntField(
form,
"Reply Timeout",
@@ -504,6 +351,23 @@ func (s *appState) wecomAppForm() tview.Primitive {
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)
+ }
+ }
+}
+
+func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {
+ form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) {
+ *allowFrom = splitCSV(text)
+ })
+}
+
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
form := tview.NewForm()
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/model.go b/cmd/picoclaw-launcher-tui/internal/ui/model.go
index ba91f5b09..304b4efa7 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/model.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/model.go
@@ -335,7 +335,11 @@ func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
s.showMessage("Test OK", resp.Status)
return
}
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
+ if err != nil {
+ s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
+ return
+ }
s.showMessage(
"Test failed",
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/style.go b/cmd/picoclaw-launcher-tui/internal/ui/style.go
index ff4f8b1a8..68cdd60b9 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/style.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/style.go
@@ -5,6 +5,19 @@ import (
"github.com/rivo/tview"
)
+const (
+ colorBlue = "[#3e5db9]"
+ colorRed = "[#d54646]"
+ banner = "\r\n[::b]" +
+ colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
+ colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
+ colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
+ colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
+ colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
+ colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
+ "[:]"
+)
+
func applyStyles() {
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
@@ -24,14 +37,7 @@ func bannerView() *tview.TextView {
text.SetDynamicColors(true)
text.SetTextAlign(tview.AlignCenter)
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
- text.SetText(
- "[::b][#84aaff]██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
- "[#84aaff]██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
- "[#84aaff]██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
- "[#84aaff]██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
- "[#84aaff]██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
- "[#84aaff]╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝",
- )
+ text.SetText(banner)
text.SetBorder(false)
return text
}
diff --git a/cmd/picoclaw-launcher/README.md b/cmd/picoclaw-launcher/README.md
index 641279bb1..0872a5f65 100644
--- a/cmd/picoclaw-launcher/README.md
+++ b/cmd/picoclaw-launcher/README.md
@@ -9,7 +9,7 @@ A standalone launcher for PicoClaw, providing visual JSON editing and OAuth prov
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
-- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
+- 📡 **Channel Configuration** — Form-based settings for 13 channel types (Telegram, Discord, Slack, Matrix, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
diff --git a/cmd/picoclaw-launcher/internal/server/auth_handlers.go b/cmd/picoclaw-launcher/internal/server/auth_handlers.go
index 1e9b8be0a..3b48f9739 100644
--- a/cmd/picoclaw-launcher/internal/server/auth_handlers.go
+++ b/cmd/picoclaw-launcher/internal/server/auth_handlers.go
@@ -297,7 +297,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
}
defer resp.Body.Close()
- body, _ := io.ReadAll(resp.Body)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("reading userinfo response: %w", err)
+ }
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("userinfo request failed: %s", string(body))
}
diff --git a/cmd/picoclaw-launcher/internal/ui/index.html b/cmd/picoclaw-launcher/internal/ui/index.html
index 93893fd75..e77ef4fea 100644
--- a/cmd/picoclaw-launcher/internal/ui/index.html
+++ b/cmd/picoclaw-launcher/internal/ui/index.html
@@ -538,6 +538,7 @@
+
@@ -606,6 +607,7 @@
+
@@ -1011,6 +1013,16 @@ const channelSchemas = {
{ key: 'app_token', label: 'App Token', type: 'password', placeholder: 'xapp-...' },
]
},
+ matrix: {
+ title: 'Matrix', configKey: 'matrix', docSlug: null,
+ fields: [
+ { key: 'homeserver', label: 'Homeserver', type: 'text', placeholder: 'https://matrix.org' },
+ { key: 'user_id', label: 'User ID', type: 'text', placeholder: '@bot:matrix.org' },
+ { key: 'access_token', label: 'Access Token', type: 'password', placeholder: 'syt_...' },
+ { key: 'device_id', label: 'Device ID', type: 'text', placeholder: 'Optional device ID' },
+ { key: 'join_on_invite', label: 'Join On Invite', type: 'toggle' },
+ ]
+ },
wecom: {
title: 'WeCom (Bot)', configKey: 'wecom', docSlug: 'wecom-bot',
fields: [
@@ -1392,9 +1404,7 @@ function saveModelFromModal() {
saveConfig().then(renderModels);
}
-document.getElementById('modelModal').addEventListener('click', function(e) {
- if (e.target === this) closeModelModal();
-});
+
// ── Channel Forms ───────────────────────────────────
function renderChannelForm(chKey) {
diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go
index 633ce8740..a0a229167 100644
--- a/cmd/picoclaw/internal/auth/helpers.go
+++ b/cmd/picoclaw/internal/auth/helpers.go
@@ -1,6 +1,7 @@
package auth
import (
+ "bufio"
"encoding/json"
"fmt"
"io"
@@ -15,14 +16,17 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
)
-const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
+const (
+ supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
+ defaultAnthropicModel = "claude-sonnet-4.6"
+)
-func authLoginCmd(provider string, useDeviceCode bool) error {
+func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
switch provider {
case "openai":
return authLoginOpenAI(useDeviceCode)
case "anthropic":
- return authLoginPasteToken(provider)
+ return authLoginAnthropic(useOauth)
case "google-antigravity", "antigravity":
return authLoginGoogleAntigravity()
default:
@@ -163,6 +167,81 @@ func authLoginGoogleAntigravity() error {
return nil
}
+func authLoginAnthropic(useOauth bool) error {
+ if useOauth {
+ return authLoginAnthropicSetupToken()
+ }
+
+ fmt.Println("Anthropic login method:")
+ fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)")
+ fmt.Println(" 2) API key (from console.anthropic.com)")
+
+ scanner := bufio.NewScanner(os.Stdin)
+ for {
+ fmt.Print("Choose [1]: ")
+ choice := "1"
+ if scanner.Scan() {
+ text := strings.TrimSpace(scanner.Text())
+ if text != "" {
+ choice = text
+ }
+ }
+
+ switch choice {
+ case "1":
+ return authLoginAnthropicSetupToken()
+ case "2":
+ return authLoginPasteToken("anthropic")
+ default:
+ fmt.Printf("Invalid choice: %s. Please enter 1 or 2.\n", choice)
+ }
+ }
+}
+
+func authLoginAnthropicSetupToken() error {
+ cred, err := auth.LoginSetupToken(os.Stdin)
+ if err != nil {
+ return fmt.Errorf("login failed: %w", err)
+ }
+
+ if err = auth.SetCredential("anthropic", cred); err != nil {
+ return fmt.Errorf("failed to save credentials: %w", err)
+ }
+
+ appCfg, err := internal.LoadConfig()
+ if err == nil {
+ appCfg.Providers.Anthropic.AuthMethod = "oauth"
+
+ found := false
+ for i := range appCfg.ModelList {
+ if isAnthropicModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = "oauth"
+ found = true
+ break
+ }
+ }
+ if !found {
+ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ ModelName: defaultAnthropicModel,
+ Model: "anthropic/" + defaultAnthropicModel,
+ AuthMethod: "oauth",
+ })
+ // Only set default model if user has no default configured yet
+ if appCfg.Agents.Defaults.GetModelName() == "" {
+ appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
+ }
+ }
+
+ if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
+ return fmt.Errorf("could not update config: %w", err)
+ }
+ }
+
+ fmt.Println("Setup token saved for Anthropic!")
+
+ return nil
+}
+
func fetchGoogleUserEmail(accessToken string) (string, error) {
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
@@ -177,7 +256,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
}
defer resp.Body.Close()
- body, _ := io.ReadAll(resp.Body)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("reading userinfo response: %w", err)
+ }
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("userinfo request failed: %s", string(body))
}
@@ -217,13 +299,12 @@ func authLoginPasteToken(provider string) error {
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
- ModelName: "claude-sonnet-4.6",
- Model: "anthropic/claude-sonnet-4.6",
+ ModelName: defaultAnthropicModel,
+ Model: "anthropic/" + defaultAnthropicModel,
AuthMethod: "token",
})
+ appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
}
- // Update default model
- appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
case "openai":
appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList
@@ -360,6 +441,16 @@ func authStatusCmd() error {
if !cred.ExpiresAt.IsZero() {
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
}
+
+ if provider == "anthropic" && cred.AuthMethod == "oauth" {
+ usage, err := auth.FetchAnthropicUsage(cred.AccessToken)
+ if err != nil {
+ fmt.Printf(" Usage: unavailable (%v)\n", err)
+ } else {
+ fmt.Printf(" Usage (5h): %.1f%%\n", usage.FiveHourUtilization*100)
+ fmt.Printf(" Usage (7d): %.1f%%\n", usage.SevenDayUtilization*100)
+ }
+ }
}
return nil
diff --git a/cmd/picoclaw/internal/auth/login.go b/cmd/picoclaw/internal/auth/login.go
index 9a6d28d2f..afbe098aa 100644
--- a/cmd/picoclaw/internal/auth/login.go
+++ b/cmd/picoclaw/internal/auth/login.go
@@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command {
var (
provider string
useDeviceCode bool
+ useOauth bool
)
cmd := &cobra.Command{
@@ -13,12 +14,16 @@ func newLoginCommand() *cobra.Command {
Short: "Login via OAuth or paste token",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
- return authLoginCmd(provider, useDeviceCode)
+ return authLoginCmd(provider, useDeviceCode, useOauth)
},
}
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
+ cmd.Flags().BoolVar(
+ &useOauth, "setup-token", false,
+ "Use setup-token flow for Anthropic (from `claude setup-token`)",
+ )
_ = cmd.MarkFlagRequired("provider")
return cmd
diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go
index 6efec6a0f..c9fce8a23 100644
--- a/cmd/picoclaw/internal/gateway/helpers.go
+++ b/cmd/picoclaw/internal/gateway/helpers.go
@@ -21,8 +21,10 @@ import (
_ "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/irc"
_ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
+ _ "github.com/sipeed/picoclaw/pkg/channels/matrix"
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
@@ -45,6 +47,7 @@ import (
"github.com/sipeed/picoclaw/pkg/stats"
"github.com/sipeed/picoclaw/pkg/tailscale"
"github.com/sipeed/picoclaw/pkg/tools"
+ "github.com/sipeed/picoclaw/pkg/voice"
)
func gatewayCmd(debug bool, orchestration bool, enableStats bool) error {
@@ -159,6 +162,12 @@ func gatewayCmd(debug bool, orchestration bool, enableStats bool) error {
agentLoop.SetChannelManager(channelManager)
agentLoop.SetMediaStore(mediaStore)
+ // Wire up voice transcription if a supported provider is configured.
+ if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
+ agentLoop.SetTranscriber(transcriber)
+ logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
+ }
+
enabledChannels := channelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
@@ -331,19 +340,25 @@ func setupCronTool(
// Create cron service
cronService := cron.NewCronService(cronStorePath, nil)
- // Create and register CronTool
- cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
- if err != nil {
- log.Fatalf("Critical error during CronTool initialization: %v", err)
+ // Create and register CronTool if enabled
+ var cronTool *tools.CronTool
+ if cfg.Tools.IsToolEnabled("cron") {
+ var err error
+ cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
+ if err != nil {
+ log.Fatalf("Critical error during CronTool initialization: %v", err)
+ }
+
+ agentLoop.RegisterTool(cronTool)
}
- agentLoop.RegisterTool(cronTool)
-
- // Set the onJob handler
- cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
- result := cronTool.ExecuteJob(context.Background(), job)
- return result, nil
- })
+ // Set onJob handler
+ if cronTool != nil {
+ cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
+ result := cronTool.ExecuteJob(context.Background(), job)
+ return result, nil
+ })
+ }
return cronService
}
diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go
index 1f52df5dd..f81d7013d 100644
--- a/cmd/picoclaw/internal/helpers.go
+++ b/cmd/picoclaw/internal/helpers.go
@@ -18,9 +18,21 @@ var (
goVersion string
)
-func GetConfigPath() string {
+// GetPicoclawHome returns the picoclaw home directory.
+// Priority: $PICOCLAW_HOME > ~/.picoclaw
+func GetPicoclawHome() string {
+ if home := os.Getenv("PICOCLAW_HOME"); home != "" {
+ return home
+ }
home, _ := os.UserHomeDir()
- return filepath.Join(home, ".picoclaw", "config.json")
+ return filepath.Join(home, ".picoclaw")
+}
+
+func GetConfigPath() string {
+ if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
+ return configPath
+ }
+ return filepath.Join(GetPicoclawHome(), "config.json")
}
func LoadConfig() (*config.Config, error) {
diff --git a/cmd/picoclaw/internal/helpers_test.go b/cmd/picoclaw/internal/helpers_test.go
index 9342d141d..646be1ba1 100644
--- a/cmd/picoclaw/internal/helpers_test.go
+++ b/cmd/picoclaw/internal/helpers_test.go
@@ -19,6 +19,27 @@ func TestGetConfigPath(t *testing.T) {
assert.Equal(t, want, got)
}
+func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
+ t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
+ t.Setenv("HOME", "/tmp/home")
+
+ got := GetConfigPath()
+ want := filepath.Join("/custom/picoclaw", "config.json")
+
+ assert.Equal(t, want, got)
+}
+
+func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
+ t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
+ t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
+ t.Setenv("HOME", "/tmp/home")
+
+ got := GetConfigPath()
+ want := "/custom/config.json"
+
+ assert.Equal(t, want, got)
+}
+
func TestFormatVersion_NoGitCommit(t *testing.T) {
oldVersion, oldGit := version, gitCommit
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
@@ -95,3 +116,13 @@ func TestGetConfigPath_Windows(t *testing.T) {
func TestGetVersion(t *testing.T) {
assert.Equal(t, "dev", GetVersion())
}
+
+func TestGetConfigPath_WithEnv(t *testing.T) {
+ t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
+ t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred
+
+ got := GetConfigPath()
+ want := "/tmp/custom/config.json"
+
+ assert.Equal(t, want, got)
+}
diff --git a/cmd/picoclaw/internal/skills/install.go b/cmd/picoclaw/internal/skills/install.go
index a30f68632..78bc421db 100644
--- a/cmd/picoclaw/internal/skills/install.go
+++ b/cmd/picoclaw/internal/skills/install.go
@@ -21,8 +21,8 @@ picoclaw skills install --registry clawhub github
`,
Args: func(cmd *cobra.Command, args []string) error {
if registry != "" {
- if len(args) != 2 {
- return fmt.Errorf("when --registry is set, exactly 2 arguments are required: ")
+ if len(args) != 1 {
+ return fmt.Errorf("when --registry is set, exactly 1 argument is required: ")
}
return nil
}
@@ -45,7 +45,7 @@ picoclaw skills install --registry clawhub github
return err
}
- return skillsInstallFromRegistry(cfg, args[0], args[1])
+ return skillsInstallFromRegistry(cfg, registry, args[0])
}
return skillsInstallCmd(installer, args[0])
diff --git a/cmd/picoclaw/internal/skills/install_test.go b/cmd/picoclaw/internal/skills/install_test.go
index 97787a986..6b362822d 100644
--- a/cmd/picoclaw/internal/skills/install_test.go
+++ b/cmd/picoclaw/internal/skills/install_test.go
@@ -26,3 +26,72 @@ func TestNewInstallSubcommand(t *testing.T) {
assert.Len(t, cmd.Aliases, 0)
}
+
+func TestInstallCommandArgs(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ registry string
+ expectError bool
+ errorMsg string
+ }{
+ {
+ name: "no registry, one arg",
+ args: []string{"sipeed/picoclaw-skills/weather"},
+ registry: "",
+ expectError: false,
+ },
+ {
+ name: "no registry, no args",
+ args: []string{},
+ registry: "",
+ expectError: true,
+ errorMsg: "exactly 1 argument is required: ",
+ },
+ {
+ name: "no registry, too many args",
+ args: []string{"arg1", "arg2"},
+ registry: "",
+ expectError: true,
+ errorMsg: "exactly 1 argument is required: ",
+ },
+ {
+ name: "with registry, one arg",
+ args: []string{"weather-skill"},
+ registry: "clawhub",
+ expectError: false,
+ },
+ {
+ name: "with registry, no args",
+ args: []string{},
+ registry: "clawhub",
+ expectError: true,
+ errorMsg: "when --registry is set, exactly 1 argument is required: ",
+ },
+ {
+ name: "with registry, too many args",
+ args: []string{"arg1", "arg2"},
+ registry: "clawhub",
+ expectError: true,
+ errorMsg: "when --registry is set, exactly 1 argument is required: ",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cmd := newInstallCommand(nil)
+
+ if tt.registry != "" {
+ require.NoError(t, cmd.Flags().Set("registry", tt.registry))
+ }
+
+ err := cmd.Args(cmd, tt.args)
+ if tt.expectError {
+ require.Error(t, err)
+ assert.Equal(t, tt.errorMsg, err.Error())
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 6db69c990..d9263462e 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -48,7 +48,21 @@ func NewPicoclawCommand() *cobra.Command {
return cmd
}
+const (
+ colorBlue = "\033[1;38;2;62;93;185m"
+ colorRed = "\033[1;38;2;213;70;70m"
+ banner = "\r\n" +
+ colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
+ colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
+ colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
+ colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
+ colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
+ colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
+ "\033[0m\r\n"
+)
+
func main() {
+ fmt.Printf("%s", banner)
cmd := NewPicoclawCommand()
if err := cmd.Execute(); err != nil {
os.Exit(1)
diff --git a/config/config.example.json b/config/config.example.json
index d885ef94b..0e2cae8e5 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -6,7 +6,9 @@
"model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
- "max_tool_iterations": 20
+ "max_tool_iterations": 20,
+ "summarize_message_threshold": 20,
+ "summarize_token_percent": 75
}
},
"model_list": [
@@ -20,7 +22,8 @@
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "sk-ant-your-key",
- "api_base": "https://api.anthropic.com/v1"
+ "api_base": "https://api.anthropic.com/v1",
+ "thinking_level": "high"
},
{
"model_name": "gemini",
@@ -49,6 +52,7 @@
"telegram": {
"enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN",
+ "base_url": "",
"proxy": "",
"allow_from": [
"YOUR_USER_ID"
@@ -58,6 +62,7 @@
"discord": {
"enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN",
+ "proxy": "",
"allow_from": [],
"group_trigger": {
"mention_only": false
@@ -93,7 +98,8 @@
"encrypt_key": "",
"verification_token": "",
"allow_from": [],
- "reasoning_channel_id": ""
+ "reasoning_channel_id": "",
+ "random_reaction_emoji": []
},
"dingtalk": {
"enabled": false,
@@ -109,6 +115,23 @@
"allow_from": [],
"reasoning_channel_id": ""
},
+ "matrix": {
+ "enabled": false,
+ "homeserver": "https://matrix.org",
+ "user_id": "@your-bot:matrix.org",
+ "access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+ "device_id": "",
+ "join_on_invite": true,
+ "allow_from": [],
+ "group_trigger": {
+ "mention_only": true
+ },
+ "placeholder": {
+ "enabled": true,
+ "text": "Thinking... 💭"
+ },
+ "reasoning_channel_id": ""
+ },
"line": {
"enabled": false,
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
@@ -127,7 +150,7 @@
"reasoning_channel_id": ""
},
"wecom": {
- "_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
+ "_comment": "WeCom Bot - Easier setup, supports group chats",
"enabled": false,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
@@ -138,7 +161,7 @@
"reasoning_channel_id": ""
},
"wecom_app": {
- "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
+ "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.",
"enabled": false,
"corp_id": "YOUR_CORP_ID",
"corp_secret": "YOUR_CORP_SECRET",
@@ -149,6 +172,38 @@
"allow_from": [],
"reply_timeout": 5,
"reasoning_channel_id": ""
+ },
+ "wecom_aibot": {
+ "_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
+ "enabled": false,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "max_steps": 10,
+ "welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
+ "reasoning_channel_id": ""
+ },
+ "irc": {
+ "enabled": false,
+ "server": "irc.libera.chat:6697",
+ "tls": true,
+ "nick": "mybot",
+ "user": "",
+ "real_name": "",
+ "password": "",
+ "nickserv_password": "",
+ "sasl_user": "",
+ "sasl_password": "",
+ "channels": ["#mychannel"],
+ "request_caps": ["server-time", "message-tags"],
+ "allow_from": [],
+ "group_trigger": {
+ "mention_only": true
+ },
+ "typing": {
+ "enabled": false
+ },
+ "reasoning_channel_id": ""
}
},
"providers": {
@@ -210,43 +265,190 @@
"mistral": {
"api_key": "",
"api_base": "https://api.mistral.ai/v1"
+ },
+ "avian": {
+ "api_key": "",
+ "api_base": "https://api.avian.io/v1"
}
},
"tools": {
+ "allow_read_paths": null,
+ "allow_write_paths": null,
"web": {
+ "enabled": true,
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
+ "tavily": {
+ "enabled": false,
+ "api_key": "",
+ "base_url": "",
+ "max_results": 0
+ },
"duckduckgo": {
"enabled": true,
"max_results": 5
},
"perplexity": {
"enabled": false,
- "api_key": "pplx-xxx",
+ "api_key": "",
"max_results": 5
},
- "proxy": ""
+ "searxng": {
+ "enabled": false,
+ "base_url": "http://localhost:8888",
+ "max_results": 5
+ },
+ "glm_search": {
+ "enabled": false,
+ "api_key": "",
+ "base_url": "https://open.bigmodel.cn/api/paas/v4/web_search",
+ "search_engine": "search_std",
+ "max_results": 5
+ },
+ "fetch_limit_bytes": 10485760
},
"cron": {
+ "enabled": true,
"exec_timeout_minutes": 5
},
+ "mcp": {
+ "enabled": false,
+ "servers": {
+ "context7": {
+ "enabled": false,
+ "type": "http",
+ "url": "https://mcp.context7.com/mcp",
+ "headers": {
+ "CONTEXT7_API_KEY": "ctx7sk-xx"
+ }
+ },
+ "filesystem": {
+ "enabled": false,
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-filesystem",
+ "/tmp"
+ ]
+ },
+ "github": {
+ "enabled": false,
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-github"
+ ],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
+ }
+ },
+ "brave-search": {
+ "enabled": false,
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-brave-search"
+ ],
+ "env": {
+ "BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
+ }
+ },
+ "postgres": {
+ "enabled": false,
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-postgres",
+ "postgresql://user:password@localhost/dbname"
+ ]
+ },
+ "slack": {
+ "enabled": false,
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-slack"
+ ],
+ "env": {
+ "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
+ "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
+ }
+ }
+ }
+ },
"exec": {
- "enable_deny_patterns": false,
- "custom_deny_patterns": []
+ "enabled": true,
+ "enable_deny_patterns": true,
+ "custom_deny_patterns": null,
+ "custom_allow_patterns": null
},
"skills": {
+ "enabled": true,
"registries": {
"clawhub": {
"enabled": true,
"base_url": "https://clawhub.ai",
- "search_path": "/api/v1/search",
- "skills_path": "/api/v1/skills",
- "download_path": "/api/v1/download"
+ "auth_token": "",
+ "search_path": "",
+ "skills_path": "",
+ "download_path": "",
+ "timeout": 0,
+ "max_zip_size": 0,
+ "max_response_size": 0
}
+ },
+ "max_concurrent_searches": 2,
+ "search_cache": {
+ "max_size": 50,
+ "ttl_seconds": 300
}
+ },
+ "media_cleanup": {
+ "enabled": true,
+ "max_age_minutes": 30,
+ "interval_minutes": 5
+ },
+ "append_file": {
+ "enabled": true
+ },
+ "edit_file": {
+ "enabled": true
+ },
+ "find_skills": {
+ "enabled": true
+ },
+ "i2c": {
+ "enabled": false
+ },
+ "install_skill": {
+ "enabled": true
+ },
+ "list_dir": {
+ "enabled": true
+ },
+ "message": {
+ "enabled": true
+ },
+ "read_file": {
+ "enabled": true
+ },
+ "spawn": {
+ "enabled": true
+ },
+ "spi": {
+ "enabled": false
+ },
+ "subagent": {
+ "enabled": true
+ },
+ "web_fetch": {
+ "enabled": true
+ },
+ "write_file": {
+ "enabled": true
}
},
"heartbeat": {
diff --git a/docker/Dockerfile.full b/docker/Dockerfile.full
new file mode 100644
index 000000000..30e1680d5
--- /dev/null
+++ b/docker/Dockerfile.full
@@ -0,0 +1,44 @@
+# ============================================================
+# Stage 1: Build the picoclaw binary
+# ============================================================
+FROM golang:1.26.0-alpine AS builder
+
+RUN apk add --no-cache git make
+
+WORKDIR /src
+
+# Cache dependencies
+COPY go.mod go.sum ./
+RUN go mod download
+
+# Copy source and build
+COPY . .
+RUN make build
+
+# ============================================================
+# Stage 2: Node.js-based runtime with full MCP support
+# ============================================================
+FROM node:24-alpine3.23
+
+# Install runtime dependencies
+RUN apk add --no-cache \
+ ca-certificates \
+ curl \
+ git \
+ python3 \
+ py3-pip
+
+# Install uv and symlink to system path
+RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
+ ln -s /root/.local/bin/uv /usr/local/bin/uv && \
+ ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \
+ uv --version
+
+# Copy binary
+COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
+
+# Create picoclaw home directory
+RUN /usr/local/bin/picoclaw onboard
+
+ENTRYPOINT ["picoclaw"]
+CMD ["gateway"]
diff --git a/docker/docker-compose.full.yml b/docker/docker-compose.full.yml
new file mode 100644
index 000000000..6f34448c4
--- /dev/null
+++ b/docker/docker-compose.full.yml
@@ -0,0 +1,44 @@
+services:
+ # ─────────────────────────────────────────────
+ # PicoClaw Agent (one-shot query) - Full MCP Support
+ # docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent -m "Hello"
+ # ─────────────────────────────────────────────
+ picoclaw-agent:
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile.full
+ container_name: picoclaw-agent-full
+ profiles:
+ - agent
+ volumes:
+ - ../config/config.json:/root/.picoclaw/config.json:ro
+ - picoclaw-workspace:/root/.picoclaw/workspace
+ - picoclaw-npm-cache:/root/.npm # npm cache for faster MCP server installs
+ entrypoint: ["picoclaw", "agent"]
+ stdin_open: true
+ tty: true
+
+ # ─────────────────────────────────────────────
+ # PicoClaw Gateway (Long-running Bot) - Full MCP Support
+ # docker compose -f docker/docker-compose.full.yml --profile gateway up
+ # ─────────────────────────────────────────────
+ picoclaw-gateway:
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile.full
+ container_name: picoclaw-gateway-full
+ restart: unless-stopped
+ profiles:
+ - gateway
+ volumes:
+ # Configuration file
+ - ../config/config.json:/root/.picoclaw/config.json:ro
+ # Persistent workspace (sessions, memory, logs)
+ - picoclaw-workspace:/root/.picoclaw/workspace
+ # NPM cache for faster MCP server installs
+ - picoclaw-npm-cache:/root/.npm
+ command: ["gateway"]
+
+volumes:
+ picoclaw-workspace:
+ picoclaw-npm-cache: # Cache npm packages to speed up MCP server installations
diff --git a/docs/agent-refactor/README.md b/docs/agent-refactor/README.md
new file mode 100644
index 000000000..db8575fc9
--- /dev/null
+++ b/docs/agent-refactor/README.md
@@ -0,0 +1,145 @@
+# Agent Refactor
+
+## What this directory is for
+
+This directory is the working area for the current Agent refactor.
+
+The purpose of this refactor is simple:
+
+the project needs a smaller, clearer, and more stable Agent model before more Agent-related behavior is added.
+
+The codebase already contains meaningful Agent behavior. What it still lacks is a sufficiently explicit and stable semantic boundary around that behavior.
+
+This refactor exists to fix that first.
+
+---
+
+## Refactor stance
+
+This is a maintenance-led consolidation effort.
+
+It is not a general invitation to expand Agent behavior in parallel.
+
+During this refactor window, Agent-related work should converge on the current refactor track instead of branching into new semantics.
+
+That means:
+
+- concept clarification before feature expansion
+- boundary tightening before abstraction growth
+- semantic consolidation before new behavior
+
+---
+
+## Core rule: minimum concepts only
+
+This refactor follows one hard rule:
+
+**do not introduce a new concept unless it is strictly necessary**
+
+More explicitly:
+
+- if an existing concept can be clarified, reuse it
+- if an existing boundary can be made explicit, do that first
+- if a behavior can be expressed without a new abstraction, do not add one
+- "future flexibility" is not enough justification on its own
+
+The goal of this refactor is not to grow the model.
+
+The goal is to reduce ambiguity.
+
+---
+
+## What is being clarified
+
+This refactor is currently concerned with the following questions:
+
+1. what an `Agent` is
+2. what an `AgentLoop` is
+3. what the lifecycle of `AgentLoop` is
+4. what the event surface around `AgentLoop` is
+5. how persona / identity is assembled
+6. how capabilities are represented
+7. how context boundaries and compression work
+8. how subagent coordination works
+
+These are the current working boundaries.
+
+If they need to be adjusted, they should be adjusted explicitly rather than drift implicitly in code.
+
+---
+
+## Status of this directory
+
+The documents here are working materials.
+
+They are not final or immutable.
+
+If current notes are incomplete, incorrectly split, or too broad, they should be revised. This directory should evolve with the refactor rather than pretending the first draft is complete.
+
+---
+
+## Suggested document split
+
+This directory may eventually contain notes such as:
+
+- `agent-overview.md`
+ - what an Agent is
+- `agent-loop.md`
+ - AgentLoop contract, lifecycle, event surface
+- `persona.md`
+ - persona and identity assembly
+- `capability.md`
+ - tools / skills / MCP capability semantics
+- `context.md`
+ - context scope, history, summary, compression
+- `subagent.md`
+ - subagent coordination rules
+
+These files should be added only when they help clarify the current refactor work.
+
+This directory should not turn into a generic architecture dump.
+
+---
+
+## What this directory is not for
+
+This directory is not intended for:
+
+- broad speculative architecture
+- future multi-node protocol design not required by the current refactor
+- parallel feature planning unrelated to Agent consolidation
+- adding new concepts before current ones are made clear
+
+If a topic does not directly help reduce ambiguity in the current Agent model, it probably does not belong here yet.
+
+---
+
+## Relationship to implementation
+
+Implementation changes should not keep redefining Agent semantics implicitly.
+
+If a PR changes or depends on Agent semantics, those semantics should either already exist here or be clarified in a linked issue first.
+
+This directory is here to make implementation narrower and more disciplined.
+
+---
+
+## Relationship to GitHub tracking
+
+The umbrella issue for this refactor should point here.
+
+The issue is the coordination surface.
+
+This directory is the repository-local working surface.
+
+---
+
+## Summary
+
+The main question of this refactor is not:
+
+- what more can Agent do
+
+The main question is:
+
+- what is the smallest stable model that current Agent behavior can be organized around
diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md
index 310827723..3fafffb7d 100644
--- a/docs/channels/feishu/README.zh.md
+++ b/docs/channels/feishu/README.zh.md
@@ -26,7 +26,8 @@
| app_secret | string | 是 | 飞书应用的 App Secret |
| encrypt_key | string | 否 | 事件回调加密密钥 |
| verification_token | string | 否 | 用于Webhook事件验证的Token |
-| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+| allow_from | array | 否 | 用户ID白名单,空表示所有用户 |
+| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
## 设置流程
@@ -35,3 +36,4 @@
3. 配置事件订阅和Webhook URL
4. 设置加密(可选,生产环境建议启用)
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
+6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
diff --git a/docs/channels/matrix/README.md b/docs/channels/matrix/README.md
new file mode 100644
index 000000000..c213aa80b
--- /dev/null
+++ b/docs/channels/matrix/README.md
@@ -0,0 +1,59 @@
+# Matrix Channel Configuration Guide
+
+## 1. Example Configuration
+
+Add this to `config.json`:
+
+```json
+{
+ "channels": {
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.org",
+ "user_id": "@your-bot:matrix.org",
+ "access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+ "device_id": "",
+ "join_on_invite": true,
+ "allow_from": [],
+ "group_trigger": {
+ "mention_only": true
+ },
+ "placeholder": {
+ "enabled": true,
+ "text": "Thinking..."
+ },
+ "reasoning_channel_id": ""
+ }
+ }
+}
+```
+
+## 2. Field Reference
+
+| Field | Type | Required | Description |
+|----------------------|----------|----------|-------------|
+| enabled | bool | Yes | Enable or disable the Matrix channel |
+| homeserver | string | Yes | Matrix homeserver URL (for example `https://matrix.org`) |
+| user_id | string | Yes | Bot Matrix user ID (for example `@bot:matrix.org`) |
+| access_token | string | Yes | Bot access token |
+| device_id | string | No | Optional Matrix device ID |
+| join_on_invite | bool | No | Auto-join invited rooms |
+| allow_from | []string | No | User whitelist (Matrix user IDs) |
+| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
+| placeholder | object | No | Placeholder message config |
+| reasoning_channel_id | string | No | Target channel for reasoning output |
+
+## 3. Currently Supported
+
+- Text message send/receive
+- Incoming image/audio/video/file download (MediaStore first, local path fallback)
+- Incoming audio normalization into existing transcription flow (`[audio: ...]`)
+- Outgoing image/audio/video/file upload and send
+- Group trigger rules (including mention-only mode)
+- Typing state (`m.typing`)
+- Placeholder message + final reply replacement
+- Auto-join invited rooms (can be disabled)
+
+## 4. TODO
+
+- Rich media metadata improvements (for example image/video size and thumbnails)
diff --git a/docs/channels/matrix/README.zh.md b/docs/channels/matrix/README.zh.md
new file mode 100644
index 000000000..efbc13093
--- /dev/null
+++ b/docs/channels/matrix/README.zh.md
@@ -0,0 +1,59 @@
+# Matrix 通道配置指南
+
+## 1. 配置示例
+
+在 `config.json` 中添加:
+
+```json
+{
+ "channels": {
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.org",
+ "user_id": "@your-bot:matrix.org",
+ "access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+ "device_id": "",
+ "join_on_invite": true,
+ "allow_from": [],
+ "group_trigger": {
+ "mention_only": true
+ },
+ "placeholder": {
+ "enabled": true,
+ "text": "Thinking... 💭"
+ },
+ "reasoning_channel_id": ""
+ }
+ }
+}
+```
+
+## 2. 参数说明
+
+| 字段 | 类型 | 必填 | 说明 |
+|----------------------|----------|------|------|
+| enabled | bool | 是 | 是否启用 Matrix 通道 |
+| homeserver | string | 是 | Matrix 服务器地址(例如 `https://matrix.org`) |
+| user_id | string | 是 | 机器人 Matrix 用户 ID(例如 `@bot:matrix.org`) |
+| access_token | string | 是 | 机器人 access token |
+| device_id | string | 否 | 设备 ID(可选) |
+| join_on_invite | bool | 否 | 是否自动加入邀请房间 |
+| allow_from | []string | 否 | 白名单用户(Matrix 用户 ID) |
+| group_trigger | object | 否 | 群聊触发策略(支持 `mention_only` / `prefixes`) |
+| placeholder | object | 否 | 占位消息配置 |
+| reasoning_channel_id | string | 否 | 思维链输出目标通道 |
+
+## 3. 当前支持
+
+- 文本消息收发
+- 图片/音频/视频/文件消息入站下载(写入 MediaStore / 本地路径回退)
+- 音频消息按统一标记进入现有转写流程(`[audio: ...]`)
+- 图片/音频/视频/文件消息出站发送(上传到 Matrix 媒体库后发送)
+- 群聊触发规则(支持仅 @ 提及时响应)
+- Typing 状态(`m.typing`)
+- 占位消息(`Thinking... 💭`)+ 最终回复替换
+- 自动加入邀请房间(可关闭)
+
+## 4. TODO
+
+- 富媒体细节增强(如 image/video 的尺寸、缩略图等 metadata)
diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md
new file mode 100644
index 000000000..d210528af
--- /dev/null
+++ b/docs/channels/wecom/wecom_aibot/README.zh.md
@@ -0,0 +1,116 @@
+# 企业微信智能机器人 (AI Bot)
+
+企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议,并支持超时后通过 `response_url` 主动推送最终回复。
+
+## 与其他 WeCom 通道的对比
+
+| 特性 | WeCom Bot | WeCom App | **WeCom AI Bot** |
+|------|-----------|-----------|-----------------|
+| 私聊 | ✅ | ✅ | ✅ |
+| 群聊 | ✅ | ❌ | ✅ |
+| 流式输出 | ❌ | ❌ | ✅ |
+| 超时主动推送 | ❌ | ✅ | ✅ |
+| 配置复杂度 | 低 | 高 | 中 |
+
+## 配置
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "allow_from": [],
+ "welcome_message": "你好!有什么可以帮助你的吗?",
+ "max_steps": 10
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------------- | ------ | ---- | -------------------------------------------------- |
+| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 |
+| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 |
+| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot) |
+| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 |
+| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 |
+| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) |
+| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) |
+
+## 设置流程
+
+1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
+2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
+3. 在 AI Bot 配置页面,填写"消息接收"信息:
+ - **URL**:`http://:18791/webhook/wecom-aibot`
+ - **Token**:随机生成或自定义
+ - **EncodingAESKey**:点击"随机生成",得到 43 字符密钥
+4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存(企业微信会发送验证请求)
+
+> [!TIP]
+> 服务器需要能被企业微信服务器访问。如在内网/本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。
+
+## 流式响应协议
+
+WeCom AI Bot 使用"流式拉取"协议,区别于普通 Webhook 的一次性回复:
+
+```
+用户发消息
+ │
+ ▼
+PicoClaw 立即返回 {finish: false}(Agent 开始处理)
+ │
+ ▼
+企业微信每隔约 1 秒拉取一次 {msgtype: "stream", stream: {id: "..."}}
+ │
+ ├─ Agent 未完成 → 返回 {finish: false}(继续等待)
+ │
+ └─ Agent 完成 → 返回 {finish: true, content: "回答内容"}
+```
+
+**超时处理**(任务超过 30 秒):
+
+若 Agent 处理时间超过约 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会:
+
+1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」
+2. Agent 继续在后台运行
+3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户
+
+> `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。
+
+## 欢迎语
+
+配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件),PicoClaw 会自动回复该欢迎语。留空则静默忽略。
+
+```json
+"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?"
+```
+
+## 常见问题
+
+### 回调 URL 验证失败
+
+- 确认服务器防火墙已开放对应端口(默认 18791)
+- 确认 `token` 与 `encoding_aes_key` 填写正确
+- 检查 PicoClaw 日志是否收到了来自企业微信的 GET 请求
+
+### 消息没有回复
+
+- 检查 `allow_from` 是否意外限制了发送者
+- 查看日志中是否出现 `context canceled` 或 Agent 错误
+- 确认 Agent 配置(`model_name` 等)正确
+
+### 超长任务没有收到最终推送
+
+- 确认消息回调中携带了 `response_url`(仅企业微信新版 AI Bot 支持)
+- 确认服务器能主动访问外网(需向 `response_url` POST 请求)
+- 查看日志关键词 `response_url mode` 和 `Sending reply via response_url`
+
+## 参考文档
+
+- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/100719)
+- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719)
+- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138)
diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md
index 8aba1aa91..e64a3a107 100644
--- a/docs/tools_configuration.md
+++ b/docs/tools_configuration.md
@@ -8,6 +8,7 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
{
"tools": {
"web": { ... },
+ "mcp": { ... },
"exec": { ... },
"cron": { ... },
"skills": { ... }
@@ -21,35 +22,35 @@ Web tools are used for web search and fetching.
### Brave
-| Config | Type | Default | Description |
-|--------|------|---------|-------------|
-| `enabled` | bool | false | Enable Brave search |
-| `api_key` | string | - | Brave Search API key |
-| `max_results` | int | 5 | Maximum number of results |
+| Config | Type | Default | Description |
+| ------------- | ------ | ------- | ------------------------- |
+| `enabled` | bool | false | Enable Brave search |
+| `api_key` | string | - | Brave Search API key |
+| `max_results` | int | 5 | Maximum number of results |
### DuckDuckGo
-| Config | Type | Default | Description |
-|--------|------|---------|-------------|
-| `enabled` | bool | true | Enable DuckDuckGo search |
-| `max_results` | int | 5 | Maximum number of results |
+| Config | Type | Default | Description |
+| ------------- | ---- | ------- | ------------------------- |
+| `enabled` | bool | true | Enable DuckDuckGo search |
+| `max_results` | int | 5 | Maximum number of results |
### Perplexity
-| Config | Type | Default | Description |
-|--------|------|---------|-------------|
-| `enabled` | bool | false | Enable Perplexity search |
-| `api_key` | string | - | Perplexity API key |
-| `max_results` | int | 5 | Maximum number of results |
+| Config | Type | Default | Description |
+| ------------- | ------ | ------- | ------------------------- |
+| `enabled` | bool | false | Enable Perplexity search |
+| `api_key` | string | - | Perplexity API key |
+| `max_results` | int | 5 | Maximum number of results |
## Exec Tool
The exec tool is used to execute shell commands.
-| Config | Type | Default | Description |
-|--------|------|---------|-------------|
-| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
-| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
+| Config | Type | Default | Description |
+| ---------------------- | ----- | ------- | ------------------------------------------ |
+| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
+| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
### Functionality
@@ -80,10 +81,7 @@ By default, PicoClaw blocks the following dangerous commands:
"tools": {
"exec": {
"enable_deny_patterns": true,
- "custom_deny_patterns": [
- "\\brm\\s+-r\\b",
- "\\bkillall\\s+python"
- ]
+ "custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"]
}
}
}
@@ -93,9 +91,84 @@ By default, PicoClaw blocks the following dangerous commands:
The cron tool is used for scheduling periodic tasks.
-| Config | Type | Default | Description |
-|--------|------|---------|-------------|
-| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
+| Config | Type | Default | Description |
+| ---------------------- | ---- | ------- | ---------------------------------------------- |
+| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
+
+## MCP Tool
+
+The MCP tool enables integration with external Model Context Protocol servers.
+
+### Global Config
+
+| Config | Type | Default | Description |
+| --------- | ------ | ------- | ----------------------------------- |
+| `enabled` | bool | false | Enable MCP integration globally |
+| `servers` | object | `{}` | Map of server name to server config |
+
+### Per-Server Config
+
+| Config | Type | Required | Description |
+| ---------- | ------ | -------- | ------------------------------------------ |
+| `enabled` | bool | yes | Enable this MCP server |
+| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
+| `command` | string | stdio | Executable command for stdio transport |
+| `args` | array | no | Command arguments for stdio transport |
+| `env` | object | no | Environment variables for stdio process |
+| `env_file` | string | no | Path to environment file for stdio process |
+| `url` | string | sse/http | Endpoint URL for `sse`/`http` transport |
+| `headers` | object | no | HTTP headers for `sse`/`http` transport |
+
+### Transport Behavior
+
+- If `type` is omitted, transport is auto-detected:
+ - `url` is set → `sse`
+ - `command` is set → `stdio`
+- `http` and `sse` both use `url` + optional `headers`.
+- `env` and `env_file` are only applied to `stdio` servers.
+
+### Configuration Examples
+
+#### 1) Stdio MCP server
+
+```json
+{
+ "tools": {
+ "mcp": {
+ "enabled": true,
+ "servers": {
+ "filesystem": {
+ "enabled": true,
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ }
+ }
+ }
+ }
+}
+```
+
+#### 2) Remote SSE/HTTP MCP server
+
+```json
+{
+ "tools": {
+ "mcp": {
+ "enabled": true,
+ "servers": {
+ "remote-mcp": {
+ "enabled": true,
+ "type": "sse",
+ "url": "https://example.com/mcp",
+ "headers": {
+ "Authorization": "Bearer YOUR_TOKEN"
+ }
+ }
+ }
+ }
+ }
+}
+```
## Skills Tool
@@ -103,13 +176,14 @@ The skills tool configures skill discovery and installation via registries like
### Registries
-| Config | Type | Default | Description |
-|--------|------|---------|-------------|
-| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
-| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
-| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
-| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
-| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
+| Config | Type | Default | Description |
+| ---------------------------------- | ------ | -------------------- | ----------------------- |
+| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
+| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
+| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
+| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
+| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
+| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
### Configuration Example
@@ -121,6 +195,7 @@ The skills tool configures skill discovery and installation via registries like
"clawhub": {
"enabled": true,
"base_url": "https://clawhub.ai",
+ "auth_token": "",
"search_path": "/api/v1/search",
"skills_path": "/api/v1/skills",
"download_path": "/api/v1/download"
@@ -136,8 +211,10 @@ The skills tool configures skill discovery and installation via registries like
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS__`:
For example:
+
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
+- `PICOCLAW_TOOLS_MCP_ENABLED=true`
-Note: Array-type environment variables are not currently supported and must be set via the config file.
+Note: Nested map-style config (for example `tools.mcp.servers..*`) is configured in `config.json` rather than environment variables.
diff --git a/docs/wecom-app-configuration.md b/docs/wecom-app-configuration.md
deleted file mode 100644
index 3c720ecd1..000000000
--- a/docs/wecom-app-configuration.md
+++ /dev/null
@@ -1,115 +0,0 @@
-# 企业微信自建应用 (WeCom App) 配置指南
-
-本文档介绍如何在 PicoClaw 中配置企业微信自建应用 (wecom-app) 通道。
-
-## 功能特性
-
-| 功能 | 支持状态 |
-|------|---------|
-| 被动接收消息 | ✅ |
-| 主动发送消息 | ✅ |
-| 私聊 | ✅ |
-| 群聊 | ❌ |
-
-## 配置步骤
-
-### 1. 企业微信后台配置
-
-1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
-2. 进入"应用管理" → 选择自建应用
-3. 记录以下信息:
- - **AgentId**: 应用详情页显示
- - **Secret**: 点击"查看"获取
-4. 进入"我的企业"页面,记录 **企业ID** (CorpID)
-
-### 2. 接收消息配置
-
-1. 在应用详情页,点击"接收消息"的"设置API接收"
-2. 填写以下信息:
- - **URL**: `http://your-server:18790/webhook/wecom-app`
- - **Token**: 随机生成或自定义(用于签名验证)
- - **EncodingAESKey**: 点击"随机生成"生成43字符的密钥
-3. 点击"保存"时,企业微信会发送验证请求
-
-### 3. PicoClaw 配置
-
-在 `config.json` 中添加以下配置:
-
-```json
-{
- "channels": {
- "wecom_app": {
- "enabled": true,
- "corp_id": "wwxxxxxxxxxxxxxxxx", // 企业ID
- "corp_secret": "xxxxxxxxxxxxxxxxxxxxxxxx", // 应用Secret
- "agent_id": 1000002, // 应用AgentId
- "token": "your_token", // 接收消息配置的Token
- "encoding_aes_key": "your_encoding_aes_key", // 接收消息配置的EncodingAESKey
- "webhook_path": "/webhook/wecom-app",
- "allow_from": [],
- "reply_timeout": 5
- }
- }
-}
-```
-
-## 常见问题
-
-### 1. 回调URL验证失败
-
-**症状**: 企业微信保存API接收消息时提示验证失败
-
-**检查项**:
-- 确认服务器防火墙已开放 Gateway 端口(默认 18790)
-- 确认 `corp_id`、`token`、`encoding_aes_key` 配置正确
-- 查看 PicoClaw 日志是否有请求到达
-
-### 2. 中文消息解密失败
-
-**症状**: 发送中文消息时出现 `invalid padding size` 错误
-
-**原因**: 企业微信使用非标准的 PKCS7 填充(32字节块大小)
-
-**解决**: 确保使用最新版本的 PicoClaw,已修复此问题。
-
-### 3. 端口冲突
-
-**症状**: 启动时提示端口已被占用
-
-**解决**: 修改 `gateway.port` 为其他端口(所有 Webhook 渠道共享同一个 Gateway HTTP 服务器)
-
-## 技术细节
-
-### 加密算法
-
-- **算法**: AES-256-CBC
-- **密钥**: EncodingAESKey Base64解码后的32字节
-- **IV**: AESKey的前16字节
-- **填充**: PKCS7(块大小为32字节,非标准16字节)
-- **消息格式**: XML
-
-### 消息结构
-
-解密后的消息格式:
-```
-random(16B) + msg_len(4B) + msg + receiveid
-```
-
-其中 `receiveid` 对于自建应用是 `corp_id`。
-
-## 调试
-
-启用调试模式查看详细日志:
-
-```bash
-picoclaw gateway --debug
-```
-
-关键日志标识:
-- `wecom_app`: WeCom App 通道相关日志
-- `wecom_common`: 加密解密相关日志
-
-## 参考文档
-
-- [企业微信官方文档 - 接收消息](https://developer.work.weixin.qq.com/document/path/96211)
-- [企业微信官方加解密库](https://github.com/sbzhu/weworkapi_golang)
diff --git a/go.mod b/go.mod
index 5a07d1c68..f60be046f 100644
--- a/go.mod
+++ b/go.mod
@@ -4,32 +4,36 @@ go 1.25.7
require (
github.com/adhocore/gronx v1.19.6
- github.com/anthropics/anthropic-sdk-go v1.26.0
+ github.com/anthropics/anthropic-sdk-go v1.22.1
github.com/bwmarrin/discordgo v0.29.0
- github.com/caarlos0/env/v11 v11.4.0
+ github.com/caarlos0/env/v11 v11.3.1
github.com/chzyer/readline v1.5.1
+ github.com/ergochat/irc-go v0.5.0
github.com/gdamore/tcell/v2 v2.13.8
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
+ github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1
- github.com/mymmrac/telego v1.7.0
+ github.com/modelcontextprotocol/go-sdk v1.3.1
+ 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.24.0
+ github.com/openai/openai-go/v3 v3.22.0
github.com/rivo/tview v0.42.0
- github.com/slack-go/slack v0.18.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-20260227112304-c9652e4448a2
+ 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
+ maunium.net/go/mautrix v0.26.3
modernc.org/sqlite v1.46.1
)
require (
- filippo.io/edwards25519 v1.2.0 // indirect
+ filippo.io/edwards25519 v1.1.1 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -41,20 +45,22 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
- github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
+ github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
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/rs/zerolog v1.34.0 // indirect
+ github.com/segmentio/asm v1.1.3 // indirect
+ github.com/segmentio/encoding v0.5.3 // indirect
github.com/spf13/pflag v1.0.10 // indirect
- github.com/vektah/gqlparser/v2 v2.5.32 // 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-20260218203240-3dfff04db8fa // indirect
+ golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
- modernc.org/libc v1.69.0 // indirect
+ modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
rsc.io/qr v0.2.0 // indirect
@@ -66,8 +72,8 @@ require (
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.30
- github.com/go-resty/resty/v2 v2.17.2 // 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
@@ -80,7 +86,8 @@ require (
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.10 // 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.51.0 // indirect
diff --git a/go.sum b/go.sum
index d08174b78..4060997f8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,6 @@
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
-filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
-filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
+filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
@@ -11,8 +11,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg
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.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
-github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
+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=
@@ -23,8 +23,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
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.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
-github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
+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=
@@ -44,30 +44,32 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
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/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
-github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
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/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
+github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
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.30 h1:WUihzt8VOEAwkcjX2zlYJydgL+2lQZwn/syfh412vwA=
-github.com/github/copilot-sdk/go v0.1.30/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts=
+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.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
-github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
+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=
@@ -98,6 +100,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
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=
@@ -132,8 +136,10 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
-github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
-github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
+github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
+github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
+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=
@@ -146,10 +152,10 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
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.24.0 h1:08x6GnYiB+AAejTo6yzPY8RkZMJQ8NpreiOyM5QfyYU=
-github.com/openai/openai-go/v3 v3.24.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
-github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE=
-github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+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=
@@ -167,10 +173,14 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
+github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
+github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
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.18.0 h1:PM3IWgAoaPTnitOyfy8Unq/rk8OZLAxlBUhNLv8sbyg=
-github.com/slack-go/slack v0.18.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE=
+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=
@@ -208,12 +218,14 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
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.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
-github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
-github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
-github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
+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=
@@ -221,8 +233,8 @@ go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
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-20260227112304-c9652e4448a2 h1:tYSfEoDVfPEWWuNgbYzyaX6TmWwlplW6NktbaGsVAb0=
-go.mau.fi/whatsmeow v0.0.0-20260227112304-c9652e4448a2/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
+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=
@@ -236,8 +248,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
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-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
-golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
+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=
@@ -257,6 +269,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
@@ -345,25 +359,26 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
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=
+maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
+maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
-modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
-modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
-modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
-modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
+modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
+modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
+modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
-modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
-modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
+modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
-modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
+modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
+modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go
index ab0d098cd..aa252ee94 100644
--- a/pkg/agent/context_cache_test.go
+++ b/pkg/agent/context_cache_test.go
@@ -540,6 +540,162 @@ Updated content.`
}
}
+// TestGlobalSkillFileContentChange verifies that modifying a global skill
+// (~/.picoclaw/skills) invalidates the cached system prompt.
+func TestGlobalSkillFileContentChange(t *testing.T) {
+ tmpHome := t.TempDir()
+ t.Setenv("HOME", tmpHome)
+
+ tmpDir := setupWorkspace(t, nil)
+ defer os.RemoveAll(tmpDir)
+
+ globalSkillPath := filepath.Join(tmpHome, ".picoclaw", "skills", "global-skill", "SKILL.md")
+ if err := os.MkdirAll(filepath.Dir(globalSkillPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ v1 := `---
+name: global-skill
+description: global-v1
+---
+# Global Skill v1`
+ if err := os.WriteFile(globalSkillPath, []byte(v1), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cb := NewContextBuilder(tmpDir)
+ sp1 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp1, "global-v1") {
+ t.Fatal("expected initial prompt to contain global skill description")
+ }
+
+ v2 := `---
+name: global-skill
+description: global-v2
+---
+# Global Skill v2`
+ if err := os.WriteFile(globalSkillPath, []byte(v2), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ future := time.Now().Add(2 * time.Second)
+ if err := os.Chtimes(globalSkillPath, future, future); err != nil {
+ t.Fatalf("failed to update mtime for %s: %v", globalSkillPath, err)
+ }
+
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Fatal("sourceFilesChangedLocked() should detect global skill file content change")
+ }
+
+ sp2 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp2, "global-v2") {
+ t.Error("rebuilt prompt should contain updated global skill description")
+ }
+ if sp1 == sp2 {
+ t.Error("cache should be invalidated when global skill file content changes")
+ }
+}
+
+// TestBuiltinSkillFileContentChange verifies that modifying a builtin skill
+// invalidates the cached system prompt.
+func TestBuiltinSkillFileContentChange(t *testing.T) {
+ tmpHome := t.TempDir()
+ t.Setenv("HOME", tmpHome)
+
+ tmpDir := setupWorkspace(t, nil)
+ defer os.RemoveAll(tmpDir)
+
+ builtinRoot := t.TempDir()
+ t.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot)
+
+ builtinSkillPath := filepath.Join(builtinRoot, "builtin-skill", "SKILL.md")
+ if err := os.MkdirAll(filepath.Dir(builtinSkillPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ v1 := `---
+name: builtin-skill
+description: builtin-v1
+---
+# Builtin Skill v1`
+ if err := os.WriteFile(builtinSkillPath, []byte(v1), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cb := NewContextBuilder(tmpDir)
+ sp1 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp1, "builtin-v1") {
+ t.Fatal("expected initial prompt to contain builtin skill description")
+ }
+
+ v2 := `---
+name: builtin-skill
+description: builtin-v2
+---
+# Builtin Skill v2`
+ if err := os.WriteFile(builtinSkillPath, []byte(v2), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ future := time.Now().Add(2 * time.Second)
+ if err := os.Chtimes(builtinSkillPath, future, future); err != nil {
+ t.Fatalf("failed to update mtime for %s: %v", builtinSkillPath, err)
+ }
+
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Fatal("sourceFilesChangedLocked() should detect builtin skill file content change")
+ }
+
+ sp2 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp2, "builtin-v2") {
+ t.Error("rebuilt prompt should contain updated builtin skill description")
+ }
+ if sp1 == sp2 {
+ t.Error("cache should be invalidated when builtin skill file content changes")
+ }
+}
+
+// TestSkillFileDeletionInvalidatesCache verifies that deleting a nested skill
+// file invalidates the cached system prompt.
+func TestSkillFileDeletionInvalidatesCache(t *testing.T) {
+ tmpDir := setupWorkspace(t, map[string]string{
+ "skills/delete-me/SKILL.md": `---
+name: delete-me
+description: delete-me-v1
+---
+# Delete Me`,
+ })
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+ sp1 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp1, "delete-me-v1") {
+ t.Fatal("expected initial prompt to contain skill description")
+ }
+
+ skillPath := filepath.Join(tmpDir, "skills", "delete-me", "SKILL.md")
+ if err := os.Remove(skillPath); err != nil {
+ t.Fatal(err)
+ }
+
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Fatal("sourceFilesChangedLocked() should detect deleted skill file")
+ }
+
+ sp2 := cb.BuildSystemPromptWithCache()
+ if strings.Contains(sp2, "delete-me-v1") {
+ t.Error("rebuilt prompt should not contain deleted skill description")
+ }
+ if sp1 == sp2 {
+ t.Error("cache should be invalidated when skill file is deleted")
+ }
+}
+
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
// can safely call BuildSystemPromptWithCache concurrently without producing
diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go
index d72d0aca9..6f6884bbe 100644
--- a/pkg/agent/context_test.go
+++ b/pkg/agent/context_test.go
@@ -269,3 +269,77 @@ func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
}
}
}
+
+// TestSanitizeHistoryForProvider_IncompleteToolResults tests the forward validation
+// that ensures assistant messages with tool_calls have ALL matching tool results.
+// This fixes the DeepSeek error: "An assistant message with 'tool_calls' must be
+// followed by tool messages responding to each 'tool_call_id'."
+func TestSanitizeHistoryForProvider_IncompleteToolResults(t *testing.T) {
+ // Assistant expects tool results for both A and B, but only A is present
+ history := []providers.Message{
+ msg("user", "do two things"),
+ assistantWithTools("A", "B"),
+ toolResult("A"),
+ // toolResult("B") is missing - this would cause DeepSeek to fail
+ msg("user", "next question"),
+ msg("assistant", "answer"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ // The assistant message with incomplete tool results should be dropped,
+ // along with its partial tool result. The remaining messages are:
+ // user ("do two things"), user ("next question"), assistant ("answer")
+ if len(result) != 3 {
+ t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "user", "assistant")
+}
+
+// TestSanitizeHistoryForProvider_MissingAllToolResults tests the case where
+// an assistant message has tool_calls but no tool results follow at all.
+func TestSanitizeHistoryForProvider_MissingAllToolResults(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "do something"),
+ assistantWithTools("A"),
+ // No tool results at all
+ msg("user", "hello"),
+ msg("assistant", "hi"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ // The assistant message with no tool results should be dropped.
+ // Remaining: user ("do something"), user ("hello"), assistant ("hi")
+ if len(result) != 3 {
+ t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "user", "assistant")
+}
+
+// TestSanitizeHistoryForProvider_PartialToolResultsInMiddle tests that
+// incomplete tool results in the middle of a conversation are properly handled.
+func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "first"),
+ assistantWithTools("A"),
+ toolResult("A"),
+ msg("assistant", "done"),
+ msg("user", "second"),
+ assistantWithTools("B", "C"),
+ toolResult("B"),
+ // toolResult("C") is missing
+ msg("user", "third"),
+ assistantWithTools("D"),
+ toolResult("D"),
+ msg("assistant", "all done"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ // First round is complete (user, assistant+tools, tool, assistant),
+ // second round is incomplete and dropped (assistant+tools, partial tool),
+ // third round is complete (user, assistant+tools, tool, assistant).
+ // Remaining: user, assistant, tool, assistant, user, user, assistant, tool, assistant
+ if len(result) != 9 {
+ t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant")
+}
diff --git a/pkg/agent/loop_media.go b/pkg/agent/loop_media.go
new file mode 100644
index 000000000..82547a008
--- /dev/null
+++ b/pkg/agent/loop_media.go
@@ -0,0 +1,122 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+import (
+ "bytes"
+ "encoding/base64"
+ "io"
+ "os"
+ "strings"
+
+ "github.com/h2non/filetype"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// resolveMediaRefs replaces media:// refs in message Media fields with base64 data URLs.
+// Uses streaming base64 encoding (file handle → encoder → buffer) to avoid holding
+// both raw bytes and encoded string in memory simultaneously.
+// Returns a new slice; original messages are not mutated.
+func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
+ if store == nil {
+ return messages
+ }
+
+ result := make([]providers.Message, len(messages))
+ copy(result, messages)
+
+ for i, m := range result {
+ if len(m.Media) == 0 {
+ continue
+ }
+
+ resolved := make([]string, 0, len(m.Media))
+ for _, ref := range m.Media {
+ if !strings.HasPrefix(ref, "media://") {
+ resolved = append(resolved, ref)
+ continue
+ }
+
+ localPath, meta, err := store.ResolveWithMeta(ref)
+ if err != nil {
+ logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{
+ "ref": ref,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ info, err := os.Stat(localPath)
+ if err != nil {
+ logger.WarnCF("agent", "Failed to stat media file", map[string]any{
+ "path": localPath,
+ "error": err.Error(),
+ })
+ continue
+ }
+ if info.Size() > int64(maxSize) {
+ logger.WarnCF("agent", "Media file too large, skipping", map[string]any{
+ "path": localPath,
+ "size": info.Size(),
+ "max_size": maxSize,
+ })
+ continue
+ }
+
+ // Determine MIME type: prefer metadata, fallback to magic-bytes detection
+ mime := meta.ContentType
+ if mime == "" {
+ kind, ftErr := filetype.MatchFile(localPath)
+ if ftErr != nil || kind == filetype.Unknown {
+ logger.WarnCF("agent", "Unknown media type, skipping", map[string]any{
+ "path": localPath,
+ })
+ continue
+ }
+ mime = kind.MIME.Value
+ }
+
+ // Streaming base64: open file → base64 encoder → buffer
+ // Peak memory: ~1.33x file size (buffer only, no raw bytes copy)
+ f, err := os.Open(localPath)
+ if err != nil {
+ logger.WarnCF("agent", "Failed to open media file", map[string]any{
+ "path": localPath,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ prefix := "data:" + mime + ";base64,"
+ encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
+ var buf bytes.Buffer
+ buf.Grow(len(prefix) + encodedLen)
+ buf.WriteString(prefix)
+
+ encoder := base64.NewEncoder(base64.StdEncoding, &buf)
+ if _, err := io.Copy(encoder, f); err != nil {
+ f.Close()
+ logger.WarnCF("agent", "Failed to encode media file", map[string]any{
+ "path": localPath,
+ "error": err.Error(),
+ })
+ continue
+ }
+ encoder.Close()
+ f.Close()
+
+ resolved = append(resolved, buf.String())
+ }
+
+ result[i].Media = resolved
+ }
+
+ return result
+}
diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go
index d511da80d..300352331 100644
--- a/pkg/agent/registry.go
+++ b/pkg/agent/registry.go
@@ -7,6 +7,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
+ "github.com/sipeed/picoclaw/pkg/tools"
)
// AgentRegistry manages multiple agent instances and routes messages to them.
@@ -137,6 +138,19 @@ func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bo
return false
}
+// ForEachTool calls fn for every tool registered under the given name
+// across all agents. This is useful for propagating dependencies (e.g.
+// MediaStore) to tools after registry construction.
+func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ for _, agent := range r.agents {
+ if t, ok := agent.Tools.Get(name); ok {
+ fn(t)
+ }
+ }
+}
+
// GetDefaultAgent returns the default agent instance.
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
diff --git a/pkg/agent/thinking.go b/pkg/agent/thinking.go
new file mode 100644
index 000000000..015b69282
--- /dev/null
+++ b/pkg/agent/thinking.go
@@ -0,0 +1,39 @@
+package agent
+
+import "strings"
+
+// ThinkingLevel controls how the provider sends thinking parameters.
+//
+// - "adaptive": sends {thinking: {type: "adaptive"}} + output_config.effort (Claude 4.6+)
+// - "low"/"medium"/"high"/"xhigh": sends {thinking: {type: "enabled", budget_tokens: N}} (all models)
+// - "off": disables thinking
+type ThinkingLevel string
+
+const (
+ ThinkingOff ThinkingLevel = "off"
+ ThinkingLow ThinkingLevel = "low"
+ ThinkingMedium ThinkingLevel = "medium"
+ ThinkingHigh ThinkingLevel = "high"
+ ThinkingXHigh ThinkingLevel = "xhigh"
+ ThinkingAdaptive ThinkingLevel = "adaptive"
+)
+
+// parseThinkingLevel normalizes a config string to a ThinkingLevel.
+// Case-insensitive and whitespace-tolerant for user-facing config values.
+// Returns ThinkingOff for unknown or empty values.
+func parseThinkingLevel(level string) ThinkingLevel {
+ switch strings.ToLower(strings.TrimSpace(level)) {
+ case "adaptive":
+ return ThinkingAdaptive
+ case "low":
+ return ThinkingLow
+ case "medium":
+ return ThinkingMedium
+ case "high":
+ return ThinkingHigh
+ case "xhigh":
+ return ThinkingXHigh
+ default:
+ return ThinkingOff
+ }
+}
diff --git a/pkg/agent/thinking_test.go b/pkg/agent/thinking_test.go
new file mode 100644
index 000000000..be3a68c33
--- /dev/null
+++ b/pkg/agent/thinking_test.go
@@ -0,0 +1,35 @@
+package agent
+
+import "testing"
+
+func TestParseThinkingLevel(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want ThinkingLevel
+ }{
+ {"off", "off", ThinkingOff},
+ {"empty", "", ThinkingOff},
+ {"low", "low", ThinkingLow},
+ {"medium", "medium", ThinkingMedium},
+ {"high", "high", ThinkingHigh},
+ {"xhigh", "xhigh", ThinkingXHigh},
+ {"adaptive", "adaptive", ThinkingAdaptive},
+ {"unknown", "unknown", ThinkingOff},
+ // Case-insensitive and whitespace-tolerant
+ {"upper_Medium", "Medium", ThinkingMedium},
+ {"upper_HIGH", "HIGH", ThinkingHigh},
+ {"mixed_Adaptive", "Adaptive", ThinkingAdaptive},
+ {"leading_space", " high", ThinkingHigh},
+ {"trailing_space", "low ", ThinkingLow},
+ {"both_spaces", " medium ", ThinkingMedium},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := parseThinkingLevel(tt.input); got != tt.want {
+ t.Errorf("parseThinkingLevel(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/pkg/auth/anthropic_usage.go b/pkg/auth/anthropic_usage.go
new file mode 100644
index 000000000..716b2908e
--- /dev/null
+++ b/pkg/auth/anthropic_usage.go
@@ -0,0 +1,71 @@
+package auth
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+const (
+ anthropicBetaHeader = "oauth-2025-04-20"
+ anthropicAPIVersion = "2023-06-01"
+)
+
+// anthropicUsageURL is the endpoint for fetching OAuth usage stats.
+// It is a var (not const) to allow overriding in tests.
+var anthropicUsageURL = "https://api.anthropic.com/api/oauth/usage"
+
+func setAnthropicUsageURL(url string) { anthropicUsageURL = url }
+
+type AnthropicUsage struct {
+ FiveHourUtilization float64
+ SevenDayUtilization float64
+}
+
+func FetchAnthropicUsage(token string) (*AnthropicUsage, error) {
+ req, err := http.NewRequest("GET", anthropicUsageURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Anthropic-Version", anthropicAPIVersion)
+ req.Header.Set("Anthropic-Beta", anthropicBetaHeader)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading usage response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ if resp.StatusCode == http.StatusForbidden {
+ return nil, fmt.Errorf("insufficient scope: usage endpoint requires oauth scope")
+ }
+ return nil, fmt.Errorf("usage request failed (%d): %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ FiveHour struct {
+ Utilization float64 `json:"utilization"`
+ } `json:"five_hour"`
+ SevenDay struct {
+ Utilization float64 `json:"utilization"`
+ } `json:"seven_day"`
+ }
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parsing usage response: %w", err)
+ }
+
+ return &AnthropicUsage{
+ FiveHourUtilization: result.FiveHour.Utilization,
+ SevenDayUtilization: result.SevenDay.Utilization,
+ }, nil
+}
diff --git a/pkg/auth/anthropic_usage_test.go b/pkg/auth/anthropic_usage_test.go
new file mode 100644
index 000000000..ef4a35364
--- /dev/null
+++ b/pkg/auth/anthropic_usage_test.go
@@ -0,0 +1,98 @@
+package auth
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestFetchAnthropicUsage_Success(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
+ t.Errorf("Authorization = %q, want %q", got, "Bearer test-token")
+ }
+ if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader {
+ t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader)
+ }
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"five_hour":{"utilization":0.42},"seven_day":{"utilization":0.85}}`))
+ }))
+ defer srv.Close()
+
+ // Temporarily override the URL by using the test server
+ origURL := anthropicUsageURL
+ defer func() { setAnthropicUsageURL(origURL) }()
+ setAnthropicUsageURL(srv.URL)
+
+ usage, err := FetchAnthropicUsage("test-token")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if usage.FiveHourUtilization != 0.42 {
+ t.Errorf("FiveHourUtilization = %v, want 0.42", usage.FiveHourUtilization)
+ }
+ if usage.SevenDayUtilization != 0.85 {
+ t.Errorf("SevenDayUtilization = %v, want 0.85", usage.SevenDayUtilization)
+ }
+}
+
+func TestFetchAnthropicUsage_Forbidden(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ w.Write([]byte(`{"error":"forbidden"}`))
+ }))
+ defer srv.Close()
+
+ origURL := anthropicUsageURL
+ defer func() { setAnthropicUsageURL(origURL) }()
+ setAnthropicUsageURL(srv.URL)
+
+ _, err := FetchAnthropicUsage("test-token")
+ if err == nil {
+ t.Fatal("expected error for 403, got nil")
+ }
+ if !strings.Contains(err.Error(), "insufficient scope") {
+ t.Errorf("expected 'insufficient scope' error, got %q", err.Error())
+ }
+}
+
+func TestFetchAnthropicUsage_ServerError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte(`internal error`))
+ }))
+ defer srv.Close()
+
+ origURL := anthropicUsageURL
+ defer func() { setAnthropicUsageURL(origURL) }()
+ setAnthropicUsageURL(srv.URL)
+
+ _, err := FetchAnthropicUsage("test-token")
+ if err == nil {
+ t.Fatal("expected error for 500, got nil")
+ }
+ if !strings.Contains(err.Error(), "500") {
+ t.Errorf("expected error containing '500', got %q", err.Error())
+ }
+}
+
+func TestFetchAnthropicUsage_MalformedJSON(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`not json`))
+ }))
+ defer srv.Close()
+
+ origURL := anthropicUsageURL
+ defer func() { setAnthropicUsageURL(origURL) }()
+ setAnthropicUsageURL(srv.URL)
+
+ _, err := FetchAnthropicUsage("test-token")
+ if err == nil {
+ t.Fatal("expected error for malformed JSON, got nil")
+ }
+ if !strings.Contains(err.Error(), "parsing usage response") {
+ t.Errorf("expected 'parsing usage response' error, got %q", err.Error())
+ }
+}
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index 91c9e25c5..4667e3d81 100644
--- a/pkg/auth/oauth.go
+++ b/pkg/auth/oauth.go
@@ -212,7 +212,10 @@ func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
}
defer resp.Body.Close()
- body, _ := io.ReadAll(resp.Body)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading device code response: %w", err)
+ }
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("device code request failed: %s", string(body))
}
@@ -300,7 +303,10 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
}
defer resp.Body.Close()
- body, _ := io.ReadAll(resp.Body)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading device code response: %w", err)
+ }
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("device code request failed: %s", string(body))
}
@@ -360,7 +366,10 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
return nil, fmt.Errorf("pending")
}
- body, _ := io.ReadAll(resp.Body)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading device token response: %w", err)
+ }
var tokenResp struct {
AuthorizationCode string `json:"authorization_code"`
@@ -401,7 +410,10 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
}
defer resp.Body.Close()
- body, _ := io.ReadAll(resp.Body)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading token refresh response: %w", err)
+ }
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token refresh failed: %s", string(body))
}
@@ -494,7 +506,10 @@ func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
}
defer resp.Body.Close()
- body, _ := io.ReadAll(resp.Body)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading token exchange response: %w", err)
+ }
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token exchange failed: %s", string(body))
}
diff --git a/pkg/auth/store.go b/pkg/auth/store.go
index 283dc6977..2e55d4877 100644
--- a/pkg/auth/store.go
+++ b/pkg/auth/store.go
@@ -39,6 +39,9 @@ func (c *AuthCredential) NeedsRefresh() bool {
}
func authFilePath() string {
+ if home := os.Getenv("PICOCLAW_HOME"); home != "" {
+ return filepath.Join(home, "auth.json")
+ }
home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw", "auth.json")
}
diff --git a/pkg/auth/token.go b/pkg/auth/token.go
index a5a13ff03..0e69e60ac 100644
--- a/pkg/auth/token.go
+++ b/pkg/auth/token.go
@@ -31,6 +31,35 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
}, nil
}
+func LoginSetupToken(r io.Reader) (*AuthCredential, error) {
+ fmt.Println("Paste your setup token from `claude setup-token`:")
+ fmt.Print("> ")
+
+ scanner := bufio.NewScanner(r)
+ if !scanner.Scan() {
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("reading token: %w", err)
+ }
+ return nil, fmt.Errorf("no input received")
+ }
+
+ token := strings.TrimSpace(scanner.Text())
+
+ if !strings.HasPrefix(token, "sk-ant-oat01-") {
+ return nil, fmt.Errorf("invalid setup token: expected prefix sk-ant-oat01-")
+ }
+
+ if len(token) < 80 {
+ return nil, fmt.Errorf("invalid setup token: too short (expected at least 80 characters)")
+ }
+
+ return &AuthCredential{
+ AccessToken: token,
+ Provider: "anthropic",
+ AuthMethod: "oauth",
+ }, nil
+}
+
func providerDisplayName(provider string) string {
switch provider {
case "anthropic":
diff --git a/pkg/auth/token_test.go b/pkg/auth/token_test.go
new file mode 100644
index 000000000..673cd9d5d
--- /dev/null
+++ b/pkg/auth/token_test.go
@@ -0,0 +1,61 @@
+package auth
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestLoginSetupToken(t *testing.T) {
+ // A valid token: correct prefix + at least 80 chars
+ validToken := "sk-ant-oat01-" + strings.Repeat("a", 80)
+
+ tests := []struct {
+ name string
+ input string
+ wantErr string
+ }{
+ {"valid token", validToken, ""},
+ {"empty input", "", "expected prefix sk-ant-oat01-"},
+ {"wrong prefix", "sk-ant-api-" + strings.Repeat("a", 80), "expected prefix sk-ant-oat01-"},
+ {"too short", "sk-ant-oat01-short", "too short"},
+ {"whitespace only", " ", "expected prefix sk-ant-oat01-"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := strings.NewReader(tt.input + "\n")
+ cred, err := LoginSetupToken(r)
+
+ if tt.wantErr != "" {
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil", tt.wantErr)
+ }
+ if !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cred.AccessToken != validToken {
+ t.Errorf("AccessToken = %q, want %q", cred.AccessToken, validToken)
+ }
+ if cred.Provider != "anthropic" {
+ t.Errorf("Provider = %q, want %q", cred.Provider, "anthropic")
+ }
+ if cred.AuthMethod != "oauth" {
+ t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth")
+ }
+ })
+ }
+}
+
+func TestLoginSetupToken_EmptyReader(t *testing.T) {
+ r := strings.NewReader("")
+ _, err := LoginSetupToken(r)
+ if err == nil {
+ t.Fatal("expected error for empty reader, got nil")
+ }
+}
diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go
index 57070c6d7..4b89e0e07 100644
--- a/pkg/channels/discord/discord.go
+++ b/pkg/channels/discord/discord.go
@@ -3,12 +3,16 @@ package discord
import (
"context"
"fmt"
+ "net/http"
+ "net/url"
"os"
+ "regexp"
"strings"
"sync"
"time"
"github.com/bwmarrin/discordgo"
+ "github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
@@ -23,6 +27,12 @@ const (
sendTimeout = 10 * time.Second
)
+var (
+ // Pre-compiled regexes for resolveDiscordRefs (avoid re-compiling per call)
+ channelRefRe = regexp.MustCompile(`<#(\d+)>`)
+ msgLinkRe = regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`)
+)
+
type DiscordChannel struct {
*channels.BaseChannel
session *discordgo.Session
@@ -40,6 +50,9 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
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),
@@ -368,6 +381,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
content = c.stripBotMention(content)
}
+ // Resolve Discord refs in main content before concatenation to avoid
+ // double-expanding links that appear in the referenced message.
+ content = c.resolveDiscordRefs(s, content, m.GuildID)
+
+ // Prepend referenced (quoted) message content if this is a reply
+ if m.MessageReference != nil && m.ReferencedMessage != nil {
+ refContent := m.ReferencedMessage.Content
+ if refContent != "" {
+ refAuthor := "unknown"
+ if m.ReferencedMessage.Author != nil {
+ refAuthor = m.ReferencedMessage.Author.Username
+ }
+ refContent = c.resolveDiscordRefs(s, refContent, m.GuildID)
+ content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s",
+ refAuthor, refContent, content)
+ }
+ }
+
senderID := m.Author.ID
mediaPaths := make([]string, 0, len(m.Attachments))
@@ -501,9 +532,88 @@ func (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func()
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
+}
+
+// resolveDiscordRefs resolves channel references (<#id> → #channel-name) and
+// expands Discord message links to show the linked message content.
+// Only links pointing to the same guild are expanded to prevent cross-guild leakage.
+func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string {
+ // 1. Resolve channel references: <#id> → #channel-name
+ text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string {
+ parts := channelRefRe.FindStringSubmatch(match)
+ if len(parts) < 2 {
+ return match
+ }
+ // Prefer session state cache to avoid API calls
+ if ch, err := s.State.Channel(parts[1]); err == nil {
+ return "#" + ch.Name
+ }
+ if ch, err := s.Channel(parts[1]); err == nil {
+ return "#" + ch.Name
+ }
+ return match
+ })
+
+ // 2. Expand Discord message links (max 3, same guild only)
+ matches := msgLinkRe.FindAllStringSubmatch(text, 3)
+ for _, m := range matches {
+ if len(m) < 4 {
+ continue
+ }
+ linkGuildID, channelID, messageID := m[1], m[2], m[3]
+ // Security: only expand links from the same guild
+ if linkGuildID != guildID {
+ continue
+ }
+ msg, err := s.ChannelMessage(channelID, messageID)
+ if err != nil || msg == nil || msg.Content == "" {
+ continue
+ }
+ author := "unknown"
+ if msg.Author != nil {
+ author = msg.Author.Username
+ }
+ text += fmt.Sprintf("\n[linked message from %s]: %s", author, msg.Content)
+ }
+
+ return text
+}
+
// 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 {
diff --git a/pkg/channels/discord/discord_resolve_test.go b/pkg/channels/discord/discord_resolve_test.go
new file mode 100644
index 000000000..4bc65cc18
--- /dev/null
+++ b/pkg/channels/discord/discord_resolve_test.go
@@ -0,0 +1,98 @@
+package discord
+
+import (
+ "testing"
+)
+
+func TestChannelRefRegex(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantID string
+ wantOK bool
+ }{
+ {"basic channel ref", "<#123456789>", "123456789", true},
+ {"long id", "<#9876543210123456>", "9876543210123456", true},
+ {"no match plain text", "hello world", "", false},
+ {"no match partial", "<#>", "", false},
+ {"no match letters", "<#abc>", "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ matches := channelRefRe.FindStringSubmatch(tt.input)
+ if tt.wantOK {
+ if len(matches) < 2 || matches[1] != tt.wantID {
+ t.Errorf("channelRefRe(%q) = %v, want ID %q", tt.input, matches, tt.wantID)
+ }
+ } else {
+ if len(matches) >= 2 {
+ t.Errorf("channelRefRe(%q) should not match, got %v", tt.input, matches)
+ }
+ }
+ })
+ }
+}
+
+func TestMsgLinkRegex(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantGuild string
+ wantChan string
+ wantMsg string
+ wantOK bool
+ }{
+ {
+ "discord.com link",
+ "https://discord.com/channels/111/222/333",
+ "111", "222", "333", true,
+ },
+ {
+ "discordapp.com link",
+ "https://discordapp.com/channels/111/222/333",
+ "111", "222", "333", true,
+ },
+ {
+ "real world ids",
+ "check this https://discord.com/channels/9000000000000001/9000000000000002/9000000000000003 please",
+ "9000000000000001", "9000000000000002", "9000000000000003", true,
+ },
+ {"no match http", "http://discord.com/channels/1/2/3", "", "", "", false},
+ {"no match missing segment", "https://discord.com/channels/1/2", "", "", "", false},
+ {"no match plain text", "hello world", "", "", "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ matches := msgLinkRe.FindStringSubmatch(tt.input)
+ if tt.wantOK {
+ if len(matches) < 4 {
+ t.Fatalf("msgLinkRe(%q) didn't match, want guild=%s chan=%s msg=%s",
+ tt.input, tt.wantGuild, tt.wantChan, tt.wantMsg)
+ }
+ if matches[1] != tt.wantGuild || matches[2] != tt.wantChan || matches[3] != tt.wantMsg {
+ t.Errorf("msgLinkRe(%q) = guild=%s chan=%s msg=%s, want %s/%s/%s",
+ tt.input, matches[1], matches[2], matches[3],
+ tt.wantGuild, tt.wantChan, tt.wantMsg)
+ }
+ } else {
+ if len(matches) >= 4 {
+ t.Errorf("msgLinkRe(%q) should not match, got %v", tt.input, matches)
+ }
+ }
+ })
+ }
+}
+
+func TestMsgLinkRegex_MultipleMatches(t *testing.T) {
+ input := "see https://discord.com/channels/1/2/3 and https://discord.com/channels/4/5/6 and https://discord.com/channels/7/8/9 and https://discord.com/channels/10/11/12"
+ matches := msgLinkRe.FindAllStringSubmatch(input, 3)
+ if len(matches) != 3 {
+ t.Fatalf("expected 3 matches (capped), got %d", len(matches))
+ }
+ // Verify the 3rd match is 7/8/9 (not 10/11/12)
+ if matches[2][1] != "7" || matches[2][2] != "8" || matches[2][3] != "9" {
+ t.Errorf("3rd match = %v, want guild=7 chan=8 msg=9", matches[2])
+ }
+}
diff --git a/pkg/channels/discord/discord_test.go b/pkg/channels/discord/discord_test.go
new file mode 100644
index 000000000..0cd5328f4
--- /dev/null
+++ b/pkg/channels/discord/discord_test.go
@@ -0,0 +1,91 @@
+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/feishu/common.go b/pkg/channels/feishu/common.go
index e8a057741..fbe085b73 100644
--- a/pkg/channels/feishu/common.go
+++ b/pkg/channels/feishu/common.go
@@ -1,5 +1,16 @@
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 {
@@ -7,3 +18,69 @@ func stringValue(v *string) string {
}
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
new file mode 100644
index 000000000..fefc9f7c1
--- /dev/null
+++ b/pkg/channels/feishu/common_test.go
@@ -0,0 +1,292 @@
+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
index d0ec758c6..f5e3aa224 100644
--- a/pkg/channels/feishu/feishu_32.go
+++ b/pkg/channels/feishu/feishu_32.go
@@ -16,6 +16,8 @@ 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(
@@ -25,15 +27,35 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
// Start is a stub method to satisfy the Channel interface
func (c *FeishuChannel) Start(ctx context.Context) error {
- return nil
+ return errUnsupported
}
// Stop is a stub method to satisfy the Channel interface
func (c *FeishuChannel) Stop(ctx context.Context) error {
- return nil
+ return errUnsupported
}
// Send is a stub method to satisfy the Channel interface
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- return errors.New("feishu channel is not supported on 32-bit architectures")
+ 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
index 1db1bf669..5217dd4e9 100644
--- a/pkg/channels/feishu/feishu_64.go
+++ b/pkg/channels/feishu/feishu_64.go
@@ -4,12 +4,19 @@ package feishu
import (
"context"
+ "crypto/rand"
"encoding/json"
"fmt"
+ "io"
+ "math/big"
+ "net/http"
+ "os"
+ "path/filepath"
"sync"
- "time"
+ "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"
@@ -19,6 +26,7 @@ import (
"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"
)
@@ -28,6 +36,8 @@ type FeishuChannel struct {
client *lark.Client
wsClient *larkws.Client
+ botOpenID atomic.Value // stores string; populated lazily for @mention detection
+
mu sync.Mutex
cancel context.CancelFunc
}
@@ -38,11 +48,13 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
- return &FeishuChannel{
+ ch := &FeishuChannel{
BaseChannel: base,
config: cfg,
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
- }, nil
+ }
+ ch.SetOwner(ch)
+ return ch, nil
}
func (c *FeishuChannel) Start(ctx context.Context) error {
@@ -50,6 +62,13 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
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)
@@ -93,46 +112,231 @@ func (c *FeishuChannel) Stop(ctx context.Context) error {
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")
+ return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
}
- payload, err := json.Marshal(map[string]string{"text": msg.Content})
+ // Build interactive card with markdown content
+ cardContent, err := buildMarkdownCard(msg.Content)
if err != nil {
- return fmt.Errorf("failed to marshal feishu content: %w", err)
+ 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(msg.ChatID).
- MsgType(larkim.MsgTypeText).
- Content(string(payload)).
- Uuid(fmt.Sprintf("picoclaw-%d", time.Now().UnixNano())).
+ 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: %w", channels.ErrTemporary)
+ return "", fmt.Errorf("feishu placeholder send: %w", err)
}
-
if !resp.Success() {
- return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
+ return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg)
}
- logger.DebugCF("feishu", "Feishu message sent", map[string]any{
- "chat_id": msg.ChatID,
- })
+ if resp.Data != nil && resp.Data.MessageId != nil {
+ return *resp.Data.MessageId, nil
+ }
+ return "", nil
+}
+
+// ReactToMessage implements channels.ReactionCapable.
+// Adds a reaction (randomly chosen from config) and returns an undo function to remove it.
+func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
+ // Get emoji list from config
+ emojiList := c.config.RandomReactionEmoji
+ if len(emojiList) == 0 {
+ // Default to "Pin" if no config
+ emojiList = []string{"Pin"}
+ }
+
+ // Randomly choose one from the list using crypto/rand for better distribution
+ idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(emojiList))))
+ var chosenEmoji string
+ if err != nil {
+ chosenEmoji = emojiList[0]
+ } else {
+ chosenEmoji = emojiList[idx.Int64()]
+ }
+
+ req := larkim.NewCreateMessageReactionReqBuilder().
+ MessageId(messageID).
+ Body(larkim.NewCreateMessageReactionReqBodyBuilder().
+ ReactionType(larkim.NewEmojiBuilder().EmojiType(chosenEmoji).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{
+ "emoji": chosenEmoji,
+ "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{
+ "emoji": chosenEmoji,
+ "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
@@ -151,34 +355,68 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
senderID = "unknown"
}
- content := extractFeishuMessageContent(message)
+ 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{}
- messageID := ""
- if mid := stringValue(message.MessageId); mid != "" {
- messageID = mid
+ if messageID != "" {
+ metadata["message_id"] = messageID
}
- if messageType := stringValue(message.MessageType); messageType != "" {
+ if messageType != "" {
metadata["message_type"] = messageType
}
- if chatType := stringValue(message.ChatType); chatType != "" {
+ chatType := stringValue(message.ChatType)
+ if chatType != "" {
metadata["chat_type"] = chatType
}
if sender != nil && sender.TenantKey != nil {
metadata["tenant_key"] = *sender.TenantKey
}
- chatType := stringValue(message.ChatType)
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(false, content)
+ respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
return nil
}
@@ -186,22 +424,398 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
}
logger.InfoCF("feishu", "Feishu message received", map[string]any{
- "sender_id": senderID,
- "chat_id": chatID,
- "preview": utils.Truncate(content, 80),
+ "sender_id": senderID,
+ "chat_id": chatID,
+ "message_id": messageID,
+ "preview": utils.Truncate(content, 80),
})
- senderInfo := bus.SenderInfo{
- Platform: "feishu",
- PlatformID: senderID,
- CanonicalID: identity.BuildCanonicalID("feishu", senderID),
+ 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)
}
- if !c.IsAllowedSender(senderInfo) {
- return nil
+ 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.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, senderInfo)
+ 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
}
@@ -222,20 +836,3 @@ func extractFeishuSenderID(sender *larkim.EventSender) string {
return ""
}
-
-func extractFeishuMessageContent(message *larkim.EventMessage) string {
- if message == nil || message.Content == nil || *message.Content == "" {
- return ""
- }
-
- if message.MessageType != nil && *message.MessageType == larkim.MsgTypeText {
- var textPayload struct {
- Text string `json:"text"`
- }
- if err := json.Unmarshal([]byte(*message.Content), &textPayload); err == nil {
- return textPayload.Text
- }
- }
-
- return *message.Content
-}
diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go
new file mode 100644
index 000000000..dc3eab2e7
--- /dev/null
+++ b/pkg/channels/feishu/feishu_64_test.go
@@ -0,0 +1,256 @@
+//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/interfaces.go b/pkg/channels/interfaces.go
index 48516260e..5b28f8213 100644
--- a/pkg/channels/interfaces.go
+++ b/pkg/channels/interfaces.go
@@ -1,6 +1,10 @@
package channels
-import "context"
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/commands"
+)
// TypingCapable — channels that can show a typing/thinking indicator.
// StartTyping begins the indicator and returns a stop function.
@@ -52,3 +56,10 @@ type PlaceholderRecorder interface {
RecordTypingStop(channel, chatID string, stop func())
RecordReactionUndo(channel, chatID string, undo func())
}
+
+// CommandRegistrarCapable is implemented by channels that can register
+// command menus with their upstream platform (e.g. Telegram BotCommand).
+// Channels that do not support platform-level command menus can ignore it.
+type CommandRegistrarCapable interface {
+ RegisterCommands(ctx context.Context, defs []commands.Definition) error
+}
diff --git a/pkg/channels/interfaces_command_test.go b/pkg/channels/interfaces_command_test.go
new file mode 100644
index 000000000..de5502644
--- /dev/null
+++ b/pkg/channels/interfaces_command_test.go
@@ -0,0 +1,16 @@
+package channels
+
+import (
+ "context"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/commands"
+)
+
+type mockRegistrar struct{}
+
+func (mockRegistrar) RegisterCommands(context.Context, []commands.Definition) error { return nil }
+
+func TestCommandRegistrarCapable_Compiles(t *testing.T) {
+ var _ CommandRegistrarCapable = mockRegistrar{}
+}
diff --git a/pkg/channels/irc/handler.go b/pkg/channels/irc/handler.go
new file mode 100644
index 000000000..aca4ddd11
--- /dev/null
+++ b/pkg/channels/irc/handler.go
@@ -0,0 +1,154 @@
+package irc
+
+import (
+ "fmt"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/ergochat/irc-go/ircevent"
+ "github.com/ergochat/irc-go/ircmsg"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/identity"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// onConnect is called after a successful connection (and on reconnect).
+func (c *IRCChannel) onConnect(conn *ircevent.Connection) {
+ // NickServ auth (only if SASL is not configured)
+ if c.config.NickServPassword != "" && c.config.SASLUser == "" {
+ conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword)
+ }
+
+ // Join configured channels
+ for _, ch := range c.config.Channels {
+ conn.Join(ch)
+ logger.InfoCF("irc", "Joined IRC channel", map[string]any{
+ "channel": ch,
+ })
+ }
+}
+
+// onPrivmsg handles incoming PRIVMSG events.
+func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) {
+ if len(e.Params) < 2 {
+ return
+ }
+
+ nick := e.Nick()
+ currentNick := conn.CurrentNick()
+
+ // Ignore own messages
+ if strings.EqualFold(nick, currentNick) {
+ return
+ }
+
+ target := e.Params[0] // channel name or bot's nick
+ content := e.Params[1] // message text
+
+ // Determine if this is a DM or channel message
+ isDM := !strings.HasPrefix(target, "#") && !strings.HasPrefix(target, "&")
+
+ var chatID string
+ var peer bus.Peer
+
+ if isDM {
+ chatID = nick
+ peer = bus.Peer{Kind: "direct", ID: nick}
+ } else {
+ chatID = target
+ peer = bus.Peer{Kind: "group", ID: target}
+ }
+
+ sender := bus.SenderInfo{
+ Platform: "irc",
+ PlatformID: nick,
+ CanonicalID: identity.BuildCanonicalID("irc", nick),
+ Username: nick,
+ DisplayName: nick,
+ }
+
+ if !c.IsAllowedSender(sender) {
+ return
+ }
+
+ // For channel messages, check group trigger (mention detection)
+ if !isDM {
+ isMentioned := isBotMentioned(content, currentNick)
+ if isMentioned {
+ content = stripBotMention(content, currentNick)
+ }
+ respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
+ if !respond {
+ return
+ }
+ content = cleaned
+ }
+
+ if strings.TrimSpace(content) == "" {
+ return
+ }
+
+ messageID := fmt.Sprintf("%s-%d", nick, time.Now().UnixNano())
+
+ metadata := map[string]string{
+ "platform": "irc",
+ "server": c.config.Server,
+ }
+ if !isDM {
+ metadata["channel"] = target
+ }
+
+ c.HandleMessage(c.ctx, peer, messageID, nick, chatID, content, nil, metadata, sender)
+}
+
+// nickMentionedAt returns the byte index where botNick is mentioned in content
+// with word-boundary checks, or -1 if not found. Also checks for "nick:" /
+// "nick," prefix convention.
+func nickMentionedAt(content, botNick string) int {
+ lower := strings.ToLower(content)
+ lowerNick := strings.ToLower(botNick)
+
+ // "nick:" or "nick," at start (most common IRC convention)
+ if strings.HasPrefix(lower, lowerNick+":") || strings.HasPrefix(lower, lowerNick+",") {
+ return 0
+ }
+
+ // Word-boundary match anywhere in the message
+ idx := strings.Index(lower, lowerNick)
+ if idx < 0 {
+ return -1
+ }
+ runes := []rune(lower)
+ nickRunes := []rune(lowerNick)
+ endIdx := idx + len(string(nickRunes))
+ before := idx == 0 || !unicode.IsLetter(runes[idx-1]) && !unicode.IsDigit(runes[idx-1])
+ after := endIdx >= len(lower) || !unicode.IsLetter(rune(lower[endIdx])) && !unicode.IsDigit(rune(lower[endIdx]))
+ if before && after {
+ return idx
+ }
+ return -1
+}
+
+// isBotMentioned checks if the bot's nick appears in the message.
+func isBotMentioned(content, botNick string) bool {
+ return nickMentionedAt(content, botNick) >= 0
+}
+
+// stripBotMention removes "nick: " or "nick, " prefix from content.
+func stripBotMention(content, botNick string) string {
+ idx := nickMentionedAt(content, botNick)
+ if idx != 0 {
+ return content
+ }
+ lowerNick := strings.ToLower(botNick)
+ lower := strings.ToLower(content)
+ for _, sep := range []string{":", ","} {
+ prefix := lowerNick + sep
+ if strings.HasPrefix(lower, prefix) {
+ return strings.TrimSpace(content[len(prefix):])
+ }
+ }
+ return content
+}
diff --git a/pkg/channels/irc/init.go b/pkg/channels/irc/init.go
new file mode 100644
index 000000000..221d41b62
--- /dev/null
+++ b/pkg/channels/irc/init.go
@@ -0,0 +1,16 @@
+package irc
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("irc", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ if !cfg.Channels.IRC.Enabled {
+ return nil, nil
+ }
+ return NewIRCChannel(cfg.Channels.IRC, b)
+ })
+}
diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go
new file mode 100644
index 000000000..28c59b540
--- /dev/null
+++ b/pkg/channels/irc/irc.go
@@ -0,0 +1,194 @@
+package irc
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "strings"
+
+ "github.com/ergochat/irc-go/ircevent"
+ "github.com/ergochat/irc-go/ircmsg"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// IRCChannel implements the Channel interface for IRC servers.
+type IRCChannel struct {
+ *channels.BaseChannel
+ config config.IRCConfig
+ conn *ircevent.Connection
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// NewIRCChannel creates a new IRC channel.
+func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) {
+ if cfg.Server == "" {
+ return nil, fmt.Errorf("irc server is required")
+ }
+ if cfg.Nick == "" {
+ return nil, fmt.Errorf("irc nick is required")
+ }
+
+ base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom,
+ channels.WithMaxMessageLength(400),
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
+
+ return &IRCChannel{
+ BaseChannel: base,
+ config: cfg,
+ }, nil
+}
+
+// Start connects to the IRC server and begins listening.
+func (c *IRCChannel) Start(ctx context.Context) error {
+ logger.InfoC("irc", "Starting IRC channel")
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ user := c.config.User
+ if user == "" {
+ user = c.config.Nick
+ }
+ realName := c.config.RealName
+ if realName == "" {
+ realName = c.config.Nick
+ }
+ caps := []string(c.config.RequestCaps)
+ if len(caps) == 0 {
+ caps = []string{"server-time", "message-tags"}
+ }
+
+ conn := &ircevent.Connection{
+ Server: c.config.Server,
+ Nick: c.config.Nick,
+ User: user,
+ RealName: realName,
+ Password: c.config.Password,
+ UseTLS: c.config.TLS,
+ RequestCaps: caps,
+ QuitMessage: "Goodbye",
+ Debug: false,
+ Log: nil,
+ }
+
+ if c.config.TLS {
+ conn.TLSConfig = &tls.Config{
+ ServerName: extractHost(c.config.Server),
+ }
+ }
+
+ // SASL auth (takes priority over NickServ)
+ if c.config.SASLUser != "" && c.config.SASLPassword != "" {
+ conn.SASLLogin = c.config.SASLUser
+ conn.SASLPassword = c.config.SASLPassword
+ }
+
+ // Register event handlers
+ conn.AddConnectCallback(func(e ircmsg.Message) {
+ c.onConnect(conn)
+ })
+ conn.AddCallback("PRIVMSG", func(e ircmsg.Message) {
+ c.onPrivmsg(conn, e)
+ })
+
+ if err := conn.Connect(); err != nil {
+ return fmt.Errorf("irc connect failed: %w", err)
+ }
+
+ c.conn = conn
+
+ // ircevent.Connection.Loop() handles reconnection internally.
+ go conn.Loop()
+
+ c.SetRunning(true)
+ logger.InfoCF("irc", "IRC channel started", map[string]any{
+ "server": c.config.Server,
+ "nick": c.config.Nick,
+ })
+ return nil
+}
+
+// Stop disconnects from the IRC server.
+func (c *IRCChannel) Stop(ctx context.Context) error {
+ logger.InfoC("irc", "Stopping IRC channel")
+ c.SetRunning(false)
+
+ if c.conn != nil {
+ c.conn.Quit()
+ }
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ logger.InfoC("irc", "IRC channel stopped")
+ return nil
+}
+
+// Send sends a message to an IRC channel or user.
+func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ target := msg.ChatID
+ if target == "" {
+ return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
+ }
+
+ if strings.TrimSpace(msg.Content) == "" {
+ return nil
+ }
+
+ // Send each line separately (IRC is line-oriented)
+ lines := strings.Split(msg.Content, "\n")
+ for _, line := range lines {
+ line = strings.TrimRight(line, "\r")
+ if line == "" {
+ continue
+ }
+ c.conn.Privmsg(target, line)
+ }
+
+ logger.DebugCF("irc", "Message sent", map[string]any{
+ "target": target,
+ "lines": len(lines),
+ })
+ return nil
+}
+
+// StartTyping implements channels.TypingCapable using IRCv3 +typing client tag.
+// Requires typing.enabled in config and server support for message-tags capability.
+func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
+ noop := func() {}
+
+ if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil {
+ return noop, nil
+ }
+
+ // Check if server supports message-tags (required for TAGMSG)
+ if _, ok := c.conn.AcknowledgedCaps()["message-tags"]; !ok {
+ return noop, nil
+ }
+
+ c.conn.SendWithTags(map[string]string{"+typing": "active"}, "TAGMSG", chatID)
+
+ return func() {
+ if c.IsRunning() && c.conn != nil {
+ c.conn.SendWithTags(map[string]string{"+typing": "done"}, "TAGMSG", chatID)
+ }
+ }, nil
+}
+
+// extractHost returns the hostname portion of a host:port string.
+func extractHost(server string) string {
+ host, _, found := strings.Cut(server, ":")
+ if found {
+ return host
+ }
+ return server
+}
diff --git a/pkg/channels/irc/irc_test.go b/pkg/channels/irc/irc_test.go
new file mode 100644
index 000000000..168252a4d
--- /dev/null
+++ b/pkg/channels/irc/irc_test.go
@@ -0,0 +1,145 @@
+package irc
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestNewIRCChannel(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+
+ t.Run("missing server", func(t *testing.T) {
+ cfg := config.IRCConfig{Nick: "bot"}
+ _, err := NewIRCChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing server, got nil")
+ }
+ })
+
+ t.Run("missing nick", func(t *testing.T) {
+ cfg := config.IRCConfig{Server: "irc.example.com:6667"}
+ _, err := NewIRCChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing nick, got nil")
+ }
+ })
+
+ t.Run("valid config", func(t *testing.T) {
+ cfg := config.IRCConfig{
+ Server: "irc.example.com:6667",
+ Nick: "testbot",
+ Channels: []string{"#test"},
+ }
+ ch, err := NewIRCChannel(cfg, msgBus)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ch.Name() != "irc" {
+ t.Errorf("Name() = %q, want %q", ch.Name(), "irc")
+ }
+ if ch.IsRunning() {
+ t.Error("new channel should not be running")
+ }
+ })
+}
+
+func TestExtractHost(t *testing.T) {
+ tests := []struct {
+ server string
+ want string
+ }{
+ {"irc.libera.chat:6697", "irc.libera.chat"},
+ {"localhost:6667", "localhost"},
+ {"irc.example.com", "irc.example.com"},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.server, func(t *testing.T) {
+ got := extractHost(tt.server)
+ if got != tt.want {
+ t.Errorf("extractHost(%q) = %q, want %q", tt.server, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestNickMentionedAt(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ nick string
+ want int
+ }{
+ {"colon prefix", "bot: hello", "bot", 0},
+ {"comma prefix", "bot, hello", "bot", 0},
+ {"case insensitive", "BOT: hello", "bot", 0},
+ {"word boundary mid", "hey bot what's up", "bot", 4},
+ {"no mention", "hello world", "bot", -1},
+ {"substring mismatch", "robotics are cool", "bot", -1},
+ {"nick at end", "hello bot", "bot", 6},
+ {"empty content", "", "bot", -1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := nickMentionedAt(tt.content, tt.nick)
+ if got != tt.want {
+ t.Errorf("nickMentionedAt(%q, %q) = %d, want %d", tt.content, tt.nick, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsBotMentioned(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ nick string
+ want bool
+ }{
+ {"colon prefix", "bot: hello", "bot", true},
+ {"comma prefix", "bot, hello", "bot", true},
+ {"case insensitive", "BOT: hello", "bot", true},
+ {"word boundary mid", "hey bot what's up", "bot", true},
+ {"no mention", "hello world", "bot", false},
+ {"substring mismatch", "robotics are cool", "bot", false},
+ {"nick at end", "hello bot", "bot", true},
+ {"empty content", "", "bot", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isBotMentioned(tt.content, tt.nick)
+ if got != tt.want {
+ t.Errorf("isBotMentioned(%q, %q) = %v, want %v", tt.content, tt.nick, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestStripBotMention(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ nick string
+ want string
+ }{
+ {"colon prefix", "bot: hello there", "bot", "hello there"},
+ {"comma prefix", "bot, help me", "bot", "help me"},
+ {"case insensitive", "BOT: hello", "bot", "hello"},
+ {"no prefix match", "hello bot", "bot", "hello bot"},
+ {"only prefix", "bot:", "bot", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := stripBotMention(tt.content, tt.nick)
+ if got != tt.want {
+ t.Errorf("stripBotMention(%q, %q) = %q, want %q", tt.content, tt.nick, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go
index 398f12e6b..b36350a06 100644
--- a/pkg/channels/line/line.go
+++ b/pkg/channels/line/line.go
@@ -654,7 +654,10 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- respBody, _ := io.ReadAll(resp.Body)
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err))
+ }
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
}
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 6037b29b6..f2217a829 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -77,7 +77,9 @@ var channelRateConfig = map[string]float64{
"telegram": 20,
"discord": 1,
"slack": 1,
+ "matrix": 2,
"line": 10,
+ "irc": 2,
}
type channelWorker struct {
@@ -302,6 +304,13 @@ func (m *Manager) initChannels() error {
m.initChannel("slack", "Slack")
}
+ if m.config.Channels.Matrix.Enabled &&
+ m.config.Channels.Matrix.Homeserver != "" &&
+ m.config.Channels.Matrix.UserID != "" &&
+ m.config.Channels.Matrix.AccessToken != "" {
+ m.initChannel("matrix", "Matrix")
+ }
+
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
m.initChannel("line", "LINE")
}
@@ -314,6 +323,10 @@ func (m *Manager) initChannels() error {
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")
}
@@ -322,6 +335,10 @@ func (m *Manager) initChannels() error {
m.initChannel("pico", "Pico")
}
+ if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" {
+ m.initChannel("irc", "IRC")
+ }
+
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})
@@ -786,86 +803,88 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork
})
}
-func (m *Manager) dispatchOutbound(ctx context.Context) {
- logger.InfoC("channels", "Outbound dispatcher started")
+func dispatchLoop[M any](
+ ctx context.Context,
+ m *Manager,
+ subscribe func(context.Context) (M, bool),
+ getChannel func(M) string,
+ enqueue func(context.Context, *channelWorker, M) bool,
+ startMsg, stopMsg, unknownMsg, noWorkerMsg string,
+) {
+ logger.InfoC("channels", startMsg)
for {
- msg, ok := m.bus.SubscribeOutbound(ctx)
+ msg, ok := subscribe(ctx)
if !ok {
- logger.InfoC("channels", "Outbound dispatcher stopped")
+ logger.InfoC("channels", stopMsg)
return
}
+ channel := getChannel(msg)
+
// Silently skip internal channels
- if constants.IsInternalChannel(msg.Channel) {
+ if constants.IsInternalChannel(channel) {
continue
}
m.mu.RLock()
- _, exists := m.channels[msg.Channel]
- w, wExists := m.workers[msg.Channel]
+ _, exists := m.channels[channel]
+ w, wExists := m.workers[channel]
m.mu.RUnlock()
if !exists {
- logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{
- "channel": msg.Channel,
- })
+ logger.WarnCF("channels", unknownMsg, map[string]any{"channel": channel})
continue
}
if wExists && w != nil {
- select {
- case w.queue <- msg:
- case <-ctx.Done():
+ if !enqueue(ctx, w, msg) {
return
}
} else if exists {
- logger.WarnCF("channels", "Channel has no active worker, skipping message", map[string]any{
- "channel": msg.Channel,
- })
+ logger.WarnCF("channels", noWorkerMsg, map[string]any{"channel": channel})
}
}
}
+func (m *Manager) dispatchOutbound(ctx context.Context) {
+ dispatchLoop(
+ ctx, m,
+ m.bus.SubscribeOutbound,
+ func(msg bus.OutboundMessage) string { return msg.Channel },
+ func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool {
+ select {
+ case w.queue <- msg:
+ return true
+ case <-ctx.Done():
+ return false
+ }
+ },
+ "Outbound dispatcher started",
+ "Outbound dispatcher stopped",
+ "Unknown channel for outbound message",
+ "Channel has no active worker, skipping message",
+ )
+}
+
func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
- logger.InfoC("channels", "Outbound media dispatcher started")
-
- for {
- msg, ok := m.bus.SubscribeOutboundMedia(ctx)
- if !ok {
- logger.InfoC("channels", "Outbound media dispatcher stopped")
- return
- }
-
- // Silently skip internal channels
- if constants.IsInternalChannel(msg.Channel) {
- continue
- }
-
- m.mu.RLock()
- _, exists := m.channels[msg.Channel]
- w, wExists := m.workers[msg.Channel]
- m.mu.RUnlock()
-
- if !exists {
- logger.WarnCF("channels", "Unknown channel for outbound media message", map[string]any{
- "channel": msg.Channel,
- })
- continue
- }
-
- if wExists && w != nil {
+ dispatchLoop(
+ ctx, m,
+ m.bus.SubscribeOutboundMedia,
+ func(msg bus.OutboundMediaMessage) string { return msg.Channel },
+ func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool {
select {
case w.mediaQueue <- msg:
+ return true
case <-ctx.Done():
- return
+ return false
}
- } else if exists {
- logger.WarnCF("channels", "Channel has no active worker, skipping media message", map[string]any{
- "channel": msg.Channel,
- })
- }
- }
+ },
+ "Outbound media dispatcher started",
+ "Outbound media dispatcher stopped",
+ "Unknown channel for outbound media message",
+ "Channel has no active worker, skipping media message",
+ )
}
// runMediaWorker processes outbound media messages for a single channel.
diff --git a/pkg/channels/matrix/init.go b/pkg/channels/matrix/init.go
new file mode 100644
index 000000000..6677f855e
--- /dev/null
+++ b/pkg/channels/matrix/init.go
@@ -0,0 +1,13 @@
+package matrix
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewMatrixChannel(cfg.Channels.Matrix, b)
+ })
+}
diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go
new file mode 100644
index 000000000..d51eee8fb
--- /dev/null
+++ b/pkg/channels/matrix/matrix.go
@@ -0,0 +1,1115 @@
+package matrix
+
+import (
+ "context"
+ "fmt"
+ "html"
+ "mime"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+
+ "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"
+)
+
+const (
+ typingRefreshInterval = 20 * time.Second
+ typingServerTTL = 30 * time.Second
+ roomKindCacheTTL = 5 * time.Minute
+ roomKindCacheCleanupPeriod = 1 * time.Minute
+ roomKindCacheMaxEntries = 2048
+
+ matrixMediaTempDirName = "picoclaw_media"
+)
+
+var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`)
+
+type roomKindCacheEntry struct {
+ isGroup bool
+ expiresAt time.Time
+ touchedAt time.Time
+}
+
+type roomKindCache struct {
+ mu sync.Mutex
+ entries map[string]roomKindCacheEntry
+ maxEntries int
+ ttl time.Duration
+}
+
+func newRoomKindCache(maxEntries int, ttl time.Duration) *roomKindCache {
+ if maxEntries <= 0 {
+ maxEntries = roomKindCacheMaxEntries
+ }
+ if ttl <= 0 {
+ ttl = roomKindCacheTTL
+ }
+
+ return &roomKindCache{
+ entries: make(map[string]roomKindCacheEntry),
+ maxEntries: maxEntries,
+ ttl: ttl,
+ }
+}
+
+func (c *roomKindCache) get(roomID string, now time.Time) (bool, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ entry, ok := c.entries[roomID]
+ if !ok {
+ return false, false
+ }
+ if !entry.expiresAt.After(now) {
+ delete(c.entries, roomID)
+ return false, false
+ }
+
+ return entry.isGroup, true
+}
+
+func (c *roomKindCache) set(roomID string, isGroup bool, now time.Time) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if entry, ok := c.entries[roomID]; ok {
+ entry.isGroup = isGroup
+ entry.expiresAt = now.Add(c.ttl)
+ entry.touchedAt = now
+ c.entries[roomID] = entry
+ return
+ }
+
+ c.cleanupExpiredLocked(now)
+ for len(c.entries) >= c.maxEntries {
+ if !c.evictOldestLocked() {
+ break
+ }
+ }
+
+ c.entries[roomID] = roomKindCacheEntry{
+ isGroup: isGroup,
+ expiresAt: now.Add(c.ttl),
+ touchedAt: now,
+ }
+}
+
+func (c *roomKindCache) cleanupExpired(now time.Time) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.cleanupExpiredLocked(now)
+}
+
+func (c *roomKindCache) cleanupExpiredLocked(now time.Time) int {
+ removed := 0
+ for roomID, entry := range c.entries {
+ if !entry.expiresAt.After(now) {
+ delete(c.entries, roomID)
+ removed++
+ }
+ }
+ return removed
+}
+
+func (c *roomKindCache) evictOldestLocked() bool {
+ if len(c.entries) == 0 {
+ return false
+ }
+
+ var (
+ oldestRoomID string
+ oldestAt time.Time
+ )
+
+ for roomID, entry := range c.entries {
+ if oldestRoomID == "" || entry.touchedAt.Before(oldestAt) {
+ oldestRoomID = roomID
+ oldestAt = entry.touchedAt
+ }
+ }
+
+ delete(c.entries, oldestRoomID)
+ return true
+}
+
+type typingSession struct {
+ stopCh chan struct{}
+ once sync.Once
+}
+
+func newTypingSession() *typingSession {
+ return &typingSession{
+ stopCh: make(chan struct{}),
+ }
+}
+
+func (s *typingSession) stop() {
+ s.once.Do(func() {
+ close(s.stopCh)
+ })
+}
+
+// MatrixChannel implements the Channel interface for Matrix.
+type MatrixChannel struct {
+ *channels.BaseChannel
+
+ client *mautrix.Client
+ config config.MatrixConfig
+ syncer *mautrix.DefaultSyncer
+
+ ctx context.Context
+ cancel context.CancelFunc
+ startTime time.Time
+
+ typingMu sync.Mutex
+ typingSessions map[string]*typingSession // roomID -> session
+
+ roomKindCache *roomKindCache
+ localpartMentionR *regexp.Regexp
+}
+
+func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) {
+ homeserver := strings.TrimSpace(cfg.Homeserver)
+ userID := strings.TrimSpace(cfg.UserID)
+ accessToken := strings.TrimSpace(cfg.AccessToken)
+ if homeserver == "" {
+ return nil, fmt.Errorf("matrix homeserver is required")
+ }
+ if userID == "" {
+ return nil, fmt.Errorf("matrix user_id is required")
+ }
+ if accessToken == "" {
+ return nil, fmt.Errorf("matrix access_token is required")
+ }
+
+ client, err := mautrix.NewClient(homeserver, id.UserID(userID), accessToken)
+ if err != nil {
+ return nil, fmt.Errorf("create matrix client: %w", err)
+ }
+ if cfg.DeviceID != "" {
+ client.DeviceID = id.DeviceID(cfg.DeviceID)
+ }
+
+ syncer, ok := client.Syncer.(*mautrix.DefaultSyncer)
+ if !ok {
+ return nil, fmt.Errorf("matrix syncer is not *mautrix.DefaultSyncer")
+ }
+
+ base := channels.NewBaseChannel(
+ "matrix",
+ cfg,
+ messageBus,
+ cfg.AllowFrom,
+ channels.WithMaxMessageLength(65536),
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
+
+ return &MatrixChannel{
+ BaseChannel: base,
+ client: client,
+ config: cfg,
+ syncer: syncer,
+ typingSessions: make(map[string]*typingSession),
+ startTime: time.Now(),
+ roomKindCache: newRoomKindCache(roomKindCacheMaxEntries, roomKindCacheTTL),
+ localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)),
+ typingMu: sync.Mutex{},
+ }, nil
+}
+
+func (c *MatrixChannel) Start(ctx context.Context) error {
+ logger.InfoC("matrix", "Starting Matrix channel")
+
+ c.ctx, c.cancel = context.WithCancel(ctx)
+ c.startTime = time.Now()
+
+ c.syncer.OnEventType(event.EventMessage, c.handleMessageEvent)
+ c.syncer.OnEventType(event.StateMember, c.handleMemberEvent)
+
+ c.SetRunning(true)
+ go c.runRoomKindCacheJanitor(c.ctx)
+
+ go func() {
+ if err := c.client.SyncWithContext(c.ctx); err != nil && c.ctx.Err() == nil {
+ logger.ErrorCF("matrix", "Matrix sync stopped unexpectedly", map[string]any{
+ "error": err.Error(),
+ })
+ }
+ }()
+
+ logger.InfoC("matrix", "Matrix channel started")
+ return nil
+}
+
+func (c *MatrixChannel) Stop(ctx context.Context) error {
+ logger.InfoC("matrix", "Stopping Matrix channel")
+ c.SetRunning(false)
+
+ if c.cancel != nil {
+ c.cancel()
+ }
+ c.stopTypingSessions(ctx)
+
+ logger.InfoC("matrix", "Matrix channel stopped")
+ return nil
+}
+
+func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ roomID := id.RoomID(strings.TrimSpace(msg.ChatID))
+ if roomID == "" {
+ return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed)
+ }
+
+ content := strings.TrimSpace(msg.Content)
+ if content == "" {
+ return nil
+ }
+
+ _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
+ MsgType: event.MsgText,
+ Body: content,
+ })
+ if err != nil {
+ return fmt.Errorf("matrix send: %w", channels.ErrTemporary)
+ }
+ return nil
+}
+
+// SendMedia implements channels.MediaSender.
+func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+ sendCtx := ctx
+ if sendCtx == nil {
+ sendCtx = context.Background()
+ }
+
+ roomID := id.RoomID(strings.TrimSpace(msg.ChatID))
+ if roomID == "" {
+ return fmt.Errorf("matrix room 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 := sendCtx.Err(); err != nil {
+ return err
+ }
+
+ localPath, meta, err := store.ResolveWithMeta(part.Ref)
+ if err != nil {
+ logger.ErrorCF("matrix", "Failed to resolve media ref", map[string]any{
+ "ref": part.Ref,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ fileInfo, err := os.Stat(localPath)
+ if err != nil {
+ logger.ErrorCF("matrix", "Failed to stat media file", map[string]any{
+ "path": localPath,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ file, err := os.Open(localPath)
+ if err != nil {
+ logger.ErrorCF("matrix", "Failed to open media file", map[string]any{
+ "path": localPath,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ filename := strings.TrimSpace(part.Filename)
+ if filename == "" {
+ filename = strings.TrimSpace(meta.Filename)
+ }
+ if filename == "" {
+ filename = filepath.Base(localPath)
+ }
+ if filename == "" {
+ filename = "file"
+ }
+
+ contentType := strings.TrimSpace(part.ContentType)
+ if contentType == "" {
+ contentType = strings.TrimSpace(meta.ContentType)
+ }
+ if contentType == "" {
+ contentType = mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
+ }
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+
+ uploadResp, err := c.client.UploadMedia(sendCtx, mautrix.ReqUploadMedia{
+ Content: file,
+ ContentLength: fileInfo.Size(),
+ ContentType: contentType,
+ FileName: filename,
+ })
+ file.Close()
+ if err != nil {
+ logger.ErrorCF("matrix", "Failed to upload media", map[string]any{
+ "path": localPath,
+ "type": part.Type,
+ "error": err.Error(),
+ })
+ return fmt.Errorf("matrix upload media: %w", channels.ErrTemporary)
+ }
+
+ msgType := matrixOutboundMsgType(part.Type, filename, contentType)
+ content := matrixOutboundContent(
+ part.Caption,
+ filename,
+ msgType,
+ contentType,
+ fileInfo.Size(),
+ uploadResp.ContentURI.CUString(),
+ )
+
+ if _, err := c.client.SendMessageEvent(sendCtx, roomID, event.EventMessage, content); err != nil {
+ logger.ErrorCF("matrix", "Failed to send media message", map[string]any{
+ "room_id": roomID.String(),
+ "type": msgType,
+ "error": err.Error(),
+ })
+ return fmt.Errorf("matrix send media: %w", channels.ErrTemporary)
+ }
+ }
+
+ return nil
+}
+
+// StartTyping implements channels.TypingCapable.
+func (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
+ if !c.IsRunning() {
+ return func() {}, nil
+ }
+
+ roomID := id.RoomID(strings.TrimSpace(chatID))
+ if roomID == "" {
+ return func() {}, fmt.Errorf("matrix room ID is empty")
+ }
+
+ session := newTypingSession()
+
+ c.typingMu.Lock()
+ if prev := c.typingSessions[chatID]; prev != nil {
+ prev.stop()
+ }
+ c.typingSessions[chatID] = session
+ c.typingMu.Unlock()
+
+ parent := c.baseContext()
+ go c.typingLoop(parent, roomID, session)
+
+ var once sync.Once
+ stop := func() {
+ once.Do(func() {
+ session.stop()
+ c.typingMu.Lock()
+ if current := c.typingSessions[chatID]; current == session {
+ delete(c.typingSessions, chatID)
+ }
+ c.typingMu.Unlock()
+ _, _ = c.client.UserTyping(context.Background(), roomID, false, 0)
+ })
+ }
+
+ return stop, nil
+}
+
+// SendPlaceholder implements channels.PlaceholderCapable.
+func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
+ if !c.config.Placeholder.Enabled {
+ return "", nil
+ }
+
+ roomID := id.RoomID(strings.TrimSpace(chatID))
+ if roomID == "" {
+ return "", fmt.Errorf("matrix room ID is empty")
+ }
+
+ text := strings.TrimSpace(c.config.Placeholder.Text)
+ if text == "" {
+ text = "Thinking... 💭"
+ }
+
+ resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
+ MsgType: event.MsgNotice,
+ Body: text,
+ })
+ if err != nil {
+ return "", err
+ }
+
+ return resp.EventID.String(), nil
+}
+
+// EditMessage implements channels.MessageEditor.
+func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
+ roomID := id.RoomID(strings.TrimSpace(chatID))
+ if roomID == "" {
+ return fmt.Errorf("matrix room ID is empty")
+ }
+ if strings.TrimSpace(messageID) == "" {
+ return fmt.Errorf("matrix message ID is empty")
+ }
+
+ editContent := &event.MessageEventContent{
+ MsgType: event.MsgText,
+ Body: content,
+ }
+ editContent.SetEdit(id.EventID(messageID))
+
+ _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent)
+ return err
+}
+
+func (c *MatrixChannel) handleMemberEvent(ctx context.Context, evt *event.Event) {
+ if !c.config.JoinOnInvite {
+ return
+ }
+ if evt == nil {
+ return
+ }
+
+ member := evt.Content.AsMember()
+ if member.Membership != event.MembershipInvite {
+ return
+ }
+ if evt.GetStateKey() != c.client.UserID.String() {
+ return
+ }
+
+ _, err := c.client.JoinRoomByID(c.baseContext(), evt.RoomID)
+ if err != nil {
+ logger.WarnCF("matrix", "Failed to auto-join invited room", map[string]any{
+ "room_id": evt.RoomID.String(),
+ "error": err.Error(),
+ })
+ return
+ }
+
+ logger.InfoCF("matrix", "Joined room after invite", map[string]any{
+ "room_id": evt.RoomID.String(),
+ })
+}
+
+func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event) {
+ if evt == nil {
+ return
+ }
+
+ // Ignore our own messages.
+ if evt.Sender == c.client.UserID {
+ return
+ }
+
+ // Ignore historical events on first sync.
+ if time.UnixMilli(evt.Timestamp).Before(c.startTime) {
+ return
+ }
+
+ msgEvt := evt.Content.AsMessage()
+ if msgEvt == nil {
+ return
+ }
+
+ // Ignore edits.
+ if msgEvt.RelatesTo != nil && msgEvt.RelatesTo.GetReplaceID() != "" {
+ return
+ }
+
+ roomID := evt.RoomID.String()
+ scope := channels.BuildMediaScope("matrix", roomID, evt.ID.String())
+
+ content, mediaPaths, ok := c.extractInboundContent(ctx, msgEvt, scope)
+ if !ok {
+ return
+ }
+ content = strings.TrimSpace(content)
+ if content == "" && len(mediaPaths) == 0 {
+ return
+ }
+
+ senderID := evt.Sender.String()
+ sender := bus.SenderInfo{
+ Platform: "matrix",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("matrix", senderID),
+ Username: senderID,
+ DisplayName: senderID,
+ }
+
+ if !c.IsAllowedSender(sender) {
+ logger.DebugCF("matrix", "Message rejected by allowlist", map[string]any{
+ "sender_id": senderID,
+ })
+ return
+ }
+
+ isGroup := c.isGroupRoom(ctx, evt.RoomID)
+ if isGroup {
+ isMentioned := c.isBotMentioned(msgEvt)
+ if isMentioned {
+ content = c.stripSelfMention(content)
+ }
+ respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
+ if !respond {
+ logger.DebugCF("matrix", "Ignoring group message by trigger rules", map[string]any{
+ "room_id": roomID,
+ "is_mentioned": isMentioned,
+ "mention_only": c.config.GroupTrigger.MentionOnly,
+ "prefixes": c.config.GroupTrigger.Prefixes,
+ })
+ return
+ }
+ content = cleaned
+ } else {
+ content = c.stripSelfMention(content)
+ }
+
+ content = strings.TrimSpace(content)
+ if content == "" {
+ return
+ }
+
+ peerKind := "direct"
+ peerID := senderID
+ if isGroup {
+ peerKind = "group"
+ peerID = roomID
+ }
+
+ metadata := map[string]string{
+ "room_id": roomID,
+ "timestamp": fmt.Sprintf("%d", evt.Timestamp),
+ "is_group": fmt.Sprintf("%t", isGroup),
+ "sender_raw": senderID,
+ }
+ if replyTo := msgEvt.GetRelatesTo().GetReplyTo(); replyTo != "" {
+ metadata["reply_to_msg_id"] = replyTo.String()
+ }
+
+ c.HandleMessage(
+ c.baseContext(),
+ bus.Peer{Kind: peerKind, ID: peerID},
+ evt.ID.String(),
+ senderID,
+ roomID,
+ content,
+ mediaPaths,
+ metadata,
+ sender,
+ )
+}
+
+func (c *MatrixChannel) extractInboundContent(
+ ctx context.Context,
+ msgEvt *event.MessageEventContent,
+ scope string,
+) (string, []string, bool) {
+ switch msgEvt.MsgType {
+ case event.MsgText, event.MsgNotice:
+ return msgEvt.Body, nil, true
+ case event.MsgImage, event.MsgAudio, event.MsgVideo, event.MsgFile:
+ return c.extractInboundMedia(ctx, msgEvt, scope)
+ default:
+ logger.DebugCF("matrix", "Ignoring unsupported matrix msgtype", map[string]any{
+ "msgtype": msgEvt.MsgType,
+ })
+ return "", nil, false
+ }
+}
+
+func (c *MatrixChannel) extractInboundMedia(
+ ctx context.Context,
+ msgEvt *event.MessageEventContent,
+ scope string,
+) (string, []string, bool) {
+ mediaKind := matrixMediaKind(msgEvt.MsgType)
+ label := matrixMediaLabel(msgEvt, mediaKind)
+ content := fmt.Sprintf("[%s: %s]", mediaKind, label)
+ if caption := strings.TrimSpace(msgEvt.GetCaption()); caption != "" {
+ content = caption + "\n" + content
+ }
+
+ localPath, err := c.downloadMedia(ctx, msgEvt, mediaKind)
+ if err != nil {
+ logger.WarnCF("matrix", "Failed to download media; forwarding as text-only marker", map[string]any{
+ "msgtype": msgEvt.MsgType,
+ "error": err.Error(),
+ })
+ return content, nil, true
+ }
+
+ filename := matrixMediaFilename(label, mediaKind, matrixContentType(msgEvt))
+ ref := c.storeMedia(localPath, media.MediaMeta{
+ Filename: filename,
+ ContentType: matrixContentType(msgEvt),
+ Source: "matrix",
+ }, scope)
+ return content, []string{ref}, true
+}
+
+func (c *MatrixChannel) storeMedia(localPath string, meta media.MediaMeta, scope string) string {
+ if store := c.GetMediaStore(); store != nil {
+ ref, err := store.Store(localPath, meta, scope)
+ if err == nil {
+ return ref
+ }
+ logger.WarnCF("matrix", "Failed to store media in MediaStore, falling back to local path", map[string]any{
+ "path": localPath,
+ "error": err.Error(),
+ })
+ }
+ return localPath
+}
+
+func (c *MatrixChannel) downloadMedia(
+ ctx context.Context,
+ msgEvt *event.MessageEventContent,
+ mediaKind string,
+) (string, error) {
+ uri := matrixMediaURI(msgEvt)
+ if uri == "" {
+ return "", fmt.Errorf("empty matrix media URL")
+ }
+ parsed := uri.ParseOrIgnore()
+ if parsed.IsEmpty() {
+ return "", fmt.Errorf("invalid matrix media URL: %s", uri)
+ }
+
+ dlCtx := c.baseContext()
+ if ctx != nil {
+ dlCtx = ctx
+ }
+ reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
+ defer cancel()
+
+ data, err := c.client.DownloadBytes(reqCtx, parsed)
+ if err != nil {
+ return "", err
+ }
+
+ // Encrypted attachments put URL in msgEvt.File and require client-side decryption.
+ if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
+ err = msgEvt.File.DecryptInPlace(data)
+ if err != nil {
+ return "", fmt.Errorf("decrypt matrix media: %w", err)
+ }
+ }
+
+ label := matrixMediaLabel(msgEvt, mediaKind)
+ ext := matrixMediaExt(label, matrixContentType(msgEvt), mediaKind)
+ mediaDir, err := matrixMediaTempDir()
+ if err != nil {
+ return "", fmt.Errorf("create matrix media directory: %w", err)
+ }
+ tmp, err := os.CreateTemp(mediaDir, "matrix-media-*"+ext)
+ if err != nil {
+ return "", err
+ }
+ defer tmp.Close()
+
+ if _, err = tmp.Write(data); err != nil {
+ _ = os.Remove(tmp.Name())
+ return "", err
+ }
+
+ return tmp.Name(), nil
+}
+
+func matrixContentType(msgEvt *event.MessageEventContent) string {
+ if msgEvt != nil && msgEvt.Info != nil {
+ return strings.TrimSpace(msgEvt.Info.MimeType)
+ }
+ return ""
+}
+
+func matrixMediaURI(msgEvt *event.MessageEventContent) id.ContentURIString {
+ if msgEvt == nil {
+ return ""
+ }
+ if msgEvt.URL != "" {
+ return msgEvt.URL
+ }
+ if msgEvt.File != nil {
+ return msgEvt.File.URL
+ }
+ return ""
+}
+
+func matrixMediaKind(msgType event.MessageType) string {
+ switch msgType {
+ case event.MsgAudio:
+ return "audio"
+ case event.MsgVideo:
+ return "video"
+ case event.MsgFile:
+ return "file"
+ default:
+ return "image"
+ }
+}
+
+func matrixOutboundMsgType(partType, filename, contentType string) event.MessageType {
+ switch strings.ToLower(strings.TrimSpace(partType)) {
+ case "image":
+ return event.MsgImage
+ case "audio", "voice":
+ return event.MsgAudio
+ case "video":
+ return event.MsgVideo
+ case "file", "document":
+ return event.MsgFile
+ }
+
+ ct := strings.ToLower(strings.TrimSpace(contentType))
+ switch {
+ case strings.HasPrefix(ct, "image/"):
+ return event.MsgImage
+ case strings.HasPrefix(ct, "audio/"), ct == "application/ogg", ct == "application/x-ogg":
+ return event.MsgAudio
+ case strings.HasPrefix(ct, "video/"):
+ return event.MsgVideo
+ }
+
+ switch strings.ToLower(strings.TrimSpace(filepath.Ext(filename))) {
+ case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
+ return event.MsgImage
+ case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
+ return event.MsgAudio
+ case ".mp4", ".avi", ".mov", ".webm", ".mkv":
+ return event.MsgVideo
+ default:
+ return event.MsgFile
+ }
+}
+
+func matrixOutboundContent(
+ caption, filename string,
+ msgType event.MessageType,
+ contentType string,
+ size int64,
+ uri id.ContentURIString,
+) *event.MessageEventContent {
+ body := strings.TrimSpace(caption)
+ if body == "" {
+ body = filename
+ }
+ if body == "" {
+ body = matrixMediaKind(msgType)
+ }
+
+ info := &event.FileInfo{MimeType: strings.TrimSpace(contentType)}
+ if size > 0 && size <= int64(int(^uint(0)>>1)) {
+ info.Size = int(size)
+ }
+
+ content := &event.MessageEventContent{
+ MsgType: msgType,
+ Body: body,
+ URL: uri,
+ FileName: filename,
+ Info: info,
+ }
+ return content
+}
+
+func matrixMediaLabel(msgEvt *event.MessageEventContent, fallback string) string {
+ if msgEvt == nil {
+ return fallback
+ }
+ if v := strings.TrimSpace(msgEvt.FileName); v != "" {
+ return v
+ }
+ if v := strings.TrimSpace(msgEvt.Body); v != "" {
+ return v
+ }
+ return fallback
+}
+
+func matrixMediaFilename(label, mediaKind, contentType string) string {
+ filename := strings.TrimSpace(label)
+ if filename == "" {
+ filename = mediaKind
+ }
+ if filepath.Ext(filename) == "" {
+ filename += matrixMediaExt("", contentType, mediaKind)
+ }
+ return filename
+}
+
+func matrixMediaExt(filename, contentType, mediaKind string) string {
+ if ext := strings.TrimSpace(filepath.Ext(filename)); ext != "" {
+ return ext
+ }
+ if contentType != "" {
+ if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 {
+ return exts[0]
+ }
+ }
+ switch mediaKind {
+ case "audio":
+ return ".ogg"
+ case "video":
+ return ".mp4"
+ case "file":
+ return ".bin"
+ default:
+ return ".jpg"
+ }
+}
+
+func (c *MatrixChannel) isGroupRoom(ctx context.Context, roomID id.RoomID) bool {
+ now := time.Now()
+ if isGroup, ok := c.roomKindCache.get(roomID.String(), now); ok {
+ return isGroup
+ }
+
+ qctx := c.baseContext()
+ if ctx != nil {
+ qctx = ctx
+ }
+ reqCtx, cancel := context.WithTimeout(qctx, 5*time.Second)
+ defer cancel()
+
+ resp, err := c.client.JoinedMembers(reqCtx, roomID)
+ if err != nil {
+ logger.DebugCF("matrix", "Failed to query room members; assume direct", map[string]any{
+ "room_id": roomID.String(),
+ "error": err.Error(),
+ })
+ return false
+ }
+
+ isGroup := len(resp.Joined) > 2
+ c.roomKindCache.set(roomID.String(), isGroup, now)
+ return isGroup
+}
+
+func (c *MatrixChannel) isBotMentioned(msgEvt *event.MessageEventContent) bool {
+ if msgEvt == nil {
+ return false
+ }
+
+ if msgEvt.Mentions != nil && msgEvt.Mentions.Has(c.client.UserID) {
+ return true
+ }
+
+ userID := c.client.UserID.String()
+ if userID != "" && strings.Contains(msgEvt.Body, userID) {
+ return true
+ }
+ if mentionsUserInFormattedBody(msgEvt.FormattedBody, c.client.UserID) {
+ return true
+ }
+
+ mentionR := c.localpartMentionR
+ if mentionR == nil {
+ mentionR = localpartMentionRegexp(matrixLocalpart(c.client.UserID))
+ }
+ if mentionR == nil {
+ return false
+ }
+
+ // Matrix users are addressed as MXID "@localpart:server", but many clients
+ // emit plain-text mentions as "@localpart". Both forms are handled here.
+ return mentionR.MatchString(msgEvt.Body) || mentionR.MatchString(msgEvt.FormattedBody)
+}
+
+func mentionsUserInFormattedBody(formattedBody string, userID id.UserID) bool {
+ target := strings.ToLower(strings.TrimSpace(userID.String()))
+ if target == "" {
+ return false
+ }
+
+ formattedBody = strings.TrimSpace(formattedBody)
+ if formattedBody == "" {
+ return false
+ }
+
+ if strings.Contains(strings.ToLower(formattedBody), target) {
+ return true
+ }
+
+ matches := matrixMentionHrefRegexp.FindAllStringSubmatch(formattedBody, -1)
+ for _, match := range matches {
+ if len(match) < 2 {
+ continue
+ }
+ decoded := decodeMatrixMentionHref(match[1])
+ if strings.Contains(strings.ToLower(decoded), target) {
+ return true
+ }
+
+ u, err := url.Parse(decoded)
+ if err != nil {
+ continue
+ }
+
+ if strings.Contains(strings.ToLower(u.Path), target) || strings.Contains(strings.ToLower(u.Fragment), target) {
+ return true
+ }
+ if strings.Contains(strings.ToLower(decodeMatrixMentionHref(u.Fragment)), target) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func decodeMatrixMentionHref(v string) string {
+ decoded := html.UnescapeString(strings.TrimSpace(v))
+ if decoded == "" {
+ return ""
+ }
+
+ for i := 0; i < 2; i++ {
+ next, err := url.QueryUnescape(decoded)
+ if err != nil || next == decoded {
+ break
+ }
+ decoded = next
+ }
+ return decoded
+}
+
+func (c *MatrixChannel) typingLoop(ctx context.Context, roomID id.RoomID, session *typingSession) {
+ sendTyping := func() {
+ _, err := c.client.UserTyping(ctx, roomID, true, typingServerTTL)
+ if err != nil {
+ logger.DebugCF("matrix", "Failed to send typing status", map[string]any{
+ "room_id": roomID.String(),
+ "error": err.Error(),
+ })
+ }
+ }
+
+ sendTyping()
+ ticker := time.NewTicker(typingRefreshInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-session.stopCh:
+ return
+ case <-ticker.C:
+ sendTyping()
+ }
+ }
+}
+
+func (c *MatrixChannel) stopTypingSessions(ctx context.Context) {
+ c.typingMu.Lock()
+ sessions := c.typingSessions
+ c.typingSessions = make(map[string]*typingSession)
+ c.typingMu.Unlock()
+
+ stopCtx := ctx
+ if stopCtx == nil {
+ stopCtx = context.Background()
+ }
+ for roomID, session := range sessions {
+ session.stop()
+ _, _ = c.client.UserTyping(stopCtx, id.RoomID(roomID), false, 0)
+ }
+}
+
+func (c *MatrixChannel) baseContext() context.Context {
+ if c.ctx != nil {
+ return c.ctx
+ }
+ return context.Background()
+}
+
+func (c *MatrixChannel) runRoomKindCacheJanitor(ctx context.Context) {
+ ticker := time.NewTicker(roomKindCacheCleanupPeriod)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case now := <-ticker.C:
+ c.roomKindCache.cleanupExpired(now)
+ }
+ }
+}
+
+func (c *MatrixChannel) stripSelfMention(text string) string {
+ return stripUserMentionWithRegexp(text, c.client.UserID, c.localpartMentionR)
+}
+
+func matrixMediaTempDir() (string, error) {
+ mediaDir := filepath.Join(os.TempDir(), matrixMediaTempDirName)
+ if err := os.MkdirAll(mediaDir, 0o700); err != nil {
+ return "", err
+ }
+ return mediaDir, nil
+}
+
+func matrixLocalpart(userID id.UserID) string {
+ s := strings.TrimPrefix(userID.String(), "@")
+ localpart, _, _ := strings.Cut(s, ":")
+ return strings.TrimSpace(localpart)
+}
+
+func localpartMentionRegexp(localpart string) *regexp.Regexp {
+ localpart = strings.TrimSpace(localpart)
+ if localpart == "" {
+ return nil
+ }
+
+ // Match Matrix mentions in plain text while avoiding false positives:
+ // "@picoclaw" and "@picoclaw:matrix.org" should match,
+ // "test@example.com" and "hellopicoclawworld" should not.
+ pattern := `(?i)(^|[^[:alnum:]_])@` + regexp.QuoteMeta(localpart) + `(?::[A-Za-z0-9._:-]+)?([^[:alnum:]_]|$)`
+ return regexp.MustCompile(pattern)
+}
+
+func stripUserMention(text string, userID id.UserID) string {
+ return stripUserMentionWithRegexp(text, userID, localpartMentionRegexp(matrixLocalpart(userID)))
+}
+
+func stripUserMentionWithRegexp(text string, userID id.UserID, mentionR *regexp.Regexp) string {
+ cleaned := strings.ReplaceAll(text, userID.String(), "")
+
+ if mentionR != nil {
+ cleaned = mentionR.ReplaceAllString(cleaned, "$1$2")
+ }
+
+ cleaned = strings.TrimSpace(cleaned)
+ cleaned = strings.TrimLeft(cleaned, ",:; ")
+ return strings.TrimSpace(cleaned)
+}
diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go
new file mode 100644
index 000000000..e76db0d3e
--- /dev/null
+++ b/pkg/channels/matrix/matrix_test.go
@@ -0,0 +1,291 @@
+package matrix
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+)
+
+func TestMatrixLocalpartMentionRegexp(t *testing.T) {
+ re := localpartMentionRegexp("picoclaw")
+
+ cases := []struct {
+ text string
+ want bool
+ }{
+ {text: "@picoclaw hello", want: true},
+ {text: "hi @picoclaw:matrix.org", want: true},
+ {
+ text: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e",
+ want: false, // historical false-positive case in PR #356
+ },
+ {text: "mail test@example.com", want: false},
+ }
+
+ for _, tc := range cases {
+ if got := re.MatchString(tc.text); got != tc.want {
+ t.Fatalf("text=%q match=%v want=%v", tc.text, got, tc.want)
+ }
+ }
+}
+
+func TestStripUserMention(t *testing.T) {
+ userID := id.UserID("@picoclaw:matrix.org")
+
+ cases := []struct {
+ in string
+ want string
+ }{
+ {in: "@picoclaw:matrix.org hello", want: "hello"},
+ {in: "@picoclaw, hello", want: "hello"},
+ {in: "no mention here", want: "no mention here"},
+ }
+
+ for _, tc := range cases {
+ if got := stripUserMention(tc.in, userID); got != tc.want {
+ t.Fatalf("stripUserMention(%q)=%q want=%q", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestIsBotMentioned(t *testing.T) {
+ ch := &MatrixChannel{
+ client: &mautrix.Client{
+ UserID: id.UserID("@picoclaw:matrix.org"),
+ },
+ }
+
+ cases := []struct {
+ name string
+ msg event.MessageEventContent
+ want bool
+ }{
+ {
+ name: "mentions field",
+ msg: event.MessageEventContent{
+ Body: "hello",
+ Mentions: &event.Mentions{
+ UserIDs: []id.UserID{id.UserID("@picoclaw:matrix.org")},
+ },
+ },
+ want: true,
+ },
+ {
+ name: "full user id in body",
+ msg: event.MessageEventContent{
+ Body: "@picoclaw:matrix.org hello",
+ },
+ want: true,
+ },
+ {
+ name: "localpart with at sign",
+ msg: event.MessageEventContent{
+ Body: "@picoclaw hello",
+ },
+ want: true,
+ },
+ {
+ name: "localpart without at sign should not match",
+ msg: event.MessageEventContent{
+ Body: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e",
+ },
+ want: false,
+ },
+ {
+ name: "formatted mention href matrix.to plain",
+ msg: event.MessageEventContent{
+ Body: "hello bot",
+ FormattedBody: `PicoClaw hello`,
+ },
+ want: true,
+ },
+ {
+ name: "formatted mention href matrix.to encoded",
+ msg: event.MessageEventContent{
+ Body: "hello bot",
+ FormattedBody: `PicoClaw hello`,
+ },
+ want: true,
+ },
+ }
+
+ for _, tc := range cases {
+ if got := ch.isBotMentioned(&tc.msg); got != tc.want {
+ t.Fatalf("%s: got=%v want=%v", tc.name, got, tc.want)
+ }
+ }
+}
+
+func TestRoomKindCache_ExpiresEntries(t *testing.T) {
+ cache := newRoomKindCache(4, 5*time.Second)
+ now := time.Unix(100, 0)
+ cache.set("!room:matrix.org", true, now)
+
+ if got, ok := cache.get("!room:matrix.org", now.Add(2*time.Second)); !ok || !got {
+ t.Fatalf("expected cached group room before ttl, got ok=%v group=%v", ok, got)
+ }
+
+ if _, ok := cache.get("!room:matrix.org", now.Add(6*time.Second)); ok {
+ t.Fatal("expected cache miss after ttl expiry")
+ }
+}
+
+func TestRoomKindCache_EvictsOldestWhenFull(t *testing.T) {
+ cache := newRoomKindCache(2, time.Minute)
+ now := time.Unix(200, 0)
+
+ cache.set("!room1:matrix.org", false, now)
+ cache.set("!room2:matrix.org", false, now.Add(1*time.Second))
+ cache.set("!room3:matrix.org", true, now.Add(2*time.Second))
+
+ if _, ok := cache.get("!room1:matrix.org", now.Add(2*time.Second)); ok {
+ t.Fatal("expected oldest cache entry to be evicted")
+ }
+ if got, ok := cache.get("!room2:matrix.org", now.Add(2*time.Second)); !ok || got {
+ t.Fatalf("expected room2 to remain and be direct, got ok=%v group=%v", ok, got)
+ }
+ if got, ok := cache.get("!room3:matrix.org", now.Add(2*time.Second)); !ok || !got {
+ t.Fatalf("expected room3 to remain and be group, got ok=%v group=%v", ok, got)
+ }
+}
+
+func TestMatrixMediaTempDir(t *testing.T) {
+ dir, err := matrixMediaTempDir()
+ if err != nil {
+ t.Fatalf("matrixMediaTempDir failed: %v", err)
+ }
+ if filepath.Base(dir) != matrixMediaTempDirName {
+ t.Fatalf("unexpected media dir base: %q", filepath.Base(dir))
+ }
+
+ info, err := os.Stat(dir)
+ if err != nil {
+ t.Fatalf("media dir not created: %v", err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("expected directory, got mode=%v", info.Mode())
+ }
+}
+
+func TestMatrixMediaExt(t *testing.T) {
+ if got := matrixMediaExt("photo.png", "", "image"); got != ".png" {
+ t.Fatalf("filename extension mismatch: got=%q", got)
+ }
+ if got := matrixMediaExt("", "image/webp", "image"); got != ".webp" {
+ t.Fatalf("content-type extension mismatch: got=%q", got)
+ }
+ if got := matrixMediaExt("", "", "image"); got != ".jpg" {
+ t.Fatalf("default image extension mismatch: got=%q", got)
+ }
+ if got := matrixMediaExt("", "", "audio"); got != ".ogg" {
+ t.Fatalf("default audio extension mismatch: got=%q", got)
+ }
+ if got := matrixMediaExt("", "", "video"); got != ".mp4" {
+ t.Fatalf("default video extension mismatch: got=%q", got)
+ }
+ if got := matrixMediaExt("", "", "file"); got != ".bin" {
+ t.Fatalf("default file extension mismatch: got=%q", got)
+ }
+}
+
+func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
+ ch := &MatrixChannel{}
+ msg := &event.MessageEventContent{
+ MsgType: event.MsgImage,
+ Body: "test.png",
+ }
+
+ content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event")
+ if !ok {
+ t.Fatal("expected ok for image fallback")
+ }
+ if content != "[image: test.png]" {
+ t.Fatalf("unexpected content: %q", content)
+ }
+ if len(mediaRefs) != 0 {
+ t.Fatalf("expected no media refs, got %d", len(mediaRefs))
+ }
+}
+
+func TestExtractInboundContent_AudioNoURLFallback(t *testing.T) {
+ ch := &MatrixChannel{}
+ msg := &event.MessageEventContent{
+ MsgType: event.MsgAudio,
+ FileName: "voice.ogg",
+ Body: "please transcribe",
+ }
+
+ content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event")
+ if !ok {
+ t.Fatal("expected ok for audio fallback")
+ }
+ if content != "please transcribe\n[audio: voice.ogg]" {
+ t.Fatalf("unexpected content: %q", content)
+ }
+ if len(mediaRefs) != 0 {
+ t.Fatalf("expected no media refs, got %d", len(mediaRefs))
+ }
+}
+
+func TestMatrixOutboundMsgType(t *testing.T) {
+ cases := []struct {
+ name string
+ partType string
+ filename string
+ contentType string
+ want event.MessageType
+ }{
+ {name: "explicit image", partType: "image", want: event.MsgImage},
+ {name: "explicit audio", partType: "audio", want: event.MsgAudio},
+ {name: "mime fallback video", contentType: "video/mp4", want: event.MsgVideo},
+ {name: "extension fallback audio", filename: "voice.ogg", want: event.MsgAudio},
+ {name: "unknown defaults file", filename: "report.txt", want: event.MsgFile},
+ }
+
+ for _, tc := range cases {
+ if got := matrixOutboundMsgType(tc.partType, tc.filename, tc.contentType); got != tc.want {
+ t.Fatalf("%s: got=%q want=%q", tc.name, got, tc.want)
+ }
+ }
+}
+
+func TestMatrixOutboundContent(t *testing.T) {
+ content := matrixOutboundContent(
+ "please review",
+ "voice.ogg",
+ event.MsgAudio,
+ "audio/ogg",
+ 1234,
+ id.ContentURIString("mxc://matrix.org/abc"),
+ )
+ if content.Body != "please review" {
+ t.Fatalf("unexpected body: %q", content.Body)
+ }
+ if content.FileName != "voice.ogg" {
+ t.Fatalf("unexpected filename: %q", content.FileName)
+ }
+ if content.Info == nil || content.Info.MimeType != "audio/ogg" {
+ t.Fatalf("unexpected content type: %+v", content.Info)
+ }
+ if content.Info == nil || content.Info.Size != 1234 {
+ t.Fatalf("unexpected size: %+v", content.Info)
+ }
+
+ noCaption := matrixOutboundContent(
+ "",
+ "image.png",
+ event.MsgImage,
+ "image/png",
+ 0,
+ id.ContentURIString("mxc://matrix.org/def"),
+ )
+ if noCaption.Body != "image.png" {
+ t.Fatalf("unexpected fallback body: %q", noCaption.Body)
+ }
+}
diff --git a/pkg/channels/telegram/command_registration.go b/pkg/channels/telegram/command_registration.go
new file mode 100644
index 000000000..d3152ec3d
--- /dev/null
+++ b/pkg/channels/telegram/command_registration.go
@@ -0,0 +1,116 @@
+package telegram
+
+import (
+ "context"
+ "math/rand"
+ "slices"
+ "time"
+
+ "github.com/mymmrac/telego"
+
+ "github.com/sipeed/picoclaw/pkg/commands"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+var commandRegistrationBackoff = []time.Duration{
+ 5 * time.Second,
+ 15 * time.Second,
+ 60 * time.Second,
+ 5 * time.Minute,
+ 10 * time.Minute,
+}
+
+func commandRegistrationDelay(attempt int) time.Duration {
+ if len(commandRegistrationBackoff) == 0 {
+ return 0
+ }
+ base := commandRegistrationBackoff[min(attempt, len(commandRegistrationBackoff)-1)]
+ // Full jitter in [0.5, 1.0) to avoid synchronized retries across instances.
+ return time.Duration(float64(base) * (0.5 + rand.Float64()*0.5))
+}
+
+// RegisterCommands registers bot commands on Telegram platform.
+func (c *TelegramChannel) RegisterCommands(ctx context.Context, defs []commands.Definition) error {
+ botCommands := make([]telego.BotCommand, 0, len(defs))
+ for _, def := range defs {
+ if def.Name == "" || def.Description == "" {
+ continue
+ }
+ botCommands = append(botCommands, telego.BotCommand{
+ Command: def.Name,
+ Description: def.Description,
+ })
+ }
+
+ current, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{})
+ if err != nil {
+ // If we can't read current commands, fall through to set them.
+ logger.WarnCF("telegram", "Failed to get current commands, will set unconditionally",
+ map[string]any{"error": err.Error()})
+ } else if slices.Equal(current, botCommands) {
+ logger.DebugCF("telegram", "Bot commands are up to date", nil)
+ return nil
+ }
+
+ return c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
+ Commands: botCommands,
+ })
+}
+
+func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []commands.Definition) {
+ if len(defs) == 0 {
+ return
+ }
+
+ register := c.registerFunc
+ if register == nil {
+ register = c.RegisterCommands
+ }
+
+ regCtx, cancel := context.WithCancel(ctx)
+ c.commandRegCancel = cancel
+
+ // Registration runs asynchronously so Telegram message intake is never blocked
+ // by temporary upstream API failures. Retry stops on success or channel shutdown.
+ go func() {
+ attempt := 0
+ timer := time.NewTimer(0)
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ defer timer.Stop()
+ for {
+ err := register(regCtx, defs)
+ if err == nil {
+ logger.InfoCF("telegram", "Telegram commands registered", map[string]any{
+ "count": len(defs),
+ })
+ return
+ }
+
+ delay := commandRegistrationDelay(attempt)
+ logger.WarnCF("telegram", "Telegram command registration failed; will retry", map[string]any{
+ "error": err.Error(),
+ "retry_after": delay.String(),
+ })
+ attempt++
+
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(delay)
+
+ select {
+ case <-regCtx.Done():
+ return
+ case <-timer.C:
+ }
+ }
+ }()
+}
diff --git a/pkg/channels/telegram/command_registration_test.go b/pkg/channels/telegram/command_registration_test.go
new file mode 100644
index 000000000..26f891b2e
--- /dev/null
+++ b/pkg/channels/telegram/command_registration_test.go
@@ -0,0 +1,96 @@
+package telegram
+
+import (
+ "context"
+ "errors"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/commands"
+)
+
+func TestStartCommandRegistration_DoesNotBlock(t *testing.T) {
+ ch := &TelegramChannel{}
+ started := make(chan struct{}, 1)
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ ch.registerFunc = func(context.Context, []commands.Definition) error {
+ started <- struct{}{}
+ return errors.New("temporary failure")
+ }
+
+ ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help"}})
+
+ select {
+ case <-started:
+ case <-time.After(time.Second):
+ t.Fatal("registration did not start asynchronously")
+ }
+}
+
+func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) {
+ ch := &TelegramChannel{}
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ origBackoff := commandRegistrationBackoff
+ commandRegistrationBackoff = []time.Duration{5 * time.Millisecond}
+ defer func() { commandRegistrationBackoff = origBackoff }()
+
+ var attempts atomic.Int32
+ ch.registerFunc = func(context.Context, []commands.Definition) error {
+ n := attempts.Add(1)
+ if n < 3 {
+ return errors.New("temporary failure")
+ }
+ return nil
+ }
+
+ ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}})
+
+ deadline := time.Now().Add(250 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ if attempts.Load() >= 3 {
+ break
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ if attempts.Load() < 3 {
+ t.Fatalf("expected at least 3 attempts, got %d", attempts.Load())
+ }
+
+ stable := attempts.Load()
+ time.Sleep(30 * time.Millisecond)
+ if attempts.Load() != stable {
+ t.Fatalf("expected retries to stop after success, got %d -> %d", stable, attempts.Load())
+ }
+}
+
+func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) {
+ ch := &TelegramChannel{}
+ ctx, cancel := context.WithCancel(context.Background())
+
+ origBackoff := commandRegistrationBackoff
+ commandRegistrationBackoff = []time.Duration{5 * time.Millisecond}
+ defer func() { commandRegistrationBackoff = origBackoff }()
+ defer cancel()
+
+ var attempts atomic.Int32
+ ch.registerFunc = func(context.Context, []commands.Definition) error {
+ attempts.Add(1)
+ return errors.New("always fail")
+ }
+
+ ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}})
+
+ time.Sleep(20 * time.Millisecond)
+ cancel()
+ time.Sleep(20 * time.Millisecond) // allow in-flight attempt to settle
+ stable := attempts.Load()
+ time.Sleep(30 * time.Millisecond)
+ if attempts.Load() != stable {
+ t.Fatalf("expected retries to quiesce after cancel, got %d -> %d", stable, attempts.Load())
+ }
+}
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 44078eeae..e4f4dff7a 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -12,12 +12,12 @@ import (
"time"
"github.com/mymmrac/telego"
- "github.com/mymmrac/telego/telegohandler"
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/commands"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -40,13 +40,15 @@ var (
type TelegramChannel struct {
*channels.BaseChannel
- bot *telego.Bot
- bh *telegohandler.BotHandler
- commands TelegramCommander
- config *config.Config
- chatIDs map[string]int64
- ctx context.Context
- cancel context.CancelFunc
+ bot *telego.Bot
+ bh *th.BotHandler
+ config *config.Config
+ chatIDs map[string]int64
+ ctx context.Context
+ cancel context.CancelFunc
+
+ registerFunc func(context.Context, []commands.Definition) error
+ commandRegCancel context.CancelFunc
}
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
@@ -72,6 +74,10 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
}))
}
+ 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)
@@ -82,14 +88,13 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
telegramCfg,
bus,
telegramCfg.AllowFrom,
- channels.WithMaxMessageLength(4096),
+ channels.WithMaxMessageLength(4000),
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),
@@ -109,29 +114,13 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to start long polling: %w", err)
}
- bh, err := telegohandler.NewBotHandler(c.bot, updates)
+ 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 {
- c.commands.Help(ctx, message)
- return nil
- }, th.CommandEqual("help"))
- 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.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())
@@ -141,7 +130,15 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
"username": c.bot.Username(),
})
- go bh.Start()
+ c.startCommandRegistration(c.ctx, commands.BuiltinDefinitions())
+
+ go func() {
+ if err = bh.Start(); err != nil {
+ logger.ErrorCF("telegram", "Bot handler failed", map[string]any{
+ "error": err.Error(),
+ })
+ }
+ }()
return nil
}
@@ -152,13 +149,16 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
// Stop the bot handler
if c.bh != nil {
- c.bh.Stop()
+ _ = c.bh.StopWithContext(ctx)
}
// Cancel our context (stops long polling)
if c.cancel != nil {
c.cancel()
}
+ if c.commandRegCancel != nil {
+ c.commandRegCancel()
+ }
return nil
}
@@ -173,25 +173,60 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
- htmlContent := markdownToTelegramHTML(msg.Content)
+ if msg.Content == "" {
+ return nil
+ }
- // Typing/placeholder handled by Manager.preSend — just send the message
+ // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
+ // so msg.Content is guaranteed to be within that limit. We still need to
+ // check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
+ queue := []string{msg.Content}
+ for len(queue) > 0 {
+ chunk := queue[0]
+ queue = queue[1:]
+
+ htmlContent := markdownToTelegramHTML(chunk)
+
+ if len([]rune(htmlContent)) > 4096 {
+ ratio := float64(len([]rune(chunk))) / float64(len([]rune(htmlContent)))
+ smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin
+ if smallerLen < 100 {
+ smallerLen = 100
+ }
+ // Push sub-chunks back to the front of the queue for
+ // re-validation instead of sending them blindly.
+ subChunks := channels.SplitMessage(chunk, smallerLen)
+ queue = append(subChunks, queue...)
+ continue
+ }
+
+ if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// sendHTMLChunk sends a single HTML message, falling back to the original
+// markdown as plain text on parse failure so users never see raw HTML tags.
+func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error {
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML
if threadID != 0 {
tgMsg.MessageThreadID = threadID
}
- if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
+ 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.Text = mdFallback
tgMsg.ParseMode = ""
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}
-
return nil
}
@@ -784,34 +819,34 @@ func escapeHTML(text string) string {
// 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 == "" {
+ text, entities := telegramEntityTextAndList(message)
+ if text == "" || len(entities) == 0 {
return false
}
- entities := message.Entities
- if entities == nil {
- entities = message.CaptionEntities
+ botUsername := ""
+ if c.bot != nil {
+ botUsername = c.bot.Username()
}
+ runes := []rune(text)
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
- }
- }
+ entityText, ok := telegramEntityText(runes, entity)
+ if !ok {
+ continue
}
- if entity.Type == "text_mention" && entity.User != nil {
- if entity.User.Username == botUsername {
+
+ switch entity.Type {
+ case telego.EntityTypeMention:
+ if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) {
+ return true
+ }
+ case telego.EntityTypeTextMention:
+ if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) {
+ return true
+ }
+ case telego.EntityTypeBotCommand:
+ if isBotCommandEntityForThisBot(entityText, botUsername) {
return true
}
}
@@ -819,6 +854,46 @@ func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
return false
}
+func telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) {
+ if message.Text != "" {
+ return message.Text, message.Entities
+ }
+ return message.Caption, message.CaptionEntities
+}
+
+func telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) {
+ if entity.Offset < 0 || entity.Length <= 0 {
+ return "", false
+ }
+ end := entity.Offset + entity.Length
+ if entity.Offset >= len(runes) || end > len(runes) {
+ return "", false
+ }
+ return string(runes[entity.Offset:end]), true
+}
+
+func isBotCommandEntityForThisBot(entityText, botUsername string) bool {
+ if !strings.HasPrefix(entityText, "/") {
+ return false
+ }
+ command := strings.TrimPrefix(entityText, "/")
+ if command == "" {
+ return false
+ }
+
+ at := strings.IndexRune(command, '@')
+ if at == -1 {
+ // A bare /command delivered to this bot is intended for this bot.
+ return true
+ }
+
+ mentionUsername := command[at+1:]
+ if mentionUsername == "" || botUsername == "" {
+ return false
+ }
+ return strings.EqualFold(mentionUsername, botUsername)
+}
+
// stripBotMention removes the @bot mention from the content.
func (c *TelegramChannel) stripBotMention(content string) string {
botUsername := c.bot.Username()
diff --git a/pkg/channels/telegram/telegram_commands.go b/pkg/channels/telegram/telegram_commands.go
deleted file mode 100644
index aa7e4c147..000000000
--- a/pkg/channels/telegram/telegram_commands.go
+++ /dev/null
@@ -1,160 +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
-/session - Show usage statistics
-/skills - List available skills
-/skill [message] - Invoke a skill
-/plan - Show or manage your plan
- `
- _, 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/telegram/telegram_dispatch_test.go b/pkg/channels/telegram/telegram_dispatch_test.go
new file mode 100644
index 000000000..1ea4a4824
--- /dev/null
+++ b/pkg/channels/telegram/telegram_dispatch_test.go
@@ -0,0 +1,52 @@
+package telegram
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/mymmrac/telego"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+)
+
+func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
+ messageBus := bus.NewMessageBus()
+ ch := &TelegramChannel{
+ BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
+ chatIDs: make(map[string]int64),
+ ctx: context.Background(),
+ }
+
+ msg := &telego.Message{
+ Text: "/new",
+ MessageID: 9,
+ Chat: telego.Chat{
+ ID: 123,
+ Type: "private",
+ },
+ From: &telego.User{
+ ID: 42,
+ FirstName: "Alice",
+ },
+ }
+
+ if err := ch.handleMessage(context.Background(), msg); err != nil {
+ t.Fatalf("handleMessage error: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ inbound, ok := messageBus.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("expected inbound message to be forwarded")
+ }
+ if inbound.Channel != "telegram" {
+ t.Fatalf("channel=%q", inbound.Channel)
+ }
+ if inbound.Content != "/new" {
+ t.Fatalf("content=%q", inbound.Content)
+ }
+}
diff --git a/pkg/channels/telegram/telegram_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go
new file mode 100644
index 000000000..0d5b985fe
--- /dev/null
+++ b/pkg/channels/telegram/telegram_group_command_filter_test.go
@@ -0,0 +1,147 @@
+package telegram
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/mymmrac/telego"
+ ta "github.com/mymmrac/telego/telegoapi"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+type getMeCaller struct {
+ username string
+}
+
+func (c getMeCaller) Call(_ context.Context, url string, _ *ta.RequestData) (*ta.Response, error) {
+ if strings.HasSuffix(url, "/getMe") {
+ result := fmt.Sprintf(`{"id":1,"is_bot":true,"first_name":"bot","username":%q}`, c.username)
+ return &ta.Response{Ok: true, Result: []byte(result)}, nil
+ }
+ return &ta.Response{Ok: true, Result: []byte("true")}, nil
+}
+
+func newTestTelegramBot(t *testing.T, username string) *telego.Bot {
+ t.Helper()
+
+ token := "123456:" + strings.Repeat("a", 35)
+ bot, err := telego.NewBot(token,
+ telego.WithAPICaller(getMeCaller{username: username}),
+ telego.WithDiscardLogger(),
+ )
+ if err != nil {
+ t.Fatalf("NewBot error: %v", err)
+ }
+ return bot
+}
+
+func newGroupMentionOnlyChannel(t *testing.T, botUsername string) (*TelegramChannel, *bus.MessageBus) {
+ t.Helper()
+
+ messageBus := bus.NewMessageBus()
+ ch := &TelegramChannel{
+ BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil,
+ channels.WithGroupTrigger(config.GroupTriggerConfig{MentionOnly: true}),
+ ),
+ bot: newTestTelegramBot(t, botUsername),
+ chatIDs: make(map[string]int64),
+ ctx: context.Background(),
+ }
+ return ch, messageBus
+}
+
+func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) {
+ tests := []struct {
+ name string
+ text string
+ wantForwarded bool
+ wantContent string
+ }{
+ {
+ name: "command with bot username",
+ text: "/new@testbot",
+ wantForwarded: true,
+ wantContent: "/new",
+ },
+ {
+ name: "bare command",
+ text: "/new",
+ wantForwarded: true,
+ wantContent: "/new",
+ },
+ {
+ name: "command for another bot",
+ text: "/new@otherbot",
+ wantForwarded: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ch, messageBus := newGroupMentionOnlyChannel(t, "testbot")
+
+ msg := &telego.Message{
+ Text: tc.text,
+ Entities: []telego.MessageEntity{{
+ Type: telego.EntityTypeBotCommand,
+ Offset: 0,
+ Length: len([]rune(tc.text)),
+ }},
+ MessageID: 42,
+ Chat: telego.Chat{
+ ID: 123,
+ Type: "group",
+ },
+ From: &telego.User{
+ ID: 7,
+ FirstName: "Alice",
+ },
+ }
+
+ if err := ch.handleMessage(context.Background(), msg); err != nil {
+ t.Fatalf("handleMessage error: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
+ defer cancel()
+
+ inbound, ok := messageBus.ConsumeInbound(ctx)
+ if tc.wantForwarded {
+ if !ok {
+ t.Fatal("expected inbound message to be forwarded")
+ }
+ if inbound.Content != tc.wantContent {
+ t.Fatalf("content=%q want=%q", inbound.Content, tc.wantContent)
+ }
+ return
+ }
+
+ if ok {
+ t.Fatalf("expected message to be filtered, got content=%q", inbound.Content)
+ }
+ })
+ }
+}
+
+func TestIsBotMentioned_MentionEntityUnaffected(t *testing.T) {
+ ch, _ := newGroupMentionOnlyChannel(t, "testbot")
+
+ msg := &telego.Message{
+ Text: "@testbot hello",
+ Entities: []telego.MessageEntity{{
+ Type: telego.EntityTypeMention,
+ Offset: 0,
+ Length: len("@testbot"),
+ }},
+ }
+
+ if !ch.isBotMentioned(msg) {
+ t.Fatal("expected mention entity to be treated as bot mention")
+ }
+}
diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go
new file mode 100644
index 000000000..93fe8c36d
--- /dev/null
+++ b/pkg/channels/wecom/aibot.go
@@ -0,0 +1,1017 @@
+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, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err)
+ }
+ switch {
+ case resp.StatusCode == http.StatusTooManyRequests:
+ return fmt.Errorf("response_url rate limited (%d): %s: %w",
+ resp.StatusCode, respBody, channels.ErrRateLimit)
+ case resp.StatusCode >= 500:
+ return fmt.Errorf("response_url server error (%d): %s: %w",
+ resp.StatusCode, respBody, channels.ErrTemporary)
+ default:
+ return fmt.Errorf("response_url returned %d: %s: %w",
+ resp.StatusCode, respBody, channels.ErrSendFailed)
+ }
+}
+
+// encryptResponse encrypts a streaming response
+func (c *WeComAIBotChannel) encryptResponse(
+ streamID, timestamp, nonce string,
+ response WeComAIBotStreamResponse,
+) string {
+ // Marshal response to JSON
+ plaintext, err := json.Marshal(response)
+ if err != nil {
+ logger.ErrorCF("wecom_aibot", "Failed to marshal response", map[string]any{
+ "error": err,
+ })
+ return ""
+ }
+
+ logger.DebugCF("wecom_aibot", "Encrypting response", map[string]any{
+ "stream_id": streamID,
+ "finish": response.Stream.Finish,
+ "preview": utils.Truncate(response.Stream.Content, 100),
+ })
+
+ // Encrypt message
+ encrypted, err := c.encryptMessage(string(plaintext), "")
+ if err != nil {
+ logger.ErrorCF("wecom_aibot", "Failed to encrypt message", map[string]any{
+ "error": err,
+ })
+ return ""
+ }
+
+ // Generate signature
+ signature := computeSignature(c.config.Token, timestamp, nonce, encrypted)
+
+ // Build encrypted response
+ encryptedResp := WeComAIBotEncryptedResponse{
+ Encrypt: encrypted,
+ MsgSignature: signature,
+ Timestamp: timestamp,
+ Nonce: nonce,
+ }
+
+ respJSON, err := json.Marshal(encryptedResp)
+ if err != nil {
+ logger.ErrorCF("wecom_aibot", "Failed to marshal encrypted response", map[string]any{
+ "error": err,
+ })
+ return ""
+ }
+
+ logger.DebugCF("wecom_aibot", "Response encrypted", map[string]any{
+ "stream_id": streamID,
+ })
+
+ return string(respJSON)
+}
+
+// encryptEmptyResponse returns a minimal valid encrypted response
+func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string {
+ // Construct a zero-value stream response and encrypt it so that
+ // WeCom always receives a syntactically valid encrypted JSON object.
+ emptyResp := WeComAIBotStreamResponse{}
+ return c.encryptResponse("", timestamp, nonce, emptyResp)
+}
+
+// encryptMessage encrypts a plain text message for WeCom AI Bot
+func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) {
+ aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey)
+ if err != nil {
+ return "", err
+ }
+
+ frame, err := packWeComFrame(plaintext, receiveid)
+ if err != nil {
+ return "", err
+ }
+
+ // PKCS7 padding then AES-CBC encrypt
+ paddedFrame := pkcs7Pad(frame, blockSize)
+ ciphertext, err := encryptAESCBC(aesKey, paddedFrame)
+ if err != nil {
+ return "", err
+ }
+
+ return base64.StdEncoding.EncodeToString(ciphertext), nil
+}
+
+// generateStreamID generates a random stream ID
+func (c *WeComAIBotChannel) generateStreamID() string {
+ const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ b := make([]byte, 10)
+ for i := range b {
+ n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
+ b[i] = letters[n.Int64()]
+ }
+ return string(b)
+}
+
+// cleanupLoop periodically cleans up old streaming tasks
+func (c *WeComAIBotChannel) cleanupLoop() {
+ ticker := time.NewTicker(5 * time.Minute)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ c.cleanupOldTasks()
+ case <-c.ctx.Done():
+ return
+ }
+ }
+}
+
+// cleanupOldTasks removes tasks that have exceeded their expected lifetime:
+// - Active tasks (in streamTasks): cleaned up after 1 hour (response_url validity window).
+// - StreamClosed tasks (in chatTasks only): cleaned up after streamClosedGracePeriod.
+// These tasks are waiting for the agent to call Send() via response_url. If the agent
+// crashes or times out without calling Send(), we must not let them accumulate indefinitely.
+// The grace period is generous enough to cover typical LLM latency but far shorter than 1 hour,
+// preventing chatTasks from filling up when many requests time out in quick succession.
+const (
+ streamClosedGracePeriod = 10 * time.Minute // max wait for agent after stream closes
+ taskMaxLifetime = 1 * time.Hour // absolute max (≈ response_url validity)
+)
+
+func (c *WeComAIBotChannel) cleanupOldTasks() {
+ c.taskMu.Lock()
+ defer c.taskMu.Unlock()
+
+ now := time.Now()
+ cutoff := now.Add(-taskMaxLifetime)
+ for id, task := range c.streamTasks {
+ if task.CreatedTime.Before(cutoff) {
+ delete(c.streamTasks, id)
+ task.cancel() // interrupt agent goroutine still waiting for LLM
+ queue := c.chatTasks[task.ChatID]
+ for i, t := range queue {
+ if t == task {
+ c.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...)
+ break
+ }
+ }
+ if len(c.chatTasks[task.ChatID]) == 0 {
+ delete(c.chatTasks, task.ChatID)
+ }
+ logger.DebugCF("wecom_aibot", "Cleaned up expired task", map[string]any{
+ "stream_id": id,
+ })
+ }
+ }
+ // Clean up StreamClosed tasks from chatTasks.
+ // Two expiry conditions are checked:
+ // 1. Absolute expiry: task was created more than taskMaxLifetime ago.
+ // 2. Grace expiry: stream closed more than streamClosedGracePeriod ago
+ // (agent had enough time to reply; it is not coming back).
+ for chatID, queue := range c.chatTasks {
+ filtered := queue[:0]
+ for i, t := range queue {
+ absoluteExpired := t.CreatedTime.Before(cutoff)
+ graceExpired := t.StreamClosed &&
+ !t.StreamClosedAt.IsZero() &&
+ t.StreamClosedAt.Before(now.Add(-streamClosedGracePeriod))
+ if t.Finished {
+ // Finished tasks should have been removed by removeTask().
+ // Finding one here (especially not at position 0) means an
+ // unexpected code path left it stranded, causing the queue to
+ // grow silently. Log a warning so it is visible, then drop it.
+ if i > 0 {
+ logger.WarnCF("wecom_aibot",
+ "Found stranded Finished task in the middle of chatTasks queue; "+
+ "this should not happen — removeTask() should have spliced it out",
+ map[string]any{
+ "chat_id": chatID,
+ "stream_id": t.StreamID,
+ "position": i,
+ })
+ }
+ // The task is already finished; its context was already canceled
+ // by removeTask(), so no further action is required.
+ continue
+ } else if !absoluteExpired && !graceExpired {
+ filtered = append(filtered, t)
+ } else {
+ t.cancel() // cancel any lingering agent goroutine
+ }
+ }
+ if len(filtered) == 0 {
+ delete(c.chatTasks, chatID)
+ } else {
+ c.chatTasks[chatID] = filtered
+ }
+ }
+}
+
+// handleHealth handles health check requests
+func (c *WeComAIBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
+ status := "ok"
+ if !c.IsRunning() {
+ status = "not running"
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{
+ "status": status,
+ })
+}
diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go
new file mode 100644
index 000000000..6f0664187
--- /dev/null
+++ b/pkg/channels/wecom/aibot_test.go
@@ -0,0 +1,210 @@
+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
index 292a71fd2..2098fcd4e 100644
--- a/pkg/channels/wecom/app.go
+++ b/pkg/channels/wecom/app.go
@@ -38,8 +38,7 @@ type WeComAppChannel struct {
tokenMu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
- processedMsgs map[string]bool // Message deduplication: msg_id -> processed
- msgMu sync.RWMutex
+ processedMsgs *MessageDeduplicator
}
// WeComXMLMessage represents the XML message structure from WeCom
@@ -144,7 +143,7 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
client: &http.Client{Timeout: clientTimeout},
ctx: ctx,
cancel: cancel,
- processedMsgs: make(map[string]bool),
+ processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
}, nil
}
@@ -322,8 +321,17 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp
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)))
+ respBody, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return "", channels.ClassifySendError(
+ resp.StatusCode,
+ fmt.Errorf("reading wecom upload error response: %w", readErr),
+ )
+ }
+ return "", channels.ClassifySendError(
+ resp.StatusCode,
+ fmt.Errorf("wecom upload error: %s", string(respBody)),
+ )
}
var result struct {
@@ -342,18 +350,11 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp
return result.MediaID, nil
}
-// sendImageMessage sends an image message using a media_id.
-func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error {
+// 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)
- msg := WeComImageMessage{
- ToUser: userID,
- MsgType: "image",
- AgentID: c.config.AgentID,
- }
- msg.Image.MediaID = mediaID
-
- jsonData, err := json.Marshal(msg)
+ jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
@@ -379,8 +380,17 @@ func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, use
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, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return channels.ClassifySendError(
+ resp.StatusCode,
+ fmt.Errorf("reading wecom_app error response: %w", readErr),
+ )
+ }
+ return channels.ClassifySendError(
+ resp.StatusCode,
+ fmt.Errorf("wecom_app API error: %s", string(respBody)),
+ )
}
respBody, err := io.ReadAll(resp.Body)
@@ -400,6 +410,17 @@ func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, use
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 != "" {
@@ -603,23 +624,12 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
// Message deduplication: Use msg_id to prevent duplicate processing
// As per WeCom documentation, use msg_id for deduplication
msgID := fmt.Sprintf("%d", msg.MsgId)
- c.msgMu.Lock()
- if c.processedMsgs[msgID] {
- c.msgMu.Unlock()
+ if !c.processedMsgs.MarkMessageProcessed(msgID) {
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
"msg_id": msgID,
})
return
}
- c.processedMsgs[msgID] = true
- // Clean up old messages while still holding the lock to avoid a data race
- // on len(). Reset the map but re-insert the current msgID so it remains
- // deduplicated.
- if len(c.processedMsgs) > 1000 {
- c.processedMsgs = make(map[string]bool)
- c.processedMsgs[msgID] = true
- }
- c.msgMu.Unlock()
senderID := msg.FromUserName
chatID := senderID // WeCom App uses user ID as chat ID for direct messages
@@ -722,63 +732,15 @@ func (c *WeComAppChannel) getAccessToken() string {
return c.accessToken
}
-// sendTextMessage sends a text message to a user
+// sendTextMessage sends a text message to a user.
func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error {
- apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
-
msg := WeComTextMessage{
ToUser: userID,
MsgType: "text",
AgentID: c.config.AgentID,
}
msg.Text.Content = content
-
- jsonData, err := json.Marshal(msg)
- if err != nil {
- return fmt.Errorf("failed to marshal message: %w", err)
- }
-
- // Use configurable timeout (default 5 seconds)
- timeout := c.config.ReplyTimeout
- if timeout <= 0 {
- timeout = 5
- }
-
- reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
- defer cancel()
-
- req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
-
- 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("wecom_app API error: %s", string(body)))
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return fmt.Errorf("failed to read response: %w", err)
- }
-
- var sendResp WeComSendMessageResponse
- if err := json.Unmarshal(body, &sendResp); err != nil {
- return fmt.Errorf("failed to parse response: %w", err)
- }
-
- if sendResp.ErrCode != 0 {
- return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
- }
-
- return nil
+ return c.sendWeComMessage(ctx, accessToken, msg)
}
// handleHealth handles health check requests
diff --git a/pkg/channels/wecom/app_test.go b/pkg/channels/wecom/app_test.go
index 0d15e955b..7f230494f 100644
--- a/pkg/channels/wecom/app_test.go
+++ b/pkg/channels/wecom/app_test.go
@@ -323,60 +323,6 @@ func TestWeComAppDecryptMessage(t *testing.T) {
})
}
-func TestWeComAppPKCS7Unpad(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 TestWeComAppHandleVerification(t *testing.T) {
msgBus := bus.NewMessageBus()
aesKey := generateTestAESKeyApp()
diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go
index 0d0426c0d..96d5a961f 100644
--- a/pkg/channels/wecom/bot.go
+++ b/pkg/channels/wecom/bot.go
@@ -9,7 +9,6 @@ import (
"io"
"net/http"
"strings"
- "sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -28,8 +27,7 @@ type WeComBotChannel struct {
client *http.Client
ctx context.Context
cancel context.CancelFunc
- processedMsgs map[string]bool // Message deduplication: msg_id -> processed
- msgMu sync.RWMutex
+ processedMsgs *MessageDeduplicator
}
// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
@@ -108,7 +106,7 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
client: &http.Client{Timeout: clientTimeout},
ctx: ctx,
cancel: cancel,
- processedMsgs: make(map[string]bool),
+ processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
}, nil
}
@@ -330,23 +328,12 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
// Message deduplication: Use msg_id to prevent duplicate processing
msgID := msg.MsgID
- c.msgMu.Lock()
- if c.processedMsgs[msgID] {
- c.msgMu.Unlock()
+ if !c.processedMsgs.MarkMessageProcessed(msgID) {
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
"msg_id": msgID,
})
return
}
- c.processedMsgs[msgID] = true
- // Clean up old messages while still holding the lock to avoid a data race
- // on len(). Reset the map but re-insert the current msgID so it remains
- // deduplicated.
- if len(c.processedMsgs) > 1000 {
- c.processedMsgs = make(map[string]bool)
- c.processedMsgs[msgID] = true
- }
- c.msgMu.Unlock()
senderID := msg.From.UserID
@@ -466,8 +453,17 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
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, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return channels.ClassifySendError(
+ resp.StatusCode,
+ fmt.Errorf("reading webhook error response: %w", readErr),
+ )
+ }
+ return channels.ClassifySendError(
+ resp.StatusCode,
+ fmt.Errorf("webhook API error: %s", string(body)),
+ )
}
body, err := io.ReadAll(resp.Body)
diff --git a/pkg/channels/wecom/bot_test.go b/pkg/channels/wecom/bot_test.go
index 97b503ce8..c053578b1 100644
--- a/pkg/channels/wecom/bot_test.go
+++ b/pkg/channels/wecom/bot_test.go
@@ -412,22 +412,9 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
- t.Run("valid direct message callback", func(t *testing.T) {
- // Create JSON message for direct chat (single)
- jsonMsg := `{
- "msgid": "test_msg_id_123",
- "aibotid": "test_aibot_id",
- "chattype": "single",
- "from": {"userid": "user123"},
- "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- "msgtype": "text",
- "text": {"content": "Hello World"}
- }`
-
- // Encrypt message
+ runBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder {
+ t.Helper()
encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
-
- // Create encrypted XML wrapper
encryptedWrapper := struct {
XMLName xml.Name `xml:"xml"`
Encrypt string `xml:"Encrypt"`
@@ -435,20 +422,29 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
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)
}
@@ -458,8 +454,7 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
})
t.Run("valid group message callback", func(t *testing.T) {
- // Create JSON message for group chat
- jsonMsg := `{
+ w := runBotMessageCallback(t, `{
"msgid": "test_msg_id_456",
"aibotid": "test_aibot_id",
"chatid": "group_chat_id_123",
@@ -468,33 +463,7 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
"msgtype": "text",
"text": {"content": "Hello Group"}
- }`
-
- // Encrypt message
- encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
-
- // Create encrypted XML wrapper
- encryptedWrapper := struct {
- XMLName xml.Name `xml:"xml"`
- Encrypt string `xml:"Encrypt"`
- }{
- Encrypt: encrypted,
- }
- wrapperData, _ := xml.Marshal(encryptedWrapper)
-
- timestamp := "1234567890"
- nonce := "test_nonce"
- signature := generateSignature("test_token", timestamp, nonce, encrypted)
-
- req := httptest.NewRequest(
- http.MethodPost,
- "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
- bytes.NewReader(wrapperData),
- )
- w := httptest.NewRecorder()
-
- ch.handleMessageCallback(context.Background(), w, req)
-
+ }`)
if w.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
}
diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go
index 39a27d04c..6510e6f81 100644
--- a/pkg/channels/wecom/common.go
+++ b/pkg/channels/wecom/common.go
@@ -1,12 +1,15 @@
package wecom
import (
+ "bytes"
"crypto/aes"
"crypto/cipher"
+ "crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"fmt"
+ "math/big"
"sort"
"strings"
)
@@ -14,25 +17,23 @@ import (
// 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
}
-
- // Sort parameters
- params := []string{token, timestamp, nonce, msgEncrypt}
- sort.Strings(params)
-
- // Concatenate
- str := strings.Join(params, "")
-
- // SHA1 hash
- hash := sha1.Sum([]byte(str))
- expectedSignature := fmt.Sprintf("%x", hash)
-
- return expectedSignature == msgSignature
+ return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature
}
// decryptMessage decrypts the encrypted message using AES
@@ -53,64 +54,128 @@ func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (s
return string(decoded), nil
}
- // Decode AES key (base64)
- aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
+ aesKey, err := decodeWeComAESKey(encodingAESKey)
if err != nil {
- return "", fmt.Errorf("failed to decode AES key: %w", err)
+ return "", err
}
- // Decode encrypted message
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
if err != nil {
return "", fmt.Errorf("failed to decode message: %w", err)
}
- // AES decrypt
+ 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 "", fmt.Errorf("failed to create cipher: %w", err)
+ return nil, fmt.Errorf("failed to create cipher: %w", err)
}
-
- if len(cipherText) < aes.BlockSize {
- return "", fmt.Errorf("ciphertext too short")
- }
-
- // IV is the first 16 bytes of AESKey
iv := aesKey[:aes.BlockSize]
- mode := cipher.NewCBCDecrypter(block, iv)
- plainText := make([]byte, len(cipherText))
- mode.CryptBlocks(plainText, cipherText)
+ ciphertext := make([]byte, len(plaintext))
+ cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plaintext)
+ return ciphertext, nil
+}
- // Remove PKCS7 padding
- plainText, err = pkcs7Unpad(plainText)
- if err != nil {
- return "", fmt.Errorf("failed to unpad: %w", err)
+// 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
+}
- // Parse message structure
- // Format: random(16) + msg_len(4) + msg + receiveid
- if len(plainText) < 20 {
- return "", fmt.Errorf("decrypted message too short")
+// 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(plainText[16:20])
- if int(msgLen) > len(plainText)-20 {
- return "", fmt.Errorf("invalid message length")
+ msgLen := binary.BigEndian.Uint32(data[16:20])
+ if int(msgLen) > len(data)-20 {
+ return "", fmt.Errorf("invalid message length: %d", msgLen)
}
-
- msg := plainText[20 : 20+msgLen]
-
- // Verify receiveid if provided
- if receiveid != "" && len(plainText) > 20+int(msgLen) {
- actualReceiveID := string(plainText[20+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 {
diff --git a/pkg/channels/wecom/dedupe.go b/pkg/channels/wecom/dedupe.go
new file mode 100644
index 000000000..865be668e
--- /dev/null
+++ b/pkg/channels/wecom/dedupe.go
@@ -0,0 +1,54 @@
+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
new file mode 100644
index 000000000..10dff4cfe
--- /dev/null
+++ b/pkg/channels/wecom/dedupe_test.go
@@ -0,0 +1,83 @@
+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
index 3ef1ecdf3..bc5a70fa3 100644
--- a/pkg/channels/wecom/init.go
+++ b/pkg/channels/wecom/init.go
@@ -13,4 +13,7 @@ func init() {
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/channels/whatsapp/whatsapp_command_test.go b/pkg/channels/whatsapp/whatsapp_command_test.go
new file mode 100644
index 000000000..ee8aa4a52
--- /dev/null
+++ b/pkg/channels/whatsapp/whatsapp_command_test.go
@@ -0,0 +1,41 @@
+package whatsapp
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
+ messageBus := bus.NewMessageBus()
+ ch := &WhatsAppChannel{
+ BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppConfig{}, messageBus, nil),
+ ctx: context.Background(),
+ }
+
+ ch.handleIncomingMessage(map[string]any{
+ "type": "message",
+ "id": "mid1",
+ "from": "user1",
+ "chat": "chat1",
+ "content": "/help",
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ inbound, ok := messageBus.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("expected inbound message to be forwarded")
+ }
+ if inbound.Channel != "whatsapp" {
+ t.Fatalf("channel=%q", inbound.Channel)
+ }
+ if inbound.Content != "/help" {
+ t.Fatalf("content=%q", inbound.Content)
+ }
+}
diff --git a/pkg/channels/whatsapp_native/whatsapp_command_test.go b/pkg/channels/whatsapp_native/whatsapp_command_test.go
new file mode 100644
index 000000000..cc2dcb619
--- /dev/null
+++ b/pkg/channels/whatsapp_native/whatsapp_command_test.go
@@ -0,0 +1,56 @@
+//go:build whatsapp_native
+
+package whatsapp
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "go.mau.fi/whatsmeow/proto/waE2E"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+ "google.golang.org/protobuf/proto"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
+ messageBus := bus.NewMessageBus()
+ ch := &WhatsAppNativeChannel{
+ BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppConfig{}, messageBus, nil),
+ runCtx: context.Background(),
+ }
+
+ evt := &events.Message{
+ Info: types.MessageInfo{
+ MessageSource: types.MessageSource{
+ Sender: types.NewJID("1001", types.DefaultUserServer),
+ Chat: types.NewJID("1001", types.DefaultUserServer),
+ },
+ ID: "mid1",
+ PushName: "Alice",
+ },
+ Message: &waE2E.Message{
+ Conversation: proto.String("/new"),
+ },
+ }
+
+ ch.handleIncoming(evt)
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ inbound, ok := messageBus.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("expected inbound message to be forwarded")
+ }
+ if inbound.Channel != "whatsapp_native" {
+ t.Fatalf("channel=%q", inbound.Channel)
+ }
+ if inbound.Content != "/new" {
+ t.Fatalf("content=%q", inbound.Content)
+ }
+}
diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go
new file mode 100644
index 000000000..a36dd3eba
--- /dev/null
+++ b/pkg/commands/builtin.go
@@ -0,0 +1,16 @@
+package commands
+
+// BuiltinDefinitions returns all built-in command definitions.
+// Each command group is defined in its own cmd_*.go file.
+// Definitions are stateless — runtime dependencies are provided
+// via the Runtime parameter passed to handlers at execution time.
+func BuiltinDefinitions() []Definition {
+ return []Definition{
+ startCommand(),
+ helpCommand(),
+ showCommand(),
+ listCommand(),
+ switchCommand(),
+ checkCommand(),
+ }
+}
diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go
new file mode 100644
index 000000000..66a84825e
--- /dev/null
+++ b/pkg/commands/builtin_test.go
@@ -0,0 +1,145 @@
+package commands
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func findDefinitionByName(t *testing.T, defs []Definition, name string) Definition {
+ t.Helper()
+ for _, def := range defs {
+ if def.Name == name {
+ return def
+ }
+ }
+ t.Fatalf("missing /%s definition", name)
+ return Definition{}
+}
+
+func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
+ defs := BuiltinDefinitions()
+ helpDef := findDefinitionByName(t, defs, "help")
+ if helpDef.Handler == nil {
+ t.Fatalf("/help handler should not be nil")
+ }
+
+ var reply string
+ err := helpDef.Handler(context.Background(), Request{
+ Text: "/help",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ }, nil)
+ if err != nil {
+ t.Fatalf("/help handler error: %v", err)
+ }
+ // Now uses auto-generated EffectiveUsage which includes agents
+ if !strings.Contains(reply, "/show [model|channel|agents]") {
+ t.Fatalf("/help reply missing /show usage, got %q", reply)
+ }
+ if !strings.Contains(reply, "/list [models|channels|agents]") {
+ t.Fatalf("/help reply missing /list usage, got %q", reply)
+ }
+}
+
+func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
+ defs := BuiltinDefinitions()
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ cases := []string{"telegram", "whatsapp"}
+ for _, channel := range cases {
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Channel: channel,
+ Text: "/show channel",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("/show channel on %s: outcome=%v, want=%v", channel, res.Outcome, OutcomeHandled)
+ }
+ want := "Current Channel: " + channel
+ if reply != want {
+ t.Fatalf("/show channel reply=%q, want=%q", reply, want)
+ }
+ }
+}
+
+func TestBuiltinListChannels_UsesGetEnabledChannels(t *testing.T) {
+ rt := &Runtime{
+ GetEnabledChannels: func() []string {
+ return []string{"telegram", "slack"}
+ },
+ }
+ defs := BuiltinDefinitions()
+ ex := NewExecutor(NewRegistry(defs), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/list channels",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("/list channels: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if !strings.Contains(reply, "telegram") || !strings.Contains(reply, "slack") {
+ t.Fatalf("/list channels reply=%q, want telegram and slack", reply)
+ }
+}
+
+func TestBuiltinShowAgents_RestoresOldBehavior(t *testing.T) {
+ rt := &Runtime{
+ ListAgentIDs: func() []string {
+ return []string{"default", "coder"}
+ },
+ }
+ defs := BuiltinDefinitions()
+ ex := NewExecutor(NewRegistry(defs), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/show agents",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("/show agents: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if !strings.Contains(reply, "default") || !strings.Contains(reply, "coder") {
+ t.Fatalf("/show agents reply=%q, want agent IDs", reply)
+ }
+}
+
+func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) {
+ rt := &Runtime{
+ ListAgentIDs: func() []string {
+ return []string{"default", "coder"}
+ },
+ }
+ defs := BuiltinDefinitions()
+ ex := NewExecutor(NewRegistry(defs), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/list agents",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("/list agents: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if !strings.Contains(reply, "default") || !strings.Contains(reply, "coder") {
+ t.Fatalf("/list agents reply=%q, want agent IDs", reply)
+ }
+}
diff --git a/pkg/commands/cmd_check.go b/pkg/commands/cmd_check.go
new file mode 100644
index 000000000..f0193dc4f
--- /dev/null
+++ b/pkg/commands/cmd_check.go
@@ -0,0 +1,33 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+)
+
+func checkCommand() Definition {
+ return Definition{
+ Name: "check",
+ Description: "Check channel availability",
+ SubCommands: []SubCommand{
+ {
+ Name: "channel",
+ Description: "Check if a channel is available",
+ ArgsUsage: "",
+ Handler: func(_ context.Context, req Request, rt *Runtime) error {
+ if rt == nil || rt.SwitchChannel == nil {
+ return req.Reply(unavailableMsg)
+ }
+ value := nthToken(req.Text, 2)
+ if value == "" {
+ return req.Reply("Usage: /check channel ")
+ }
+ if err := rt.SwitchChannel(value); err != nil {
+ return req.Reply(err.Error())
+ }
+ return req.Reply(fmt.Sprintf("Channel '%s' is available and enabled", value))
+ },
+ },
+ },
+ }
+}
diff --git a/pkg/commands/cmd_help.go b/pkg/commands/cmd_help.go
new file mode 100644
index 000000000..94f7f0101
--- /dev/null
+++ b/pkg/commands/cmd_help.go
@@ -0,0 +1,44 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+func helpCommand() Definition {
+ return Definition{
+ Name: "help",
+ Description: "Show this help message",
+ Usage: "/help",
+ Handler: func(_ context.Context, req Request, rt *Runtime) error {
+ var defs []Definition
+ if rt != nil && rt.ListDefinitions != nil {
+ defs = rt.ListDefinitions()
+ } else {
+ defs = BuiltinDefinitions()
+ }
+ return req.Reply(formatHelpMessage(defs))
+ },
+ }
+}
+
+func formatHelpMessage(defs []Definition) string {
+ if len(defs) == 0 {
+ return "No commands available."
+ }
+
+ lines := make([]string, 0, len(defs))
+ for _, def := range defs {
+ usage := def.EffectiveUsage()
+ if usage == "" {
+ usage = "/" + def.Name
+ }
+ desc := def.Description
+ if desc == "" {
+ desc = "No description"
+ }
+ lines = append(lines, fmt.Sprintf("%s - %s", usage, desc))
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/pkg/commands/cmd_list.go b/pkg/commands/cmd_list.go
new file mode 100644
index 000000000..bf47b6e9c
--- /dev/null
+++ b/pkg/commands/cmd_list.go
@@ -0,0 +1,52 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+func listCommand() Definition {
+ return Definition{
+ Name: "list",
+ Description: "List available options",
+ SubCommands: []SubCommand{
+ {
+ Name: "models",
+ Description: "Configured models",
+ Handler: func(_ context.Context, req Request, rt *Runtime) error {
+ if rt == nil || rt.GetModelInfo == nil {
+ return req.Reply(unavailableMsg)
+ }
+ name, provider := rt.GetModelInfo()
+ if provider == "" {
+ provider = "configured default"
+ }
+ return req.Reply(fmt.Sprintf(
+ "Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
+ name, provider,
+ ))
+ },
+ },
+ {
+ Name: "channels",
+ Description: "Enabled channels",
+ Handler: func(_ context.Context, req Request, rt *Runtime) error {
+ if rt == nil || rt.GetEnabledChannels == nil {
+ return req.Reply(unavailableMsg)
+ }
+ enabled := rt.GetEnabledChannels()
+ if len(enabled) == 0 {
+ return req.Reply("No channels enabled")
+ }
+ return req.Reply(fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")))
+ },
+ },
+ {
+ Name: "agents",
+ Description: "Registered agents",
+ Handler: agentsHandler(),
+ },
+ },
+ }
+}
diff --git a/pkg/commands/cmd_show.go b/pkg/commands/cmd_show.go
new file mode 100644
index 000000000..c655e6880
--- /dev/null
+++ b/pkg/commands/cmd_show.go
@@ -0,0 +1,38 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+)
+
+func showCommand() Definition {
+ return Definition{
+ Name: "show",
+ Description: "Show current configuration",
+ SubCommands: []SubCommand{
+ {
+ Name: "model",
+ Description: "Current model and provider",
+ Handler: func(_ context.Context, req Request, rt *Runtime) error {
+ if rt == nil || rt.GetModelInfo == nil {
+ return req.Reply(unavailableMsg)
+ }
+ name, provider := rt.GetModelInfo()
+ return req.Reply(fmt.Sprintf("Current Model: %s (Provider: %s)", name, provider))
+ },
+ },
+ {
+ Name: "channel",
+ Description: "Current channel",
+ Handler: func(_ context.Context, req Request, _ *Runtime) error {
+ return req.Reply(fmt.Sprintf("Current Channel: %s", req.Channel))
+ },
+ },
+ {
+ Name: "agents",
+ Description: "Registered agents",
+ Handler: agentsHandler(),
+ },
+ },
+ }
+}
diff --git a/pkg/commands/cmd_start.go b/pkg/commands/cmd_start.go
new file mode 100644
index 000000000..8b500aa10
--- /dev/null
+++ b/pkg/commands/cmd_start.go
@@ -0,0 +1,14 @@
+package commands
+
+import "context"
+
+func startCommand() Definition {
+ return Definition{
+ Name: "start",
+ Description: "Start the bot",
+ Usage: "/start",
+ Handler: func(_ context.Context, req Request, _ *Runtime) error {
+ return req.Reply("Hello! I am PicoClaw 🦞")
+ },
+ }
+}
diff --git a/pkg/commands/cmd_switch.go b/pkg/commands/cmd_switch.go
new file mode 100644
index 000000000..fb8fc109e
--- /dev/null
+++ b/pkg/commands/cmd_switch.go
@@ -0,0 +1,42 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+)
+
+func switchCommand() Definition {
+ return Definition{
+ Name: "switch",
+ Description: "Switch model",
+ SubCommands: []SubCommand{
+ {
+ Name: "model",
+ Description: "Switch to a different model",
+ ArgsUsage: "to ",
+ Handler: func(_ context.Context, req Request, rt *Runtime) error {
+ if rt == nil || rt.SwitchModel == nil {
+ return req.Reply(unavailableMsg)
+ }
+ // Parse: /switch model to
+ value := nthToken(req.Text, 3) // tokens: [/switch, model, to, ]
+ if nthToken(req.Text, 2) != "to" || value == "" {
+ return req.Reply("Usage: /switch model to ")
+ }
+ oldModel, err := rt.SwitchModel(value)
+ if err != nil {
+ return req.Reply(err.Error())
+ }
+ return req.Reply(fmt.Sprintf("Switched model from %s to %s", oldModel, value))
+ },
+ },
+ {
+ Name: "channel",
+ Description: "Moved to /check channel",
+ Handler: func(_ context.Context, req Request, _ *Runtime) error {
+ return req.Reply("This command has moved. Please use: /check channel ")
+ },
+ },
+ },
+ }
+}
diff --git a/pkg/commands/cmd_switch_test.go b/pkg/commands/cmd_switch_test.go
new file mode 100644
index 000000000..59ed305bb
--- /dev/null
+++ b/pkg/commands/cmd_switch_test.go
@@ -0,0 +1,279 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+ "testing"
+)
+
+func TestSwitchModel_Success(t *testing.T) {
+ rt := &Runtime{
+ SwitchModel: func(value string) (string, error) {
+ return "old-model", nil
+ },
+ }
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/switch model to gpt-4",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ want := "Switched model from old-model to gpt-4"
+ if reply != want {
+ t.Fatalf("reply=%q, want=%q", reply, want)
+ }
+}
+
+func TestSwitchModel_MissingToKeyword(t *testing.T) {
+ rt := &Runtime{
+ SwitchModel: func(value string) (string, error) {
+ return "old", nil
+ },
+ }
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/switch model gpt-4",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if reply != "Usage: /switch model to " {
+ t.Fatalf("reply=%q, want usage message", reply)
+ }
+}
+
+func TestSwitchModel_MissingValue(t *testing.T) {
+ rt := &Runtime{
+ SwitchModel: func(value string) (string, error) {
+ return "old", nil
+ },
+ }
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/switch model to",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if reply != "Usage: /switch model to " {
+ t.Fatalf("reply=%q, want usage message", reply)
+ }
+}
+
+func TestSwitchModel_Error(t *testing.T) {
+ rt := &Runtime{
+ SwitchModel: func(value string) (string, error) {
+ return "", fmt.Errorf("model not found")
+ },
+ }
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/switch model to bad-model",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if reply != "model not found" {
+ t.Fatalf("reply=%q, want error message", reply)
+ }
+}
+
+func TestSwitchModel_NilDep(t *testing.T) {
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/switch model to gpt-4",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if reply != "Command unavailable in current context." {
+ t.Fatalf("reply=%q, want unavailable message", reply)
+ }
+}
+
+func TestSwitchChannel_Redirect(t *testing.T) {
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/switch channel to telegram",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ want := "This command has moved. Please use: /check channel "
+ if reply != want {
+ t.Fatalf("reply=%q, want=%q", reply, want)
+ }
+}
+
+func TestCheckChannel_Success(t *testing.T) {
+ rt := &Runtime{
+ SwitchChannel: func(value string) error {
+ return nil
+ },
+ }
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/check channel telegram",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ want := "Channel 'telegram' is available and enabled"
+ if reply != want {
+ t.Fatalf("reply=%q, want=%q", reply, want)
+ }
+}
+
+func TestCheckChannel_Error(t *testing.T) {
+ rt := &Runtime{
+ SwitchChannel: func(value string) error {
+ return fmt.Errorf("channel '%s' not found", value)
+ },
+ }
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/check channel unknown",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if reply != "channel 'unknown' not found" {
+ t.Fatalf("reply=%q, want error message", reply)
+ }
+}
+
+func TestCheckChannel_NilDep(t *testing.T) {
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/check channel telegram",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if reply != "Command unavailable in current context." {
+ t.Fatalf("reply=%q, want unavailable message", reply)
+ }
+}
+
+func TestCheckChannel_MissingValue(t *testing.T) {
+ rt := &Runtime{
+ SwitchChannel: func(value string) error {
+ return nil
+ },
+ }
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/check channel",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if reply != "Usage: /check channel " {
+ t.Fatalf("reply=%q, want usage message", reply)
+ }
+}
+
+func TestSwitch_BangPrefix(t *testing.T) {
+ rt := &Runtime{
+ SwitchModel: func(value string) (string, error) {
+ return "old", nil
+ },
+ }
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "!switch model to gpt-4",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("! prefix: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if reply != "Switched model from old to gpt-4" {
+ t.Fatalf("! prefix: reply=%q, want success message", reply)
+ }
+}
+
+func TestSwitch_NoSubCommand(t *testing.T) {
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/switch",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ // Should get usage message from executor's sub-command routing
+ if reply == "" {
+ t.Fatal("expected usage reply for bare /switch")
+ }
+}
diff --git a/pkg/commands/definition.go b/pkg/commands/definition.go
new file mode 100644
index 000000000..7309df317
--- /dev/null
+++ b/pkg/commands/definition.go
@@ -0,0 +1,48 @@
+package commands
+
+import (
+ "fmt"
+ "strings"
+)
+
+// SubCommand defines a single sub-command within a parent command.
+type SubCommand struct {
+ Name string
+ Description string
+ ArgsUsage string // optional, e.g. ""
+ Handler Handler
+}
+
+// Definition is the single-source metadata and behavior contract for a slash command.
+//
+// Design notes (phase 1):
+// - Every channel reads command shape from this type instead of keeping local copies.
+// - Visibility is global: all definitions are considered available to all channels.
+// - Platform menu registration (for example Telegram BotCommand) also derives from this
+// same definition so UI labels and runtime behavior stay aligned.
+type Definition struct {
+ Name string
+ Description string
+ Usage string // for simple commands; ignored when SubCommands is set
+ Aliases []string
+ SubCommands []SubCommand // optional; when set, Executor routes to sub-command handlers
+ Handler Handler // for simple commands without sub-commands
+}
+
+// EffectiveUsage returns the usage string. When SubCommands are present,
+// it is auto-generated from sub-command names so metadata and behavior
+// cannot drift.
+func (d Definition) EffectiveUsage() string {
+ if len(d.SubCommands) == 0 {
+ return d.Usage
+ }
+ names := make([]string, 0, len(d.SubCommands))
+ for _, sc := range d.SubCommands {
+ name := sc.Name
+ if sc.ArgsUsage != "" {
+ name += " " + sc.ArgsUsage
+ }
+ names = append(names, name)
+ }
+ return fmt.Sprintf("/%s [%s]", d.Name, strings.Join(names, "|"))
+}
diff --git a/pkg/commands/definition_test.go b/pkg/commands/definition_test.go
new file mode 100644
index 000000000..27ad4a0a2
--- /dev/null
+++ b/pkg/commands/definition_test.go
@@ -0,0 +1,41 @@
+package commands
+
+import (
+ "testing"
+)
+
+func TestDefinition_EffectiveUsage_NoSubCommands(t *testing.T) {
+ d := Definition{Name: "start", Usage: "/start"}
+ if got := d.EffectiveUsage(); got != "/start" {
+ t.Fatalf("EffectiveUsage()=%q, want %q", got, "/start")
+ }
+}
+
+func TestDefinition_EffectiveUsage_WithSubCommands(t *testing.T) {
+ d := Definition{
+ Name: "show",
+ SubCommands: []SubCommand{
+ {Name: "model"},
+ {Name: "channel"},
+ {Name: "agents"},
+ },
+ }
+ want := "/show [model|channel|agents]"
+ if got := d.EffectiveUsage(); got != want {
+ t.Fatalf("EffectiveUsage()=%q, want %q", got, want)
+ }
+}
+
+func TestDefinition_EffectiveUsage_WithArgsUsage(t *testing.T) {
+ d := Definition{
+ Name: "session",
+ SubCommands: []SubCommand{
+ {Name: "list"},
+ {Name: "resume", ArgsUsage: ""},
+ },
+ }
+ want := "/session [list|resume ]"
+ if got := d.EffectiveUsage(); got != want {
+ t.Fatalf("EffectiveUsage()=%q, want %q", got, want)
+ }
+}
diff --git a/pkg/commands/executor.go b/pkg/commands/executor.go
new file mode 100644
index 000000000..78a50e6c2
--- /dev/null
+++ b/pkg/commands/executor.go
@@ -0,0 +1,89 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+)
+
+type Outcome int
+
+const (
+ // OutcomePassthrough means this input should continue through normal agent flow.
+ OutcomePassthrough Outcome = iota
+ // OutcomeHandled means a command handler executed (with or without handler error).
+ OutcomeHandled
+)
+
+type ExecuteResult struct {
+ Outcome Outcome
+ Command string
+ Err error
+}
+
+type Executor struct {
+ reg *Registry
+ rt *Runtime
+}
+
+func NewExecutor(reg *Registry, rt *Runtime) *Executor {
+ return &Executor{reg: reg, rt: rt}
+}
+
+// Execute implements a two-state command decision:
+// 1) handled: execute command immediately;
+// 2) passthrough: not a command or intentionally deferred to agent logic.
+func (e *Executor) Execute(ctx context.Context, req Request) ExecuteResult {
+ cmdName, ok := parseCommandName(req.Text)
+ if !ok {
+ return ExecuteResult{Outcome: OutcomePassthrough}
+ }
+
+ if e == nil || e.reg == nil {
+ return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
+ }
+
+ def, found := e.reg.Lookup(cmdName)
+ if !found {
+ return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
+ }
+
+ return e.executeDefinition(ctx, req, def)
+}
+
+func (e *Executor) executeDefinition(ctx context.Context, req Request, def Definition) ExecuteResult {
+ // Ensure Reply is always non-nil so handlers don't need to check.
+ if req.Reply == nil {
+ req.Reply = func(string) error { return nil }
+ }
+
+ // Simple command — no sub-commands
+ if len(def.SubCommands) == 0 {
+ if def.Handler == nil {
+ return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}
+ }
+ err := def.Handler(ctx, req, e.rt)
+ return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
+ }
+
+ // Sub-command routing
+ subName := nthToken(req.Text, 1)
+ if subName == "" {
+ err := req.Reply("Usage: " + def.EffectiveUsage())
+ return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
+ }
+
+ normalized := normalizeCommandName(subName)
+ for _, sc := range def.SubCommands {
+ if normalizeCommandName(sc.Name) == normalized {
+ if sc.Handler == nil {
+ return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}
+ }
+ err := sc.Handler(ctx, req, e.rt)
+ return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
+ }
+ }
+
+ // Unknown sub-command
+ err := req.Reply(fmt.Sprintf("Unknown option: %s. Usage: %s", subName, def.EffectiveUsage()))
+ return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
+}
diff --git a/pkg/commands/executor_test.go b/pkg/commands/executor_test.go
new file mode 100644
index 000000000..09350f1b6
--- /dev/null
+++ b/pkg/commands/executor_test.go
@@ -0,0 +1,260 @@
+package commands
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestExecutor_RegisteredWithoutHandler_ReturnsPassthrough(t *testing.T) {
+ defs := []Definition{{Name: "show"}}
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/show"})
+ if res.Outcome != OutcomePassthrough {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
+ }
+}
+
+func TestExecutor_UnknownSlashCommand_ReturnsPassthrough(t *testing.T) {
+ defs := []Definition{{Name: "show"}}
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/unknown"})
+ if res.Outcome != OutcomePassthrough {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
+ }
+}
+
+func TestExecutor_SupportedCommandWithHandler_ReturnsHandled(t *testing.T) {
+ called := false
+ defs := []Definition{
+ {
+ Name: "help",
+ Handler: func(context.Context, Request, *Runtime) error {
+ called = true
+ return nil
+ },
+ },
+ }
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help@my_bot"})
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if !called {
+ t.Fatalf("expected handler to be called")
+ }
+}
+
+func TestExecutor_AliasWithoutHandler_ReturnsPassthrough(t *testing.T) {
+ defs := []Definition{
+ {
+ Name: "show",
+ Aliases: []string{"display"},
+ },
+ }
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/display"})
+ if res.Outcome != OutcomePassthrough {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
+ }
+ if res.Command != "show" {
+ t.Fatalf("command=%q, want=%q", res.Command, "show")
+ }
+}
+
+func TestExecutor_AliasWithHandler_ReturnsHandled(t *testing.T) {
+ called := false
+ defs := []Definition{
+ {
+ Name: "clear",
+ Aliases: []string{"reset"},
+ Handler: func(context.Context, Request, *Runtime) error {
+ called = true
+ return nil
+ },
+ },
+ }
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/reset"})
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if res.Command != "clear" {
+ t.Fatalf("command=%q, want=%q", res.Command, "clear")
+ }
+ if !called {
+ t.Fatalf("expected handler to be called")
+ }
+}
+
+func TestExecutor_SupportedCommandWithNilHandler_ReturnsPassthrough(t *testing.T) {
+ defs := []Definition{
+ {Name: "placeholder"},
+ }
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder list"})
+ if res.Outcome != OutcomePassthrough {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
+ }
+ if res.Command != "placeholder" {
+ t.Fatalf("command=%q, want=%q", res.Command, "placeholder")
+ }
+}
+
+func TestExecutor_NilHandlerDoesNotMaskLaterHandler(t *testing.T) {
+ // With Lookup-based dispatch, the first registered definition for a name wins.
+ // A definition with nil Handler and no SubCommands returns Passthrough.
+ defs := []Definition{
+ {Name: "placeholder"},
+ }
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder"})
+ if res.Outcome != OutcomePassthrough {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
+ }
+ if res.Command != "placeholder" {
+ t.Fatalf("command=%q, want=%q", res.Command, "placeholder")
+ }
+}
+
+func TestExecutor_HandlerErrorIsPropagated(t *testing.T) {
+ wantErr := errors.New("handler failed")
+ defs := []Definition{
+ {
+ Name: "help",
+ Handler: func(context.Context, Request, *Runtime) error {
+ return wantErr
+ },
+ },
+ }
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help"})
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if !errors.Is(res.Err, wantErr) {
+ t.Fatalf("err=%v, want=%v", res.Err, wantErr)
+ }
+}
+
+func TestExecutor_SupportsBangPrefixAndCaseInsensitiveCommand(t *testing.T) {
+ called := false
+ defs := []Definition{
+ {
+ Name: "help",
+ Handler: func(context.Context, Request, *Runtime) error {
+ called = true
+ return nil
+ },
+ },
+ }
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "!HELP"})
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if !called {
+ t.Fatalf("expected handler to be called")
+ }
+}
+
+func TestExecutor_SubCommand_RoutesToCorrectHandler(t *testing.T) {
+ modelCalled := false
+ defs := []Definition{
+ {
+ Name: "show",
+ SubCommands: []SubCommand{
+ {Name: "model", Handler: func(_ context.Context, _ Request, _ *Runtime) error {
+ modelCalled = true
+ return nil
+ }},
+ {Name: "channel"},
+ },
+ },
+ }
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{Text: "/show model"})
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if !modelCalled {
+ t.Fatal("model sub-command handler was not called")
+ }
+}
+
+func TestExecutor_SubCommand_NoArg_RepliesUsage(t *testing.T) {
+ defs := []Definition{
+ {
+ Name: "show",
+ SubCommands: []SubCommand{
+ {Name: "model"},
+ {Name: "channel"},
+ },
+ },
+ }
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/show",
+ Reply: func(text string) error { reply = text; return nil },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if reply != "Usage: /show [model|channel]" {
+ t.Fatalf("reply=%q, want usage message", reply)
+ }
+}
+
+func TestExecutor_SubCommand_UnknownArg_RepliesError(t *testing.T) {
+ defs := []Definition{
+ {
+ Name: "show",
+ SubCommands: []SubCommand{
+ {Name: "model"},
+ },
+ },
+ }
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/show foobar",
+ Reply: func(text string) error { reply = text; return nil },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if !strings.Contains(reply, "foobar") {
+ t.Fatalf("reply=%q, should mention unknown sub-command", reply)
+ }
+}
+
+func TestExecutor_SubCommand_NilHandler_ReturnsPassthrough(t *testing.T) {
+ defs := []Definition{
+ {
+ Name: "show",
+ SubCommands: []SubCommand{
+ {Name: "model"}, // nil Handler
+ },
+ },
+ }
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{Text: "/show model"})
+ if res.Outcome != OutcomePassthrough {
+ t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
+ }
+}
diff --git a/pkg/commands/handler_agents.go b/pkg/commands/handler_agents.go
new file mode 100644
index 000000000..c459516eb
--- /dev/null
+++ b/pkg/commands/handler_agents.go
@@ -0,0 +1,21 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+// agentsHandler returns a shared handler for both /show agents and /list agents.
+func agentsHandler() Handler {
+ return func(_ context.Context, req Request, rt *Runtime) error {
+ if rt == nil || rt.ListAgentIDs == nil {
+ return req.Reply(unavailableMsg)
+ }
+ ids := rt.ListAgentIDs()
+ if len(ids) == 0 {
+ return req.Reply("No agents registered")
+ }
+ return req.Reply(fmt.Sprintf("Registered agents: %s", strings.Join(ids, ", ")))
+ }
+}
diff --git a/pkg/commands/registry.go b/pkg/commands/registry.go
new file mode 100644
index 000000000..e17d489a6
--- /dev/null
+++ b/pkg/commands/registry.go
@@ -0,0 +1,55 @@
+package commands
+
+type Registry struct {
+ defs []Definition
+ index map[string]int
+}
+
+// NewRegistry stores the canonical command set used by both dispatch and
+// optional platform registration adapters.
+func NewRegistry(defs []Definition) *Registry {
+ stored := make([]Definition, len(defs))
+ copy(stored, defs)
+
+ index := make(map[string]int, len(stored)*2)
+ for i, def := range stored {
+ registerCommandName(index, def.Name, i)
+ for _, alias := range def.Aliases {
+ registerCommandName(index, alias, i)
+ }
+ }
+
+ return &Registry{defs: stored, index: index}
+}
+
+// Definitions returns all registered command definitions.
+// Command availability is global and no longer channel-scoped.
+func (r *Registry) Definitions() []Definition {
+ out := make([]Definition, len(r.defs))
+ copy(out, r.defs)
+ return out
+}
+
+// Lookup returns a command definition by normalized command name or alias.
+func (r *Registry) Lookup(name string) (Definition, bool) {
+ key := normalizeCommandName(name)
+ if key == "" {
+ return Definition{}, false
+ }
+ idx, ok := r.index[key]
+ if !ok {
+ return Definition{}, false
+ }
+ return r.defs[idx], true
+}
+
+func registerCommandName(index map[string]int, name string, defIndex int) {
+ key := normalizeCommandName(name)
+ if key == "" {
+ return
+ }
+ if _, exists := index[key]; exists {
+ return
+ }
+ index[key] = defIndex
+}
diff --git a/pkg/commands/registry_test.go b/pkg/commands/registry_test.go
new file mode 100644
index 000000000..bfff76b7c
--- /dev/null
+++ b/pkg/commands/registry_test.go
@@ -0,0 +1,49 @@
+package commands
+
+import "testing"
+
+func TestRegistry_Definitions_ReturnsCopy(t *testing.T) {
+ defs := []Definition{
+ {Name: "help", Description: "Show help"},
+ {Name: "admin", Description: "Admin command"},
+ }
+ r := NewRegistry(defs)
+
+ got := r.Definitions()
+ if len(got) != 2 {
+ t.Fatalf("definitions len = %d, want 2", len(got))
+ }
+
+ got[0].Name = "mutated"
+ again := r.Definitions()
+ if again[0].Name != "help" {
+ t.Fatalf("registry should not be mutated by caller, got first name %q", again[0].Name)
+ }
+}
+
+func TestRegistry_Lookup_MatchesByLowercaseNameAndAlias(t *testing.T) {
+ r := NewRegistry([]Definition{
+ {Name: "Help", Aliases: []string{"Assist"}},
+ {Name: "List"},
+ })
+
+ def, ok := r.Lookup("help")
+ if !ok || def.Name != "Help" {
+ t.Fatalf("lookup by lowercase name failed: ok=%v def=%+v", ok, def)
+ }
+
+ def, ok = r.Lookup("HELP")
+ if !ok || def.Name != "Help" {
+ t.Fatalf("lookup by uppercase name failed: ok=%v def=%+v", ok, def)
+ }
+
+ def, ok = r.Lookup("assist")
+ if !ok || def.Name != "Help" {
+ t.Fatalf("lookup by lowercase alias failed: ok=%v def=%+v", ok, def)
+ }
+
+ def, ok = r.Lookup("ASSIST")
+ if !ok || def.Name != "Help" {
+ t.Fatalf("lookup by uppercase alias failed: ok=%v def=%+v", ok, def)
+ }
+}
diff --git a/pkg/commands/request.go b/pkg/commands/request.go
new file mode 100644
index 000000000..62ee600f2
--- /dev/null
+++ b/pkg/commands/request.go
@@ -0,0 +1,75 @@
+package commands
+
+import (
+ "context"
+ "strings"
+)
+
+type Handler func(ctx context.Context, req Request, rt *Runtime) error
+
+type Request struct {
+ Channel string
+ ChatID string
+ SenderID string
+ Text string
+ Reply func(text string) error
+}
+
+const unavailableMsg = "Command unavailable in current context."
+
+var commandPrefixes = []string{"/", "!"}
+
+// parseCommandName accepts "/name", "!name", and Telegram's "/name@bot", then
+// normalizes to lowercase command names.
+func parseCommandName(input string) (string, bool) {
+ token := nthToken(input, 0)
+ if token == "" {
+ return "", false
+ }
+
+ name, ok := trimCommandPrefix(token)
+ if !ok {
+ return "", false
+ }
+ if i := strings.Index(name, "@"); i >= 0 {
+ name = name[:i]
+ }
+ name = normalizeCommandName(name)
+ if name == "" {
+ return "", false
+ }
+ return name, true
+}
+
+func trimCommandPrefix(token string) (string, bool) {
+ for _, prefix := range commandPrefixes {
+ if strings.HasPrefix(token, prefix) {
+ return strings.TrimPrefix(token, prefix), true
+ }
+ }
+ return "", false
+}
+
+// HasCommandPrefix returns true if the input starts with a recognized
+// command prefix (e.g. "/" or "!").
+func HasCommandPrefix(input string) bool {
+ token := nthToken(input, 0)
+ if token == "" {
+ return false
+ }
+ _, ok := trimCommandPrefix(token)
+ return ok
+}
+
+// nthToken returns the 0-indexed token from whitespace-split input.
+func nthToken(input string, n int) string {
+ parts := strings.Fields(strings.TrimSpace(input))
+ if n >= len(parts) {
+ return ""
+ }
+ return parts[n]
+}
+
+func normalizeCommandName(name string) string {
+ return strings.ToLower(strings.TrimSpace(name))
+}
diff --git a/pkg/commands/request_test.go b/pkg/commands/request_test.go
new file mode 100644
index 000000000..4389e453b
--- /dev/null
+++ b/pkg/commands/request_test.go
@@ -0,0 +1,28 @@
+package commands
+
+import "testing"
+
+func TestHasCommandPrefix(t *testing.T) {
+ tests := []struct {
+ input string
+ want bool
+ }{
+ {"/help", true},
+ {"!help", true},
+ {"/switch model to gpt-4", true},
+ {"!switch model to gpt-4", true},
+ {"hello", false},
+ {"", false},
+ {" ", false},
+ {"hello /world", false},
+ {"/", true},
+ {"!", true},
+ {" /help", true},
+ }
+ for _, tt := range tests {
+ got := HasCommandPrefix(tt.input)
+ if got != tt.want {
+ t.Errorf("HasCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ }
+}
diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go
new file mode 100644
index 000000000..227d495f4
--- /dev/null
+++ b/pkg/commands/runtime.go
@@ -0,0 +1,16 @@
+package commands
+
+import "github.com/sipeed/picoclaw/pkg/config"
+
+// Runtime provides runtime dependencies to command handlers. It is constructed
+// per-request by the agent loop so that per-request state (like session scope)
+// can coexist with long-lived callbacks (like GetModelInfo).
+type Runtime struct {
+ Config *config.Config
+ GetModelInfo func() (name, provider string)
+ ListAgentIDs func() []string
+ ListDefinitions func() []Definition
+ GetEnabledChannels func() []string
+ SwitchModel func(value string) (oldModel string, err error)
+ SwitchChannel func(value string) error
+}
diff --git a/pkg/commands/show_list_handlers_test.go b/pkg/commands/show_list_handlers_test.go
new file mode 100644
index 000000000..047708f0f
--- /dev/null
+++ b/pkg/commands/show_list_handlers_test.go
@@ -0,0 +1,85 @@
+package commands
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestShowListHandlers_ChannelPolicy(t *testing.T) {
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), nil)
+
+ var telegramReply string
+ handled := ex.Execute(context.Background(), Request{
+ Channel: "telegram",
+ Text: "/show channel",
+ Reply: func(text string) error {
+ telegramReply = text
+ return nil
+ },
+ })
+ if handled.Outcome != OutcomeHandled {
+ t.Fatalf("telegram /show outcome=%v, want=%v", handled.Outcome, OutcomeHandled)
+ }
+ if telegramReply != "Current Channel: telegram" {
+ t.Fatalf("telegram /show reply=%q, want=%q", telegramReply, "Current Channel: telegram")
+ }
+
+ var whatsappReply string
+ handledWhatsApp := ex.Execute(context.Background(), Request{
+ Channel: "whatsapp",
+ Text: "/show channel",
+ Reply: func(text string) error {
+ whatsappReply = text
+ return nil
+ },
+ })
+ if handledWhatsApp.Outcome != OutcomeHandled {
+ t.Fatalf("whatsapp /show outcome=%v, want=%v", handledWhatsApp.Outcome, OutcomeHandled)
+ }
+ if handledWhatsApp.Command != "show" {
+ t.Fatalf("whatsapp /show command=%q, want=%q", handledWhatsApp.Command, "show")
+ }
+ if whatsappReply != "Current Channel: whatsapp" {
+ t.Fatalf("whatsapp /show reply=%q, want=%q", whatsappReply, "Current Channel: whatsapp")
+ }
+
+ passthrough := ex.Execute(context.Background(), Request{
+ Channel: "whatsapp",
+ Text: "/foo",
+ })
+ if passthrough.Outcome != OutcomePassthrough {
+ t.Fatalf("whatsapp /foo outcome=%v, want=%v", passthrough.Outcome, OutcomePassthrough)
+ }
+ if passthrough.Command != "foo" {
+ t.Fatalf("whatsapp /foo command=%q, want=%q", passthrough.Command, "foo")
+ }
+}
+
+func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
+ rt := &Runtime{
+ GetEnabledChannels: func() []string {
+ return []string{"telegram"}
+ },
+ }
+ ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Channel: "whatsapp",
+ Text: "/list channels",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("whatsapp /list outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if res.Command != "list" {
+ t.Fatalf("whatsapp /list command=%q, want=%q", res.Command, "list")
+ }
+ if !strings.Contains(reply, "telegram") {
+ t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply)
+ }
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 1352d034b..f219f364d 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"os"
- "strings"
"sync/atomic"
"github.com/caarlos0/env/v11"
@@ -170,22 +169,48 @@ type SessionConfig struct {
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
}
+// RoutingConfig controls the intelligent model routing feature.
+// When enabled, each incoming message is scored against structural features
+// (message length, code blocks, tool call history, conversation depth, attachments).
+// Messages scoring below Threshold are sent to LightModel; all others use the
+// agent's primary model. This reduces cost and latency for simple tasks without
+// requiring any keyword matching — all scoring is language-agnostic.
+type RoutingConfig struct {
+ Enabled bool `json:"enabled"`
+ LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks
+ Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
+}
+
type AgentDefaults struct {
- Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
- RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
- Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
- ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
- Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
- ModelFallbacks []string `json:"model_fallbacks,omitempty"`
- ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
- ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
- PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
- PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
- MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
- Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
- MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
- TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
- Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
+ Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
+ RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
+ AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
+ Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
+ ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
+ Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
+ ModelFallbacks []string `json:"model_fallbacks,omitempty"`
+ ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
+ ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
+ PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
+ PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
+ MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
+ Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
+ MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
+ TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
+ Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
+ SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
+ SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
+ MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
+ Routing *RoutingConfig `json:"routing,omitempty"`
+}
+
+const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
+
+func (d *AgentDefaults) GetMaxMediaSize() int {
+ if d.MaxMediaSize > 0 {
+ return d.MaxMediaSize
+ }
+ return DefaultMaxMediaSize
}
// GetModelName returns the effective model name for the agent defaults.
@@ -198,19 +223,22 @@ 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"`
- Pico PicoConfig `json:"pico"`
+ 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"`
+ Matrix MatrixConfig `json:"matrix"`
+ LINE LINEConfig `json:"line"`
+ OneBot OneBotConfig `json:"onebot"`
+ WeCom WeComConfig `json:"wecom"`
+ WeComApp WeComAppConfig `json:"wecom_app"`
+ WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
+ Pico PicoConfig `json:"pico"`
+ IRC IRCConfig `json:"irc"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
@@ -238,35 +266,38 @@ type WhatsAppConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
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"`
- Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
- WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
+ 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"`
+ WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
+ 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"`
SubagentThreadID int `json:"subagent_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_SUBAGENT_THREAD_ID"`
HeartbeatThreadID int `json:"heartbeat_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_HEARTBEAT_THREAD_ID"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
+ 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"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
+ 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"`
+ RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
}
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"`
@@ -312,6 +343,19 @@ type SlackConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
}
+type MatrixConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
+ Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
+ UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
+ DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
+ JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_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"`
@@ -369,6 +413,18 @@ type WeComAppConfig struct {
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"`
@@ -382,6 +438,25 @@ type PicoConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
+type IRCConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
+ Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
+ TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"`
+ Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"`
+ User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"`
+ RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"`
+ Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
+ NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
+ SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
+ SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
+ Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
+ RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
+}
+
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
@@ -395,6 +470,7 @@ type DevicesConfig struct {
type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"`
OpenAI OpenAIProviderConfig `json:"openai"`
+ LiteLLM ProviderConfig `json:"litellm"`
OpenRouter ProviderConfig `json:"openrouter"`
Groq ProviderConfig `json:"groq"`
Zhipu ProviderConfig `json:"zhipu"`
@@ -406,11 +482,13 @@ type ProvidersConfig struct {
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
DeepSeek ProviderConfig `json:"deepseek"`
Cerebras ProviderConfig `json:"cerebras"`
+ Vivgrid ProviderConfig `json:"vivgrid"`
VolcEngine ProviderConfig `json:"volcengine"`
GitHubCopilot ProviderConfig `json:"github_copilot"`
Antigravity ProviderConfig `json:"antigravity"`
Qwen ProviderConfig `json:"qwen"`
Mistral ProviderConfig `json:"mistral"`
+ Avian ProviderConfig `json:"avian"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@@ -418,6 +496,7 @@ type ProvidersConfig struct {
func (p ProvidersConfig) IsEmpty() bool {
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
+ p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" &&
p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
@@ -429,11 +508,13 @@ func (p ProvidersConfig) IsEmpty() bool {
p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
+ p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" &&
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
- p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
+ p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
+ p.Avian.APIKey == "" && p.Avian.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
@@ -479,12 +560,12 @@ type ModelConfig struct {
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
-
// Optional optimizations
RPM int `json:"rpm,omitempty"` // Requests per minute limit
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
Stream *bool `json:"stream,omitempty"` // Use SSE streaming (default: protocol-dependent)
RequestTimeout int `json:"request_timeout,omitempty"`
+ ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
}
// Validate checks if the ModelConfig has all required fields.
@@ -503,6 +584,10 @@ type GatewayConfig struct {
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
}
+type ToolConfig struct {
+ Enabled bool `json:"enabled" env:"ENABLED"`
+}
+
type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
@@ -527,43 +612,85 @@ type PerplexityConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
+type SearXNGConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"`
+}
+
+type GLMSearchConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
+ // SearchEngine specifies the search backend: "search_std" (default),
+ // "search_pro", "search_pro_sogou", or "search_pro_quark".
+ SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"`
+}
+
type WebToolsConfig struct {
- Brave BraveConfig `json:"brave"`
- Tavily TavilyConfig `json:"tavily"`
- DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
- Perplexity PerplexityConfig `json:"perplexity"`
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
+ Brave BraveConfig ` json:"brave"`
+ Tavily TavilyConfig ` json:"tavily"`
+ DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
+ Perplexity PerplexityConfig ` json:"perplexity"`
+ SearXNG SearXNGConfig ` json:"searxng"`
+ GLMSearch GLMSearchConfig ` json:"glm_search"`
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
+ FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
}
type CronToolsConfig struct {
- ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
+ ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
}
type ExecConfig struct {
- EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
- CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
-}
-
-type MediaCleanupConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"`
- MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"`
- Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"`
-}
-
-type ToolsConfig struct {
- Web WebToolsConfig `json:"web"`
- Cron CronToolsConfig `json:"cron"`
- Exec ExecConfig `json:"exec"`
- Skills SkillsToolsConfig `json:"skills"`
- MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
+ EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
+ CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
+ CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
+ TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
}
type SkillsToolsConfig struct {
- Registries SkillsRegistriesConfig `json:"registries"`
- MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"`
- SearchCache SearchCacheConfig `json:"search_cache"`
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
+ Registries SkillsRegistriesConfig ` json:"registries"`
+ MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
+ SearchCache SearchCacheConfig ` json:"search_cache"`
+}
+
+type MediaCleanupConfig struct {
+ ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"`
+ MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"`
+ Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
+}
+
+type ToolsConfig struct {
+ AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
+ AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
+ Web WebToolsConfig `json:"web"`
+ Cron CronToolsConfig `json:"cron"`
+ Exec ExecConfig `json:"exec"`
+ Skills SkillsToolsConfig `json:"skills"`
+ MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
+ MCP MCPConfig `json:"mcp"`
+ AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
+ EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
+ FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
+ I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
+ InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
+ ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
+ Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
+ ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
+ SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
+ Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
+ SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
+ Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
+ WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
+ WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
}
type SearchCacheConfig struct {
@@ -587,6 +714,33 @@ type ClawHubRegistryConfig struct {
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
}
+// MCPServerConfig defines configuration for a single MCP server
+type MCPServerConfig struct {
+ // Enabled indicates whether this MCP server is active
+ Enabled bool `json:"enabled"`
+ // Command is the executable to run (e.g., "npx", "python", "/path/to/server")
+ Command string `json:"command"`
+ // Args are the arguments to pass to the command
+ Args []string `json:"args,omitempty"`
+ // Env are environment variables to set for the server process (stdio only)
+ Env map[string]string `json:"env,omitempty"`
+ // EnvFile is the path to a file containing environment variables (stdio only)
+ EnvFile string `json:"env_file,omitempty"`
+ // Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set)
+ Type string `json:"type,omitempty"`
+ // URL is used for SSE/HTTP transport
+ URL string `json:"url,omitempty"`
+ // Headers are HTTP headers to send with requests (sse/http only)
+ Headers map[string]string `json:"headers,omitempty"`
+}
+
+// MCPConfig defines configuration for all MCP servers
+type MCPConfig struct {
+ ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"`
+ // Servers is a map of server name to server configuration
+ Servers map[string]MCPServerConfig `json:"servers,omitempty"`
+}
+
func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig()
@@ -643,7 +797,8 @@ func (c *Config) migrateChannelConfigs() {
}
// OneBot: group_trigger_prefix -> group_trigger.prefixes
- if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
+ if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
+ len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
}
}
@@ -766,25 +921,7 @@ func (c *Config) FindModelConfigByRef(protocol, modelID string) *ModelConfig {
// HasProvidersConfig checks if any provider in the old providers config has configuration.
func (c *Config) HasProvidersConfig() bool {
- v := c.Providers
- return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" ||
- v.OpenAI.APIKey != "" || v.OpenAI.APIBase != "" ||
- v.OpenRouter.APIKey != "" || v.OpenRouter.APIBase != "" ||
- v.Groq.APIKey != "" || v.Groq.APIBase != "" ||
- v.Zhipu.APIKey != "" || v.Zhipu.APIBase != "" ||
- v.VLLM.APIKey != "" || v.VLLM.APIBase != "" ||
- v.Gemini.APIKey != "" || v.Gemini.APIBase != "" ||
- v.Nvidia.APIKey != "" || v.Nvidia.APIBase != "" ||
- v.Ollama.APIKey != "" || v.Ollama.APIBase != "" ||
- v.Moonshot.APIKey != "" || v.Moonshot.APIBase != "" ||
- v.ShengSuanYun.APIKey != "" || v.ShengSuanYun.APIBase != "" ||
- v.DeepSeek.APIKey != "" || v.DeepSeek.APIBase != "" ||
- v.Cerebras.APIKey != "" || v.Cerebras.APIBase != "" ||
- v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
- v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
- v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
- v.Qwen.APIKey != "" || v.Qwen.APIBase != "" ||
- v.Mistral.APIKey != "" || v.Mistral.APIBase != ""
+ return !c.Providers.IsEmpty()
}
// ValidateModelList validates all ModelConfig entries in the model_list.
@@ -798,3 +935,50 @@ func (c *Config) ValidateModelList() error {
}
return nil
}
+
+func (t *ToolsConfig) IsToolEnabled(name string) bool {
+ switch name {
+ case "web":
+ return t.Web.Enabled
+ case "cron":
+ return t.Cron.Enabled
+ case "exec":
+ return t.Exec.Enabled
+ case "skills":
+ return t.Skills.Enabled
+ case "media_cleanup":
+ return t.MediaCleanup.Enabled
+ case "append_file":
+ return t.AppendFile.Enabled
+ case "edit_file":
+ return t.EditFile.Enabled
+ case "find_skills":
+ return t.FindSkills.Enabled
+ case "i2c":
+ return t.I2C.Enabled
+ case "install_skill":
+ return t.InstallSkill.Enabled
+ case "list_dir":
+ return t.ListDir.Enabled
+ case "message":
+ return t.Message.Enabled
+ case "read_file":
+ return t.ReadFile.Enabled
+ case "spawn":
+ return t.Spawn.Enabled
+ case "spi":
+ return t.SPI.Enabled
+ case "subagent":
+ return t.Subagent.Enabled
+ case "web_fetch":
+ return t.WebFetch.Enabled
+ case "send_file":
+ return t.SendFile.Enabled
+ case "write_file":
+ return t.WriteFile.Enabled
+ case "mcp":
+ return t.MCP.Enabled
+ default:
+ return true
+ }
+}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 4a64bac72..86155530c 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -283,6 +283,9 @@ func TestDefaultConfig_Channels(t *testing.T) {
if cfg.Channels.Slack.Enabled {
t.Error("Slack should be disabled by default")
}
+ if cfg.Channels.Matrix.Enabled {
+ t.Error("Matrix should be disabled by default")
+ }
}
// TestDefaultConfig_WebTools verifies web tools config
@@ -556,6 +559,18 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) {
}
// TestDefaultConfig_DMScope verifies the default dm_scope value
+// TestDefaultConfig_SummarizationThresholds verifies summarization defaults
+func TestDefaultConfig_SummarizationThresholds(t *testing.T) {
+ cfg := DefaultConfig()
+
+ if cfg.Agents.Defaults.SummarizeMessageThreshold != 20 {
+ t.Errorf("SummarizeMessageThreshold = %d, want 20", cfg.Agents.Defaults.SummarizeMessageThreshold)
+ }
+ if cfg.Agents.Defaults.SummarizeTokenPercent != 75 {
+ t.Errorf("SummarizeTokenPercent = %d, want 75", cfg.Agents.Defaults.SummarizeTokenPercent)
+ }
+}
+
func TestDefaultConfig_DMScope(t *testing.T) {
cfg := DefaultConfig()
@@ -563,3 +578,28 @@ func TestDefaultConfig_DMScope(t *testing.T) {
t.Errorf("Session.DMScope = %q, want 'per-channel-peer'", cfg.Session.DMScope)
}
}
+
+func TestDefaultConfig_WorkspacePath_Default(t *testing.T) {
+ // Unset to ensure we test the default
+ t.Setenv("PICOCLAW_HOME", "")
+ // Set a known home for consistent test results
+ t.Setenv("HOME", "/tmp/home")
+
+ cfg := DefaultConfig()
+ want := filepath.Join("/tmp/home", ".picoclaw", "workspace")
+
+ if cfg.Agents.Defaults.Workspace != want {
+ t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want)
+ }
+}
+
+func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
+ t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home")
+
+ cfg := DefaultConfig()
+ want := "/custom/picoclaw/home/workspace"
+
+ if cfg.Agents.Defaults.Workspace != want {
+ t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
+ }
+}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index d95f09ed0..b4177f083 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -5,19 +5,37 @@
package config
+import (
+ "os"
+ "path/filepath"
+)
+
// DefaultConfig returns the default configuration for PicoClaw.
func DefaultConfig() *Config {
+ // Determine the base path for the workspace.
+ // Priority: $PICOCLAW_HOME > ~/.picoclaw
+ var homePath string
+ if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" {
+ homePath = picoclawHome
+ } else {
+ userHome, _ := os.UserHomeDir()
+ homePath = filepath.Join(userHome, ".picoclaw")
+ }
+ workspacePath := filepath.Join(homePath, "workspace")
+
return &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
- Workspace: "~/.picoclaw/workspace",
- RestrictToWorkspace: true,
- Provider: "",
- Model: "",
- MaxTokens: 32768,
- Temperature: nil, // nil means use provider default
- MaxToolIterations: 50,
- TaskReminderInterval: 5,
+ Workspace: workspacePath,
+ RestrictToWorkspace: true,
+ Provider: "",
+ Model: "",
+ MaxTokens: 32768,
+ Temperature: nil, // nil means use provider default
+ MaxToolIterations: 50,
+ SummarizeMessageThreshold: 20,
+ TaskReminderInterval: 5,
+ SummarizeTokenPercent: 75,
},
},
Bindings: []AgentBinding{},
@@ -82,6 +100,22 @@ func DefaultConfig() *Config {
AppToken: "",
AllowFrom: FlexibleStringSlice{},
},
+ Matrix: MatrixConfig{
+ Enabled: false,
+ Homeserver: "https://matrix.org",
+ UserID: "",
+ AccessToken: "",
+ DeviceID: "",
+ JoinOnInvite: true,
+ AllowFrom: FlexibleStringSlice{},
+ GroupTrigger: GroupTriggerConfig{
+ MentionOnly: true,
+ },
+ Placeholder: PlaceholderConfig{
+ Enabled: true,
+ Text: "Thinking... 💭",
+ },
+ },
LINE: LINEConfig{
Enabled: false,
ChannelSecret: "",
@@ -124,6 +158,16 @@ func DefaultConfig() *Config {
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: "",
@@ -236,6 +280,14 @@ func DefaultConfig() *Config {
APIKey: "",
},
+ // Vivgrid - https://vivgrid.com
+ {
+ ModelName: "vivgrid-auto",
+ Model: "vivgrid/auto",
+ APIBase: "https://api.vivgrid.com/v1",
+ APIKey: "",
+ },
+
// Volcengine (火山引擎) - https://console.volcengine.com/ark
{
ModelName: "doubao-pro",
@@ -283,6 +335,20 @@ func DefaultConfig() *Config {
APIKey: "",
},
+ // Avian - https://avian.io
+ {
+ ModelName: "deepseek-v3.2",
+ Model: "avian/deepseek/deepseek-v3.2",
+ APIBase: "https://api.avian.io/v1",
+ APIKey: "",
+ },
+ {
+ ModelName: "kimi-k2.5",
+ Model: "avian/moonshotai/kimi-k2.5",
+ APIBase: "https://api.avian.io/v1",
+ APIKey: "",
+ },
+
// VLLM (local) - http://localhost:8000
{
ModelName: "local-model",
@@ -297,12 +363,18 @@ func DefaultConfig() *Config {
},
Tools: ToolsConfig{
MediaCleanup: MediaCleanupConfig{
- Enabled: true,
+ ToolConfig: ToolConfig{
+ Enabled: true,
+ },
MaxAge: 30,
Interval: 5,
},
Web: WebToolsConfig{
- Proxy: "",
+ ToolConfig: ToolConfig{
+ Enabled: true,
+ },
+ Proxy: "",
+ FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
Brave: BraveConfig{
Enabled: false,
APIKey: "",
@@ -317,14 +389,36 @@ func DefaultConfig() *Config {
APIKey: "",
MaxResults: 5,
},
+ SearXNG: SearXNGConfig{
+ Enabled: false,
+ BaseURL: "",
+ MaxResults: 5,
+ },
+ GLMSearch: GLMSearchConfig{
+ Enabled: false,
+ APIKey: "",
+ BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search",
+ SearchEngine: "search_std",
+ MaxResults: 5,
+ },
},
Cron: CronToolsConfig{
+ ToolConfig: ToolConfig{
+ Enabled: true,
+ },
ExecTimeoutMinutes: 5,
},
Exec: ExecConfig{
+ ToolConfig: ToolConfig{
+ Enabled: true,
+ },
EnableDenyPatterns: true,
+ TimeoutSeconds: 60,
},
Skills: SkillsToolsConfig{
+ ToolConfig: ToolConfig{
+ Enabled: true,
+ },
Registries: SkillsRegistriesConfig{
ClawHub: ClawHubRegistryConfig{
Enabled: true,
@@ -337,6 +431,54 @@ func DefaultConfig() *Config {
TTLSeconds: 300,
},
},
+ SendFile: ToolConfig{
+ Enabled: true,
+ },
+ MCP: MCPConfig{
+ ToolConfig: ToolConfig{
+ Enabled: false,
+ },
+ Servers: map[string]MCPServerConfig{},
+ },
+ AppendFile: ToolConfig{
+ Enabled: true,
+ },
+ EditFile: ToolConfig{
+ Enabled: true,
+ },
+ FindSkills: ToolConfig{
+ Enabled: true,
+ },
+ I2C: ToolConfig{
+ Enabled: false, // Hardware tool - Linux only
+ },
+ InstallSkill: ToolConfig{
+ Enabled: true,
+ },
+ ListDir: ToolConfig{
+ Enabled: true,
+ },
+ Message: ToolConfig{
+ Enabled: true,
+ },
+ ReadFile: ToolConfig{
+ Enabled: true,
+ },
+ Spawn: ToolConfig{
+ Enabled: true,
+ },
+ SPI: ToolConfig{
+ Enabled: false, // Hardware tool - Linux only
+ },
+ Subagent: ToolConfig{
+ Enabled: true,
+ },
+ WebFetch: ToolConfig{
+ Enabled: true,
+ },
+ WriteFile: ToolConfig{
+ Enabled: true,
+ },
},
Heartbeat: HeartbeatConfig{
Enabled: true,
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index 7a0d8a94a..ade9bf677 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -88,6 +88,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true
},
},
+ {
+ providerNames: []string{"litellm"},
+ protocol: "litellm",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "litellm",
+ Model: "litellm/auto",
+ APIKey: p.LiteLLM.APIKey,
+ APIBase: p.LiteLLM.APIBase,
+ Proxy: p.LiteLLM.Proxy,
+ RequestTimeout: p.LiteLLM.RequestTimeout,
+ }, true
+ },
+ },
{
providerNames: []string{"openrouter"},
protocol: "openrouter",
@@ -275,6 +292,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true
},
},
+ {
+ providerNames: []string{"vivgrid"},
+ protocol: "vivgrid",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "vivgrid",
+ Model: "vivgrid/auto",
+ APIKey: p.Vivgrid.APIKey,
+ APIBase: p.Vivgrid.APIBase,
+ Proxy: p.Vivgrid.Proxy,
+ RequestTimeout: p.Vivgrid.RequestTimeout,
+ }, true
+ },
+ },
{
providerNames: []string{"volcengine", "doubao"},
protocol: "volcengine",
@@ -356,6 +390,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true
},
},
+ {
+ providerNames: []string{"avian"},
+ protocol: "avian",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Avian.APIKey == "" && p.Avian.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "avian",
+ Model: "avian/deepseek/deepseek-v3.2",
+ APIKey: p.Avian.APIKey,
+ APIBase: p.Avian.APIBase,
+ Proxy: p.Avian.Proxy,
+ RequestTimeout: p.Avian.RequestTimeout,
+ }, true
+ },
+ },
}
// Process each provider migration
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index db8f4657d..d3019aab0 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -63,6 +63,33 @@ func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
}
}
+func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
+ cfg := &Config{
+ Providers: ProvidersConfig{
+ LiteLLM: ProviderConfig{
+ APIKey: "litellm-key",
+ APIBase: "http://localhost:4000/v1",
+ },
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ if result[0].ModelName != "litellm" {
+ t.Errorf("ModelName = %q, want %q", result[0].ModelName, "litellm")
+ }
+ if result[0].Model != "litellm/auto" {
+ t.Errorf("Model = %q, want %q", result[0].Model, "litellm/auto")
+ }
+ if result[0].APIBase != "http://localhost:4000/v1" {
+ t.Errorf("APIBase = %q, want %q", result[0].APIBase, "http://localhost:4000/v1")
+ }
+}
+
func TestConvertProvidersToModelList_Multiple(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
@@ -115,6 +142,7 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}},
+ LiteLLM: ProviderConfig{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"},
Anthropic: ProviderConfig{APIKey: "key2"},
OpenRouter: ProviderConfig{APIKey: "key3"},
Groq: ProviderConfig{APIKey: "key4"},
@@ -127,19 +155,21 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
ShengSuanYun: ProviderConfig{APIKey: "key11"},
DeepSeek: ProviderConfig{APIKey: "key12"},
Cerebras: ProviderConfig{APIKey: "key13"},
- VolcEngine: ProviderConfig{APIKey: "key14"},
+ Vivgrid: ProviderConfig{APIKey: "key14"},
+ VolcEngine: ProviderConfig{APIKey: "key15"},
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
Antigravity: ProviderConfig{AuthMethod: "oauth"},
Qwen: ProviderConfig{APIKey: "key17"},
Mistral: ProviderConfig{APIKey: "key18"},
+ Avian: ProviderConfig{APIKey: "key19"},
},
}
result := ConvertProvidersToModelList(cfg)
- // All 18 providers should be converted
- if len(result) != 18 {
- t.Errorf("len(result) = %d, want 18", len(result))
+ // All 21 providers should be converted
+ if len(result) != 21 {
+ t.Errorf("len(result) = %d, want 21", len(result))
}
}
diff --git a/pkg/cron/service.go b/pkg/cron/service.go
index 6962041c1..04775ac42 100644
--- a/pkg/cron/service.go
+++ b/pkg/cron/service.go
@@ -190,14 +190,21 @@ func (cs *CronService) executeJobByID(jobID string) {
cs.mu.RUnlock()
if callbackJob == nil {
+ log.Printf("[cron] job %s not found, skipping", jobID)
return
}
+ // Log job execution start
+ log.Printf("[cron] ▶ executing job '%s' (id: %s, schedule: %s, channel: %s)",
+ callbackJob.Name, jobID, callbackJob.Schedule.Kind, callbackJob.Payload.Channel)
+
var err error
if cs.onJob != nil {
_, err = cs.onJob(callbackJob)
}
+ execDuration := time.Now().UnixMilli() - startTime
+
// Now acquire lock to update state
cs.mu.Lock()
defer cs.mu.Unlock()
@@ -220,22 +227,35 @@ func (cs *CronService) executeJobByID(jobID string) {
if err != nil {
job.State.LastStatus = "error"
job.State.LastError = err.Error()
+ log.Printf("[cron] ✗ job '%s' failed after %dms: %v", job.Name, execDuration, err)
} else {
job.State.LastStatus = "ok"
job.State.LastError = ""
}
// Compute next run time
+ var nextRunStr string
if job.Schedule.Kind == "at" {
if job.DeleteAfterRun {
cs.removeJobUnsafe(job.ID)
+ nextRunStr = "(deleted)"
} else {
job.Enabled = false
job.State.NextRunAtMS = nil
+ nextRunStr = "(disabled)"
}
} else {
nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())
job.State.NextRunAtMS = nextRun
+ if nextRun != nil {
+ nextRunStr = time.UnixMilli(*nextRun).Format("2006-01-02 15:04:05")
+ } else {
+ nextRunStr = "(none)"
+ }
+ }
+
+ if err == nil {
+ log.Printf("[cron] ✓ job '%s' completed in %dms, next run: %s", job.Name, execDuration, nextRunStr)
}
if err := cs.saveStoreUnsafe(); err != nil {
diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go
index 1425296d8..8b34ebf6c 100644
--- a/pkg/heartbeat/service_test.go
+++ b/pkg/heartbeat/service_test.go
@@ -47,79 +47,63 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
}
}
-func TestExecuteHeartbeat_Error(t *testing.T) {
- tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp dir: %v", err)
- }
- defer os.RemoveAll(tmpDir)
-
- hs := NewHeartbeatService(tmpDir, 30, true)
- hs.stopChan = make(chan struct{}) // Enable for testing
-
- hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
- return &tools.ToolResult{
- ForLLM: "Heartbeat failed: connection error",
- ForUser: "",
- Silent: false,
- IsError: true,
- Async: false,
- }
- })
-
- // Create HEARTBEAT.md
- os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
-
- hs.executeHeartbeat()
-
- // Check log file for error message
- logFile := filepath.Join(tmpDir, "heartbeat.log")
- data, err := os.ReadFile(logFile)
- if err != nil {
- t.Fatalf("Failed to read log file: %v", err)
+func TestExecuteHeartbeat_ResultLogging(t *testing.T) {
+ tests := []struct {
+ name string
+ result *tools.ToolResult
+ wantLog string
+ }{
+ {
+ name: "error result",
+ result: &tools.ToolResult{
+ ForLLM: "Heartbeat failed: connection error",
+ ForUser: "",
+ Silent: false,
+ IsError: true,
+ Async: false,
+ },
+ wantLog: "error message",
+ },
+ {
+ name: "silent result",
+ result: &tools.ToolResult{
+ ForLLM: "Heartbeat completed successfully",
+ ForUser: "",
+ Silent: true,
+ IsError: false,
+ Async: false,
+ },
+ wantLog: "completion message",
+ },
}
- logContent := string(data)
- if logContent == "" {
- t.Error("Expected log file to contain error message")
- }
-}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
-func TestExecuteHeartbeat_Silent(t *testing.T) {
- tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp dir: %v", err)
- }
- defer os.RemoveAll(tmpDir)
+ hs := NewHeartbeatService(tmpDir, 30, true)
+ hs.stopChan = make(chan struct{}) // Enable for testing
- hs := NewHeartbeatService(tmpDir, 30, true)
- hs.stopChan = make(chan struct{}) // Enable for testing
+ hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
+ return tt.result
+ })
- hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
- return &tools.ToolResult{
- ForLLM: "Heartbeat completed successfully",
- ForUser: "",
- Silent: true,
- IsError: false,
- Async: false,
- }
- })
+ os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
+ hs.executeHeartbeat()
- // Create HEARTBEAT.md
- os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
-
- hs.executeHeartbeat()
-
- // Check log file for completion message
- logFile := filepath.Join(tmpDir, "heartbeat.log")
- data, err := os.ReadFile(logFile)
- if err != nil {
- t.Fatalf("Failed to read log file: %v", err)
- }
-
- logContent := string(data)
- if logContent == "" {
- t.Error("Expected log file to contain completion message")
+ logFile := filepath.Join(tmpDir, "heartbeat.log")
+ data, err := os.ReadFile(logFile)
+ if err != nil {
+ t.Fatalf("Failed to read log file: %v", err)
+ }
+ if string(data) == "" {
+ t.Errorf("Expected log file to contain %s", tt.wantLog)
+ }
+ })
}
}
diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go
new file mode 100644
index 000000000..7b63cc979
--- /dev/null
+++ b/pkg/mcp/manager.go
@@ -0,0 +1,532 @@
+package mcp
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// headerTransport is an http.RoundTripper that adds custom headers to requests
+type headerTransport struct {
+ base http.RoundTripper
+ headers map[string]string
+}
+
+func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ // Clone the request to avoid modifying the original
+ req = req.Clone(req.Context())
+
+ // Add custom headers
+ for key, value := range t.headers {
+ req.Header.Set(key, value)
+ }
+
+ // Use the base transport
+ base := t.base
+ if base == nil {
+ base = http.DefaultTransport
+ }
+ return base.RoundTrip(req)
+}
+
+// loadEnvFile loads environment variables from a file in .env format
+// Each line should be in the format: KEY=value
+// Lines starting with # are comments
+// Empty lines are ignored
+func loadEnvFile(path string) (map[string]string, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open env file: %w", err)
+ }
+ defer file.Close()
+
+ envVars := make(map[string]string)
+ scanner := bufio.NewScanner(file)
+ lineNum := 0
+
+ for scanner.Scan() {
+ lineNum++
+ line := strings.TrimSpace(scanner.Text())
+
+ // Skip empty lines and comments
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ // Parse KEY=value
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid format at line %d: %s", lineNum, line)
+ }
+
+ key := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+
+ if key == "" {
+ return nil, fmt.Errorf("invalid format at line %d: empty key", lineNum)
+ }
+
+ // Remove surrounding quotes if present
+ if len(value) >= 2 {
+ if (value[0] == '"' && value[len(value)-1] == '"') ||
+ (value[0] == '\'' && value[len(value)-1] == '\'') {
+ value = value[1 : len(value)-1]
+ }
+ }
+
+ envVars[key] = value
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("error reading env file: %w", err)
+ }
+
+ return envVars, nil
+}
+
+// ServerConnection represents a connection to an MCP server
+type ServerConnection struct {
+ Name string
+ Client *mcp.Client
+ Session *mcp.ClientSession
+ Tools []*mcp.Tool
+}
+
+// Manager manages multiple MCP server connections
+type Manager struct {
+ servers map[string]*ServerConnection
+ mu sync.RWMutex
+ closed atomic.Bool // changed from bool to atomic.Bool to avoid TOCTOU race
+ wg sync.WaitGroup // tracks in-flight CallTool calls
+}
+
+// NewManager creates a new MCP manager
+func NewManager() *Manager {
+ return &Manager{
+ servers: make(map[string]*ServerConnection),
+ }
+}
+
+// LoadFromConfig loads MCP servers from configuration
+func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error {
+ return m.LoadFromMCPConfig(ctx, cfg.Tools.MCP, cfg.WorkspacePath())
+}
+
+// LoadFromMCPConfig loads MCP servers from MCP configuration and workspace path.
+// This is the minimal dependency version that doesn't require the full Config object.
+func (m *Manager) LoadFromMCPConfig(
+ ctx context.Context,
+ mcpCfg config.MCPConfig,
+ workspacePath string,
+) error {
+ if !mcpCfg.Enabled {
+ logger.InfoCF("mcp", "MCP integration is disabled", nil)
+ return nil
+ }
+
+ if len(mcpCfg.Servers) == 0 {
+ logger.InfoCF("mcp", "No MCP servers configured", nil)
+ return nil
+ }
+
+ logger.InfoCF("mcp", "Initializing MCP servers",
+ map[string]any{
+ "count": len(mcpCfg.Servers),
+ })
+
+ var wg sync.WaitGroup
+ errs := make(chan error, len(mcpCfg.Servers))
+ enabledCount := 0
+
+ for name, serverCfg := range mcpCfg.Servers {
+ if !serverCfg.Enabled {
+ logger.DebugCF("mcp", "Skipping disabled server",
+ map[string]any{
+ "server": name,
+ })
+ continue
+ }
+
+ enabledCount++
+ wg.Add(1)
+ go func(name string, serverCfg config.MCPServerConfig, workspace string) {
+ defer wg.Done()
+
+ // Resolve relative envFile paths relative to workspace
+ if serverCfg.EnvFile != "" && !filepath.IsAbs(serverCfg.EnvFile) {
+ if workspace == "" {
+ err := fmt.Errorf(
+ "workspace path is empty while resolving relative envFile %q for server %s",
+ serverCfg.EnvFile,
+ name,
+ )
+ logger.ErrorCF("mcp", "Invalid MCP server configuration",
+ map[string]any{
+ "server": name,
+ "env_file": serverCfg.EnvFile,
+ "error": err.Error(),
+ })
+ errs <- err
+ return
+ }
+ serverCfg.EnvFile = filepath.Join(workspace, serverCfg.EnvFile)
+ }
+
+ if err := m.ConnectServer(ctx, name, serverCfg); err != nil {
+ logger.ErrorCF("mcp", "Failed to connect to MCP server",
+ map[string]any{
+ "server": name,
+ "error": err.Error(),
+ })
+ errs <- fmt.Errorf("failed to connect to server %s: %w", name, err)
+ }
+ }(name, serverCfg, workspacePath)
+ }
+
+ wg.Wait()
+ close(errs)
+
+ // Collect errors
+ var allErrors []error
+ for err := range errs {
+ allErrors = append(allErrors, err)
+ }
+
+ connectedCount := len(m.GetServers())
+
+ // If all enabled servers failed to connect, return aggregated error
+ if enabledCount > 0 && connectedCount == 0 {
+ logger.ErrorCF("mcp", "All MCP servers failed to connect",
+ map[string]any{
+ "failed": len(allErrors),
+ "total": enabledCount,
+ })
+ return errors.Join(allErrors...)
+ }
+
+ if len(allErrors) > 0 {
+ logger.WarnCF("mcp", "Some MCP servers failed to connect",
+ map[string]any{
+ "failed": len(allErrors),
+ "connected": connectedCount,
+ "total": enabledCount,
+ })
+ // Don't fail completely if some servers successfully connected
+ }
+
+ logger.InfoCF("mcp", "MCP server initialization complete",
+ map[string]any{
+ "connected": connectedCount,
+ "total": enabledCount,
+ })
+
+ return nil
+}
+
+// ConnectServer connects to a single MCP server
+func (m *Manager) ConnectServer(
+ ctx context.Context,
+ name string,
+ cfg config.MCPServerConfig,
+) error {
+ logger.InfoCF("mcp", "Connecting to MCP server",
+ map[string]any{
+ "server": name,
+ "command": cfg.Command,
+ "args_count": len(cfg.Args),
+ })
+
+ // Create client
+ client := mcp.NewClient(&mcp.Implementation{
+ Name: "picoclaw",
+ Version: "1.0.0",
+ }, nil)
+
+ // Create transport based on configuration
+ // Auto-detect transport type if not explicitly specified
+ var transport mcp.Transport
+ transportType := cfg.Type
+
+ // Auto-detect: if URL is provided, use SSE; if command is provided, use stdio
+ if transportType == "" {
+ if cfg.URL != "" {
+ transportType = "sse"
+ } else if cfg.Command != "" {
+ transportType = "stdio"
+ } else {
+ return fmt.Errorf("either URL or command must be provided")
+ }
+ }
+
+ switch transportType {
+ case "sse", "http":
+ if cfg.URL == "" {
+ return fmt.Errorf("URL is required for SSE/HTTP transport")
+ }
+ logger.DebugCF("mcp", "Using SSE/HTTP transport",
+ map[string]any{
+ "server": name,
+ "url": cfg.URL,
+ })
+
+ sseTransport := &mcp.StreamableClientTransport{
+ Endpoint: cfg.URL,
+ }
+
+ // Add custom headers if provided
+ if len(cfg.Headers) > 0 {
+ // Create a custom HTTP client with header-injecting transport
+ sseTransport.HTTPClient = &http.Client{
+ Transport: &headerTransport{
+ base: http.DefaultTransport,
+ headers: cfg.Headers,
+ },
+ }
+ logger.DebugCF("mcp", "Added custom HTTP headers",
+ map[string]any{
+ "server": name,
+ "header_count": len(cfg.Headers),
+ })
+ }
+
+ transport = sseTransport
+ case "stdio":
+ if cfg.Command == "" {
+ return fmt.Errorf("command is required for stdio transport")
+ }
+ logger.DebugCF("mcp", "Using stdio transport",
+ map[string]any{
+ "server": name,
+ "command": cfg.Command,
+ })
+ // Create command with context
+ cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...)
+
+ // Build environment variables with proper override semantics
+ // Use a map to ensure config variables override file variables
+ envMap := make(map[string]string)
+
+ // Start with parent process environment
+ for _, e := range cmd.Environ() {
+ if idx := strings.Index(e, "="); idx > 0 {
+ envMap[e[:idx]] = e[idx+1:]
+ }
+ }
+
+ // Load environment variables from file if specified
+ if cfg.EnvFile != "" {
+ envVars, err := loadEnvFile(cfg.EnvFile)
+ if err != nil {
+ return fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err)
+ }
+ for k, v := range envVars {
+ envMap[k] = v
+ }
+ logger.DebugCF("mcp", "Loaded environment variables from file",
+ map[string]any{
+ "server": name,
+ "envFile": cfg.EnvFile,
+ "var_count": len(envVars),
+ })
+ }
+
+ // Environment variables from config override those from file
+ for k, v := range cfg.Env {
+ envMap[k] = v
+ }
+
+ // Convert map to slice
+ env := make([]string, 0, len(envMap))
+ for k, v := range envMap {
+ env = append(env, fmt.Sprintf("%s=%s", k, v))
+ }
+ cmd.Env = env
+
+ transport = &mcp.CommandTransport{Command: cmd}
+ default:
+ return fmt.Errorf(
+ "unsupported transport type: %s (supported: stdio, sse, http)",
+ transportType,
+ )
+ }
+
+ // Connect to server
+ session, err := client.Connect(ctx, transport, nil)
+ if err != nil {
+ return fmt.Errorf("failed to connect: %w", err)
+ }
+
+ // Get server info
+ initResult := session.InitializeResult()
+ logger.InfoCF("mcp", "Connected to MCP server",
+ map[string]any{
+ "server": name,
+ "serverName": initResult.ServerInfo.Name,
+ "serverVersion": initResult.ServerInfo.Version,
+ "protocol": initResult.ProtocolVersion,
+ })
+
+ // List available tools if supported
+ var tools []*mcp.Tool
+ if initResult.Capabilities.Tools != nil {
+ for tool, err := range session.Tools(ctx, nil) {
+ if err != nil {
+ logger.WarnCF("mcp", "Error listing tool",
+ map[string]any{
+ "server": name,
+ "error": err.Error(),
+ })
+ continue
+ }
+ tools = append(tools, tool)
+ }
+
+ logger.InfoCF("mcp", "Listed tools from MCP server",
+ map[string]any{
+ "server": name,
+ "toolCount": len(tools),
+ })
+ }
+
+ // Store connection
+ m.mu.Lock()
+ m.servers[name] = &ServerConnection{
+ Name: name,
+ Client: client,
+ Session: session,
+ Tools: tools,
+ }
+ m.mu.Unlock()
+
+ return nil
+}
+
+// GetServers returns all connected servers
+func (m *Manager) GetServers() map[string]*ServerConnection {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ result := make(map[string]*ServerConnection, len(m.servers))
+ for k, v := range m.servers {
+ result[k] = v
+ }
+ return result
+}
+
+// GetServer returns a specific server connection
+func (m *Manager) GetServer(name string) (*ServerConnection, bool) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ conn, ok := m.servers[name]
+ return conn, ok
+}
+
+// CallTool calls a tool on a specific server
+func (m *Manager) CallTool(
+ ctx context.Context,
+ serverName, toolName string,
+ arguments map[string]any,
+) (*mcp.CallToolResult, error) {
+ // Check if closed before acquiring lock (fast path)
+ if m.closed.Load() {
+ return nil, fmt.Errorf("manager is closed")
+ }
+
+ m.mu.RLock()
+ // Double-check after acquiring lock to prevent TOCTOU race
+ if m.closed.Load() {
+ m.mu.RUnlock()
+ return nil, fmt.Errorf("manager is closed")
+ }
+ conn, ok := m.servers[serverName]
+ if ok {
+ m.wg.Add(1) // Add to WaitGroup while holding the lock
+ }
+ m.mu.RUnlock()
+
+ if !ok {
+ return nil, fmt.Errorf("server %s not found", serverName)
+ }
+ defer m.wg.Done()
+
+ params := &mcp.CallToolParams{
+ Name: toolName,
+ Arguments: arguments,
+ }
+
+ result, err := conn.Session.CallTool(ctx, params)
+ if err != nil {
+ return nil, fmt.Errorf("failed to call tool: %w", err)
+ }
+
+ return result, nil
+}
+
+// Close closes all server connections
+func (m *Manager) Close() error {
+ // Use Swap to atomically set closed=true and get the previous value
+ // This prevents TOCTOU race with CallTool's closed check
+ if m.closed.Swap(true) {
+ return nil // already closed
+ }
+
+ // Wait for all in-flight CallTool calls to finish before closing sessions
+ // After closed=true is set, no new CallTool can start (they check closed first)
+ m.wg.Wait()
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ logger.InfoCF("mcp", "Closing all MCP server connections",
+ map[string]any{
+ "count": len(m.servers),
+ })
+
+ var errs []error
+ for name, conn := range m.servers {
+ if err := conn.Session.Close(); err != nil {
+ logger.ErrorCF("mcp", "Failed to close server connection",
+ map[string]any{
+ "server": name,
+ "error": err.Error(),
+ })
+ errs = append(errs, fmt.Errorf("server %s: %w", name, err))
+ }
+ }
+
+ m.servers = make(map[string]*ServerConnection)
+
+ if len(errs) > 0 {
+ return fmt.Errorf("failed to close %d server(s): %w", len(errs), errors.Join(errs...))
+ }
+
+ return nil
+}
+
+// GetAllTools returns all tools from all connected servers
+func (m *Manager) GetAllTools() map[string][]*mcp.Tool {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ result := make(map[string][]*mcp.Tool)
+ for name, conn := range m.servers {
+ if len(conn.Tools) > 0 {
+ result[name] = conn.Tools
+ }
+ }
+ return result
+}
diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go
new file mode 100644
index 000000000..f353942ab
--- /dev/null
+++ b/pkg/mcp/manager_test.go
@@ -0,0 +1,308 @@
+package mcp
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestLoadEnvFile(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ expected map[string]string
+ expectErr bool
+ }{
+ {
+ name: "basic env file",
+ content: `API_KEY=secret123
+DATABASE_URL=postgres://localhost/db
+PORT=8080`,
+ expected: map[string]string{
+ "API_KEY": "secret123",
+ "DATABASE_URL": "postgres://localhost/db",
+ "PORT": "8080",
+ },
+ expectErr: false,
+ },
+ {
+ name: "with comments and empty lines",
+ content: `# This is a comment
+API_KEY=secret123
+
+# Another comment
+DATABASE_URL=postgres://localhost/db
+
+PORT=8080`,
+ expected: map[string]string{
+ "API_KEY": "secret123",
+ "DATABASE_URL": "postgres://localhost/db",
+ "PORT": "8080",
+ },
+ expectErr: false,
+ },
+ {
+ name: "with quoted values",
+ content: `API_KEY="secret with spaces"
+NAME='single quoted'
+PLAIN=no-quotes`,
+ expected: map[string]string{
+ "API_KEY": "secret with spaces",
+ "NAME": "single quoted",
+ "PLAIN": "no-quotes",
+ },
+ expectErr: false,
+ },
+ {
+ name: "with spaces around equals",
+ content: `API_KEY = secret123
+DATABASE_URL= postgres://localhost/db
+PORT =8080`,
+ expected: map[string]string{
+ "API_KEY": "secret123",
+ "DATABASE_URL": "postgres://localhost/db",
+ "PORT": "8080",
+ },
+ expectErr: false,
+ },
+ {
+ name: "invalid format - no equals",
+ content: `INVALID_LINE`,
+ expectErr: true,
+ },
+ {
+ name: "empty file",
+ content: ``,
+ expected: map[string]string{},
+ expectErr: false,
+ },
+ {
+ name: "only comments",
+ content: `# Comment 1
+# Comment 2`,
+ expected: map[string]string{},
+ expectErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpDir := t.TempDir()
+ envFile := filepath.Join(tmpDir, ".env")
+
+ if err := os.WriteFile(envFile, []byte(tt.content), 0o644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ result, err := loadEnvFile(envFile)
+
+ if tt.expectErr {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if len(result) != len(tt.expected) {
+ t.Errorf("Expected %d variables, got %d", len(tt.expected), len(result))
+ }
+
+ for key, expectedValue := range tt.expected {
+ if actualValue, ok := result[key]; !ok {
+ t.Errorf("Expected key %s not found", key)
+ } else if actualValue != expectedValue {
+ t.Errorf("For key %s: expected %q, got %q", key, expectedValue, actualValue)
+ }
+ }
+ })
+ }
+}
+
+func TestLoadEnvFileNotFound(t *testing.T) {
+ _, err := loadEnvFile("/nonexistent/file.env")
+ if err == nil {
+ t.Error("Expected error for nonexistent file")
+ }
+}
+
+func TestEnvFilePriority(t *testing.T) {
+ // Create a temporary .env file
+ tmpDir := t.TempDir()
+ envFile := filepath.Join(tmpDir, ".env")
+
+ envContent := `API_KEY=from_file
+DATABASE_URL=from_file
+SHARED_VAR=from_file`
+
+ if err := os.WriteFile(envFile, []byte(envContent), 0o644); err != nil {
+ t.Fatalf("Failed to create .env file: %v", err)
+ }
+
+ // Load envFile
+ envVars, err := loadEnvFile(envFile)
+ if err != nil {
+ t.Fatalf("Failed to load env file: %v", err)
+ }
+
+ // Verify envFile variables
+ if envVars["API_KEY"] != "from_file" {
+ t.Errorf("Expected API_KEY=from_file, got %s", envVars["API_KEY"])
+ }
+
+ // Simulate config.Env overriding envFile
+ configEnv := map[string]string{
+ "SHARED_VAR": "from_config",
+ "NEW_VAR": "from_config",
+ }
+
+ // Merge: envFile first, then config overrides
+ merged := make(map[string]string)
+ for k, v := range envVars {
+ merged[k] = v
+ }
+ for k, v := range configEnv {
+ merged[k] = v
+ }
+
+ // Verify priority: config.Env should override envFile
+ if merged["SHARED_VAR"] != "from_config" {
+ t.Errorf(
+ "Expected SHARED_VAR=from_config (config should override file), got %s",
+ merged["SHARED_VAR"],
+ )
+ }
+ if merged["API_KEY"] != "from_file" {
+ t.Errorf("Expected API_KEY=from_file, got %s", merged["API_KEY"])
+ }
+ if merged["NEW_VAR"] != "from_config" {
+ t.Errorf("Expected NEW_VAR=from_config, got %s", merged["NEW_VAR"])
+ }
+}
+
+func TestLoadFromMCPConfig_EmptyWorkspaceWithRelativeEnvFile(t *testing.T) {
+ mgr := NewManager()
+
+ mcpCfg := config.MCPConfig{
+ ToolConfig: config.ToolConfig{
+ Enabled: true,
+ },
+ Servers: map[string]config.MCPServerConfig{
+ "test-server": {
+ Enabled: true,
+ Command: "echo",
+ Args: []string{"ok"},
+ EnvFile: ".env",
+ },
+ },
+ }
+
+ err := mgr.LoadFromMCPConfig(context.Background(), mcpCfg, "")
+ if err == nil {
+ t.Fatal("expected error for relative env_file with empty workspace path, got nil")
+ }
+
+ if !strings.Contains(err.Error(), "workspace path is empty") {
+ t.Fatalf("expected workspace path validation error, got: %v", err)
+ }
+}
+
+func TestNewManager_InitialState(t *testing.T) {
+ mgr := NewManager()
+ if mgr == nil {
+ t.Fatal("expected manager instance, got nil")
+ }
+ if len(mgr.GetServers()) != 0 {
+ t.Fatalf("expected no servers on new manager, got %d", len(mgr.GetServers()))
+ }
+}
+
+func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) {
+ mgr := NewManager()
+
+ err := mgr.LoadFromMCPConfig(
+ context.Background(),
+ config.MCPConfig{ToolConfig: config.ToolConfig{Enabled: false}},
+ "/tmp",
+ )
+ if err != nil {
+ t.Fatalf("expected nil error when MCP disabled, got: %v", err)
+ }
+
+ err = mgr.LoadFromMCPConfig(
+ context.Background(),
+ config.MCPConfig{ToolConfig: config.ToolConfig{Enabled: true}},
+ "/tmp",
+ )
+ if err != nil {
+ t.Fatalf("expected nil error when no servers configured, got: %v", err)
+ }
+}
+
+func TestGetServers_ReturnsCopy(t *testing.T) {
+ mgr := NewManager()
+ mgr.servers["s1"] = &ServerConnection{Name: "s1"}
+
+ servers := mgr.GetServers()
+ delete(servers, "s1")
+
+ if _, ok := mgr.GetServer("s1"); !ok {
+ t.Fatal("expected internal manager state to remain unchanged")
+ }
+}
+
+func TestGetAllTools_FiltersEmptyTools(t *testing.T) {
+ mgr := NewManager()
+ mgr.servers["empty"] = &ServerConnection{Name: "empty", Tools: nil}
+ mgr.servers["with-tools"] = &ServerConnection{Name: "with-tools", Tools: []*sdkmcp.Tool{{}}}
+
+ all := mgr.GetAllTools()
+ if _, ok := all["empty"]; ok {
+ t.Fatal("expected server without tools to be excluded")
+ }
+ if _, ok := all["with-tools"]; !ok {
+ t.Fatal("expected server with tools to be included")
+ }
+}
+
+func TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) {
+ t.Run("manager closed", func(t *testing.T) {
+ mgr := NewManager()
+ mgr.closed.Store(true)
+
+ _, err := mgr.CallTool(context.Background(), "s1", "tool", nil)
+ if err == nil || !strings.Contains(err.Error(), "manager is closed") {
+ t.Fatalf("expected manager closed error, got: %v", err)
+ }
+ })
+
+ t.Run("server missing", func(t *testing.T) {
+ mgr := NewManager()
+
+ _, err := mgr.CallTool(context.Background(), "missing", "tool", nil)
+ if err == nil || !strings.Contains(err.Error(), "not found") {
+ t.Fatalf("expected server not found error, got: %v", err)
+ }
+ })
+}
+
+func TestClose_IdempotentOnEmptyManager(t *testing.T) {
+ mgr := NewManager()
+
+ if err := mgr.Close(); err != nil {
+ t.Fatalf("first close should succeed, got: %v", err)
+ }
+ if err := mgr.Close(); err != nil {
+ t.Fatalf("second close should be idempotent, got: %v", err)
+ }
+}
diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go
new file mode 100644
index 000000000..e12e2c5ab
--- /dev/null
+++ b/pkg/memory/jsonl.go
@@ -0,0 +1,460 @@
+package memory
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "hash/fnv"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+const (
+ // numLockShards is the fixed number of mutexes used to serialize
+ // per-session access. Using a sharded array instead of a map keeps
+ // memory bounded regardless of how many sessions are created over
+ // the lifetime of the process — important for a long-running daemon.
+ numLockShards = 64
+
+ // maxLineSize is the maximum size of a single JSON line in a .jsonl
+ // file. Tool results (read_file, web search, etc.) can be large, so
+ // we set a generous limit. The scanner starts at 64 KB and grows
+ // only as needed up to this cap.
+ maxLineSize = 10 * 1024 * 1024 // 10 MB
+)
+
+// sessionMeta holds per-session metadata stored in a .meta.json file.
+type sessionMeta struct {
+ Key string `json:"key"`
+ Summary string `json:"summary"`
+ Skip int `json:"skip"`
+ Count int `json:"count"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// JSONLStore implements Store using append-only JSONL files.
+//
+// Each session is stored as two files:
+//
+// {sanitized_key}.jsonl — one JSON-encoded message per line, append-only
+// {sanitized_key}.meta.json — session metadata (summary, logical truncation offset)
+//
+// Messages are never physically deleted from the JSONL file. Instead,
+// TruncateHistory records a "skip" offset in the metadata file and
+// GetHistory ignores lines before that offset. This keeps all writes
+// append-only, which is both fast and crash-safe.
+type JSONLStore struct {
+ dir string
+ locks [numLockShards]sync.Mutex
+}
+
+// NewJSONLStore creates a new JSONL-backed store rooted at dir.
+func NewJSONLStore(dir string) (*JSONLStore, error) {
+ err := os.MkdirAll(dir, 0o755)
+ if err != nil {
+ return nil, fmt.Errorf("memory: create directory: %w", err)
+ }
+ return &JSONLStore{dir: dir}, nil
+}
+
+// sessionLock returns a mutex for the given session key.
+// Keys are mapped to a fixed pool of shards via FNV hash, so
+// memory usage is O(1) regardless of total session count.
+func (s *JSONLStore) sessionLock(key string) *sync.Mutex {
+ h := fnv.New32a()
+ h.Write([]byte(key))
+ return &s.locks[h.Sum32()%numLockShards]
+}
+
+func (s *JSONLStore) jsonlPath(key string) string {
+ return filepath.Join(s.dir, sanitizeKey(key)+".jsonl")
+}
+
+func (s *JSONLStore) metaPath(key string) string {
+ return filepath.Join(s.dir, sanitizeKey(key)+".meta.json")
+}
+
+// sanitizeKey converts a session key to a safe filename component.
+// Mirrors pkg/session.sanitizeFilename so that migration paths match.
+//
+// Note: this is a lossy mapping — "telegram:123" and "telegram_123"
+// both produce the same filename. This is an intentional tradeoff:
+// keys with colons (e.g. from channels) are by far the common case,
+// and a bidirectional encoding (like URL-encoding) would complicate
+// file listings and debugging.
+func sanitizeKey(key string) string {
+ return strings.ReplaceAll(key, ":", "_")
+}
+
+// readMeta loads the metadata file for a session.
+// Returns a zero-value sessionMeta if the file does not exist.
+func (s *JSONLStore) readMeta(key string) (sessionMeta, error) {
+ data, err := os.ReadFile(s.metaPath(key))
+ if os.IsNotExist(err) {
+ return sessionMeta{Key: key}, nil
+ }
+ if err != nil {
+ return sessionMeta{}, fmt.Errorf("memory: read meta: %w", err)
+ }
+ var meta sessionMeta
+ err = json.Unmarshal(data, &meta)
+ if err != nil {
+ return sessionMeta{}, fmt.Errorf("memory: decode meta: %w", err)
+ }
+ return meta, nil
+}
+
+// writeMeta atomically writes the metadata file using the project's
+// standard WriteFileAtomic (temp + fsync + rename).
+func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error {
+ data, err := json.MarshalIndent(meta, "", " ")
+ if err != nil {
+ return fmt.Errorf("memory: encode meta: %w", err)
+ }
+ return fileutil.WriteFileAtomic(s.metaPath(key), data, 0o644)
+}
+
+// readMessages reads valid JSON lines from a .jsonl file, skipping
+// the first `skip` lines without unmarshaling them. This avoids the
+// cost of json.Unmarshal on logically truncated messages.
+// Malformed trailing lines (e.g. from a crash) are silently skipped.
+func readMessages(path string, skip int) ([]providers.Message, error) {
+ f, err := os.Open(path)
+ if os.IsNotExist(err) {
+ return []providers.Message{}, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("memory: open jsonl: %w", err)
+ }
+ defer f.Close()
+
+ var msgs []providers.Message
+ scanner := bufio.NewScanner(f)
+ // Allow large lines for tool results (read_file, web search, etc.).
+ scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
+
+ lineNum := 0
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ lineNum++
+ if lineNum <= skip {
+ continue
+ }
+ var msg providers.Message
+ if err := json.Unmarshal(line, &msg); err != nil {
+ // Corrupt line — likely a partial write from a crash.
+ // Log so operators know data was skipped, but don't
+ // fail the entire read; this is the standard JSONL
+ // recovery pattern.
+ log.Printf("memory: skipping corrupt line %d in %s: %v",
+ lineNum, filepath.Base(path), err)
+ continue
+ }
+ msgs = append(msgs, msg)
+ }
+ if scanner.Err() != nil {
+ return nil, fmt.Errorf("memory: scan jsonl: %w", scanner.Err())
+ }
+
+ if msgs == nil {
+ msgs = []providers.Message{}
+ }
+ return msgs, nil
+}
+
+// countLines counts the total number of non-empty lines in a .jsonl file.
+// Used by TruncateHistory to reconcile a stale meta.Count without
+// the overhead of unmarshaling every message.
+func countLines(path string) (int, error) {
+ f, err := os.Open(path)
+ if os.IsNotExist(err) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, fmt.Errorf("memory: open jsonl: %w", err)
+ }
+ defer f.Close()
+
+ n := 0
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
+ for scanner.Scan() {
+ if len(scanner.Bytes()) > 0 {
+ n++
+ }
+ }
+ return n, scanner.Err()
+}
+
+func (s *JSONLStore) AddMessage(
+ _ context.Context, sessionKey, role, content string,
+) error {
+ return s.addMsg(sessionKey, providers.Message{
+ Role: role,
+ Content: content,
+ })
+}
+
+func (s *JSONLStore) AddFullMessage(
+ _ context.Context, sessionKey string, msg providers.Message,
+) error {
+ return s.addMsg(sessionKey, msg)
+}
+
+// addMsg is the shared implementation for AddMessage and AddFullMessage.
+func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error {
+ l := s.sessionLock(sessionKey)
+ l.Lock()
+ defer l.Unlock()
+
+ // Append the message as a single JSON line.
+ line, err := json.Marshal(msg)
+ if err != nil {
+ return fmt.Errorf("memory: marshal message: %w", err)
+ }
+ line = append(line, '\n')
+
+ f, err := os.OpenFile(
+ s.jsonlPath(sessionKey),
+ os.O_CREATE|os.O_WRONLY|os.O_APPEND,
+ 0o644,
+ )
+ if err != nil {
+ return fmt.Errorf("memory: open jsonl for append: %w", err)
+ }
+ _, writeErr := f.Write(line)
+ if writeErr != nil {
+ f.Close()
+ return fmt.Errorf("memory: append message: %w", writeErr)
+ }
+ // Flush to physical storage before closing. This matches the
+ // durability guarantee of writeMeta and rewriteJSONL (which use
+ // WriteFileAtomic with fsync). Without Sync, a power loss could
+ // leave the append in the kernel page cache only — lost on reboot.
+ if syncErr := f.Sync(); syncErr != nil {
+ f.Close()
+ return fmt.Errorf("memory: sync jsonl: %w", syncErr)
+ }
+ if closeErr := f.Close(); closeErr != nil {
+ return fmt.Errorf("memory: close jsonl: %w", closeErr)
+ }
+
+ // Update metadata.
+ meta, err := s.readMeta(sessionKey)
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ if meta.Count == 0 && meta.CreatedAt.IsZero() {
+ meta.CreatedAt = now
+ }
+ meta.Count++
+ meta.UpdatedAt = now
+
+ return s.writeMeta(sessionKey, meta)
+}
+
+func (s *JSONLStore) GetHistory(
+ _ context.Context, sessionKey string,
+) ([]providers.Message, error) {
+ l := s.sessionLock(sessionKey)
+ l.Lock()
+ defer l.Unlock()
+
+ meta, err := s.readMeta(sessionKey)
+ if err != nil {
+ return nil, err
+ }
+
+ // Pass meta.Skip so readMessages skips those lines without
+ // unmarshaling them — avoids wasted CPU on truncated messages.
+ msgs, err := readMessages(s.jsonlPath(sessionKey), meta.Skip)
+ if err != nil {
+ return nil, err
+ }
+
+ return msgs, nil
+}
+
+func (s *JSONLStore) GetSummary(
+ _ context.Context, sessionKey string,
+) (string, error) {
+ l := s.sessionLock(sessionKey)
+ l.Lock()
+ defer l.Unlock()
+
+ meta, err := s.readMeta(sessionKey)
+ if err != nil {
+ return "", err
+ }
+ return meta.Summary, nil
+}
+
+func (s *JSONLStore) SetSummary(
+ _ context.Context, sessionKey, summary string,
+) error {
+ l := s.sessionLock(sessionKey)
+ l.Lock()
+ defer l.Unlock()
+
+ meta, err := s.readMeta(sessionKey)
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ if meta.CreatedAt.IsZero() {
+ meta.CreatedAt = now
+ }
+ meta.Summary = summary
+ meta.UpdatedAt = now
+
+ return s.writeMeta(sessionKey, meta)
+}
+
+func (s *JSONLStore) TruncateHistory(
+ _ context.Context, sessionKey string, keepLast int,
+) error {
+ l := s.sessionLock(sessionKey)
+ l.Lock()
+ defer l.Unlock()
+
+ meta, err := s.readMeta(sessionKey)
+ if err != nil {
+ return err
+ }
+
+ // Always reconcile meta.Count with the actual line count on disk.
+ // A crash between the JSONL append and the meta update in addMsg
+ // leaves meta.Count stale (e.g. file has 101 lines but meta says
+ // 100). Counting lines is cheap — no unmarshal, just a scan — and
+ // TruncateHistory is not a hot path, so always re-count.
+ n, countErr := countLines(s.jsonlPath(sessionKey))
+ if countErr != nil {
+ return countErr
+ }
+ meta.Count = n
+
+ if keepLast <= 0 {
+ meta.Skip = meta.Count
+ } else {
+ effective := meta.Count - meta.Skip
+ if keepLast < effective {
+ meta.Skip = meta.Count - keepLast
+ }
+ }
+ meta.UpdatedAt = time.Now()
+
+ return s.writeMeta(sessionKey, meta)
+}
+
+func (s *JSONLStore) SetHistory(
+ _ context.Context,
+ sessionKey string,
+ history []providers.Message,
+) error {
+ l := s.sessionLock(sessionKey)
+ l.Lock()
+ defer l.Unlock()
+
+ meta, err := s.readMeta(sessionKey)
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ if meta.CreatedAt.IsZero() {
+ meta.CreatedAt = now
+ }
+ meta.Skip = 0
+ meta.Count = len(history)
+ meta.UpdatedAt = now
+
+ // Write meta BEFORE rewriting the JSONL file. If we crash between
+ // the two writes, meta has Skip=0 and the old file is still intact,
+ // so GetHistory reads from line 1 — returning "too many" messages
+ // rather than losing data. The next SetHistory call corrects this.
+ err = s.writeMeta(sessionKey, meta)
+ if err != nil {
+ return err
+ }
+
+ return s.rewriteJSONL(sessionKey, history)
+}
+
+// Compact physically rewrites the JSONL file, dropping all logically
+// skipped lines. This reclaims disk space that accumulates after
+// repeated TruncateHistory calls.
+//
+// It is safe to call at any time; if there is nothing to compact
+// (skip == 0) the method returns immediately.
+func (s *JSONLStore) Compact(
+ _ context.Context, sessionKey string,
+) error {
+ l := s.sessionLock(sessionKey)
+ l.Lock()
+ defer l.Unlock()
+
+ meta, err := s.readMeta(sessionKey)
+ if err != nil {
+ return err
+ }
+ if meta.Skip == 0 {
+ return nil
+ }
+
+ // Read only the active messages, skipping truncated lines
+ // without unmarshaling them.
+ active, err := readMessages(s.jsonlPath(sessionKey), meta.Skip)
+ if err != nil {
+ return err
+ }
+
+ // Write meta BEFORE rewriting the JSONL file. If the process
+ // crashes between the two writes, meta has Skip=0 and the old
+ // (uncompacted) file is still intact, so GetHistory reads from
+ // line 1 — returning previously-truncated messages rather than
+ // losing data. The next Compact or TruncateHistory corrects this.
+ meta.Skip = 0
+ meta.Count = len(active)
+ meta.UpdatedAt = time.Now()
+
+ err = s.writeMeta(sessionKey, meta)
+ if err != nil {
+ return err
+ }
+
+ return s.rewriteJSONL(sessionKey, active)
+}
+
+// rewriteJSONL atomically replaces the JSONL file with the given messages
+// using the project's standard WriteFileAtomic (temp + fsync + rename).
+func (s *JSONLStore) rewriteJSONL(
+ sessionKey string, msgs []providers.Message,
+) error {
+ var buf bytes.Buffer
+ for i, msg := range msgs {
+ line, err := json.Marshal(msg)
+ if err != nil {
+ return fmt.Errorf("memory: marshal message %d: %w", i, err)
+ }
+ buf.Write(line)
+ buf.WriteByte('\n')
+ }
+ return fileutil.WriteFileAtomic(s.jsonlPath(sessionKey), buf.Bytes(), 0o644)
+}
+
+func (s *JSONLStore) Close() error {
+ return nil
+}
diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go
new file mode 100644
index 000000000..356ff14ff
--- /dev/null
+++ b/pkg/memory/jsonl_test.go
@@ -0,0 +1,835 @@
+package memory
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func newTestStore(t *testing.T) *JSONLStore {
+ t.Helper()
+ store, err := NewJSONLStore(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewJSONLStore: %v", err)
+ }
+ return store
+}
+
+func TestNewJSONLStore_CreatesDirectory(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "nested", "sessions")
+ store, err := NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore: %v", err)
+ }
+ defer store.Close()
+
+ info, err := os.Stat(dir)
+ if err != nil {
+ t.Fatalf("Stat: %v", err)
+ }
+ if !info.IsDir() {
+ t.Errorf("expected directory, got file")
+ }
+}
+
+func TestAddMessage_BasicRoundtrip(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ err := store.AddMessage(ctx, "s1", "user", "hello")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ err = store.AddMessage(ctx, "s1", "assistant", "hi there")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "s1")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 2 {
+ t.Fatalf("expected 2 messages, got %d", len(history))
+ }
+ if history[0].Role != "user" || history[0].Content != "hello" {
+ t.Errorf("msg[0] = %+v", history[0])
+ }
+ if history[1].Role != "assistant" || history[1].Content != "hi there" {
+ t.Errorf("msg[1] = %+v", history[1])
+ }
+}
+
+func TestAddMessage_AutoCreatesSession(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ // Adding a message to a non-existent session should work.
+ err := store.AddMessage(ctx, "new-session", "user", "first message")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "new-session")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 1 {
+ t.Fatalf("expected 1 message, got %d", len(history))
+ }
+}
+
+func TestAddFullMessage_WithToolCalls(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ msg := providers.Message{
+ Role: "assistant",
+ Content: "Let me search that.",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_abc",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "web_search",
+ Arguments: `{"q":"golang jsonl"}`,
+ },
+ },
+ },
+ }
+
+ err := store.AddFullMessage(ctx, "tc", msg)
+ if err != nil {
+ t.Fatalf("AddFullMessage: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "tc")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 1 {
+ t.Fatalf("expected 1, got %d", len(history))
+ }
+ if len(history[0].ToolCalls) != 1 {
+ t.Fatalf("expected 1 tool call, got %d", len(history[0].ToolCalls))
+ }
+ tc := history[0].ToolCalls[0]
+ if tc.ID != "call_abc" {
+ t.Errorf("tool call ID = %q", tc.ID)
+ }
+ if tc.Function == nil || tc.Function.Name != "web_search" {
+ t.Errorf("tool call function = %+v", tc.Function)
+ }
+}
+
+func TestAddFullMessage_ToolCallID(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ msg := providers.Message{
+ Role: "tool",
+ Content: "search results here",
+ ToolCallID: "call_abc",
+ }
+
+ err := store.AddFullMessage(ctx, "tr", msg)
+ if err != nil {
+ t.Fatalf("AddFullMessage: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "tr")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 1 {
+ t.Fatalf("expected 1, got %d", len(history))
+ }
+ if history[0].ToolCallID != "call_abc" {
+ t.Errorf("ToolCallID = %q", history[0].ToolCallID)
+ }
+}
+
+func TestGetHistory_EmptySession(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ history, err := store.GetHistory(ctx, "nonexistent")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if history == nil {
+ t.Fatal("expected non-nil empty slice")
+ }
+ if len(history) != 0 {
+ t.Errorf("expected 0 messages, got %d", len(history))
+ }
+}
+
+func TestGetHistory_Ordering(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ for i := 0; i < 5; i++ {
+ err := store.AddMessage(
+ ctx, "order",
+ "user",
+ string(rune('a'+i)),
+ )
+ if err != nil {
+ t.Fatalf("AddMessage(%d): %v", i, err)
+ }
+ }
+
+ history, err := store.GetHistory(ctx, "order")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 5 {
+ t.Fatalf("expected 5, got %d", len(history))
+ }
+ for i := 0; i < 5; i++ {
+ expected := string(rune('a' + i))
+ if history[i].Content != expected {
+ t.Errorf("msg[%d].Content = %q, want %q", i, history[i].Content, expected)
+ }
+ }
+}
+
+func TestSetSummary_GetSummary(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ // No summary yet.
+ summary, err := store.GetSummary(ctx, "s1")
+ if err != nil {
+ t.Fatalf("GetSummary: %v", err)
+ }
+ if summary != "" {
+ t.Errorf("expected empty, got %q", summary)
+ }
+
+ // Set a summary.
+ err = store.SetSummary(ctx, "s1", "talked about Go")
+ if err != nil {
+ t.Fatalf("SetSummary: %v", err)
+ }
+
+ summary, err = store.GetSummary(ctx, "s1")
+ if err != nil {
+ t.Fatalf("GetSummary: %v", err)
+ }
+ if summary != "talked about Go" {
+ t.Errorf("summary = %q", summary)
+ }
+
+ // Update summary.
+ err = store.SetSummary(ctx, "s1", "updated summary")
+ if err != nil {
+ t.Fatalf("SetSummary: %v", err)
+ }
+
+ summary, err = store.GetSummary(ctx, "s1")
+ if err != nil {
+ t.Fatalf("GetSummary: %v", err)
+ }
+ if summary != "updated summary" {
+ t.Errorf("summary = %q", summary)
+ }
+}
+
+func TestTruncateHistory_KeepLast(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ for i := 0; i < 10; i++ {
+ err := store.AddMessage(
+ ctx, "trunc",
+ "user",
+ string(rune('a'+i)),
+ )
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ }
+
+ err := store.TruncateHistory(ctx, "trunc", 4)
+ if err != nil {
+ t.Fatalf("TruncateHistory: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "trunc")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 4 {
+ t.Fatalf("expected 4, got %d", len(history))
+ }
+ // Should be the last 4: g, h, i, j
+ if history[0].Content != "g" {
+ t.Errorf("first kept = %q, want 'g'", history[0].Content)
+ }
+ if history[3].Content != "j" {
+ t.Errorf("last kept = %q, want 'j'", history[3].Content)
+ }
+}
+
+func TestTruncateHistory_KeepZero(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ for i := 0; i < 5; i++ {
+ err := store.AddMessage(ctx, "empty", "user", "msg")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ }
+
+ err := store.TruncateHistory(ctx, "empty", 0)
+ if err != nil {
+ t.Fatalf("TruncateHistory: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "empty")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 0 {
+ t.Errorf("expected 0, got %d", len(history))
+ }
+}
+
+func TestTruncateHistory_KeepMoreThanExists(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ for i := 0; i < 3; i++ {
+ err := store.AddMessage(ctx, "few", "user", "msg")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ }
+
+ // Keep 100, but only 3 exist — should keep all.
+ err := store.TruncateHistory(ctx, "few", 100)
+ if err != nil {
+ t.Fatalf("TruncateHistory: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "few")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 3 {
+ t.Errorf("expected 3, got %d", len(history))
+ }
+}
+
+func TestSetHistory_ReplacesAll(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ // Add some initial messages.
+ for i := 0; i < 5; i++ {
+ err := store.AddMessage(ctx, "replace", "user", "old")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ }
+
+ // Replace with new history.
+ newHistory := []providers.Message{
+ {Role: "user", Content: "new1"},
+ {Role: "assistant", Content: "new2"},
+ }
+ err := store.SetHistory(ctx, "replace", newHistory)
+ if err != nil {
+ t.Fatalf("SetHistory: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "replace")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 2 {
+ t.Fatalf("expected 2, got %d", len(history))
+ }
+ if history[0].Content != "new1" || history[1].Content != "new2" {
+ t.Errorf("history = %+v", history)
+ }
+}
+
+func TestSetHistory_ResetsSkip(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ // Add messages and truncate.
+ for i := 0; i < 10; i++ {
+ err := store.AddMessage(ctx, "skip-reset", "user", "old")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ }
+ err := store.TruncateHistory(ctx, "skip-reset", 3)
+ if err != nil {
+ t.Fatalf("TruncateHistory: %v", err)
+ }
+
+ // SetHistory should reset skip to 0.
+ newHistory := []providers.Message{
+ {Role: "user", Content: "fresh"},
+ }
+ err = store.SetHistory(ctx, "skip-reset", newHistory)
+ if err != nil {
+ t.Fatalf("SetHistory: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "skip-reset")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 1 {
+ t.Fatalf("expected 1, got %d", len(history))
+ }
+ if history[0].Content != "fresh" {
+ t.Errorf("content = %q", history[0].Content)
+ }
+}
+
+func TestColonInKey(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ err := store.AddMessage(ctx, "telegram:123", "user", "hi")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "telegram:123")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 1 {
+ t.Fatalf("expected 1, got %d", len(history))
+ }
+
+ // Verify the file is named with underscore.
+ jsonlFile := filepath.Join(store.dir, "telegram_123.jsonl")
+ if _, statErr := os.Stat(jsonlFile); statErr != nil {
+ t.Errorf("expected file %s to exist: %v", jsonlFile, statErr)
+ }
+}
+
+func TestCompact_RemovesSkippedMessages(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ // Write 10 messages, then truncate to keep last 3.
+ for i := 0; i < 10; i++ {
+ err := store.AddMessage(ctx, "compact", "user", string(rune('a'+i)))
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ }
+ err := store.TruncateHistory(ctx, "compact", 3)
+ if err != nil {
+ t.Fatalf("TruncateHistory: %v", err)
+ }
+
+ // Before compact: file still has 10 lines.
+ allOnDisk, err := readMessages(store.jsonlPath("compact"), 0)
+ if err != nil {
+ t.Fatalf("readMessages: %v", err)
+ }
+ if len(allOnDisk) != 10 {
+ t.Fatalf("before compact: expected 10 on disk, got %d", len(allOnDisk))
+ }
+
+ // Compact.
+ err = store.Compact(ctx, "compact")
+ if err != nil {
+ t.Fatalf("Compact: %v", err)
+ }
+
+ // After compact: file should have only 3 lines.
+ allOnDisk, err = readMessages(store.jsonlPath("compact"), 0)
+ if err != nil {
+ t.Fatalf("readMessages: %v", err)
+ }
+ if len(allOnDisk) != 3 {
+ t.Fatalf("after compact: expected 3 on disk, got %d", len(allOnDisk))
+ }
+
+ // GetHistory should still return the same 3 messages.
+ history, err := store.GetHistory(ctx, "compact")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 3 {
+ t.Fatalf("expected 3, got %d", len(history))
+ }
+ if history[0].Content != "h" || history[2].Content != "j" {
+ t.Errorf("wrong content: %+v", history)
+ }
+}
+
+func TestCompact_NoOpWhenNoSkip(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ for i := 0; i < 5; i++ {
+ err := store.AddMessage(ctx, "noop", "user", "msg")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ }
+
+ // Compact without prior truncation — should be a no-op.
+ err := store.Compact(ctx, "noop")
+ if err != nil {
+ t.Fatalf("Compact: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "noop")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 5 {
+ t.Errorf("expected 5, got %d", len(history))
+ }
+}
+
+func TestCompact_ThenAppend(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ for i := 0; i < 8; i++ {
+ err := store.AddMessage(ctx, "cap", "user", string(rune('a'+i)))
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ }
+
+ err := store.TruncateHistory(ctx, "cap", 2)
+ if err != nil {
+ t.Fatalf("TruncateHistory: %v", err)
+ }
+ err = store.Compact(ctx, "cap")
+ if err != nil {
+ t.Fatalf("Compact: %v", err)
+ }
+
+ // Append after compaction should work correctly.
+ err = store.AddMessage(ctx, "cap", "user", "new")
+ if err != nil {
+ t.Fatalf("AddMessage after compact: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "cap")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 3 {
+ t.Fatalf("expected 3, got %d", len(history))
+ }
+ // g, h (kept from truncation), new (appended after compaction).
+ if history[0].Content != "g" {
+ t.Errorf("first = %q, want 'g'", history[0].Content)
+ }
+ if history[2].Content != "new" {
+ t.Errorf("last = %q, want 'new'", history[2].Content)
+ }
+}
+
+func TestTruncateHistory_StaleMetaCount(t *testing.T) {
+ // Simulates a crash between JSONL append and meta update in addMsg:
+ // file has N+1 lines but meta.Count is still N. TruncateHistory must
+ // reconcile with the real line count so that keepLast is accurate.
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ // Write 10 messages normally (meta.Count = 10).
+ for i := 0; i < 10; i++ {
+ err := store.AddMessage(ctx, "stale", "user", string(rune('a'+i)))
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ }
+
+ // Simulate crash: append a line to JSONL but do NOT update meta.
+ // This leaves meta.Count = 10 while the file has 11 lines.
+ jsonlPath := store.jsonlPath("stale")
+ f, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_APPEND, 0o644)
+ if err != nil {
+ t.Fatalf("open for append: %v", err)
+ }
+ _, err = f.WriteString(`{"role":"user","content":"orphan"}` + "\n")
+ if err != nil {
+ t.Fatalf("write orphan: %v", err)
+ }
+ f.Close()
+
+ // TruncateHistory(keepLast=4) should keep the last 4 of 11 lines,
+ // not the last 4 of 10.
+ err = store.TruncateHistory(ctx, "stale", 4)
+ if err != nil {
+ t.Fatalf("TruncateHistory: %v", err)
+ }
+
+ history, err := store.GetHistory(ctx, "stale")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 4 {
+ t.Fatalf("expected 4, got %d", len(history))
+ }
+ // Last 4 of [a,b,c,d,e,f,g,h,i,j,orphan] = [h,i,j,orphan]
+ if history[0].Content != "h" {
+ t.Errorf("first kept = %q, want 'h'", history[0].Content)
+ }
+ if history[3].Content != "orphan" {
+ t.Errorf("last kept = %q, want 'orphan'", history[3].Content)
+ }
+}
+
+func TestCrashRecovery_PartialLine(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ // Write a valid message first.
+ err := store.AddMessage(ctx, "crash", "user", "valid")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+
+ // Simulate a crash by appending a partial JSON line directly.
+ jsonlPath := store.jsonlPath("crash")
+ f, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_APPEND, 0o644)
+ if err != nil {
+ t.Fatalf("open for append: %v", err)
+ }
+ _, err = f.WriteString(`{"role":"user","content":"incomple`)
+ if err != nil {
+ t.Fatalf("write partial: %v", err)
+ }
+ f.Close()
+
+ // GetHistory should return only the valid message.
+ history, err := store.GetHistory(ctx, "crash")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 1 {
+ t.Fatalf("expected 1 valid message, got %d", len(history))
+ }
+ if history[0].Content != "valid" {
+ t.Errorf("content = %q", history[0].Content)
+ }
+}
+
+func TestPersistence_AcrossInstances(t *testing.T) {
+ dir := t.TempDir()
+ ctx := context.Background()
+
+ // Write with first instance.
+ store1, err := NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore: %v", err)
+ }
+ err = store1.AddMessage(ctx, "persist", "user", "remember me")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ err = store1.SetSummary(ctx, "persist", "a test session")
+ if err != nil {
+ t.Fatalf("SetSummary: %v", err)
+ }
+ store1.Close()
+
+ // Read with second instance.
+ store2, err := NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore: %v", err)
+ }
+ defer store2.Close()
+
+ history, err := store2.GetHistory(ctx, "persist")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 1 || history[0].Content != "remember me" {
+ t.Errorf("history = %+v", history)
+ }
+
+ summary, err := store2.GetSummary(ctx, "persist")
+ if err != nil {
+ t.Fatalf("GetSummary: %v", err)
+ }
+ if summary != "a test session" {
+ t.Errorf("summary = %q", summary)
+ }
+}
+
+func TestConcurrent_AddAndRead(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ var wg sync.WaitGroup
+ const goroutines = 10
+ const msgsPerGoroutine = 20
+
+ // Concurrent writes.
+ for g := 0; g < goroutines; g++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < msgsPerGoroutine; i++ {
+ _ = store.AddMessage(ctx, "concurrent", "user", "msg")
+ }
+ }()
+ }
+ wg.Wait()
+
+ history, err := store.GetHistory(ctx, "concurrent")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ expected := goroutines * msgsPerGoroutine
+ if len(history) != expected {
+ t.Errorf("expected %d messages, got %d", expected, len(history))
+ }
+}
+
+func TestConcurrent_SummarizeRace(t *testing.T) {
+ // Simulates the #704 race: one goroutine adds messages while
+ // another truncates + sets summary — like summarizeSession().
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ // Seed with some messages.
+ for i := 0; i < 20; i++ {
+ err := store.AddMessage(ctx, "race", "user", "seed")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ }
+
+ var wg sync.WaitGroup
+
+ // Writer goroutine (main agent loop).
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < 50; i++ {
+ _ = store.AddMessage(ctx, "race", "user", "new")
+ }
+ }()
+
+ // Summarizer goroutine (background task).
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < 10; i++ {
+ _ = store.SetSummary(ctx, "race", "summary")
+ _ = store.TruncateHistory(ctx, "race", 5)
+ }
+ }()
+
+ wg.Wait()
+
+ // Verify the store is still in a consistent state.
+ _, err := store.GetHistory(ctx, "race")
+ if err != nil {
+ t.Fatalf("GetHistory after race: %v", err)
+ }
+ _, err = store.GetSummary(ctx, "race")
+ if err != nil {
+ t.Fatalf("GetSummary after race: %v", err)
+ }
+}
+
+func TestMultipleSessions_Isolation(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ err := store.AddMessage(ctx, "s1", "user", "msg for s1")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ err = store.AddMessage(ctx, "s2", "user", "msg for s2")
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+
+ h1, err := store.GetHistory(ctx, "s1")
+ if err != nil {
+ t.Fatalf("GetHistory s1: %v", err)
+ }
+ h2, err := store.GetHistory(ctx, "s2")
+ if err != nil {
+ t.Fatalf("GetHistory s2: %v", err)
+ }
+
+ if len(h1) != 1 || h1[0].Content != "msg for s1" {
+ t.Errorf("s1 history = %+v", h1)
+ }
+ if len(h2) != 1 || h2[0].Content != "msg for s2" {
+ t.Errorf("s2 history = %+v", h2)
+ }
+}
+
+func BenchmarkAddMessage(b *testing.B) {
+ dir := b.TempDir()
+ store, err := NewJSONLStore(dir)
+ if err != nil {
+ b.Fatalf("NewJSONLStore: %v", err)
+ }
+ defer store.Close()
+ ctx := context.Background()
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = store.AddMessage(ctx, "bench", "user", "benchmark message content")
+ }
+}
+
+func BenchmarkGetHistory_100(b *testing.B) {
+ dir := b.TempDir()
+ store, err := NewJSONLStore(dir)
+ if err != nil {
+ b.Fatalf("NewJSONLStore: %v", err)
+ }
+ defer store.Close()
+ ctx := context.Background()
+
+ for i := 0; i < 100; i++ {
+ _ = store.AddMessage(ctx, "bench", "user", "message content")
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, _ = store.GetHistory(ctx, "bench")
+ }
+}
+
+func BenchmarkGetHistory_1000(b *testing.B) {
+ dir := b.TempDir()
+ store, err := NewJSONLStore(dir)
+ if err != nil {
+ b.Fatalf("NewJSONLStore: %v", err)
+ }
+ defer store.Close()
+ ctx := context.Background()
+
+ for i := 0; i < 1000; i++ {
+ _ = store.AddMessage(ctx, "bench", "user", "message content")
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, _ = store.GetHistory(ctx, "bench")
+ }
+}
diff --git a/pkg/memory/migration.go b/pkg/memory/migration.go
new file mode 100644
index 000000000..c9d5176ab
--- /dev/null
+++ b/pkg/memory/migration.go
@@ -0,0 +1,108 @@
+package memory
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// jsonSession mirrors pkg/session.Session for migration purposes.
+type jsonSession struct {
+ Key string `json:"key"`
+ Messages []providers.Message `json:"messages"`
+ Summary string `json:"summary,omitempty"`
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
+}
+
+// MigrateFromJSON reads legacy sessions/*.json files from sessionsDir,
+// writes them into the Store, and renames each migrated file to
+// .json.migrated as a backup. Returns the number of sessions migrated.
+//
+// Files that fail to parse are logged and skipped. Already-migrated
+// files (.json.migrated) are ignored, making the function idempotent.
+func MigrateFromJSON(
+ ctx context.Context, sessionsDir string, store Store,
+) (int, error) {
+ entries, err := os.ReadDir(sessionsDir)
+ if os.IsNotExist(err) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, fmt.Errorf("memory: read sessions dir: %w", err)
+ }
+
+ migrated := 0
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if !strings.HasSuffix(name, ".json") {
+ continue
+ }
+ // Skip already-migrated files.
+ if strings.HasSuffix(name, ".migrated") {
+ continue
+ }
+
+ srcPath := filepath.Join(sessionsDir, name)
+
+ data, readErr := os.ReadFile(srcPath)
+ if readErr != nil {
+ log.Printf("memory: migrate: skip %s: %v", name, readErr)
+ continue
+ }
+
+ var sess jsonSession
+ if parseErr := json.Unmarshal(data, &sess); parseErr != nil {
+ log.Printf("memory: migrate: skip %s: %v", name, parseErr)
+ continue
+ }
+
+ // Use the key from the JSON content, not the filename.
+ // Filenames are sanitized (":" → "_") but keys are not.
+ key := sess.Key
+ if key == "" {
+ key = strings.TrimSuffix(name, ".json")
+ }
+
+ // Use SetHistory (atomic replace) instead of per-message
+ // AddFullMessage. This makes migration idempotent: if the
+ // process crashes after writing messages but before the
+ // rename below, a retry replaces the partial data cleanly
+ // instead of duplicating messages.
+ if setErr := store.SetHistory(ctx, key, sess.Messages); setErr != nil {
+ return migrated, fmt.Errorf(
+ "memory: migrate %s: set history: %w",
+ name, setErr,
+ )
+ }
+
+ if sess.Summary != "" {
+ if sumErr := store.SetSummary(ctx, key, sess.Summary); sumErr != nil {
+ return migrated, fmt.Errorf(
+ "memory: migrate %s: set summary: %w",
+ name, sumErr,
+ )
+ }
+ }
+
+ // Rename to .migrated as backup (not delete).
+ renameErr := os.Rename(srcPath, srcPath+".migrated")
+ if renameErr != nil {
+ log.Printf("memory: migrate: rename %s: %v", name, renameErr)
+ }
+
+ migrated++
+ }
+
+ return migrated, nil
+}
diff --git a/pkg/memory/migration_test.go b/pkg/memory/migration_test.go
new file mode 100644
index 000000000..3170758b7
--- /dev/null
+++ b/pkg/memory/migration_test.go
@@ -0,0 +1,384 @@
+package memory
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func writeJSONSession(
+ t *testing.T, dir string, filename string, sess jsonSession,
+) {
+ t.Helper()
+ data, err := json.MarshalIndent(sess, "", " ")
+ if err != nil {
+ t.Fatalf("marshal session: %v", err)
+ }
+ err = os.WriteFile(filepath.Join(dir, filename), data, 0o644)
+ if err != nil {
+ t.Fatalf("write session file: %v", err)
+ }
+}
+
+func TestMigrateFromJSON_Basic(t *testing.T) {
+ sessionsDir := t.TempDir()
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ writeJSONSession(t, sessionsDir, "test.json", jsonSession{
+ Key: "test",
+ Messages: []providers.Message{
+ {Role: "user", Content: "hello"},
+ {Role: "assistant", Content: "hi"},
+ },
+ Summary: "A greeting.",
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+
+ count, err := MigrateFromJSON(ctx, sessionsDir, store)
+ if err != nil {
+ t.Fatalf("MigrateFromJSON: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("expected 1 migrated, got %d", count)
+ }
+
+ history, err := store.GetHistory(ctx, "test")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 2 {
+ t.Fatalf("expected 2 messages, got %d", len(history))
+ }
+ if history[0].Content != "hello" || history[1].Content != "hi" {
+ t.Errorf("unexpected messages: %+v", history)
+ }
+
+ summary, err := store.GetSummary(ctx, "test")
+ if err != nil {
+ t.Fatalf("GetSummary: %v", err)
+ }
+ if summary != "A greeting." {
+ t.Errorf("summary = %q", summary)
+ }
+}
+
+func TestMigrateFromJSON_WithToolCalls(t *testing.T) {
+ sessionsDir := t.TempDir()
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ writeJSONSession(t, sessionsDir, "tools.json", jsonSession{
+ Key: "tools",
+ Messages: []providers.Message{
+ {
+ Role: "assistant",
+ Content: "Searching...",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "web_search",
+ Arguments: `{"q":"test"}`,
+ },
+ },
+ },
+ },
+ {
+ Role: "tool",
+ Content: "result",
+ ToolCallID: "call_1",
+ },
+ },
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+
+ count, err := MigrateFromJSON(ctx, sessionsDir, store)
+ if err != nil {
+ t.Fatalf("MigrateFromJSON: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("expected 1, got %d", count)
+ }
+
+ history, err := store.GetHistory(ctx, "tools")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 2 {
+ t.Fatalf("expected 2 messages, got %d", len(history))
+ }
+ if len(history[0].ToolCalls) != 1 {
+ t.Fatalf("expected 1 tool call, got %d", len(history[0].ToolCalls))
+ }
+ if history[0].ToolCalls[0].Function.Name != "web_search" {
+ t.Errorf("function = %q", history[0].ToolCalls[0].Function.Name)
+ }
+ if history[1].ToolCallID != "call_1" {
+ t.Errorf("ToolCallID = %q", history[1].ToolCallID)
+ }
+}
+
+func TestMigrateFromJSON_MultipleFiles(t *testing.T) {
+ sessionsDir := t.TempDir()
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ for i := 0; i < 3; i++ {
+ key := string(rune('a' + i))
+ writeJSONSession(t, sessionsDir, key+".json", jsonSession{
+ Key: key,
+ Messages: []providers.Message{{Role: "user", Content: "msg " + key}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+ }
+
+ count, err := MigrateFromJSON(ctx, sessionsDir, store)
+ if err != nil {
+ t.Fatalf("MigrateFromJSON: %v", err)
+ }
+ if count != 3 {
+ t.Errorf("expected 3, got %d", count)
+ }
+
+ for i := 0; i < 3; i++ {
+ key := string(rune('a' + i))
+ history, histErr := store.GetHistory(ctx, key)
+ if histErr != nil {
+ t.Fatalf("GetHistory(%q): %v", key, histErr)
+ }
+ if len(history) != 1 {
+ t.Errorf("session %q: expected 1 msg, got %d", key, len(history))
+ }
+ }
+}
+
+func TestMigrateFromJSON_InvalidJSON(t *testing.T) {
+ sessionsDir := t.TempDir()
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ // One valid, one invalid.
+ writeJSONSession(t, sessionsDir, "good.json", jsonSession{
+ Key: "good",
+ Messages: []providers.Message{{Role: "user", Content: "ok"}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+ err := os.WriteFile(
+ filepath.Join(sessionsDir, "bad.json"),
+ []byte("{invalid json"),
+ 0o644,
+ )
+ if err != nil {
+ t.Fatalf("write bad file: %v", err)
+ }
+
+ count, err := MigrateFromJSON(ctx, sessionsDir, store)
+ if err != nil {
+ t.Fatalf("MigrateFromJSON: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("expected 1 (bad file skipped), got %d", count)
+ }
+
+ history, err := store.GetHistory(ctx, "good")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 1 {
+ t.Errorf("expected 1 message, got %d", len(history))
+ }
+}
+
+func TestMigrateFromJSON_RenamesFiles(t *testing.T) {
+ sessionsDir := t.TempDir()
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ writeJSONSession(t, sessionsDir, "rename.json", jsonSession{
+ Key: "rename",
+ Messages: []providers.Message{{Role: "user", Content: "hi"}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+
+ _, err := MigrateFromJSON(ctx, sessionsDir, store)
+ if err != nil {
+ t.Fatalf("MigrateFromJSON: %v", err)
+ }
+
+ // Original .json should not exist.
+ _, statErr := os.Stat(filepath.Join(sessionsDir, "rename.json"))
+ if !os.IsNotExist(statErr) {
+ t.Error("rename.json should have been renamed")
+ }
+ // .json.migrated should exist.
+ _, statErr = os.Stat(
+ filepath.Join(sessionsDir, "rename.json.migrated"),
+ )
+ if statErr != nil {
+ t.Errorf("rename.json.migrated should exist: %v", statErr)
+ }
+}
+
+func TestMigrateFromJSON_Idempotent(t *testing.T) {
+ sessionsDir := t.TempDir()
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ writeJSONSession(t, sessionsDir, "idem.json", jsonSession{
+ Key: "idem",
+ Messages: []providers.Message{{Role: "user", Content: "once"}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+
+ count1, err := MigrateFromJSON(ctx, sessionsDir, store)
+ if err != nil {
+ t.Fatalf("first migration: %v", err)
+ }
+ if count1 != 1 {
+ t.Errorf("first run: expected 1, got %d", count1)
+ }
+
+ // Second run should find only .migrated files, skip them.
+ count2, err := MigrateFromJSON(ctx, sessionsDir, store)
+ if err != nil {
+ t.Fatalf("second migration: %v", err)
+ }
+ if count2 != 0 {
+ t.Errorf("second run: expected 0, got %d", count2)
+ }
+
+ history, err := store.GetHistory(ctx, "idem")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 1 {
+ t.Errorf("expected 1 message, got %d", len(history))
+ }
+}
+
+func TestMigrateFromJSON_ColonInKey(t *testing.T) {
+ sessionsDir := t.TempDir()
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ // File is named telegram_123 (sanitized), but the key inside is telegram:123.
+ writeJSONSession(t, sessionsDir, "telegram_123.json", jsonSession{
+ Key: "telegram:123",
+ Messages: []providers.Message{{Role: "user", Content: "from telegram"}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+
+ count, err := MigrateFromJSON(ctx, sessionsDir, store)
+ if err != nil {
+ t.Fatalf("MigrateFromJSON: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("expected 1, got %d", count)
+ }
+
+ // Accessible via the original key "telegram:123".
+ history, err := store.GetHistory(ctx, "telegram:123")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history) != 1 {
+ t.Fatalf("expected 1 message, got %d", len(history))
+ }
+ if history[0].Content != "from telegram" {
+ t.Errorf("content = %q", history[0].Content)
+ }
+
+ // In the file-based store, "telegram:123" and "telegram_123" both
+ // sanitize to the same filename, so they share storage. This is
+ // expected — the colon-to-underscore mapping is a one-way function.
+ history2, err := store.GetHistory(ctx, "telegram_123")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ if len(history2) != 1 {
+ t.Errorf("expected 1 (same file), got %d", len(history2))
+ }
+}
+
+func TestMigrateFromJSON_RetryAfterCrash(t *testing.T) {
+ // Simulates a crash during migration: first run writes messages
+ // but doesn't rename the .json file. Second run must replace
+ // (not duplicate) the messages thanks to SetHistory semantics.
+ sessionsDir := t.TempDir()
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ writeJSONSession(t, sessionsDir, "retry.json", jsonSession{
+ Key: "retry",
+ Messages: []providers.Message{
+ {Role: "user", Content: "one"},
+ {Role: "assistant", Content: "two"},
+ },
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+
+ // First migration succeeds — writes messages and renames file.
+ count, err := MigrateFromJSON(ctx, sessionsDir, store)
+ if err != nil {
+ t.Fatalf("first migration: %v", err)
+ }
+ if count != 1 {
+ t.Fatalf("expected 1, got %d", count)
+ }
+
+ // Simulate "crash before rename": restore the .json file.
+ src := filepath.Join(sessionsDir, "retry.json.migrated")
+ dst := filepath.Join(sessionsDir, "retry.json")
+ if renameErr := os.Rename(src, dst); renameErr != nil {
+ t.Fatalf("restore .json: %v", renameErr)
+ }
+
+ // Second migration should re-import without duplicating messages.
+ count, err = MigrateFromJSON(ctx, sessionsDir, store)
+ if err != nil {
+ t.Fatalf("second migration: %v", err)
+ }
+ if count != 1 {
+ t.Fatalf("expected 1, got %d", count)
+ }
+
+ history, err := store.GetHistory(ctx, "retry")
+ if err != nil {
+ t.Fatalf("GetHistory: %v", err)
+ }
+ // Must be exactly 2 messages (not 4 from duplication).
+ if len(history) != 2 {
+ t.Fatalf("expected 2 messages (no duplicates), got %d", len(history))
+ }
+ if history[0].Content != "one" || history[1].Content != "two" {
+ t.Errorf("unexpected messages: %+v", history)
+ }
+}
+
+func TestMigrateFromJSON_NonexistentDir(t *testing.T) {
+ store := newTestStore(t)
+ ctx := context.Background()
+
+ count, err := MigrateFromJSON(ctx, "/nonexistent/path", store)
+ if err != nil {
+ t.Fatalf("MigrateFromJSON: %v", err)
+ }
+ if count != 0 {
+ t.Errorf("expected 0, got %d", count)
+ }
+}
diff --git a/pkg/memory/store.go b/pkg/memory/store.go
new file mode 100644
index 000000000..b6e11707d
--- /dev/null
+++ b/pkg/memory/store.go
@@ -0,0 +1,42 @@
+package memory
+
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// Store defines an interface for persistent session storage.
+// Each method is an atomic operation — there is no separate Save() call.
+type Store interface {
+ // AddMessage appends a simple text message to a session.
+ AddMessage(ctx context.Context, sessionKey, role, content string) error
+
+ // AddFullMessage appends a complete message (with tool calls, etc.) to a session.
+ AddFullMessage(ctx context.Context, sessionKey string, msg providers.Message) error
+
+ // GetHistory returns all messages for a session in insertion order.
+ // Returns an empty slice (not nil) if the session does not exist.
+ GetHistory(ctx context.Context, sessionKey string) ([]providers.Message, error)
+
+ // GetSummary returns the conversation summary for a session.
+ // Returns an empty string if no summary exists.
+ GetSummary(ctx context.Context, sessionKey string) (string, error)
+
+ // SetSummary updates the conversation summary for a session.
+ SetSummary(ctx context.Context, sessionKey, summary string) error
+
+ // TruncateHistory removes all but the last keepLast messages from a session.
+ // If keepLast <= 0, all messages are removed.
+ TruncateHistory(ctx context.Context, sessionKey string, keepLast int) error
+
+ // SetHistory replaces all messages in a session with the provided history.
+ SetHistory(ctx context.Context, sessionKey string, history []providers.Message) error
+
+ // Compact reclaims storage by physically removing logically truncated
+ // data. Backends that do not accumulate dead data may return nil.
+ Compact(ctx context.Context, sessionKey string) error
+
+ // Close releases any resources held by the store.
+ Close() error
+}
diff --git a/pkg/migrate/internal/common_test.go b/pkg/migrate/internal/common_test.go
index a089157f5..a67293c19 100644
--- a/pkg/migrate/internal/common_test.go
+++ b/pkg/migrate/internal/common_test.go
@@ -118,64 +118,55 @@ func TestPlanWorkspaceMigration(t *testing.T) {
assert.GreaterOrEqual(t, len(actions), 1)
}
-func TestPlanWorkspaceMigrationWithExistingDestination(t *testing.T) {
- tmpDir := t.TempDir()
- srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
- dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
+func TestPlanWorkspaceMigrationExistingFile(t *testing.T) {
+ tests := []struct {
+ name string
+ force bool
+ wantActionType ActionType
+ }{
+ {
+ name: "backup when not forced",
+ force: false,
+ wantActionType: ActionBackup,
+ },
+ {
+ name: "copy when forced",
+ force: true,
+ wantActionType: ActionCopy,
+ },
+ }
- err := os.MkdirAll(srcWorkspace, 0o755)
- require.NoError(t, err)
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpDir := t.TempDir()
+ srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
+ dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
- err = os.MkdirAll(dstWorkspace, 0o755)
- require.NoError(t, err)
+ err := os.MkdirAll(srcWorkspace, 0o755)
+ require.NoError(t, err)
- err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
- require.NoError(t, err)
+ err = os.MkdirAll(dstWorkspace, 0o755)
+ require.NoError(t, err)
- err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
- require.NoError(t, err)
+ err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
+ require.NoError(t, err)
- actions, err := PlanWorkspaceMigration(
- srcWorkspace,
- dstWorkspace,
- []string{"file1.txt"},
- []string{},
- false,
- )
- require.NoError(t, err)
+ err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
+ require.NoError(t, err)
- require.GreaterOrEqual(t, len(actions), 1)
- assert.Equal(t, ActionBackup, actions[0].Type)
-}
+ actions, err := PlanWorkspaceMigration(
+ srcWorkspace,
+ dstWorkspace,
+ []string{"file1.txt"},
+ []string{},
+ tt.force,
+ )
+ require.NoError(t, err)
-func TestPlanWorkspaceMigrationForce(t *testing.T) {
- tmpDir := t.TempDir()
- srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
- dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
-
- err := os.MkdirAll(srcWorkspace, 0o755)
- require.NoError(t, err)
-
- err = os.MkdirAll(dstWorkspace, 0o755)
- require.NoError(t, err)
-
- err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
- require.NoError(t, err)
-
- err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
- require.NoError(t, err)
-
- actions, err := PlanWorkspaceMigration(
- srcWorkspace,
- dstWorkspace,
- []string{"file1.txt"},
- []string{},
- true,
- )
- require.NoError(t, err)
-
- require.GreaterOrEqual(t, len(actions), 1)
- assert.Equal(t, ActionCopy, actions[0].Type)
+ require.GreaterOrEqual(t, len(actions), 1)
+ assert.Equal(t, tt.wantActionType, actions[0].Type)
+ })
+ }
}
func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) {
diff --git a/pkg/migrate/sources/openclaw/common.go b/pkg/migrate/sources/openclaw/common.go
index dddd98089..d57dbe34f 100644
--- a/pkg/migrate/sources/openclaw/common.go
+++ b/pkg/migrate/sources/openclaw/common.go
@@ -22,6 +22,7 @@ var supportedChannels = map[string]bool{
"qq": true,
"dingtalk": true,
"slack": true,
+ "matrix": true,
"line": true,
"onebot": true,
"wecom": true,
diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go
index 39ad48fad..19d63bb77 100644
--- a/pkg/migrate/sources/openclaw/openclaw_config.go
+++ b/pkg/migrate/sources/openclaw/openclaw_config.go
@@ -371,6 +371,8 @@ func (c *OpenClawConfig) IsChannelEnabled(name string) bool {
return c.Channels.Discord == nil || c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled
case "slack":
return c.Channels.Slack == nil || c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled
+ case "matrix":
+ return c.Channels.Matrix == nil || c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled
case "whatsapp":
return c.Channels.WhatsApp == nil || c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled
case "feishu":
@@ -397,6 +399,11 @@ func GetChannelAllowFrom(ch any) []string {
return nil
}
return c.AllowFrom
+ case *OpenClawMatrixConfig:
+ if c == nil {
+ return nil
+ }
+ return c.AllowFrom
case *OpenClawWhatsAppConfig:
if c == nil {
return nil
@@ -627,6 +634,7 @@ type ChannelsConfig struct {
QQ QQConfig `json:"qq"`
DingTalk DingTalkConfig `json:"dingtalk"`
Slack SlackConfig `json:"slack"`
+ Matrix MatrixConfig `json:"matrix"`
LINE LINEConfig `json:"line"`
}
@@ -687,6 +695,14 @@ type SlackConfig struct {
AllowFrom []string `json:"allow_from"`
}
+type MatrixConfig struct {
+ Enabled bool `json:"enabled"`
+ Homeserver string `json:"homeserver"`
+ UserID string `json:"user_id"`
+ AccessToken string `json:"access_token"`
+ AllowFrom []string `json:"allow_from"`
+}
+
type LINEConfig struct {
Enabled bool `json:"enabled"`
ChannelSecret string `json:"channel_secret"`
@@ -862,12 +878,26 @@ func (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig {
}
}
+ if c.Channels.Matrix != nil && supportedChannels["matrix"] {
+ enabled := c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled
+ channels.Matrix = MatrixConfig{
+ Enabled: enabled,
+ AllowFrom: c.Channels.Matrix.AllowFrom,
+ }
+ if c.Channels.Matrix.Homeserver != nil {
+ channels.Matrix.Homeserver = *c.Channels.Matrix.Homeserver
+ }
+ if c.Channels.Matrix.UserID != nil {
+ channels.Matrix.UserID = *c.Channels.Matrix.UserID
+ }
+ if c.Channels.Matrix.AccessToken != nil {
+ channels.Matrix.AccessToken = *c.Channels.Matrix.AccessToken
+ }
+ }
+
if c.Channels.Signal != nil {
*warnings = append(*warnings, "Channel 'signal': No PicoClaw adapter available")
}
- if c.Channels.Matrix != nil {
- *warnings = append(*warnings, "Channel 'matrix': No PicoClaw adapter available")
- }
if c.Channels.IRC != nil {
*warnings = append(*warnings, "Channel 'irc': No PicoClaw adapter available")
}
@@ -1020,6 +1050,14 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
BotToken: c.Slack.BotToken,
AppToken: c.Slack.AppToken,
},
+ Matrix: config.MatrixConfig{
+ Enabled: c.Matrix.Enabled,
+ Homeserver: c.Matrix.Homeserver,
+ UserID: c.Matrix.UserID,
+ AccessToken: c.Matrix.AccessToken,
+ AllowFrom: c.Matrix.AllowFrom,
+ JoinOnInvite: true,
+ },
LINE: config.LINEConfig{
Enabled: c.LINE.Enabled,
ChannelSecret: c.LINE.ChannelSecret,
diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go
index 7d884522c..3a7d0c686 100644
--- a/pkg/migrate/sources/openclaw/openclaw_config_test.go
+++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "strings"
"testing"
)
@@ -375,6 +376,96 @@ func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) {
}
}
+func TestConvertToPicoClawWithMatrix(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+
+ testConfig := `{
+ "channels": {
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.example.com",
+ "userId": "@bot:matrix.example.com",
+ "accessToken": "syt_test_token",
+ "allowFrom": ["@alice:matrix.example.com"]
+ }
+ }
+ }`
+
+ err := os.WriteFile(configPath, []byte(testConfig), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write test config: %v", err)
+ }
+
+ cfg, err := LoadOpenClawConfig(configPath)
+ if err != nil {
+ t.Fatalf("failed to load config: %v", err)
+ }
+
+ picoCfg, warnings, err := cfg.ConvertToPicoClaw("")
+ if err != nil {
+ t.Fatalf("failed to convert config: %v", err)
+ }
+
+ if !picoCfg.Channels.Matrix.Enabled {
+ t.Error("matrix should be enabled")
+ }
+ if picoCfg.Channels.Matrix.Homeserver != "https://matrix.example.com" {
+ t.Errorf("expected matrix homeserver, got %q", picoCfg.Channels.Matrix.Homeserver)
+ }
+ if picoCfg.Channels.Matrix.UserID != "@bot:matrix.example.com" {
+ t.Errorf("expected matrix user_id, got %q", picoCfg.Channels.Matrix.UserID)
+ }
+ if picoCfg.Channels.Matrix.AccessToken != "syt_test_token" {
+ t.Errorf("expected matrix access_token, got %q", picoCfg.Channels.Matrix.AccessToken)
+ }
+ if len(picoCfg.Channels.Matrix.AllowFrom) != 1 ||
+ picoCfg.Channels.Matrix.AllowFrom[0] != "@alice:matrix.example.com" {
+ t.Errorf("unexpected matrix allow_from: %#v", picoCfg.Channels.Matrix.AllowFrom)
+ }
+
+ for _, w := range warnings {
+ if strings.Contains(w, "Channel 'matrix'") {
+ t.Fatalf("matrix should no longer be reported as unsupported, warning=%q", w)
+ }
+ }
+}
+
+func TestConvertToPicoClawWithMatrixDisabled(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+
+ testConfig := `{
+ "channels": {
+ "matrix": {
+ "enabled": false,
+ "homeserver": "https://matrix.example.com",
+ "userId": "@bot:matrix.example.com",
+ "accessToken": "syt_test_token"
+ }
+ }
+ }`
+
+ err := os.WriteFile(configPath, []byte(testConfig), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write test config: %v", err)
+ }
+
+ cfg, err := LoadOpenClawConfig(configPath)
+ if err != nil {
+ t.Fatalf("failed to load config: %v", err)
+ }
+
+ picoCfg, _, err := cfg.ConvertToPicoClaw("")
+ if err != nil {
+ t.Fatalf("failed to convert config: %v", err)
+ }
+
+ if picoCfg.Channels.Matrix.Enabled {
+ t.Error("matrix should respect enabled=false from source config")
+ }
+}
+
func TestOpenClawAgentModel(t *testing.T) {
model := &OpenClawAgentModel{
Primary: strPtr("anthropic/claude-3-opus"),
@@ -425,6 +516,9 @@ func TestChannelEnabled(t *testing.T) {
if !cfg.IsChannelEnabled("slack") {
t.Error("slack should be enabled (explicitly set)")
}
+ if !cfg.IsChannelEnabled("matrix") {
+ t.Error("matrix should be enabled (nil config defaults to enabled)")
+ }
if cfg.IsChannelEnabled("line") {
t.Error("line should return false (not in switch cases)")
}
diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go
index 3b79519e7..6e5260039 100644
--- a/pkg/providers/anthropic/provider.go
+++ b/pkg/providers/anthropic/provider.go
@@ -23,7 +23,10 @@ type (
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
)
-const defaultBaseURL = "https://api.anthropic.com"
+const (
+ defaultBaseURL = "https://api.anthropic.com"
+ anthropicBetaHeader = "oauth-2025-04-20"
+)
type Provider struct {
client *anthropic.Client
@@ -31,6 +34,9 @@ type Provider struct {
baseURL string
}
+// SupportsThinking implements providers.ThinkingCapable.
+func (p *Provider) SupportsThinking() bool { return true }
+
func NewProvider(token string) *Provider {
return NewProviderWithBaseURL(token, "")
}
@@ -77,7 +83,10 @@ func (p *Provider) Chat(
if err != nil {
return nil, fmt.Errorf("refreshing token: %w", err)
}
- opts = append(opts, option.WithAuthToken(tok))
+ opts = append(opts,
+ option.WithAuthToken(tok),
+ option.WithHeader("anthropic-beta", anthropicBetaHeader),
+ )
}
params, err := buildParams(messages, tools, model, options)
@@ -85,6 +94,11 @@ func (p *Provider) Chat(
return nil, err
}
+ // OAuth/setup-tokens require streaming; API keys use non-streaming.
+ if p.tokenSource != nil {
+ return p.chatStreaming(ctx, params, opts)
+ }
+
resp, err := p.client.Messages.New(ctx, params, opts...)
if err != nil {
return nil, fmt.Errorf("claude API call: %w", err)
@@ -93,6 +107,28 @@ func (p *Provider) Chat(
return parseResponse(resp), nil
}
+func (p *Provider) chatStreaming(
+ ctx context.Context,
+ params anthropic.MessageNewParams,
+ opts []option.RequestOption,
+) (*LLMResponse, error) {
+ stream := p.client.Messages.NewStreaming(ctx, params, opts...)
+ defer stream.Close()
+
+ var msg anthropic.Message
+ for stream.Next() {
+ event := stream.Current()
+ if err := msg.Accumulate(event); err != nil {
+ return nil, fmt.Errorf("claude streaming accumulate: %w", err)
+ }
+ }
+ if err := stream.Err(); err != nil {
+ return nil, fmt.Errorf("claude API call: %w", err)
+ }
+
+ return parseResponse(&msg), nil
+}
+
func (p *Provider) GetDefaultModel() string {
return "claude-sonnet-4.6"
}
@@ -144,7 +180,16 @@ func buildParams(
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
}
for _, tc := range msg.ToolCalls {
- blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
+ args := tc.Arguments
+ if args == nil && tc.Function != nil && tc.Function.Arguments != "" {
+ if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
+ args = map[string]any{}
+ }
+ }
+ if args == nil {
+ args = map[string]any{}
+ }
+ blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, tc.Name))
}
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
} else {
@@ -164,8 +209,12 @@ func buildParams(
maxTokens = int64(mt)
}
+ // Normalize model ID: Anthropic API uses hyphens (claude-sonnet-4-6),
+ // but config may use dots (claude-sonnet-4.6).
+ apiModel := strings.ReplaceAll(model, ".", "-")
+
params := anthropic.MessageNewParams{
- Model: anthropic.Model(model),
+ Model: anthropic.Model(apiModel),
Messages: anthropicMessages,
MaxTokens: maxTokens,
}
@@ -182,9 +231,80 @@ func buildParams(
params.Tools = translateTools(tools)
}
+ // Extended Thinking / Adaptive Thinking
+ // The thinking_level value directly determines the API parameter format:
+ // "adaptive" → {thinking: {type: "adaptive"}} + output_config.effort
+ // "low/medium/high/xhigh" → {thinking: {type: "enabled", budget_tokens: N}}
+ if level, ok := options["thinking_level"].(string); ok && level != "" && level != "off" {
+ applyThinkingConfig(¶ms, level)
+ }
+
return params, nil
}
+// applyThinkingConfig sets thinking parameters based on the level value.
+// "adaptive" uses the adaptive thinking API (Claude 4.6+).
+// All other levels use budget_tokens which is universally supported.
+//
+// Anthropic API constraint: temperature must not be set when thinking is enabled.
+// budget_tokens must be strictly less than max_tokens.
+func applyThinkingConfig(params *anthropic.MessageNewParams, level string) {
+ // Anthropic API rejects requests with temperature set alongside thinking.
+ // Reset to zero value (omitted from JSON serialization).
+ if params.Temperature.Valid() {
+ log.Printf("anthropic: temperature cleared because thinking is enabled (level=%s)", level)
+ }
+ params.Temperature = anthropic.MessageNewParams{}.Temperature
+
+ if level == "adaptive" {
+ adaptive := anthropic.NewThinkingConfigAdaptiveParam()
+ params.Thinking = anthropic.ThinkingConfigParamUnion{OfAdaptive: &adaptive}
+ params.OutputConfig = anthropic.OutputConfigParam{
+ Effort: anthropic.OutputConfigEffortHigh,
+ }
+ return
+ }
+
+ budget := int64(levelToBudget(level))
+ if budget <= 0 {
+ return
+ }
+
+ // budget_tokens must be < max_tokens; clamp to respect user's max_tokens setting.
+ if budget >= params.MaxTokens {
+ log.Printf("anthropic: budget_tokens (%d) clamped to %d (max_tokens-1)", budget, params.MaxTokens-1)
+ budget = params.MaxTokens - 1
+ } else if budget > params.MaxTokens*80/100 {
+ log.Printf("anthropic: thinking budget (%d) exceeds 80%% of max_tokens (%d), output may be truncated",
+ budget, params.MaxTokens)
+ }
+ params.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget)
+}
+
+// levelToBudget maps a thinking level to budget_tokens.
+// Values are based on Anthropic's recommendations and community best practices:
+//
+// low = 4,096 — simple reasoning, quick debugging (Claude Code "think")
+// medium = 16,384 — Anthropic recommended sweet spot for most tasks
+// high = 32,000 — complex architecture, deep analysis (diminishing returns above this)
+// xhigh = 64,000 — extreme reasoning, research problems, benchmarks
+//
+// Note: For Claude 4.6+, prefer adaptive thinking over manual budget_tokens.
+func levelToBudget(level string) int {
+ switch level {
+ case "low":
+ return 4096
+ case "medium":
+ return 16384
+ case "high":
+ return 32000
+ case "xhigh":
+ return 64000
+ default:
+ return 0
+ }
+}
+
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
result := make([]anthropic.ToolUnionParam, 0, len(tools))
for _, t := range tools {
@@ -219,10 +339,14 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
func parseResponse(resp *anthropic.Message) *LLMResponse {
var content strings.Builder
+ var reasoning strings.Builder
var toolCalls []ToolCall
for _, block := range resp.Content {
switch block.Type {
+ case "thinking":
+ tb := block.AsThinking()
+ reasoning.WriteString(tb.Thinking)
case "text":
tb := block.AsText()
content.WriteString(tb.Text)
@@ -253,6 +377,7 @@ func parseResponse(resp *anthropic.Message) *LLMResponse {
return &LLMResponse{
Content: content.String(),
+ Reasoning: reasoning.String(),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: &UsageInfo{
diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go
index f3be40087..2c8a8e6bf 100644
--- a/pkg/providers/anthropic/provider_test.go
+++ b/pkg/providers/anthropic/provider_test.go
@@ -23,8 +23,8 @@ func TestBuildParams_BasicMessage(t *testing.T) {
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
- if string(params.Model) != "claude-sonnet-4.6" {
- t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4.6")
+ if string(params.Model) != "claude-sonnet-4-6" {
+ t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-6")
}
if params.MaxTokens != 1024 {
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
@@ -264,6 +264,65 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
}
}
+func TestProvider_ChatStreamingRoundTrip(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/messages" {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" {
+ t.Errorf("Authorization = %q, want %q", got, "Bearer refreshed-token")
+ }
+ if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader {
+ t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader)
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, _ := w.(http.Flusher)
+
+ events := []string{
+ "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4-6\",\"stop_reason\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n",
+ "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
+ "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n",
+ "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n",
+ "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
+ "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n",
+ "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
+ }
+ for _, e := range events {
+ w.Write([]byte(e))
+ if flusher != nil {
+ flusher.Flush()
+ }
+ }
+ }))
+ defer server.Close()
+
+ p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) {
+ return "refreshed-token", nil
+ }, server.URL)
+
+ resp, err := p.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "Hello"}},
+ nil,
+ "claude-sonnet-4.6",
+ map[string]any{},
+ )
+ if err != nil {
+ t.Fatalf("Chat() error: %v", err)
+ }
+ if resp.Content != "Hello world" {
+ t.Errorf("Content = %q, want %q", resp.Content, "Hello world")
+ }
+ if resp.FinishReason != "stop" {
+ t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
+ }
+ if resp.Usage.CompletionTokens != 5 {
+ t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens)
+ }
+}
+
func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
c := anthropic.NewClient(
anthropicoption.WithAuthToken(token),
diff --git a/pkg/providers/anthropic/thinking_test.go b/pkg/providers/anthropic/thinking_test.go
new file mode 100644
index 000000000..e69a3869e
--- /dev/null
+++ b/pkg/providers/anthropic/thinking_test.go
@@ -0,0 +1,212 @@
+package anthropicprovider
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/anthropics/anthropic-sdk-go"
+)
+
+func TestApplyThinkingConfig_Adaptive(t *testing.T) {
+ params := anthropic.MessageNewParams{
+ MaxTokens: 16000,
+ Temperature: anthropic.Float(0.7),
+ }
+ applyThinkingConfig(¶ms, "adaptive")
+
+ if params.Thinking.OfAdaptive == nil {
+ t.Fatal("expected adaptive thinking")
+ }
+ if params.Thinking.OfEnabled != nil {
+ t.Error("should not set enabled thinking in adaptive mode")
+ }
+ if params.OutputConfig.Effort != anthropic.OutputConfigEffortHigh {
+ t.Errorf("effort = %q, want %q", params.OutputConfig.Effort, anthropic.OutputConfigEffortHigh)
+ }
+ if params.Temperature.Valid() {
+ t.Error("temperature should be cleared when thinking is enabled")
+ }
+}
+
+func TestApplyThinkingConfig_BudgetLevels(t *testing.T) {
+ tests := []struct {
+ level string
+ wantBudget int64
+ }{
+ {"low", 4096},
+ {"medium", 16384},
+ {"high", 32000},
+ {"xhigh", 64000},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.level, func(t *testing.T) {
+ params := anthropic.MessageNewParams{
+ MaxTokens: 200000,
+ Temperature: anthropic.Float(0.5),
+ }
+ applyThinkingConfig(¶ms, tt.level)
+
+ if params.Thinking.OfEnabled == nil {
+ t.Fatal("expected enabled thinking")
+ }
+ if params.Thinking.OfAdaptive != nil {
+ t.Error("should not set adaptive thinking")
+ }
+ if params.Thinking.OfEnabled.BudgetTokens != tt.wantBudget {
+ t.Errorf("budget_tokens = %d, want %d", params.Thinking.OfEnabled.BudgetTokens, tt.wantBudget)
+ }
+ if params.OutputConfig.Effort != "" {
+ t.Errorf("effort = %q, want empty", params.OutputConfig.Effort)
+ }
+ if params.Temperature.Valid() {
+ t.Error("temperature should be cleared when thinking is enabled")
+ }
+ })
+ }
+}
+
+func TestApplyThinkingConfig_BudgetClamp(t *testing.T) {
+ // budget_tokens must be < max_tokens; clamp budget down to respect user's max_tokens.
+ params := anthropic.MessageNewParams{MaxTokens: 4096}
+ applyThinkingConfig(¶ms, "high") // budget=32000 > maxTokens=4096
+
+ if params.Thinking.OfEnabled == nil {
+ t.Fatal("expected enabled thinking")
+ }
+ if params.Thinking.OfEnabled.BudgetTokens != 4095 {
+ t.Errorf("budget_tokens = %d, want 4095 (maxTokens-1)", params.Thinking.OfEnabled.BudgetTokens)
+ }
+ if params.MaxTokens != 4096 {
+ t.Errorf("max_tokens should not be modified, got %d", params.MaxTokens)
+ }
+}
+
+func TestApplyThinkingConfig_UnknownLevel(t *testing.T) {
+ params := anthropic.MessageNewParams{MaxTokens: 16000}
+ applyThinkingConfig(¶ms, "unknown")
+
+ if params.Thinking.OfEnabled != nil {
+ t.Error("should not set enabled thinking for unknown level")
+ }
+ if params.Thinking.OfAdaptive != nil {
+ t.Error("should not set adaptive thinking for unknown level")
+ }
+}
+
+func TestLevelToBudget(t *testing.T) {
+ tests := []struct {
+ name string
+ level string
+ want int
+ }{
+ {"low", "low", 4096},
+ {"medium", "medium", 16384},
+ {"high", "high", 32000},
+ {"xhigh", "xhigh", 64000},
+ {"off", "off", 0},
+ {"empty", "", 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := levelToBudget(tt.level); got != tt.want {
+ t.Errorf("levelToBudget(%q) = %d, want %d", tt.level, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestBuildParams_ThinkingClearsTemperature(t *testing.T) {
+ msgs := []Message{{Role: "user", Content: "hello"}}
+ opts := map[string]any{
+ "max_tokens": 200000,
+ "temperature": 0.8,
+ "thinking_level": "medium",
+ }
+
+ params, err := buildParams(msgs, nil, "claude-sonnet-4-6", opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if params.Temperature.Valid() {
+ t.Error("temperature should be cleared when thinking_level is set")
+ }
+ if params.Thinking.OfEnabled == nil {
+ t.Fatal("expected enabled thinking")
+ }
+ if params.Thinking.OfEnabled.BudgetTokens != 16384 {
+ t.Errorf("budget_tokens = %d, want 16384", params.Thinking.OfEnabled.BudgetTokens)
+ }
+}
+
+// unmarshalBlocks constructs []ContentBlockUnion via JSON round-trip so that
+// the internal JSON.raw field is populated (required by AsText/AsThinking).
+func unmarshalBlocks(t *testing.T, jsonStr string) []anthropic.ContentBlockUnion {
+ t.Helper()
+ var blocks []anthropic.ContentBlockUnion
+ if err := json.Unmarshal([]byte(jsonStr), &blocks); err != nil {
+ t.Fatalf("unmarshalBlocks: %v", err)
+ }
+ return blocks
+}
+
+func TestParseResponse_ThinkingBlock(t *testing.T) {
+ resp := &anthropic.Message{
+ Content: unmarshalBlocks(t, `[
+ {"type":"thinking","thinking":"Let me reason step by step...","signature":"sig"},
+ {"type":"text","text":"The answer is 42."}
+ ]`),
+ StopReason: anthropic.StopReasonEndTurn,
+ }
+
+ result := parseResponse(resp)
+
+ if result.Reasoning != "Let me reason step by step..." {
+ t.Errorf("Reasoning = %q, want thinking content", result.Reasoning)
+ }
+ if result.Content != "The answer is 42." {
+ t.Errorf("Content = %q, want text content", result.Content)
+ }
+ if result.FinishReason != "stop" {
+ t.Errorf("FinishReason = %q, want stop", result.FinishReason)
+ }
+}
+
+func TestParseResponse_NoThinkingBlock(t *testing.T) {
+ resp := &anthropic.Message{
+ Content: unmarshalBlocks(t, `[
+ {"type":"text","text":"Just a normal response."}
+ ]`),
+ StopReason: anthropic.StopReasonEndTurn,
+ }
+
+ result := parseResponse(resp)
+
+ if result.Reasoning != "" {
+ t.Errorf("Reasoning = %q, want empty", result.Reasoning)
+ }
+ if result.Content != "Just a normal response." {
+ t.Errorf("Content = %q, want text content", result.Content)
+ }
+}
+
+func TestBuildParams_NoThinkingKeepsTemperature(t *testing.T) {
+ msgs := []Message{{Role: "user", Content: "hello"}}
+ opts := map[string]any{
+ "temperature": 0.8,
+ }
+
+ params, err := buildParams(msgs, nil, "claude-sonnet-4-6", opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !params.Temperature.Valid() {
+ t.Error("temperature should be preserved when thinking is not set")
+ }
+ if params.Temperature.Value != 0.8 {
+ t.Errorf("temperature = %f, want 0.8", params.Temperature.Value)
+ }
+}
diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go
index 05c6e4763..5e17e0853 100644
--- a/pkg/providers/antigravity_provider.go
+++ b/pkg/providers/antigravity_provider.go
@@ -635,7 +635,10 @@ func FetchAntigravityProjectID(accessToken string) (string, error) {
}
defer resp.Body.Close()
- body, _ := io.ReadAll(resp.Body)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("reading loadCodeAssist response: %w", err)
+ }
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("loadCodeAssist failed: %s", string(body))
}
@@ -676,7 +679,10 @@ func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelIn
}
defer resp.Body.Close()
- body, _ := io.ReadAll(resp.Body)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading fetchAvailableModels response: %w", err)
+ }
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf(
"fetchAvailableModels failed (HTTP %d): %s",
diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go
index d7b30bae5..11c33ab2c 100644
--- a/pkg/providers/claude_cli_provider_test.go
+++ b/pkg/providers/claude_cli_provider_test.go
@@ -660,12 +660,11 @@ func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) {
// --- buildToolsPrompt tests ---
func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) {
- p := NewClaudeCliProvider("/workspace")
tools := []ToolDefinition{
{Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}},
{Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}},
}
- got := p.buildToolsPrompt(tools)
+ got := buildCLIToolsPrompt(tools)
if strings.Contains(got, "skip_me") {
t.Error("buildToolsPrompt() should skip non-function tools")
}
@@ -675,11 +674,10 @@ func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) {
}
func TestBuildToolsPrompt_NoDescription(t *testing.T) {
- p := NewClaudeCliProvider("/workspace")
tools := []ToolDefinition{
{Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}},
}
- got := p.buildToolsPrompt(tools)
+ got := buildCLIToolsPrompt(tools)
if !strings.Contains(got, "bare_tool") {
t.Error("should include tool name")
}
@@ -689,14 +687,13 @@ func TestBuildToolsPrompt_NoDescription(t *testing.T) {
}
func TestBuildToolsPrompt_NoParameters(t *testing.T) {
- p := NewClaudeCliProvider("/workspace")
tools := []ToolDefinition{
{Type: "function", Function: ToolFunctionDefinition{
Name: "no_params_tool",
Description: "A tool with no parameters",
}},
}
- got := p.buildToolsPrompt(tools)
+ got := buildCLIToolsPrompt(tools)
if strings.Contains(got, "Parameters:") {
t.Error("should not include Parameters: section when nil")
}
diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go
index 98ac3e7e8..aaceb891a 100644
--- a/pkg/providers/factory.go
+++ b/pkg/providers/factory.go
@@ -105,6 +105,15 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
sel.apiBase = "https://openrouter.ai/api/v1"
}
}
+ case "litellm":
+ if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" {
+ sel.apiKey = cfg.Providers.LiteLLM.APIKey
+ sel.apiBase = cfg.Providers.LiteLLM.APIBase
+ sel.proxy = cfg.Providers.LiteLLM.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "http://localhost:4000/v1"
+ }
+ }
case "zhipu", "glm":
if cfg.Providers.Zhipu.APIKey != "" {
sel.apiKey = cfg.Providers.Zhipu.APIKey
@@ -147,6 +156,15 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
sel.apiBase = "https://integrate.api.nvidia.com/v1"
}
}
+ case "vivgrid":
+ if cfg.Providers.Vivgrid.APIKey != "" {
+ sel.apiKey = cfg.Providers.Vivgrid.APIKey
+ sel.apiBase = cfg.Providers.Vivgrid.APIBase
+ sel.proxy = cfg.Providers.Vivgrid.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.vivgrid.com/v1"
+ }
+ }
case "claude-cli", "claude-code", "claudecode":
workspace := cfg.WorkspacePath()
if workspace == "" {
@@ -175,6 +193,15 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
sel.model = "deepseek-chat"
}
}
+ case "avian":
+ if cfg.Providers.Avian.APIKey != "" {
+ sel.apiKey = cfg.Providers.Avian.APIKey
+ sel.apiBase = cfg.Providers.Avian.APIBase
+ sel.proxy = cfg.Providers.Avian.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.avian.io/v1"
+ }
+ }
case "mistral":
if cfg.Providers.Mistral.APIKey != "" {
sel.apiKey = cfg.Providers.Mistral.APIKey
@@ -280,6 +307,13 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
if sel.apiBase == "" {
sel.apiBase = "https://integrate.api.nvidia.com/v1"
}
+ case strings.HasPrefix(model, "vivgrid/") && cfg.Providers.Vivgrid.APIKey != "":
+ sel.apiKey = cfg.Providers.Vivgrid.APIKey
+ sel.apiBase = cfg.Providers.Vivgrid.APIBase
+ sel.proxy = cfg.Providers.Vivgrid.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.vivgrid.com/v1"
+ }
case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
sel.apiKey = cfg.Providers.Ollama.APIKey
sel.apiBase = cfg.Providers.Ollama.APIBase
@@ -294,6 +328,13 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
if sel.apiBase == "" {
sel.apiBase = "https://api.mistral.ai/v1"
}
+ case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "":
+ sel.apiKey = cfg.Providers.Avian.APIKey
+ sel.apiBase = cfg.Providers.Avian.APIBase
+ sel.proxy = cfg.Providers.Avian.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.avian.io/v1"
+ }
case cfg.Providers.VLLM.APIBase != "":
sel.apiKey = cfg.Providers.VLLM.APIKey
sel.apiBase = cfg.Providers.VLLM.APIBase
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index 8c6b5b692..da87ba94f 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -55,7 +55,7 @@ func ExtractProtocol(model string) (protocol, modelID string) {
// CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create.
-// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
+// Supported protocols: openai, litellm, anthropic, antigravity, claude-cli, codex-cli, github-copilot
// Returns the provider, the model ID (without protocol prefix), and any error.
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
if cfg == nil {
@@ -110,9 +110,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)),
), modelID, nil
- case "openrouter", "groq", "zhipu", "gemini", "nvidia",
+ case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
- "volcengine", "vllm", "qwen", "mistral":
+ "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@@ -195,6 +195,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://api.openai.com/v1"
case "openrouter":
return "https://openrouter.ai/api/v1"
+ case "litellm":
+ return "http://localhost:4000/v1"
case "groq":
return "https://api.groq.com/openai/v1"
case "zhipu":
@@ -213,6 +215,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://api.deepseek.com/v1"
case "cerebras":
return "https://api.cerebras.ai/v1"
+ case "vivgrid":
+ return "https://api.vivgrid.com/v1"
case "volcengine":
return "https://ark.cn-beijing.volces.com/api/v3"
case "qwen":
@@ -223,6 +227,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://api.minimax.io/v1"
case "mistral":
return "https://api.mistral.ai/v1"
+ case "avian":
+ return "https://api.avian.io/v1"
default:
return ""
}
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index e0c0eddef..17bc55d25 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -108,6 +108,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"groq", "groq"},
{"openrouter", "openrouter"},
{"cerebras", "cerebras"},
+ {"vivgrid", "vivgrid"},
{"qwen", "qwen"},
{"vllm", "vllm"},
{"deepseek", "deepseek"},
@@ -135,6 +136,32 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
}
}
+func TestGetDefaultAPIBase_LiteLLM(t *testing.T) {
+ if got := getDefaultAPIBase("litellm"); got != "http://localhost:4000/v1" {
+ t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "litellm", got, "http://localhost:4000/v1")
+ }
+}
+
+func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-litellm",
+ Model: "litellm/my-proxy-alias",
+ APIKey: "test-key",
+ APIBase: "http://localhost:4000/v1",
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "my-proxy-alias" {
+ t.Errorf("modelID = %q, want %q", modelID, "my-proxy-alias")
+ }
+}
+
func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-anthropic",
diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go
index 8fa0287c4..64cbc211b 100644
--- a/pkg/providers/factory_test.go
+++ b/pkg/providers/factory_test.go
@@ -17,6 +17,27 @@ func TestResolveProviderSelection(t *testing.T) {
wantProxy string
wantErrSubstr string
}{
+ {
+ name: "explicit litellm provider uses configured base",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "litellm"
+ cfg.Providers.LiteLLM.APIKey = "litellm-key"
+ cfg.Providers.LiteLLM.APIBase = "http://localhost:4000/v1"
+ cfg.Providers.LiteLLM.Proxy = "http://127.0.0.1:7890"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "http://localhost:4000/v1",
+ wantProxy: "http://127.0.0.1:7890",
+ },
+ {
+ name: "explicit litellm provider defaults base when only key is configured",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "litellm"
+ cfg.Providers.LiteLLM.APIKey = "litellm-key"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "http://localhost:4000/v1",
+ },
{
name: "explicit claude-cli provider routes to cli provider type",
setup: func(cfg *config.Config) {
@@ -67,6 +88,17 @@ func TestResolveProviderSelection(t *testing.T) {
wantAPIBase: "https://integrate.api.nvidia.com/v1",
wantProxy: "http://127.0.0.1:7890",
},
+ {
+ name: "explicit vivgrid provider uses defaults",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "vivgrid"
+ cfg.Providers.Vivgrid.APIKey = "vivgrid-key"
+ cfg.Providers.Vivgrid.Proxy = "http://127.0.0.1:7890"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://api.vivgrid.com/v1",
+ wantProxy: "http://127.0.0.1:7890",
+ },
{
name: "openrouter model uses openrouter defaults",
setup: func(cfg *config.Config) {
diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go
index e962bafe1..4379c9e94 100644
--- a/pkg/providers/protocoltypes/types.go
+++ b/pkg/providers/protocoltypes/types.go
@@ -136,6 +136,7 @@ type ContentBlock struct {
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
+ Media []string `json:"media,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
diff --git a/pkg/providers/types.go b/pkg/providers/types.go
index ae7efb5e1..aad2dccc2 100644
--- a/pkg/providers/types.go
+++ b/pkg/providers/types.go
@@ -40,6 +40,13 @@ type StatefulProvider interface {
Close()
}
+// ThinkingCapable is an optional interface for providers that support
+// extended thinking (e.g. Anthropic). Used by the agent loop to warn
+// when thinking_level is configured but the active provider cannot use it.
+type ThinkingCapable interface {
+ SupportsThinking() bool
+}
+
// FailoverReason classifies why an LLM request failed for fallback decisions.
type FailoverReason string
diff --git a/pkg/routing/classifier.go b/pkg/routing/classifier.go
new file mode 100644
index 000000000..8cddaf069
--- /dev/null
+++ b/pkg/routing/classifier.go
@@ -0,0 +1,80 @@
+package routing
+
+// Classifier evaluates a feature set and returns a complexity score in [0, 1].
+// A higher score indicates a more complex task that benefits from a heavy model.
+// The score is compared against the configured threshold: score >= threshold selects
+// the primary (heavy) model; score < threshold selects the light model.
+//
+// Classifier is an interface so that future implementations (ML-based, embedding-based,
+// or any other approach) can be swapped in without changing routing infrastructure.
+type Classifier interface {
+ Score(f Features) float64
+}
+
+// RuleClassifier is the v1 implementation.
+// It uses a weighted sum of structural signals with no external dependencies,
+// no API calls, and sub-microsecond latency. The raw sum is capped at 1.0 so
+// that the returned score always falls within the [0, 1] contract.
+//
+// Individual weights (multiple signals can fire simultaneously):
+//
+// token > 200 (≈600 chars): 0.35 — very long prompts are almost always complex
+// token 50-200: 0.15 — medium length; may or may not be complex
+// code block present: 0.40 — coding tasks need the heavy model
+// tool calls > 3 (recent): 0.25 — dense tool usage signals an agentic workflow
+// tool calls 1-3 (recent): 0.10 — some tool activity
+// conversation depth > 10: 0.10 — long sessions carry implicit complexity
+// attachments present: 1.00 — hard gate; multi-modal always needs heavy model
+//
+// Default threshold is 0.35, so:
+// - Pure greetings / trivial Q&A: 0.00 → light ✓
+// - Medium prose message (50–200 tokens): 0.15 → light ✓
+// - Message with code block: 0.40 → heavy ✓
+// - Long message (>200 tokens): 0.35 → heavy ✓
+// - Active tool session + medium message: 0.25 → light (acceptable)
+// - Any message with an image/audio attachment: 1.00 → heavy ✓
+type RuleClassifier struct{}
+
+// Score computes the complexity score for the given feature set.
+// The returned value is in [0, 1]. Attachments short-circuit to 1.0.
+func (c *RuleClassifier) Score(f Features) float64 {
+ // Hard gate: multi-modal inputs always require the heavy model.
+ if f.HasAttachments {
+ return 1.0
+ }
+
+ var score float64
+
+ // Token estimate — primary verbosity signal
+ switch {
+ case f.TokenEstimate > 200:
+ score += 0.35
+ case f.TokenEstimate > 50:
+ score += 0.15
+ }
+
+ // Fenced code blocks — strongest indicator of a coding/technical task
+ if f.CodeBlockCount > 0 {
+ score += 0.40
+ }
+
+ // Recent tool call density — indicates an ongoing agentic workflow
+ switch {
+ case f.RecentToolCalls > 3:
+ score += 0.25
+ case f.RecentToolCalls > 0:
+ score += 0.10
+ }
+
+ // Conversation depth — accumulated context implies compound task
+ if f.ConversationDepth > 10 {
+ score += 0.10
+ }
+
+ // Cap at 1.0 to honor the [0, 1] contract even when multiple signals fire
+ // simultaneously (e.g., long message + code block + tool chain = 1.10 raw).
+ if score > 1.0 {
+ score = 1.0
+ }
+ return score
+}
diff --git a/pkg/routing/features.go b/pkg/routing/features.go
new file mode 100644
index 000000000..c371e21aa
--- /dev/null
+++ b/pkg/routing/features.go
@@ -0,0 +1,127 @@
+package routing
+
+import (
+ "strings"
+ "unicode/utf8"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// lookbackWindow is the number of recent history entries scanned for tool calls.
+// Six entries covers roughly one full tool-use round-trip (user → assistant+tool_call → tool_result → assistant).
+const lookbackWindow = 6
+
+// Features holds the structural signals extracted from a message and its session context.
+// Every dimension is language-agnostic by construction — no keyword or pattern matching
+// against natural-language content. This ensures consistent routing for all locales.
+type Features struct {
+ // TokenEstimate is a proxy for token count.
+ // CJK runes count as 1 token each; non-CJK runes as 0.25 tokens each.
+ // This avoids API calls while giving accurate estimates for all scripts.
+ TokenEstimate int
+
+ // CodeBlockCount is the number of fenced code blocks (``` pairs) in the message.
+ // Coding tasks almost always require the heavy model.
+ CodeBlockCount int
+
+ // RecentToolCalls is the count of tool_call messages in the last lookbackWindow
+ // history entries. A high density indicates an active agentic workflow.
+ RecentToolCalls int
+
+ // ConversationDepth is the total number of messages in the session history.
+ // Deep sessions tend to carry implicit complexity built up over many turns.
+ ConversationDepth int
+
+ // HasAttachments is true when the message appears to contain media (images,
+ // audio, video). Multi-modal inputs require vision-capable heavy models.
+ HasAttachments bool
+}
+
+// ExtractFeatures computes the structural feature vector for a message.
+// It is a pure function with no side effects and zero allocations beyond
+// the returned struct.
+func ExtractFeatures(msg string, history []providers.Message) Features {
+ return Features{
+ TokenEstimate: estimateTokens(msg),
+ CodeBlockCount: countCodeBlocks(msg),
+ RecentToolCalls: countRecentToolCalls(history),
+ ConversationDepth: len(history),
+ HasAttachments: hasAttachments(msg),
+ }
+}
+
+// estimateTokens returns a token count proxy that handles both CJK and Latin text.
+// CJK runes (U+2E80–U+9FFF, U+F900–U+FAFF, U+AC00–U+D7AF) map to roughly one
+// token each, while non-CJK runes average ~0.25 tokens/rune (≈4 chars per token
+// for English). Splitting the count this way avoids the 3x underestimation that a
+// flat rune_count/3 would produce for Chinese, Japanese, and Korean text.
+func estimateTokens(msg string) int {
+ total := utf8.RuneCountInString(msg)
+ if total == 0 {
+ return 0
+ }
+ cjk := 0
+ for _, r := range msg {
+ if r >= 0x2E80 && r <= 0x9FFF || r >= 0xF900 && r <= 0xFAFF || r >= 0xAC00 && r <= 0xD7AF {
+ cjk++
+ }
+ }
+ return cjk + (total-cjk)/4
+}
+
+// countCodeBlocks counts the number of complete fenced code blocks.
+// Each ``` delimiter increments a counter; pairs of delimiters form one block.
+// An unclosed opening fence (odd count) is treated as zero complete blocks
+// since it may just be an inline code span or a typo.
+func countCodeBlocks(msg string) int {
+ n := strings.Count(msg, "```")
+ return n / 2
+}
+
+// countRecentToolCalls counts messages with tool calls in the last lookbackWindow
+// entries of history. It examines the ToolCalls field rather than parsing
+// the content string, so it is robust to any message format.
+func countRecentToolCalls(history []providers.Message) int {
+ start := len(history) - lookbackWindow
+ if start < 0 {
+ start = 0
+ }
+
+ count := 0
+ for _, msg := range history[start:] {
+ if len(msg.ToolCalls) > 0 {
+ count += len(msg.ToolCalls)
+ }
+ }
+ return count
+}
+
+// hasAttachments returns true when the message content contains embedded media.
+// It checks for base64 data URIs (data:image/, data:audio/, data:video/) and
+// common image/audio URL extensions. This is intentionally conservative —
+// false negatives (missing an attachment) just mean the routing falls back to
+// the primary model anyway.
+func hasAttachments(msg string) bool {
+ lower := strings.ToLower(msg)
+
+ // Base64 data URIs embedded directly in the message
+ if strings.Contains(lower, "data:image/") ||
+ strings.Contains(lower, "data:audio/") ||
+ strings.Contains(lower, "data:video/") {
+ return true
+ }
+
+ // Common image/audio extensions in URLs or file references
+ mediaExts := []string{
+ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp",
+ ".mp3", ".wav", ".ogg", ".m4a", ".flac",
+ ".mp4", ".avi", ".mov", ".webm",
+ }
+ for _, ext := range mediaExts {
+ if strings.Contains(lower, ext) {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/routing/router.go b/pkg/routing/router.go
new file mode 100644
index 000000000..b1fa347e9
--- /dev/null
+++ b/pkg/routing/router.go
@@ -0,0 +1,82 @@
+package routing
+
+import (
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// defaultThreshold is used when the config threshold is zero or negative.
+// At 0.35 a message needs at least one strong signal (code block, long text,
+// or an attachment) before the heavy model is chosen.
+const defaultThreshold = 0.35
+
+// RouterConfig holds the validated model routing settings.
+// It mirrors config.RoutingConfig but lives in pkg/routing to keep the
+// dependency graph simple: pkg/agent resolves config → routing, not the reverse.
+type RouterConfig struct {
+ // LightModel is the model_name (from model_list) used for simple tasks.
+ LightModel string
+
+ // Threshold is the complexity score cutoff in [0, 1].
+ // score >= Threshold → primary (heavy) model.
+ // score < Threshold → light model.
+ Threshold float64
+}
+
+// Router selects the appropriate model tier for each incoming message.
+// It is safe for concurrent use from multiple goroutines.
+type Router struct {
+ cfg RouterConfig
+ classifier Classifier
+}
+
+// New creates a Router with the given config and the default RuleClassifier.
+// If cfg.Threshold is zero or negative, defaultThreshold (0.35) is used.
+func New(cfg RouterConfig) *Router {
+ if cfg.Threshold <= 0 {
+ cfg.Threshold = defaultThreshold
+ }
+ return &Router{
+ cfg: cfg,
+ classifier: &RuleClassifier{},
+ }
+}
+
+// newWithClassifier creates a Router with a custom Classifier.
+// Intended for unit tests that need to inject a deterministic scorer.
+func newWithClassifier(cfg RouterConfig, c Classifier) *Router {
+ if cfg.Threshold <= 0 {
+ cfg.Threshold = defaultThreshold
+ }
+ return &Router{cfg: cfg, classifier: c}
+}
+
+// SelectModel returns the model to use for this conversation turn along with
+// the computed complexity score (for logging and debugging).
+//
+// - If score < cfg.Threshold: returns (cfg.LightModel, true, score)
+// - Otherwise: returns (primaryModel, false, score)
+//
+// The caller is responsible for resolving the returned model name into
+// provider candidates (see AgentInstance.LightCandidates).
+func (r *Router) SelectModel(
+ msg string,
+ history []providers.Message,
+ primaryModel string,
+) (model string, usedLight bool, score float64) {
+ features := ExtractFeatures(msg, history)
+ score = r.classifier.Score(features)
+ if score < r.cfg.Threshold {
+ return r.cfg.LightModel, true, score
+ }
+ return primaryModel, false, score
+}
+
+// LightModel returns the configured light model name.
+func (r *Router) LightModel() string {
+ return r.cfg.LightModel
+}
+
+// Threshold returns the complexity threshold in use.
+func (r *Router) Threshold() float64 {
+ return r.cfg.Threshold
+}
diff --git a/pkg/routing/router_test.go b/pkg/routing/router_test.go
new file mode 100644
index 000000000..2824d10ab
--- /dev/null
+++ b/pkg/routing/router_test.go
@@ -0,0 +1,414 @@
+package routing
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// ── ExtractFeatures ──────────────────────────────────────────────────────────
+
+func TestExtractFeatures_EmptyMessage(t *testing.T) {
+ f := ExtractFeatures("", nil)
+ if f.TokenEstimate != 0 {
+ t.Errorf("TokenEstimate: got %d, want 0", f.TokenEstimate)
+ }
+ if f.CodeBlockCount != 0 {
+ t.Errorf("CodeBlockCount: got %d, want 0", f.CodeBlockCount)
+ }
+ if f.RecentToolCalls != 0 {
+ t.Errorf("RecentToolCalls: got %d, want 0", f.RecentToolCalls)
+ }
+ if f.ConversationDepth != 0 {
+ t.Errorf("ConversationDepth: got %d, want 0", f.ConversationDepth)
+ }
+ if f.HasAttachments {
+ t.Error("HasAttachments: got true, want false")
+ }
+}
+
+func TestExtractFeatures_TokenEstimate(t *testing.T) {
+ // 30 ASCII runes: 0 CJK + 30/4 = 7 tokens
+ msg := strings.Repeat("a", 30)
+ f := ExtractFeatures(msg, nil)
+ if f.TokenEstimate != 7 {
+ t.Errorf("TokenEstimate: got %d, want 7", f.TokenEstimate)
+ }
+}
+
+func TestExtractFeatures_TokenEstimate_CJK(t *testing.T) {
+ // 9 CJK runes → 9 tokens (each CJK rune ≈ 1 token).
+ // Using a rune slice literal avoids CJK string literals in source.
+ msg := string([]rune{
+ 0x4F60, 0x597D, 0x4E16, 0x754C,
+ 0x4F60, 0x597D, 0x4E16, 0x754C,
+ 0x4F60,
+ })
+ f := ExtractFeatures(msg, nil)
+ if f.TokenEstimate != 9 {
+ t.Errorf("CJK TokenEstimate: got %d, want 9", f.TokenEstimate)
+ }
+}
+
+func TestExtractFeatures_TokenEstimate_Mixed(t *testing.T) {
+ // Mixed: 4 CJK runes + 8 ASCII runes → 4 + 8/4 = 6 tokens.
+ msg := string([]rune{0x4F60, 0x597D, 0x4E16, 0x754C}) + "hello ok"
+ f := ExtractFeatures(msg, nil)
+ if f.TokenEstimate != 6 {
+ t.Errorf("Mixed TokenEstimate: got %d, want 6", f.TokenEstimate)
+ }
+}
+
+func TestExtractFeatures_CodeBlocks(t *testing.T) {
+ cases := []struct {
+ msg string
+ want int
+ }{
+ {"no code here", 0},
+ {"```go\nfmt.Println()\n```", 1},
+ {"```python\npass\n```\n```js\nconsole.log()\n```", 2},
+ {"```unclosed", 0}, // odd number of fences = 0 complete blocks
+ }
+ for _, tc := range cases {
+ f := ExtractFeatures(tc.msg, nil)
+ if f.CodeBlockCount != tc.want {
+ t.Errorf("msg=%q: CodeBlockCount got %d, want %d", tc.msg, f.CodeBlockCount, tc.want)
+ }
+ }
+}
+
+func TestExtractFeatures_RecentToolCalls(t *testing.T) {
+ // History longer than lookbackWindow — only last lookbackWindow entries count.
+ history := make([]providers.Message, 10)
+ // Put 2 tool calls at positions 8 and 9 (within the last 6)
+ history[8] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}}}
+ history[9] = providers.Message{
+ Role: "assistant",
+ ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}},
+ }
+ // Position 3 is outside the lookback window and must NOT be counted
+ history[3] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "old_tool"}}}
+
+ f := ExtractFeatures("test", history)
+ // 1 (position 8) + 2 (position 9) = 3
+ if f.RecentToolCalls != 3 {
+ t.Errorf("RecentToolCalls: got %d, want 3", f.RecentToolCalls)
+ }
+}
+
+func TestExtractFeatures_ConversationDepth(t *testing.T) {
+ history := make([]providers.Message, 7)
+ f := ExtractFeatures("msg", history)
+ if f.ConversationDepth != 7 {
+ t.Errorf("ConversationDepth: got %d, want 7", f.ConversationDepth)
+ }
+}
+
+func TestExtractFeatures_HasAttachments_DataURI(t *testing.T) {
+ cases := []struct {
+ msg string
+ want bool
+ }{
+ {"plain text", false},
+ {"here is an image: data:image/png;base64,abc123", true},
+ {"audio: data:audio/mp3;base64,xyz", true},
+ {"video: data:video/mp4;base64,xyz", true},
+ }
+ for _, tc := range cases {
+ f := ExtractFeatures(tc.msg, nil)
+ if f.HasAttachments != tc.want {
+ t.Errorf("msg=%q: HasAttachments got %v, want %v", tc.msg, f.HasAttachments, tc.want)
+ }
+ }
+}
+
+func TestExtractFeatures_HasAttachments_Extension(t *testing.T) {
+ cases := []struct {
+ msg string
+ want bool
+ }{
+ {"check out photo.jpg", true},
+ {"see screenshot.png", true},
+ {"listen to audio.mp3", true},
+ {"watch clip.mp4", true},
+ {"just a .go file", false},
+ {"document.pdf", false}, // pdf is not in the media list
+ }
+ for _, tc := range cases {
+ f := ExtractFeatures(tc.msg, nil)
+ if f.HasAttachments != tc.want {
+ t.Errorf("msg=%q: HasAttachments got %v, want %v", tc.msg, f.HasAttachments, tc.want)
+ }
+ }
+}
+
+// ── RuleClassifier ───────────────────────────────────────────────────────────
+
+func TestRuleClassifier_ZeroFeatures(t *testing.T) {
+ c := &RuleClassifier{}
+ score := c.Score(Features{})
+ if score != 0.0 {
+ t.Errorf("zero features: got %f, want 0.0", score)
+ }
+}
+
+func TestRuleClassifier_AttachmentsHardGate(t *testing.T) {
+ c := &RuleClassifier{}
+ score := c.Score(Features{HasAttachments: true})
+ if score != 1.0 {
+ t.Errorf("attachments: got %f, want 1.0", score)
+ }
+}
+
+func TestRuleClassifier_CodeBlockAlone(t *testing.T) {
+ c := &RuleClassifier{}
+ // Code block alone = 0.40, above default threshold 0.35
+ score := c.Score(Features{CodeBlockCount: 1})
+ if score < 0.35 {
+ t.Errorf("code block: score %f is below default threshold 0.35", score)
+ }
+}
+
+func TestRuleClassifier_LongMessage(t *testing.T) {
+ c := &RuleClassifier{}
+ // >200 tokens = 0.35, exactly at default threshold → heavy
+ score := c.Score(Features{TokenEstimate: 250})
+ if score < 0.35 {
+ t.Errorf("long message: score %f is below default threshold 0.35", score)
+ }
+}
+
+func TestRuleClassifier_MediumMessage(t *testing.T) {
+ c := &RuleClassifier{}
+ // 50-200 tokens = 0.15, below threshold → light
+ score := c.Score(Features{TokenEstimate: 100})
+ if score >= 0.35 {
+ t.Errorf("medium message: score %f should be below default threshold 0.35", score)
+ }
+}
+
+func TestRuleClassifier_ShortMessage(t *testing.T) {
+ c := &RuleClassifier{}
+ // <50 tokens, no other signals = 0.0 → light
+ score := c.Score(Features{TokenEstimate: 10})
+ if score != 0.0 {
+ t.Errorf("short message: got %f, want 0.0", score)
+ }
+}
+
+func TestRuleClassifier_ToolCallDensity(t *testing.T) {
+ c := &RuleClassifier{}
+
+ scoreNone := c.Score(Features{RecentToolCalls: 0})
+ scoreLow := c.Score(Features{RecentToolCalls: 2})
+ scoreHigh := c.Score(Features{RecentToolCalls: 5})
+
+ if scoreNone != 0.0 {
+ t.Errorf("no tools: got %f, want 0.0", scoreNone)
+ }
+ if scoreLow <= scoreNone {
+ t.Errorf("low tools should score higher than none: %f vs %f", scoreLow, scoreNone)
+ }
+ if scoreHigh <= scoreLow {
+ t.Errorf("high tools should score higher than low: %f vs %f", scoreHigh, scoreLow)
+ }
+}
+
+func TestRuleClassifier_DeepConversation(t *testing.T) {
+ c := &RuleClassifier{}
+ shallow := c.Score(Features{ConversationDepth: 5})
+ deep := c.Score(Features{ConversationDepth: 15})
+ if deep <= shallow {
+ t.Errorf("deep conversation should score higher: %f vs %f", deep, shallow)
+ }
+}
+
+func TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) {
+ c := &RuleClassifier{}
+ // Max all signals simultaneously
+ f := Features{
+ TokenEstimate: 500,
+ CodeBlockCount: 3,
+ RecentToolCalls: 10,
+ ConversationDepth: 20,
+ }
+ score := c.Score(f)
+ if score > 1.0 {
+ t.Errorf("score %f exceeds 1.0", score)
+ }
+}
+
+// ── Router ───────────────────────────────────────────────────────────────────
+
+func TestRouter_DefaultThreshold(t *testing.T) {
+ r := New(RouterConfig{LightModel: "gemini-flash"})
+ if r.Threshold() != defaultThreshold {
+ t.Errorf("default threshold: got %f, want %f", r.Threshold(), defaultThreshold)
+ }
+}
+
+func TestRouter_NegativeThresholdFallsBackToDefault(t *testing.T) {
+ r := New(RouterConfig{LightModel: "gemini-flash", Threshold: -0.1})
+ if r.Threshold() != defaultThreshold {
+ t.Errorf("negative threshold: got %f, want %f", r.Threshold(), defaultThreshold)
+ }
+}
+
+func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) {
+ r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35})
+ msg := "hi"
+ model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
+ if !usedLight {
+ t.Error("simple message: expected light model to be selected")
+ }
+ if model != "gemini-flash" {
+ t.Errorf("simple message: model got %q, want %q", model, "gemini-flash")
+ }
+}
+
+func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) {
+ r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35})
+ msg := "```go\nfmt.Println(\"hello\")\n```"
+ model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
+ if usedLight {
+ t.Error("code block: expected primary model to be selected")
+ }
+ if model != "claude-sonnet-4-6" {
+ t.Errorf("code block: model got %q, want %q", model, "claude-sonnet-4-6")
+ }
+}
+
+func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) {
+ r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35})
+ msg := "can you analyze this? data:image/png;base64,abc123"
+ model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
+ if usedLight {
+ t.Error("attachment: expected primary model to be selected")
+ }
+ if model != "claude-sonnet-4-6" {
+ t.Errorf("attachment: model got %q, want %q", model, "claude-sonnet-4-6")
+ }
+}
+
+func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) {
+ r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35})
+ // >200 token estimate: 210 * 3 = 630 chars
+ msg := strings.Repeat("word ", 210)
+ model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
+ if usedLight {
+ t.Error("long message: expected primary model to be selected")
+ }
+ if model != "claude-sonnet-4-6" {
+ t.Errorf("long message: model got %q, want %q", model, "claude-sonnet-4-6")
+ }
+}
+
+func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) {
+ // Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior.
+ // Routing is conservative: only promote to heavy when the signal is unambiguous.
+ r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35})
+ history := []providers.Message{
+ {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}},
+ {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}, {Name: "search"}}},
+ }
+ msg := "ok"
+ _, usedLight, _ := r.SelectModel(msg, history, "claude-sonnet-4-6")
+ if !usedLight {
+ t.Error("short message + moderate tool calls: expected light model (score 0.20 < 0.35)")
+ }
+}
+
+func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) {
+ // Tool calls (0.25) + medium message (0.15) = 0.40 >= 0.35 → heavy
+ r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.35})
+ history := []providers.Message{
+ {Role: "assistant", ToolCalls: []providers.ToolCall{
+ {Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"},
+ }},
+ }
+ // ~55 tokens * 3 = 165 chars
+ msg := strings.Repeat("word ", 55)
+ _, usedLight, _ := r.SelectModel(msg, history, "claude-sonnet-4-6")
+ if usedLight {
+ t.Error("tool chain + medium message: expected primary model (score >= 0.35)")
+ }
+}
+
+func TestRouter_SelectModel_CustomThreshold(t *testing.T) {
+ // Very low threshold: even a short message triggers heavy model
+ r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.05})
+ msg := strings.Repeat("word ", 55) // medium message → 0.15 >= 0.05
+ _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
+ if usedLight {
+ t.Error("low threshold: medium message should use primary model")
+ }
+}
+
+func TestRouter_SelectModel_HighThreshold(t *testing.T) {
+ // Very high threshold: even code blocks route to light
+ r := New(RouterConfig{LightModel: "gemini-flash", Threshold: 0.99})
+ msg := "```go\nfmt.Println()\n```"
+ _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
+ if !usedLight {
+ t.Error("very high threshold: code block (0.40) should route to light model")
+ }
+}
+
+func TestRouter_LightModel(t *testing.T) {
+ r := New(RouterConfig{LightModel: "my-fast-model", Threshold: 0.35})
+ if r.LightModel() != "my-fast-model" {
+ t.Errorf("LightModel: got %q, want %q", r.LightModel(), "my-fast-model")
+ }
+}
+
+// ── newWithClassifier (internal testing hook) ─────────────────────────────────
+
+type fixedScoreClassifier struct{ score float64 }
+
+func (f *fixedScoreClassifier) Score(_ Features) float64 { return f.score }
+
+func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) {
+ r := newWithClassifier(
+ RouterConfig{LightModel: "light", Threshold: 0.5},
+ &fixedScoreClassifier{score: 0.2},
+ )
+ _, usedLight, _ := r.SelectModel("anything", nil, "heavy")
+ if !usedLight {
+ t.Error("low score with custom classifier: expected light model")
+ }
+}
+
+func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) {
+ r := newWithClassifier(
+ RouterConfig{LightModel: "light", Threshold: 0.5},
+ &fixedScoreClassifier{score: 0.8},
+ )
+ _, usedLight, _ := r.SelectModel("anything", nil, "heavy")
+ if usedLight {
+ t.Error("high score with custom classifier: expected primary model")
+ }
+}
+
+func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) {
+ // score == threshold → primary (uses >= comparison)
+ r := newWithClassifier(
+ RouterConfig{LightModel: "light", Threshold: 0.5},
+ &fixedScoreClassifier{score: 0.5},
+ )
+ _, usedLight, _ := r.SelectModel("anything", nil, "heavy")
+ if usedLight {
+ t.Error("score == threshold: expected primary model (>= threshold → primary)")
+ }
+}
+
+func TestRouter_SelectModel_ReturnsScore(t *testing.T) {
+ r := newWithClassifier(
+ RouterConfig{LightModel: "light", Threshold: 0.5},
+ &fixedScoreClassifier{score: 0.42},
+ )
+ _, _, score := r.SelectModel("anything", nil, "heavy")
+ if score != 0.42 {
+ t.Errorf("score: got %f, want 0.42", score)
+ }
+}
diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go
index 2103bdc52..76e717e4b 100644
--- a/pkg/skills/clawhub_registry.go
+++ b/pkg/skills/clawhub_registry.go
@@ -260,15 +260,7 @@ func (c *ClawHubRegistry) DownloadAndInstall(
}
u.RawQuery = q.Encode()
- req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- if c.authToken != "" {
- req.Header.Set("Authorization", "Bearer "+c.authToken)
- }
-
- tmpPath, err := utils.DownloadToFile(ctx, c.client, req, int64(c.maxZipSize))
+ tmpPath, err := c.downloadToTempFileWithRetry(ctx, u.String())
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
}
@@ -285,17 +277,12 @@ func (c *ClawHubRegistry) DownloadAndInstall(
// --- HTTP helper ---
func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, error) {
- req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
+ req, err := c.newGetRequest(ctx, urlStr, "application/json")
if err != nil {
return nil, err
}
- req.Header.Set("Accept", "application/json")
- if c.authToken != "" {
- req.Header.Set("Authorization", "Bearer "+c.authToken)
- }
-
- resp, err := c.client.Do(req)
+ resp, err := utils.DoRequestWithRetry(c.client, req)
if err != nil {
return nil, err
}
@@ -313,3 +300,64 @@ func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, err
return body, nil
}
+
+func (c *ClawHubRegistry) newGetRequest(ctx context.Context, urlStr, accept string) (*http.Request, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", accept)
+ if c.authToken != "" {
+ req.Header.Set("Authorization", "Bearer "+c.authToken)
+ }
+ return req, nil
+}
+
+func (c *ClawHubRegistry) downloadToTempFileWithRetry(ctx context.Context, urlStr string) (string, error) {
+ req, err := c.newGetRequest(ctx, urlStr, "application/zip")
+ if err != nil {
+ return "", err
+ }
+
+ resp, err := utils.DoRequestWithRetry(c.client, req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ errBody := make([]byte, 512)
+ n, _ := io.ReadFull(resp.Body, errBody)
+ return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(errBody[:n]))
+ }
+
+ tmpFile, err := os.CreateTemp("", "picoclaw-dl-*")
+ if err != nil {
+ return "", fmt.Errorf("failed to create temp file: %w", err)
+ }
+ tmpPath := tmpFile.Name()
+
+ cleanup := func() {
+ _ = tmpFile.Close()
+ _ = os.Remove(tmpPath)
+ }
+
+ src := io.LimitReader(resp.Body, int64(c.maxZipSize)+1)
+ written, err := io.Copy(tmpFile, src)
+ if err != nil {
+ cleanup()
+ return "", fmt.Errorf("download write failed: %w", err)
+ }
+
+ if written > int64(c.maxZipSize) {
+ cleanup()
+ return "", fmt.Errorf("download too large: %d bytes (max %d)", written, c.maxZipSize)
+ }
+
+ if err := tmpFile.Close(); err != nil {
+ _ = os.Remove(tmpPath)
+ return "", fmt.Errorf("failed to close temp file: %w", err)
+ }
+
+ return tmpPath, nil
+}
diff --git a/pkg/skills/clawhub_registry_test.go b/pkg/skills/clawhub_registry_test.go
index 65ee638da..055da22dc 100644
--- a/pkg/skills/clawhub_registry_test.go
+++ b/pkg/skills/clawhub_registry_test.go
@@ -54,6 +54,39 @@ func TestClawHubRegistrySearch(t *testing.T) {
assert.Equal(t, "clawhub", results[0].RegistryName)
}
+func TestClawHubRegistrySearchRetries429(t *testing.T) {
+ attempts := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ if attempts == 1 {
+ w.Header().Set("Retry-After", "0")
+ w.WriteHeader(http.StatusTooManyRequests)
+ w.Write([]byte("rate limited"))
+ return
+ }
+
+ slug := "github"
+ name := "GitHub Integration"
+ summary := "Interact with GitHub repos"
+ version := "1.0.0"
+
+ json.NewEncoder(w).Encode(clawhubSearchResponse{
+ Results: []clawhubSearchResult{
+ {Score: 0.95, Slug: &slug, DisplayName: &name, Summary: &summary, Version: &version},
+ },
+ })
+ }))
+ defer srv.Close()
+
+ reg := newTestRegistry(srv.URL, "")
+ results, err := reg.Search(context.Background(), "github", 5)
+
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ assert.Equal(t, 2, attempts)
+ assert.Equal(t, "github", results[0].Slug)
+}
+
func TestClawHubRegistryGetSkillMeta(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/skills/github", r.URL.Path)
@@ -137,6 +170,54 @@ func TestClawHubRegistryDownloadAndInstall(t *testing.T) {
assert.Contains(t, string(readmeContent), "# Test Skill")
}
+func TestClawHubRegistryDownloadAndInstallRetries429(t *testing.T) {
+ zipBuf := createTestZip(t, map[string]string{
+ "SKILL.md": "---\nname: retry-skill\ndescription: A test\n---\nHello skill",
+ })
+
+ downloadAttempts := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/retry-skill":
+ json.NewEncoder(w).Encode(clawhubSkillResponse{
+ Slug: "retry-skill",
+ DisplayName: "Retry Skill",
+ Summary: "A retry test skill",
+ LatestVersion: &clawhubVersionInfo{Version: "1.0.0"},
+ })
+ case "/api/v1/download":
+ downloadAttempts++
+ if downloadAttempts == 1 {
+ w.Header().Set("Retry-After", "0")
+ w.WriteHeader(http.StatusTooManyRequests)
+ w.Write([]byte("rate limited"))
+ return
+ }
+ assert.Equal(t, "retry-skill", r.URL.Query().Get("slug"))
+ w.Header().Set("Content-Type", "application/zip")
+ w.Write(zipBuf)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ defer srv.Close()
+
+ tmpDir := t.TempDir()
+ targetDir := filepath.Join(tmpDir, "retry-skill")
+
+ reg := newTestRegistry(srv.URL, "")
+ result, err := reg.DownloadAndInstall(context.Background(), "retry-skill", "", targetDir)
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ assert.Equal(t, "1.0.0", result.Version)
+ assert.Equal(t, 2, downloadAttempts)
+
+ skillContent, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md"))
+ require.NoError(t, err)
+ assert.Contains(t, string(skillContent), "Hello skill")
+}
+
func TestClawHubRegistryAuthToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go
index af69312bc..71c9d80c5 100644
--- a/pkg/skills/loader.go
+++ b/pkg/skills/loader.go
@@ -64,6 +64,29 @@ type SkillsLoader struct {
builtinSkills string // builtin skills
}
+// SkillRoots returns all unique skill root directories used by this loader.
+// The order follows resolution priority: workspace > global > builtin.
+func (sl *SkillsLoader) SkillRoots() []string {
+ roots := []string{sl.workspaceSkills, sl.globalSkills, sl.builtinSkills}
+ seen := make(map[string]struct{}, len(roots))
+ out := make([]string, 0, len(roots))
+
+ for _, root := range roots {
+ trimmed := strings.TrimSpace(root)
+ if trimmed == "" {
+ continue
+ }
+ clean := filepath.Clean(trimmed)
+ if _, ok := seen[clean]; ok {
+ continue
+ }
+ seen[clean] = struct{}{}
+ out = append(out, clean)
+ }
+
+ return out
+}
+
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
return &SkillsLoader{
workspace: workspace,
diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go
index 9428bea62..31619f9c2 100644
--- a/pkg/skills/loader_test.go
+++ b/pkg/skills/loader_test.go
@@ -326,3 +326,19 @@ func TestStripFrontmatter(t *testing.T) {
})
}
}
+
+func TestSkillRootsTrimsWhitespaceAndDedups(t *testing.T) {
+ tmp := t.TempDir()
+ workspace := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+ builtin := filepath.Join(tmp, "builtin")
+
+ sl := NewSkillsLoader(workspace, " "+global+" ", "\t"+builtin+"\n")
+ roots := sl.SkillRoots()
+
+ assert.Equal(t, []string{
+ filepath.Join(workspace, "skills"),
+ global,
+ builtin,
+ }, roots)
+}
diff --git a/pkg/state/state.go b/pkg/state/state.go
index a712f248f..34d1576b6 100644
--- a/pkg/state/state.go
+++ b/pkg/state/state.go
@@ -48,7 +48,9 @@ func NewManager(workspace string) *Manager {
oldStateFile := filepath.Join(workspace, "state.json")
// Create state directory if it doesn't exist
- os.MkdirAll(stateDir, 0o755)
+ if err := os.MkdirAll(stateDir, 0o755); err != nil {
+ log.Fatalf("[FATAL] state: failed to create state directory: %v", err)
+ }
sm := &Manager{
workspace: workspace,
@@ -62,13 +64,17 @@ func NewManager(workspace string) *Manager {
if data, err := os.ReadFile(oldStateFile); err == nil {
if err := json.Unmarshal(data, sm.state); err == nil {
// Migrate to new location
- sm.saveAtomic()
+ if err := sm.saveAtomic(); err != nil {
+ log.Printf("[WARN] state: failed to save state: %v", err)
+ }
log.Printf("[INFO] state: migrated state from %s to %s", oldStateFile, stateFile)
}
}
} else {
// Load from new location
- sm.load()
+ if err := sm.load(); err != nil {
+ log.Printf("[WARN] state: failed to load state: %v", err)
+ }
}
return sm
diff --git a/pkg/tools/base.go b/pkg/tools/base.go
index c939f4aea..04ebe8d7e 100644
--- a/pkg/tools/base.go
+++ b/pkg/tools/base.go
@@ -10,11 +10,38 @@ type Tool interface {
Execute(ctx context.Context, args map[string]any) *ToolResult
}
-// ContextualTool is an optional interface that tools can implement
-// to receive the current message context (channel, chatID)
-type ContextualTool interface {
- Tool
- SetContext(channel, chatID string)
+// --- Request-scoped tool context (channel / chatID) ---
+//
+// Carried via context.Value so that concurrent tool calls each receive
+// their own immutable copy — no mutable state on singleton tool instances.
+//
+// Keys are unexported pointer-typed vars — guaranteed collision-free,
+// and only accessible through the helper functions below.
+
+type toolCtxKey struct{ name string }
+
+var (
+ ctxKeyChannel = &toolCtxKey{"channel"}
+ ctxKeyChatID = &toolCtxKey{"chatID"}
+)
+
+// WithToolContext returns a child context carrying channel and chatID.
+func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
+ ctx = context.WithValue(ctx, ctxKeyChannel, channel)
+ ctx = context.WithValue(ctx, ctxKeyChatID, chatID)
+ return ctx
+}
+
+// ToolChannel extracts the channel from ctx, or "" if unset.
+func ToolChannel(ctx context.Context) string {
+ v, _ := ctx.Value(ctxKeyChannel).(string)
+ return v
+}
+
+// ToolChatID extracts the chatID from ctx, or "" if unset.
+func ToolChatID(ctx context.Context) string {
+ v, _ := ctx.Value(ctxKeyChatID).(string)
+ return v
}
// AsyncCallback is a function type that async tools use to notify completion.
@@ -22,51 +49,36 @@ type ContextualTool interface {
//
// The ctx parameter allows the callback to be canceled if the agent is shutting down.
// The result parameter contains the tool's execution result.
-//
-// Example usage in an async tool:
-//
-// func (t *MyAsyncTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
-// // Start async work in background
-// go func() {
-// result := doAsyncWork()
-// if t.callback != nil {
-// t.callback(ctx, result)
-// }
-// }()
-// return AsyncResult("Async task started")
-// }
type AsyncCallback func(ctx context.Context, result *ToolResult)
-// AsyncTool is an optional interface that tools can implement to support
+// AsyncExecutor is an optional interface that tools can implement to support
// asynchronous execution with completion callbacks.
//
-// Async tools return immediately with an AsyncResult, then notify completion
-// via the callback set by SetCallback.
+// Unlike the old AsyncTool pattern (SetCallback + Execute), AsyncExecutor
+// receives the callback as a parameter of ExecuteAsync. This eliminates the
+// data race where concurrent calls could overwrite each other's callbacks
+// on a shared tool instance.
//
// This is useful for:
-// - Long-running operations that shouldn't block the agent loop
-// - Subagent spawns that complete independently
-// - Background tasks that need to report results later
+// - Long-running operations that shouldn't block the agent loop
+// - Subagent spawns that complete independently
+// - Background tasks that need to report results later
//
// Example:
//
-// type SpawnTool struct {
-// callback AsyncCallback
-// }
-//
-// func (t *SpawnTool) SetCallback(cb AsyncCallback) {
-// t.callback = cb
-// }
-//
-// func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
-// go t.runSubagent(ctx, args)
+// func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
+// go func() {
+// result := t.runSubagent(ctx, args)
+// if cb != nil { cb(ctx, result) }
+// }()
// return AsyncResult("Subagent spawned, will report back")
// }
-type AsyncTool interface {
+type AsyncExecutor interface {
Tool
- // SetCallback registers a callback function to be invoked when the async operation completes.
- // The callback will be called from a goroutine and should handle thread-safety if needed.
- SetCallback(cb AsyncCallback)
+ // ExecuteAsync runs the tool asynchronously. The callback cb will be
+ // invoked (possibly from another goroutine) when the async operation
+ // completes. cb is guaranteed to be non-nil by the caller (registry).
+ ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult
}
// StatusProvider is an optional interface that tools can implement
diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go
new file mode 100644
index 000000000..6e53cf354
--- /dev/null
+++ b/pkg/tools/mcp_tool.go
@@ -0,0 +1,246 @@
+package tools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "hash/fnv"
+ "strings"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// MCPManager defines the interface for MCP manager operations
+// This allows for easier testing with mock implementations
+type MCPManager interface {
+ CallTool(
+ ctx context.Context,
+ serverName, toolName string,
+ arguments map[string]any,
+ ) (*mcp.CallToolResult, error)
+}
+
+// MCPTool wraps an MCP tool to implement the Tool interface
+type MCPTool struct {
+ manager MCPManager
+ serverName string
+ tool *mcp.Tool
+}
+
+// NewMCPTool creates a new MCP tool wrapper
+func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool {
+ return &MCPTool{
+ manager: manager,
+ serverName: serverName,
+ tool: tool,
+ }
+}
+
+// sanitizeIdentifierComponent normalizes a string so it can be safely used
+// as part of a tool/function identifier for downstream providers.
+// It:
+// - lowercases the string
+// - replaces any character not in [a-z0-9_-] with '_'
+// - collapses multiple consecutive '_' into a single '_'
+// - trims leading/trailing '_'
+// - falls back to "unnamed" if the result is empty
+// - truncates overly long components to a reasonable length
+func sanitizeIdentifierComponent(s string) string {
+ const maxLen = 64
+
+ s = strings.ToLower(s)
+ var b strings.Builder
+ b.Grow(len(s))
+
+ prevUnderscore := false
+ for _, r := range s {
+ isAllowed := (r >= 'a' && r <= 'z') ||
+ (r >= '0' && r <= '9') ||
+ r == '_' || r == '-'
+
+ if !isAllowed {
+ // Normalize any disallowed character to '_'
+ if !prevUnderscore {
+ b.WriteRune('_')
+ prevUnderscore = true
+ }
+ continue
+ }
+
+ if r == '_' {
+ if prevUnderscore {
+ continue
+ }
+ prevUnderscore = true
+ } else {
+ prevUnderscore = false
+ }
+
+ b.WriteRune(r)
+ }
+
+ result := strings.Trim(b.String(), "_")
+ if result == "" {
+ result = "unnamed"
+ }
+
+ if len(result) > maxLen {
+ result = result[:maxLen]
+ }
+
+ return result
+}
+
+// Name returns the tool name, prefixed with the server name.
+// The total length is capped at 64 characters (OpenAI-compatible API limit).
+// A short hash of the original (unsanitized) server and tool names is appended
+// whenever sanitization is lossy or the name is truncated, ensuring that two
+// names which differ only in disallowed characters remain distinct after sanitization.
+func (t *MCPTool) Name() string {
+ // Prefix with server name to avoid conflicts, and sanitize components
+ sanitizedServer := sanitizeIdentifierComponent(t.serverName)
+ sanitizedTool := sanitizeIdentifierComponent(t.tool.Name)
+ full := fmt.Sprintf("mcp_%s_%s", sanitizedServer, sanitizedTool)
+
+ // Check if sanitization was lossless (only lowercasing, no char replacement/truncation)
+ lossless := strings.ToLower(t.serverName) == sanitizedServer &&
+ strings.ToLower(t.tool.Name) == sanitizedTool
+
+ const maxTotal = 64
+ if lossless && len(full) <= maxTotal {
+ return full
+ }
+
+ // Sanitization was lossy or name too long: append hash of the ORIGINAL names
+ // (not the sanitized names) so different originals always yield different hashes.
+ h := fnv.New32a()
+ _, _ = h.Write([]byte(t.serverName + "\x00" + t.tool.Name))
+ suffix := fmt.Sprintf("%08x", h.Sum32()) // 8 chars
+
+ base := full
+ if len(base) > maxTotal-9 {
+ base = strings.TrimRight(full[:maxTotal-9], "_")
+ }
+ return base + "_" + suffix
+}
+
+// Description returns the tool description
+func (t *MCPTool) Description() string {
+ desc := t.tool.Description
+ if desc == "" {
+ desc = fmt.Sprintf("MCP tool from %s server", t.serverName)
+ }
+ // Add server info to description
+ return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc)
+}
+
+// Parameters returns the tool parameters schema
+func (t *MCPTool) Parameters() map[string]any {
+ // The InputSchema is already a JSON Schema object
+ schema := t.tool.InputSchema
+
+ // Handle nil schema
+ if schema == nil {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{},
+ "required": []string{},
+ }
+ }
+
+ // Try direct conversion first (fast path)
+ if schemaMap, ok := schema.(map[string]any); ok {
+ return schemaMap
+ }
+
+ // Handle json.RawMessage and []byte - unmarshal directly
+ var jsonData []byte
+ if rawMsg, ok := schema.(json.RawMessage); ok {
+ jsonData = rawMsg
+ } else if bytes, ok := schema.([]byte); ok {
+ jsonData = bytes
+ }
+
+ if jsonData != nil {
+ var result map[string]any
+ if err := json.Unmarshal(jsonData, &result); err == nil {
+ return result
+ }
+ // Fallback on error
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{},
+ "required": []string{},
+ }
+ }
+
+ // For other types (structs, etc.), convert via JSON marshal/unmarshal
+ var err error
+ jsonData, err = json.Marshal(schema)
+ if err != nil {
+ // Fallback to empty schema if marshaling fails
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{},
+ "required": []string{},
+ }
+ }
+
+ var result map[string]any
+ if err := json.Unmarshal(jsonData, &result); err != nil {
+ // Fallback to empty schema if unmarshaling fails
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{},
+ "required": []string{},
+ }
+ }
+
+ return result
+}
+
+// Execute executes the MCP tool
+func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err)
+ }
+
+ if result == nil {
+ nilErr := fmt.Errorf("MCP tool returned nil result without error")
+ return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr)
+ }
+
+ // Handle error result from server
+ if result.IsError {
+ errMsg := extractContentText(result.Content)
+ return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)).
+ WithError(fmt.Errorf("MCP tool error: %s", errMsg))
+ }
+
+ // Extract text content from result
+ output := extractContentText(result.Content)
+
+ return &ToolResult{
+ ForLLM: output,
+ IsError: false,
+ }
+}
+
+// extractContentText extracts text from MCP content array
+func extractContentText(content []mcp.Content) string {
+ var parts []string
+ for _, c := range content {
+ switch v := c.(type) {
+ case *mcp.TextContent:
+ parts = append(parts, v.Text)
+ case *mcp.ImageContent:
+ // For images, just indicate that an image was returned
+ parts = append(parts, fmt.Sprintf("[Image: %s]", v.MIMEType))
+ default:
+ // For other content types, use string representation
+ parts = append(parts, fmt.Sprintf("[Content: %T]", v))
+ }
+ }
+ return strings.Join(parts, "\n")
+}
diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go
new file mode 100644
index 000000000..95bb0f992
--- /dev/null
+++ b/pkg/tools/mcp_tool_test.go
@@ -0,0 +1,492 @@
+package tools
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// MockMCPManager is a mock implementation of MCPManager interface for testing
+type MockMCPManager struct {
+ callToolFunc func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error)
+}
+
+func (m *MockMCPManager) CallTool(
+ ctx context.Context,
+ serverName, toolName string,
+ arguments map[string]any,
+) (*mcp.CallToolResult, error) {
+ if m.callToolFunc != nil {
+ return m.callToolFunc(ctx, serverName, toolName, arguments)
+ }
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: "mock result"},
+ },
+ IsError: false,
+ }, nil
+}
+
+// TestNewMCPTool verifies MCP tool creation
+func TestNewMCPTool(t *testing.T) {
+ manager := &MockMCPManager{}
+ tool := &mcp.Tool{
+ Name: "test_tool",
+ Description: "A test tool",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "input": map[string]any{
+ "type": "string",
+ "description": "Test input",
+ },
+ },
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", tool)
+
+ if mcpTool == nil {
+ t.Fatal("NewMCPTool should not return nil")
+ }
+ // Verify tool properties we can access
+ if mcpTool.Name() != "mcp_test_server_test_tool" {
+ t.Errorf("Expected tool name with prefix, got '%s'", mcpTool.Name())
+ }
+}
+
+// TestMCPTool_Name verifies tool name with server prefix
+func TestMCPTool_Name(t *testing.T) {
+ tests := []struct {
+ name string
+ serverName string
+ toolName string
+ expected string
+ }{
+ {
+ name: "simple name",
+ serverName: "github",
+ toolName: "create_issue",
+ expected: "mcp_github_create_issue",
+ },
+ {
+ name: "filesystem server",
+ serverName: "filesystem",
+ toolName: "read_file",
+ expected: "mcp_filesystem_read_file",
+ },
+ {
+ name: "remote server",
+ serverName: "remote-api",
+ toolName: "fetch_data",
+ expected: "mcp_remote-api_fetch_data",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ manager := &MockMCPManager{}
+ tool := &mcp.Tool{Name: tt.toolName}
+ mcpTool := NewMCPTool(manager, tt.serverName, tool)
+
+ result := mcpTool.Name()
+ if result != tt.expected {
+ t.Errorf("Expected name '%s', got '%s'", tt.expected, result)
+ }
+ })
+ }
+}
+
+// TestMCPTool_Description verifies tool description generation
+func TestMCPTool_Description(t *testing.T) {
+ tests := []struct {
+ name string
+ serverName string
+ toolDescription string
+ expectContains []string
+ }{
+ {
+ name: "with description",
+ serverName: "github",
+ toolDescription: "Create a GitHub issue",
+ expectContains: []string{"[MCP:github]", "Create a GitHub issue"},
+ },
+ {
+ name: "empty description",
+ serverName: "filesystem",
+ toolDescription: "",
+ expectContains: []string{"[MCP:filesystem]", "MCP tool from filesystem server"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ manager := &MockMCPManager{}
+ tool := &mcp.Tool{
+ Name: "test_tool",
+ Description: tt.toolDescription,
+ }
+ mcpTool := NewMCPTool(manager, tt.serverName, tool)
+
+ result := mcpTool.Description()
+
+ for _, expected := range tt.expectContains {
+ if !strings.Contains(result, expected) {
+ t.Errorf("Description should contain '%s', got: %s", expected, result)
+ }
+ }
+ })
+ }
+}
+
+// TestMCPTool_Parameters verifies parameter schema conversion
+func TestMCPTool_Parameters(t *testing.T) {
+ tests := []struct {
+ name string
+ inputSchema any
+ expectType string
+ checkProperty string
+ expectProperty bool
+ }{
+ {
+ name: "map schema",
+ inputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "query": map[string]any{
+ "type": "string",
+ "description": "Search query",
+ },
+ },
+ "required": []string{"query"},
+ },
+ expectType: "object",
+ checkProperty: "query",
+ expectProperty: true,
+ },
+ {
+ name: "nil schema",
+ inputSchema: nil,
+ expectType: "object",
+ expectProperty: false,
+ },
+ {
+ name: "json.RawMessage schema",
+ inputSchema: []byte(`{
+ "type": "object",
+ "properties": {
+ "repo": {
+ "type": "string",
+ "description": "Repository name"
+ },
+ "stars": {
+ "type": "integer",
+ "description": "Minimum stars"
+ }
+ },
+ "required": ["repo"]
+ }`),
+ expectType: "object",
+ checkProperty: "repo",
+ expectProperty: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ manager := &MockMCPManager{}
+ tool := &mcp.Tool{
+ Name: "test_tool",
+ InputSchema: tt.inputSchema,
+ }
+ mcpTool := NewMCPTool(manager, "test_server", tool)
+
+ params := mcpTool.Parameters()
+
+ if params == nil {
+ t.Fatal("Parameters should not be nil")
+ }
+
+ if params["type"] != tt.expectType {
+ t.Errorf("Expected type '%s', got '%v'", tt.expectType, params["type"])
+ }
+
+ // Check if property exists when expected
+ if tt.checkProperty != "" {
+ properties, ok := params["properties"].(map[string]any)
+ if !ok && tt.expectProperty {
+ t.Errorf("Expected properties to be a map")
+ return
+ }
+ if ok {
+ _, hasProperty := properties[tt.checkProperty]
+ if hasProperty != tt.expectProperty {
+ t.Errorf("Expected property '%s' existence: %v, got: %v",
+ tt.checkProperty, tt.expectProperty, hasProperty)
+ }
+ }
+ }
+ })
+ }
+}
+
+// TestMCPTool_Execute_Success tests successful tool execution
+func TestMCPTool_Execute_Success(t *testing.T) {
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ // Verify correct parameters passed
+ if serverName != "github" {
+ t.Errorf("Expected serverName 'github', got '%s'", serverName)
+ }
+ if toolName != "search_repos" {
+ t.Errorf("Expected toolName 'search_repos', got '%s'", toolName)
+ }
+
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: "Found 3 repositories"},
+ },
+ IsError: false,
+ }, nil
+ },
+ }
+
+ tool := &mcp.Tool{
+ Name: "search_repos",
+ Description: "Search GitHub repositories",
+ }
+ mcpTool := NewMCPTool(manager, "github", tool)
+
+ ctx := context.Background()
+ args := map[string]any{
+ "query": "golang mcp",
+ }
+
+ result := mcpTool.Execute(ctx, args)
+
+ if result == nil {
+ t.Fatal("Result should not be nil")
+ }
+ if result.IsError {
+ t.Errorf("Expected no error, got error: %s", result.ForLLM)
+ }
+ if result.ForLLM != "Found 3 repositories" {
+ t.Errorf("Expected 'Found 3 repositories', got '%s'", result.ForLLM)
+ }
+}
+
+// TestMCPTool_Execute_ManagerError tests execution when manager returns error
+func TestMCPTool_Execute_ManagerError(t *testing.T) {
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return nil, fmt.Errorf("connection failed")
+ },
+ }
+
+ tool := &mcp.Tool{Name: "test_tool"}
+ mcpTool := NewMCPTool(manager, "test_server", tool)
+
+ ctx := context.Background()
+ result := mcpTool.Execute(ctx, map[string]any{})
+
+ if result == nil {
+ t.Fatal("Result should not be nil")
+ }
+ if !result.IsError {
+ t.Error("Expected IsError to be true")
+ }
+ if !strings.Contains(result.ForLLM, "MCP tool execution failed") {
+ t.Errorf("Error message should mention execution failure, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "connection failed") {
+ t.Errorf("Error message should include original error, got: %s", result.ForLLM)
+ }
+}
+
+// TestMCPTool_Execute_ServerError tests execution when server returns error
+func TestMCPTool_Execute_ServerError(t *testing.T) {
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: "Invalid API key"},
+ },
+ IsError: true,
+ }, nil
+ },
+ }
+
+ tool := &mcp.Tool{Name: "test_tool"}
+ mcpTool := NewMCPTool(manager, "test_server", tool)
+
+ ctx := context.Background()
+ result := mcpTool.Execute(ctx, map[string]any{})
+
+ if result == nil {
+ t.Fatal("Result should not be nil")
+ }
+ if !result.IsError {
+ t.Error("Expected IsError to be true")
+ }
+ if !strings.Contains(result.ForLLM, "MCP tool returned error") {
+ t.Errorf("Error message should mention server error, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "Invalid API key") {
+ t.Errorf("Error message should include server message, got: %s", result.ForLLM)
+ }
+}
+
+// TestMCPTool_Execute_MultipleContent tests execution with multiple content items
+func TestMCPTool_Execute_MultipleContent(t *testing.T) {
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: "First line"},
+ &mcp.TextContent{Text: "Second line"},
+ &mcp.TextContent{Text: "Third line"},
+ },
+ IsError: false,
+ }, nil
+ },
+ }
+
+ tool := &mcp.Tool{Name: "multi_output"}
+ mcpTool := NewMCPTool(manager, "test_server", tool)
+
+ ctx := context.Background()
+ result := mcpTool.Execute(ctx, map[string]any{})
+
+ if result.IsError {
+ t.Errorf("Expected no error, got: %s", result.ForLLM)
+ }
+
+ expected := "First line\nSecond line\nThird line"
+ if result.ForLLM != expected {
+ t.Errorf("Expected '%s', got '%s'", expected, result.ForLLM)
+ }
+}
+
+// TestExtractContentText_TextContent tests text content extraction
+func TestExtractContentText_TextContent(t *testing.T) {
+ content := []mcp.Content{
+ &mcp.TextContent{Text: "Hello World"},
+ &mcp.TextContent{Text: "Second message"},
+ }
+
+ result := extractContentText(content)
+ expected := "Hello World\nSecond message"
+
+ if result != expected {
+ t.Errorf("Expected '%s', got '%s'", expected, result)
+ }
+}
+
+// TestExtractContentText_ImageContent tests image content extraction
+func TestExtractContentText_ImageContent(t *testing.T) {
+ content := []mcp.Content{
+ &mcp.ImageContent{
+ Data: []byte("base64data"),
+ MIMEType: "image/png",
+ },
+ }
+
+ result := extractContentText(content)
+
+ if !strings.Contains(result, "[Image:") {
+ t.Errorf("Expected image indicator, got: %s", result)
+ }
+ if !strings.Contains(result, "image/png") {
+ t.Errorf("Expected MIME type in output, got: %s", result)
+ }
+}
+
+// TestExtractContentText_MixedContent tests mixed content types
+func TestExtractContentText_MixedContent(t *testing.T) {
+ content := []mcp.Content{
+ &mcp.TextContent{Text: "Description"},
+ &mcp.ImageContent{
+ Data: []byte("data"),
+ MIMEType: "image/jpeg",
+ },
+ &mcp.TextContent{Text: "More text"},
+ }
+
+ result := extractContentText(content)
+
+ if !strings.Contains(result, "Description") {
+ t.Errorf("Should contain text content, got: %s", result)
+ }
+ if !strings.Contains(result, "[Image:") {
+ t.Errorf("Should contain image indicator, got: %s", result)
+ }
+ if !strings.Contains(result, "More text") {
+ t.Errorf("Should contain second text, got: %s", result)
+ }
+}
+
+// TestExtractContentText_EmptyContent tests empty content array
+func TestExtractContentText_EmptyContent(t *testing.T) {
+ content := []mcp.Content{}
+
+ result := extractContentText(content)
+
+ if result != "" {
+ t.Errorf("Expected empty string for empty content, got: %s", result)
+ }
+}
+
+// TestMCPTool_InterfaceCompliance verifies MCPTool implements Tool interface
+func TestMCPTool_InterfaceCompliance(t *testing.T) {
+ manager := &MockMCPManager{}
+ tool := &mcp.Tool{Name: "test"}
+ mcpTool := NewMCPTool(manager, "test_server", tool)
+
+ // Verify it implements Tool interface
+ var _ Tool = mcpTool
+}
+
+// TestMCPTool_Parameters_MapSchema tests schema that's already a map
+func TestMCPTool_Parameters_MapSchema(t *testing.T) {
+ manager := &MockMCPManager{}
+ schema := map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "name": map[string]any{
+ "type": "string",
+ "description": "The name parameter",
+ },
+ },
+ "required": []string{"name"},
+ }
+
+ tool := &mcp.Tool{
+ Name: "test_tool",
+ InputSchema: schema,
+ }
+ mcpTool := NewMCPTool(manager, "test_server", tool)
+
+ params := mcpTool.Parameters()
+
+ // Should return the schema as-is when it's already a map
+ if params["type"] != "object" {
+ t.Errorf("Expected type 'object', got '%v'", params["type"])
+ }
+
+ props, ok := params["properties"].(map[string]any)
+ if !ok {
+ t.Error("Properties should be a map")
+ }
+
+ nameParam, ok := props["name"].(map[string]any)
+ if !ok {
+ t.Error("Name parameter should exist")
+ }
+
+ if nameParam["type"] != "string" {
+ t.Errorf("Name type should be 'string', got '%v'", nameParam["type"])
+ }
+}
diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go
new file mode 100644
index 000000000..1a03e58ed
--- /dev/null
+++ b/pkg/tools/send_file.go
@@ -0,0 +1,150 @@
+package tools
+
+import (
+ "context"
+ "fmt"
+ "mime"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/h2non/filetype"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+// SendFileTool allows the LLM to send a local file (image, document, etc.)
+// to the user on the current chat channel via the MediaStore pipeline.
+type SendFileTool struct {
+ workspace string
+ restrict bool
+ maxFileSize int
+ mediaStore media.MediaStore
+
+ defaultChannel string
+ defaultChatID string
+}
+
+func NewSendFileTool(workspace string, restrict bool, maxFileSize int, store media.MediaStore) *SendFileTool {
+ if maxFileSize <= 0 {
+ maxFileSize = config.DefaultMaxMediaSize
+ }
+ return &SendFileTool{
+ workspace: workspace,
+ restrict: restrict,
+ maxFileSize: maxFileSize,
+ mediaStore: store,
+ }
+}
+
+func (t *SendFileTool) Name() string { return "send_file" }
+func (t *SendFileTool) Description() string {
+ return "Send a local file (image, document, etc.) to the user on the current chat channel."
+}
+
+func (t *SendFileTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "path": map[string]any{
+ "type": "string",
+ "description": "Path to the local file. Relative paths are resolved from workspace.",
+ },
+ "filename": map[string]any{
+ "type": "string",
+ "description": "Optional display filename. Defaults to the basename of path.",
+ },
+ },
+ "required": []string{"path"},
+ }
+}
+
+func (t *SendFileTool) SetContext(channel, chatID string) {
+ t.defaultChannel = channel
+ t.defaultChatID = chatID
+}
+
+func (t *SendFileTool) SetMediaStore(store media.MediaStore) {
+ t.mediaStore = store
+}
+
+func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ path, _ := args["path"].(string)
+ if strings.TrimSpace(path) == "" {
+ return ErrorResult("path is required")
+ }
+
+ // Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values.
+ channel := ToolChannel(ctx)
+ if channel == "" {
+ channel = t.defaultChannel
+ }
+ chatID := ToolChatID(ctx)
+ if chatID == "" {
+ chatID = t.defaultChatID
+ }
+ if channel == "" || chatID == "" {
+ return ErrorResult("no target channel/chat available")
+ }
+
+ if t.mediaStore == nil {
+ return ErrorResult("media store not configured")
+ }
+
+ resolved, err := validatePath(path, t.workspace, t.restrict)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("invalid path: %v", err))
+ }
+
+ info, err := os.Stat(resolved)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("file not found: %v", err))
+ }
+ if info.IsDir() {
+ return ErrorResult("path is a directory, expected a file")
+ }
+ if info.Size() > int64(t.maxFileSize) {
+ return ErrorResult(fmt.Sprintf(
+ "file too large: %d bytes (max %d bytes)",
+ info.Size(), t.maxFileSize,
+ ))
+ }
+
+ filename, _ := args["filename"].(string)
+ if filename == "" {
+ filename = filepath.Base(resolved)
+ }
+
+ mediaType := detectMediaType(resolved)
+ scope := fmt.Sprintf("tool:send_file:%s:%s", channel, chatID)
+
+ ref, err := t.mediaStore.Store(resolved, media.MediaMeta{
+ Filename: filename,
+ ContentType: mediaType,
+ Source: "tool:send_file",
+ }, scope)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to register media: %v", err))
+ }
+
+ return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref})
+}
+
+// detectMediaType determines the MIME type of a file.
+// Uses magic-bytes detection (h2non/filetype) first, then falls back to
+// extension-based lookup via mime.TypeByExtension.
+func detectMediaType(path string) string {
+ kind, err := filetype.MatchFile(path)
+ if err == nil && kind != filetype.Unknown {
+ return kind.MIME.Value
+ }
+
+ if ext := filepath.Ext(path); ext != "" {
+ if t := mime.TypeByExtension(ext); t != "" {
+ return t
+ }
+ }
+
+ return "application/octet-stream"
+}
diff --git a/pkg/tools/send_file_test.go b/pkg/tools/send_file_test.go
new file mode 100644
index 000000000..08d129674
--- /dev/null
+++ b/pkg/tools/send_file_test.go
@@ -0,0 +1,176 @@
+package tools
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+func TestSendFileTool_MissingPath(t *testing.T) {
+ store := media.NewFileMediaStore()
+ tool := NewSendFileTool("/tmp", false, 0, store)
+ tool.SetContext("feishu", "chat123")
+
+ result := tool.Execute(context.Background(), map[string]any{})
+ if !result.IsError {
+ t.Fatal("expected error for missing path")
+ }
+}
+
+func TestSendFileTool_NoContext(t *testing.T) {
+ store := media.NewFileMediaStore()
+ tool := NewSendFileTool("/tmp", false, 0, store)
+ // no SetContext call
+
+ result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"})
+ if !result.IsError {
+ t.Fatal("expected error when no channel context")
+ }
+}
+
+func TestSendFileTool_NoMediaStore(t *testing.T) {
+ tool := NewSendFileTool("/tmp", false, 0, nil)
+ tool.SetContext("feishu", "chat123")
+
+ result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"})
+ if !result.IsError {
+ t.Fatal("expected error when no media store")
+ }
+}
+
+func TestSendFileTool_Directory(t *testing.T) {
+ store := media.NewFileMediaStore()
+ tool := NewSendFileTool("/tmp", false, 0, store)
+ tool.SetContext("feishu", "chat123")
+
+ result := tool.Execute(context.Background(), map[string]any{"path": "/tmp"})
+ if !result.IsError {
+ t.Fatal("expected error for directory path")
+ }
+}
+
+func TestSendFileTool_FileTooLarge(t *testing.T) {
+ dir := t.TempDir()
+ testFile := filepath.Join(dir, "big.bin")
+ // Create a file larger than the limit
+ if err := os.WriteFile(testFile, make([]byte, 1024), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ store := media.NewFileMediaStore()
+ tool := NewSendFileTool(dir, false, 512, store) // 512 byte limit
+ tool.SetContext("feishu", "chat123")
+
+ result := tool.Execute(context.Background(), map[string]any{"path": testFile})
+ if !result.IsError {
+ t.Fatal("expected error for oversized file")
+ }
+ if !strings.Contains(result.ForLLM, "too large") {
+ t.Errorf("expected 'too large' in error, got %q", result.ForLLM)
+ }
+}
+
+func TestSendFileTool_DefaultMaxSize(t *testing.T) {
+ tool := NewSendFileTool("/tmp", false, 0, nil)
+ if tool.maxFileSize != config.DefaultMaxMediaSize {
+ t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize)
+ }
+}
+
+func TestSendFileTool_Success(t *testing.T) {
+ dir := t.TempDir()
+ testFile := filepath.Join(dir, "photo.png")
+ if err := os.WriteFile(testFile, []byte("fake png"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ store := media.NewFileMediaStore()
+ tool := NewSendFileTool(dir, false, 0, store)
+ tool.SetContext("feishu", "chat123")
+
+ result := tool.Execute(context.Background(), map[string]any{"path": testFile})
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", result.ForLLM)
+ }
+ if len(result.Media) != 1 {
+ t.Fatalf("expected 1 media ref, got %d", len(result.Media))
+ }
+ if result.Media[0][:8] != "media://" {
+ t.Errorf("expected media:// ref, got %q", result.Media[0])
+ }
+}
+
+func TestSendFileTool_CustomFilename(t *testing.T) {
+ dir := t.TempDir()
+ testFile := filepath.Join(dir, "img.jpg")
+ if err := os.WriteFile(testFile, []byte("fake jpg"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ store := media.NewFileMediaStore()
+ tool := NewSendFileTool(dir, false, 0, store)
+ tool.SetContext("telegram", "chat456")
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "filename": "my-photo.jpg",
+ })
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", result.ForLLM)
+ }
+ if len(result.Media) != 1 {
+ t.Fatalf("expected 1 media ref, got %d", len(result.Media))
+ }
+}
+
+func TestDetectMediaType_MagicBytes(t *testing.T) {
+ dir := t.TempDir()
+
+ // Minimal valid PNG header
+ pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
+ pngFile := filepath.Join(dir, "image.dat") // wrong extension, but valid PNG bytes
+ if err := os.WriteFile(pngFile, pngHeader, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got := detectMediaType(pngFile)
+ if got != "image/png" {
+ t.Errorf("expected image/png from magic bytes, got %q", got)
+ }
+}
+
+func TestDetectMediaType_FallbackToExtension(t *testing.T) {
+ dir := t.TempDir()
+
+ // File with unrecognizable content but known extension
+ txtFile := filepath.Join(dir, "readme.txt")
+ if err := os.WriteFile(txtFile, []byte("hello world"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got := detectMediaType(txtFile)
+ // text/plain or similar — just verify it's not application/octet-stream
+ if got == "application/octet-stream" {
+ t.Errorf("expected extension-based MIME for .txt, got %q", got)
+ }
+}
+
+func TestDetectMediaType_UnknownFallsToOctetStream(t *testing.T) {
+ dir := t.TempDir()
+
+ // File with no extension and random bytes
+ unknownFile := filepath.Join(dir, "mystery")
+ if err := os.WriteFile(unknownFile, []byte{0x00, 0x01, 0x02}, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got := detectMediaType(unknownFile)
+ if got != "application/octet-stream" {
+ t.Errorf("expected application/octet-stream, got %q", got)
+ }
+}
diff --git a/pkg/utils/media.go b/pkg/utils/media.go
index 8d08e5805..31d888f79 100644
--- a/pkg/utils/media.go
+++ b/pkg/utils/media.go
@@ -3,6 +3,7 @@ package utils
import (
"io"
"net/http"
+ "net/url"
"os"
"path/filepath"
"strings"
@@ -54,11 +55,12 @@ type DownloadOptions struct {
Timeout time.Duration
ExtraHeaders map[string]string
LoggerPrefix string
+ ProxyURL string
}
// DownloadFile downloads a file from URL to a local temp directory.
// Returns the local file path or empty string on error.
-func DownloadFile(url, filename string, opts DownloadOptions) string {
+func DownloadFile(urlStr, filename string, opts DownloadOptions) string {
// Set defaults
if opts.Timeout == 0 {
opts.Timeout = 60 * time.Second
@@ -80,7 +82,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string {
localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_"+safeName)
// Create HTTP request
- req, err := http.NewRequest("GET", url, nil)
+ req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]any{
"error": err.Error(),
@@ -94,11 +96,24 @@ func DownloadFile(url, filename string, opts DownloadOptions) string {
}
client := &http.Client{Timeout: opts.Timeout}
+ if opts.ProxyURL != "" {
+ proxyURL, parseErr := url.Parse(opts.ProxyURL)
+ if parseErr != nil {
+ logger.ErrorCF(opts.LoggerPrefix, "Invalid proxy URL for download", map[string]any{
+ "error": parseErr.Error(),
+ "proxy": opts.ProxyURL,
+ })
+ return ""
+ }
+ client.Transport = &http.Transport{
+ Proxy: http.ProxyURL(proxyURL),
+ }
+ }
resp, err := client.Do(req)
if err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]any{
"error": err.Error(),
- "url": url,
+ "url": urlStr,
})
return ""
}
@@ -107,7 +122,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string {
if resp.StatusCode != http.StatusOK {
logger.ErrorCF(opts.LoggerPrefix, "File download returned non-200 status", map[string]any{
"status": resp.StatusCode,
- "url": url,
+ "url": urlStr,
})
return ""
}
diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go
index f973e77fe..e949d7a22 100644
--- a/pkg/voice/transcriber.go
+++ b/pkg/voice/transcriber.go
@@ -10,12 +10,19 @@ import (
"net/http"
"os"
"path/filepath"
+ "strings"
"time"
+ "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
+type Transcriber interface {
+ Name() string
+ Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
+}
+
type GroqTranscriber struct {
apiKey string
apiBase string
@@ -152,8 +159,22 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
return &result, nil
}
-func (t *GroqTranscriber) IsAvailable() bool {
- available := t.apiKey != ""
- logger.DebugCF("voice", "Checking transcriber availability", map[string]any{"available": available})
- return available
+func (t *GroqTranscriber) Name() string {
+ return "groq"
+}
+
+// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or
+// nil if no supported transcription provider is configured.
+func DetectTranscriber(cfg *config.Config) Transcriber {
+ // Direct Groq provider config takes priority.
+ if key := cfg.Providers.Groq.APIKey; key != "" {
+ return NewGroqTranscriber(key)
+ }
+ // Fall back to any model-list entry that uses the groq/ protocol.
+ for _, mc := range cfg.ModelList {
+ if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
+ return NewGroqTranscriber(mc.APIKey)
+ }
+ }
+ return nil
}
diff --git a/pkg/voice/transcriber_test.go b/pkg/voice/transcriber_test.go
new file mode 100644
index 000000000..9b6add333
--- /dev/null
+++ b/pkg/voice/transcriber_test.go
@@ -0,0 +1,160 @@
+package voice
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// Ensure GroqTranscriber satisfies the Transcriber interface at compile time.
+var _ Transcriber = (*GroqTranscriber)(nil)
+
+func TestGroqTranscriberName(t *testing.T) {
+ tr := NewGroqTranscriber("sk-test")
+ if got := tr.Name(); got != "groq" {
+ t.Errorf("Name() = %q, want %q", got, "groq")
+ }
+}
+
+func TestDetectTranscriber(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg *config.Config
+ wantNil bool
+ wantName string
+ }{
+ {
+ name: "no config",
+ cfg: &config.Config{},
+ wantNil: true,
+ },
+ {
+ name: "groq provider key",
+ cfg: &config.Config{
+ Providers: config.ProvidersConfig{
+ Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
+ },
+ },
+ wantName: "groq",
+ },
+ {
+ name: "groq via model list",
+ cfg: &config.Config{
+ ModelList: []config.ModelConfig{
+ {Model: "openai/gpt-4o", APIKey: "sk-openai"},
+ {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
+ },
+ },
+ wantName: "groq",
+ },
+ {
+ name: "groq model list entry without key is skipped",
+ cfg: &config.Config{
+ ModelList: []config.ModelConfig{
+ {Model: "groq/llama-3.3-70b", APIKey: ""},
+ },
+ },
+ wantNil: true,
+ },
+ {
+ name: "provider key takes priority over model list",
+ cfg: &config.Config{
+ Providers: config.ProvidersConfig{
+ Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
+ },
+ ModelList: []config.ModelConfig{
+ {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
+ },
+ },
+ wantName: "groq",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ tr := DetectTranscriber(tc.cfg)
+ if tc.wantNil {
+ if tr != nil {
+ t.Errorf("DetectTranscriber() = %v, want nil", tr)
+ }
+ return
+ }
+ if tr == nil {
+ t.Fatal("DetectTranscriber() = nil, want non-nil")
+ }
+ if got := tr.Name(); got != tc.wantName {
+ t.Errorf("Name() = %q, want %q", got, tc.wantName)
+ }
+ })
+ }
+}
+
+func TestTranscribe(t *testing.T) {
+ // Write a minimal fake audio file so the transcriber can open and send it.
+ tmpDir := t.TempDir()
+ audioPath := filepath.Join(tmpDir, "clip.ogg")
+ if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
+ t.Fatalf("failed to write fake audio file: %v", err)
+ }
+
+ t.Run("success", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/audio/transcriptions" {
+ t.Errorf("unexpected path: %s", r.URL.Path)
+ }
+ if r.Header.Get("Authorization") != "Bearer sk-test" {
+ t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization"))
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(TranscriptionResponse{
+ Text: "hello world",
+ Language: "en",
+ Duration: 1.5,
+ })
+ }))
+ defer srv.Close()
+
+ tr := NewGroqTranscriber("sk-test")
+ tr.apiBase = srv.URL
+
+ resp, err := tr.Transcribe(context.Background(), audioPath)
+ if err != nil {
+ t.Fatalf("Transcribe() error: %v", err)
+ }
+ if resp.Text != "hello world" {
+ t.Errorf("Text = %q, want %q", resp.Text, "hello world")
+ }
+ if resp.Language != "en" {
+ t.Errorf("Language = %q, want %q", resp.Language, "en")
+ }
+ })
+
+ t.Run("api error", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
+ }))
+ defer srv.Close()
+
+ tr := NewGroqTranscriber("sk-bad")
+ tr.apiBase = srv.URL
+
+ _, err := tr.Transcribe(context.Background(), audioPath)
+ if err == nil {
+ t.Fatal("expected error for non-200 response, got nil")
+ }
+ })
+
+ t.Run("missing file", func(t *testing.T) {
+ tr := NewGroqTranscriber("sk-test")
+ _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
+ if err == nil {
+ t.Fatal("expected error for missing file, got nil")
+ }
+ })
+}
diff --git a/scripts/test-docker-mcp.sh b/scripts/test-docker-mcp.sh
new file mode 100755
index 000000000..9d582ffa0
--- /dev/null
+++ b/scripts/test-docker-mcp.sh
@@ -0,0 +1,49 @@
+#!/bin/sh
+# Test script for MCP tools in Docker (full-featured image)
+
+set -e
+
+COMPOSE_FILE="docker/docker-compose.full.yml"
+SERVICE="picoclaw-agent"
+
+echo "🧪 Testing MCP tools in Docker container (full-featured image)..."
+echo ""
+
+# Build the image
+echo "📦 Building Docker image..."
+docker compose -f "$COMPOSE_FILE" build "$SERVICE"
+
+# Test npx
+echo "✅ Testing npx..."
+docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx --version'
+
+# Test npm
+echo "✅ Testing npm..."
+docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npm --version'
+
+# Test node
+echo "✅ Testing Node.js..."
+docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'node --version'
+
+# Test git
+echo "✅ Testing git..."
+docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'git --version'
+
+# Test python
+echo "✅ Testing Python..."
+docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'python3 --version'
+
+# Test uv
+echo "✅ Testing uv..."
+docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'uv --version'
+
+# Test MCP server installation (quick)
+echo "✅ Testing @modelcontextprotocol/server-filesystem MCP server install with npx..."
+docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c '/dev/null 2>&1 || true
+
+echo "Starting Ergo IRC server on port $IRC_PORT..."
+docker run -d \
+ --name "$CONTAINER_NAME" \
+ -p "$IRC_PORT:6667" \
+ ghcr.io/ergochat/ergo:stable
+
+for i in $(seq 1 10); do
+ if nc -z localhost "$IRC_PORT" 2>/dev/null; then
+ break
+ fi
+ if [ "$i" -eq 10 ]; then
+ echo "ERROR: Server did not start within 10s"
+ exit 1
+ fi
+ sleep 1
+done
+
+echo ""
+echo "IRC server ready on localhost:$IRC_PORT"
+echo ""
+echo "Add this to your ~/.picoclaw/config.json under \"channels\":"
+echo ""
+echo ' "irc": {'
+echo ' "enabled": true,'
+echo ' "server": "localhost:6667",'
+echo ' "tls": false,'
+echo ' "nick": "picobot",'
+echo ' "channels": ["#test"],'
+echo ' "allow_from": [],'
+echo ' "group_trigger": { "mention_only": true }'
+echo ' }'
+echo ""
+echo "Then run picoclaw:"
+echo " cd packages/picoclaw && go run ./cmd/picoclaw gateway"
+echo ""
+echo "Connect with an IRC client:"
+echo " irssi: /connect localhost $IRC_PORT"
+echo " weechat: /server add test localhost/$IRC_PORT && /connect test"
+echo " Join #test, then: picobot: hello"
+echo ""
+echo "To stop the IRC server:"
+echo " docker rm -f $CONTAINER_NAME"