diff --git a/.gitignore b/.gitignore
index ce30d749e..3ff195fbf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,7 @@ build/
*.out
/picoclaw
/picoclaw-test
-cmd/picoclaw/workspace
+cmd/**/workspace
# Picoclaw specific
diff --git a/.golangci.yaml b/.golangci.yaml
index d45d69e67..d0ba90716 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -28,9 +28,7 @@ linters:
- wsl_v5
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
- - bodyclose
- contextcheck
- - dogsled
- embeddedstructfieldcheck
- errcheck
- errchkjson
@@ -45,32 +43,24 @@ linters:
- gocritic
- gocyclo
- godox
- - goprintffuncname
- gosec
- ineffassign
- lll
- maintidx
- - misspell
- mnd
- modernize
- - nakedret
- nestif
- nilnil
- paralleltest
- perfsprint
- - prealloc
- - predeclared
- revive
- staticcheck
- tagalign
- testifylint
- thelper
- unparam
- - unused
- usestdlibvars
- usetesting
- - wastedassign
- - whitespace
settings:
errcheck:
check-type-assertions: true
@@ -152,6 +142,9 @@ linters:
- gocognit
- gocyclo
path: _test\.go$
+ - linters:
+ - nolintlint
+ path: 'pkg/tools/(i2c\.go|spi\.go)$'
issues:
max-issues-per-linter: 0
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 2c47f7d86..90bdc8437 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -5,7 +5,7 @@ version: 2
before:
hooks:
- go mod tidy
- - go generate ./cmd/picoclaw
+ - go generate ./cmd/picoclaw/...
builds:
- id: picoclaw
@@ -15,10 +15,10 @@ builds:
- stdjson
ldflags:
- -s -w
- - -X main.version={{ .Version }}
- - -X main.gitCommit={{ .ShortCommit }}
- - -X main.buildTime={{ .Date }}
- - -X main.goVersion={{ .Env.GOVERSION }}
+ - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
+ - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
+ - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
+ - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
goos:
- linux
- windows
@@ -28,9 +28,10 @@ builds:
- amd64
- arm64
- riscv64
- - s390x
- - mips64
+ - loong64
- arm
+ goarm:
+ - "7"
main: ./cmd/picoclaw
ignore:
- goos: windows
@@ -38,7 +39,9 @@ builds:
dockers_v2:
- id: picoclaw
- dockerfile: Dockerfile.goreleaser
+ dockerfile: docker/Dockerfile.goreleaser
+ extra_files:
+ - docker/entrypoint.sh
ids:
- picoclaw
images:
@@ -67,6 +70,25 @@ archives:
- goos: windows
formats: [zip]
+nfpms:
+ - id: picoclaw
+ package_name: picoclaw
+ file_name_template: >-
+ {{ .PackageName }}_
+ {{- if eq .Arch "amd64" }}x86_64
+ {{- else if eq .Arch "arm64" }}aarch64
+ {{- else if eq .Arch "arm" }}armv{{ .Arm }}
+ {{- else }}{{ .Arch }}{{ end }}
+ vendor: picoclaw
+ homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw
+ maintainer: picoclaw contributors
+ description: picoclaw - a tool for managing and running tasks
+ license: MIT
+ formats:
+ - rpm
+ - deb
+ bindir: /usr/bin
+
changelog:
sort: asc
filters:
diff --git a/Makefile b/Makefile
index 371d30065..e2ef23e72 100644
--- a/Makefile
+++ b/Makefile
@@ -11,10 +11,11 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date +%FT%T%z)
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
-LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
+INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
+LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
# Go variables
-GO?=go
+GO?=CGO_ENABLED=0 go
GOFLAGS?=-v -tags stdjson
# Golangci-lint
@@ -24,6 +25,7 @@ GOLANGCI_LINT?=golangci-lint
INSTALL_PREFIX?=$(HOME)/.local
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
+INSTALL_TMP_SUFFIX=.new
# Workspace and Skills
PICOCLAW_HOME?=$(HOME)/.picoclaw
@@ -42,6 +44,8 @@ ifeq ($(UNAME_S),Linux)
ARCH=amd64
else ifeq ($(UNAME_M),aarch64)
ARCH=arm64
+ else ifeq ($(UNAME_M),armv81)
+ ARCH=arm64
else ifeq ($(UNAME_M),loongarch64)
ARCH=loong64
else ifeq ($(UNAME_M),riscv64)
@@ -83,14 +87,50 @@ build: generate
@echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
+## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
+build-whatsapp-native: generate
+## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
+ @echo "Building for multiple platforms..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
+ GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
+ 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=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)
+ @echo "Build complete"
+## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
+
+## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)
+build-linux-arm: generate
+ @echo "Building for linux/arm (GOARM=7)..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
+ @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
+
+## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
+build-linux-arm64: generate
+ @echo "Building for linux/arm64..."
+ @mkdir -p $(BUILD_DIR)
+ 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-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)"
+
## build-all: Build picoclaw for all platforms
build-all: generate
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
+ GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
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=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)
@echo "All builds complete"
@@ -99,8 +139,10 @@ build-all: generate
install: build
@echo "Installing $(BINARY_NAME)..."
@mkdir -p $(INSTALL_BIN_DIR)
- @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
- @chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
+ # Copy binary with temporary suffix to ensure atomic update
+ @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
+ @chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
+ @mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
@echo "Installation complete!"
@@ -141,6 +183,10 @@ fmt:
lint:
@$(GOLANGCI_LINT) run
+## fix: Fix linting issues
+fix:
+ @$(GOLANGCI_LINT) run --fix
+
## deps: Download dependencies
deps:
@$(GO) mod download
@@ -204,7 +250,7 @@ help:
@echo " make [target]"
@echo ""
@echo "Targets:"
- @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'
+ @grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
@echo ""
@echo "Examples:"
@echo " make build # Build for current platform"
diff --git a/README.fr.md b/README.fr.md
index 7199f7098..c452b71ac 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -50,7 +50,7 @@
## 📢 Actualités
-2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/picoclaw_community_roadmap_260216.md) — nous avons hâte de vous accueillir !
+2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/ROADMAP.md) — nous avons hâte de vous accueillir !
2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
@@ -164,35 +164,43 @@ Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installe
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
-# 2. Configurez vos clés API
-cp config/config.example.json config/config.json
-vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
+# 2. Premier lancement — génère docker/data/config.json puis s'arrête
+docker compose -f docker/docker-compose.yml --profile gateway up
+# Le conteneur affiche "First-run setup complete." puis s'arrête.
-# 3. Compiler & Démarrer
-docker compose --profile gateway up -d
+# 3. Configurez vos clés API
+vim docker/data/config.json # Clés API du fournisseur, tokens de bot, etc.
-# 4. Voir les logs
-docker compose logs -f picoclaw-gateway
+# 4. Démarrer
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
-# 5. Arrêter
-docker compose --profile gateway down
+> [!TIP]
+> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
+
+```bash
+# 5. Voir les logs
+docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
+
+# 6. Arrêter
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Mode Agent (exécution unique)
```bash
# Poser une question
-docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?"
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Combien font 2+2 ?"
# Mode interactif
-docker compose run --rm picoclaw-agent
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
```
-### Recompiler
+### Mettre à jour
```bash
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 Démarrage Rapide
@@ -217,12 +225,13 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"channels": {
@@ -248,6 +257,9 @@ picoclaw onboard
}
```
+> **Nouveau** : Le format de configuration `model_list` permet d'ajouter des fournisseurs sans modifier le code. Voir [Configuration de Modèle](#configuration-de-modèle-model_list) pour plus de détails.
+> `request_timeout` est optionnel et s'exprime en secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le délai d'expiration par défaut (120s).
+
**3. Obtenir des Clés API**
* **Fournisseur LLM** : [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)
@@ -975,6 +987,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
```
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
+**Proxy/API personnalisée**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### Équilibrage de Charge
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
diff --git a/README.ja.md b/README.ja.md
index bb0bdfb28..6d5d09451 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -126,35 +126,43 @@ Docker Compose を使えば、ローカルにインストールせずに PicoCla
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
-# 2. API キーを設定
-cp config/config.example.json config/config.json
-vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定
+# 2. 初回起動 — docker/data/config.json を自動生成して終了
+docker compose -f docker/docker-compose.yml --profile gateway up
+# コンテナが "First-run setup complete." を表示して停止します。
-# 3. ビルドと起動
-docker compose --profile gateway up -d
+# 3. API キーを設定
+vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定
-# 4. ログ確認
-docker compose logs -f picoclaw-gateway
+# 4. 起動
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
-# 5. 停止
-docker compose --profile gateway down
+> [!TIP]
+> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
+
+```bash
+# 5. ログ確認
+docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
+
+# 6. 停止
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Agent モード(ワンショット)
```bash
# 質問を投げる
-docker compose run --rm picoclaw-agent -m "What is 2+2?"
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
# インタラクティブモード
-docker compose run --rm picoclaw-agent
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
```
-### リビルド
+### アップデート
```bash
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 クイックスタート(ネイティブ)
@@ -162,7 +170,7 @@ docker compose --profile gateway up -d
> [!TIP]
> `~/.picoclaw/config.json` に API キーを設定してください。
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
+> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
**1. 初期化**
@@ -179,12 +187,13 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"channels": {
@@ -193,14 +202,37 @@ picoclaw onboard
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"allow_from": []
}
+ },
+ "tools": {
+ "web": {
+ "search": {
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
+ "max_results": 5
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
}
}
```
+> **新機能**: `model_list` 形式により、プロバイダーをコード変更なしで追加できます。詳細は [モデル設定](#モデル設定-model_list) を参照してください。
+> `request_timeout` は任意の秒単位設定です。省略または `<= 0` の場合、PicoClaw はデフォルトのタイムアウト(120秒)を使用します。
+
**3. API キーの取得**
-- **LLM プロバイダー**: [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) · [Qwen](https://dashscope.console.aliyun.com)
-- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
+- **LLM プロバイダー**: [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 検索**(任意): [Tavily](https://tavily.com) - AI エージェント向けに最適化 (月 1000 リクエスト) · [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
@@ -894,6 +926,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
```
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
+**カスタムプロキシ/API**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### ロードバランシング
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
@@ -985,7 +1028,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
Web 検索を有効にするには:
-1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
+1. [https://tavily.com](https://tavily.com) (月 1000 クエリ無料) または [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
2. `~/.picoclaw/config.json` に追加:
```json
{
@@ -1023,5 +1066,6 @@ Web 検索を有効にするには:
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
+| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
diff --git a/README.md b/README.md
index 7bc7b1089..b040d0605 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,13 @@
+
+
+
- [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
+[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
+
---
@@ -42,16 +46,17 @@
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
>
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
+>
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
-
## 📢 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/picoclaw_community_roadmap_260216.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 come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
+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-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.
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
@@ -100,9 +105,12 @@
### 📱 Run on old Android Phones
+
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
+
1. **Install Termux** (Available on F-Droid or Google Play).
2. **Execute cmds**
+
```bash
# Note: Replace v0.1.1 with the latest version from the Releases page
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
@@ -110,6 +118,7 @@ chmod +x picoclaw-linux-arm64
pkg install proot
termux-chroot ./picoclaw-linux-arm64 onboard
```
+
And then follow the instructions in the "Quick Start" section to complete the configuration!
@@ -145,10 +154,15 @@ make build
# Build for multiple platforms
make build-all
+# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
+make build-pi-zero
+
# Build And Install
make install
```
+**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both.
+
## 🐳 Docker Compose
You can also run PicoClaw using Docker Compose without installing anything locally.
@@ -158,35 +172,43 @@ You can also run PicoClaw using Docker Compose without installing anything local
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
-# 2. Set your API keys
-cp config/config.example.json config/config.json
-vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
+# 2. First run — auto-generates docker/data/config.json then exits
+docker compose -f docker/docker-compose.yml --profile gateway up
+# The container prints "First-run setup complete." and stops.
-# 3. Build & Start
-docker compose --profile gateway up -d
+# 3. Set your API keys
+vim docker/data/config.json # Set provider API keys, bot tokens, etc.
-# 4. Check logs
-docker compose logs -f picoclaw-gateway
+# 4. Start
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
-# 5. Stop
-docker compose --profile gateway down
+> [!TIP]
+> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
+
+```bash
+# 5. Check logs
+docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
+
+# 6. Stop
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Agent Mode (One-shot)
```bash
# Ask a question
-docker compose run --rm picoclaw-agent -m "What is 2+2?"
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
# Interactive mode
-docker compose run --rm picoclaw-agent
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
```
-### Rebuild
+### Update
```bash
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 Quick Start
@@ -194,7 +216,7 @@ docker compose --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 [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) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
**1. Initialize**
@@ -209,7 +231,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "gpt4",
+ "model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -219,7 +241,8 @@ picoclaw onboard
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
- "api_key": "your-api-key"
+ "api_key": "your-api-key",
+ "request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
@@ -234,6 +257,11 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
+ "max_results": 5
+ },
"duckduckgo": {
"enabled": true,
"max_results": 5
@@ -244,11 +272,12 @@ picoclaw onboard
```
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
+> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
**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): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
+* **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)
> **Note**: See `config.example.json` for a complete configuration template.
@@ -264,12 +293,13 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
-Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
+Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom
| Channel | Setup |
| ------------ | ---------------------------------- |
| **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) |
+| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
@@ -323,7 +353,6 @@ picoclaw gateway
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
**3. Get your User ID**
-
* Discord Settings → Advanced → enable **Developer Mode**
* Right-click your avatar → **Copy User ID**
@@ -361,6 +390,33 @@ picoclaw gateway
+
+WhatsApp (native via whatsmeow)
+
+PicoClaw can connect to WhatsApp in two ways:
+
+- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
+- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
+
+**Configure (native)**
+
+```json
+{
+ "channels": {
+ "whatsapp": {
+ "enabled": true,
+ "use_native": true,
+ "session_store_path": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
+
+
+
QQ
@@ -425,7 +481,6 @@ picoclaw gateway
```bash
picoclaw gateway
```
-
@@ -521,7 +576,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
* Go to WeCom Admin Console → App Management → Create App
* Copy **AgentId** and **Secret**
* Go to "My Company" page, copy **CorpID**
-
**2. Configure receive message**
* In App details, click "Receive Message" → "Set API"
@@ -605,23 +659,23 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
}
```
-| Option | Default | Description |
-|--------|---------|-------------|
-| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
-| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
+| Option | Default | Description |
+| ----------------------- | ----------------------- | ----------------------------------------- |
+| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
+| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
#### Protected Tools
When `restrict_to_workspace: true`, the following tools are sandboxed:
-| Tool | Function | Restriction |
-|------|----------|-------------|
-| `read_file` | Read files | Only files within workspace |
-| `write_file` | Write files | Only files within workspace |
-| `list_dir` | List directories | Only directories within workspace |
-| `edit_file` | Edit files | Only files within workspace |
-| `append_file` | Append to files | Only files within workspace |
-| `exec` | Execute commands | Command paths must be within workspace |
+| Tool | Function | Restriction |
+| ------------- | ---------------- | -------------------------------------- |
+| `read_file` | Read files | Only files within workspace |
+| `write_file` | Write files | Only files within workspace |
+| `list_dir` | List directories | Only directories within workspace |
+| `edit_file` | Edit files | Only files within workspace |
+| `append_file` | Append to files | Only files within workspace |
+| `exec` | Execute commands | Command paths must be within workspace |
#### Additional Exec Protection
@@ -674,11 +728,11 @@ export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
The `restrict_to_workspace` setting applies consistently across all execution paths:
-| Execution Path | Security Boundary |
-|----------------|-------------------|
-| Main Agent | `restrict_to_workspace` ✅ |
+| Execution Path | Security Boundary |
+| ---------------- | ---------------------------- |
+| Main Agent | `restrict_to_workspace` ✅ |
| Subagent / Spawn | Inherits same restriction ✅ |
-| Heartbeat tasks | Inherits same restriction ✅ |
+| Heartbeat tasks | Inherits same restriction ✅ |
All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks.
@@ -704,21 +758,23 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
# Periodic Tasks
## Quick Tasks (respond directly)
+
- Report current time
## Long Tasks (use spawn for async)
+
- Search the web for AI news and summarize
- Check email and report important messages
```
**Key behaviors:**
-| Feature | Description |
-|---------|-------------|
-| **spawn** | Creates async subagent, doesn't block heartbeat |
-| **Independent context** | Subagent has its own context, no session history |
-| **message tool** | Subagent communicates with user directly via message tool |
-| **Non-blocking** | After spawning, heartbeat continues to next task |
+| Feature | Description |
+| ----------------------- | --------------------------------------------------------- |
+| **spawn** | Creates async subagent, doesn't block heartbeat |
+| **Independent context** | Subagent has its own context, no session history |
+| **message tool** | Subagent communicates with user directly via message tool |
+| **Non-blocking** | After spawning, heartbeat continues to next task |
#### How Subagent Communication Works
@@ -749,10 +805,10 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
}
```
-| Option | Default | Description |
-|--------|---------|-------------|
-| `enabled` | `true` | Enable/disable heartbeat |
-| `interval` | `30` | Check interval in minutes (min: 5) |
+| Option | Default | Description |
+| ---------- | ------- | ---------------------------------- |
+| `enabled` | `true` | Enable/disable heartbeat |
+| `interval` | `30` | Check interval in minutes (min: 5) |
**Environment variables:**
@@ -764,17 +820,17 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
> [!NOTE]
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
-| Provider | Purpose | Get API Key |
-| -------------------------- | --------------------------------------- | ------------------------------------------------------ |
-| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
-| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
-| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
-| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
-| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
-| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| Provider | Purpose | Get API Key |
+| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
+| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
+| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `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) |
+| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
### Model Configuration (model_list)
@@ -789,25 +845,25 @@ 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) |
-| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
-| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
-| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
-| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
-| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
-| **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) |
-| **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 | - |
-| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
-| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+| 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) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
+| **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) |
+| **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 | - |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
#### Basic Configuration
@@ -841,6 +897,7 @@ This design also enables **multi-agent support** with flexible provider selectio
#### Vendor-Specific Examples
**OpenAI**
+
```json
{
"model_name": "gpt-5.2",
@@ -850,6 +907,7 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**智谱 AI (GLM)**
+
```json
{
"model_name": "glm-4.7",
@@ -859,6 +917,7 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**DeepSeek**
+
```json
{
"model_name": "deepseek-chat",
@@ -868,6 +927,7 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**Anthropic (with API key)**
+
```json
{
"model_name": "claude-sonnet-4.6",
@@ -875,9 +935,11 @@ This design also enables **multi-agent support** with flexible provider selectio
"api_key": "sk-ant-your-key"
}
```
+
> Run `picoclaw auth login --provider anthropic` to paste your API token.
**Ollama (local)**
+
```json
{
"model_name": "llama3",
@@ -886,12 +948,14 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**Custom Proxy/API**
+
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
- "api_key": "sk-..."
+ "api_key": "sk-...",
+ "request_timeout": 300
}
```
@@ -923,6 +987,7 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
**Old Config (deprecated):**
+
```json
{
"providers": {
@@ -941,6 +1006,7 @@ The old `providers` configuration is **deprecated** but still supported for back
```
**New Config (recommended):**
+
```json
{
"model_list": [
@@ -1037,7 +1103,11 @@ picoclaw agent -m "Hello"
"allow_from": [""]
},
"whatsapp": {
- "enabled": false
+ "enabled": false,
+ "bridge_url": "ws://localhost:3001",
+ "use_native": false,
+ "session_store_path": "",
+ "allow_from": []
},
"feishu": {
"enabled": false,
@@ -1105,19 +1175,19 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
PRs welcome! The codebase is intentionally small and readable. 🤗
-Roadmap coming soon...
+See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
-Developer group building, Entry Requirement: At least 1 Merged PR.
+Developer group building, join after your first merged PR!
User Groups:
-discord:
+discord:
## 🐛 Troubleshooting
-### Web search says "API 配置问题"
+### Web search says "API key configuration issue"
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
diff --git a/README.pt-br.md b/README.pt-br.md
index ec8fe8e1c..61663e363 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -50,7 +50,7 @@
## 📢 Novidades
-2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo!
+2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/ROADMAP.md) — estamos ansiosos para ter você a bordo!
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
@@ -165,35 +165,43 @@ Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada loca
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
-# 2. Configure suas API keys
-cp config/config.example.json config/config.json
-vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
+# 2. Primeiro uso — gera docker/data/config.json automaticamente e para
+docker compose -f docker/docker-compose.yml --profile gateway up
+# O contêiner exibe "First-run setup complete." e para.
-# 3. Build & Iniciar
-docker compose --profile gateway up -d
+# 3. Configure suas API keys
+vim docker/data/config.json # Chaves de API do provedor, tokens de bot, etc.
-# 4. Ver logs
-docker compose logs -f picoclaw-gateway
+# 4. Iniciar
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
-# 5. Parar
-docker compose --profile gateway down
+> [!TIP]
+> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
+
+```bash
+# 5. Ver logs
+docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
+
+# 6. Parar
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Modo Agente (Execução única)
```bash
# Fazer uma pergunta
-docker compose run --rm picoclaw-agent -m "Quanto e 2+2?"
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Quanto e 2+2?"
# Modo interativo
-docker compose run --rm picoclaw-agent
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
```
-### Rebuild
+### Atualizar
```bash
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 Início Rápido
@@ -218,12 +226,13 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"tools": {
@@ -242,6 +251,9 @@ picoclaw onboard
}
```
+> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alterar código. Veja [Configuração de Modelo](#configuração-de-modelo-model_list) para detalhes.
+> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s).
+
**3. Obter API Keys**
* **Provedor de LLM**: [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)
@@ -969,6 +981,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
```
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
+**Proxy/API personalizada**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### Balanceamento de Carga
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
diff --git a/README.vi.md b/README.vi.md
index 161842933..f8ece7eda 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -50,7 +50,7 @@
## 📢 Tin tức
-2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn!
+2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
@@ -145,35 +145,43 @@ Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cà
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
-# 2. Thiết lập API Key
-cp config/config.example.json config/config.json
-vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
+# 2. Lần chạy đầu tiên — tự tạo docker/data/config.json rồi dừng lại
+docker compose -f docker/docker-compose.yml --profile gateway up
+# Container hiển thị "First-run setup complete." rồi tự dừng.
-# 3. Build & Khởi động
-docker compose --profile gateway up -d
+# 3. Thiết lập API Key
+vim docker/data/config.json # API key của provider, bot token, v.v.
-# 4. Xem logs
-docker compose logs -f picoclaw-gateway
+# 4. Khởi động
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
-# 5. Dừng
-docker compose --profile gateway down
+> [!TIP]
+> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
+
+```bash
+# 5. Xem logs
+docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
+
+# 6. Dừng
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Chế độ Agent (chạy một lần)
```bash
# Đặt câu hỏi
-docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?"
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 bằng mấy?"
# Chế độ tương tác
-docker compose run --rm picoclaw-agent
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
```
-### Build lại
+### Cập nhật
```bash
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 Bắt đầu nhanh
@@ -198,12 +206,13 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"channels": {
@@ -216,6 +225,9 @@ picoclaw onboard
}
```
+> **Mới**: Định dạng cấu hình `model_list` cho phép thêm nhà cung cấp mà không cần thay đổi mã nguồn. Xem [Cấu hình Mô hình](#cấu-hình-mô-hình-model_list) để biết chi tiết.
+> `request_timeout` là tùy chọn và dùng đơn vị giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sẽ dùng timeout mặc định (120s).
+
**3. Lấy API Key**
* **Nhà cung cấp LLM**: [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)
@@ -940,6 +952,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
```
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
+**Proxy/API tùy chỉnh**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### Cân bằng Tải tải
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
diff --git a/README.zh.md b/README.zh.md
index 4d739c5eb..7c9351cb4 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -52,7 +52,7 @@
## 📢 新闻 (News)
-2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
+2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与!
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
@@ -166,38 +166,43 @@ make install
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
-# 2. 设置 API Key
-cp config/config.example.json config/config.json
-vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
+# 2. 首次运行 — 自动生成 docker/data/config.json 后退出
+docker compose -f docker/docker-compose.yml --profile gateway up
+# 容器打印 "First-run setup complete." 后自动停止
-# 3. 构建并启动
-docker compose --profile gateway up -d
+# 3. 填写 API Key 等配置
+vim docker/data/config.json # 设置 provider API key、Bot Token 等
-# 4. 查看日志
-docker compose logs -f picoclaw-gateway
+# 4. 正式启动
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
-# 5. 停止
-docker compose --profile gateway down
+> [!TIP]
+> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
+```bash
+# 5. 查看日志
+docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
+
+# 6. 停止
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Agent 模式 (一次性运行)
```bash
# 提问
-docker compose run --rm picoclaw-agent -m "2+2 等于几?"
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?"
# 交互模式
-docker compose run --rm picoclaw-agent
-
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
```
-### 重新构建
+### 更新镜像
```bash
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
-
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 快速开始
@@ -205,7 +210,7 @@ docker compose --profile gateway up -d
> [!TIP]
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
+> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
**1. 初始化 (Initialize)**
@@ -221,7 +226,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "gpt4",
+ "model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -231,7 +236,8 @@ picoclaw onboard
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
- "api_key": "your-api-key"
+ "api_key": "your-api-key",
+ "request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
@@ -246,8 +252,9 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
- "duckduckgo": {
- "enabled": true,
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
"max_results": 5
}
},
@@ -259,11 +266,12 @@ picoclaw onboard
```
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
+> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。
**3. 获取 API Key**
-- **LLM 提供商**: [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)
-- **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
+* **LLM 提供商**: [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)
+* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
> **注意**: 完整的配置模板请参考 `config.example.json`。
@@ -546,7 +554,8 @@ Agent 读取 HEARTBEAT.md
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
- "api_key": "sk-..."
+ "api_key": "sk-...",
+ "request_timeout": 300
}
```
@@ -771,7 +780,7 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
启用网络搜索:
-1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
+1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费)
2. 添加到 `~/.picoclaw/config.json`:
```json
@@ -804,10 +813,10 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
## 📝 API Key 对比
-| 服务 | 免费层级 | 适用场景 |
-| ---------------- | -------------- | ----------------------------- |
-| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
-| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
-| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
-| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
-| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
+| 服务 | 免费层级 | 适用场景 |
+| --- | --- | --- |
+| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
+| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
+| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
+| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
+| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
diff --git a/assets/picoclaw_detect_person.mp4 b/assets/picoclaw_detect_person.mp4
deleted file mode 100644
index b56999689..000000000
Binary files a/assets/picoclaw_detect_person.mp4 and /dev/null differ
diff --git a/assets/wechat.png b/assets/wechat.png
index a34217c33..1900c7556 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/cmd/picoclaw/cmd_cron.go b/cmd/picoclaw/cmd_cron.go
deleted file mode 100644
index 8c42bde06..000000000
--- a/cmd/picoclaw/cmd_cron.go
+++ /dev/null
@@ -1,227 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "time"
-
- "github.com/sipeed/picoclaw/pkg/cron"
-)
-
-func cronCmd() {
- if len(os.Args) < 3 {
- cronHelp()
- return
- }
-
- subcommand := os.Args[2]
-
- // Load config to get workspace path
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- return
- }
-
- cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
-
- switch subcommand {
- case "list":
- cronListCmd(cronStorePath)
- case "add":
- cronAddCmd(cronStorePath)
- case "remove":
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw cron remove ")
- return
- }
- cronRemoveCmd(cronStorePath, os.Args[3])
- case "enable":
- cronEnableCmd(cronStorePath, false)
- case "disable":
- cronEnableCmd(cronStorePath, true)
- default:
- fmt.Printf("Unknown cron command: %s\n", subcommand)
- cronHelp()
- }
-}
-
-func cronHelp() {
- fmt.Println("\nCron commands:")
- fmt.Println(" list List all scheduled jobs")
- fmt.Println(" add Add a new scheduled job")
- fmt.Println(" remove Remove a job by ID")
- fmt.Println(" enable Enable a job")
- fmt.Println(" disable Disable a job")
- fmt.Println()
- fmt.Println("Add options:")
- fmt.Println(" -n, --name Job name")
- fmt.Println(" -m, --message Message for agent")
- fmt.Println(" -e, --every Run every N seconds")
- fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
- fmt.Println(" -d, --deliver Deliver response to channel")
- fmt.Println(" --to Recipient for delivery")
- fmt.Println(" --channel Channel for delivery")
-}
-
-func cronListCmd(storePath string) {
- cs := cron.NewCronService(storePath, nil)
- jobs := cs.ListJobs(true) // Show all jobs, including disabled
-
- if len(jobs) == 0 {
- fmt.Println("No scheduled jobs.")
- return
- }
-
- fmt.Println("\nScheduled Jobs:")
- fmt.Println("----------------")
- for _, job := range jobs {
- var schedule string
- if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
- schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
- } else if job.Schedule.Kind == "cron" {
- schedule = job.Schedule.Expr
- } else {
- schedule = "one-time"
- }
-
- nextRun := "scheduled"
- if job.State.NextRunAtMS != nil {
- nextTime := time.UnixMilli(*job.State.NextRunAtMS)
- nextRun = nextTime.Format("2006-01-02 15:04")
- }
-
- status := "enabled"
- if !job.Enabled {
- status = "disabled"
- }
-
- fmt.Printf(" %s (%s)\n", job.Name, job.ID)
- fmt.Printf(" Schedule: %s\n", schedule)
- fmt.Printf(" Status: %s\n", status)
- fmt.Printf(" Next run: %s\n", nextRun)
- }
-}
-
-func cronAddCmd(storePath string) {
- name := ""
- message := ""
- var everySec *int64
- cronExpr := ""
- deliver := false
- channel := ""
- to := ""
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "-n", "--name":
- if i+1 < len(args) {
- name = args[i+1]
- i++
- }
- case "-m", "--message":
- if i+1 < len(args) {
- message = args[i+1]
- i++
- }
- case "-e", "--every":
- if i+1 < len(args) {
- var sec int64
- fmt.Sscanf(args[i+1], "%d", &sec)
- everySec = &sec
- i++
- }
- case "-c", "--cron":
- if i+1 < len(args) {
- cronExpr = args[i+1]
- i++
- }
- case "-d", "--deliver":
- deliver = true
- case "--to":
- if i+1 < len(args) {
- to = args[i+1]
- i++
- }
- case "--channel":
- if i+1 < len(args) {
- channel = args[i+1]
- i++
- }
- }
- }
-
- if name == "" {
- fmt.Println("Error: --name is required")
- return
- }
-
- if message == "" {
- fmt.Println("Error: --message is required")
- return
- }
-
- if everySec == nil && cronExpr == "" {
- fmt.Println("Error: Either --every or --cron must be specified")
- return
- }
-
- var schedule cron.CronSchedule
- if everySec != nil {
- everyMS := *everySec * 1000
- schedule = cron.CronSchedule{
- Kind: "every",
- EveryMS: &everyMS,
- }
- } else {
- schedule = cron.CronSchedule{
- Kind: "cron",
- Expr: cronExpr,
- }
- }
-
- cs := cron.NewCronService(storePath, nil)
- job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
- if err != nil {
- fmt.Printf("Error adding job: %v\n", err)
- return
- }
-
- fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
-}
-
-func cronRemoveCmd(storePath, jobID string) {
- cs := cron.NewCronService(storePath, nil)
- if cs.RemoveJob(jobID) {
- fmt.Printf("✓ Removed job %s\n", jobID)
- } else {
- fmt.Printf("✗ Job %s not found\n", jobID)
- }
-}
-
-func cronEnableCmd(storePath string, disable bool) {
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw cron enable/disable ")
- return
- }
-
- jobID := os.Args[3]
- cs := cron.NewCronService(storePath, nil)
- enabled := !disable
-
- job := cs.EnableJob(jobID, enabled)
- if job != nil {
- status := "enabled"
- if disable {
- status = "disabled"
- }
- fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
- } else {
- fmt.Printf("✗ Job %s not found\n", jobID)
- }
-}
diff --git a/cmd/picoclaw/cmd_migrate.go b/cmd/picoclaw/cmd_migrate.go
deleted file mode 100644
index 86d4903ef..000000000
--- a/cmd/picoclaw/cmd_migrate.go
+++ /dev/null
@@ -1,81 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
-
-import (
- "fmt"
- "os"
-
- "github.com/sipeed/picoclaw/pkg/migrate"
-)
-
-func migrateCmd() {
- if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
- migrateHelp()
- return
- }
-
- opts := migrate.Options{}
-
- args := os.Args[2:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--dry-run":
- opts.DryRun = true
- case "--config-only":
- opts.ConfigOnly = true
- case "--workspace-only":
- opts.WorkspaceOnly = true
- case "--force":
- opts.Force = true
- case "--refresh":
- opts.Refresh = true
- case "--openclaw-home":
- if i+1 < len(args) {
- opts.OpenClawHome = args[i+1]
- i++
- }
- case "--picoclaw-home":
- if i+1 < len(args) {
- opts.PicoClawHome = args[i+1]
- i++
- }
- default:
- fmt.Printf("Unknown flag: %s\n", args[i])
- migrateHelp()
- os.Exit(1)
- }
- }
-
- result, err := migrate.Run(opts)
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- os.Exit(1)
- }
-
- if !opts.DryRun {
- migrate.PrintSummary(result)
- }
-}
-
-func migrateHelp() {
- fmt.Println("\nMigrate from OpenClaw to PicoClaw")
- fmt.Println()
- fmt.Println("Usage: picoclaw migrate [options]")
- fmt.Println()
- fmt.Println("Options:")
- fmt.Println(" --dry-run Show what would be migrated without making changes")
- fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
- fmt.Println(" --config-only Only migrate config, skip workspace files")
- fmt.Println(" --workspace-only Only migrate workspace files, skip config")
- fmt.Println(" --force Skip confirmation prompts")
- fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
- fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
- fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
- fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
- fmt.Println(" picoclaw migrate --force Migrate without confirmation")
-}
diff --git a/cmd/picoclaw/internal/agent/command.go b/cmd/picoclaw/internal/agent/command.go
new file mode 100644
index 000000000..47262fc85
--- /dev/null
+++ b/cmd/picoclaw/internal/agent/command.go
@@ -0,0 +1,30 @@
+package agent
+
+import (
+ "github.com/spf13/cobra"
+)
+
+func NewAgentCommand() *cobra.Command {
+ var (
+ message string
+ sessionKey string
+ model string
+ debug bool
+ )
+
+ cmd := &cobra.Command{
+ Use: "agent",
+ Short: "Interact with the agent directly",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return agentCmd(message, sessionKey, model, debug)
+ },
+ }
+
+ cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
+ cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)")
+ cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key")
+ cmd.Flags().StringVarP(&model, "model", "", "", "Model to use")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/agent/command_test.go b/cmd/picoclaw/internal/agent/command_test.go
new file mode 100644
index 000000000..1457d6a49
--- /dev/null
+++ b/cmd/picoclaw/internal/agent/command_test.go
@@ -0,0 +1,33 @@
+package agent
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewAgentCommand(t *testing.T) {
+ cmd := NewAgentCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "agent", cmd.Use)
+ assert.Equal(t, "Interact with the agent directly", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 0)
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.True(t, cmd.HasFlags())
+
+ assert.NotNil(t, cmd.Flags().Lookup("debug"))
+ assert.NotNil(t, cmd.Flags().Lookup("message"))
+ assert.NotNil(t, cmd.Flags().Lookup("session"))
+ assert.NotNil(t, cmd.Flags().Lookup("model"))
+}
diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/internal/agent/helpers.go
similarity index 68%
rename from cmd/picoclaw/cmd_agent.go
rename to cmd/picoclaw/internal/agent/helpers.go
index 6d6ff935f..f754abc65 100644
--- a/cmd/picoclaw/cmd_agent.go
+++ b/cmd/picoclaw/internal/agent/helpers.go
@@ -1,7 +1,4 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package agent
import (
"bufio"
@@ -14,62 +11,44 @@ import (
"github.com/chzyer/readline"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
-func agentCmd() {
- message := ""
- sessionKey := "cli:default"
- modelOverride := ""
-
- args := os.Args[2:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--debug", "-d":
- logger.SetLevel(logger.DEBUG)
- fmt.Println("🔍 Debug mode enabled")
- case "-m", "--message":
- if i+1 < len(args) {
- message = args[i+1]
- i++
- }
- case "-s", "--session":
- if i+1 < len(args) {
- sessionKey = args[i+1]
- i++
- }
- case "--model", "-model":
- if i+1 < len(args) {
- modelOverride = args[i+1]
- i++
- }
- }
+func agentCmd(message, sessionKey, model string, debug bool) error {
+ if sessionKey == "" {
+ sessionKey = "cli:default"
}
- cfg, err := loadConfig()
+ if debug {
+ logger.SetLevel(logger.DEBUG)
+ fmt.Println("🔍 Debug mode enabled")
+ }
+
+ cfg, err := internal.LoadConfig()
if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("error loading config: %w", err)
}
- if modelOverride != "" {
- cfg.Agents.Defaults.Model = modelOverride
+ if model != "" {
+ cfg.Agents.Defaults.ModelName = model
}
provider, modelID, err := providers.CreateProvider(cfg)
if err != nil {
- fmt.Printf("Error creating provider: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("error creating provider: %w", err)
}
+
// Use the resolved model ID from provider creation
if modelID != "" {
- cfg.Agents.Defaults.Model = modelID
+ cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
+ defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
// Print agent startup info (only for interactive mode)
@@ -85,18 +64,20 @@ func agentCmd() {
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
if err != nil {
- fmt.Printf("Error: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("error processing message: %w", err)
}
- fmt.Printf("\n%s %s\n", logo, response)
- } else {
- fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
- interactiveMode(agentLoop, sessionKey)
+ fmt.Printf("\n%s %s\n", internal.Logo, response)
+ return nil
}
+
+ fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", internal.Logo)
+ interactiveMode(agentLoop, sessionKey)
+
+ return nil
}
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
- prompt := fmt.Sprintf("%s You: ", logo)
+ prompt := fmt.Sprintf("%s You: ", internal.Logo)
rl, err := readline.NewEx(&readline.Config{
Prompt: prompt,
@@ -141,14 +122,14 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
continue
}
- fmt.Printf("\n%s %s\n\n", logo, response)
+ fmt.Printf("\n%s %s\n\n", internal.Logo, response)
}
}
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := bufio.NewReader(os.Stdin)
for {
- fmt.Print(fmt.Sprintf("%s You: ", logo))
+ fmt.Print(fmt.Sprintf("%s You: ", internal.Logo))
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
@@ -176,6 +157,6 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
continue
}
- fmt.Printf("\n%s %s\n\n", logo, response)
+ fmt.Printf("\n%s %s\n\n", internal.Logo, response)
}
}
diff --git a/cmd/picoclaw/internal/auth/command.go b/cmd/picoclaw/internal/auth/command.go
new file mode 100644
index 000000000..12a0a3a8c
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/command.go
@@ -0,0 +1,22 @@
+package auth
+
+import "github.com/spf13/cobra"
+
+func NewAuthCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "auth",
+ Short: "Manage authentication (login, logout, status)",
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return cmd.Help()
+ },
+ }
+
+ cmd.AddCommand(
+ newLoginCommand(),
+ newLogoutCommand(),
+ newStatusCommand(),
+ newModelsCommand(),
+ )
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/auth/command_test.go b/cmd/picoclaw/internal/auth/command_test.go
new file mode 100644
index 000000000..48dc704dd
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/command_test.go
@@ -0,0 +1,55 @@
+package auth
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewAuthCommand(t *testing.T) {
+ cmd := NewAuthCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "auth", cmd.Use)
+ assert.Equal(t, "Manage authentication (login, logout, status)", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 0)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.False(t, cmd.HasFlags())
+ assert.True(t, cmd.HasSubCommands())
+
+ allowedCommands := []string{
+ "login",
+ "logout",
+ "status",
+ "models",
+ }
+
+ subcommands := cmd.Commands()
+ assert.Len(t, subcommands, len(allowedCommands))
+
+ for _, subcmd := range subcommands {
+ found := slices.Contains(allowedCommands, subcmd.Name())
+ assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
+
+ assert.Len(t, subcmd.Aliases, 0)
+ assert.False(t, subcmd.Hidden)
+
+ assert.False(t, subcmd.HasSubCommands())
+
+ assert.Nil(t, subcmd.Run)
+ assert.NotNil(t, subcmd.RunE)
+
+ assert.Nil(t, subcmd.PersistentPreRun)
+ assert.Nil(t, subcmd.PersistentPostRun)
+ }
+}
diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/internal/auth/helpers.go
similarity index 65%
rename from cmd/picoclaw/cmd_auth.go
rename to cmd/picoclaw/internal/auth/helpers.go
index 729c56177..633ce8740 100644
--- a/cmd/picoclaw/cmd_auth.go
+++ b/cmd/picoclaw/internal/auth/helpers.go
@@ -1,7 +1,4 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package auth
import (
"encoding/json"
@@ -12,92 +9,28 @@ import (
"strings"
"time"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
-const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
-
-func authCmd() {
- if len(os.Args) < 3 {
- authHelp()
- return
- }
-
- switch os.Args[2] {
- case "login":
- authLoginCmd()
- case "logout":
- authLogoutCmd()
- case "status":
- authStatusCmd()
- case "models":
- authModelsCmd()
- default:
- fmt.Printf("Unknown auth command: %s\n", os.Args[2])
- authHelp()
- }
-}
-
-func authHelp() {
- fmt.Println("\nAuth commands:")
- fmt.Println(" login Login via OAuth or paste token")
- fmt.Println(" logout Remove stored credentials")
- fmt.Println(" status Show current auth status")
- fmt.Println(" models List available Antigravity models")
- fmt.Println()
- fmt.Println("Login options:")
- fmt.Println(" --provider Provider to login with (openai, anthropic, google-antigravity)")
- fmt.Println(" --device-code Use device code flow (for headless environments)")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw auth login --provider openai")
- fmt.Println(" picoclaw auth login --provider openai --device-code")
- fmt.Println(" picoclaw auth login --provider anthropic")
- fmt.Println(" picoclaw auth login --provider google-antigravity")
- fmt.Println(" picoclaw auth models")
- fmt.Println(" picoclaw auth logout --provider openai")
- fmt.Println(" picoclaw auth status")
-}
-
-func authLoginCmd() {
- provider := ""
- useDeviceCode := false
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--provider", "-p":
- if i+1 < len(args) {
- provider = args[i+1]
- i++
- }
- case "--device-code":
- useDeviceCode = true
- }
- }
-
- if provider == "" {
- fmt.Println("Error: --provider is required")
- fmt.Println(supportedProvidersMsg)
- return
- }
+const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
+func authLoginCmd(provider string, useDeviceCode bool) error {
switch provider {
case "openai":
- authLoginOpenAI(useDeviceCode)
+ return authLoginOpenAI(useDeviceCode)
case "anthropic":
- authLoginPasteToken(provider)
+ return authLoginPasteToken(provider)
case "google-antigravity", "antigravity":
- authLoginGoogleAntigravity()
+ return authLoginGoogleAntigravity()
default:
- fmt.Printf("Unsupported provider: %s\n", provider)
- fmt.Println(supportedProvidersMsg)
+ return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
}
}
-func authLoginOpenAI(useDeviceCode bool) {
+func authLoginOpenAI(useDeviceCode bool) error {
cfg := auth.OpenAIOAuthConfig()
var cred *auth.AuthCredential
@@ -110,16 +43,14 @@ func authLoginOpenAI(useDeviceCode bool) {
}
if err != nil {
- fmt.Printf("Login failed: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("login failed: %w", err)
}
if err = auth.SetCredential("openai", cred); err != nil {
- fmt.Printf("Failed to save credentials: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("failed to save credentials: %w", err)
}
- appCfg, err := loadConfig()
+ appCfg, err := internal.LoadConfig()
if err == nil {
// Update Providers (legacy format)
appCfg.Providers.OpenAI.AuthMethod = "oauth"
@@ -144,10 +75,10 @@ func authLoginOpenAI(useDeviceCode bool) {
}
// Update default model to use OpenAI
- appCfg.Agents.Defaults.Model = "gpt-5.2"
+ appCfg.Agents.Defaults.ModelName = "gpt-5.2"
- if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
- fmt.Printf("Warning: could not update config: %v\n", err)
+ if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
+ return fmt.Errorf("could not update config: %w", err)
}
}
@@ -156,15 +87,16 @@ func authLoginOpenAI(useDeviceCode bool) {
fmt.Printf("Account: %s\n", cred.AccountID)
}
fmt.Println("Default model set to: gpt-5.2")
+
+ return nil
}
-func authLoginGoogleAntigravity() {
+func authLoginGoogleAntigravity() error {
cfg := auth.GoogleAntigravityOAuthConfig()
cred, err := auth.LoginBrowser(cfg)
if err != nil {
- fmt.Printf("Login failed: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("login failed: %w", err)
}
cred.Provider = "google-antigravity"
@@ -189,11 +121,10 @@ func authLoginGoogleAntigravity() {
}
if err = auth.SetCredential("google-antigravity", cred); err != nil {
- fmt.Printf("Failed to save credentials: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("failed to save credentials: %w", err)
}
- appCfg, err := loadConfig()
+ appCfg, err := internal.LoadConfig()
if err == nil {
// Update Providers (legacy format, for backward compatibility)
appCfg.Providers.Antigravity.AuthMethod = "oauth"
@@ -218,9 +149,9 @@ func authLoginGoogleAntigravity() {
}
// Update default model
- appCfg.Agents.Defaults.Model = "gemini-flash"
+ appCfg.Agents.Defaults.ModelName = "gemini-flash"
- if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
+ if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
}
}
@@ -228,6 +159,8 @@ func authLoginGoogleAntigravity() {
fmt.Println("\n✓ Google Antigravity login successful!")
fmt.Println("Default model set to: gemini-flash")
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
+
+ return nil
}
func fetchGoogleUserEmail(accessToken string) (string, error) {
@@ -258,19 +191,17 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
return userInfo.Email, nil
}
-func authLoginPasteToken(provider string) {
+func authLoginPasteToken(provider string) error {
cred, err := auth.LoginPasteToken(provider, os.Stdin)
if err != nil {
- fmt.Printf("Login failed: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("login failed: %w", err)
}
if err = auth.SetCredential(provider, cred); err != nil {
- fmt.Printf("Failed to save credentials: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("failed to save credentials: %w", err)
}
- appCfg, err := loadConfig()
+ appCfg, err := internal.LoadConfig()
if err == nil {
switch provider {
case "anthropic":
@@ -292,7 +223,7 @@ func authLoginPasteToken(provider string) {
})
}
// Update default model
- appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
+ appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
case "openai":
appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList
@@ -312,38 +243,29 @@ func authLoginPasteToken(provider string) {
})
}
// Update default model
- appCfg.Agents.Defaults.Model = "gpt-5.2"
+ appCfg.Agents.Defaults.ModelName = "gpt-5.2"
}
- if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
- fmt.Printf("Warning: could not update config: %v\n", err)
+ if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
+ return fmt.Errorf("could not update config: %w", err)
}
}
fmt.Printf("Token saved for %s!\n", provider)
- fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model)
-}
-func authLogoutCmd() {
- provider := ""
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--provider", "-p":
- if i+1 < len(args) {
- provider = args[i+1]
- i++
- }
- }
+ if appCfg != nil {
+ fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
}
+ return nil
+}
+
+func authLogoutCmd(provider string) error {
if provider != "" {
if err := auth.DeleteCredential(provider); err != nil {
- fmt.Printf("Failed to remove credentials: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("failed to remove credentials: %w", err)
}
- appCfg, err := loadConfig()
+ appCfg, err := internal.LoadConfig()
if err == nil {
// Clear AuthMethod in ModelList
for i := range appCfg.ModelList {
@@ -371,44 +293,46 @@ func authLogoutCmd() {
case "google-antigravity", "antigravity":
appCfg.Providers.Antigravity.AuthMethod = ""
}
- config.SaveConfig(getConfigPath(), appCfg)
+ config.SaveConfig(internal.GetConfigPath(), appCfg)
}
fmt.Printf("Logged out from %s\n", provider)
- } else {
- if err := auth.DeleteAllCredentials(); err != nil {
- fmt.Printf("Failed to remove credentials: %v\n", err)
- os.Exit(1)
- }
- appCfg, err := loadConfig()
- if err == nil {
- // Clear all AuthMethods in ModelList
- for i := range appCfg.ModelList {
- appCfg.ModelList[i].AuthMethod = ""
- }
- // Clear all AuthMethods in Providers (legacy)
- appCfg.Providers.OpenAI.AuthMethod = ""
- appCfg.Providers.Anthropic.AuthMethod = ""
- appCfg.Providers.Antigravity.AuthMethod = ""
- config.SaveConfig(getConfigPath(), appCfg)
- }
-
- fmt.Println("Logged out from all providers")
+ return nil
}
+
+ if err := auth.DeleteAllCredentials(); err != nil {
+ return fmt.Errorf("failed to remove credentials: %w", err)
+ }
+
+ appCfg, err := internal.LoadConfig()
+ if err == nil {
+ // Clear all AuthMethods in ModelList
+ for i := range appCfg.ModelList {
+ appCfg.ModelList[i].AuthMethod = ""
+ }
+ // Clear all AuthMethods in Providers (legacy)
+ appCfg.Providers.OpenAI.AuthMethod = ""
+ appCfg.Providers.Anthropic.AuthMethod = ""
+ appCfg.Providers.Antigravity.AuthMethod = ""
+ config.SaveConfig(internal.GetConfigPath(), appCfg)
+ }
+
+ fmt.Println("Logged out from all providers")
+
+ return nil
}
-func authStatusCmd() {
+func authStatusCmd() error {
store, err := auth.LoadStore()
if err != nil {
- fmt.Printf("Error loading auth store: %v\n", err)
- return
+ return fmt.Errorf("failed to load auth store: %w", err)
}
if len(store.Credentials) == 0 {
fmt.Println("No authenticated providers.")
fmt.Println("Run: picoclaw auth login --provider ")
- return
+ return nil
}
fmt.Println("\nAuthenticated Providers:")
@@ -437,14 +361,16 @@ func authStatusCmd() {
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
}
}
+
+ return nil
}
-func authModelsCmd() {
+func authModelsCmd() error {
cred, err := auth.GetCredential("google-antigravity")
if err != nil || cred == nil {
- fmt.Println("Not logged in to Google Antigravity.")
- fmt.Println("Run: picoclaw auth login --provider google-antigravity")
- return
+ return fmt.Errorf(
+ "not logged in to Google Antigravity.\nrun: picoclaw auth login --provider google-antigravity",
+ )
}
// Refresh token if needed
@@ -459,21 +385,18 @@ func authModelsCmd() {
projectID := cred.ProjectID
if projectID == "" {
- fmt.Println("No project ID stored. Try logging in again.")
- return
+ return fmt.Errorf("no project id stored. Try logging in again")
}
fmt.Printf("Fetching models for project: %s\n\n", projectID)
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
if err != nil {
- fmt.Printf("Error fetching models: %v\n", err)
- return
+ return fmt.Errorf("error fetching models: %w", err)
}
if len(models) == 0 {
- fmt.Println("No models available.")
- return
+ return fmt.Errorf("no models available")
}
fmt.Println("Available Antigravity Models:")
@@ -489,6 +412,8 @@ func authModelsCmd() {
}
fmt.Printf(" %s %s\n", status, name)
}
+
+ return nil
}
// isAntigravityModel checks if a model string belongs to antigravity provider
diff --git a/cmd/picoclaw/internal/auth/login.go b/cmd/picoclaw/internal/auth/login.go
new file mode 100644
index 000000000..9a6d28d2f
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/login.go
@@ -0,0 +1,25 @@
+package auth
+
+import "github.com/spf13/cobra"
+
+func newLoginCommand() *cobra.Command {
+ var (
+ provider string
+ useDeviceCode bool
+ )
+
+ cmd := &cobra.Command{
+ Use: "login",
+ Short: "Login via OAuth or paste token",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return authLoginCmd(provider, useDeviceCode)
+ },
+ }
+
+ 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.MarkFlagRequired("provider")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/auth/login_test.go b/cmd/picoclaw/internal/auth/login_test.go
new file mode 100644
index 000000000..d6a03c25b
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/login_test.go
@@ -0,0 +1,29 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewLoginSubCommand(t *testing.T) {
+ cmd := newLoginCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "Login via OAuth or paste token", cmd.Short)
+
+ assert.True(t, cmd.HasFlags())
+
+ assert.NotNil(t, cmd.Flags().Lookup("device-code"))
+
+ providerFlag := cmd.Flags().Lookup("provider")
+ require.NotNil(t, providerFlag)
+
+ val, found := providerFlag.Annotations[cobra.BashCompOneRequiredFlag]
+ require.True(t, found)
+ require.NotEmpty(t, val)
+ assert.Equal(t, "true", val[0])
+}
diff --git a/cmd/picoclaw/internal/auth/logout.go b/cmd/picoclaw/internal/auth/logout.go
new file mode 100644
index 000000000..384667524
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/logout.go
@@ -0,0 +1,20 @@
+package auth
+
+import "github.com/spf13/cobra"
+
+func newLogoutCommand() *cobra.Command {
+ var provider string
+
+ cmd := &cobra.Command{
+ Use: "logout",
+ Short: "Remove stored credentials",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return authLogoutCmd(provider)
+ },
+ }
+
+ cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to logout from (openai, anthropic); empty = all")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/auth/logout_test.go b/cmd/picoclaw/internal/auth/logout_test.go
new file mode 100644
index 000000000..c0f3a5e92
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/logout_test.go
@@ -0,0 +1,20 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewLogoutSubcommand(t *testing.T) {
+ cmd := newLogoutCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "Remove stored credentials", cmd.Short)
+
+ assert.True(t, cmd.HasFlags())
+
+ assert.NotNil(t, cmd.Flags().Lookup("provider"))
+}
diff --git a/cmd/picoclaw/internal/auth/models.go b/cmd/picoclaw/internal/auth/models.go
new file mode 100644
index 000000000..cabe6822c
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/models.go
@@ -0,0 +1,15 @@
+package auth
+
+import "github.com/spf13/cobra"
+
+func newModelsCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "models",
+ Short: "Show available models",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return authModelsCmd()
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/auth/models_test.go b/cmd/picoclaw/internal/auth/models_test.go
new file mode 100644
index 000000000..26ca67787
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/models_test.go
@@ -0,0 +1,19 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewModelsCommand(t *testing.T) {
+ cmd := newModelsCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "models", cmd.Use)
+ assert.Equal(t, "Show available models", cmd.Short)
+
+ assert.False(t, cmd.HasFlags())
+}
diff --git a/cmd/picoclaw/internal/auth/status.go b/cmd/picoclaw/internal/auth/status.go
new file mode 100644
index 000000000..ca3007d12
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/status.go
@@ -0,0 +1,16 @@
+package auth
+
+import "github.com/spf13/cobra"
+
+func newStatusCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "status",
+ Short: "Show current auth status",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return authStatusCmd()
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/auth/status_test.go b/cmd/picoclaw/internal/auth/status_test.go
new file mode 100644
index 000000000..7748ba502
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/status_test.go
@@ -0,0 +1,18 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewStatusSubcommand(t *testing.T) {
+ cmd := newStatusCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "Show current auth status", cmd.Short)
+
+ assert.False(t, cmd.HasFlags())
+}
diff --git a/cmd/picoclaw/internal/cron/add.go b/cmd/picoclaw/internal/cron/add.go
new file mode 100644
index 000000000..947557d5a
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/add.go
@@ -0,0 +1,64 @@
+package cron
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/cron"
+)
+
+func newAddCommand(storePath func() string) *cobra.Command {
+ var (
+ name string
+ message string
+ every int64
+ cronExp string
+ deliver bool
+ channel string
+ to string
+ )
+
+ cmd := &cobra.Command{
+ Use: "add",
+ Short: "Add a new scheduled job",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ if every <= 0 && cronExp == "" {
+ return fmt.Errorf("either --every or --cron must be specified")
+ }
+
+ var schedule cron.CronSchedule
+ if every > 0 {
+ everyMS := every * 1000
+ schedule = cron.CronSchedule{Kind: "every", EveryMS: &everyMS}
+ } else {
+ schedule = cron.CronSchedule{Kind: "cron", Expr: cronExp}
+ }
+
+ cs := cron.NewCronService(storePath(), nil)
+ job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
+ if err != nil {
+ return fmt.Errorf("error adding job: %w", err)
+ }
+
+ fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
+
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVarP(&name, "name", "n", "", "Job name")
+ cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
+ cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
+ cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
+ cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel")
+ cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
+ cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
+
+ _ = cmd.MarkFlagRequired("name")
+ _ = cmd.MarkFlagRequired("message")
+ cmd.MarkFlagsMutuallyExclusive("every", "cron")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/cron/add_test.go b/cmd/picoclaw/internal/cron/add_test.go
new file mode 100644
index 000000000..09701fab5
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/add_test.go
@@ -0,0 +1,57 @@
+package cron
+
+import (
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewAddSubcommand(t *testing.T) {
+ fn := func() string { return "" }
+ cmd := newAddCommand(fn)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "add", cmd.Use)
+ assert.Equal(t, "Add a new scheduled job", cmd.Short)
+
+ assert.True(t, cmd.HasFlags())
+
+ assert.NotNil(t, cmd.Flags().Lookup("every"))
+ assert.NotNil(t, cmd.Flags().Lookup("cron"))
+ assert.NotNil(t, cmd.Flags().Lookup("deliver"))
+ assert.NotNil(t, cmd.Flags().Lookup("to"))
+ assert.NotNil(t, cmd.Flags().Lookup("channel"))
+
+ nameFlag := cmd.Flags().Lookup("name")
+ require.NotNil(t, nameFlag)
+
+ messageFlag := cmd.Flags().Lookup("message")
+ require.NotNil(t, messageFlag)
+
+ val, found := nameFlag.Annotations[cobra.BashCompOneRequiredFlag]
+ require.True(t, found)
+ require.NotEmpty(t, val)
+ assert.Equal(t, "true", val[0])
+
+ val, found = messageFlag.Annotations[cobra.BashCompOneRequiredFlag]
+ require.True(t, found)
+ require.NotEmpty(t, val)
+ assert.Equal(t, "true", val[0])
+}
+
+func TestNewAddCommandEveryAndCronMutuallyExclusive(t *testing.T) {
+ cmd := newAddCommand(func() string { return "testing" })
+
+ cmd.SetArgs([]string{
+ "--name", "job",
+ "--message", "hello",
+ "--every", "10",
+ "--cron", "0 9 * * *",
+ })
+
+ err := cmd.Execute()
+ require.Error(t, err)
+}
diff --git a/cmd/picoclaw/internal/cron/command.go b/cmd/picoclaw/internal/cron/command.go
new file mode 100644
index 000000000..39f8ccf28
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/command.go
@@ -0,0 +1,44 @@
+package cron
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+)
+
+func NewCronCommand() *cobra.Command {
+ var storePath string
+
+ cmd := &cobra.Command{
+ Use: "cron",
+ Aliases: []string{"c"},
+ Short: "Manage scheduled tasks",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return cmd.Help()
+ },
+ // Resolve storePath at execution time so it reflects the current config
+ // and is shared across all subcommands.
+ PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
+ cfg, err := internal.LoadConfig()
+ if err != nil {
+ return fmt.Errorf("error loading config: %w", err)
+ }
+ storePath = filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
+ return nil
+ },
+ }
+
+ cmd.AddCommand(
+ newListCommand(func() string { return storePath }),
+ newAddCommand(func() string { return storePath }),
+ newRemoveCommand(func() string { return storePath }),
+ newEnableCommand(func() string { return storePath }),
+ newDisableCommand(func() string { return storePath }),
+ )
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/cron/command_test.go b/cmd/picoclaw/internal/cron/command_test.go
new file mode 100644
index 000000000..af2ac83ae
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/command_test.go
@@ -0,0 +1,58 @@
+package cron
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewCronCommand(t *testing.T) {
+ cmd := NewCronCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "Manage scheduled tasks", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 1)
+ assert.True(t, cmd.HasAlias("c"))
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.NotNil(t, cmd.PersistentPreRunE)
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.True(t, cmd.HasSubCommands())
+
+ allowedCommands := []string{
+ "list",
+ "add",
+ "remove",
+ "enable",
+ "disable",
+ }
+
+ subcommands := cmd.Commands()
+ assert.Len(t, subcommands, len(allowedCommands))
+
+ for _, subcmd := range subcommands {
+ found := slices.Contains(allowedCommands, subcmd.Name())
+ assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
+
+ assert.Len(t, subcmd.Aliases, 0)
+ assert.False(t, subcmd.Hidden)
+
+ assert.False(t, subcmd.HasSubCommands())
+
+ assert.Nil(t, subcmd.Run)
+ assert.NotNil(t, subcmd.RunE)
+
+ assert.Nil(t, subcmd.PersistentPreRun)
+ assert.Nil(t, subcmd.PersistentPostRun)
+ }
+}
diff --git a/cmd/picoclaw/internal/cron/disable.go b/cmd/picoclaw/internal/cron/disable.go
new file mode 100644
index 000000000..a3670fd50
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/disable.go
@@ -0,0 +1,16 @@
+package cron
+
+import "github.com/spf13/cobra"
+
+func newDisableCommand(storePath func() string) *cobra.Command {
+ return &cobra.Command{
+ Use: "disable",
+ Short: "Disable a job",
+ Args: cobra.ExactArgs(1),
+ Example: `picoclaw cron disable 1`,
+ RunE: func(_ *cobra.Command, args []string) error {
+ cronSetJobEnabled(storePath(), args[0], false)
+ return nil
+ },
+ }
+}
diff --git a/cmd/picoclaw/internal/cron/disable_test.go b/cmd/picoclaw/internal/cron/disable_test.go
new file mode 100644
index 000000000..e5d2ff844
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/disable_test.go
@@ -0,0 +1,20 @@
+package cron
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDisableSubcommand(t *testing.T) {
+ fn := func() string { return "" }
+ cmd := newDisableCommand(fn)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "disable", cmd.Use)
+ assert.Equal(t, "Disable a job", cmd.Short)
+
+ assert.True(t, cmd.HasExample())
+}
diff --git a/cmd/picoclaw/internal/cron/enable.go b/cmd/picoclaw/internal/cron/enable.go
new file mode 100644
index 000000000..7f8b05233
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/enable.go
@@ -0,0 +1,16 @@
+package cron
+
+import "github.com/spf13/cobra"
+
+func newEnableCommand(storePath func() string) *cobra.Command {
+ return &cobra.Command{
+ Use: "enable",
+ Short: "Enable a job",
+ Args: cobra.ExactArgs(1),
+ Example: `picoclaw cron enable 1`,
+ RunE: func(_ *cobra.Command, args []string) error {
+ cronSetJobEnabled(storePath(), args[0], true)
+ return nil
+ },
+ }
+}
diff --git a/cmd/picoclaw/internal/cron/enable_test.go b/cmd/picoclaw/internal/cron/enable_test.go
new file mode 100644
index 000000000..85a2e01aa
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/enable_test.go
@@ -0,0 +1,20 @@
+package cron
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEnableSubcommand(t *testing.T) {
+ fn := func() string { return "" }
+ cmd := newEnableCommand(fn)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "enable", cmd.Use)
+ assert.Equal(t, "Enable a job", cmd.Short)
+
+ assert.True(t, cmd.HasExample())
+}
diff --git a/cmd/picoclaw/internal/cron/helpers.go b/cmd/picoclaw/internal/cron/helpers.go
new file mode 100644
index 000000000..88bdf1bf7
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/helpers.go
@@ -0,0 +1,66 @@
+package cron
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/cron"
+)
+
+func cronListCmd(storePath string) {
+ cs := cron.NewCronService(storePath, nil)
+ jobs := cs.ListJobs(true) // Show all jobs, including disabled
+
+ if len(jobs) == 0 {
+ fmt.Println("No scheduled jobs.")
+ return
+ }
+
+ fmt.Println("\nScheduled Jobs:")
+ fmt.Println("----------------")
+ for _, job := range jobs {
+ var schedule string
+ if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
+ schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
+ } else if job.Schedule.Kind == "cron" {
+ schedule = job.Schedule.Expr
+ } else {
+ schedule = "one-time"
+ }
+
+ nextRun := "scheduled"
+ if job.State.NextRunAtMS != nil {
+ nextTime := time.UnixMilli(*job.State.NextRunAtMS)
+ nextRun = nextTime.Format("2006-01-02 15:04")
+ }
+
+ status := "enabled"
+ if !job.Enabled {
+ status = "disabled"
+ }
+
+ fmt.Printf(" %s (%s)\n", job.Name, job.ID)
+ fmt.Printf(" Schedule: %s\n", schedule)
+ fmt.Printf(" Status: %s\n", status)
+ fmt.Printf(" Next run: %s\n", nextRun)
+ }
+}
+
+func cronRemoveCmd(storePath, jobID string) {
+ cs := cron.NewCronService(storePath, nil)
+ if cs.RemoveJob(jobID) {
+ fmt.Printf("✓ Removed job %s\n", jobID)
+ } else {
+ fmt.Printf("✗ Job %s not found\n", jobID)
+ }
+}
+
+func cronSetJobEnabled(storePath, jobID string, enabled bool) {
+ cs := cron.NewCronService(storePath, nil)
+ job := cs.EnableJob(jobID, enabled)
+ if job != nil {
+ fmt.Printf("✓ Job '%s' enabled\n", job.Name)
+ } else {
+ fmt.Printf("✗ Job %s not found\n", jobID)
+ }
+}
diff --git a/cmd/picoclaw/internal/cron/list.go b/cmd/picoclaw/internal/cron/list.go
new file mode 100644
index 000000000..854eb1a44
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/list.go
@@ -0,0 +1,17 @@
+package cron
+
+import "github.com/spf13/cobra"
+
+func newListCommand(storePath func() string) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List all scheduled jobs",
+ Args: cobra.NoArgs,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ cronListCmd(storePath())
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/cron/list_test.go b/cmd/picoclaw/internal/cron/list_test.go
new file mode 100644
index 000000000..0b9d1bd59
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/list_test.go
@@ -0,0 +1,17 @@
+package cron
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewListSubcommand(t *testing.T) {
+ fn := func() string { return "" }
+ cmd := newListCommand(fn)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "List all scheduled jobs", cmd.Short)
+}
diff --git a/cmd/picoclaw/internal/cron/remove.go b/cmd/picoclaw/internal/cron/remove.go
new file mode 100644
index 000000000..5f1d1a04b
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/remove.go
@@ -0,0 +1,18 @@
+package cron
+
+import "github.com/spf13/cobra"
+
+func newRemoveCommand(storePath func() string) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "remove",
+ Short: "Remove a job by ID",
+ Args: cobra.ExactArgs(1),
+ Example: `picoclaw cron remove 1`,
+ RunE: func(_ *cobra.Command, args []string) error {
+ cronRemoveCmd(storePath(), args[0])
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/cron/remove_test.go b/cmd/picoclaw/internal/cron/remove_test.go
new file mode 100644
index 000000000..36121f370
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/remove_test.go
@@ -0,0 +1,19 @@
+package cron
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewRemoveSubcommand(t *testing.T) {
+ fn := func() string { return "" }
+ cmd := newRemoveCommand(fn)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "Remove a job by ID", cmd.Short)
+
+ assert.True(t, cmd.HasExample())
+}
diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go
new file mode 100644
index 000000000..66a56f9ce
--- /dev/null
+++ b/cmd/picoclaw/internal/gateway/command.go
@@ -0,0 +1,23 @@
+package gateway
+
+import (
+ "github.com/spf13/cobra"
+)
+
+func NewGatewayCommand() *cobra.Command {
+ var debug bool
+
+ cmd := &cobra.Command{
+ Use: "gateway",
+ Aliases: []string{"g"},
+ Short: "Start picoclaw gateway",
+ Args: cobra.NoArgs,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return gatewayCmd(debug)
+ },
+ }
+
+ cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/gateway/command_test.go b/cmd/picoclaw/internal/gateway/command_test.go
new file mode 100644
index 000000000..4d591ea67
--- /dev/null
+++ b/cmd/picoclaw/internal/gateway/command_test.go
@@ -0,0 +1,31 @@
+package gateway
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewGatewayCommand(t *testing.T) {
+ cmd := NewGatewayCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "gateway", cmd.Use)
+ assert.Equal(t, "Start picoclaw gateway", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 1)
+ assert.True(t, cmd.HasAlias("g"))
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.True(t, cmd.HasFlags())
+ assert.NotNil(t, cmd.Flags().Lookup("debug"))
+}
diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/internal/gateway/helpers.go
similarity index 69%
rename from cmd/picoclaw/cmd_gateway.go
rename to cmd/picoclaw/internal/gateway/helpers.go
index 28ef76ad3..baa489b92 100644
--- a/cmd/picoclaw/cmd_gateway.go
+++ b/cmd/picoclaw/internal/gateway/helpers.go
@@ -1,58 +1,61 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package gateway
import (
"context"
"fmt"
- "net/http"
"os"
"os/signal"
"path/filepath"
- "strings"
"time"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
+ _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
+ _ "github.com/sipeed/picoclaw/pkg/channels/discord"
+ _ "github.com/sipeed/picoclaw/pkg/channels/feishu"
+ _ "github.com/sipeed/picoclaw/pkg/channels/line"
+ _ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
+ _ "github.com/sipeed/picoclaw/pkg/channels/onebot"
+ _ "github.com/sipeed/picoclaw/pkg/channels/pico"
+ _ "github.com/sipeed/picoclaw/pkg/channels/qq"
+ _ "github.com/sipeed/picoclaw/pkg/channels/slack"
+ _ "github.com/sipeed/picoclaw/pkg/channels/telegram"
+ _ "github.com/sipeed/picoclaw/pkg/channels/wecom"
+ _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
+ _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/devices"
"github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
- "github.com/sipeed/picoclaw/pkg/voice"
)
-func gatewayCmd() {
- // Check for --debug flag
- args := os.Args[2:]
- for _, arg := range args {
- if arg == "--debug" || arg == "-d" {
- logger.SetLevel(logger.DEBUG)
- fmt.Println("🔍 Debug mode enabled")
- break
- }
+func gatewayCmd(debug bool) error {
+ if debug {
+ logger.SetLevel(logger.DEBUG)
+ fmt.Println("🔍 Debug mode enabled")
}
- cfg, err := loadConfig()
+ cfg, err := internal.LoadConfig()
if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("error loading config: %w", err)
}
provider, modelID, err := providers.CreateProvider(cfg)
if err != nil {
- fmt.Printf("Error creating provider: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("error creating provider: %w", err)
}
+
// Use the resolved model ID from provider creation
if modelID != "" {
- cfg.Agents.Defaults.Model = modelID
+ cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
@@ -112,50 +115,23 @@ func gatewayCmd() {
return tools.SilentResult(response)
})
- channelManager, err := channels.NewManager(cfg, msgBus)
+ // Create media store for file lifecycle management with TTL cleanup
+ mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
+ Enabled: cfg.Tools.MediaCleanup.Enabled,
+ MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
+ Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
+ })
+ mediaStore.Start()
+
+ channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
if err != nil {
- fmt.Printf("Error creating channel manager: %v\n", err)
- os.Exit(1)
+ mediaStore.Stop()
+ return fmt.Errorf("error creating channel manager: %w", err)
}
- // Inject channel manager into agent loop for command handling
+ // Inject channel manager and media store into agent loop
agentLoop.SetChannelManager(channelManager)
-
- var transcriber *voice.GroqTranscriber
- groqAPIKey := cfg.Providers.Groq.APIKey
- if groqAPIKey == "" {
- for _, mc := range cfg.ModelList {
- if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
- groqAPIKey = mc.APIKey
- break
- }
- }
- }
- if groqAPIKey != "" {
- transcriber = voice.NewGroqTranscriber(groqAPIKey)
- logger.InfoC("voice", "Groq voice transcription enabled")
- }
-
- if transcriber != nil {
- if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
- if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
- tc.SetTranscriber(transcriber)
- logger.InfoC("voice", "Groq transcription attached to Telegram channel")
- }
- }
- if discordChannel, ok := channelManager.GetChannel("discord"); ok {
- if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
- dc.SetTranscriber(transcriber)
- logger.InfoC("voice", "Groq transcription attached to Discord channel")
- }
- }
- if slackChannel, ok := channelManager.GetChannel("slack"); ok {
- if sc, ok := slackChannel.(*channels.SlackChannel); ok {
- sc.SetTranscriber(transcriber)
- logger.InfoC("voice", "Groq transcription attached to Slack channel")
- }
- }
- }
+ agentLoop.SetMediaStore(mediaStore)
enabledChannels := channelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
@@ -192,16 +168,15 @@ func gatewayCmd() {
fmt.Println("✓ Device event service started")
}
+ // Setup shared HTTP server with health endpoints and webhook handlers
+ healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
+ addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
+ channelManager.SetupHTTPServer(addr, healthServer)
+
if err := channelManager.StartAll(ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err)
}
- healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
- go func() {
- if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
- }
- }()
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
go agentLoop.Run(ctx)
@@ -211,14 +186,26 @@ func gatewayCmd() {
<-sigChan
fmt.Println("\nShutting down...")
+ if cp, ok := provider.(providers.StatefulProvider); ok {
+ cp.Close()
+ }
cancel()
- healthServer.Stop(context.Background())
+ msgBus.Close()
+
+ // Use a fresh context with timeout for graceful shutdown,
+ // since the original ctx is already canceled.
+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer shutdownCancel()
+
+ channelManager.StopAll(shutdownCtx)
deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
+ mediaStore.Stop()
agentLoop.Stop()
- channelManager.StopAll(ctx)
fmt.Println("✓ Gateway stopped")
+
+ return nil
}
func setupCronTool(
diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go
new file mode 100644
index 000000000..1f52df5dd
--- /dev/null
+++ b/cmd/picoclaw/internal/helpers.go
@@ -0,0 +1,52 @@
+package internal
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+const Logo = "🦞"
+
+var (
+ version = "dev"
+ gitCommit string
+ buildTime string
+ goVersion string
+)
+
+func GetConfigPath() string {
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".picoclaw", "config.json")
+}
+
+func LoadConfig() (*config.Config, error) {
+ return config.LoadConfig(GetConfigPath())
+}
+
+// FormatVersion returns the version string with optional git commit
+func FormatVersion() string {
+ v := version
+ if gitCommit != "" {
+ v += fmt.Sprintf(" (git: %s)", gitCommit)
+ }
+ return v
+}
+
+// FormatBuildInfo returns build time and go version info
+func FormatBuildInfo() (string, string) {
+ build := buildTime
+ goVer := goVersion
+ if goVer == "" {
+ goVer = runtime.Version()
+ }
+ return build, goVer
+}
+
+// GetVersion returns the version string
+func GetVersion() string {
+ return version
+}
diff --git a/cmd/picoclaw/internal/helpers_test.go b/cmd/picoclaw/internal/helpers_test.go
new file mode 100644
index 000000000..9342d141d
--- /dev/null
+++ b/cmd/picoclaw/internal/helpers_test.go
@@ -0,0 +1,97 @@
+package internal
+
+import (
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetConfigPath(t *testing.T) {
+ t.Setenv("HOME", "/tmp/home")
+
+ got := GetConfigPath()
+ want := filepath.Join("/tmp/home", ".picoclaw", "config.json")
+
+ assert.Equal(t, want, got)
+}
+
+func TestFormatVersion_NoGitCommit(t *testing.T) {
+ oldVersion, oldGit := version, gitCommit
+ t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
+
+ version = "1.2.3"
+ gitCommit = ""
+
+ assert.Equal(t, "1.2.3", FormatVersion())
+}
+
+func TestFormatVersion_WithGitCommit(t *testing.T) {
+ oldVersion, oldGit := version, gitCommit
+ t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
+
+ version = "1.2.3"
+ gitCommit = "abc123"
+
+ assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
+}
+
+func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
+ oldBuildTime, oldGoVersion := buildTime, goVersion
+ t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
+
+ buildTime = "2026-02-20T00:00:00Z"
+ goVersion = "go1.23.0"
+
+ build, goVer := FormatBuildInfo()
+
+ assert.Equal(t, buildTime, build)
+ assert.Equal(t, goVersion, goVer)
+}
+
+func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
+ oldBuildTime, oldGoVersion := buildTime, goVersion
+ t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
+
+ buildTime = ""
+ goVersion = "go1.23.0"
+
+ build, goVer := FormatBuildInfo()
+
+ assert.Empty(t, build)
+ assert.Equal(t, goVersion, goVer)
+}
+
+func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
+ oldBuildTime, oldGoVersion := buildTime, goVersion
+ t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
+
+ buildTime = "x"
+ goVersion = ""
+
+ build, goVer := FormatBuildInfo()
+
+ assert.Equal(t, "x", build)
+ assert.Equal(t, runtime.Version(), goVer)
+}
+
+func TestGetConfigPath_Windows(t *testing.T) {
+ if runtime.GOOS != "windows" {
+ t.Skip("windows-specific HOME behavior varies; run on windows")
+ }
+
+ testUserProfilePath := `C:\Users\Test`
+ t.Setenv("USERPROFILE", testUserProfilePath)
+
+ got := GetConfigPath()
+ want := filepath.Join(testUserProfilePath, ".picoclaw", "config.json")
+
+ require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
+}
+
+func TestGetVersion(t *testing.T) {
+ assert.Equal(t, "dev", GetVersion())
+}
diff --git a/cmd/picoclaw/internal/migrate/command.go b/cmd/picoclaw/internal/migrate/command.go
new file mode 100644
index 000000000..fb1cee164
--- /dev/null
+++ b/cmd/picoclaw/internal/migrate/command.go
@@ -0,0 +1,48 @@
+package migrate
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/migrate"
+)
+
+func NewMigrateCommand() *cobra.Command {
+ var opts migrate.Options
+
+ cmd := &cobra.Command{
+ Use: "migrate",
+ Short: "Migrate from OpenClaw to PicoClaw",
+ Args: cobra.NoArgs,
+ Example: ` picoclaw migrate
+ picoclaw migrate --dry-run
+ picoclaw migrate --refresh
+ picoclaw migrate --force`,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ result, err := migrate.Run(opts)
+ if err != nil {
+ return err
+ }
+ if !opts.DryRun {
+ migrate.PrintSummary(result)
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
+ "Show what would be migrated without making changes")
+ cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
+ "Re-sync workspace files from OpenClaw (repeatable)")
+ cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
+ "Only migrate config, skip workspace files")
+ cmd.Flags().BoolVar(&opts.WorkspaceOnly, "workspace-only", false,
+ "Only migrate workspace files, skip config")
+ cmd.Flags().BoolVar(&opts.Force, "force", false,
+ "Skip confirmation prompts")
+ cmd.Flags().StringVar(&opts.OpenClawHome, "openclaw-home", "",
+ "Override OpenClaw home directory (default: ~/.openclaw)")
+ cmd.Flags().StringVar(&opts.PicoClawHome, "picoclaw-home", "",
+ "Override PicoClaw home directory (default: ~/.picoclaw)")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/migrate/command_test.go b/cmd/picoclaw/internal/migrate/command_test.go
new file mode 100644
index 000000000..1948aa327
--- /dev/null
+++ b/cmd/picoclaw/internal/migrate/command_test.go
@@ -0,0 +1,38 @@
+package migrate
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewMigrateCommand(t *testing.T) {
+ cmd := NewMigrateCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "migrate", cmd.Use)
+ assert.Equal(t, "Migrate from OpenClaw to PicoClaw", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 0)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.True(t, cmd.HasFlags())
+
+ assert.NotNil(t, cmd.Flags().Lookup("dry-run"))
+ assert.NotNil(t, cmd.Flags().Lookup("refresh"))
+ assert.NotNil(t, cmd.Flags().Lookup("config-only"))
+ assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
+ assert.NotNil(t, cmd.Flags().Lookup("force"))
+ assert.NotNil(t, cmd.Flags().Lookup("openclaw-home"))
+ assert.NotNil(t, cmd.Flags().Lookup("picoclaw-home"))
+}
diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go
new file mode 100644
index 000000000..ec1012959
--- /dev/null
+++ b/cmd/picoclaw/internal/onboard/command.go
@@ -0,0 +1,24 @@
+package onboard
+
+import (
+ "embed"
+
+ "github.com/spf13/cobra"
+)
+
+//go:generate cp -r ../../../../workspace .
+//go:embed workspace
+var embeddedFiles embed.FS
+
+func NewOnboardCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "onboard",
+ Aliases: []string{"o"},
+ Short: "Initialize picoclaw configuration and workspace",
+ Run: func(cmd *cobra.Command, args []string) {
+ onboard()
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/onboard/command_test.go b/cmd/picoclaw/internal/onboard/command_test.go
new file mode 100644
index 000000000..bc799a079
--- /dev/null
+++ b/cmd/picoclaw/internal/onboard/command_test.go
@@ -0,0 +1,29 @@
+package onboard
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewOnboardCommand(t *testing.T) {
+ cmd := NewOnboardCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "onboard", cmd.Use)
+ assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 1)
+ assert.True(t, cmd.HasAlias("o"))
+
+ assert.NotNil(t, cmd.Run)
+ assert.Nil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.False(t, cmd.HasFlags())
+ assert.False(t, cmd.HasSubCommands())
+}
diff --git a/cmd/picoclaw/cmd_onboard.go b/cmd/picoclaw/internal/onboard/helpers.go
similarity index 90%
rename from cmd/picoclaw/cmd_onboard.go
rename to cmd/picoclaw/internal/onboard/helpers.go
index 1a9ebad61..4db8bdc8b 100644
--- a/cmd/picoclaw/cmd_onboard.go
+++ b/cmd/picoclaw/internal/onboard/helpers.go
@@ -1,24 +1,17 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package onboard
import (
- "embed"
"fmt"
"io/fs"
"os"
"path/filepath"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
-//go:generate cp -r ../../workspace .
-//go:embed workspace
-var embeddedFiles embed.FS
-
func onboard() {
- configPath := getConfigPath()
+ configPath := internal.GetConfigPath()
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Config already exists at %s\n", configPath)
@@ -40,7 +33,7 @@ func onboard() {
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)
- fmt.Printf("%s picoclaw is ready!\n", logo)
+ fmt.Printf("%s picoclaw is ready!\n", internal.Logo)
fmt.Println("\nNext steps:")
fmt.Println(" 1. Add your API key to", configPath)
fmt.Println("")
@@ -53,6 +46,13 @@ func onboard() {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
+func createWorkspaceTemplates(workspace string) {
+ err := copyEmbeddedToTarget(workspace)
+ if err != nil {
+ fmt.Printf("Error copying workspace templates: %v\n", err)
+ }
+}
+
func copyEmbeddedToTarget(targetDir string) error {
// Ensure target directory exists
if err := os.MkdirAll(targetDir, 0o755); err != nil {
@@ -99,10 +99,3 @@ func copyEmbeddedToTarget(targetDir string) error {
return err
}
-
-func createWorkspaceTemplates(workspace string) {
- err := copyEmbeddedToTarget(workspace)
- if err != nil {
- fmt.Printf("Error copying workspace templates: %v\n", err)
- }
-}
diff --git a/cmd/picoclaw/internal/skills/command.go b/cmd/picoclaw/internal/skills/command.go
new file mode 100644
index 000000000..7f8bd011d
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/command.go
@@ -0,0 +1,79 @@
+package skills
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+type deps struct {
+ workspace string
+ installer *skills.SkillInstaller
+ skillsLoader *skills.SkillsLoader
+}
+
+func NewSkillsCommand() *cobra.Command {
+ var d deps
+
+ cmd := &cobra.Command{
+ Use: "skills",
+ Short: "Manage skills",
+ PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
+ cfg, err := internal.LoadConfig()
+ if err != nil {
+ return fmt.Errorf("error loading config: %w", err)
+ }
+
+ d.workspace = cfg.WorkspacePath()
+ d.installer = skills.NewSkillInstaller(d.workspace)
+
+ // get global config directory and builtin skills directory
+ globalDir := filepath.Dir(internal.GetConfigPath())
+ globalSkillsDir := filepath.Join(globalDir, "skills")
+ builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
+ d.skillsLoader = skills.NewSkillsLoader(d.workspace, globalSkillsDir, builtinSkillsDir)
+
+ return nil
+ },
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return cmd.Help()
+ },
+ }
+
+ installerFn := func() (*skills.SkillInstaller, error) {
+ if d.installer == nil {
+ return nil, fmt.Errorf("skills installer is not initialized")
+ }
+ return d.installer, nil
+ }
+
+ loaderFn := func() (*skills.SkillsLoader, error) {
+ if d.skillsLoader == nil {
+ return nil, fmt.Errorf("skills loader is not initialized")
+ }
+ return d.skillsLoader, nil
+ }
+
+ workspaceFn := func() (string, error) {
+ if d.workspace == "" {
+ return "", fmt.Errorf("workspace is not initialized")
+ }
+ return d.workspace, nil
+ }
+
+ cmd.AddCommand(
+ newListCommand(loaderFn),
+ newInstallCommand(installerFn),
+ newInstallBuiltinCommand(workspaceFn),
+ newListBuiltinCommand(),
+ newRemoveCommand(installerFn),
+ newSearchCommand(installerFn),
+ newShowCommand(loaderFn),
+ )
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/command_test.go b/cmd/picoclaw/internal/skills/command_test.go
new file mode 100644
index 000000000..0917d1384
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/command_test.go
@@ -0,0 +1,28 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewSkillsCommand(t *testing.T) {
+ cmd := NewSkillsCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "skills", cmd.Use)
+ assert.Equal(t, "Manage skills", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 0)
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.NotNil(t, cmd.PersistentPreRunE)
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+}
diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/internal/skills/helpers.go
similarity index 72%
rename from cmd/picoclaw/cmd_skills.go
rename to cmd/picoclaw/internal/skills/helpers.go
index 0814494b3..439b81a4f 100644
--- a/cmd/picoclaw/cmd_skills.go
+++ b/cmd/picoclaw/internal/skills/helpers.go
@@ -1,40 +1,20 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package skills
import (
"context"
"fmt"
+ "io"
"os"
"path/filepath"
"strings"
"time"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
)
-func skillsHelp() {
- fmt.Println("\nSkills commands:")
- fmt.Println(" list List installed skills")
- fmt.Println(" install Install skill from GitHub")
- fmt.Println(" install-builtin Install all builtin skills to workspace")
- fmt.Println(" list-builtin List available builtin skills")
- fmt.Println(" remove Remove installed skill")
- fmt.Println(" search Search available skills")
- fmt.Println(" show Show skill details")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw skills list")
- fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
- fmt.Println(" picoclaw skills install-builtin")
- fmt.Println(" picoclaw skills list-builtin")
- fmt.Println(" picoclaw skills remove weather")
- fmt.Println(" picoclaw skills install --registry clawhub github")
-}
-
func skillsListCmd(loader *skills.SkillsLoader) {
allSkills := loader.ListSkills()
@@ -53,53 +33,31 @@ func skillsListCmd(loader *skills.SkillsLoader) {
}
}
-func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) {
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw skills install ")
- fmt.Println(" picoclaw skills install --registry ")
- return
- }
-
- // Check for --registry flag.
- if os.Args[3] == "--registry" {
- if len(os.Args) < 6 {
- fmt.Println("Usage: picoclaw skills install --registry ")
- fmt.Println("Example: picoclaw skills install --registry clawhub github")
- return
- }
- registryName := os.Args[4]
- slug := os.Args[5]
- skillsInstallFromRegistry(cfg, registryName, slug)
- return
- }
-
- // Default: install from GitHub (backward compatible).
- repo := os.Args[3]
+func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
fmt.Printf("Installing skill from %s...\n", repo)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
- fmt.Printf("\u2717 Failed to install skill: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("failed to install skill: %w", err)
}
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
+
+ return nil
}
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
-func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
+func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
err := utils.ValidateSkillIdentifier(registryName)
if err != nil {
- fmt.Printf("\u2717 Invalid registry name: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("✗ invalid registry name: %w", err)
}
err = utils.ValidateSkillIdentifier(slug)
if err != nil {
- fmt.Printf("\u2717 Invalid slug: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("✗ invalid slug: %w", err)
}
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
@@ -111,24 +69,21 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
registry := registryMgr.GetRegistry(registryName)
if registry == nil {
- fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName)
- os.Exit(1)
+ return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
}
workspace := cfg.WorkspacePath()
targetDir := filepath.Join(workspace, "skills", slug)
if _, err = os.Stat(targetDir); err == nil {
- fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
- os.Exit(1)
+ return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
- fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
}
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
@@ -137,8 +92,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
if rmErr != nil {
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
}
- fmt.Printf("\u2717 Failed to install skill: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("✗ failed to install skill: %w", err)
}
if result.IsMalwareBlocked {
@@ -146,8 +100,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
if rmErr != nil {
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
}
- fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
- os.Exit(1)
+
+ return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
}
if result.IsSuspicious {
@@ -158,6 +112,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
if result.Summary != "" {
fmt.Printf(" %s\n", result.Summary)
}
+
+ return nil
}
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
@@ -208,7 +164,7 @@ func skillsInstallBuiltinCmd(workspace string) {
}
func skillsListBuiltinCmd() {
- cfg, err := loadConfig()
+ cfg, err := internal.LoadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
@@ -303,3 +259,37 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
fmt.Println("----------------------")
fmt.Println(content)
}
+
+func copyDirectory(src, dst string) error {
+ return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ relPath, err := filepath.Rel(src, path)
+ if err != nil {
+ return err
+ }
+
+ dstPath := filepath.Join(dst, relPath)
+
+ if info.IsDir() {
+ return os.MkdirAll(dstPath, info.Mode())
+ }
+
+ srcFile, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer srcFile.Close()
+
+ dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
+ if err != nil {
+ return err
+ }
+ defer dstFile.Close()
+
+ _, err = io.Copy(dstFile, srcFile)
+ return err
+ })
+}
diff --git a/cmd/picoclaw/internal/skills/install.go b/cmd/picoclaw/internal/skills/install.go
new file mode 100644
index 000000000..a30f68632
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/install.go
@@ -0,0 +1,58 @@
+package skills
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
+ var registry string
+
+ cmd := &cobra.Command{
+ Use: "install",
+ Short: "Install skill from GitHub",
+ Example: `
+picoclaw skills install sipeed/picoclaw-skills/weather
+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: ")
+ }
+ return nil
+ }
+
+ if len(args) != 1 {
+ return fmt.Errorf("exactly 1 argument is required: ")
+ }
+
+ return nil
+ },
+ RunE: func(_ *cobra.Command, args []string) error {
+ installer, err := installerFn()
+ if err != nil {
+ return err
+ }
+
+ if registry != "" {
+ cfg, err := internal.LoadConfig()
+ if err != nil {
+ return err
+ }
+
+ return skillsInstallFromRegistry(cfg, args[0], args[1])
+ }
+
+ return skillsInstallCmd(installer, args[0])
+ },
+ }
+
+ cmd.Flags().StringVar(®istry, "registry", "", "Install from registry: --registry ")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/install_test.go b/cmd/picoclaw/internal/skills/install_test.go
new file mode 100644
index 000000000..97787a986
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/install_test.go
@@ -0,0 +1,28 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewInstallSubcommand(t *testing.T) {
+ cmd := newInstallCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "install", cmd.Use)
+ assert.Equal(t, "Install skill from GitHub", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.True(t, cmd.HasFlags())
+ assert.NotNil(t, cmd.Flags().Lookup("registry"))
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/skills/installbuiltin.go b/cmd/picoclaw/internal/skills/installbuiltin.go
new file mode 100644
index 000000000..d4b7c6a9f
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/installbuiltin.go
@@ -0,0 +1,21 @@
+package skills
+
+import "github.com/spf13/cobra"
+
+func newInstallBuiltinCommand(workspaceFn func() (string, error)) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "install-builtin",
+ Short: "Install all builtin skills to workspace",
+ Example: `picoclaw skills install-builtin`,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ workspace, err := workspaceFn()
+ if err != nil {
+ return err
+ }
+ skillsInstallBuiltinCmd(workspace)
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/installbuiltin_test.go b/cmd/picoclaw/internal/skills/installbuiltin_test.go
new file mode 100644
index 000000000..ea65907e3
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/installbuiltin_test.go
@@ -0,0 +1,27 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewInstallbuiltinSubcommand(t *testing.T) {
+ cmd := newInstallBuiltinCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "install-builtin", cmd.Use)
+ assert.Equal(t, "Install all builtin skills to workspace", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/skills/list.go b/cmd/picoclaw/internal/skills/list.go
new file mode 100644
index 000000000..7d89ff8ed
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/list.go
@@ -0,0 +1,25 @@
+package skills
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+func newListCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List installed skills",
+ Example: `picoclaw skills list`,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ loader, err := loaderFn()
+ if err != nil {
+ return err
+ }
+ skillsListCmd(loader)
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/list_test.go b/cmd/picoclaw/internal/skills/list_test.go
new file mode 100644
index 000000000..9947ce7aa
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/list_test.go
@@ -0,0 +1,27 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewListSubcommand(t *testing.T) {
+ cmd := newListCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "list", cmd.Use)
+ assert.Equal(t, "List installed skills", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/skills/listbuiltin.go b/cmd/picoclaw/internal/skills/listbuiltin.go
new file mode 100644
index 000000000..a3efb8d83
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/listbuiltin.go
@@ -0,0 +1,16 @@
+package skills
+
+import "github.com/spf13/cobra"
+
+func newListBuiltinCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list-builtin",
+ Short: "List available builtin skills",
+ Example: `picoclaw skills list-builtin`,
+ Run: func(_ *cobra.Command, _ []string) {
+ skillsListBuiltinCmd()
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/listbuiltin_test.go b/cmd/picoclaw/internal/skills/listbuiltin_test.go
new file mode 100644
index 000000000..d4f45a436
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/listbuiltin_test.go
@@ -0,0 +1,26 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewListbuiltinSubcommand(t *testing.T) {
+ cmd := newListBuiltinCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "list-builtin", cmd.Use)
+ assert.Equal(t, "List available builtin skills", cmd.Short)
+
+ assert.NotNil(t, cmd.Run)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/skills/remove.go b/cmd/picoclaw/internal/skills/remove.go
new file mode 100644
index 000000000..cd7d3a8b4
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/remove.go
@@ -0,0 +1,27 @@
+package skills
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "remove",
+ Aliases: []string{"rm", "uninstall"},
+ Short: "Remove installed skill",
+ Args: cobra.ExactArgs(1),
+ Example: `picoclaw skills remove weather`,
+ RunE: func(_ *cobra.Command, args []string) error {
+ installer, err := installerFn()
+ if err != nil {
+ return err
+ }
+ skillsRemoveCmd(installer, args[0])
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/remove_test.go b/cmd/picoclaw/internal/skills/remove_test.go
new file mode 100644
index 000000000..b4c79760c
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/remove_test.go
@@ -0,0 +1,29 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewRemoveSubcommand(t *testing.T) {
+ cmd := newRemoveCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "remove", cmd.Use)
+ assert.Equal(t, "Remove installed skill", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 2)
+ assert.True(t, cmd.HasAlias("rm"))
+ assert.True(t, cmd.HasAlias("uninstall"))
+}
diff --git a/cmd/picoclaw/internal/skills/search.go b/cmd/picoclaw/internal/skills/search.go
new file mode 100644
index 000000000..53bc99109
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/search.go
@@ -0,0 +1,24 @@
+package skills
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+func newSearchCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "search",
+ Short: "Search available skills",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ installer, err := installerFn()
+ if err != nil {
+ return err
+ }
+ skillsSearchCmd(installer)
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/search_test.go b/cmd/picoclaw/internal/skills/search_test.go
new file mode 100644
index 000000000..19f63a9ff
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/search_test.go
@@ -0,0 +1,25 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewSearchSubcommand(t *testing.T) {
+ cmd := newSearchCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "search", cmd.Use)
+ assert.Equal(t, "Search available skills", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.False(t, cmd.HasSubCommands())
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/skills/show.go b/cmd/picoclaw/internal/skills/show.go
new file mode 100644
index 000000000..e484f3f28
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/show.go
@@ -0,0 +1,26 @@
+package skills
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+func newShowCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "show",
+ Short: "Show skill details",
+ Args: cobra.ExactArgs(1),
+ Example: `picoclaw skills show weather`,
+ RunE: func(_ *cobra.Command, args []string) error {
+ loader, err := loaderFn()
+ if err != nil {
+ return err
+ }
+ skillsShowCmd(loader, args[0])
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/show_test.go b/cmd/picoclaw/internal/skills/show_test.go
new file mode 100644
index 000000000..5858d2790
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/show_test.go
@@ -0,0 +1,27 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewShowSubcommand(t *testing.T) {
+ cmd := newShowCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "show", cmd.Use)
+ assert.Equal(t, "Show skill details", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/status/command.go b/cmd/picoclaw/internal/status/command.go
new file mode 100644
index 000000000..9303ae2ec
--- /dev/null
+++ b/cmd/picoclaw/internal/status/command.go
@@ -0,0 +1,18 @@
+package status
+
+import (
+ "github.com/spf13/cobra"
+)
+
+func NewStatusCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "status",
+ Aliases: []string{"s"},
+ Short: "Show picoclaw status",
+ Run: func(cmd *cobra.Command, args []string) {
+ statusCmd()
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/status/command_test.go b/cmd/picoclaw/internal/status/command_test.go
new file mode 100644
index 000000000..974b4ea3d
--- /dev/null
+++ b/cmd/picoclaw/internal/status/command_test.go
@@ -0,0 +1,29 @@
+package status
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewStatusCommand(t *testing.T) {
+ cmd := NewStatusCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "status", cmd.Use)
+
+ assert.Len(t, cmd.Aliases, 1)
+ assert.True(t, cmd.HasAlias("s"))
+
+ assert.Equal(t, "Show picoclaw status", cmd.Short)
+
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.NotNil(t, cmd.Run)
+ assert.Nil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+}
diff --git a/cmd/picoclaw/cmd_status.go b/cmd/picoclaw/internal/status/helpers.go
similarity index 88%
rename from cmd/picoclaw/cmd_status.go
rename to cmd/picoclaw/internal/status/helpers.go
index 07296784e..ab28f4885 100644
--- a/cmd/picoclaw/cmd_status.go
+++ b/cmd/picoclaw/internal/status/helpers.go
@@ -1,27 +1,25 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package status
import (
"fmt"
"os"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/auth"
)
func statusCmd() {
- cfg, err := loadConfig()
+ cfg, err := internal.LoadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
}
- configPath := getConfigPath()
+ configPath := internal.GetConfigPath()
- fmt.Printf("%s picoclaw Status\n", logo)
- fmt.Printf("Version: %s\n", formatVersion())
- build, _ := formatBuildInfo()
+ fmt.Printf("%s picoclaw Status\n", internal.Logo)
+ fmt.Printf("Version: %s\n", internal.FormatVersion())
+ build, _ := internal.FormatBuildInfo()
if build != "" {
fmt.Printf("Build: %s\n", build)
}
@@ -41,7 +39,7 @@ func statusCmd() {
}
if _, err := os.Stat(configPath); err == nil {
- fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
+ fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
diff --git a/cmd/picoclaw/internal/version/command.go b/cmd/picoclaw/internal/version/command.go
new file mode 100644
index 000000000..1cf686671
--- /dev/null
+++ b/cmd/picoclaw/internal/version/command.go
@@ -0,0 +1,33 @@
+package version
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+)
+
+func NewVersionCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "version",
+ Aliases: []string{"v"},
+ Short: "Show version information",
+ Run: func(_ *cobra.Command, _ []string) {
+ printVersion()
+ },
+ }
+
+ return cmd
+}
+
+func printVersion() {
+ fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
+ build, goVer := internal.FormatBuildInfo()
+ if build != "" {
+ fmt.Printf(" Build: %s\n", build)
+ }
+ if goVer != "" {
+ fmt.Printf(" Go: %s\n", goVer)
+ }
+}
diff --git a/cmd/picoclaw/internal/version/command_test.go b/cmd/picoclaw/internal/version/command_test.go
new file mode 100644
index 000000000..f08a4d1ea
--- /dev/null
+++ b/cmd/picoclaw/internal/version/command_test.go
@@ -0,0 +1,31 @@
+package version
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewVersionCommand(t *testing.T) {
+ cmd := NewVersionCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "version", cmd.Use)
+
+ assert.Len(t, cmd.Aliases, 1)
+ assert.True(t, cmd.HasAlias("v"))
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Equal(t, "Show version information", cmd.Short)
+
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.NotNil(t, cmd.Run)
+ assert.Nil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+}
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 1e4b393f8..6db69c990 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -8,192 +8,49 @@ package main
import (
"fmt"
- "io"
"os"
- "path/filepath"
- "runtime"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/skills"
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
)
-var (
- version = "dev"
- gitCommit string
- buildTime string
- goVersion string
-)
+func NewPicoclawCommand() *cobra.Command {
+ short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
-const logo = "🦞"
-
-// formatVersion returns the version string with optional git commit
-func formatVersion() string {
- v := version
- if gitCommit != "" {
- v += fmt.Sprintf(" (git: %s)", gitCommit)
+ cmd := &cobra.Command{
+ Use: "picoclaw",
+ Short: short,
+ Example: "picoclaw list",
}
- return v
-}
-// formatBuildInfo returns build time and go version info
-func formatBuildInfo() (build string, goVer string) {
- if buildTime != "" {
- build = buildTime
- }
- goVer = goVersion
- if goVer == "" {
- goVer = runtime.Version()
- }
- return
-}
+ cmd.AddCommand(
+ onboard.NewOnboardCommand(),
+ agent.NewAgentCommand(),
+ auth.NewAuthCommand(),
+ gateway.NewGatewayCommand(),
+ status.NewStatusCommand(),
+ cron.NewCronCommand(),
+ migrate.NewMigrateCommand(),
+ skills.NewSkillsCommand(),
+ version.NewVersionCommand(),
+ )
-func printVersion() {
- fmt.Printf("%s picoclaw %s\n", logo, formatVersion())
- build, goVer := formatBuildInfo()
- if build != "" {
- fmt.Printf(" Build: %s\n", build)
- }
- if goVer != "" {
- fmt.Printf(" Go: %s\n", goVer)
- }
-}
-
-func copyDirectory(src, dst string) error {
- return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
-
- relPath, err := filepath.Rel(src, path)
- if err != nil {
- return err
- }
-
- dstPath := filepath.Join(dst, relPath)
-
- if info.IsDir() {
- return os.MkdirAll(dstPath, info.Mode())
- }
-
- srcFile, err := os.Open(path)
- if err != nil {
- return err
- }
- defer srcFile.Close()
-
- dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
- if err != nil {
- return err
- }
- defer dstFile.Close()
-
- _, err = io.Copy(dstFile, srcFile)
- return err
- })
+ return cmd
}
func main() {
- if len(os.Args) < 2 {
- printHelp()
- os.Exit(1)
- }
-
- command := os.Args[1]
-
- switch command {
- case "onboard":
- onboard()
- case "agent":
- agentCmd()
- case "gateway":
- gatewayCmd()
- case "status":
- statusCmd()
- case "migrate":
- migrateCmd()
- case "auth":
- authCmd()
- case "cron":
- cronCmd()
- case "skills":
- if len(os.Args) < 3 {
- skillsHelp()
- return
- }
-
- subcommand := os.Args[2]
-
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- os.Exit(1)
- }
-
- workspace := cfg.WorkspacePath()
- installer := skills.NewSkillInstaller(workspace)
- // 获取全局配置目录和内置 skills 目录
- globalDir := filepath.Dir(getConfigPath())
- globalSkillsDir := filepath.Join(globalDir, "skills")
- builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
- skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
-
- switch subcommand {
- case "list":
- skillsListCmd(skillsLoader)
- case "install":
- skillsInstallCmd(installer, cfg)
- case "remove", "uninstall":
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw skills remove ")
- return
- }
- skillsRemoveCmd(installer, os.Args[3])
- case "install-builtin":
- skillsInstallBuiltinCmd(workspace)
- case "list-builtin":
- skillsListBuiltinCmd()
- case "search":
- skillsSearchCmd(installer)
- case "show":
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw skills show ")
- return
- }
- skillsShowCmd(skillsLoader, os.Args[3])
- default:
- fmt.Printf("Unknown skills command: %s\n", subcommand)
- skillsHelp()
- }
- case "version", "--version", "-v":
- printVersion()
- default:
- fmt.Printf("Unknown command: %s\n", command)
- printHelp()
+ cmd := NewPicoclawCommand()
+ if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}
-
-func printHelp() {
- fmt.Printf("%s picoclaw - Personal AI Assistant v%s\n\n", logo, version)
- fmt.Println("Usage: picoclaw ")
- fmt.Println()
- fmt.Println("Commands:")
- fmt.Println(" onboard Initialize picoclaw configuration and workspace")
- fmt.Println(" agent Interact with the agent directly")
- fmt.Println(" auth Manage authentication (login, logout, status)")
- fmt.Println(" gateway Start picoclaw gateway")
- fmt.Println(" status Show picoclaw status")
- fmt.Println(" cron Manage scheduled tasks")
- fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
- fmt.Println(" skills Manage skills (install, list, remove)")
- fmt.Println(" version Show version information")
-}
-
-func getConfigPath() string {
- home, _ := os.UserHomeDir()
- return filepath.Join(home, ".picoclaw", "config.json")
-}
-
-func loadConfig() (*config.Config, error) {
- return config.LoadConfig(getConfigPath())
-}
diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go
new file mode 100644
index 000000000..3740ba358
--- /dev/null
+++ b/cmd/picoclaw/main_test.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+)
+
+func TestNewPicoclawCommand(t *testing.T) {
+ cmd := NewPicoclawCommand()
+
+ require.NotNil(t, cmd)
+
+ short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
+
+ assert.Equal(t, "picoclaw", cmd.Use)
+ assert.Equal(t, short, cmd.Short)
+
+ assert.True(t, cmd.HasSubCommands())
+ assert.True(t, cmd.HasAvailableSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Nil(t, cmd.Run)
+ assert.Nil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ allowedCommands := []string{
+ "agent",
+ "auth",
+ "cron",
+ "gateway",
+ "migrate",
+ "onboard",
+ "skills",
+ "status",
+ "version",
+ }
+
+ subcommands := cmd.Commands()
+ assert.Len(t, subcommands, len(allowedCommands))
+
+ for _, subcmd := range subcommands {
+ found := slices.Contains(allowedCommands, subcmd.Name())
+ assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
+
+ assert.False(t, subcmd.Hidden)
+ }
+}
diff --git a/config/config.example.json b/config/config.example.json
index 2ad5664d6..762bb90aa 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -3,7 +3,7 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
- "model": "gpt4",
+ "model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -52,30 +52,37 @@
"proxy": "",
"allow_from": [
"YOUR_USER_ID"
- ]
+ ],
+ "reasoning_channel_id": ""
},
"discord": {
"enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN",
"allow_from": [],
- "mention_only": false
+ "mention_only": false,
+ "reasoning_channel_id": ""
},
"qq": {
"enabled": false,
"app_id": "YOUR_QQ_APP_ID",
"app_secret": "YOUR_QQ_APP_SECRET",
- "allow_from": []
+ "allow_from": [],
+ "reasoning_channel_id": ""
},
"maixcam": {
"enabled": false,
"host": "0.0.0.0",
"port": 18790,
- "allow_from": []
+ "allow_from": [],
+ "reasoning_channel_id": ""
},
"whatsapp": {
"enabled": false,
"bridge_url": "ws://localhost:3001",
- "allow_from": []
+ "use_native": false,
+ "session_store_path": "",
+ "allow_from": [],
+ "reasoning_channel_id": ""
},
"feishu": {
"enabled": false,
@@ -83,19 +90,22 @@
"app_secret": "",
"encrypt_key": "",
"verification_token": "",
- "allow_from": []
+ "allow_from": [],
+ "reasoning_channel_id": ""
},
"dingtalk": {
"enabled": false,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
- "allow_from": []
+ "allow_from": [],
+ "reasoning_channel_id": ""
},
"slack": {
"enabled": false,
"bot_token": "xoxb-YOUR-BOT-TOKEN",
"app_token": "xapp-YOUR-APP-TOKEN",
- "allow_from": []
+ "allow_from": [],
+ "reasoning_channel_id": ""
},
"line": {
"enabled": false,
@@ -104,7 +114,8 @@
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line",
- "allow_from": []
+ "allow_from": [],
+ "reasoning_channel_id": ""
},
"onebot": {
"enabled": false,
@@ -112,7 +123,8 @@
"access_token": "",
"reconnect_interval": 5,
"group_trigger_prefix": [],
- "allow_from": []
+ "allow_from": [],
+ "reasoning_channel_id": ""
},
"wecom": {
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
@@ -124,7 +136,8 @@
"webhook_port": 18793,
"webhook_path": "/webhook/wecom",
"allow_from": [],
- "reply_timeout": 5
+ "reply_timeout": 5,
+ "reasoning_channel_id": ""
},
"wecom_app": {
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
@@ -138,7 +151,8 @@
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app",
"allow_from": [],
- "reply_timeout": 5
+ "reply_timeout": 5,
+ "reasoning_channel_id": ""
}
},
"providers": {
@@ -196,6 +210,10 @@
"volcengine": {
"api_key": "",
"api_base": ""
+ },
+ "mistral": {
+ "api_key": "",
+ "api_base": "https://api.mistral.ai/v1"
}
},
"tools": {
@@ -213,7 +231,8 @@
"enabled": false,
"api_key": "pplx-xxx",
"max_results": 5
- }
+ },
+ "proxy": ""
},
"cron": {
"exec_timeout_minutes": 5
@@ -288,7 +307,7 @@
"monitor_usb": true
},
"gateway": {
- "host": "0.0.0.0",
+ "host": "127.0.0.1",
"port": 18790
}
}
\ No newline at end of file
diff --git a/Dockerfile b/docker/Dockerfile
similarity index 96%
rename from Dockerfile
rename to docker/Dockerfile
index 0360cfda6..480244127 100644
--- a/Dockerfile
+++ b/docker/Dockerfile
@@ -1,7 +1,7 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
-FROM golang:1.26.0-alpine AS builder
+FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make
diff --git a/Dockerfile.goreleaser b/docker/Dockerfile.goreleaser
similarity index 58%
rename from Dockerfile.goreleaser
rename to docker/Dockerfile.goreleaser
index 0cdc8c6bd..68a02aae8 100644
--- a/Dockerfile.goreleaser
+++ b/docker/Dockerfile.goreleaser
@@ -5,6 +5,8 @@ ARG TARGETPLATFORM
RUN apk add --no-cache ca-certificates tzdata
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
+COPY docker/entrypoint.sh /entrypoint.sh
-ENTRYPOINT ["picoclaw"]
-CMD ["gateway"]
+RUN chmod +x /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/docker-compose.yml b/docker/docker-compose.yml
similarity index 64%
rename from docker-compose.yml
rename to docker/docker-compose.yml
index c268b01cd..9ec71abab 100644
--- a/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -1,12 +1,10 @@
services:
# ─────────────────────────────────────────────
# PicoClaw Agent (one-shot query)
- # docker compose run --rm picoclaw-agent -m "Hello"
+ # docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
# ─────────────────────────────────────────────
picoclaw-agent:
- build:
- context: .
- dockerfile: Dockerfile
+ image: docker.io/sipeed/picoclaw:latest
container_name: picoclaw-agent
profiles:
- agent
@@ -14,33 +12,23 @@ services:
#extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
- - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
- - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
+ - ./data:/root/.picoclaw
entrypoint: ["picoclaw", "agent"]
stdin_open: true
tty: true
# ─────────────────────────────────────────────
# PicoClaw Gateway (Long-running Bot)
- # docker compose up picoclaw-gateway
+ # docker compose -f docker/docker-compose.yml up picoclaw-gateway
# ─────────────────────────────────────────────
picoclaw-gateway:
- build:
- context: .
- dockerfile: Dockerfile
+ image: docker.io/sipeed/picoclaw:latest
container_name: picoclaw-gateway
- restart: unless-stopped
+ restart: on-failure
profiles:
- gateway
# Uncomment to access host network; leave commented unless needed.
#extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
- # Configuration file
- - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
- # Persistent workspace (sessions, memory, logs)
- - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
- command: ["gateway"]
-
-volumes:
- picoclaw-workspace:
+ - ./data:/root/.picoclaw
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 000000000..b6fc724b5
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+set -e
+
+# First-run: neither config nor workspace exists.
+# If config.json is already mounted but workspace is missing we skip onboard to
+# avoid the interactive "Overwrite? (y/n)" prompt hanging in a non-TTY container.
+if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.json" ]; then
+ picoclaw onboard
+ echo ""
+ echo "First-run setup complete."
+ echo "Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container."
+ exit 0
+fi
+
+exec picoclaw gateway "$@"
diff --git a/docs/design/issue-783-investigation-and-fix-plan.zh.md b/docs/design/issue-783-investigation-and-fix-plan.zh.md
new file mode 100644
index 000000000..1c9fc1e70
--- /dev/null
+++ b/docs/design/issue-783-investigation-and-fix-plan.zh.md
@@ -0,0 +1,61 @@
+# Issue #783 调研与修复执行文档
+
+## 1. 问题澄清(已确认)
+
+- 现象:当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名(如 `step-3.5-flash`)时,fallback 链路将别名当作真实 `provider/model` 解析,导致 `provider` 可能为空、`model` 可能错误。
+- 根因:`ResolveCandidates` 仅对字符串做 `ParseModelRef`,未先通过 `model_list` 将别名映射到真实 `model` 字段。
+- 影响:
+ - fallback 执行可能把别名直接发给 OpenAI-compatible provider,触发 `Unknown Model`。
+ - `defaults.provider` 为空时,日志出现 `provider=` 空值。
+
+## 2. 本次目标
+
+- 修复 fallback 候选解析:优先通过 `model_list` 解析别名。
+- 兼容旧行为:若未命中 `model_list`,继续走原有 `ParseModelRef` 兜底。
+- 补充测试:覆盖别名、嵌套路径模型(如 `openrouter/stepfun/...`)、空默认 provider。
+- 验证代码风格:与当前仓库风格保持一致(命名、错误处理、测试结构)。
+
+## 3. 联网最佳实践调研结论(已完成)
+
+- [x] 查阅 OpenAI-compatible 网关(如 OpenRouter)对 `model` 字段的推荐处理。
+- [x] 查阅多 provider/fallback 设计最佳实践(候选解析、日志可观测性)。
+- [x] 将外部建议映射为本仓库可执行约束。
+
+外部参考要点(来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档):
+
+- 优先显式配置,不依赖字符串切分推断 provider。
+- 对网关模型标识应保留完整路径语义,避免截断导致 Unknown Model。
+- fallback 与 primary 应复用同一解析策略,避免“主路径正确、降级路径错误”。
+
+参考链接:
+
+- OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection
+- OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks
+- OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion
+- LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture
+- Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/
+
+与本仓库对应的可执行约束:
+
+- 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。
+- 未命中映射时保留旧解析行为,保证兼容性。
+- 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。
+
+## 4. 实施步骤(顺序执行)
+
+- [x] Step 1: 对齐现有代码模式,定位最小改动点(`pkg/agent` + `pkg/providers`)。
+- [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。
+- [x] Step 3: 增加/更新单元测试,覆盖 issue 场景。
+- [x] Step 4: 代码风格一致性复核(与现有文件风格对照)。
+- [x] Step 5: 运行质量门禁(LSP + `make check`)。
+
+## 5. 执行记录
+
+- 状态:已完成
+- 已完成改动:
+ - `pkg/providers/fallback.go`:新增 `ResolveCandidatesWithLookup`,并保持 `ResolveCandidates` 向后兼容。
+ - `pkg/agent/instance.go`:在构建 fallback candidates 前,优先通过 `model_list` 解析别名,并对无协议模型补齐默认 `openai/` 前缀后再解析。
+ - `pkg/providers/fallback_test.go`:新增别名解析与去重测试。
+ - `pkg/agent/instance_test.go`:新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。
+- 风格对齐检查(完成):与 `pkg/providers/fallback_test.go`、`pkg/providers/model_ref_test.go` 现有模式一致。
+- 质量验证(完成):先 `make generate`,后 `make check` 全量通过。
diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md
index 589dfc043..0d4af719c 100644
--- a/docs/migration/model-list-migration.md
+++ b/docs/migration/model-list-migration.md
@@ -117,6 +117,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
| `rpm` | No | Requests per minute limit |
| `max_tokens_field` | No | Field name for max tokens |
+| `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` |
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
diff --git a/docs/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md
deleted file mode 100644
index 95de768c6..000000000
--- a/docs/picoclaw_community_roadmap_260216.md
+++ /dev/null
@@ -1,112 +0,0 @@
-## 🚀 Join the PicoClaw Journey: Call for Community Volunteers & Roadmap Reveal
-
-**Hello, PicoClaw Community!**
-
-First, a massive thank you to everyone for your enthusiasm and PR contributions. It is because of you that PicoClaw continues to iterate and evolve so rapidly. Thanks to the simplicity and accessibility of the **Go language**, we’ve seen a non-stop stream of high-quality PRs!
-
-PicoClaw is growing much faster than we anticipated. As we are currently in the midst of the **Chinese New Year holiday**, we are looking to recruit community volunteers to help us maintain this incredible momentum.
-
-This document outlines the specific volunteer roles we need right now and provides a look at our upcoming **Roadmap**.
-
-### 🎁 Community Perks
-
-To show our appreciation, developers who officially join our community operations will receive:
-
-* **Exclusive AI Hardware:** Our upcoming, unreleased AI device.
-* **Token Discounts:** Potential discounts on LLM tokens (currently in negotiations with major providers).
-
-### 🎥 Calling All Content Creators!
-
-Not a developer? You can still help! We welcome users to post **PicoClaw reviews or tutorials**.
-
-* **Twitter:** Use the tag **#picoclaw** and mention **@SipeedIO**.
-* **Bilibili:** Mention **@Sipeed矽速科技** or send us a DM.
-We will be rewarding high-quality content creators with the same perks as our community developers!
-
----
-
-## 🛠️ Urgent Volunteer Roles
-
-We are looking for experts in the following areas:
-
-1. **Issue/PR Reviewers**
-* **The Mission:** With PRs and Issues exploding in volume, we need help with initial triage, evaluation, and merging.
-* **Focus:** Preliminary merging and community health. Efficiency optimization and security audits will be handled by specialized roles.
-
-
-2. **Resource Optimization Experts**
-* **The Mission:** Rapid growth has introduced dependencies that are making PicoClaw a bit "heavy." We want to keep it lean.
-* **Focus:** Analyzing resource growth between releases and trimming redundancy.
-* **Priority:** **RAM usage optimization** > Binary size reduction.
-
-
-3. **Security Audit & Bug Fixes**
-* **The Mission:** Due to the "vibe coding" nature of our early stages, we need a thorough review of network security and AI permission management.
-* **Focus:** Auditing the codebase for vulnerabilities and implementing robust fixes.
-
-
-4. **Documentation & DX (Developer Experience)**
-* **The Mission:** Our current README is a bit outdated. We need "step-by-step" guides that even beginners can follow.
-* **Focus:** Creating clear, user-friendly documentation for both setup and development.
-
-
-5. **AI-Powered CI/CD Optimization**
-* **The Mission:** PicoClaw started as a "vibe coding" experiment; now we want to use AI to manage it.
-* **Focus:** Automating builds with AI and exploring AI-driven issue resolution.
-
-**How to Apply:** > If you are interested in any of the roles above, please send an email to support@sipeed.com with the subject line: [Apply: PicoClaw Expert Volunteer] + Your Desired Role.
-Please include a brief introduction and any relevant experience or portfolio links. We will review all applications and grant project permissions to selected contributors!
-
----
-
-## 📍 The Roadmap
-
-Interested in a specific feature? You can "claim" these tasks and start building:
-
-###
-* **Provider:**
- * **Provider Refactor:** Currently being handled by **@Daming** (ETA: 5 days)
- * You can still submit code; Daming will merge it into the new implementation.
-* **Channels:**
- * Support for OneBot, additional platforms
- * attachments (images, audio, video, files).
-* **Skills:**
- * Implementing `find_skill` to discover tools via [ClawhHub](https://clawhub.ai) and other platforms.
-* **Operations:** * MCP Support.
- * Android operations (e.g., botdrop).
- * Browser automation via CDP or ActionBook.
-
-
-* **Multi-Agent Ecosystem:**
- * **Basic Model-Agent**
- * **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
- * **Swarm Mode.**
- * **AIEOS Integration.**
-
-
-* **Branding:**
- * **Logo**: We need a cute logo! We’re leaning toward a **Mantis Shrimp**—small, but packs a legendary punch!
-
-
-We have officially created these tasks as GitHub Issues, all marked with the roadmap tag.
-This list will be updated continuously as we progress.
-If you would like to claim a task, please feel free to start a conversation by commenting directly on the corresponding issue!
-
----
-
-## 🤝 How to Join
-
-**Everything is open to your creativity!** If you have a wild idea, just PR it.
-
-1. **The Fast Track:** Once you have at least **one merged PR**, you are eligible to join our **Developer Discord** to help plan the future of PicoClaw.
-2. **The Application Track:** If you haven’t submitted a PR yet but want to dive in, email **support@sipeed.com** with the subject:
-> `[Apply Join PicoClaw Dev Group] + Your GitHub Account`
-> Include the role you're interested in and any evidence of your development experience.
-
-
-
-### Looking Ahead
-
-Powered by PicoClaw, we are crafting a Swarm AI Assistant to transform your environment into a seamless network of personal stewards. By automating the friction of daily life, we empower you to transcend the ordinary and freely explore your creative potential.
-
-**Finally, Happy Chinese New Year to everyone!** May PicoClaw gallop forward in this **Year of the Horse!** 🐎
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 000000000..219d2c6e3
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,43 @@
+# Troubleshooting
+
+## "model ... not found in model_list" or OpenRouter "free is not a valid model ID"
+
+**Symptom:** You see either:
+
+- `Error creating provider: model "openrouter/free" not found in model_list`
+- OpenRouter returns 400: `"free is not a valid model ID"`
+
+**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand.
+
+- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it.
+- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing).
+
+**Fix:** In `~/.picoclaw/config.json` (or your config path):
+
+1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`).
+2. That entry’s **model** must be a valid OpenRouter model ID, for example:
+ - `"openrouter/free"` – auto free-tier
+ - `"google/gemini-2.0-flash-exp:free"`
+ - `"meta-llama/llama-3.1-8b-instruct:free"`
+
+Example snippet:
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "openrouter-free"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "openrouter-free",
+ "model": "openrouter/free",
+ "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY",
+ "api_base": "https://openrouter.ai/api/v1"
+ }
+ ]
+}
+```
+
+Get your key at [OpenRouter Keys](https://openrouter.ai/keys).
diff --git a/go.mod b/go.mod
index 705767f89..316c926c2 100644
--- a/go.mod
+++ b/go.mod
@@ -11,20 +11,49 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
+ github.com/mdp/qrterminal/v3 v3.2.1
github.com/modelcontextprotocol/go-sdk v1.3.0
github.com/mymmrac/telego v1.6.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/slack-go/slack v0.17.3
+ github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
+ go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.35.0
+ golang.org/x/time v0.14.0
+ google.golang.org/protobuf v1.36.11
+ modernc.org/sqlite v1.46.1
)
require (
+ filippo.io/edwards25519 v1.1.0 // 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
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ 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-20260113132338-7c7de50cc741 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/rs/zerolog v1.34.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/vektah/gqlparser/v2 v2.5.27 // indirect
+ go.mau.fi/libsignal v0.2.1 // indirect
+ go.mau.fi/util v0.9.6 // indirect
+ golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
+ golang.org/x/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.67.6 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ rsc.io/qr v0.2.0 // indirect
)
require (
diff --git a/go.sum b/go.sum
index 450d449bf..f278c762b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,10 +1,20 @@
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
+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=
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
+github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
+github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
+github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
+github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
+github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
+github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@@ -25,11 +35,19 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
+github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
@@ -41,6 +59,7 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m
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=
@@ -64,6 +83,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -73,7 +94,11 @@ 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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
@@ -90,10 +115,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
+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/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -106,14 +144,30 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
+github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
+github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+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/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
+github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -148,6 +202,8 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
+github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
+github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -155,8 +211,15 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
+go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
+go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
+go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
+go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
+go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -167,10 +230,14 @@ 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-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
+golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -213,8 +280,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
@@ -223,6 +293,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
+golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
+golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -230,8 +302,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
-golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@@ -239,8 +313,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
-golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -253,6 +327,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -267,3 +343,33 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
+modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+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.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.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=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
+modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
+rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index a9db5afdd..b7c6e1108 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -1,24 +1,38 @@
package agent
import (
+ "errors"
"fmt"
+ "io/fs"
"os"
"path/filepath"
"runtime"
"strings"
+ "sync"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
- "github.com/sipeed/picoclaw/pkg/tools"
)
type ContextBuilder struct {
workspace string
skillsLoader *skills.SkillsLoader
memory *MemoryStore
- tools *tools.ToolRegistry // Direct reference to tool registry
+
+ // Cache for system prompt to avoid rebuilding on every call.
+ // This fixes issue #607: repeated reprocessing of the entire context.
+ // The cache auto-invalidates when workspace source files change (mtime check).
+ systemPromptMutex sync.RWMutex
+ cachedSystemPrompt string
+ cachedAt time.Time // max observed mtime across tracked paths at cache build time
+
+ // existedAtCache tracks which source file paths existed the last time the
+ // cache was built. This lets sourceFilesChanged detect files that are newly
+ // created (didn't exist at cache time, now exist) or deleted (existed at
+ // cache time, now gone) — both of which should trigger a cache rebuild.
+ existedAtCache map[string]bool
}
func getGlobalConfigDir() string {
@@ -43,69 +57,29 @@ func NewContextBuilder(workspace string) *ContextBuilder {
}
}
-// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
-func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
- cb.tools = registry
-}
-
func (cb *ContextBuilder) getIdentity() string {
- now := time.Now().Format("2006-01-02 15:04 (Monday)")
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
- runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
-
- // Build tools section dynamically
- toolsSection := cb.buildToolsSection()
return fmt.Sprintf(`# picoclaw 🦞
You are picoclaw, a helpful AI assistant.
-## Current Time
-%s
-
-## Runtime
-%s
-
## Workspace
Your workspace is at: %s
- Memory: %s/memory/MEMORY.md
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
- Skills: %s/skills/{skill-name}/SKILL.md
-%s
-
## Important Rules
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
-3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`,
- now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
-}
+3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
-func (cb *ContextBuilder) buildToolsSection() string {
- if cb.tools == nil {
- return ""
- }
-
- summaries := cb.tools.GetSummaries()
- if len(summaries) == 0 {
- return ""
- }
-
- var sb strings.Builder
- sb.WriteString("## Available Tools\n\n")
- sb.WriteString(
- "**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n",
- )
- sb.WriteString("You have access to the following tools:\n\n")
- for _, s := range summaries {
- sb.WriteString(s)
- sb.WriteString("\n")
- }
-
- return sb.String()
+4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`,
+ workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
}
func (cb *ContextBuilder) BuildSystemPrompt() string {
@@ -140,6 +114,226 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
return strings.Join(parts, "\n\n---\n\n")
}
+// BuildSystemPromptWithCache returns the cached system prompt if available
+// and source files haven't changed, otherwise builds and caches it.
+// Source file changes are detected via mtime checks (cheap stat calls).
+func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
+ // Try read lock first — fast path when cache is valid
+ cb.systemPromptMutex.RLock()
+ if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
+ result := cb.cachedSystemPrompt
+ cb.systemPromptMutex.RUnlock()
+ return result
+ }
+ cb.systemPromptMutex.RUnlock()
+
+ // Acquire write lock for building
+ cb.systemPromptMutex.Lock()
+ defer cb.systemPromptMutex.Unlock()
+
+ // Double-check: another goroutine may have rebuilt while we waited
+ if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
+ return cb.cachedSystemPrompt
+ }
+
+ // Snapshot the baseline (existence + max mtime) BEFORE building the prompt.
+ // This way cachedAt reflects the pre-build state: if a file is modified
+ // during BuildSystemPrompt, its new mtime will be > baseline.maxMtime,
+ // so the next sourceFilesChangedLocked check will correctly trigger a
+ // rebuild. The alternative (baseline after build) risks caching stale
+ // content with a too-new baseline, making the staleness invisible.
+ baseline := cb.buildCacheBaseline()
+ prompt := cb.BuildSystemPrompt()
+ cb.cachedSystemPrompt = prompt
+ cb.cachedAt = baseline.maxMtime
+ cb.existedAtCache = baseline.existed
+
+ logger.DebugCF("agent", "System prompt cached",
+ map[string]any{
+ "length": len(prompt),
+ })
+
+ return prompt
+}
+
+// InvalidateCache clears the cached system prompt.
+// Normally not needed because the cache auto-invalidates via mtime checks,
+// but this is useful for tests or explicit reload commands.
+func (cb *ContextBuilder) InvalidateCache() {
+ cb.systemPromptMutex.Lock()
+ defer cb.systemPromptMutex.Unlock()
+
+ cb.cachedSystemPrompt = ""
+ cb.cachedAt = time.Time{}
+ cb.existedAtCache = nil
+
+ logger.DebugCF("agent", "System prompt cache invalidated", nil)
+}
+
+// sourcePaths returns the workspace source file paths tracked for cache
+// invalidation (bootstrap files + memory). The skills directory is handled
+// separately in sourceFilesChangedLocked because it requires both directory-
+// level and recursive file-level mtime checks.
+func (cb *ContextBuilder) sourcePaths() []string {
+ return []string{
+ filepath.Join(cb.workspace, "AGENTS.md"),
+ filepath.Join(cb.workspace, "SOUL.md"),
+ filepath.Join(cb.workspace, "USER.md"),
+ filepath.Join(cb.workspace, "IDENTITY.md"),
+ filepath.Join(cb.workspace, "memory", "MEMORY.md"),
+ }
+}
+
+// cacheBaseline holds the file existence snapshot and the latest observed
+// mtime across all tracked paths. Used as the cache reference point.
+type cacheBaseline struct {
+ existed map[string]bool
+ maxMtime time.Time
+}
+
+// buildCacheBaseline records which tracked paths currently exist and computes
+// the latest mtime across all tracked files + skills directory contents.
+// Called under write lock when the cache is built.
+func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
+ skillsDir := filepath.Join(cb.workspace, "skills")
+
+ // All paths whose existence we track: source files + skills dir.
+ allPaths := append(cb.sourcePaths(), skillsDir)
+
+ existed := make(map[string]bool, len(allPaths))
+ var maxMtime time.Time
+
+ for _, p := range allPaths {
+ info, err := os.Stat(p)
+ existed[p] = err == nil
+ if err == nil && info.ModTime().After(maxMtime) {
+ maxMtime = info.ModTime()
+ }
+ }
+
+ // Walk skills files to capture their mtimes too.
+ // Use os.Stat (not d.Info) to match the stat method used in
+ // fileChangedSince / skillFilesModifiedSince for consistency.
+ _ = filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr == nil && !d.IsDir() {
+ if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) {
+ maxMtime = info.ModTime()
+ }
+ }
+ return nil
+ })
+
+ // If no tracked files exist yet (empty workspace), maxMtime is zero.
+ // Use a very old non-zero time so that:
+ // 1. cachedAt.IsZero() won't trigger perpetual rebuilds.
+ // 2. Any real file created afterwards has mtime > cachedAt, so it
+ // will be detected by fileChangedSince (unlike time.Now() which
+ // could race with a file whose mtime <= Now).
+ if maxMtime.IsZero() {
+ maxMtime = time.Unix(1, 0)
+ }
+
+ return cacheBaseline{existed: existed, maxMtime: maxMtime}
+}
+
+// sourceFilesChangedLocked checks whether any workspace source file has been
+// modified, created, or deleted since the cache was last built.
+//
+// IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex.
+// Go's sync.RWMutex is not reentrant, so this function must NOT acquire the
+// lock itself (it would deadlock when called from BuildSystemPromptWithCache
+// which already holds RLock or Lock).
+func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
+ if cb.cachedAt.IsZero() {
+ return true
+ }
+
+ // Check tracked source files (bootstrap + memory).
+ for _, p := range cb.sourcePaths() {
+ if cb.fileChangedSince(p) {
+ return true
+ }
+ }
+
+ // --- Skills directory (handled separately from sourcePaths) ---
+ //
+ // 1. Creation/deletion: tracked via existedAtCache, same as bootstrap files.
+ skillsDir := filepath.Join(cb.workspace, "skills")
+ if cb.fileChangedSince(skillsDir) {
+ return true
+ }
+
+ // 2. Structural changes (add/remove entries inside the dir) are reflected
+ // in the directory's own mtime, which fileChangedSince already checks.
+ //
+ // 3. Content-only edits to files inside skills/ do NOT update the parent
+ // directory mtime on most filesystems, so we recursively walk to check
+ // individual file mtimes at any nesting depth.
+ if skillFilesModifiedSince(skillsDir, cb.cachedAt) {
+ return true
+ }
+
+ return false
+}
+
+// fileChangedSince returns true if a tracked source file has been modified,
+// newly created, or deleted since the cache was built.
+//
+// Four cases:
+// - existed at cache time, exists now -> check mtime
+// - existed at cache time, gone now -> changed (deleted)
+// - absent at cache time, exists now -> changed (created)
+// - absent at cache time, gone now -> no change
+func (cb *ContextBuilder) fileChangedSince(path string) bool {
+ // Defensive: if existedAtCache was never initialized, treat as changed
+ // so the cache rebuilds rather than silently serving stale data.
+ if cb.existedAtCache == nil {
+ return true
+ }
+
+ existedBefore := cb.existedAtCache[path]
+ info, err := os.Stat(path)
+ existsNow := err == nil
+
+ if existedBefore != existsNow {
+ return true // file was created or deleted
+ }
+ if !existsNow {
+ return false // didn't exist before, doesn't exist now
+ }
+ return info.ModTime().After(cb.cachedAt)
+}
+
+// errWalkStop is a sentinel error used to stop filepath.WalkDir early.
+// Using a dedicated error (instead of fs.SkipAll) makes the early-exit
+// intent explicit and avoids the nilerr linter warning that would fire
+// if the callback returned nil when its err parameter is non-nil.
+var errWalkStop = errors.New("walk stop")
+
+// skillFilesModifiedSince recursively walks the skills directory and checks
+// whether any file was modified after t. This catches content-only edits at
+// any nesting depth (e.g. skills/name/docs/extra.md) that don't update
+// parent directory mtimes.
+func skillFilesModifiedSince(skillsDir string, t time.Time) bool {
+ changed := false
+ err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr == nil && !d.IsDir() {
+ if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) {
+ changed = true
+ return errWalkStop // stop walking
+ }
+ }
+ return nil
+ })
+ // errWalkStop is expected (early exit on first changed file).
+ // os.IsNotExist means the skills dir doesn't exist yet — not an error.
+ // Any other error is unexpected and worth logging.
+ if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
+ logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
+ }
+ return changed
+}
+
func (cb *ContextBuilder) LoadBootstrapFiles() string {
bootstrapFiles := []string{
"AGENTS.md",
@@ -159,6 +353,28 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
return sb.String()
}
+// buildDynamicContext returns a short dynamic context string with per-request info.
+// This changes every request (time, session) so it is NOT part of the cached prompt.
+// LLM-side KV cache reuse is achieved by each provider adapter's native mechanism:
+// - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block
+// - OpenAI / Codex: prompt_cache_key for prefix-based caching
+//
+// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
+// See: https://platform.openai.com/docs/guides/prompt-caching
+func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string {
+ now := time.Now().Format("2006-01-02 15:04 (Monday)")
+ rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt)
+
+ if channel != "" && chatID != "" {
+ fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
+ }
+
+ return sb.String()
+}
+
func (cb *ContextBuilder) BuildMessages(
history []providers.Message,
summary string,
@@ -168,23 +384,65 @@ func (cb *ContextBuilder) BuildMessages(
) []providers.Message {
messages := []providers.Message{}
- systemPrompt := cb.BuildSystemPrompt()
+ // The static part (identity, bootstrap, skills, memory) is cached locally to
+ // avoid repeated file I/O and string building on every call (fixes issue #607).
+ // Dynamic parts (time, session, summary) are appended per request.
+ // Everything is sent as a single system message for provider compatibility:
+ // - Anthropic adapter extracts messages[0] (Role=="system") and maps its content
+ // to the top-level "system" parameter in the Messages API request. A single
+ // contiguous system block makes this extraction straightforward.
+ // - Codex maps only the first system message to its instructions field.
+ // - OpenAI-compat passes messages through as-is.
+ staticPrompt := cb.BuildSystemPromptWithCache()
- // Add Current Session info if provided
- if channel != "" && chatID != "" {
- systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
+ // Build short dynamic context (time, runtime, session) — changes per request
+ dynamicCtx := cb.buildDynamicContext(channel, chatID)
+
+ // Compose a single system message: static (cached) + dynamic + optional summary.
+ // Keeping all system content in one message ensures every provider adapter can
+ // extract it correctly (Anthropic adapter -> top-level system param,
+ // Codex -> instructions field).
+ //
+ // SystemParts carries the same content as structured blocks so that
+ // cache-aware adapters (Anthropic) can set per-block cache_control.
+ // The static block is marked "ephemeral" — its prefix hash is stable
+ // across requests, enabling LLM-side KV cache reuse.
+ stringParts := []string{staticPrompt, dynamicCtx}
+
+ contentBlocks := []providers.ContentBlock{
+ {Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}},
+ {Type: "text", Text: dynamicCtx},
}
- // Log system prompt summary for debugging (debug mode only)
+ if summary != "" {
+ summaryText := fmt.Sprintf(
+ "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
+ "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
+ summary)
+ stringParts = append(stringParts, summaryText)
+ contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
+ }
+
+ fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
+
+ // Log system prompt summary for debugging (debug mode only).
+ // Read cachedSystemPrompt under lock to avoid a data race with
+ // concurrent InvalidateCache / BuildSystemPromptWithCache writes.
+ cb.systemPromptMutex.RLock()
+ isCached := cb.cachedSystemPrompt != ""
+ cb.systemPromptMutex.RUnlock()
+
logger.DebugCF("agent", "System prompt built",
map[string]any{
- "total_chars": len(systemPrompt),
- "total_lines": strings.Count(systemPrompt, "\n") + 1,
- "section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
+ "static_chars": len(staticPrompt),
+ "dynamic_chars": len(dynamicCtx),
+ "total_chars": len(fullSystemPrompt),
+ "has_summary": summary != "",
+ "cached": isCached,
})
// Log preview of system prompt (avoid logging huge content)
- preview := systemPrompt
+ preview := fullSystemPrompt
if len(preview) > 500 {
preview = preview[:500] + "... (truncated)"
}
@@ -193,19 +451,21 @@ func (cb *ContextBuilder) BuildMessages(
"preview": preview,
})
- if summary != "" {
- systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
- }
-
history = sanitizeHistoryForProvider(history)
+ // Single system message containing all context — compatible with all providers.
+ // SystemParts enables cache-aware adapters to set per-block cache_control;
+ // Content is the concatenated fallback for adapters that don't read SystemParts.
messages = append(messages, providers.Message{
- Role: "system",
- Content: systemPrompt,
+ Role: "system",
+ Content: fullSystemPrompt,
+ SystemParts: contentBlocks,
})
+ // Add conversation history
messages = append(messages, history...)
+ // Add current user message
if strings.TrimSpace(currentMessage) != "" {
messages = append(messages, providers.Message{
Role: "user",
@@ -224,13 +484,32 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
sanitized := make([]providers.Message, 0, len(history))
for _, msg := range history {
switch msg.Role {
+ case "system":
+ // Drop system messages from history. BuildMessages always
+ // constructs its own single system message (static + dynamic +
+ // summary); extra system messages would break providers that
+ // only accept one (Anthropic, Codex).
+ logger.DebugCF("agent", "Dropping system message from history", map[string]any{})
+ continue
+
case "tool":
if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
continue
}
- last := sanitized[len(sanitized)-1]
- if last.Role != "assistant" || len(last.ToolCalls) == 0 {
+ // Walk backwards to find the nearest assistant message,
+ // skipping over any preceding tool messages (multi-tool-call case).
+ foundAssistant := false
+ for i := len(sanitized) - 1; i >= 0; i-- {
+ if sanitized[i].Role == "tool" {
+ continue
+ }
+ if sanitized[i].Role == "assistant" && len(sanitized[i].ToolCalls) > 0 {
+ foundAssistant = true
+ }
+ break
+ }
+ if !foundAssistant {
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
continue
}
@@ -288,25 +567,6 @@ func (cb *ContextBuilder) AddAssistantMessage(
return messages
}
-func (cb *ContextBuilder) loadSkills() string {
- allSkills := cb.skillsLoader.ListSkills()
- if len(allSkills) == 0 {
- return ""
- }
-
- var skillNames []string
- for _, s := range allSkills {
- skillNames = append(skillNames, s.Name)
- }
-
- content := cb.skillsLoader.LoadSkillsForContext(skillNames)
- if content == "" {
- return ""
- }
-
- return "# Skill Definitions\n\n" + content
-}
-
// GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills()
diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go
new file mode 100644
index 000000000..ba70d4c0d
--- /dev/null
+++ b/pkg/agent/context_cache_test.go
@@ -0,0 +1,513 @@
+package agent
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// setupWorkspace creates a temporary workspace with standard directories and optional files.
+// Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir).
+func setupWorkspace(t *testing.T, files map[string]string) string {
+ t.Helper()
+ tmpDir, err := os.MkdirTemp("", "picoclaw-test-*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
+ os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
+ for name, content := range files {
+ dir := filepath.Dir(filepath.Join(tmpDir, name))
+ os.MkdirAll(dir, 0o755)
+ if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return tmpDir
+}
+
+// TestSingleSystemMessage verifies that BuildMessages always produces exactly one
+// system message regardless of summary/history variations.
+// Fix: multiple system messages break Anthropic (top-level system param) and
+// Codex (only reads last system message as instructions).
+func TestSingleSystemMessage(t *testing.T) {
+ tmpDir := setupWorkspace(t, map[string]string{
+ "IDENTITY.md": "# Identity\nTest agent.",
+ })
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ tests := []struct {
+ name string
+ history []providers.Message
+ summary string
+ message string
+ }{
+ {
+ name: "no summary, no history",
+ summary: "",
+ message: "hello",
+ },
+ {
+ name: "with summary",
+ summary: "Previous conversation discussed X",
+ message: "hello",
+ },
+ {
+ name: "with history and summary",
+ history: []providers.Message{
+ {Role: "user", Content: "hi"},
+ {Role: "assistant", Content: "hello"},
+ },
+ summary: strings.Repeat("Long summary text. ", 50),
+ message: "new message",
+ },
+ {
+ name: "system message in history is filtered",
+ history: []providers.Message{
+ {Role: "system", Content: "stale system prompt from previous session"},
+ {Role: "user", Content: "hi"},
+ {Role: "assistant", Content: "hello"},
+ },
+ summary: "",
+ message: "new message",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1")
+
+ systemCount := 0
+ for _, m := range msgs {
+ if m.Role == "system" {
+ systemCount++
+ }
+ }
+ if systemCount != 1 {
+ t.Errorf("expected exactly 1 system message, got %d", systemCount)
+ }
+ if msgs[0].Role != "system" {
+ t.Errorf("first message should be system, got %s", msgs[0].Role)
+ }
+ if msgs[len(msgs)-1].Role != "user" {
+ t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role)
+ }
+
+ // System message must contain identity (static) and time (dynamic)
+ sys := msgs[0].Content
+ if !strings.Contains(sys, "picoclaw") {
+ t.Error("system message missing identity")
+ }
+ if !strings.Contains(sys, "Current Time") {
+ t.Error("system message missing dynamic time context")
+ }
+
+ // Summary handling
+ if tt.summary != "" {
+ if !strings.Contains(sys, "CONTEXT_SUMMARY:") {
+ t.Error("summary present but CONTEXT_SUMMARY prefix missing")
+ }
+ if !strings.Contains(sys, tt.summary[:20]) {
+ t.Error("summary content not found in system message")
+ }
+ } else {
+ if strings.Contains(sys, "CONTEXT_SUMMARY:") {
+ t.Error("CONTEXT_SUMMARY should not appear without summary")
+ }
+ }
+ })
+ }
+}
+
+// TestMtimeAutoInvalidation verifies that the cache detects source file changes
+// via mtime without requiring explicit InvalidateCache().
+// Fix: original implementation had no auto-invalidation — edits to bootstrap files,
+// memory, or skills were invisible until process restart.
+func TestMtimeAutoInvalidation(t *testing.T) {
+ tests := []struct {
+ name string
+ file string // relative path inside workspace
+ contentV1 string
+ contentV2 string
+ checkField string // substring to verify in rebuilt prompt
+ }{
+ {
+ name: "bootstrap file change",
+ file: "IDENTITY.md",
+ contentV1: "# Original Identity",
+ contentV2: "# Updated Identity",
+ checkField: "Updated Identity",
+ },
+ {
+ name: "memory file change",
+ file: "memory/MEMORY.md",
+ contentV1: "# Memory\nUser likes Go.",
+ contentV2: "# Memory\nUser likes Rust.",
+ checkField: "User likes Rust",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1})
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ sp1 := cb.BuildSystemPromptWithCache()
+
+ // Overwrite file and set future mtime to ensure detection.
+ // Use 2s offset for filesystem mtime resolution safety (some FS
+ // have 1s or coarser granularity, especially in CI containers).
+ fullPath := filepath.Join(tmpDir, tt.file)
+ os.WriteFile(fullPath, []byte(tt.contentV2), 0o644)
+ future := time.Now().Add(2 * time.Second)
+ os.Chtimes(fullPath, future, future)
+
+ // Verify sourceFilesChangedLocked detects the mtime change
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Fatalf("sourceFilesChangedLocked() should detect %s change", tt.file)
+ }
+
+ // Should auto-rebuild without explicit InvalidateCache()
+ sp2 := cb.BuildSystemPromptWithCache()
+ if sp1 == sp2 {
+ t.Errorf("cache not rebuilt after %s change", tt.file)
+ }
+ if !strings.Contains(sp2, tt.checkField) {
+ t.Errorf("rebuilt prompt missing expected content %q", tt.checkField)
+ }
+ })
+ }
+
+ // Skills directory mtime change
+ t.Run("skills dir change", func(t *testing.T) {
+ tmpDir := setupWorkspace(t, nil)
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+ _ = cb.BuildSystemPromptWithCache() // populate cache
+
+ // Touch skills directory (simulate new skill installed)
+ skillsDir := filepath.Join(tmpDir, "skills")
+ future := time.Now().Add(2 * time.Second)
+ os.Chtimes(skillsDir, future, future)
+
+ // Verify sourceFilesChangedLocked detects it (cache is rebuilt)
+ // We confirm by checking internal state: a second call should rebuild.
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Error("sourceFilesChangedLocked() should detect skills dir mtime change")
+ }
+ })
+}
+
+// TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild
+// even when source files haven't changed (useful for tests and reload commands).
+func TestExplicitInvalidateCache(t *testing.T) {
+ tmpDir := setupWorkspace(t, map[string]string{
+ "IDENTITY.md": "# Test Identity",
+ })
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ sp1 := cb.BuildSystemPromptWithCache()
+ cb.InvalidateCache()
+ sp2 := cb.BuildSystemPromptWithCache()
+
+ if sp1 != sp2 {
+ t.Error("prompt should be identical after invalidate+rebuild when files unchanged")
+ }
+
+ // Verify cachedAt was reset
+ cb.InvalidateCache()
+ cb.systemPromptMutex.RLock()
+ if !cb.cachedAt.IsZero() {
+ t.Error("cachedAt should be zero after InvalidateCache()")
+ }
+ cb.systemPromptMutex.RUnlock()
+}
+
+// TestCacheStability verifies that the static prompt is stable across repeated calls
+// when no files change (regression test for issue #607).
+func TestCacheStability(t *testing.T) {
+ tmpDir := setupWorkspace(t, map[string]string{
+ "IDENTITY.md": "# Identity\nContent",
+ "SOUL.md": "# Soul\nContent",
+ })
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ results := make([]string, 5)
+ for i := range results {
+ results[i] = cb.BuildSystemPromptWithCache()
+ }
+ for i := 1; i < len(results); i++ {
+ if results[i] != results[0] {
+ t.Errorf("cached prompt changed between call 0 and %d", i)
+ }
+ }
+
+ // Static prompt must NOT contain per-request data
+ if strings.Contains(results[0], "Current Time") {
+ t.Error("static cached prompt should not contain time (added dynamically)")
+ }
+}
+
+// TestNewFileCreationInvalidatesCache verifies that creating a source file that
+// did not exist when the cache was built triggers a cache rebuild.
+// This catches the "from nothing to something" edge case that the old
+// modifiedSince (return false on stat error) would miss.
+func TestNewFileCreationInvalidatesCache(t *testing.T) {
+ tests := []struct {
+ name string
+ file string // relative path inside workspace
+ content string
+ checkField string // substring to verify in rebuilt prompt
+ }{
+ {
+ name: "new bootstrap file",
+ file: "SOUL.md",
+ content: "# Soul\nBe kind and helpful.",
+ checkField: "Be kind and helpful",
+ },
+ {
+ name: "new memory file",
+ file: "memory/MEMORY.md",
+ content: "# Memory\nUser prefers dark mode.",
+ checkField: "User prefers dark mode",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Start with an empty workspace (no bootstrap/memory files)
+ tmpDir := setupWorkspace(t, nil)
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ // Populate cache — file does not exist yet
+ sp1 := cb.BuildSystemPromptWithCache()
+ if strings.Contains(sp1, tt.checkField) {
+ t.Fatalf("prompt should not contain %q before file is created", tt.checkField)
+ }
+
+ // Create the file after cache was built
+ fullPath := filepath.Join(tmpDir, tt.file)
+ os.MkdirAll(filepath.Dir(fullPath), 0o755)
+ if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // Set future mtime to guarantee detection
+ future := time.Now().Add(2 * time.Second)
+ os.Chtimes(fullPath, future, future)
+
+ // Cache should auto-invalidate because file went from absent -> present
+ sp2 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp2, tt.checkField) {
+ t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField)
+ }
+ })
+ }
+}
+
+// TestSkillFileContentChange verifies that modifying a skill file's content
+// (not just the directory structure) invalidates the cache.
+// This is the scenario where directory mtime alone is insufficient — on most
+// filesystems, editing a file inside a directory does NOT update the parent
+// directory's mtime.
+func TestSkillFileContentChange(t *testing.T) {
+ skillMD := `---
+name: test-skill
+description: "A test skill"
+---
+# Test Skill v1
+Original content.`
+
+ tmpDir := setupWorkspace(t, map[string]string{
+ "skills/test-skill/SKILL.md": skillMD,
+ })
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ // Populate cache
+ sp1 := cb.BuildSystemPromptWithCache()
+ _ = sp1 // cache is warm
+
+ // Modify the skill file content (without touching the skills/ directory)
+ updatedSkillMD := `---
+name: test-skill
+description: "An updated test skill"
+---
+# Test Skill v2
+Updated content.`
+
+ skillPath := filepath.Join(tmpDir, "skills", "test-skill", "SKILL.md")
+ if err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // Set future mtime on the skill file only (NOT the directory)
+ future := time.Now().Add(2 * time.Second)
+ os.Chtimes(skillPath, future, future)
+
+ // Verify that sourceFilesChangedLocked detects the content change
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Error("sourceFilesChangedLocked() should detect skill file content change")
+ }
+
+ // Verify cache is actually rebuilt with new content
+ sp2 := cb.BuildSystemPromptWithCache()
+ if sp1 == sp2 && strings.Contains(sp1, "test-skill") {
+ // If the skill appeared in the prompt and the prompt didn't change,
+ // the cache was not invalidated.
+ t.Error("cache should be invalidated when skill file content changes")
+ }
+}
+
+// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
+// can safely call BuildSystemPromptWithCache concurrently without producing
+// empty results, panics, or data races.
+// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache
+func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
+ tmpDir := setupWorkspace(t, map[string]string{
+ "IDENTITY.md": "# Identity\nConcurrency test agent.",
+ "SOUL.md": "# Soul\nBe helpful.",
+ "memory/MEMORY.md": "# Memory\nUser prefers Go.",
+ "skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
+ })
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ const goroutines = 20
+ const iterations = 50
+
+ var wg sync.WaitGroup
+ errs := make(chan string, goroutines*iterations)
+
+ for g := 0; g < goroutines; g++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ for i := 0; i < iterations; i++ {
+ result := cb.BuildSystemPromptWithCache()
+ if result == "" {
+ errs <- "empty prompt returned"
+ return
+ }
+ if !strings.Contains(result, "picoclaw") {
+ errs <- "prompt missing identity"
+ return
+ }
+
+ // Also exercise BuildMessages concurrently
+ msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat")
+ if len(msgs) < 2 {
+ errs <- "BuildMessages returned fewer than 2 messages"
+ return
+ }
+ if msgs[0].Role != "system" {
+ errs <- "first message not system"
+ return
+ }
+
+ // Occasionally invalidate to exercise the write path
+ if i%10 == 0 {
+ cb.InvalidateCache()
+ }
+ }
+ }(g)
+ }
+
+ wg.Wait()
+ close(errs)
+
+ for errMsg := range errs {
+ t.Errorf("concurrent access error: %s", errMsg)
+ }
+}
+
+// BenchmarkBuildMessagesWithCache measures caching performance.
+
+// TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is
+// built on an empty workspace (no tracked files exist), creating a file
+// afterwards still triggers cache invalidation. This validates the
+// time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch,
+// so fileChangedSince correctly detects the absent -> present transition AND
+// the mtime comparison succeeds even without artificially inflated Chtimes.
+func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) {
+ // Empty workspace: no bootstrap files, no memory, no skills content.
+ tmpDir := setupWorkspace(t, nil)
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ // Build cache — all tracked files are absent, maxMtime falls back to epoch.
+ sp1 := cb.BuildSystemPromptWithCache()
+
+ // Create a bootstrap file with natural mtime (no Chtimes manipulation).
+ // The file's mtime should be the current wall-clock time, which is
+ // strictly after time.Unix(1, 0).
+ soulPath := filepath.Join(tmpDir, "SOUL.md")
+ if err := os.WriteFile(soulPath, []byte("# Soul\nNewly created."), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Cache should detect the new file via existedAtCache (absent -> present).
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Fatal("sourceFilesChangedLocked should detect newly created file on empty workspace")
+ }
+
+ sp2 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp2, "Newly created") {
+ t.Error("rebuilt prompt should contain new file content")
+ }
+ if sp1 == sp2 {
+ t.Error("cache should have been invalidated after file creation")
+ }
+}
+
+// BenchmarkBuildMessagesWithCache measures caching performance.
+func BenchmarkBuildMessagesWithCache(b *testing.B) {
+ tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
+ defer os.RemoveAll(tmpDir)
+
+ os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
+ os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
+ for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} {
+ os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644)
+ }
+
+ cb := NewContextBuilder(tmpDir)
+ history := []providers.Message{
+ {Role: "user", Content: "previous message"},
+ {Role: "assistant", Content: "previous response"},
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test")
+ }
+}
diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go
new file mode 100644
index 000000000..e023c9c30
--- /dev/null
+++ b/pkg/agent/context_test.go
@@ -0,0 +1,209 @@
+package agent
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func msg(role, content string) providers.Message {
+ return providers.Message{Role: role, Content: content}
+}
+
+func assistantWithTools(toolIDs ...string) providers.Message {
+ calls := make([]providers.ToolCall, len(toolIDs))
+ for i, id := range toolIDs {
+ calls[i] = providers.ToolCall{ID: id, Type: "function"}
+ }
+ return providers.Message{Role: "assistant", ToolCalls: calls}
+}
+
+func toolResult(id string) providers.Message {
+ return providers.Message{Role: "tool", Content: "result", ToolCallID: id}
+}
+
+func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
+ result := sanitizeHistoryForProvider(nil)
+ if len(result) != 0 {
+ t.Fatalf("expected empty, got %d messages", len(result))
+ }
+
+ result = sanitizeHistoryForProvider([]providers.Message{})
+ if len(result) != 0 {
+ t.Fatalf("expected empty, got %d messages", len(result))
+ }
+}
+
+func TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "hello"),
+ assistantWithTools("A"),
+ toolResult("A"),
+ msg("assistant", "done"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 4 {
+ t.Fatalf("expected 4 messages, got %d", len(result))
+ }
+ assertRoles(t, result, "user", "assistant", "tool", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "do two things"),
+ assistantWithTools("A", "B"),
+ toolResult("A"),
+ toolResult("B"),
+ msg("assistant", "both done"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 5 {
+ t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "hi"),
+ msg("assistant", "thinking"),
+ assistantWithTools("A"),
+ toolResult("A"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 2 {
+ t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) {
+ history := []providers.Message{
+ toolResult("A"),
+ msg("user", "hello"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 1 {
+ t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user")
+}
+
+func TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "hello"),
+ toolResult("A"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 1 {
+ t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user")
+}
+
+func TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "hello"),
+ msg("assistant", "hi"),
+ toolResult("A"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 2 {
+ t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) {
+ history := []providers.Message{
+ assistantWithTools("A"),
+ toolResult("A"),
+ msg("user", "hello"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 1 {
+ t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user")
+}
+
+func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "do two things"),
+ assistantWithTools("A", "B"),
+ toolResult("A"),
+ toolResult("B"),
+ msg("assistant", "done"),
+ msg("user", "hi"),
+ assistantWithTools("C"),
+ toolResult("C"),
+ msg("assistant", "done again"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 9 {
+ t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "start"),
+ assistantWithTools("A", "B"),
+ toolResult("A"),
+ toolResult("B"),
+ assistantWithTools("C", "D"),
+ toolResult("C"),
+ toolResult("D"),
+ msg("assistant", "all done"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 8 {
+ t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "hello"),
+ msg("assistant", "hi"),
+ msg("user", "how are you"),
+ msg("assistant", "fine"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 4 {
+ t.Fatalf("expected 4 messages, got %d", len(result))
+ }
+ assertRoles(t, result, "user", "assistant", "user", "assistant")
+}
+
+func roles(msgs []providers.Message) []string {
+ r := make([]string, len(msgs))
+ for i, m := range msgs {
+ r[i] = m.Role
+ }
+ return r
+}
+
+func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
+ t.Helper()
+ if len(msgs) != len(expected) {
+ t.Fatalf("role count mismatch: got %v, want %v", roles(msgs), expected)
+ }
+ for i, exp := range expected {
+ if msgs[i].Role != exp {
+ t.Errorf("message[%d]: got role %q, want %q", i, msgs[i].Role, exp)
+ }
+ }
+}
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
index dfbef9fbc..65a1fe04d 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -59,7 +59,6 @@ func NewAgentInstance(
sessionsManager := session.NewSessionManager(sessionsDir)
contextBuilder := NewContextBuilder(workspace)
- contextBuilder.SetToolsRegistry(toolsRegistry)
agentID := routing.DefaultAgentID
agentName := ""
@@ -93,7 +92,47 @@ func NewAgentInstance(
Primary: model,
Fallbacks: fallbacks,
}
- candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
+ resolveFromModelList := func(raw string) (string, bool) {
+ ensureProtocol := func(model string) string {
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return ""
+ }
+ if strings.Contains(model, "/") {
+ return model
+ }
+ return "openai/" + model
+ }
+
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", false
+ }
+
+ if cfg != nil {
+ if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
+ return ensureProtocol(mc.Model), true
+ }
+
+ for i := range cfg.ModelList {
+ fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
+ if fullModel == "" {
+ continue
+ }
+ if fullModel == raw {
+ return ensureProtocol(fullModel), true
+ }
+ _, modelID := providers.ExtractProtocol(fullModel)
+ if modelID == raw {
+ return ensureProtocol(fullModel), true
+ }
+ }
+ }
+
+ return "", false
+ }
+
+ candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
return &AgentInstance{
ID: agentID,
@@ -133,7 +172,7 @@ func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefau
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
return strings.TrimSpace(agentCfg.Model.Primary)
}
- return defaults.Model
+ return defaults.GetModelName()
}
// resolveAgentFallbacks resolves the fallback models for an agent.
diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go
index fcc8e9bea..af1bf2ead 100644
--- a/pkg/agent/instance_test.go
+++ b/pkg/agent/instance_test.go
@@ -93,3 +93,77 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
}
}
+
+func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Model: "step-3.5-flash",
+ },
+ },
+ ModelList: []config.ModelConfig{
+ {
+ ModelName: "step-3.5-flash",
+ Model: "openrouter/stepfun/step-3.5-flash:free",
+ APIBase: "https://openrouter.ai/api/v1",
+ },
+ },
+ }
+
+ provider := &mockProvider{}
+ agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
+
+ if len(agent.Candidates) != 1 {
+ t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
+ }
+ if agent.Candidates[0].Provider != "openrouter" {
+ t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter")
+ }
+ if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" {
+ t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free")
+ }
+}
+
+func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Model: "glm-5",
+ },
+ },
+ ModelList: []config.ModelConfig{
+ {
+ ModelName: "glm-5",
+ Model: "glm-5",
+ APIBase: "https://api.z.ai/api/coding/paas/v4",
+ },
+ },
+ }
+
+ provider := &mockProvider{}
+ agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
+
+ if len(agent.Candidates) != 1 {
+ t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
+ }
+ if agent.Candidates[0].Provider != "openai" {
+ t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
+ }
+ if agent.Candidates[0].Model != "glm-5" {
+ t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
+ }
+}
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index eef800921..12ca35148 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -10,6 +10,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -22,6 +23,7 @@ import (
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
+ "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/skills"
@@ -39,6 +41,7 @@ type AgentLoop struct {
summarizing sync.Map
fallback *providers.FallbackChain
channelManager *channels.Manager
+ mediaStore media.MediaStore
}
// processOptions configures how a message is processed
@@ -53,6 +56,8 @@ type processOptions struct {
NoHistory bool // If true, don't load session history (for heartbeat)
}
+const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
+
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
registry := NewAgentRegistry(cfg, provider)
@@ -98,15 +103,20 @@ func registerSharedTools(
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
+ TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
+ TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
+ TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
+ TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
+ Proxy: cfg.Tools.Web.Proxy,
}); searchTool != nil {
agent.Tools.Register(searchTool)
}
- agent.Tools.Register(tools.NewWebFetchTool(50000))
+ agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy))
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
agent.Tools.Register(tools.NewI2CTool())
@@ -115,12 +125,13 @@ func registerSharedTools(
// Message tool
messageTool := tools.NewMessageTool()
messageTool.SetSendCallback(func(channel, chatID, content string) error {
- msgBus.PublishOutbound(bus.OutboundMessage{
+ pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer pubCancel()
+ return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: content,
})
- return nil
})
agent.Tools.Register(messageTool)
@@ -145,9 +156,6 @@ func registerSharedTools(
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
})
agent.Tools.Register(spawnTool)
-
- // Update context builder with the complete tools registry
- agent.ContextBuilder.SetToolsRegistry(agent.Tools)
}
}
@@ -229,33 +237,61 @@ func (al *AgentLoop) Run(ctx context.Context) error {
continue
}
- response, err := al.processMessage(ctx, msg)
- if err != nil {
- response = fmt.Sprintf("Error processing message: %v", err)
- }
+ // Process message
+ func() {
+ // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
+ // Currently disabled because files are deleted before the LLM can access their content.
+ // defer func() {
+ // if al.mediaStore != nil && msg.MediaScope != "" {
+ // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil {
+ // logger.WarnCF("agent", "Failed to release media", map[string]any{
+ // "scope": msg.MediaScope,
+ // "error": releaseErr.Error(),
+ // })
+ // }
+ // }
+ // }()
- if response != "" {
- // Check if the message tool already sent a response during this round.
- // If so, skip publishing to avoid duplicate messages to the user.
- // Use default agent's tools to check (message tool is shared).
- alreadySent := false
- defaultAgent := al.registry.GetDefaultAgent()
- if defaultAgent != nil {
- if tool, ok := defaultAgent.Tools.Get("message"); ok {
- if mt, ok := tool.(*tools.MessageTool); ok {
- alreadySent = mt.HasSentInRound()
+ response, err := al.processMessage(ctx, msg)
+ if err != nil {
+ response = fmt.Sprintf("Error processing message: %v", err)
+ }
+
+ if response != "" {
+ // Check if the message tool already sent a response during this round.
+ // If so, skip publishing to avoid duplicate messages to the user.
+ // Use default agent's tools to check (message tool is shared).
+ alreadySent := false
+ defaultAgent := al.registry.GetDefaultAgent()
+ if defaultAgent != nil {
+ if tool, ok := defaultAgent.Tools.Get("message"); ok {
+ if mt, ok := tool.(*tools.MessageTool); ok {
+ alreadySent = mt.HasSentInRound()
+ }
}
}
- }
- if !alreadySent {
- al.bus.PublishOutbound(bus.OutboundMessage{
- Channel: msg.Channel,
- ChatID: msg.ChatID,
- Content: response,
- })
+ if !alreadySent {
+ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
+ Channel: msg.Channel,
+ ChatID: msg.ChatID,
+ Content: response,
+ })
+ logger.InfoCF("agent", "Published outbound response",
+ map[string]any{
+ "channel": msg.Channel,
+ "chat_id": msg.ChatID,
+ "content_len": len(response),
+ })
+ } else {
+ logger.DebugCF(
+ "agent",
+ "Skipped outbound (message tool already sent)",
+ map[string]any{"channel": msg.Channel},
+ )
+ }
}
- }
+ }()
}
}
@@ -278,6 +314,41 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
al.channelManager = cm
}
+// SetMediaStore injects a MediaStore for media lifecycle management.
+func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
+ al.mediaStore = s
+}
+
+// inferMediaType determines the media type ("image", "audio", "video", "file")
+// from a filename and MIME content type.
+func inferMediaType(filename, contentType string) string {
+ ct := strings.ToLower(contentType)
+ fn := strings.ToLower(filename)
+
+ if strings.HasPrefix(ct, "image/") {
+ return "image"
+ }
+ if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" {
+ return "audio"
+ }
+ if strings.HasPrefix(ct, "video/") {
+ return "video"
+ }
+
+ // Fallback: infer from extension
+ ext := filepath.Ext(fn)
+ switch ext {
+ case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
+ return "image"
+ case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
+ return "audio"
+ case ".mp4", ".avi", ".mov", ".webm", ".mkv":
+ return "video"
+ }
+
+ return "file"
+}
+
// RecordLastChannel records the last active channel for this workspace.
// This uses the atomic state save mechanism to prevent data loss on crash.
func (al *AgentLoop) RecordLastChannel(channel string) error {
@@ -319,12 +390,15 @@ func (al *AgentLoop) ProcessDirectWithChannel(
// Each heartbeat is independent and doesn't accumulate context.
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
agent := al.registry.GetDefaultAgent()
+ if agent == nil {
+ return "", fmt.Errorf("no default agent for heartbeat")
+ }
return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: "heartbeat",
Channel: channel,
ChatID: chatID,
UserMessage: content,
- DefaultResponse: "I've completed processing but have no response to give.",
+ DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
NoHistory: true, // Don't load session history for heartbeat
@@ -371,6 +445,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
if !ok {
agent = al.registry.GetDefaultAgent()
}
+ if agent == nil {
+ return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
+ }
+
+ // Reset message-tool state for this round so we don't skip publishing due to a previous round.
+ if tool, ok := agent.Tools.Get("message"); ok {
+ if mt, ok := tool.(tools.ContextualTool); ok {
+ mt.SetContext(msg.Channel, msg.ChatID)
+ }
+ }
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
sessionKey := route.SessionKey
@@ -390,7 +474,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: msg.Content,
- DefaultResponse: "I've completed processing but have no response to give.",
+ DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
})
@@ -437,6 +521,9 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
// Use default agent for system messages
agent := al.registry.GetDefaultAgent()
+ if agent == nil {
+ return "", fmt.Errorf("no default agent for system message")
+ }
// Use the origin session for context
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
@@ -512,7 +599,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 8. Optional: send response via bus
if opts.SendResponse {
- al.bus.PublishOutbound(bus.OutboundMessage{
+ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: finalContent,
@@ -532,6 +619,34 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
return finalContent, nil
}
+func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
+ if al.channelManager == nil {
+ return ""
+ }
+ if ch, ok := al.channelManager.GetChannel(channelName); ok {
+ return ch.ReasoningChannelID()
+ }
+ return ""
+}
+
+func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) {
+ if reasoningContent == "" || channelName == "" || channelID == "" {
+ return
+ }
+
+ // Check context cancellation before attempting to publish,
+ // since PublishOutbound's select may race between send and ctx.Done().
+ if ctx.Err() != nil {
+ return
+ }
+
+ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
+ Channel: channelName,
+ ChatID: channelID,
+ Content: reasoningContent,
+ })
+}
+
// runLLMIteration executes the LLM call loop with tool handling.
func (al *AgentLoop) runLLMIteration(
ctx context.Context,
@@ -585,8 +700,9 @@ func (al *AgentLoop) runLLMIteration(
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
- "max_tokens": agent.MaxTokens,
- "temperature": agent.Temperature,
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
+ "prompt_cache_key": agent.ID,
})
},
)
@@ -601,8 +717,9 @@ func (al *AgentLoop) runLLMIteration(
return fbResult.Response, nil
}
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
- "max_tokens": agent.MaxTokens,
- "temperature": agent.Temperature,
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
+ "prompt_cache_key": agent.ID,
})
}
@@ -627,7 +744,7 @@ func (al *AgentLoop) runLLMIteration(
})
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
- al.bus.PublishOutbound(bus.OutboundMessage{
+ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: "Context window exceeded. Compressing history and retrying...",
@@ -656,6 +773,18 @@ func (al *AgentLoop) runLLMIteration(
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
}
+ go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
+
+ logger.DebugCF("agent", "LLM response",
+ map[string]any{
+ "agent_id": agent.ID,
+ "iteration": iteration,
+ "content_chars": len(response.Content),
+ "tool_calls": len(response.ToolCalls),
+ "reasoning": response.Reasoning,
+ "target_channel": al.targetReasoningChannelID(opts.Channel),
+ "channel": opts.Channel,
+ })
// Check if no tool calls - we're done
if len(response.ToolCalls) == 0 {
finalContent = response.Content
@@ -688,8 +817,9 @@ func (al *AgentLoop) runLLMIteration(
// Build assistant message with tool calls
assistantMsg := providers.Message{
- Role: "assistant",
- Content: response.Content,
+ Role: "assistant",
+ Content: response.Content,
+ ReasoningContent: response.ReasoningContent,
}
for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
@@ -756,7 +886,7 @@ func (al *AgentLoop) runLLMIteration(
// Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
- al.bus.PublishOutbound(bus.OutboundMessage{
+ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: toolResult.ForUser,
@@ -768,6 +898,28 @@ func (al *AgentLoop) runLLMIteration(
})
}
+ // If tool returned media refs, publish them as outbound media
+ if len(toolResult.Media) > 0 && opts.SendResponse {
+ parts := make([]bus.MediaPart, 0, len(toolResult.Media))
+ for _, ref := range toolResult.Media {
+ part := bus.MediaPart{Ref: ref}
+ // Populate metadata from MediaStore when available
+ if al.mediaStore != nil {
+ if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
+ part.Filename = meta.Filename
+ part.ContentType = meta.ContentType
+ part.Type = inferMediaType(meta.Filename, meta.ContentType)
+ }
+ }
+ parts = append(parts, part)
+ }
+ al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{
+ Channel: opts.Channel,
+ ChatID: opts.ChatID,
+ Parts: parts,
+ })
+ }
+
// Determine content for LLM based on tool result
contentForLLM := toolResult.ForLLM
if contentForLLM == "" && toolResult.Err != nil {
@@ -820,13 +972,7 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() {
defer al.summarizing.Delete(summarizeKey)
- if !constants.IsInternalChannel(channel) {
- al.bus.PublishOutbound(bus.OutboundMessage{
- Channel: channel,
- ChatID: chatID,
- Content: "Memory threshold reached. Optimizing conversation history...",
- })
- }
+ logger.Debug("Memory threshold reached. Optimizing conversation history...")
al.summarizeSession(agent, sessionKey)
}()
}
@@ -860,7 +1006,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
droppedCount := mid
keptConversation := conversation[mid:]
- newHistory := make([]providers.Message, 0)
+ newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1)
// Append compression note to the original system prompt instead of adding a new system message
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
@@ -1022,8 +1168,9 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
nil,
agent.Model,
map[string]any{
- "max_tokens": 1024,
- "temperature": 0.3,
+ "max_tokens": 1024,
+ "temperature": 0.3,
+ "prompt_cache_key": agent.ID,
},
)
if err == nil {
@@ -1072,8 +1219,9 @@ func (al *AgentLoop) summarizeBatch(
nil,
agent.Model,
map[string]any{
- "max_tokens": 1024,
- "temperature": 0.3,
+ "max_tokens": 1024,
+ "temperature": 0.3,
+ "prompt_cache_key": agent.ID,
},
)
if err != nil {
@@ -1184,21 +1332,20 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
return "", false
}
-// extractPeer extracts the routing peer from inbound message metadata.
+// extractPeer extracts the routing peer from the inbound message's structured Peer field.
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
- peerKind := msg.Metadata["peer_kind"]
- if peerKind == "" {
+ if msg.Peer.Kind == "" {
return nil
}
- peerID := msg.Metadata["peer_id"]
+ peerID := msg.Peer.ID
if peerID == "" {
- if peerKind == "direct" {
+ if msg.Peer.Kind == "direct" {
peerID = msg.SenderID
} else {
peerID = msg.ChatID
}
}
- return &routing.RoutePeer{Kind: peerKind, ID: peerID}
+ return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
}
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 4414398b1..6dfc7ef3e 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -9,11 +9,23 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools"
)
+type fakeChannel struct{ id string }
+
+func (f *fakeChannel) Name() string { return "fake" }
+func (f *fakeChannel) Start(ctx context.Context) error { return nil }
+func (f *fakeChannel) Stop(ctx context.Context) error { return nil }
+func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil }
+func (f *fakeChannel) IsRunning() bool { return true }
+func (f *fakeChannel) IsAllowed(string) bool { return true }
+func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
+func (f *fakeChannel) ReasoningChannelID() string { return f.id }
+
func TestRecordLastChannel(t *testing.T) {
// Create temp workspace
tmpDir, err := os.MkdirTemp("", "agent-test-*")
@@ -631,3 +643,158 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
}
}
+
+func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Model: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ }
+
+ al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
+ chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)
+ if err != nil {
+ t.Fatalf("Failed to create channel manager: %v", err)
+ }
+ for name, id := range map[string]string{
+ "whatsapp": "rid-whatsapp",
+ "telegram": "rid-telegram",
+ "feishu": "rid-feishu",
+ "discord": "rid-discord",
+ "maixcam": "rid-maixcam",
+ "qq": "rid-qq",
+ "dingtalk": "rid-dingtalk",
+ "slack": "rid-slack",
+ "line": "rid-line",
+ "onebot": "rid-onebot",
+ "wecom": "rid-wecom",
+ "wecom_app": "rid-wecom-app",
+ } {
+ chManager.RegisterChannel(name, &fakeChannel{id: id})
+ }
+ al.SetChannelManager(chManager)
+ tests := []struct {
+ channel string
+ wantID string
+ }{
+ {channel: "whatsapp", wantID: "rid-whatsapp"},
+ {channel: "telegram", wantID: "rid-telegram"},
+ {channel: "feishu", wantID: "rid-feishu"},
+ {channel: "discord", wantID: "rid-discord"},
+ {channel: "maixcam", wantID: "rid-maixcam"},
+ {channel: "qq", wantID: "rid-qq"},
+ {channel: "dingtalk", wantID: "rid-dingtalk"},
+ {channel: "slack", wantID: "rid-slack"},
+ {channel: "line", wantID: "rid-line"},
+ {channel: "onebot", wantID: "rid-onebot"},
+ {channel: "wecom", wantID: "rid-wecom"},
+ {channel: "wecom_app", wantID: "rid-wecom-app"},
+ {channel: "unknown", wantID: ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.channel, func(t *testing.T) {
+ got := al.targetReasoningChannelID(tt.channel)
+ if got != tt.wantID {
+ t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID)
+ }
+ })
+ }
+}
+
+func TestHandleReasoning(t *testing.T) {
+ newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) {
+ t.Helper()
+ tmpDir, err := os.MkdirTemp("", "agent-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Model: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ }
+ msgBus := bus.NewMessageBus()
+ return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus
+ }
+
+ t.Run("skips when any required field is empty", func(t *testing.T) {
+ al, msgBus := newLoop(t)
+ al.handleReasoning(context.Background(), "reasoning", "telegram", "")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel()
+ if msg, ok := msgBus.SubscribeOutbound(ctx); ok {
+ t.Fatalf("expected no outbound message, got %+v", msg)
+ }
+ })
+
+ t.Run("publishes one message for non telegram", func(t *testing.T) {
+ al, msgBus := newLoop(t)
+ al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ msg, ok := msgBus.SubscribeOutbound(ctx)
+ if !ok {
+ t.Fatal("expected an outbound message")
+ }
+ if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" {
+ t.Fatalf("unexpected outbound message: %+v", msg)
+ }
+ })
+
+ t.Run("publishes one message for telegram", func(t *testing.T) {
+ al, msgBus := newLoop(t)
+ reasoning := "hello telegram reasoning"
+ al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ msg, ok := msgBus.SubscribeOutbound(ctx)
+ if !ok {
+ t.Fatal("expected outbound message")
+ }
+
+ if msg.Channel != "telegram" {
+ t.Fatalf("expected telegram channel message, got %+v", msg)
+ }
+ if msg.ChatID != "tg-chat" {
+ t.Fatalf("expected chatID tg-chat, got %+v", msg)
+ }
+ if msg.Content != reasoning {
+ t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
+ }
+ })
+ t.Run("expired ctx", func(t *testing.T) {
+ al, msgBus := newLoop(t)
+ reasoning := "hello telegram reasoning"
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ al.handleReasoning(ctx, reasoning, "telegram", "tg-chat")
+
+ ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ msg, ok := msgBus.SubscribeOutbound(ctx)
+ if ok {
+ t.Fatalf("expected no outbound message, got %+v", msg)
+ }
+ })
+}
diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go
index dd5f4441c..87a687479 100644
--- a/pkg/agent/memory.go
+++ b/pkg/agent/memory.go
@@ -12,6 +12,8 @@ import (
"path/filepath"
"strings"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
// MemoryStore manages persistent memory for the agent.
@@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string {
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error {
- return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ // Using 0o600 (owner read/write only) for secure default permissions.
+ return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
}
// ReadToday reads today's daily note.
@@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
// Ensure month directory exists
monthDir := filepath.Dir(todayFile)
- os.MkdirAll(monthDir, 0o755)
+ if err := os.MkdirAll(monthDir, 0o755); err != nil {
+ return err
+ }
var existingContent string
if data, err := os.ReadFile(todayFile); err == nil {
@@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
newContent = existingContent + "\n" + content
}
- return os.WriteFile(todayFile, []byte(newContent), 0o644)
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
}
// GetRecentDailyNotes returns daily notes from the last N days.
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index cf8c1c9c4..ba757ffd4 100644
--- a/pkg/auth/oauth.go
+++ b/pkg/auth/oauth.go
@@ -156,7 +156,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
case manualInput := <-manualCh:
if manualInput == "" {
- return nil, fmt.Errorf("manual input cancelled")
+ return nil, fmt.Errorf("manual input canceled")
}
// Extract code from URL if it's a full URL
code := manualInput
diff --git a/pkg/auth/store.go b/pkg/auth/store.go
index 64708421b..283dc6977 100644
--- a/pkg/auth/store.go
+++ b/pkg/auth/store.go
@@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
type AuthCredential struct {
@@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) {
func SaveStore(store *AuthStore) error {
path := authFilePath()
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
-
data, err := json.MarshalIndent(store, "", " ")
if err != nil {
return err
}
- return os.WriteFile(path, data, 0o600)
+
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ return fileutil.WriteFileAtomic(path, data, 0o600)
}
func GetCredential(provider string) (*AuthCredential, error) {
diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go
index 58c0a25d5..f5ff9587d 100644
--- a/pkg/bus/bus.go
+++ b/pkg/bus/bus.go
@@ -2,81 +2,156 @@ package bus
import (
"context"
- "sync"
+ "errors"
+ "sync/atomic"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
)
+// ErrBusClosed is returned when publishing to a closed MessageBus.
+var ErrBusClosed = errors.New("message bus closed")
+
+const defaultBusBufferSize = 64
+
type MessageBus struct {
- inbound chan InboundMessage
- outbound chan OutboundMessage
- handlers map[string]MessageHandler
- closed bool
- mu sync.RWMutex
+ inbound chan InboundMessage
+ outbound chan OutboundMessage
+ outboundMedia chan OutboundMediaMessage
+ done chan struct{}
+ closed atomic.Bool
}
func NewMessageBus() *MessageBus {
return &MessageBus{
- inbound: make(chan InboundMessage, 100),
- outbound: make(chan OutboundMessage, 100),
- handlers: make(map[string]MessageHandler),
+ inbound: make(chan InboundMessage, defaultBusBufferSize),
+ outbound: make(chan OutboundMessage, defaultBusBufferSize),
+ outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize),
+ done: make(chan struct{}),
}
}
-func (mb *MessageBus) PublishInbound(msg InboundMessage) {
- mb.mu.RLock()
- defer mb.mu.RUnlock()
- if mb.closed {
- return
+func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
+ if mb.closed.Load() {
+ return ErrBusClosed
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ select {
+ case mb.inbound <- msg:
+ return nil
+ case <-mb.done:
+ return ErrBusClosed
+ case <-ctx.Done():
+ return ctx.Err()
}
- mb.inbound <- msg
}
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
select {
- case msg := <-mb.inbound:
- return msg, true
+ case msg, ok := <-mb.inbound:
+ return msg, ok
+ case <-mb.done:
+ return InboundMessage{}, false
case <-ctx.Done():
return InboundMessage{}, false
}
}
-func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
- mb.mu.RLock()
- defer mb.mu.RUnlock()
- if mb.closed {
- return
+func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
+ if mb.closed.Load() {
+ return ErrBusClosed
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ select {
+ case mb.outbound <- msg:
+ return nil
+ case <-mb.done:
+ return ErrBusClosed
+ case <-ctx.Done():
+ return ctx.Err()
}
- mb.outbound <- msg
}
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
select {
- case msg := <-mb.outbound:
- return msg, true
+ case msg, ok := <-mb.outbound:
+ return msg, ok
+ case <-mb.done:
+ return OutboundMessage{}, false
case <-ctx.Done():
return OutboundMessage{}, false
}
}
-func (mb *MessageBus) RegisterHandler(channel string, handler MessageHandler) {
- mb.mu.Lock()
- defer mb.mu.Unlock()
- mb.handlers[channel] = handler
+func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
+ if mb.closed.Load() {
+ return ErrBusClosed
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ select {
+ case mb.outboundMedia <- msg:
+ return nil
+ case <-mb.done:
+ return ErrBusClosed
+ case <-ctx.Done():
+ return ctx.Err()
+ }
}
-func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) {
- mb.mu.RLock()
- defer mb.mu.RUnlock()
- handler, ok := mb.handlers[channel]
- return handler, ok
+func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) {
+ select {
+ case msg, ok := <-mb.outboundMedia:
+ return msg, ok
+ case <-mb.done:
+ return OutboundMediaMessage{}, false
+ case <-ctx.Done():
+ return OutboundMediaMessage{}, false
+ }
}
func (mb *MessageBus) Close() {
- mb.mu.Lock()
- defer mb.mu.Unlock()
- if mb.closed {
- return
+ if mb.closed.CompareAndSwap(false, true) {
+ close(mb.done)
+
+ // Drain buffered channels so messages aren't silently lost.
+ // Channels are NOT closed to avoid send-on-closed panics from concurrent publishers.
+ drained := 0
+ for {
+ select {
+ case <-mb.inbound:
+ drained++
+ default:
+ goto doneInbound
+ }
+ }
+ doneInbound:
+ for {
+ select {
+ case <-mb.outbound:
+ drained++
+ default:
+ goto doneOutbound
+ }
+ }
+ doneOutbound:
+ for {
+ select {
+ case <-mb.outboundMedia:
+ drained++
+ default:
+ goto doneMedia
+ }
+ }
+ doneMedia:
+ if drained > 0 {
+ logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
+ "count": drained,
+ })
+ }
}
- mb.closed = true
- close(mb.inbound)
- close(mb.outbound)
}
diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go
new file mode 100644
index 000000000..a50586df1
--- /dev/null
+++ b/pkg/bus/bus_test.go
@@ -0,0 +1,229 @@
+package bus
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestPublishConsume(t *testing.T) {
+ mb := NewMessageBus()
+ defer mb.Close()
+
+ ctx := context.Background()
+
+ msg := InboundMessage{
+ Channel: "test",
+ SenderID: "user1",
+ ChatID: "chat1",
+ Content: "hello",
+ }
+
+ if err := mb.PublishInbound(ctx, msg); err != nil {
+ t.Fatalf("PublishInbound failed: %v", err)
+ }
+
+ got, ok := mb.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("ConsumeInbound returned ok=false")
+ }
+ if got.Content != "hello" {
+ t.Fatalf("expected content 'hello', got %q", got.Content)
+ }
+ if got.Channel != "test" {
+ t.Fatalf("expected channel 'test', got %q", got.Channel)
+ }
+}
+
+func TestPublishOutboundSubscribe(t *testing.T) {
+ mb := NewMessageBus()
+ defer mb.Close()
+
+ ctx := context.Background()
+
+ msg := OutboundMessage{
+ Channel: "telegram",
+ ChatID: "123",
+ Content: "world",
+ }
+
+ if err := mb.PublishOutbound(ctx, msg); err != nil {
+ t.Fatalf("PublishOutbound failed: %v", err)
+ }
+
+ got, ok := mb.SubscribeOutbound(ctx)
+ if !ok {
+ t.Fatal("SubscribeOutbound returned ok=false")
+ }
+ if got.Content != "world" {
+ t.Fatalf("expected content 'world', got %q", got.Content)
+ }
+}
+
+func TestPublishInbound_ContextCancel(t *testing.T) {
+ mb := NewMessageBus()
+ defer mb.Close()
+
+ // Fill the buffer
+ ctx := context.Background()
+ for i := 0; i < defaultBusBufferSize; i++ {
+ if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
+ t.Fatalf("fill failed at %d: %v", i, err)
+ }
+ }
+
+ // Now buffer is full; publish with a canceled context
+ cancelCtx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"})
+ if err == nil {
+ t.Fatal("expected error from canceled context, got nil")
+ }
+ if err != context.Canceled {
+ t.Fatalf("expected context.Canceled, got %v", err)
+ }
+}
+
+func TestPublishInbound_BusClosed(t *testing.T) {
+ mb := NewMessageBus()
+ mb.Close()
+
+ err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
+ if err != ErrBusClosed {
+ t.Fatalf("expected ErrBusClosed, got %v", err)
+ }
+}
+
+func TestPublishOutbound_BusClosed(t *testing.T) {
+ mb := NewMessageBus()
+ mb.Close()
+
+ err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"})
+ if err != ErrBusClosed {
+ t.Fatalf("expected ErrBusClosed, got %v", err)
+ }
+}
+
+func TestConsumeInbound_ContextCancel(t *testing.T) {
+ mb := NewMessageBus()
+ defer mb.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, ok := mb.ConsumeInbound(ctx)
+ if ok {
+ t.Fatal("expected ok=false when context is canceled")
+ }
+}
+
+func TestConsumeInbound_BusClosed(t *testing.T) {
+ mb := NewMessageBus()
+ mb.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ _, ok := mb.ConsumeInbound(ctx)
+ if ok {
+ t.Fatal("expected ok=false when bus is closed")
+ }
+}
+
+func TestSubscribeOutbound_BusClosed(t *testing.T) {
+ mb := NewMessageBus()
+ mb.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ _, ok := mb.SubscribeOutbound(ctx)
+ if ok {
+ t.Fatal("expected ok=false when bus is closed")
+ }
+}
+
+func TestConcurrentPublishClose(t *testing.T) {
+ mb := NewMessageBus()
+ ctx := context.Background()
+
+ const numGoroutines = 100
+ var wg sync.WaitGroup
+ wg.Add(numGoroutines + 1)
+
+ // Spawn many goroutines trying to publish
+ for i := 0; i < numGoroutines; i++ {
+ go func() {
+ defer wg.Done()
+ // Use a short timeout context so we don't block forever after close
+ publishCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
+ defer cancel()
+ // Errors are expected; we just must not panic or deadlock
+ _ = mb.PublishInbound(publishCtx, InboundMessage{Content: "concurrent"})
+ }()
+ }
+
+ // Close from another goroutine
+ go func() {
+ defer wg.Done()
+ time.Sleep(5 * time.Millisecond)
+ mb.Close()
+ }()
+
+ // Must complete without deadlock
+ done := make(chan struct{})
+ go func() {
+ wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ // success
+ case <-time.After(5 * time.Second):
+ t.Fatal("test timed out - possible deadlock")
+ }
+}
+
+func TestPublishInbound_FullBuffer(t *testing.T) {
+ mb := NewMessageBus()
+ defer mb.Close()
+
+ ctx := context.Background()
+
+ // Fill the buffer
+ for i := 0; i < defaultBusBufferSize; i++ {
+ if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
+ t.Fatalf("fill failed at %d: %v", i, err)
+ }
+ }
+
+ // Buffer is full; publish with short timeout
+ timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
+ defer cancel()
+
+ err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"})
+ if err == nil {
+ t.Fatal("expected error when buffer is full and context times out")
+ }
+ if err != context.DeadlineExceeded {
+ t.Fatalf("expected context.DeadlineExceeded, got %v", err)
+ }
+}
+
+func TestCloseIdempotent(t *testing.T) {
+ mb := NewMessageBus()
+
+ // Multiple Close calls must not panic
+ mb.Close()
+ mb.Close()
+ mb.Close()
+
+ // After close, publish should return ErrBusClosed
+ err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
+ if err != ErrBusClosed {
+ t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err)
+ }
+}
diff --git a/pkg/bus/types.go b/pkg/bus/types.go
index 44f9181a5..7ad8f0417 100644
--- a/pkg/bus/types.go
+++ b/pkg/bus/types.go
@@ -1,11 +1,30 @@
package bus
+// Peer identifies the routing peer for a message (direct, group, channel, etc.)
+type Peer struct {
+ Kind string `json:"kind"` // "direct" | "group" | "channel" | ""
+ ID string `json:"id"`
+}
+
+// SenderInfo provides structured sender identity information.
+type SenderInfo struct {
+ Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ...
+ PlatformID string `json:"platform_id,omitempty"` // raw platform ID, e.g. "123456"
+ CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" format
+ Username string `json:"username,omitempty"` // username (e.g. @alice)
+ DisplayName string `json:"display_name,omitempty"` // display name
+}
+
type InboundMessage struct {
Channel string `json:"channel"`
SenderID string `json:"sender_id"`
+ Sender SenderInfo `json:"sender"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
+ Peer Peer `json:"peer"` // routing peer
+ MessageID string `json:"message_id,omitempty"` // platform message ID
+ MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
SessionKey string `json:"session_key"`
Metadata map[string]string `json:"metadata,omitempty"`
}
@@ -16,4 +35,18 @@ type OutboundMessage struct {
Content string `json:"content"`
}
-type MessageHandler func(InboundMessage) error
+// MediaPart describes a single media attachment to send.
+type MediaPart struct {
+ Type string `json:"type"` // "image" | "audio" | "video" | "file"
+ Ref string `json:"ref"` // media store ref, e.g. "media://abc123"
+ Caption string `json:"caption,omitempty"` // optional caption text
+ Filename string `json:"filename,omitempty"` // original filename hint
+ ContentType string `json:"content_type,omitempty"` // MIME type hint
+}
+
+// OutboundMediaMessage carries media attachments from Agent to channels via the bus.
+type OutboundMediaMessage struct {
+ Channel string `json:"channel"`
+ ChatID string `json:"chat_id"`
+ Parts []MediaPart `json:"parts"`
+}
diff --git a/pkg/channels/README.md b/pkg/channels/README.md
new file mode 100644
index 000000000..52b9f98f4
--- /dev/null
+++ b/pkg/channels/README.md
@@ -0,0 +1,1331 @@
+# PicoClaw Channel System Refactor: Complete Development Guide
+
+> **Branch**: `refactor/channel-system`
+> **Status**: Active development (~40 commits)
+> **Scope**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/`
+
+---
+
+## Table of Contents
+
+- [Part 1: Architecture Overview](#part-1-architecture-overview)
+- [Part 2: Migration Guide — From main Branch to Refactored Branch](#part-2-migration-guide--from-main-branch-to-refactored-branch)
+- [Part 3: New Channel Development Guide — Implementing a Channel from Scratch](#part-3-new-channel-development-guide--implementing-a-channel-from-scratch)
+- [Part 4: Core Subsystem Details](#part-4-core-subsystem-details)
+- [Part 5: Key Design Decisions and Conventions](#part-5-key-design-decisions-and-conventions)
+- [Appendix: Complete File Listing and Interface Quick Reference](#appendix-complete-file-listing-and-interface-quick-reference)
+
+---
+
+## Part 1: Architecture Overview
+
+### 1.1 Before and After Comparison
+
+**Before Refactor (main branch)**:
+
+```
+pkg/channels/
+├── telegram.go # Each channel directly in the channels package
+├── discord.go
+├── slack.go
+├── manager.go # Manager directly references each channel type
+├── ...
+```
+
+- All channel implementations lived at the top level of `pkg/channels/`
+- Manager constructed each channel via `switch` or `if-else` chains
+- Routing info like Peer and MessageID was buried in `Metadata map[string]string`
+- No rate limiting or retry on message sending
+- No unified media file lifecycle management
+- Each channel ran its own HTTP server
+- Group chat trigger filtering logic was scattered across channels
+
+**After Refactor (refactor/channel-system branch)**:
+
+```
+pkg/channels/
+├── base.go # BaseChannel shared abstraction layer
+├── interfaces.go # Optional capability interfaces (TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder)
+├── media.go # MediaSender optional interface
+├── webhook.go # WebhookHandler, HealthChecker optional interfaces
+├── errors.go # Sentinel errors (ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed)
+├── errutil.go # Error classification helpers
+├── registry.go # Factory registry (RegisterFactory / getFactory)
+├── manager.go # Unified orchestration: Worker queues, rate limiting, retries, Typing/Placeholder, shared HTTP
+├── split.go # Smart long-message splitting (preserves code block integrity)
+├── telegram/ # Each channel in its own sub-package
+│ ├── init.go # Factory registration
+│ ├── telegram.go # Implementation
+│ └── telegram_commands.go
+├── discord/
+│ ├── init.go
+│ └── discord.go
+├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ maixcam/ pico/
+│ └── ...
+
+pkg/bus/
+├── bus.go # MessageBus (buffer 64, safe close + drain)
+├── types.go # Structured message types (Peer, SenderInfo, MediaPart, InboundMessage, OutboundMessage, OutboundMediaMessage)
+
+pkg/media/
+├── store.go # MediaStore interface + FileMediaStore implementation (two-phase release, TTL cleanup)
+
+pkg/identity/
+├── identity.go # Unified user identity: canonical "platform:id" format + backward-compatible matching
+```
+
+### 1.2 Message Flow Overview
+
+```
+┌────────────┐ InboundMessage ┌───────────┐ LLM + Tools ┌────────────┐
+│ Telegram │──┐ │ │ │ │
+│ Discord │──┤ PublishInbound() │ │ PublishOutbound() │ │
+│ Slack │──┼──────────────────────▶ │ MessageBus │ ◀─────────────────── │ AgentLoop │
+│ LINE │──┤ (buffered chan, 64) │ │ (buffered chan, 64) │ │
+│ ... │──┘ │ │ │ │
+└────────────┘ └─────┬─────┘ └────────────┘
+ │
+ SubscribeOutbound() │ SubscribeOutboundMedia()
+ ▼
+ ┌───────────────────┐
+ │ Manager │
+ │ ├── dispatchOutbound() Route to Worker queues
+ │ ├── dispatchOutboundMedia()
+ │ ├── runWorker() Message split + sendWithRetry()
+ │ ├── runMediaWorker() sendMediaWithRetry()
+ │ ├── preSend() Stop Typing + Undo Reaction + Edit Placeholder
+ │ └── runTTLJanitor() Clean up expired Typing/Placeholder
+ └────────┬──────────┘
+ │
+ channel.Send() / SendMedia()
+ │
+ ▼
+ ┌────────────────┐
+ │ Platform APIs │
+ └────────────────┘
+```
+
+### 1.3 Key Design Principles
+
+| Principle | Description |
+|-----------|-------------|
+| **Sub-package Isolation** | Each channel is a standalone Go sub-package, depending on `BaseChannel` and interfaces from the `channels` parent package |
+| **Factory Registration** | Sub-packages self-register via `init()`, Manager looks up factories by name, eliminating import coupling |
+| **Capability Discovery** | Optional capabilities are declared via interfaces (`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`), discovered by Manager via runtime type assertions |
+| **Structured Messages** | Peer, MessageID, and SenderInfo promoted from Metadata to first-class fields on InboundMessage |
+| **Error Classification** | Channels return sentinel errors (`ErrRateLimit`, `ErrTemporary`, etc.), Manager uses these to determine retry strategy |
+| **Centralized Orchestration** | Rate limiting, message splitting, retries, and Typing/Reaction/Placeholder management are all handled by Manager and BaseChannel; channels only need to implement Send |
+
+---
+
+## Part 2: Migration Guide — From main Branch to Refactored Branch
+
+### 2.1 If You Have Unmerged Channel Changes
+
+#### Step 1: Identify which files you modified
+
+On the main branch, channel files were directly in `pkg/channels/` top level, e.g.:
+- `pkg/channels/telegram.go`
+- `pkg/channels/discord.go`
+
+After refactoring, these files have been removed and code moved to corresponding sub-packages:
+- `pkg/channels/telegram/telegram.go`
+- `pkg/channels/discord/discord.go`
+
+#### Step 2: Understand the structural change mapping
+
+| main branch file | Refactored branch location | Changes |
+|---|---|---|
+| `pkg/channels/telegram.go` | `pkg/channels/telegram/telegram.go` + `init.go` | Package name changed from `channels` to `telegram` |
+| `pkg/channels/discord.go` | `pkg/channels/discord/discord.go` + `init.go` | Same as above |
+| `pkg/channels/manager.go` | `pkg/channels/manager.go` | Extensively rewritten |
+| _(did not exist)_ | `pkg/channels/base.go` | New shared abstraction layer |
+| _(did not exist)_ | `pkg/channels/registry.go` | New factory registry |
+| _(did not exist)_ | `pkg/channels/errors.go` + `errutil.go` | New error classification system |
+| _(did not exist)_ | `pkg/channels/interfaces.go` | New optional capability interfaces |
+| _(did not exist)_ | `pkg/channels/media.go` | New MediaSender interface |
+| _(did not exist)_ | `pkg/channels/webhook.go` | New WebhookHandler/HealthChecker |
+| _(did not exist)_ | `pkg/channels/split.go` | New message splitting (migrated from utils) |
+| _(did not exist)_ | `pkg/bus/types.go` | New structured message types |
+| _(did not exist)_ | `pkg/media/store.go` | New media file lifecycle management |
+| _(did not exist)_ | `pkg/identity/identity.go` | New unified user identity |
+
+#### Step 3: Migrate your channel code
+
+Using Telegram as an example, the main changes are:
+
+**3a. Package declaration and imports**
+
+```go
+// Old code (main branch)
+package channels
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// New code (refactored branch)
+package telegram
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels" // Reference parent package
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity" // New
+ "github.com/sipeed/picoclaw/pkg/media" // New (if media support needed)
+)
+```
+
+**3b. Struct embeds BaseChannel**
+
+```go
+// Old code: directly held bus, config, etc. fields
+type TelegramChannel struct {
+ bus *bus.MessageBus
+ config *config.Config
+ running bool
+ allowList []string
+ // ...
+}
+
+// New code: embed BaseChannel, which provides bus, running, allowList, etc.
+type TelegramChannel struct {
+ *channels.BaseChannel // Embed shared abstraction
+ bot *telego.Bot
+ config *config.Config
+ // ... only channel-specific fields
+}
+```
+
+**3c. Constructor**
+
+```go
+// Old code: direct assignment
+func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
+ return &TelegramChannel{
+ bus: bus,
+ config: cfg,
+ allowList: cfg.Channels.Telegram.AllowFrom,
+ // ...
+ }, nil
+}
+
+// New code: use NewBaseChannel + functional options
+func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
+ base := channels.NewBaseChannel(
+ "telegram", // Name
+ cfg.Channels.Telegram, // Raw config (any type)
+ bus, // Message bus
+ cfg.Channels.Telegram.AllowFrom, // Allow list
+ channels.WithMaxMessageLength(4096), // Platform message length limit
+ channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // Group trigger config
+ )
+ return &TelegramChannel{
+ BaseChannel: base,
+ bot: bot,
+ config: cfg,
+ }, nil
+}
+```
+
+**3d. Start/Stop lifecycle**
+
+```go
+// New code: use SetRunning atomic operation
+func (c *TelegramChannel) Start(ctx context.Context) error {
+ // ... initialize bot, webhook, etc.
+ c.SetRunning(true) // Must be called after ready
+ go bh.Start()
+ return nil
+}
+
+func (c *TelegramChannel) Stop(ctx context.Context) error {
+ c.SetRunning(false) // Must be called before cleanup
+ // ... stop bot handler, cancel context
+ return nil
+}
+```
+
+**3e. Send method error returns**
+
+```go
+// Old code: returns plain error
+func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.running { return fmt.Errorf("not running") }
+ // ...
+ if err != nil { return err }
+}
+
+// New code: must return sentinel errors for Manager to determine retry strategy
+func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning // ← Manager will not retry
+ }
+ // ...
+ if err != nil {
+ // Use ClassifySendError to wrap error based on HTTP status code
+ return channels.ClassifySendError(statusCode, err)
+ // Or manually wrap:
+ // return fmt.Errorf("%w: %v", channels.ErrTemporary, err)
+ // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err)
+ // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err)
+ }
+ return nil
+}
+```
+
+**3f. Message reception (Inbound)**
+
+```go
+// Old code: directly construct InboundMessage and publish
+msg := bus.InboundMessage{
+ Channel: "telegram",
+ SenderID: senderID,
+ ChatID: chatID,
+ Content: content,
+ Metadata: map[string]string{
+ "peer_kind": "group", // Routing info buried in metadata
+ "peer_id": chatID,
+ "message_id": msgID,
+ },
+}
+c.bus.PublishInbound(ctx, msg)
+
+// New code: use BaseChannel.HandleMessage with structured fields
+sender := bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: strconv.FormatInt(from.ID, 10),
+ CanonicalID: identity.BuildCanonicalID("telegram", strconv.FormatInt(from.ID, 10)),
+ Username: from.Username,
+ DisplayName: from.FirstName,
+}
+
+peer := bus.Peer{
+ Kind: "group", // or "direct"
+ ID: chatID,
+}
+
+// HandleMessage internally calls IsAllowedSender for permission checks, builds MediaScope, and publishes to bus
+c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, sender)
+```
+
+**3g. Add factory registration (required)**
+
+Create `init.go` for your channel:
+
+```go
+// pkg/channels/telegram/init.go
+package telegram
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewTelegramChannel(cfg, b)
+ })
+}
+```
+
+**3h. Import sub-package in Gateway**
+
+```go
+// cmd/picoclaw/internal/gateway/helpers.go
+import (
+ _ "github.com/sipeed/picoclaw/pkg/channels/telegram" // Triggers init() registration
+ _ "github.com/sipeed/picoclaw/pkg/channels/discord"
+ _ "github.com/sipeed/picoclaw/pkg/channels/your_new_channel" // New addition
+)
+```
+
+#### Step 4: Migrate bus message usage
+
+If your code directly reads routing fields from `InboundMessage.Metadata`:
+
+```go
+// Old code
+peerKind := msg.Metadata["peer_kind"]
+peerID := msg.Metadata["peer_id"]
+msgID := msg.Metadata["message_id"]
+
+// New code
+peerKind := msg.Peer.Kind // First-class field
+peerID := msg.Peer.ID // First-class field
+msgID := msg.MessageID // First-class field
+sender := msg.Sender // bus.SenderInfo struct
+scope := msg.MediaScope // Media lifecycle scope
+```
+
+#### Step 5: Migrate allow-list checks
+
+```go
+// Old code
+if !c.isAllowed(senderID) { return }
+
+// New code: prefer structured check
+if !c.IsAllowedSender(sender) { return }
+// Or fall back to string check:
+if !c.IsAllowed(senderID) { return }
+```
+
+`BaseChannel.HandleMessage` already handles this logic internally — no need to duplicate the check in your channel.
+
+### 2.2 If You Have Manager Modifications
+
+The Manager has been completely rewritten. Your modifications will need to account for the new architecture:
+
+| Old Manager Responsibility | New Manager Responsibility |
+|---|---|
+| Directly construct channels (switch/if-else) | Look up and construct via factory registry |
+| Directly call channel.Send | Per-channel Worker queues + rate limiting + retries |
+| No message splitting | Automatic splitting based on MaxMessageLength |
+| Each channel runs its own HTTP server | Unified shared HTTP server |
+| No Typing/Placeholder management | Unified preSend handles Typing stop + Reaction undo + Placeholder edit; inbound-side BaseChannel.HandleMessage auto-orchestrates Typing/Reaction/Placeholder |
+| No TTL cleanup | runTTLJanitor periodically cleans up expired Typing/Reaction/Placeholder entries |
+
+### 2.3 If You Have Agent Loop Modifications
+
+Main changes to the Agent Loop:
+
+1. **MediaStore injection**: `agentLoop.SetMediaStore(mediaStore)` — Agent resolves media references produced by tools via MediaStore
+2. **ChannelManager injection**: `agentLoop.SetChannelManager(channelManager)` — Agent can query channel state
+3. **OutboundMediaMessage**: Agent now sends media messages via `bus.PublishOutboundMedia()` instead of embedding them in text replies
+4. **extractPeer**: Routing uses `msg.Peer` structured fields instead of Metadata lookups
+
+---
+
+## Part 3: New Channel Development Guide — Implementing a Channel from Scratch
+
+### 3.1 Minimum Implementation Checklist
+
+To add a new chat platform (e.g., `matrix`), you need to:
+
+1. ✅ Create sub-package directory `pkg/channels/matrix/`
+2. ✅ Create `init.go` — factory registration
+3. ✅ Create `matrix.go` — channel implementation
+4. ✅ Add blank import in Gateway helpers
+5. ✅ Add config check in Manager.initChannels()
+6. ✅ Add config struct in `pkg/config/`
+
+### 3.2 Complete Template
+
+#### `pkg/channels/matrix/init.go`
+
+```go
+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, b)
+ })
+}
+```
+
+#### `pkg/channels/matrix/matrix.go`
+
+```go
+package matrix
+
+import (
+ "context"
+ "fmt"
+
+ "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"
+)
+
+// MatrixChannel implements channels.Channel for the Matrix protocol.
+type MatrixChannel struct {
+ *channels.BaseChannel // Must embed
+ config *config.Config
+ ctx context.Context
+ cancel context.CancelFunc
+ // ... Matrix SDK client, etc.
+}
+
+func NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChannel, error) {
+ matrixCfg := cfg.Channels.Matrix // Assumes this field exists in config
+
+ base := channels.NewBaseChannel(
+ "matrix", // Channel name (globally unique)
+ matrixCfg, // Raw config
+ msgBus, // Message bus
+ matrixCfg.AllowFrom, // Allow list
+ channels.WithMaxMessageLength(65536), // Matrix message length limit
+ channels.WithGroupTrigger(matrixCfg.GroupTrigger),
+ )
+
+ return &MatrixChannel{
+ BaseChannel: base,
+ config: cfg,
+ }, nil
+}
+
+// ========== Required Channel Interface Methods ==========
+
+func (c *MatrixChannel) Start(ctx context.Context) error {
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ // 1. Initialize Matrix client
+ // 2. Start listening for messages
+ // 3. Mark as running
+ c.SetRunning(true)
+
+ logger.InfoC("matrix", "Matrix channel started")
+ return nil
+}
+
+func (c *MatrixChannel) Stop(ctx context.Context) error {
+ c.SetRunning(false)
+
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ logger.InfoC("matrix", "Matrix channel stopped")
+ return nil
+}
+
+func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ // 1. Check running state
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ // 2. Send message to Matrix
+ err := c.sendToMatrix(ctx, msg.ChatID, msg.Content)
+ if err != nil {
+ // 3. Must use error classification wrapping
+ // If you have an HTTP status code:
+ // return channels.ClassifySendError(statusCode, err)
+ // If it's a network error:
+ // return channels.ClassifyNetError(err)
+ // If manual classification is needed:
+ return fmt.Errorf("%w: %v", channels.ErrTemporary, err)
+ }
+
+ return nil
+}
+
+// ========== Incoming Message Handling ==========
+
+func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content string, msgID string) {
+ // 1. Construct structured sender identity
+ sender := bus.SenderInfo{
+ Platform: "matrix",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("matrix", senderID),
+ Username: senderID,
+ DisplayName: displayName,
+ }
+
+ // 2. Determine Peer type (direct vs group)
+ peer := bus.Peer{
+ Kind: "group", // or "direct"
+ ID: roomID,
+ }
+
+ // 3. Group chat filtering (if applicable)
+ isGroup := peer.Kind == "group"
+ if isGroup {
+ isMentioned := false // Detect @mentions based on platform specifics
+ shouldRespond, cleanContent := c.ShouldRespondInGroup(isMentioned, content)
+ if !shouldRespond {
+ return
+ }
+ content = cleanContent
+ }
+
+ // 4. Handle media attachments (if any)
+ var mediaRefs []string
+ store := c.GetMediaStore()
+ if store != nil {
+ // Download attachment locally → store.Store() → get ref
+ // mediaRefs = append(mediaRefs, ref)
+ }
+
+ // 5. Call HandleMessage to publish to bus
+ // HandleMessage internally will:
+ // - Check IsAllowedSender/IsAllowed
+ // - Build MediaScope
+ // - Publish InboundMessage
+ c.HandleMessage(
+ c.ctx,
+ peer,
+ msgID, // Platform message ID
+ senderID, // Raw sender ID
+ roomID, // Chat/room ID
+ content, // Message content
+ mediaRefs, // Media reference list
+ nil, // Extra metadata (usually nil)
+ sender, // SenderInfo (variadic parameter)
+ )
+}
+
+// ========== Internal Methods ==========
+
+func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error {
+ // Actual Matrix SDK call
+ return nil
+}
+```
+
+### 3.3 Optional Capability Interfaces
+
+Depending on platform capabilities, your channel can optionally implement the following interfaces:
+
+#### MediaSender — Send Media Attachments
+
+```go
+// If the platform supports sending images/files/audio/video
+func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ store := c.GetMediaStore()
+ if store == nil {
+ return fmt.Errorf("no media store: %w", channels.ErrSendFailed)
+ }
+
+ for _, part := range msg.Parts {
+ localPath, err := store.Resolve(part.Ref)
+ if err != nil {
+ logger.ErrorCF("matrix", "Failed to resolve media", map[string]any{
+ "ref": part.Ref, "error": err.Error(),
+ })
+ continue
+ }
+
+ // Call the appropriate API based on part.Type ("image"|"audio"|"video"|"file")
+ switch part.Type {
+ case "image":
+ // Upload image to Matrix
+ default:
+ // Upload file to Matrix
+ }
+ }
+ return nil
+}
+```
+
+#### TypingCapable — Typing Indicator
+
+```go
+// If the platform supports "typing..." indicators
+func (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (stop func(), err error) {
+ // Call Matrix API to send typing indicator
+ // The returned stop function must be idempotent
+ stopped := false
+ return func() {
+ if !stopped {
+ stopped = true
+ // Call Matrix API to stop typing
+ }
+ }, nil
+}
+```
+
+#### ReactionCapable — Message Reaction Indicator
+
+```go
+// If the platform supports adding emoji reactions to inbound messages (e.g., Slack's 👀, OneBot's emoji 289)
+func (c *MatrixChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) {
+ // Call Matrix API to add reaction to message
+ // The returned undo function removes the reaction, must be idempotent
+ err = c.addReaction(chatID, messageID, "eyes")
+ if err != nil {
+ return func() {}, err
+ }
+ return func() {
+ c.removeReaction(chatID, messageID, "eyes")
+ }, nil
+}
+```
+
+#### MessageEditor — Message Editing
+
+```go
+// If the platform supports editing sent messages (used for Placeholder replacement)
+func (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
+ // Call Matrix API to edit message
+ return nil
+}
+```
+
+#### WebhookHandler — HTTP Webhook Reception
+
+```go
+// If the channel receives messages via webhook (rather than long-polling/WebSocket)
+func (c *MatrixChannel) WebhookPath() string {
+ return "/webhook/matrix" // Path will be registered on the shared HTTP server
+}
+
+func (c *MatrixChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // Handle webhook request
+}
+```
+
+#### HealthChecker — Health Check Endpoint
+
+```go
+func (c *MatrixChannel) HealthPath() string {
+ return "/health/matrix"
+}
+
+func (c *MatrixChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
+ if c.IsRunning() {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("OK"))
+ } else {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }
+}
+```
+
+### 3.4 Inbound-side Typing/Reaction/Placeholder Auto-orchestration
+
+`BaseChannel.HandleMessage` automatically detects whether the channel implements `TypingCapable`, `ReactionCapable`, and/or `PlaceholderCapable` **before** publishing the inbound message, and triggers the corresponding indicators. The three pipelines are completely independent and do not interfere with each other:
+
+```go
+// Automatically executed inside BaseChannel.HandleMessage (no manual calls needed):
+if c.owner != nil && c.placeholderRecorder != nil {
+ // Typing — independent pipeline
+ if tc, ok := c.owner.(TypingCapable); ok {
+ if stop, err := tc.StartTyping(ctx, chatID); err == nil {
+ c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop)
+ }
+ }
+ // Reaction — independent pipeline
+ if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" {
+ if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil {
+ c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
+ }
+ }
+ // Placeholder — independent pipeline
+ if pc, ok := c.owner.(PlaceholderCapable); ok {
+ if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
+ c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
+ }
+ }
+}
+```
+
+**This means**:
+- Channels implementing `TypingCapable` (Telegram, Discord, LINE, Pico) do not need to manually call `StartTyping` + `RecordTypingStop` in `handleMessage`
+- Channels implementing `ReactionCapable` (Slack, OneBot) do not need to manually call `AddReaction` + `RecordTypingStop` in `handleMessage`
+- Channels implementing `PlaceholderCapable` (Telegram, Discord, Pico) do not need to manually send placeholder messages and call `RecordPlaceholder` in `handleMessage`
+- Channels only need to implement the corresponding interface; `HandleMessage` handles orchestration automatically
+- Channels that don't implement these interfaces are unaffected (type assertions will fail and be skipped)
+- `PlaceholderCapable`'s `SendPlaceholder` method internally decides whether to send based on the configured `PlaceholderConfig.Enabled`; returning `("", nil)` skips registration
+
+**Owner Injection**: Manager automatically calls `SetOwner(ch)` in `initChannel` to inject the concrete channel into BaseChannel — no manual setup required from developers.
+
+When the Agent finishes processing a message, Manager's `preSend` automatically:
+1. Calls the recorded `stop()` to stop Typing
+2. Calls the recorded `undo()` to undo Reaction
+3. If there is a Placeholder and the channel implements `MessageEditor`, attempts to edit the Placeholder with the final reply (skipping Send)
+
+### 3.5 Register Configuration and Gateway Integration
+
+#### Add configuration in `pkg/config/config.go`
+
+```go
+type ChannelsConfig struct {
+ // ... existing channels
+ Matrix MatrixChannelConfig `yaml:"matrix" json:"matrix"`
+}
+
+type MatrixChannelConfig struct {
+ Enabled bool `yaml:"enabled" json:"enabled"`
+ HomeServer string `yaml:"home_server" json:"home_server"`
+ Token string `yaml:"token" json:"token"`
+ AllowFrom []string `yaml:"allow_from" json:"allow_from"`
+ GroupTrigger GroupTriggerConfig `yaml:"group_trigger" json:"group_trigger"`
+}
+```
+
+#### Add entry in Manager.initChannels()
+
+```go
+// In the initChannels() method of pkg/channels/manager.go
+if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" {
+ m.initChannel("matrix", "Matrix")
+}
+```
+
+#### Add blank import in Gateway
+
+```go
+// cmd/picoclaw/internal/gateway/helpers.go
+import (
+ _ "github.com/sipeed/picoclaw/pkg/channels/matrix"
+)
+```
+
+---
+
+## Part 4: Core Subsystem Details
+
+### 4.1 MessageBus
+
+**Files**: `pkg/bus/bus.go`, `pkg/bus/types.go`
+
+```go
+type MessageBus struct {
+ inbound chan InboundMessage // buffer = 64
+ outbound chan OutboundMessage // buffer = 64
+ outboundMedia chan OutboundMediaMessage // buffer = 64
+ done chan struct{} // Close signal
+ closed atomic.Bool // Prevents double-close
+}
+```
+
+**Key Behaviors**:
+
+| Method | Behavior |
+|--------|----------|
+| `PublishInbound(ctx, msg)` | Check closed → send to inbound channel → block/timeout/close |
+| `ConsumeInbound(ctx)` | Read from inbound → block/close/cancel |
+| `PublishOutbound(ctx, msg)` | Send to outbound channel |
+| `SubscribeOutbound(ctx)` | Read from outbound (called by Manager dispatcher) |
+| `PublishOutboundMedia(ctx, msg)` | Send to outboundMedia channel |
+| `SubscribeOutboundMedia(ctx)` | Read from outboundMedia (called by Manager media dispatcher) |
+| `Close()` | CAS close → close(done) → drain all channels (**does not close the channels themselves** to avoid concurrent send-on-closed panic) |
+
+**Design Notes**:
+- Buffer size increased from 16 to 64 to reduce blocking under burst load
+- `Close()` does not close the underlying channels (only closes the `done` signal channel), because there may be concurrent `Publish` goroutines
+- Drain loop ensures buffered messages are not silently dropped
+
+### 4.2 Structured Message Types
+
+**File**: `pkg/bus/types.go`
+
+```go
+// Routing peer
+type Peer struct {
+ Kind string `json:"kind"` // "direct" | "group" | "channel" | ""
+ ID string `json:"id"`
+}
+
+// Sender identity information
+type SenderInfo struct {
+ Platform string `json:"platform,omitempty"` // "telegram", "discord", ...
+ PlatformID string `json:"platform_id,omitempty"` // Platform-native ID
+ CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" canonical format
+ Username string `json:"username,omitempty"`
+ DisplayName string `json:"display_name,omitempty"`
+}
+
+// Inbound message
+type InboundMessage struct {
+ Channel string // Source channel name
+ SenderID string // Sender ID (prefer CanonicalID)
+ Sender SenderInfo // Structured sender info
+ ChatID string // Chat/room ID
+ Content string // Message text
+ Media []string // Media reference list (media://...)
+ Peer Peer // Routing peer (first-class field)
+ MessageID string // Platform message ID (first-class field)
+ MediaScope string // Media lifecycle scope
+ SessionKey string // Session key
+ Metadata map[string]string // Only for channel-specific extensions
+}
+
+// Outbound text message
+type OutboundMessage struct {
+ Channel string
+ ChatID string
+ Content string
+}
+
+// Outbound media message
+type OutboundMediaMessage struct {
+ Channel string
+ ChatID string
+ Parts []MediaPart
+}
+
+// Media part
+type MediaPart struct {
+ Type string // "image" | "audio" | "video" | "file"
+ Ref string // "media://uuid"
+ Caption string
+ Filename string
+ ContentType string
+}
+```
+
+### 4.3 BaseChannel
+
+**File**: `pkg/channels/base.go`
+
+BaseChannel is the shared abstraction layer for all channels, providing the following capabilities:
+
+| Method/Feature | Description |
+|---|---|
+| `Name() string` | Channel name |
+| `IsRunning() bool` | Atomically read running state |
+| `SetRunning(bool)` | Atomically set running state |
+| `MaxMessageLength() int` | Message length limit (rune count), 0 = unlimited |
+| `IsAllowed(senderID string) bool` | Legacy allow-list check (supports `"id\|username"` and `"@username"` formats) |
+| `IsAllowedSender(sender SenderInfo) bool` | New allow-list check (delegates to `identity.MatchAllowed`) |
+| `ShouldRespondInGroup(isMentioned, content) (bool, string)` | Unified group chat trigger filtering logic |
+| `HandleMessage(...)` | Unified inbound message handling: permission check → build MediaScope → auto-trigger Typing/Reaction → publish to Bus |
+| `SetMediaStore(s) / GetMediaStore()` | MediaStore injected by Manager |
+| `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | PlaceholderRecorder injected by Manager |
+| `SetOwner(ch)` | Concrete channel reference injected by Manager (used for Typing/Reaction type assertions in HandleMessage) |
+
+**Functional Options**:
+
+```go
+channels.WithMaxMessageLength(4096) // Set platform message length limit
+channels.WithGroupTrigger(groupTriggerCfg) // Set group trigger configuration
+```
+
+### 4.4 Factory Registry
+
+**File**: `pkg/channels/registry.go`
+
+```go
+type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error)
+
+func RegisterFactory(name string, f ChannelFactory) // Called in sub-package init()
+func getFactory(name string) (ChannelFactory, bool) // Called internally by Manager
+```
+
+The factory registry is protected by `sync.RWMutex` and registrations occur during `init()` phase (completed at process startup). Manager looks up factories by name in `initChannel()` and calls them.
+
+### 4.5 Error Classification and Retries
+
+**Files**: `pkg/channels/errors.go`, `pkg/channels/errutil.go`
+
+#### Sentinel Errors
+
+```go
+var (
+ ErrNotRunning = errors.New("channel not running") // Permanent: do not retry
+ ErrRateLimit = errors.New("rate limited") // Fixed delay: retry after 1s
+ ErrTemporary = errors.New("temporary failure") // Exponential backoff: 500ms * 2^attempt, max 8s
+ ErrSendFailed = errors.New("send failed") // Permanent: do not retry
+)
+```
+
+#### Error Classification Helpers
+
+```go
+// Automatically classify based on HTTP status code
+func ClassifySendError(statusCode int, rawErr error) error {
+ // 429 → ErrRateLimit
+ // 5xx → ErrTemporary
+ // 4xx → ErrSendFailed
+}
+
+// Wrap network errors as temporary
+func ClassifyNetError(err error) error {
+ // → ErrTemporary
+}
+```
+
+#### Manager Retry Strategy (`sendWithRetry`)
+
+```
+Max retries: 3
+Rate limit delay: 1 second
+Base backoff: 500 milliseconds
+Max backoff: 8 seconds
+
+Retry logic:
+ ErrNotRunning → Fail immediately, no retry
+ ErrSendFailed → Fail immediately, no retry
+ ErrRateLimit → Wait 1s → retry
+ ErrTemporary → Wait 500ms * 2^attempt (max 8s) → retry
+ Other unknown → Wait 500ms * 2^attempt (max 8s) → retry
+```
+
+### 4.6 Manager Orchestration
+
+**File**: `pkg/channels/manager.go`
+
+#### Per-channel Worker Architecture
+
+```go
+type channelWorker struct {
+ ch Channel // Channel instance
+ queue chan bus.OutboundMessage // Outbound text queue (buffered 16)
+ mediaQueue chan bus.OutboundMediaMessage // Outbound media queue (buffered 16)
+ done chan struct{} // Text worker completion signal
+ mediaDone chan struct{} // Media worker completion signal
+ limiter *rate.Limiter // Per-channel rate limiter
+}
+```
+
+#### Per-channel Rate Limit Configuration
+
+```go
+var channelRateConfig = map[string]float64{
+ "telegram": 20, // 20 msg/s
+ "discord": 1, // 1 msg/s
+ "slack": 1, // 1 msg/s
+ "line": 10, // 10 msg/s
+}
+// Default: 10 msg/s
+// burst = max(1, ceil(rate/2))
+```
+
+#### Lifecycle Management
+
+```
+StartAll:
+ 1. Iterate registered channels → channel.Start(ctx)
+ 2. Create channelWorker for each successfully started channel
+ 3. Start goroutines:
+ - runWorker (per-channel outbound text)
+ - runMediaWorker (per-channel outbound media)
+ - dispatchOutbound (route from bus to worker queues)
+ - dispatchOutboundMedia (route from bus to media worker queues)
+ - runTTLJanitor (every 10s clean up expired typing/placeholder)
+ 4. Start shared HTTP server (if configured)
+
+StopAll:
+ 1. Shut down shared HTTP server (5s timeout)
+ 2. Cancel dispatcher context
+ 3. Close text worker queues → wait for drain to complete
+ 4. Close media worker queues → wait for drain to complete
+ 5. Stop each channel (channel.Stop)
+```
+
+#### Typing/Reaction/Placeholder Management
+
+```go
+// Manager implements PlaceholderRecorder interface
+func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string)
+func (m *Manager) RecordTypingStop(channel, chatID string, stop func())
+func (m *Manager) RecordReactionUndo(channel, chatID string, undo func())
+
+// Inbound side: BaseChannel.HandleMessage auto-orchestrates
+// BaseChannel.HandleMessage, before PublishInbound, auto-triggers via owner type assertions:
+// - TypingCapable.StartTyping → RecordTypingStop
+// - ReactionCapable.ReactToMessage → RecordReactionUndo
+// - PlaceholderCapable.SendPlaceholder → RecordPlaceholder
+// All three are independent and do not interfere with each other. Channels don't need to call these manually.
+
+// Outbound side: pre-send processing
+func (m *Manager) preSend(ctx, name, msg, ch) bool {
+ key := name + ":" + msg.ChatID
+ // 1. Stop Typing (call stored stop function)
+ // 2. Undo Reaction (call stored undo function)
+ // 3. Attempt to edit Placeholder (if channel implements MessageEditor)
+ // Success → return true (skip Send)
+ // Failure → return false (proceed with Send)
+}
+```
+
+Manager storage is fully separated; three pipelines do not interfere:
+
+```go
+Manager {
+ typingStops sync.Map // "channel:chatID" → typingEntry ← manages TypingCapable
+ reactionUndos sync.Map // "channel:chatID" → reactionEntry ← manages ReactionCapable
+ placeholders sync.Map // "channel:chatID" → placeholderEntry
+}
+```
+
+TTL Cleanup:
+- Typing stop functions: 5-minute TTL (auto-calls stop and deletes on expiry)
+- Reaction undo functions: 5-minute TTL (auto-calls undo and deletes on expiry)
+- Placeholder IDs: 10-minute TTL (deletes on expiry)
+- Cleanup interval: 10 seconds
+
+### 4.7 Message Splitting
+
+**File**: `pkg/channels/split.go`
+
+`SplitMessage(content string, maxLen int) []string`
+
+Smart splitting strategy:
+1. Calculate effective split point = maxLen - 10% buffer (to reserve space for code block closure)
+2. Prefer splitting at newlines
+3. Otherwise split at spaces/tabs
+4. Detect unclosed code blocks (` ``` `)
+5. If a code block is unclosed:
+ - Attempt to extend to maxLen to include the closing fence
+ - If the code block is too long, inject close/reopen fences (`\n```\n` + header)
+ - Last resort: split before the code block starts
+
+### 4.8 MediaStore
+
+**File**: `pkg/media/store.go`
+
+```go
+type MediaStore interface {
+ Store(localPath string, meta MediaMeta, scope string) (ref string, err error)
+ Resolve(ref string) (localPath string, err error)
+ ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error)
+ ReleaseAll(scope string) error
+}
+```
+
+**FileMediaStore Implementation**:
+- Pure in-memory mapping, no file copy/move
+- Reference format: `media://`
+- Scope format: `channel:chatID:messageID` (generated by `BuildMediaScope`)
+- **Two-phase operation**:
+ - Phase 1 (holding lock): collect and delete entries from map
+ - Phase 2 (no lock): delete files from disk
+ - Purpose: minimize lock contention
+- **TTL Cleanup**: `NewFileMediaStoreWithCleanup` → `Start()` launches background cleanup goroutine
+- Cleanup interval and max TTL are controlled by configuration
+
+### 4.9 Identity
+
+**File**: `pkg/identity/identity.go`
+
+```go
+// Build canonical ID
+func BuildCanonicalID(platform, platformID string) string
+// → "telegram:123456"
+
+// Parse canonical ID
+func ParseCanonicalID(canonical string) (platform, id string, ok bool)
+
+// Match against allow list (backward-compatible)
+func MatchAllowed(sender bus.SenderInfo, allowed string) bool
+```
+
+`MatchAllowed` supported allow-list formats:
+| Format | Matching |
+|--------|----------|
+| `"123456"` | Matches `sender.PlatformID` |
+| `"@alice"` | Matches `sender.Username` |
+| `"123456\|alice"` | Matches PlatformID or Username (legacy format compatibility) |
+| `"telegram:123456"` | Exact match on `sender.CanonicalID` (new format) |
+
+### 4.10 Shared HTTP Server
+
+**File**: `pkg/channels/manager.go`'s `SetupHTTPServer`
+
+Manager creates a single `http.Server` and auto-discovers and registers:
+- Channels implementing `WebhookHandler` → mounted at `wh.WebhookPath()`
+- Channels implementing `HealthChecker` → mounted at `hc.HealthPath()`
+- Global health endpoint registered by `health.Server.RegisterOnMux`
+
+Timeout configuration: ReadTimeout = 30s, WriteTimeout = 30s
+
+---
+
+## Part 5: Key Design Decisions and Conventions
+
+### 5.1 Mandatory Conventions
+
+1. **Error classification is a contract**: A channel's `Send` method **must** return sentinel errors (or wrap them). Manager's retry strategy relies entirely on `errors.Is` checks. Returning unclassified errors will cause Manager to treat them as "unknown errors" (exponential backoff retry).
+
+2. **SetRunning is a lifecycle signal**: **Must** call `c.SetRunning(true)` after successful `Start`, and **must** call `c.SetRunning(false)` at the beginning of `Stop`. **Must** check `c.IsRunning()` in `Send` and return `ErrNotRunning`.
+
+3. **HandleMessage includes permission checks**: Do not perform your own permission checks before calling `HandleMessage` (unless you need platform-specific preprocessing before the check). `HandleMessage` already calls `IsAllowedSender`/`IsAllowed` internally.
+
+4. **Message splitting is handled by Manager**: A channel's `Send` method does not need to handle long message splitting. Manager automatically splits based on `MaxMessageLength()` before calling `Send`. Channels only need to declare the limit via `WithMaxMessageLength`.
+
+5. **Typing/Reaction/Placeholder is handled by BaseChannel + Manager automatically**: A channel's `Send` method does not need to manage Typing stop, Reaction undo, or Placeholder editing. `BaseChannel.HandleMessage` auto-triggers `TypingCapable`, `ReactionCapable`, and `PlaceholderCapable` on the inbound side (via `owner` type assertions); Manager's `preSend` auto-stops Typing, undoes Reaction, and edits Placeholder on the outbound side. Channels only need to implement the corresponding interfaces.
+
+6. **Factory registration belongs in init()**: Each sub-package must have an `init.go` file calling `channels.RegisterFactory`. Gateway must trigger registration via blank imports (`_ "pkg/channels/xxx"`).
+
+### 5.2 Metadata Field Usage Conventions
+
+**Do NOT put the following information in Metadata anymore**:
+- `peer_kind` / `peer_id` → Use `InboundMessage.Peer`
+- `message_id` → Use `InboundMessage.MessageID`
+- `sender_platform` / `sender_username` → Use `InboundMessage.Sender`
+
+**Metadata should only be used for**:
+- Channel-specific extension information (e.g., Telegram's `reply_to_message_id`)
+- Temporary information that doesn't fit into structured fields
+
+### 5.3 Concurrency Safety Conventions
+
+- `BaseChannel.running`: Uses `atomic.Bool`, thread-safe
+- `Manager.channels` / `Manager.workers`: Protected by `sync.RWMutex`
+- `Manager.placeholders` / `Manager.typingStops` / `Manager.reactionUndos`: Uses `sync.Map`
+- `MessageBus.closed`: Uses `atomic.Bool`
+- `FileMediaStore`: Uses `sync.RWMutex`, two-phase operation to minimize lock-hold time
+- Channel Worker queue: Go channel, inherently concurrent-safe
+
+### 5.4 Testing Conventions
+
+Existing test files:
+- `pkg/channels/base_test.go` — BaseChannel unit tests
+- `pkg/channels/manager_test.go` — Manager unit tests
+- `pkg/channels/split_test.go` — Message splitting tests
+- `pkg/channels/errors_test.go` — Error type tests
+- `pkg/channels/errutil_test.go` — Error classification tests
+
+To add tests for a new channel:
+```bash
+go test ./pkg/channels/matrix/ -v # Sub-package tests
+go test ./pkg/channels/ -run TestSpecific -v # Framework tests
+make test # Full test suite
+```
+
+---
+
+## Appendix: Complete File Listing and Interface Quick Reference
+
+### A.1 Framework Layer Files
+
+| File | Responsibility |
+|------|---------------|
+| `pkg/channels/base.go` | BaseChannel struct, Channel interface, MessageLengthProvider, BaseChannelOption, HandleMessage |
+| `pkg/channels/interfaces.go` | TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder interfaces |
+| `pkg/channels/media.go` | MediaSender interface |
+| `pkg/channels/webhook.go` | WebhookHandler, HealthChecker interfaces |
+| `pkg/channels/errors.go` | ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed sentinels |
+| `pkg/channels/errutil.go` | ClassifySendError, ClassifyNetError helpers |
+| `pkg/channels/registry.go` | RegisterFactory, getFactory factory registry |
+| `pkg/channels/manager.go` | Manager: Worker queues, rate limiting, retries, preSend, shared HTTP, TTL janitor |
+| `pkg/channels/split.go` | SplitMessage long-message splitting |
+| `pkg/bus/bus.go` | MessageBus implementation |
+| `pkg/bus/types.go` | Peer, SenderInfo, InboundMessage, OutboundMessage, OutboundMediaMessage, MediaPart |
+| `pkg/media/store.go` | MediaStore interface, FileMediaStore implementation |
+| `pkg/identity/identity.go` | BuildCanonicalID, ParseCanonicalID, MatchAllowed |
+
+### A.2 Channel Sub-packages
+
+| Sub-package | Registered Name | Optional Interfaces |
+|-------------|----------------|-------------------|
+| `pkg/channels/telegram/` | `"telegram"` | MessageEditor, MediaSender, TypingCapable, PlaceholderCapable |
+| `pkg/channels/discord/` | `"discord"` | MessageEditor, TypingCapable, PlaceholderCapable |
+| `pkg/channels/slack/` | `"slack"` | ReactionCapable |
+| `pkg/channels/line/` | `"line"` | WebhookHandler, HealthChecker, TypingCapable |
+| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable |
+| `pkg/channels/dingtalk/` | `"dingtalk"` | WebhookHandler |
+| `pkg/channels/feishu/` | `"feishu"` | WebhookHandler (architecture-specific build tags) |
+| `pkg/channels/wecom/` | `"wecom"` + `"wecom_app"` | WebhookHandler |
+| `pkg/channels/qq/` | `"qq"` | — |
+| `pkg/channels/whatsapp/` | `"whatsapp"` | — |
+| `pkg/channels/maixcam/` | `"maixcam"` | — |
+| `pkg/channels/pico/` | `"pico"` | WebhookHandler (Pico Protocol), TypingCapable, PlaceholderCapable |
+
+### A.3 Interface Quick Reference
+
+```go
+// ===== Required =====
+type Channel interface {
+ Name() string
+ Start(ctx context.Context) error
+ Stop(ctx context.Context) error
+ Send(ctx context.Context, msg bus.OutboundMessage) error
+ IsRunning() bool
+ IsAllowed(senderID string) bool
+ IsAllowedSender(sender bus.SenderInfo) bool
+}
+
+// ===== Optional =====
+type MediaSender interface {
+ SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error
+}
+
+type TypingCapable interface {
+ StartTyping(ctx context.Context, chatID string) (stop func(), err error)
+}
+
+type ReactionCapable interface {
+ ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)
+}
+
+type PlaceholderCapable interface {
+ SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)
+}
+
+type MessageEditor interface {
+ EditMessage(ctx context.Context, chatID, messageID, content string) error
+}
+
+type WebhookHandler interface {
+ WebhookPath() string
+ http.Handler
+}
+
+type HealthChecker interface {
+ HealthPath() string
+ HealthHandler(w http.ResponseWriter, r *http.Request)
+}
+
+type MessageLengthProvider interface {
+ MaxMessageLength() int
+}
+
+// ===== Injected by Manager =====
+type PlaceholderRecorder interface {
+ RecordPlaceholder(channel, chatID, placeholderID string)
+ RecordTypingStop(channel, chatID string, stop func())
+ RecordReactionUndo(channel, chatID string, undo func())
+}
+```
+
+### A.4 Gateway Startup Sequence (Complete Bootstrap Flow)
+
+```go
+// 1. Create core components
+msgBus := bus.NewMessageBus()
+provider := providers.CreateProvider(cfg)
+agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
+
+// 2. Create media store (with TTL cleanup)
+mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig)
+mediaStore.Start()
+
+// 3. Create Channel Manager (triggers initChannels → factory lookup → construct → inject MediaStore/PlaceholderRecorder/Owner)
+channelManager := channels.NewManager(cfg, msgBus, mediaStore)
+
+// 4. Inject references
+agentLoop.SetChannelManager(channelManager)
+agentLoop.SetMediaStore(mediaStore)
+
+// 5. Configure shared HTTP server
+channelManager.SetupHTTPServer(addr, healthServer)
+
+// 6. Start
+channelManager.StartAll(ctx) // Start channels + workers + dispatchers + HTTP server
+go agentLoop.Run(ctx) // Start Agent message loop
+
+// 7. Shutdown (signal-triggered)
+cancel() // Cancel context
+msgBus.Close() // Signal close + drain
+channelManager.StopAll(shutdownCtx) // Stop HTTP + workers + channels
+mediaStore.Stop() // Stop TTL cleanup
+agentLoop.Stop() // Stop Agent
+```
+
+### A.5 Per-channel Rate Limit Reference
+
+| Channel | Rate (msg/s) | Burst |
+|---------|-------------|-------|
+| telegram | 20 | 10 |
+| discord | 1 | 1 |
+| slack | 1 | 1 |
+| line | 10 | 5 |
+| _others_ | 10 (default) | 5 |
+
+### A.6 Known Limitations and Caveats
+
+1. **Media cleanup temporarily disabled**: The `ReleaseAll` call in the Agent loop is commented out (`refactor(loop): disable media cleanup to prevent premature file deletion`) because session boundaries are not yet clearly defined. TTL cleanup remains active.
+
+2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`).
+
+3. **WeCom has two factories**: `"wecom"` (Bot mode) and `"wecom_app"` (App mode) are registered separately.
+
+4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via webhook.
\ No newline at end of file
diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md
new file mode 100644
index 000000000..0a9487cd0
--- /dev/null
+++ b/pkg/channels/README.zh.md
@@ -0,0 +1,1331 @@
+# PicoClaw Channel System 重构:完整开发指南
+
+> **分支**: `refactor/channel-system`
+> **状态**: 活跃开发中(约 40 commits)
+> **影响范围**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/`
+
+---
+
+## 目录
+
+- [第一部分:架构总览](#第一部分架构总览)
+- [第二部分:迁移指南——从 main 分支迁移到重构分支](#第二部分迁移指南从-main-分支迁移到重构分支)
+- [第三部分:新 Channel 开发指南——从零实现一个新 Channel](#第三部分新-channel-开发指南从零实现一个新-channel)
+- [第四部分:核心子系统详解](#第四部分核心子系统详解)
+- [第五部分:关键设计决策与约定](#第五部分关键设计决策与约定)
+- [附录:完整文件清单与接口速查表](#附录完整文件清单与接口速查表)
+
+---
+
+## 第一部分:架构总览
+
+### 1.1 重构前后对比
+
+**重构前(main 分支)**:
+
+```
+pkg/channels/
+├── telegram.go # 每个 channel 直接放在 channels 包内
+├── discord.go
+├── slack.go
+├── manager.go # Manager 直接引用各 channel 类型
+├── ...
+```
+
+- Channel 实现全部在 `pkg/channels/` 包的顶层
+- Manager 通过 `switch` 或 `if-else` 链条直接构造各 channel
+- Peer、MessageID 等路由信息埋在 `Metadata map[string]string` 中
+- 消息发送没有速率限制和重试
+- 没有统一的媒体文件生命周期管理
+- 各 channel 各自启动 HTTP 服务器
+- 群聊触发过滤逻辑分散在各 channel 中
+
+**重构后(refactor/channel-system 分支)**:
+
+```
+pkg/channels/
+├── base.go # BaseChannel 共享抽象层
+├── interfaces.go # 可选能力接口(TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder)
+├── media.go # MediaSender 可选接口
+├── webhook.go # WebhookHandler, HealthChecker 可选接口
+├── errors.go # 错误哨兵值(ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed)
+├── errutil.go # 错误分类帮助函数
+├── registry.go # 工厂注册表(RegisterFactory / getFactory)
+├── manager.go # 统一编排:Worker 队列、速率限制、重试、Typing/Placeholder、共享 HTTP
+├── split.go # 长消息智能分割(保留代码块完整性)
+├── telegram/ # 每个 channel 独立子包
+│ ├── init.go # 工厂注册
+│ ├── telegram.go # 实现
+│ └── telegram_commands.go
+├── discord/
+│ ├── init.go
+│ └── discord.go
+├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ maixcam/ pico/
+│ └── ...
+
+pkg/bus/
+├── bus.go # MessageBus(缓冲区 64,安全关闭+排水)
+├── types.go # 结构化消息类型(Peer, SenderInfo, MediaPart, InboundMessage, OutboundMessage, OutboundMediaMessage)
+
+pkg/media/
+├── store.go # MediaStore 接口 + FileMediaStore 实现(两阶段释放,TTL 清理)
+
+pkg/identity/
+├── identity.go # 统一用户身份:规范 "platform:id" 格式 + 向后兼容匹配
+```
+
+### 1.2 消息流转全景图
+
+```
+┌────────────┐ InboundMessage ┌───────────┐ LLM + Tools ┌────────────┐
+│ Telegram │──┐ │ │ │ │
+│ Discord │──┤ PublishInbound() │ │ PublishOutbound() │ │
+│ Slack │──┼──────────────────────▶ │ MessageBus │ ◀─────────────────── │ AgentLoop │
+│ LINE │──┤ (buffered chan, 64) │ │ (buffered chan, 64) │ │
+│ ... │──┘ │ │ │ │
+└────────────┘ └─────┬─────┘ └────────────┘
+ │
+ SubscribeOutbound() │ SubscribeOutboundMedia()
+ ▼
+ ┌───────────────────┐
+ │ Manager │
+ │ ├── dispatchOutbound() 路由到 Worker 队列
+ │ ├── dispatchOutboundMedia()
+ │ ├── runWorker() 消息分割 + sendWithRetry()
+ │ ├── runMediaWorker() sendMediaWithRetry()
+ │ ├── preSend() 停止 Typing + 撤销 Reaction + 编辑 Placeholder
+ │ └── runTTLJanitor() 清理过期 Typing/Placeholder
+ └────────┬──────────┘
+ │
+ channel.Send() / SendMedia()
+ │
+ ▼
+ ┌────────────────┐
+ │ 各平台 API/SDK │
+ └────────────────┘
+```
+
+### 1.3 关键设计原则
+
+| 原则 | 说明 |
+|------|------|
+| **子包隔离** | 每个 channel 一个独立 Go 子包,依赖 `channels` 父包提供的 `BaseChannel` 和接口 |
+| **工厂注册** | 各子包通过 `init()` 自注册,Manager 通过名字查找工厂,消除 import 耦合 |
+| **能力发现** | 可选能力通过接口(`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`)声明,Manager 运行时类型断言发现 |
+| **结构化消息** | Peer、MessageID、SenderInfo 从 Metadata 提升为 InboundMessage 的一等字段 |
+| **错误分类** | Channel 返回哨兵错误(`ErrRateLimit`, `ErrTemporary` 等),Manager 据此决定重试策略 |
+| **集中编排** | 速率限制、消息分割、重试、Typing/Reaction/Placeholder 全部由 Manager 和 BaseChannel 统一处理,Channel 只负责 Send |
+
+---
+
+## 第二部分:迁移指南——从 main 分支迁移到重构分支
+
+### 2.1 如果你有未合并的 Channel 修改
+
+#### 步骤 1:确认你修改了哪些文件
+
+在 main 分支上,Channel 文件直接位于 `pkg/channels/` 顶层,例如:
+- `pkg/channels/telegram.go`
+- `pkg/channels/discord.go`
+
+重构后,这些文件已被删除,代码移动到了对应子包:
+- `pkg/channels/telegram/telegram.go`
+- `pkg/channels/discord/discord.go`
+
+#### 步骤 2:理解结构变化映射
+
+| main 分支文件 | 重构分支位置 | 变化 |
+|---|---|---|
+| `pkg/channels/telegram.go` | `pkg/channels/telegram/telegram.go` + `init.go` | 包名从 `channels` 变为 `telegram` |
+| `pkg/channels/discord.go` | `pkg/channels/discord/discord.go` + `init.go` | 同上 |
+| `pkg/channels/manager.go` | `pkg/channels/manager.go` | 大幅重写 |
+| _(不存在)_ | `pkg/channels/base.go` | 新增共享抽象层 |
+| _(不存在)_ | `pkg/channels/registry.go` | 新增工厂注册表 |
+| _(不存在)_ | `pkg/channels/errors.go` + `errutil.go` | 新增错误分类体系 |
+| _(不存在)_ | `pkg/channels/interfaces.go` | 新增可选能力接口 |
+| _(不存在)_ | `pkg/channels/media.go` | 新增 MediaSender 接口 |
+| _(不存在)_ | `pkg/channels/webhook.go` | 新增 WebhookHandler/HealthChecker |
+| _(不存在)_ | `pkg/channels/split.go` | 新增消息分割(从 utils 迁入) |
+| _(不存在)_ | `pkg/bus/types.go` | 新增结构化消息类型 |
+| _(不存在)_ | `pkg/media/store.go` | 新增媒体文件生命周期管理 |
+| _(不存在)_ | `pkg/identity/identity.go` | 新增统一用户身份 |
+
+#### 步骤 3:迁移你的 Channel 代码
+
+以 Telegram 为例,主要改动项:
+
+**3a. 包声明和导入**
+
+```go
+// 旧代码(main 分支)
+package channels
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// 新代码(重构分支)
+package telegram
+
+import (
+ "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/media" // 新增(如需媒体)
+)
+```
+
+**3b. 结构体嵌入 BaseChannel**
+
+```go
+// 旧代码:直接持有 bus、config 等字段
+type TelegramChannel struct {
+ bus *bus.MessageBus
+ config *config.Config
+ running bool
+ allowList []string
+ // ...
+}
+
+// 新代码:嵌入 BaseChannel,它提供 bus、running、allowList 等
+type TelegramChannel struct {
+ *channels.BaseChannel // 嵌入共享抽象
+ bot *telego.Bot
+ config *config.Config
+ // ... 只保留 channel 特有字段
+}
+```
+
+**3c. 构造函数**
+
+```go
+// 旧代码:直接赋值
+func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
+ return &TelegramChannel{
+ bus: bus,
+ config: cfg,
+ allowList: cfg.Channels.Telegram.AllowFrom,
+ // ...
+ }, nil
+}
+
+// 新代码:使用 NewBaseChannel + 功能选项
+func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
+ base := channels.NewBaseChannel(
+ "telegram", // 名称
+ cfg.Channels.Telegram, // 原始配置(any 类型)
+ bus, // 消息总线
+ cfg.Channels.Telegram.AllowFrom, // 允许列表
+ channels.WithMaxMessageLength(4096), // 平台消息长度上限
+ channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // 群聊触发配置
+ )
+ return &TelegramChannel{
+ BaseChannel: base,
+ bot: bot,
+ config: cfg,
+ }, nil
+}
+```
+
+**3d. Start/Stop 生命周期**
+
+```go
+// 新代码:使用 SetRunning 原子操作
+func (c *TelegramChannel) Start(ctx context.Context) error {
+ // ... 初始化 bot、webhook 等
+ c.SetRunning(true) // 必须在就绪后调用
+ go bh.Start()
+ return nil
+}
+
+func (c *TelegramChannel) Stop(ctx context.Context) error {
+ c.SetRunning(false) // 必须在清理前调用
+ // ... 停止 bot handler、取消 context
+ return nil
+}
+```
+
+**3e. Send 方法的错误返回**
+
+```go
+// 旧代码:返回普通 error
+func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.running { return fmt.Errorf("not running") }
+ // ...
+ if err != nil { return err }
+}
+
+// 新代码:必须返回哨兵错误,供 Manager 判断重试策略
+func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning // ← Manager 不会重试
+ }
+ // ...
+ if err != nil {
+ // 使用 ClassifySendError 根据 HTTP 状态码包装错误
+ return channels.ClassifySendError(statusCode, err)
+ // 或手动包装:
+ // return fmt.Errorf("%w: %v", channels.ErrTemporary, err)
+ // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err)
+ // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err)
+ }
+ return nil
+}
+```
+
+**3f. 消息接收(Inbound)**
+
+```go
+// 旧代码:直接构造 InboundMessage 并发布
+msg := bus.InboundMessage{
+ Channel: "telegram",
+ SenderID: senderID,
+ ChatID: chatID,
+ Content: content,
+ Metadata: map[string]string{
+ "peer_kind": "group", // 路由信息埋在 metadata
+ "peer_id": chatID,
+ "message_id": msgID,
+ },
+}
+c.bus.PublishInbound(ctx, msg)
+
+// 新代码:使用 BaseChannel.HandleMessage,传入结构化字段
+sender := bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: strconv.FormatInt(from.ID, 10),
+ CanonicalID: identity.BuildCanonicalID("telegram", strconv.FormatInt(from.ID, 10)),
+ Username: from.Username,
+ DisplayName: from.FirstName,
+}
+
+peer := bus.Peer{
+ Kind: "group", // 或 "direct"
+ ID: chatID,
+}
+
+// HandleMessage 内部调用 IsAllowedSender 检查权限,构建 MediaScope,发布到 bus
+c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, sender)
+```
+
+**3g. 添加工厂注册(必需)**
+
+为你的 channel 创建 `init.go`:
+
+```go
+// pkg/channels/telegram/init.go
+package telegram
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewTelegramChannel(cfg, b)
+ })
+}
+```
+
+**3h. 在 Gateway 中导入子包**
+
+```go
+// cmd/picoclaw/internal/gateway/helpers.go
+import (
+ _ "github.com/sipeed/picoclaw/pkg/channels/telegram" // 触发 init() 注册
+ _ "github.com/sipeed/picoclaw/pkg/channels/discord"
+ _ "github.com/sipeed/picoclaw/pkg/channels/your_new_channel" // 新增
+)
+```
+
+#### 步骤 4:迁移 Bus 消息使用方式
+
+如果你的代码直接读取 `InboundMessage.Metadata` 中的路由字段:
+
+```go
+// 旧代码
+peerKind := msg.Metadata["peer_kind"]
+peerID := msg.Metadata["peer_id"]
+msgID := msg.Metadata["message_id"]
+
+// 新代码
+peerKind := msg.Peer.Kind // 一等字段
+peerID := msg.Peer.ID // 一等字段
+msgID := msg.MessageID // 一等字段
+sender := msg.Sender // bus.SenderInfo 结构体
+scope := msg.MediaScope // 媒体生命周期作用域
+```
+
+#### 步骤 5:迁移允许列表检查
+
+```go
+// 旧代码
+if !c.isAllowed(senderID) { return }
+
+// 新代码:优先使用结构化检查
+if !c.IsAllowedSender(sender) { return }
+// 或回退到字符串检查:
+if !c.IsAllowed(senderID) { return }
+```
+
+`BaseChannel.HandleMessage` 方法内部已经处理了这个逻辑,无需在 channel 中重复检查。
+
+### 2.2 如果你有 Manager 的修改
+
+Manager 已被完全重写。你的修改需要理解新架构:
+
+| 旧 Manager 职责 | 新 Manager 职责 |
+|---|---|
+| 直接构造 channel(switch/if-else) | 通过工厂注册表查找并构造 |
+| 直接调用 channel.Send | 通过 per-channel Worker 队列 + 速率限制 + 重试 |
+| 无消息分割 | 自动根据 MaxMessageLength 分割长消息 |
+| 各 channel 自建 HTTP 服务器 | 统一共享 HTTP 服务器 |
+| 无 Typing/Placeholder 管理 | 统一 preSend 处理 Typing 停止 + Reaction 撤销 + Placeholder 编辑;入站侧 BaseChannel.HandleMessage 自动编排 Typing/Reaction/Placeholder |
+| 无 TTL 清理 | runTTLJanitor 定期清理过期 Typing/Reaction/Placeholder 条目 |
+
+### 2.3 如果你有 Agent Loop 的修改
+
+Agent Loop 的主要变化:
+
+1. **MediaStore 注入**:`agentLoop.SetMediaStore(mediaStore)` — Agent 通过 MediaStore 解析工具产生的媒体引用
+2. **ChannelManager 注入**:`agentLoop.SetChannelManager(channelManager)` — Agent 可查询 channel 状态
+3. **OutboundMediaMessage**:Agent 现在通过 `bus.PublishOutboundMedia()` 发送媒体消息,而非嵌入文本回复
+4. **extractPeer**:路由使用 `msg.Peer` 结构化字段而非 Metadata 查找
+
+---
+
+## 第三部分:新 Channel 开发指南——从零实现一个新 Channel
+
+### 3.1 最小实现清单
+
+要添加一个新的聊天平台(例如 `matrix`),你需要:
+
+1. ✅ 创建子包目录 `pkg/channels/matrix/`
+2. ✅ 创建 `init.go` — 工厂注册
+3. ✅ 创建 `matrix.go` — Channel 实现
+4. ✅ 在 Gateway helpers 中添加 blank import
+5. ✅ 在 Manager.initChannels() 中添加配置检查
+6. ✅ 在 `pkg/config/` 中添加配置结构体
+
+### 3.2 完整模板
+
+#### `pkg/channels/matrix/init.go`
+
+```go
+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, b)
+ })
+}
+```
+
+#### `pkg/channels/matrix/matrix.go`
+
+```go
+package matrix
+
+import (
+ "context"
+ "fmt"
+
+ "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"
+)
+
+// MatrixChannel implements channels.Channel for the Matrix protocol.
+type MatrixChannel struct {
+ *channels.BaseChannel // 必须嵌入
+ config *config.Config
+ ctx context.Context
+ cancel context.CancelFunc
+ // ... Matrix SDK 客户端等
+}
+
+func NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChannel, error) {
+ matrixCfg := cfg.Channels.Matrix // 假设配置中有此字段
+
+ base := channels.NewBaseChannel(
+ "matrix", // channel 名称(全局唯一)
+ matrixCfg, // 原始配置
+ msgBus, // 消息总线
+ matrixCfg.AllowFrom, // 允许列表
+ channels.WithMaxMessageLength(65536), // Matrix 消息长度限制
+ channels.WithGroupTrigger(matrixCfg.GroupTrigger),
+ )
+
+ return &MatrixChannel{
+ BaseChannel: base,
+ config: cfg,
+ }, nil
+}
+
+// ========== 必须实现的 Channel 接口方法 ==========
+
+func (c *MatrixChannel) Start(ctx context.Context) error {
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ // 1. 初始化 Matrix 客户端
+ // 2. 开始监听消息
+ // 3. 标记为运行中
+ c.SetRunning(true)
+
+ logger.InfoC("matrix", "Matrix channel started")
+ return nil
+}
+
+func (c *MatrixChannel) Stop(ctx context.Context) error {
+ c.SetRunning(false)
+
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ logger.InfoC("matrix", "Matrix channel stopped")
+ return nil
+}
+
+func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ // 1. 检查运行状态
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ // 2. 发送消息到 Matrix
+ err := c.sendToMatrix(ctx, msg.ChatID, msg.Content)
+ if err != nil {
+ // 3. 必须使用错误分类包装
+ // 如果你有 HTTP 状态码:
+ // return channels.ClassifySendError(statusCode, err)
+ // 如果是网络错误:
+ // return channels.ClassifyNetError(err)
+ // 如果需要手动分类:
+ return fmt.Errorf("%w: %v", channels.ErrTemporary, err)
+ }
+
+ return nil
+}
+
+// ========== 消息接收处理 ==========
+
+func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content string, msgID string) {
+ // 1. 构造结构化发送者身份
+ sender := bus.SenderInfo{
+ Platform: "matrix",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("matrix", senderID),
+ Username: senderID,
+ DisplayName: displayName,
+ }
+
+ // 2. 确定 Peer 类型(直聊 vs 群聊)
+ peer := bus.Peer{
+ Kind: "group", // 或 "direct"
+ ID: roomID,
+ }
+
+ // 3. 群聊过滤(如适用)
+ isGroup := peer.Kind == "group"
+ if isGroup {
+ isMentioned := false // 根据平台特性检测 @提及
+ shouldRespond, cleanContent := c.ShouldRespondInGroup(isMentioned, content)
+ if !shouldRespond {
+ return
+ }
+ content = cleanContent
+ }
+
+ // 4. 处理媒体附件(如有)
+ var mediaRefs []string
+ store := c.GetMediaStore()
+ if store != nil {
+ // 下载附件到本地 → store.Store() → 获取 ref
+ // mediaRefs = append(mediaRefs, ref)
+ }
+
+ // 5. 调用 HandleMessage 发布到 bus
+ // HandleMessage 内部会:
+ // - 检查 IsAllowedSender/IsAllowed
+ // - 构建 MediaScope
+ // - 发布 InboundMessage
+ c.HandleMessage(
+ c.ctx,
+ peer,
+ msgID, // 平台消息 ID
+ senderID, // 原始发送者 ID
+ roomID, // 聊天/房间 ID
+ content, // 消息内容
+ mediaRefs, // 媒体引用列表
+ nil, // 额外 metadata(通常 nil)
+ sender, // SenderInfo(variadic 参数)
+ )
+}
+
+// ========== 内部方法 ==========
+
+func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error {
+ // 实际的 Matrix SDK 调用
+ return nil
+}
+```
+
+### 3.3 可选能力接口
+
+根据平台能力,你的 Channel 可以选择性实现以下接口:
+
+#### MediaSender — 发送媒体附件
+
+```go
+// 如果平台支持发送图片/文件/音频/视频
+func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ store := c.GetMediaStore()
+ if store == nil {
+ return fmt.Errorf("no media store: %w", channels.ErrSendFailed)
+ }
+
+ for _, part := range msg.Parts {
+ localPath, err := store.Resolve(part.Ref)
+ if err != nil {
+ logger.ErrorCF("matrix", "Failed to resolve media", map[string]any{
+ "ref": part.Ref, "error": err.Error(),
+ })
+ continue
+ }
+
+ // 根据 part.Type ("image"|"audio"|"video"|"file") 调用对应 API
+ switch part.Type {
+ case "image":
+ // 上传图片到 Matrix
+ default:
+ // 上传文件到 Matrix
+ }
+ }
+ return nil
+}
+```
+
+#### TypingCapable — Typing 指示器
+
+```go
+// 如果平台支持 "正在输入..." 提示
+func (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (stop func(), err error) {
+ // 调用 Matrix API 发送 typing 指示器
+ // 返回的 stop 函数必须是幂等的
+ stopped := false
+ return func() {
+ if !stopped {
+ stopped = true
+ // 调用 Matrix API 停止 typing
+ }
+ }, nil
+}
+```
+
+#### ReactionCapable — 消息反应指示器
+
+```go
+// 如果平台支持对入站消息添加 emoji 反应(如 Slack 的 👀、OneBot 的表情 289)
+func (c *MatrixChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) {
+ // 调用 Matrix API 添加反应到消息
+ // 返回的 undo 函数移除反应,必须是幂等的
+ err = c.addReaction(chatID, messageID, "eyes")
+ if err != nil {
+ return func() {}, err
+ }
+ return func() {
+ c.removeReaction(chatID, messageID, "eyes")
+ }, nil
+}
+```
+
+#### MessageEditor — 消息编辑
+
+```go
+// 如果平台支持编辑已发送的消息(用于 Placeholder 替换)
+func (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
+ // 调用 Matrix API 编辑消息
+ return nil
+}
+```
+
+#### WebhookHandler — HTTP Webhook 接收
+
+```go
+// 如果 channel 通过 webhook 接收消息(而非长轮询/WebSocket)
+func (c *MatrixChannel) WebhookPath() string {
+ return "/webhook/matrix" // 路径会被注册到共享 HTTP 服务器
+}
+
+func (c *MatrixChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // 处理 webhook 请求
+}
+```
+
+#### HealthChecker — 健康检查端点
+
+```go
+func (c *MatrixChannel) HealthPath() string {
+ return "/health/matrix"
+}
+
+func (c *MatrixChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
+ if c.IsRunning() {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("OK"))
+ } else {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }
+}
+```
+
+### 3.4 入站侧 Typing/Reaction/Placeholder 自动编排
+
+`BaseChannel.HandleMessage` 在发布入站消息**之前**,自动检测 channel 是否实现了 `TypingCapable`、`ReactionCapable` 和/或 `PlaceholderCapable`,并触发相应的指示器。三条管道完全独立,互不干扰:
+
+```go
+// BaseChannel.HandleMessage 内部自动执行(无需 channel 手动调用):
+if c.owner != nil && c.placeholderRecorder != nil {
+ // Typing — 独立管道
+ if tc, ok := c.owner.(TypingCapable); ok {
+ if stop, err := tc.StartTyping(ctx, chatID); err == nil {
+ c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop)
+ }
+ }
+ // Reaction — 独立管道
+ if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" {
+ if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil {
+ c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
+ }
+ }
+ // Placeholder — 独立管道
+ if pc, ok := c.owner.(PlaceholderCapable); ok {
+ if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
+ c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
+ }
+ }
+}
+```
+
+**这意味着**:
+- 实现 `TypingCapable` 的 channel(Telegram、Discord、LINE、Pico)无需在 `handleMessage` 中手动调用 `StartTyping` + `RecordTypingStop`
+- 实现 `ReactionCapable` 的 channel(Slack、OneBot)无需在 `handleMessage` 中手动调用 `AddReaction` + `RecordTypingStop`
+- 实现 `PlaceholderCapable` 的 channel(Telegram、Discord、Pico)无需在 `handleMessage` 中手动发送占位消息并调用 `RecordPlaceholder`
+- Channel 只需实现对应接口,`HandleMessage` 会自动完成编排
+- 不实现这些接口的 channel 不受影响(类型断言会失败,跳过)
+- `PlaceholderCapable` 的 `SendPlaceholder` 方法内部根据配置的 `PlaceholderConfig.Enabled` 决定是否发送;返回 `("", nil)` 时跳过注册
+
+**Owner 注入**:Manager 在 `initChannel` 中自动调用 `SetOwner(ch)` 将具体 channel 注入 BaseChannel,无需开发者手动设置。
+
+当 Agent 处理完消息后,Manager 的 `preSend` 会自动:
+1. 调用已记录的 `stop()` 停止 Typing
+2. 调用已记录的 `undo()` 撤销 Reaction
+3. 如果有 Placeholder,且 channel 实现了 `MessageEditor`,尝试编辑 Placeholder 为最终回复(跳过 Send)
+
+### 3.5 注册配置和 Gateway 接入
+
+#### 在 `pkg/config/config.go` 中添加配置
+
+```go
+type ChannelsConfig struct {
+ // ... 现有 channels
+ Matrix MatrixChannelConfig `yaml:"matrix" json:"matrix"`
+}
+
+type MatrixChannelConfig struct {
+ Enabled bool `yaml:"enabled" json:"enabled"`
+ HomeServer string `yaml:"home_server" json:"home_server"`
+ Token string `yaml:"token" json:"token"`
+ AllowFrom []string `yaml:"allow_from" json:"allow_from"`
+ GroupTrigger GroupTriggerConfig `yaml:"group_trigger" json:"group_trigger"`
+}
+```
+
+#### 在 Manager.initChannels() 中添加入口
+
+```go
+// pkg/channels/manager.go 的 initChannels() 方法中
+if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" {
+ m.initChannel("matrix", "Matrix")
+}
+```
+
+#### 在 Gateway 中添加 blank import
+
+```go
+// cmd/picoclaw/internal/gateway/helpers.go
+import (
+ _ "github.com/sipeed/picoclaw/pkg/channels/matrix"
+)
+```
+
+---
+
+## 第四部分:核心子系统详解
+
+### 4.1 MessageBus
+
+**文件**:`pkg/bus/bus.go`、`pkg/bus/types.go`
+
+```go
+type MessageBus struct {
+ inbound chan InboundMessage // 缓冲区 = 64
+ outbound chan OutboundMessage // 缓冲区 = 64
+ outboundMedia chan OutboundMediaMessage // 缓冲区 = 64
+ done chan struct{} // 关闭信号
+ closed atomic.Bool // 防止重复关闭
+}
+```
+
+**关键行为**:
+
+| 方法 | 行为 |
+|------|------|
+| `PublishInbound(ctx, msg)` | 检查 closed → 发送到 inbound channel → 阻塞/超时/关闭 |
+| `ConsumeInbound(ctx)` | 从 inbound 读取 → 阻塞/关闭/取消 |
+| `PublishOutbound(ctx, msg)` | 发送到 outbound channel |
+| `SubscribeOutbound(ctx)` | 从 outbound 读取(Manager dispatcher 调用) |
+| `PublishOutboundMedia(ctx, msg)` | 发送到 outboundMedia channel |
+| `SubscribeOutboundMedia(ctx)` | 从 outboundMedia 读取(Manager media dispatcher 调用) |
+| `Close()` | CAS 关闭 → close(done) → 排水所有 channel(**不关闭 channel 本身**,避免并发 send-on-closed panic) |
+
+**设计要点**:
+- 缓冲区从 16 增至 64,减少突发负载下的阻塞
+- `Close()` 不关闭底层 channel(只关闭 `done` 信号通道),因为可能有正在并发 `Publish` 的 goroutine
+- 排水循环确保 buffered 消息不被静默丢弃
+
+### 4.2 结构化消息类型
+
+**文件**:`pkg/bus/types.go`
+
+```go
+// 路由对等体
+type Peer struct {
+ Kind string `json:"kind"` // "direct" | "group" | "channel" | ""
+ ID string `json:"id"`
+}
+
+// 发送者身份信息
+type SenderInfo struct {
+ Platform string `json:"platform,omitempty"` // "telegram", "discord", ...
+ PlatformID string `json:"platform_id,omitempty"` // 平台原始 ID
+ CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" 规范格式
+ Username string `json:"username,omitempty"`
+ DisplayName string `json:"display_name,omitempty"`
+}
+
+// 入站消息
+type InboundMessage struct {
+ Channel string // 来源 channel 名称
+ SenderID string // 发送者 ID(优先使用 CanonicalID)
+ Sender SenderInfo // 结构化发送者信息
+ ChatID string // 聊天/房间 ID
+ Content string // 消息文本
+ Media []string // 媒体引用列表(media://...)
+ Peer Peer // 路由对等体(一等字段)
+ MessageID string // 平台消息 ID(一等字段)
+ MediaScope string // 媒体生命周期作用域
+ SessionKey string // 会话键
+ Metadata map[string]string // 仅用于 channel 特有扩展
+}
+
+// 出站文本消息
+type OutboundMessage struct {
+ Channel string
+ ChatID string
+ Content string
+}
+
+// 出站媒体消息
+type OutboundMediaMessage struct {
+ Channel string
+ ChatID string
+ Parts []MediaPart
+}
+
+// 媒体片段
+type MediaPart struct {
+ Type string // "image" | "audio" | "video" | "file"
+ Ref string // "media://uuid"
+ Caption string
+ Filename string
+ ContentType string
+}
+```
+
+### 4.3 BaseChannel
+
+**文件**:`pkg/channels/base.go`
+
+BaseChannel 是所有 channel 的共享抽象层,提供以下能力:
+
+| 方法/特性 | 说明 |
+|---|---|
+| `Name() string` | Channel 名称 |
+| `IsRunning() bool` | 原子读取运行状态 |
+| `SetRunning(bool)` | 原子设置运行状态 |
+| `MaxMessageLength() int` | 消息长度限制(rune 计数),0 = 无限制 |
+| `IsAllowed(senderID string) bool` | 旧格式允许列表检查(支持 `"id\|username"` 和 `"@username"` 格式) |
+| `IsAllowedSender(sender SenderInfo) bool` | 新格式允许列表检查(委托给 `identity.MatchAllowed`) |
+| `ShouldRespondInGroup(isMentioned, content) (bool, string)` | 统一群聊触发过滤逻辑 |
+| `HandleMessage(...)` | 统一入站消息处理:权限检查 → 构建 MediaScope → 自动触发 Typing/Reaction → 发布到 Bus |
+| `SetMediaStore(s) / GetMediaStore()` | Manager 注入的媒体存储 |
+| `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | Manager 注入的占位符记录器 |
+| `SetOwner(ch) ` | Manager 注入的具体 channel 引用(用于 HandleMessage 内部的 Typing/Reaction 类型断言) |
+
+**功能选项**:
+
+```go
+channels.WithMaxMessageLength(4096) // 设置平台消息长度限制
+channels.WithGroupTrigger(groupTriggerCfg) // 设置群聊触发配置
+```
+
+### 4.4 工厂注册表
+
+**文件**:`pkg/channels/registry.go`
+
+```go
+type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error)
+
+func RegisterFactory(name string, f ChannelFactory) // 子包 init() 中调用
+func getFactory(name string) (ChannelFactory, bool) // Manager 内部调用
+```
+
+工厂注册表使用 `sync.RWMutex` 保护,在 `init()` 阶段注册(进程启动时完成)。Manager 在 `initChannel()` 中通过名字查找工厂并调用它。
+
+### 4.5 错误分类与重试
+
+**文件**:`pkg/channels/errors.go`、`pkg/channels/errutil.go`
+
+#### 哨兵错误
+
+```go
+var (
+ ErrNotRunning = errors.New("channel not running") // 永久:不重试
+ ErrRateLimit = errors.New("rate limited") // 固定延迟:1s 后重试
+ ErrTemporary = errors.New("temporary failure") // 指数退避:500ms * 2^attempt,最大 8s
+ ErrSendFailed = errors.New("send failed") // 永久:不重试
+)
+```
+
+#### 错误分类帮助函数
+
+```go
+// 根据 HTTP 状态码自动分类
+func ClassifySendError(statusCode int, rawErr error) error {
+ // 429 → ErrRateLimit
+ // 5xx → ErrTemporary
+ // 4xx → ErrSendFailed
+}
+
+// 网络错误统一包装为临时错误
+func ClassifyNetError(err error) error {
+ // → ErrTemporary
+}
+```
+
+#### Manager 重试策略(`sendWithRetry`)
+
+```
+最大重试次数: 3
+速率限制延迟: 1 秒
+基础退避: 500 毫秒
+最大退避: 8 秒
+
+重试逻辑:
+ ErrNotRunning → 立即失败,不重试
+ ErrSendFailed → 立即失败,不重试
+ ErrRateLimit → 等待 1s → 重试
+ ErrTemporary → 等待 500ms * 2^attempt(最大 8s) → 重试
+ 其他未知错误 → 等待 500ms * 2^attempt(最大 8s) → 重试
+```
+
+### 4.6 Manager 编排
+
+**文件**:`pkg/channels/manager.go`
+
+#### Per-channel Worker 架构
+
+```go
+type channelWorker struct {
+ ch Channel // channel 实例
+ queue chan bus.OutboundMessage // 出站文本队列(缓冲 16)
+ mediaQueue chan bus.OutboundMediaMessage // 出站媒体队列(缓冲 16)
+ done chan struct{} // 文本 worker 完成信号
+ mediaDone chan struct{} // 媒体 worker 完成信号
+ limiter *rate.Limiter // per-channel 速率限制器
+}
+```
+
+#### Per-channel 速率限制配置
+
+```go
+var channelRateConfig = map[string]float64{
+ "telegram": 20, // 20 msg/s
+ "discord": 1, // 1 msg/s
+ "slack": 1, // 1 msg/s
+ "line": 10, // 10 msg/s
+}
+// 默认: 10 msg/s
+// burst = max(1, ceil(rate/2))
+```
+
+#### 生命周期管理
+
+```
+StartAll:
+ 1. 遍历已注册 channels → channel.Start(ctx)
+ 2. 为每个启动成功的 channel 创建 channelWorker
+ 3. 启动 goroutines:
+ - runWorker (per-channel 出站文本)
+ - runMediaWorker (per-channel 出站媒体)
+ - dispatchOutbound (从 bus 路由到 worker 队列)
+ - dispatchOutboundMedia (从 bus 路由到 media worker 队列)
+ - runTTLJanitor (每 10s 清理过期 typing/placeholder)
+ 4. 启动共享 HTTP 服务器(如已配置)
+
+StopAll:
+ 1. 关闭共享 HTTP 服务器(5s 超时)
+ 2. 取消 dispatcher context
+ 3. 关闭 text worker 队列 → 等待排水完成
+ 4. 关闭 media worker 队列 → 等待排水完成
+ 5. 停止每个 channel(channel.Stop)
+```
+
+#### Typing/Reaction/Placeholder 管理
+
+```go
+// Manager 实现 PlaceholderRecorder 接口
+func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string)
+func (m *Manager) RecordTypingStop(channel, chatID string, stop func())
+func (m *Manager) RecordReactionUndo(channel, chatID string, undo func())
+
+// 入站侧:BaseChannel.HandleMessage 自动编排
+// BaseChannel.HandleMessage 在 PublishInbound 之前,通过 owner 类型断言自动触发:
+// - TypingCapable.StartTyping → RecordTypingStop
+// - ReactionCapable.ReactToMessage → RecordReactionUndo
+// - PlaceholderCapable.SendPlaceholder → RecordPlaceholder
+// 三者独立,互不干扰。Channel 无需手动调用。
+
+// 出站侧:发送前处理
+func (m *Manager) preSend(ctx, name, msg, ch) bool {
+ key := name + ":" + msg.ChatID
+ // 1. 停止 Typing(调用存储的 stop 函数)
+ // 2. 撤销 Reaction(调用存储的 undo 函数)
+ // 3. 尝试编辑 Placeholder(如果 channel 实现了 MessageEditor)
+ // 成功 → return true(跳过 Send)
+ // 失败 → return false(继续 Send)
+}
+```
+
+Manager 存储完全分离,三条管道互不干扰:
+
+```go
+Manager {
+ typingStops sync.Map // "channel:chatID" → typingEntry ← 管 TypingCapable
+ reactionUndos sync.Map // "channel:chatID" → reactionEntry ← 管 ReactionCapable
+ placeholders sync.Map // "channel:chatID" → placeholderEntry
+}
+```
+
+TTL 清理:
+- Typing 停止函数:5 分钟 TTL(到期后自动调用 stop 并删除)
+- Reaction 撤销函数:5 分钟 TTL(到期后自动调用 undo 并删除)
+- Placeholder ID:10 分钟 TTL(到期后删除)
+- 清理间隔:10 秒
+
+### 4.7 消息分割
+
+**文件**:`pkg/channels/split.go`
+
+`SplitMessage(content string, maxLen int) []string`
+
+智能分割策略:
+1. 计算有效分割点 = maxLen - 10% 缓冲区(为代码块闭合留空间)
+2. 优先在换行符处分割
+3. 其次在空格/制表符处分割
+4. 检测未闭合的代码块(` ``` `)
+5. 如果代码块未闭合:
+ - 尝试扩展到 maxLen 以包含闭合围栏
+ - 如果代码块太长,注入闭合/重开围栏(`\n```\n` + header)
+ - 最后手段:在代码块开始前分割
+
+### 4.8 MediaStore
+
+**文件**:`pkg/media/store.go`
+
+```go
+type MediaStore interface {
+ Store(localPath string, meta MediaMeta, scope string) (ref string, err error)
+ Resolve(ref string) (localPath string, err error)
+ ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error)
+ ReleaseAll(scope string) error
+}
+```
+
+**FileMediaStore 实现**:
+- 纯内存映射,不复制/移动文件
+- 引用格式:`media://`
+- Scope 格式:`channel:chatID:messageID`(由 `BuildMediaScope` 生成)
+- **两阶段操作**:
+ - Phase 1(持锁):从 map 中收集并删除条目
+ - Phase 2(无锁):从磁盘删除文件
+ - 目的:最小化锁争用
+- **TTL 清理**:`NewFileMediaStoreWithCleanup` → `Start()` 启动后台清理协程
+- 清理间隔和最大存活时间由配置控制
+
+### 4.9 Identity
+
+**文件**:`pkg/identity/identity.go`
+
+```go
+// 构建规范 ID
+func BuildCanonicalID(platform, platformID string) string
+// → "telegram:123456"
+
+// 解析规范 ID
+func ParseCanonicalID(canonical string) (platform, id string, ok bool)
+
+// 匹配允许列表(向后兼容)
+func MatchAllowed(sender bus.SenderInfo, allowed string) bool
+```
+
+`MatchAllowed` 支持的允许列表格式:
+| 格式 | 匹配方式 |
+|------|----------|
+| `"123456"` | 匹配 `sender.PlatformID` |
+| `"@alice"` | 匹配 `sender.Username` |
+| `"123456\|alice"` | 匹配 PlatformID 或 Username(旧格式兼容) |
+| `"telegram:123456"` | 精确匹配 `sender.CanonicalID`(新格式) |
+
+### 4.10 共享 HTTP 服务器
+
+**文件**:`pkg/channels/manager.go` 的 `SetupHTTPServer`
+
+Manager 创建单一 `http.Server`,自动发现和注册:
+- 实现 `WebhookHandler` 的 channel → 挂载到 `wh.WebhookPath()`
+- 实现 `HealthChecker` 的 channel → 挂载到 `hc.HealthPath()`
+- Health 全局端点由 `health.Server.RegisterOnMux` 注册
+
+超时配置:ReadTimeout = 30s, WriteTimeout = 30s
+
+---
+
+## 第五部分:关键设计决策与约定
+
+### 5.1 必须遵守的约定
+
+1. **错误分类是合约**:Channel 的 `Send` 方法**必须**返回哨兵错误(或包装它们)。Manager 的重试策略完全依赖 `errors.Is` 检查。如果返回未分类的错误,Manager 会按"未知错误"处理(指数退避重试)。
+
+2. **SetRunning 是生命周期信号**:`Start` 成功后**必须**调用 `c.SetRunning(true)`,`Stop` 开始时**必须**调用 `c.SetRunning(false)`。`Send` 中**必须**检查 `c.IsRunning()` 并返回 `ErrNotRunning`。
+
+3. **HandleMessage 包含权限检查**:不要在调用 `HandleMessage` 之前自行进行权限检查(除非你需要在检查前做平台特定的预处理)。`HandleMessage` 内部已经调用 `IsAllowedSender`/`IsAllowed`。
+
+4. **消息分割由 Manager 处理**:Channel 的 `Send` 方法不需要处理长消息分割。Manager 会在调用 `Send` 之前根据 `MaxMessageLength()` 自动分割。Channel 只需通过 `WithMaxMessageLength` 声明限制。
+
+5. **Typing/Reaction/Placeholder 由 BaseChannel + Manager 自动处理**:Channel 的 `Send` 方法不需要管理 Typing 停止、Reaction 撤销或 Placeholder 编辑。`BaseChannel.HandleMessage` 在入站侧自动触发 `TypingCapable`、`ReactionCapable` 和 `PlaceholderCapable`(通过 `owner` 类型断言);Manager 的 `preSend` 在出站侧自动停止 Typing、撤销 Reaction、编辑 Placeholder。Channel 只需实现对应接口即可。
+
+6. **工厂注册在 init() 中**:每个子包必须有 `init.go` 文件调用 `channels.RegisterFactory`。Gateway 必须通过 blank import(`_ "pkg/channels/xxx"`)触发注册。
+
+### 5.2 Metadata 字段使用约定
+
+**不要再把以下信息放入 Metadata**:
+- `peer_kind` / `peer_id` → 使用 `InboundMessage.Peer`
+- `message_id` → 使用 `InboundMessage.MessageID`
+- `sender_platform` / `sender_username` → 使用 `InboundMessage.Sender`
+
+**Metadata 仅用于**:
+- Channel 特有的扩展信息(如 Telegram 的 `reply_to_message_id`)
+- 不适合放入结构化字段的临时信息
+
+### 5.3 并发安全约定
+
+- `BaseChannel.running`:使用 `atomic.Bool`,线程安全
+- `Manager.channels` / `Manager.workers`:使用 `sync.RWMutex` 保护
+- `Manager.placeholders` / `Manager.typingStops` / `Manager.reactionUndos`:使用 `sync.Map`
+- `MessageBus.closed`:使用 `atomic.Bool`
+- `FileMediaStore`:使用 `sync.RWMutex`,两阶段操作减少持锁时间
+- Channel Worker queue:Go channel,天然并发安全
+
+### 5.4 测试约定
+
+已有测试文件:
+- `pkg/channels/base_test.go` — BaseChannel 单元测试
+- `pkg/channels/manager_test.go` — Manager 单元测试
+- `pkg/channels/split_test.go` — 消息分割测试
+- `pkg/channels/errors_test.go` — 错误类型测试
+- `pkg/channels/errutil_test.go` — 错误分类测试
+
+为新 channel 添加测试时:
+```bash
+go test ./pkg/channels/matrix/ -v # 子包测试
+go test ./pkg/channels/ -run TestSpecific -v # 框架测试
+make test # 全量测试
+```
+
+---
+
+## 附录:完整文件清单与接口速查表
+
+### A.1 框架层文件
+
+| 文件 | 职责 |
+|------|------|
+| `pkg/channels/base.go` | BaseChannel 结构体、Channel 接口、MessageLengthProvider、BaseChannelOption、HandleMessage |
+| `pkg/channels/interfaces.go` | TypingCapable、MessageEditor、ReactionCapable、PlaceholderCapable、PlaceholderRecorder 接口 |
+| `pkg/channels/media.go` | MediaSender 接口 |
+| `pkg/channels/webhook.go` | WebhookHandler、HealthChecker 接口 |
+| `pkg/channels/errors.go` | ErrNotRunning、ErrRateLimit、ErrTemporary、ErrSendFailed 哨兵 |
+| `pkg/channels/errutil.go` | ClassifySendError、ClassifyNetError 帮助函数 |
+| `pkg/channels/registry.go` | RegisterFactory、getFactory 工厂注册表 |
+| `pkg/channels/manager.go` | Manager:Worker 队列、速率限制、重试、preSend、共享 HTTP、TTL janitor |
+| `pkg/channels/split.go` | SplitMessage 长消息分割 |
+| `pkg/bus/bus.go` | MessageBus 实现 |
+| `pkg/bus/types.go` | Peer、SenderInfo、InboundMessage、OutboundMessage、OutboundMediaMessage、MediaPart |
+| `pkg/media/store.go` | MediaStore 接口、FileMediaStore 实现 |
+| `pkg/identity/identity.go` | BuildCanonicalID、ParseCanonicalID、MatchAllowed |
+
+### A.2 Channel 子包
+
+| 子包 | 注册名 | 可选接口 |
+|------|--------|----------|
+| `pkg/channels/telegram/` | `"telegram"` | MessageEditor, MediaSender, TypingCapable, PlaceholderCapable |
+| `pkg/channels/discord/` | `"discord"` | MessageEditor, TypingCapable, PlaceholderCapable |
+| `pkg/channels/slack/` | `"slack"` | ReactionCapable |
+| `pkg/channels/line/` | `"line"` | WebhookHandler, HealthChecker, TypingCapable |
+| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable |
+| `pkg/channels/dingtalk/` | `"dingtalk"` | WebhookHandler |
+| `pkg/channels/feishu/` | `"feishu"` | WebhookHandler (架构特定 build tags) |
+| `pkg/channels/wecom/` | `"wecom"` + `"wecom_app"` | WebhookHandler |
+| `pkg/channels/qq/` | `"qq"` | — |
+| `pkg/channels/whatsapp/` | `"whatsapp"` | — |
+| `pkg/channels/maixcam/` | `"maixcam"` | — |
+| `pkg/channels/pico/` | `"pico"` | WebhookHandler (Pico Protocol), TypingCapable, PlaceholderCapable |
+
+### A.3 接口速查表
+
+```go
+// ===== 必须实现 =====
+type Channel interface {
+ Name() string
+ Start(ctx context.Context) error
+ Stop(ctx context.Context) error
+ Send(ctx context.Context, msg bus.OutboundMessage) error
+ IsRunning() bool
+ IsAllowed(senderID string) bool
+ IsAllowedSender(sender bus.SenderInfo) bool
+}
+
+// ===== 可选实现 =====
+type MediaSender interface {
+ SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error
+}
+
+type TypingCapable interface {
+ StartTyping(ctx context.Context, chatID string) (stop func(), err error)
+}
+
+type ReactionCapable interface {
+ ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)
+}
+
+type PlaceholderCapable interface {
+ SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)
+}
+
+type MessageEditor interface {
+ EditMessage(ctx context.Context, chatID, messageID, content string) error
+}
+
+type WebhookHandler interface {
+ WebhookPath() string
+ http.Handler
+}
+
+type HealthChecker interface {
+ HealthPath() string
+ HealthHandler(w http.ResponseWriter, r *http.Request)
+}
+
+type MessageLengthProvider interface {
+ MaxMessageLength() int
+}
+
+// ===== 由 Manager 注入 =====
+type PlaceholderRecorder interface {
+ RecordPlaceholder(channel, chatID, placeholderID string)
+ RecordTypingStop(channel, chatID string, stop func())
+ RecordReactionUndo(channel, chatID string, undo func())
+}
+```
+
+### A.4 Gateway 启动序列(完整引导流程)
+
+```go
+// 1. 创建核心组件
+msgBus := bus.NewMessageBus()
+provider := providers.CreateProvider(cfg)
+agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
+
+// 2. 创建媒体存储(带 TTL 清理)
+mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig)
+mediaStore.Start()
+
+// 3. 创建 Channel Manager(触发 initChannels → 工厂查找 → 构造 → 注入 MediaStore/PlaceholderRecorder/Owner)
+channelManager := channels.NewManager(cfg, msgBus, mediaStore)
+
+// 4. 注入引用
+agentLoop.SetChannelManager(channelManager)
+agentLoop.SetMediaStore(mediaStore)
+
+// 5. 配置共享 HTTP 服务器
+channelManager.SetupHTTPServer(addr, healthServer)
+
+// 6. 启动
+channelManager.StartAll(ctx) // 启动 channels + workers + dispatchers + HTTP server
+go agentLoop.Run(ctx) // 启动 Agent 消息循环
+
+// 7. 关闭(信号触发)
+cancel() // 取消 context
+msgBus.Close() // 信号关闭 + 排水
+channelManager.StopAll(shutdownCtx) // 停止 HTTP + workers + channels
+mediaStore.Stop() // 停止 TTL 清理
+agentLoop.Stop() // 停止 Agent
+```
+
+### A.5 Per-channel 速率限制参考
+
+| Channel | 速率 (msg/s) | Burst |
+|---------|-------------|-------|
+| telegram | 20 | 10 |
+| discord | 1 | 1 |
+| slack | 1 | 1 |
+| line | 10 | 5 |
+| _其他_ | 10 (默认) | 5 |
+
+### A.6 已知限制和注意事项
+
+1. **媒体清理暂时禁用**:Agent loop 中的 `ReleaseAll` 调用被注释掉了(`refactor(loop): disable media cleanup to prevent premature file deletion`),因为会话边界尚未明确定义。TTL 清理仍然有效。
+
+2. **Feishu 架构特定编译**:Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`)。
+
+3. **WeCom 有两个工厂**:`"wecom"`(Bot 模式)和 `"wecom_app"`(应用模式)分别注册。
+
+4. **Pico Protocol**:`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel,通过 webhook 接收消息。
\ No newline at end of file
diff --git a/pkg/channels/base.go b/pkg/channels/base.go
index cd6419ebb..063a66523 100644
--- a/pkg/channels/base.go
+++ b/pkg/channels/base.go
@@ -2,11 +2,44 @@ package channels
import (
"context"
+ "crypto/rand"
+ "encoding/binary"
+ "encoding/hex"
+ "strconv"
"strings"
+ "sync/atomic"
+ "time"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
)
+var (
+ uniqueIDCounter uint64
+ uniqueIDPrefix string
+)
+
+func init() {
+ // One-time read from crypto/rand for a unique prefix (single syscall).
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ // fallback to time-based prefix
+ binary.BigEndian.PutUint64(b[:], uint64(time.Now().UnixNano()))
+ }
+ uniqueIDPrefix = hex.EncodeToString(b[:])
+}
+
+// uniqueID generates a process-unique ID using a random prefix and an atomic counter.
+// This ID is intended for internal correlation (e.g. media scope keys) and is NOT
+// cryptographically secure — it must not be used in contexts where unpredictability matters.
+func uniqueID() string {
+ n := atomic.AddUint64(&uniqueIDCounter, 1)
+ return uniqueIDPrefix + strconv.FormatUint(n, 16)
+}
+
type Channel interface {
Name() string
Start(ctx context.Context) error
@@ -14,32 +47,126 @@ type Channel interface {
Send(ctx context.Context, msg bus.OutboundMessage) error
IsRunning() bool
IsAllowed(senderID string) bool
+ IsAllowedSender(sender bus.SenderInfo) bool
+ ReasoningChannelID() string
+}
+
+// BaseChannelOption is a functional option for configuring a BaseChannel.
+type BaseChannelOption func(*BaseChannel)
+
+// WithMaxMessageLength sets the maximum message length (in runes) for a channel.
+// Messages exceeding this limit will be automatically split by the Manager.
+// A value of 0 means no limit.
+func WithMaxMessageLength(n int) BaseChannelOption {
+ return func(c *BaseChannel) { c.maxMessageLength = n }
+}
+
+// WithGroupTrigger sets the group trigger configuration for a channel.
+func WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption {
+ return func(c *BaseChannel) { c.groupTrigger = gt }
+}
+
+// WithReasoningChannelID sets the reasoning channel ID where thoughts should be sent.
+func WithReasoningChannelID(id string) BaseChannelOption {
+ return func(c *BaseChannel) { c.reasoningChannelID = id }
+}
+
+// MessageLengthProvider is an opt-in interface that channels implement
+// to advertise their maximum message length. The Manager uses this via
+// type assertion to decide whether to split outbound messages.
+type MessageLengthProvider interface {
+ MaxMessageLength() int
}
type BaseChannel struct {
- config any
- bus *bus.MessageBus
- running bool
- name string
- allowList []string
+ config any
+ bus *bus.MessageBus
+ running atomic.Bool
+ name string
+ allowList []string
+ maxMessageLength int
+ groupTrigger config.GroupTriggerConfig
+ mediaStore media.MediaStore
+ placeholderRecorder PlaceholderRecorder
+ owner Channel // the concrete channel that embeds this BaseChannel
+ reasoningChannelID string
}
-func NewBaseChannel(name string, config any, bus *bus.MessageBus, allowList []string) *BaseChannel {
- return &BaseChannel{
+func NewBaseChannel(
+ name string,
+ config any,
+ bus *bus.MessageBus,
+ allowList []string,
+ opts ...BaseChannelOption,
+) *BaseChannel {
+ bc := &BaseChannel{
config: config,
bus: bus,
name: name,
allowList: allowList,
- running: false,
}
+ for _, opt := range opts {
+ opt(bc)
+ }
+ return bc
+}
+
+// MaxMessageLength returns the maximum message length (in runes) for this channel.
+// A value of 0 means no limit.
+func (c *BaseChannel) MaxMessageLength() int {
+ return c.maxMessageLength
+}
+
+// ShouldRespondInGroup determines whether the bot should respond in a group chat.
+// Each channel is responsible for:
+// 1. Detecting isMentioned (platform-specific)
+// 2. Stripping bot mention from content (platform-specific)
+// 3. Calling this method to get the group response decision
+//
+// Logic:
+// - If isMentioned → always respond
+// - If mention_only configured and not mentioned → ignore
+// - If prefixes configured → respond if content starts with any prefix (strip it)
+// - If prefixes configured but no match and not mentioned → ignore
+// - Otherwise (no group_trigger configured) → respond to all (permissive default)
+func (c *BaseChannel) ShouldRespondInGroup(isMentioned bool, content string) (bool, string) {
+ gt := c.groupTrigger
+
+ // Mentioned → always respond
+ if isMentioned {
+ return true, strings.TrimSpace(content)
+ }
+
+ // mention_only → require mention
+ if gt.MentionOnly {
+ return false, content
+ }
+
+ // Prefix matching
+ if len(gt.Prefixes) > 0 {
+ for _, prefix := range gt.Prefixes {
+ if prefix != "" && strings.HasPrefix(content, prefix) {
+ return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
+ }
+ }
+ // Prefixes configured but none matched and not mentioned → ignore
+ return false, content
+ }
+
+ // No group_trigger configured → permissive (respond to all)
+ return true, strings.TrimSpace(content)
}
func (c *BaseChannel) Name() string {
return c.name
}
+func (c *BaseChannel) ReasoningChannelID() string {
+ return c.reasoningChannelID
+}
+
func (c *BaseChannel) IsRunning() bool {
- return c.running
+ return c.running.Load()
}
func (c *BaseChannel) IsAllowed(senderID string) bool {
@@ -81,23 +208,130 @@ func (c *BaseChannel) IsAllowed(senderID string) bool {
return false
}
-func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []string, metadata map[string]string) {
- if !c.IsAllowed(senderID) {
- return
+// IsAllowedSender checks whether a structured SenderInfo is permitted by the allow-list.
+// It delegates to identity.MatchAllowed for each entry, providing unified matching
+// across all legacy formats and the new canonical "platform:id" format.
+func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool {
+ if len(c.allowList) == 0 {
+ return true
}
+ for _, allowed := range c.allowList {
+ if identity.MatchAllowed(sender, allowed) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (c *BaseChannel) HandleMessage(
+ ctx context.Context,
+ peer bus.Peer,
+ messageID, senderID, chatID, content string,
+ media []string,
+ metadata map[string]string,
+ senderOpts ...bus.SenderInfo,
+) {
+ // Use SenderInfo-based allow check when available, else fall back to string
+ var sender bus.SenderInfo
+ if len(senderOpts) > 0 {
+ sender = senderOpts[0]
+ }
+ if sender.CanonicalID != "" || sender.PlatformID != "" {
+ if !c.IsAllowedSender(sender) {
+ return
+ }
+ } else {
+ if !c.IsAllowed(senderID) {
+ return
+ }
+ }
+
+ // Set SenderID to canonical if available, otherwise keep the raw senderID
+ resolvedSenderID := senderID
+ if sender.CanonicalID != "" {
+ resolvedSenderID = sender.CanonicalID
+ }
+
+ scope := BuildMediaScope(c.name, chatID, messageID)
+
msg := bus.InboundMessage{
- Channel: c.name,
- SenderID: senderID,
- ChatID: chatID,
- Content: content,
- Media: media,
- Metadata: metadata,
+ Channel: c.name,
+ SenderID: resolvedSenderID,
+ Sender: sender,
+ ChatID: chatID,
+ Content: content,
+ Media: media,
+ Peer: peer,
+ MessageID: messageID,
+ MediaScope: scope,
+ Metadata: metadata,
}
- c.bus.PublishInbound(msg)
+ // Auto-trigger typing indicator, message reaction, and placeholder before publishing.
+ // Each capability is independent — all three may fire for the same message.
+ if c.owner != nil && c.placeholderRecorder != nil {
+ // Typing — independent pipeline
+ if tc, ok := c.owner.(TypingCapable); ok {
+ if stop, err := tc.StartTyping(ctx, chatID); err == nil {
+ c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop)
+ }
+ }
+ // Reaction — independent pipeline
+ if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" {
+ if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil {
+ c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
+ }
+ }
+ // Placeholder — independent pipeline
+ if pc, ok := c.owner.(PlaceholderCapable); ok {
+ if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
+ c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
+ }
+ }
+ }
+
+ if err := c.bus.PublishInbound(ctx, msg); err != nil {
+ logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{
+ "channel": c.name,
+ "chat_id": chatID,
+ "error": err.Error(),
+ })
+ }
}
-func (c *BaseChannel) setRunning(running bool) {
- c.running = running
+func (c *BaseChannel) SetRunning(running bool) {
+ c.running.Store(running)
+}
+
+// SetMediaStore injects a MediaStore into the channel.
+func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s }
+
+// GetMediaStore returns the injected MediaStore (may be nil).
+func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore }
+
+// SetPlaceholderRecorder injects a PlaceholderRecorder into the channel.
+func (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) {
+ c.placeholderRecorder = r
+}
+
+// GetPlaceholderRecorder returns the injected PlaceholderRecorder (may be nil).
+func (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder {
+ return c.placeholderRecorder
+}
+
+// SetOwner injects the concrete channel that embeds this BaseChannel.
+// This allows HandleMessage to auto-trigger TypingCapable / ReactionCapable / PlaceholderCapable.
+func (c *BaseChannel) SetOwner(ch Channel) {
+ c.owner = ch
+}
+
+// BuildMediaScope constructs a scope key for media lifecycle tracking.
+func BuildMediaScope(channel, chatID, messageID string) string {
+ id := messageID
+ if id == "" {
+ id = uniqueID()
+ }
+ return channel + ":" + chatID + ":" + id
}
diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go
index 78c6d1d66..6132b8bf9 100644
--- a/pkg/channels/base_test.go
+++ b/pkg/channels/base_test.go
@@ -1,6 +1,11 @@
package channels
-import "testing"
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
func TestBaseChannelIsAllowed(t *testing.T) {
tests := []struct {
@@ -50,3 +55,211 @@ func TestBaseChannelIsAllowed(t *testing.T) {
})
}
}
+
+func TestShouldRespondInGroup(t *testing.T) {
+ tests := []struct {
+ name string
+ gt config.GroupTriggerConfig
+ isMentioned bool
+ content string
+ wantRespond bool
+ wantContent string
+ }{
+ {
+ name: "no config - permissive default",
+ gt: config.GroupTriggerConfig{},
+ isMentioned: false,
+ content: "hello world",
+ wantRespond: true,
+ wantContent: "hello world",
+ },
+ {
+ name: "no config - mentioned",
+ gt: config.GroupTriggerConfig{},
+ isMentioned: true,
+ content: "hello world",
+ wantRespond: true,
+ wantContent: "hello world",
+ },
+ {
+ name: "mention_only - not mentioned",
+ gt: config.GroupTriggerConfig{MentionOnly: true},
+ isMentioned: false,
+ content: "hello world",
+ wantRespond: false,
+ wantContent: "hello world",
+ },
+ {
+ name: "mention_only - mentioned",
+ gt: config.GroupTriggerConfig{MentionOnly: true},
+ isMentioned: true,
+ content: "hello world",
+ wantRespond: true,
+ wantContent: "hello world",
+ },
+ {
+ name: "prefix match",
+ gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
+ isMentioned: false,
+ content: "/ask hello",
+ wantRespond: true,
+ wantContent: "hello",
+ },
+ {
+ name: "prefix no match - not mentioned",
+ gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
+ isMentioned: false,
+ content: "hello world",
+ wantRespond: false,
+ wantContent: "hello world",
+ },
+ {
+ name: "prefix no match - but mentioned",
+ gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
+ isMentioned: true,
+ content: "hello world",
+ wantRespond: true,
+ wantContent: "hello world",
+ },
+ {
+ name: "multiple prefixes - second matches",
+ gt: config.GroupTriggerConfig{Prefixes: []string{"/ask", "/bot"}},
+ isMentioned: false,
+ content: "/bot help me",
+ wantRespond: true,
+ wantContent: "help me",
+ },
+ {
+ name: "mention_only with prefixes - mentioned overrides",
+ gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}},
+ isMentioned: true,
+ content: "hello",
+ wantRespond: true,
+ wantContent: "hello",
+ },
+ {
+ name: "mention_only with prefixes - not mentioned, no prefix",
+ gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}},
+ isMentioned: false,
+ content: "hello",
+ wantRespond: false,
+ wantContent: "hello",
+ },
+ {
+ name: "empty prefix in list is skipped",
+ gt: config.GroupTriggerConfig{Prefixes: []string{"", "/ask"}},
+ isMentioned: false,
+ content: "/ask test",
+ wantRespond: true,
+ wantContent: "test",
+ },
+ {
+ name: "prefix strips leading whitespace after prefix",
+ gt: config.GroupTriggerConfig{Prefixes: []string{"/ask "}},
+ isMentioned: false,
+ content: "/ask hello",
+ wantRespond: true,
+ wantContent: "hello",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ch := NewBaseChannel("test", nil, nil, nil, WithGroupTrigger(tt.gt))
+ gotRespond, gotContent := ch.ShouldRespondInGroup(tt.isMentioned, tt.content)
+ if gotRespond != tt.wantRespond {
+ t.Errorf("ShouldRespondInGroup() respond = %v, want %v", gotRespond, tt.wantRespond)
+ }
+ if gotContent != tt.wantContent {
+ t.Errorf("ShouldRespondInGroup() content = %q, want %q", gotContent, tt.wantContent)
+ }
+ })
+ }
+}
+
+func TestIsAllowedSender(t *testing.T) {
+ tests := []struct {
+ name string
+ allowList []string
+ sender bus.SenderInfo
+ want bool
+ }{
+ {
+ name: "empty allowlist allows all",
+ allowList: nil,
+ sender: bus.SenderInfo{PlatformID: "anyone"},
+ want: true,
+ },
+ {
+ name: "numeric ID matches PlatformID",
+ allowList: []string{"123456"},
+ sender: bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: "123456",
+ CanonicalID: "telegram:123456",
+ },
+ want: true,
+ },
+ {
+ name: "canonical format matches",
+ allowList: []string{"telegram:123456"},
+ sender: bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: "123456",
+ CanonicalID: "telegram:123456",
+ },
+ want: true,
+ },
+ {
+ name: "canonical format wrong platform",
+ allowList: []string{"discord:123456"},
+ sender: bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: "123456",
+ CanonicalID: "telegram:123456",
+ },
+ want: false,
+ },
+ {
+ name: "@username matches",
+ allowList: []string{"@alice"},
+ sender: bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: "123456",
+ CanonicalID: "telegram:123456",
+ Username: "alice",
+ },
+ want: true,
+ },
+ {
+ name: "compound id|username matches by ID",
+ allowList: []string{"123456|alice"},
+ sender: bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: "123456",
+ CanonicalID: "telegram:123456",
+ Username: "alice",
+ },
+ want: true,
+ },
+ {
+ name: "non matching sender denied",
+ allowList: []string{"654321"},
+ sender: bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: "123456",
+ CanonicalID: "telegram:123456",
+ },
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ch := NewBaseChannel("test", nil, nil, tt.allowList)
+ if got := ch.IsAllowedSender(tt.sender); got != tt.want {
+ t.Fatalf("IsAllowedSender(%+v) = %v, want %v", tt.sender, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go
similarity index 83%
rename from pkg/channels/dingtalk.go
rename to pkg/channels/dingtalk/dingtalk.go
index 662fba3b7..8642ad362 100644
--- a/pkg/channels/dingtalk.go
+++ b/pkg/channels/dingtalk/dingtalk.go
@@ -1,7 +1,7 @@
// PicoClaw - Ultra-lightweight personal AI agent
// DingTalk channel implementation using Stream Mode
-package channels
+package dingtalk
import (
"context"
@@ -12,7 +12,9 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
@@ -20,7 +22,7 @@ import (
// DingTalkChannel implements the Channel interface for DingTalk (钉钉)
// It uses WebSocket for receiving messages via stream mode and API for sending
type DingTalkChannel struct {
- *BaseChannel
+ *channels.BaseChannel
config config.DingTalkConfig
clientID string
clientSecret string
@@ -37,7 +39,11 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
}
- base := NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom)
+ base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
+ channels.WithMaxMessageLength(20000),
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
return &DingTalkChannel{
BaseChannel: base,
@@ -70,7 +76,7 @@ func (c *DingTalkChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to start stream client: %w", err)
}
- c.setRunning(true)
+ c.SetRunning(true)
logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)")
return nil
}
@@ -87,7 +93,7 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error {
c.streamClient.Close()
}
- c.setRunning(false)
+ c.SetRunning(false)
logger.InfoC("dingtalk", "DingTalk channel stopped")
return nil
}
@@ -95,7 +101,7 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error {
// Send sends a message to DingTalk via the chatbot reply API
func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
- return fmt.Errorf("dingtalk channel not running")
+ return channels.ErrNotRunning
}
// Get session webhook from storage
@@ -159,12 +165,17 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
"session_webhook": data.SessionWebhook,
}
+ var peer bus.Peer
if data.ConversationType == "1" {
- metadata["peer_kind"] = "direct"
- metadata["peer_id"] = senderID
+ peer = bus.Peer{Kind: "direct", ID: senderID}
} else {
- metadata["peer_kind"] = "group"
- metadata["peer_id"] = data.ConversationId
+ peer = bus.Peer{Kind: "group", ID: data.ConversationId}
+ // In group chats, apply unified group trigger filtering
+ respond, cleaned := c.ShouldRespondInGroup(false, content)
+ if !respond {
+ return nil, nil
+ }
+ content = cleaned
}
logger.DebugCF("dingtalk", "Received message", map[string]any{
@@ -173,8 +184,20 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
"preview": utils.Truncate(content, 50),
})
+ // Build sender info
+ sender := bus.SenderInfo{
+ Platform: "dingtalk",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("dingtalk", senderID),
+ DisplayName: senderNick,
+ }
+
+ if !c.IsAllowedSender(sender) {
+ return nil, nil
+ }
+
// Handle the message through the base channel
- c.HandleMessage(senderID, chatID, content, nil, metadata)
+ c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata, sender)
// Return nil to indicate we've handled the message asynchronously
// The response will be sent through the message bus
@@ -197,7 +220,7 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
contentBytes,
)
if err != nil {
- return fmt.Errorf("failed to send reply: %w", err)
+ return fmt.Errorf("dingtalk send: %w", channels.ErrTemporary)
}
return nil
diff --git a/pkg/channels/dingtalk/init.go b/pkg/channels/dingtalk/init.go
new file mode 100644
index 000000000..5f49bce8c
--- /dev/null
+++ b/pkg/channels/dingtalk/init.go
@@ -0,0 +1,13 @@
+package dingtalk
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("dingtalk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewDingTalkChannel(cfg.Channels.DingTalk, b)
+ })
+}
diff --git a/pkg/channels/discord.go b/pkg/channels/discord/discord.go
similarity index 52%
rename from pkg/channels/discord.go
rename to pkg/channels/discord/discord.go
index 20f3b267c..cd6a2560f 100644
--- a/pkg/channels/discord.go
+++ b/pkg/channels/discord/discord.go
@@ -1,4 +1,4 @@
-package channels
+package discord
import (
"context"
@@ -11,26 +11,27 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
- "github.com/sipeed/picoclaw/pkg/voice"
)
const (
- transcriptionTimeout = 30 * time.Second
- sendTimeout = 10 * time.Second
+ sendTimeout = 10 * time.Second
)
type DiscordChannel struct {
- *BaseChannel
- session *discordgo.Session
- config config.DiscordConfig
- transcriber *voice.GroqTranscriber
- ctx context.Context
- typingMu sync.Mutex
- typingStop map[string]chan struct{} // chatID → stop signal
- botUserID string // stored for mention checking
+ *channels.BaseChannel
+ session *discordgo.Session
+ config config.DiscordConfig
+ ctx context.Context
+ cancel context.CancelFunc
+ typingMu sync.Mutex
+ typingStop map[string]chan struct{} // chatID → stop signal
+ botUserID string // stored for mention checking
}
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
@@ -39,33 +40,25 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
return nil, fmt.Errorf("failed to create discord session: %w", err)
}
- base := NewBaseChannel("discord", cfg, bus, cfg.AllowFrom)
+ base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom,
+ channels.WithMaxMessageLength(2000),
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
return &DiscordChannel{
BaseChannel: base,
session: session,
config: cfg,
- transcriber: nil,
ctx: context.Background(),
typingStop: make(map[string]chan struct{}),
}, nil
}
-func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
- c.transcriber = transcriber
-}
-
-func (c *DiscordChannel) getContext() context.Context {
- if c.ctx == nil {
- return context.Background()
- }
- return c.ctx
-}
-
func (c *DiscordChannel) Start(ctx context.Context) error {
logger.InfoC("discord", "Starting Discord bot")
- c.ctx = ctx
+ c.ctx, c.cancel = context.WithCancel(ctx)
// Get bot user ID before opening session to avoid race condition
botUser, err := c.session.User("@me")
@@ -80,7 +73,7 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to open discord session: %w", err)
}
- c.setRunning(true)
+ c.SetRunning(true)
logger.InfoCF("discord", "Discord bot connected", map[string]any{
"username": botUser.Username,
@@ -92,7 +85,7 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
func (c *DiscordChannel) Stop(ctx context.Context) error {
logger.InfoC("discord", "Stopping Discord bot")
- c.setRunning(false)
+ c.SetRunning(false)
// Stop all typing goroutines before closing session
c.typingMu.Lock()
@@ -102,6 +95,11 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
}
c.typingMu.Unlock()
+ // Cancel our context so typing goroutines using c.ctx.Done() exit
+ if c.cancel != nil {
+ c.cancel()
+ }
+
if err := c.session.Close(); err != nil {
return fmt.Errorf("failed to close discord session: %w", err)
}
@@ -110,10 +108,8 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
}
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- c.stopTyping(msg.ChatID)
-
if !c.IsRunning() {
- return fmt.Errorf("discord bot not running")
+ return channels.ErrNotRunning
}
channelID := msg.ChatID
@@ -121,20 +117,133 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return fmt.Errorf("channel ID is empty")
}
- runes := []rune(msg.Content)
- if len(runes) == 0 {
+ if len([]rune(msg.Content)) == 0 {
return nil
}
- chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars
+ return c.sendChunk(ctx, channelID, msg.Content)
+}
- for _, chunk := range chunks {
- if err := c.sendChunk(ctx, channelID, chunk); err != nil {
- return err
+// SendMedia implements the channels.MediaSender interface.
+func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ channelID := msg.ChatID
+ if channelID == "" {
+ return fmt.Errorf("channel ID is empty")
+ }
+
+ store := c.GetMediaStore()
+ if store == nil {
+ return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
+ }
+
+ // Collect all files into a single ChannelMessageSendComplex call
+ files := make([]*discordgo.File, 0, len(msg.Parts))
+ var caption string
+
+ for _, part := range msg.Parts {
+ localPath, err := store.Resolve(part.Ref)
+ if err != nil {
+ logger.ErrorCF("discord", "Failed to resolve media ref", map[string]any{
+ "ref": part.Ref,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ file, err := os.Open(localPath)
+ if err != nil {
+ logger.ErrorCF("discord", "Failed to open media file", map[string]any{
+ "path": localPath,
+ "error": err.Error(),
+ })
+ continue
+ }
+ // Note: discordgo reads from the Reader and we can't close it before send
+
+ filename := part.Filename
+ if filename == "" {
+ filename = "file"
+ }
+
+ files = append(files, &discordgo.File{
+ Name: filename,
+ ContentType: part.ContentType,
+ Reader: file,
+ })
+
+ if part.Caption != "" && caption == "" {
+ caption = part.Caption
}
}
- return nil
+ if len(files) == 0 {
+ return nil
+ }
+
+ sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
+ defer cancel()
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
+ Content: caption,
+ Files: files,
+ })
+ done <- err
+ }()
+
+ select {
+ case err := <-done:
+ // Close all file readers
+ for _, f := range files {
+ if closer, ok := f.Reader.(*os.File); ok {
+ closer.Close()
+ }
+ }
+ if err != nil {
+ return fmt.Errorf("discord send media: %w", channels.ErrTemporary)
+ }
+ return nil
+ case <-sendCtx.Done():
+ // Close all file readers
+ for _, f := range files {
+ if closer, ok := f.Reader.(*os.File); ok {
+ closer.Close()
+ }
+ }
+ return sendCtx.Err()
+ }
+}
+
+// EditMessage implements channels.MessageEditor.
+func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
+ _, err := c.session.ChannelMessageEdit(chatID, messageID, content)
+ return err
+}
+
+// SendPlaceholder implements channels.PlaceholderCapable.
+// It sends a placeholder message that will later be edited to the actual
+// response via EditMessage (channels.MessageEditor).
+func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
+ if !c.config.Placeholder.Enabled {
+ return "", nil
+ }
+
+ text := c.config.Placeholder.Text
+ if text == "" {
+ text = "Thinking... 💭"
+ }
+
+ msg, err := c.session.ChannelMessageSend(chatID, text)
+ if err != nil {
+ return "", err
+ }
+
+ return msg.ID, nil
}
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
@@ -151,11 +260,11 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin
select {
case err := <-done:
if err != nil {
- return fmt.Errorf("failed to send discord message: %w", err)
+ return fmt.Errorf("discord send: %w", channels.ErrTemporary)
}
return nil
case <-sendCtx.Done():
- return fmt.Errorf("send message timeout: %w", sendCtx.Err())
+ return sendCtx.Err()
}
}
@@ -176,17 +285,32 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return
}
- // Check allowlist first to avoid downloading attachments and transcribing for rejected users
- if !c.IsAllowed(m.Author.ID) {
+ // Check allowlist first to avoid downloading attachments for rejected users
+ sender := bus.SenderInfo{
+ Platform: "discord",
+ PlatformID: m.Author.ID,
+ CanonicalID: identity.BuildCanonicalID("discord", m.Author.ID),
+ Username: m.Author.Username,
+ }
+ // Build display name
+ displayName := m.Author.Username
+ if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
+ displayName += "#" + m.Author.Discriminator
+ }
+ sender.DisplayName = displayName
+
+ if !c.IsAllowedSender(sender) {
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
"user_id": m.Author.ID,
})
return
}
- // If configured to only respond to mentions, check if bot is mentioned
- // Skip this check for DMs (GuildID is empty) - DMs should always be responded to
- if c.config.MentionOnly && m.GuildID != "" {
+ content := m.Content
+
+ // In guild (group) channels, apply unified group trigger filtering
+ // DMs (GuildID is empty) always get a response
+ if m.GuildID != "" {
isMentioned := false
for _, mention := range m.Mentions {
if mention.ID == c.botUserID {
@@ -194,36 +318,39 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
break
}
}
- if !isMentioned {
- logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{
+ content = c.stripBotMention(content)
+ respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
+ if !respond {
+ logger.DebugCF("discord", "Group message ignored by group trigger", map[string]any{
"user_id": m.Author.ID,
})
return
}
+ content = cleaned
+ } else {
+ // DMs: just strip bot mention without filtering
+ content = c.stripBotMention(content)
}
senderID := m.Author.ID
- senderName := m.Author.Username
- if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
- senderName += "#" + m.Author.Discriminator
- }
- content := m.Content
- content = c.stripBotMention(content)
mediaPaths := make([]string, 0, len(m.Attachments))
- localFiles := make([]string, 0, len(m.Attachments))
- // Ensure temp files are cleaned up when function returns
- defer func() {
- for _, file := range localFiles {
- if err := os.Remove(file); err != nil {
- logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{
- "file": file,
- "error": err.Error(),
- })
+ scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)
+
+ // Helper to register a local file with the media store
+ storeMedia := func(localPath, filename string) string {
+ if store := c.GetMediaStore(); store != nil {
+ ref, err := store.Store(localPath, media.MediaMeta{
+ Filename: filename,
+ Source: "discord",
+ }, scope)
+ if err == nil {
+ return ref
}
}
- }()
+ return localPath // fallback
+ }
for _, attachment := range m.Attachments {
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
@@ -231,30 +358,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
if isAudio {
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
if localPath != "" {
- localFiles = append(localFiles, localPath)
-
- transcribedText := ""
- if c.transcriber != nil && c.transcriber.IsAvailable() {
- ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
- result, err := c.transcriber.Transcribe(ctx, localPath)
- cancel() // Release context resources immediately to avoid leaks in for loop
-
- if err != nil {
- logger.ErrorCF("discord", "Voice transcription failed", map[string]any{
- "error": err.Error(),
- })
- transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename)
- } else {
- transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text)
- logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{
- "text": result.Text,
- })
- }
- } else {
- transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename)
- }
-
- content = appendContent(content, transcribedText)
+ mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename))
+ content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename))
} else {
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
"url": attachment.URL,
@@ -277,11 +382,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
content = "[media only]"
}
- // Start typing after all early returns — guaranteed to have a matching Send()
- c.startTyping(m.ChannelID)
-
logger.DebugCF("discord", "Received message", map[string]any{
- "sender_name": senderName,
+ "sender_name": sender.DisplayName,
"sender_id": senderID,
"preview": utils.Truncate(content, 50),
})
@@ -293,19 +395,18 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
peerID = senderID
}
+ peer := bus.Peer{Kind: peerKind, ID: peerID}
+
metadata := map[string]string{
- "message_id": m.ID,
"user_id": senderID,
"username": m.Author.Username,
- "display_name": senderName,
+ "display_name": sender.DisplayName,
"guild_id": m.GuildID,
"channel_id": m.ChannelID,
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
- "peer_kind": peerKind,
- "peer_id": peerID,
}
- c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
+ c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender)
}
// startTyping starts a continuous typing indicator loop for the given chatID.
@@ -354,6 +455,13 @@ func (c *DiscordChannel) stopTyping(chatID string) {
}
}
+// StartTyping implements channels.TypingCapable.
+// It starts a continuous typing indicator and returns an idempotent stop function.
+func (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
+ c.startTyping(chatID)
+ return func() { c.stopTyping(chatID) }, nil
+}
+
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
return utils.DownloadFile(url, filename, utils.DownloadOptions{
LoggerPrefix: "discord",
diff --git a/pkg/channels/discord/init.go b/pkg/channels/discord/init.go
new file mode 100644
index 000000000..15a539804
--- /dev/null
+++ b/pkg/channels/discord/init.go
@@ -0,0 +1,13 @@
+package discord
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewDiscordChannel(cfg.Channels.Discord, b)
+ })
+}
diff --git a/pkg/channels/errors.go b/pkg/channels/errors.go
new file mode 100644
index 000000000..09ee88b3f
--- /dev/null
+++ b/pkg/channels/errors.go
@@ -0,0 +1,21 @@
+package channels
+
+import "errors"
+
+var (
+ // ErrNotRunning indicates the channel is not running.
+ // Manager will not retry.
+ ErrNotRunning = errors.New("channel not running")
+
+ // ErrRateLimit indicates the platform returned a rate-limit response (e.g. HTTP 429).
+ // Manager will wait a fixed delay and retry.
+ ErrRateLimit = errors.New("rate limited")
+
+ // ErrTemporary indicates a transient failure (e.g. network timeout, 5xx).
+ // Manager will use exponential backoff and retry.
+ ErrTemporary = errors.New("temporary failure")
+
+ // ErrSendFailed indicates a permanent failure (e.g. invalid chat ID, 4xx non-429).
+ // Manager will not retry.
+ ErrSendFailed = errors.New("send failed")
+)
diff --git a/pkg/channels/errors_test.go b/pkg/channels/errors_test.go
new file mode 100644
index 000000000..e5592345a
--- /dev/null
+++ b/pkg/channels/errors_test.go
@@ -0,0 +1,56 @@
+package channels
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+)
+
+func TestErrorsIs(t *testing.T) {
+ wrapped := fmt.Errorf("telegram API: %w", ErrRateLimit)
+ if !errors.Is(wrapped, ErrRateLimit) {
+ t.Error("wrapped ErrRateLimit should match")
+ }
+ if errors.Is(wrapped, ErrTemporary) {
+ t.Error("wrapped ErrRateLimit should not match ErrTemporary")
+ }
+}
+
+func TestErrorsIsAllTypes(t *testing.T) {
+ sentinels := []error{ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed}
+
+ for _, sentinel := range sentinels {
+ wrapped := fmt.Errorf("context: %w", sentinel)
+ if !errors.Is(wrapped, sentinel) {
+ t.Errorf("wrapped %v should match itself", sentinel)
+ }
+
+ // Verify it doesn't match other sentinel errors
+ for _, other := range sentinels {
+ if other == sentinel {
+ continue
+ }
+ if errors.Is(wrapped, other) {
+ t.Errorf("wrapped %v should not match %v", sentinel, other)
+ }
+ }
+ }
+}
+
+func TestErrorMessages(t *testing.T) {
+ tests := []struct {
+ err error
+ want string
+ }{
+ {ErrNotRunning, "channel not running"},
+ {ErrRateLimit, "rate limited"},
+ {ErrTemporary, "temporary failure"},
+ {ErrSendFailed, "send failed"},
+ }
+
+ for _, tt := range tests {
+ if got := tt.err.Error(); got != tt.want {
+ t.Errorf("error message = %q, want %q", got, tt.want)
+ }
+ }
+}
diff --git a/pkg/channels/errutil.go b/pkg/channels/errutil.go
new file mode 100644
index 000000000..319e3c980
--- /dev/null
+++ b/pkg/channels/errutil.go
@@ -0,0 +1,30 @@
+package channels
+
+import (
+ "fmt"
+ "net/http"
+)
+
+// ClassifySendError wraps a raw error with the appropriate sentinel based on
+// an HTTP status code. Channels that perform HTTP API calls should use this
+// in their Send path.
+func ClassifySendError(statusCode int, rawErr error) error {
+ switch {
+ case statusCode == http.StatusTooManyRequests:
+ return fmt.Errorf("%w: %v", ErrRateLimit, rawErr)
+ case statusCode >= 500:
+ return fmt.Errorf("%w: %v", ErrTemporary, rawErr)
+ case statusCode >= 400:
+ return fmt.Errorf("%w: %v", ErrSendFailed, rawErr)
+ default:
+ return rawErr
+ }
+}
+
+// ClassifyNetError wraps a network/timeout error as ErrTemporary.
+func ClassifyNetError(err error) error {
+ if err == nil {
+ return nil
+ }
+ return fmt.Errorf("%w: %v", ErrTemporary, err)
+}
diff --git a/pkg/channels/errutil_test.go b/pkg/channels/errutil_test.go
new file mode 100644
index 000000000..e3d35f65b
--- /dev/null
+++ b/pkg/channels/errutil_test.go
@@ -0,0 +1,97 @@
+package channels
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+)
+
+func TestClassifySendError(t *testing.T) {
+ raw := fmt.Errorf("some API error")
+
+ tests := []struct {
+ name string
+ statusCode int
+ wantIs error
+ wantNil bool
+ }{
+ {"429 -> ErrRateLimit", 429, ErrRateLimit, false},
+ {"500 -> ErrTemporary", 500, ErrTemporary, false},
+ {"502 -> ErrTemporary", 502, ErrTemporary, false},
+ {"503 -> ErrTemporary", 503, ErrTemporary, false},
+ {"400 -> ErrSendFailed", 400, ErrSendFailed, false},
+ {"403 -> ErrSendFailed", 403, ErrSendFailed, false},
+ {"404 -> ErrSendFailed", 404, ErrSendFailed, false},
+ {"200 -> raw error", 200, nil, false},
+ {"201 -> raw error", 201, nil, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := ClassifySendError(tt.statusCode, raw)
+ if err == nil {
+ t.Fatal("expected non-nil error")
+ }
+ if tt.wantIs != nil {
+ if !errors.Is(err, tt.wantIs) {
+ t.Errorf("errors.Is(err, %v) = false, want true; err = %v", tt.wantIs, err)
+ }
+ } else {
+ // Should return the raw error unchanged
+ if err != raw {
+ t.Errorf("expected raw error to be returned unchanged for status %d, got %v", tt.statusCode, err)
+ }
+ }
+ })
+ }
+}
+
+func TestClassifySendErrorNoFalsePositive(t *testing.T) {
+ raw := fmt.Errorf("some error")
+
+ // 429 should NOT match ErrTemporary or ErrSendFailed
+ err := ClassifySendError(429, raw)
+ if errors.Is(err, ErrTemporary) {
+ t.Error("429 should not match ErrTemporary")
+ }
+ if errors.Is(err, ErrSendFailed) {
+ t.Error("429 should not match ErrSendFailed")
+ }
+
+ // 500 should NOT match ErrRateLimit or ErrSendFailed
+ err = ClassifySendError(500, raw)
+ if errors.Is(err, ErrRateLimit) {
+ t.Error("500 should not match ErrRateLimit")
+ }
+ if errors.Is(err, ErrSendFailed) {
+ t.Error("500 should not match ErrSendFailed")
+ }
+
+ // 400 should NOT match ErrRateLimit or ErrTemporary
+ err = ClassifySendError(400, raw)
+ if errors.Is(err, ErrRateLimit) {
+ t.Error("400 should not match ErrRateLimit")
+ }
+ if errors.Is(err, ErrTemporary) {
+ t.Error("400 should not match ErrTemporary")
+ }
+}
+
+func TestClassifyNetError(t *testing.T) {
+ t.Run("nil error returns nil", func(t *testing.T) {
+ if err := ClassifyNetError(nil); err != nil {
+ t.Errorf("expected nil, got %v", err)
+ }
+ })
+
+ t.Run("non-nil error wraps as ErrTemporary", func(t *testing.T) {
+ raw := fmt.Errorf("connection refused")
+ err := ClassifyNetError(raw)
+ if err == nil {
+ t.Fatal("expected non-nil error")
+ }
+ if !errors.Is(err, ErrTemporary) {
+ t.Errorf("errors.Is(err, ErrTemporary) = false, want true; err = %v", err)
+ }
+ })
+}
diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go
new file mode 100644
index 000000000..e8a057741
--- /dev/null
+++ b/pkg/channels/feishu/common.go
@@ -0,0 +1,9 @@
+package feishu
+
+// stringValue safely dereferences a *string pointer.
+func stringValue(v *string) string {
+ if v == nil {
+ return ""
+ }
+ return *v
+}
diff --git a/pkg/channels/feishu_32.go b/pkg/channels/feishu/feishu_32.go
similarity index 93%
rename from pkg/channels/feishu_32.go
rename to pkg/channels/feishu/feishu_32.go
index 5109b8195..d0ec758c6 100644
--- a/pkg/channels/feishu_32.go
+++ b/pkg/channels/feishu/feishu_32.go
@@ -1,18 +1,19 @@
//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64
-package channels
+package feishu
import (
"context"
"errors"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
// FeishuChannel is a stub implementation for 32-bit architectures
type FeishuChannel struct {
- *BaseChannel
+ *channels.BaseChannel
}
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu/feishu_64.go
similarity index 78%
rename from pkg/channels/feishu_64.go
rename to pkg/channels/feishu/feishu_64.go
index 42e74980f..1db1bf669 100644
--- a/pkg/channels/feishu_64.go
+++ b/pkg/channels/feishu/feishu_64.go
@@ -1,6 +1,6 @@
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
-package channels
+package feishu
import (
"context"
@@ -15,13 +15,15 @@ import (
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
type FeishuChannel struct {
- *BaseChannel
+ *channels.BaseChannel
config config.FeishuConfig
client *lark.Client
wsClient *larkws.Client
@@ -31,7 +33,10 @@ type FeishuChannel struct {
}
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
- base := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom)
+ base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
return &FeishuChannel{
BaseChannel: base,
@@ -60,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
wsClient := c.wsClient
c.mu.Unlock()
- c.setRunning(true)
+ c.SetRunning(true)
logger.InfoC("feishu", "Feishu channel started (websocket mode)")
go func() {
@@ -83,14 +88,14 @@ func (c *FeishuChannel) Stop(ctx context.Context) error {
c.wsClient = nil
c.mu.Unlock()
- c.setRunning(false)
+ c.SetRunning(false)
logger.InfoC("feishu", "Feishu channel stopped")
return nil
}
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
- return fmt.Errorf("feishu channel not running")
+ return channels.ErrNotRunning
}
if msg.ChatID == "" {
@@ -114,11 +119,11 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
resp, err := c.client.Im.V1.Message.Create(ctx, req)
if err != nil {
- return fmt.Errorf("failed to send feishu message: %w", err)
+ return fmt.Errorf("feishu send: %w", channels.ErrTemporary)
}
if !resp.Success() {
- return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
+ return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
}
logger.DebugCF("feishu", "Feishu message sent", map[string]any{
@@ -128,7 +133,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return nil
}
-func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2MessageReceiveV1) error {
+func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
if event == nil || event.Event == nil || event.Event.Message == nil {
return nil
}
@@ -152,8 +157,9 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
}
metadata := map[string]string{}
- if messageID := stringValue(message.MessageId); messageID != "" {
- metadata["message_id"] = messageID
+ messageID := ""
+ if mid := stringValue(message.MessageId); mid != "" {
+ messageID = mid
}
if messageType := stringValue(message.MessageType); messageType != "" {
metadata["message_type"] = messageType
@@ -166,12 +172,17 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
}
chatType := stringValue(message.ChatType)
+ var peer bus.Peer
if chatType == "p2p" {
- metadata["peer_kind"] = "direct"
- metadata["peer_id"] = senderID
+ peer = bus.Peer{Kind: "direct", ID: senderID}
} else {
- metadata["peer_kind"] = "group"
- metadata["peer_id"] = chatID
+ peer = bus.Peer{Kind: "group", ID: chatID}
+ // In group chats, apply unified group trigger filtering
+ respond, cleaned := c.ShouldRespondInGroup(false, content)
+ if !respond {
+ return nil
+ }
+ content = cleaned
}
logger.InfoCF("feishu", "Feishu message received", map[string]any{
@@ -180,7 +191,17 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
"preview": utils.Truncate(content, 80),
})
- c.HandleMessage(senderID, chatID, content, nil, metadata)
+ senderInfo := bus.SenderInfo{
+ Platform: "feishu",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("feishu", senderID),
+ }
+
+ if !c.IsAllowedSender(senderInfo) {
+ return nil
+ }
+
+ c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, senderInfo)
return nil
}
@@ -218,10 +239,3 @@ func extractFeishuMessageContent(message *larkim.EventMessage) string {
return *message.Content
}
-
-func stringValue(v *string) string {
- if v == nil {
- return ""
- }
- return *v
-}
diff --git a/pkg/channels/feishu/init.go b/pkg/channels/feishu/init.go
new file mode 100644
index 000000000..7e5a62dae
--- /dev/null
+++ b/pkg/channels/feishu/init.go
@@ -0,0 +1,13 @@
+package feishu
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewFeishuChannel(cfg.Channels.Feishu, b)
+ })
+}
diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go
new file mode 100644
index 000000000..74caeeac5
--- /dev/null
+++ b/pkg/channels/interfaces.go
@@ -0,0 +1,41 @@
+package channels
+
+import "context"
+
+// TypingCapable — channels that can show a typing/thinking indicator.
+// StartTyping begins the indicator and returns a stop function.
+// The stop function MUST be idempotent and safe to call multiple times.
+type TypingCapable interface {
+ StartTyping(ctx context.Context, chatID string) (stop func(), err error)
+}
+
+// MessageEditor — channels that can edit an existing message.
+// messageID is always string; channels convert platform-specific types internally.
+type MessageEditor interface {
+ EditMessage(ctx context.Context, chatID string, messageID string, content string) error
+}
+
+// ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message.
+// ReactToMessage adds a reaction and returns an undo function to remove it.
+// The undo function MUST be idempotent and safe to call multiple times.
+type ReactionCapable interface {
+ ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)
+}
+
+// PlaceholderCapable — channels that can send a placeholder message
+// (e.g. "Thinking... 💭") that will later be edited to the actual response.
+// The channel MUST also implement MessageEditor for the placeholder to be useful.
+// SendPlaceholder returns the platform message ID of the placeholder so that
+// Manager.preSend can later edit it via MessageEditor.EditMessage.
+type PlaceholderCapable interface {
+ SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)
+}
+
+// PlaceholderRecorder is injected into channels by Manager.
+// Channels call these methods on inbound to register typing/placeholder state.
+// Manager uses the registered state on outbound to stop typing and edit placeholders.
+type PlaceholderRecorder interface {
+ RecordPlaceholder(channel, chatID, placeholderID string)
+ RecordTypingStop(channel, chatID string, stop func())
+ RecordReactionUndo(channel, chatID string, undo func())
+}
diff --git a/pkg/channels/line/init.go b/pkg/channels/line/init.go
new file mode 100644
index 000000000..9265575cc
--- /dev/null
+++ b/pkg/channels/line/init.go
@@ -0,0 +1,13 @@
+package line
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("line", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewLINEChannel(cfg.Channels.LINE, b)
+ })
+}
diff --git a/pkg/channels/line.go b/pkg/channels/line/line.go
similarity index 75%
rename from pkg/channels/line.go
rename to pkg/channels/line/line.go
index 44134996f..9fac2831c 100644
--- a/pkg/channels/line.go
+++ b/pkg/channels/line/line.go
@@ -1,4 +1,4 @@
-package channels
+package line
import (
"bytes"
@@ -10,14 +10,16 @@ import (
"fmt"
"io"
"net/http"
- "os"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
)
@@ -41,9 +43,8 @@ type replyTokenEntry struct {
// using the LINE Messaging API with HTTP webhook for receiving messages
// and REST API for sending messages.
type LINEChannel struct {
- *BaseChannel
+ *channels.BaseChannel
config config.LINEConfig
- httpServer *http.Server
botUserID string // Bot's user ID
botBasicID string // Bot's basic ID (e.g. @216ru...)
botDisplayName string // Bot's display name for text-based mention detection
@@ -59,7 +60,11 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
}
- base := NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom)
+ base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom,
+ channels.WithMaxMessageLength(5000),
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
return &LINEChannel{
BaseChannel: base,
@@ -67,7 +72,7 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
}, nil
}
-// Start launches the HTTP webhook server.
+// Start initializes the LINE channel.
func (c *LINEChannel) Start(ctx context.Context) error {
logger.InfoC("line", "Starting LINE channel (Webhook Mode)")
@@ -86,32 +91,7 @@ func (c *LINEChannel) Start(ctx context.Context) error {
})
}
- mux := http.NewServeMux()
- path := c.config.WebhookPath
- if path == "" {
- path = "/webhook/line"
- }
- mux.HandleFunc(path, c.webhookHandler)
-
- addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
- c.httpServer = &http.Server{
- Addr: addr,
- Handler: mux,
- }
-
- go func() {
- logger.InfoCF("line", "LINE webhook server listening", map[string]any{
- "addr": addr,
- "path": path,
- })
- if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("line", "Webhook server error", map[string]any{
- "error": err.Error(),
- })
- }
- }()
-
- c.setRunning(true)
+ c.SetRunning(true)
logger.InfoC("line", "LINE channel started (Webhook Mode)")
return nil
}
@@ -150,7 +130,7 @@ func (c *LINEChannel) fetchBotInfo() error {
return nil
}
-// Stop gracefully shuts down the HTTP server.
+// Stop gracefully stops the LINE channel.
func (c *LINEChannel) Stop(ctx context.Context) error {
logger.InfoC("line", "Stopping LINE channel")
@@ -158,21 +138,24 @@ func (c *LINEChannel) Stop(ctx context.Context) error {
c.cancel()
}
- if c.httpServer != nil {
- shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
- if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
- logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{
- "error": err.Error(),
- })
- }
- }
-
- c.setRunning(false)
+ c.SetRunning(false)
logger.InfoC("line", "LINE channel stopped")
return nil
}
+// WebhookPath returns the path for registering on the shared HTTP server.
+func (c *LINEChannel) WebhookPath() string {
+ if c.config.WebhookPath != "" {
+ return c.config.WebhookPath
+ }
+ return "/webhook/line"
+}
+
+// ServeHTTP implements http.Handler for the shared HTTP server.
+func (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ c.webhookHandler(w, r)
+}
+
// webhookHandler handles incoming LINE webhook requests.
func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -284,14 +267,6 @@ func (c *LINEChannel) processEvent(event lineEvent) {
return
}
- // In group chats, only respond when the bot is mentioned
- if isGroup && !c.isBotMentioned(msg) {
- logger.DebugCF("line", "Ignoring group message without mention", map[string]any{
- "chat_id": chatID,
- })
- return
- }
-
// Store reply token for later use
if event.ReplyToken != "" {
c.replyTokens.Store(chatID, replyTokenEntry{
@@ -307,18 +282,22 @@ func (c *LINEChannel) processEvent(event lineEvent) {
var content string
var mediaPaths []string
- localFiles := []string{}
- defer func() {
- for _, file := range localFiles {
- if err := os.Remove(file); err != nil {
- logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{
- "file": file,
- "error": err.Error(),
- })
+ scope := channels.BuildMediaScope("line", chatID, msg.ID)
+
+ // Helper to register a local file with the media store
+ storeMedia := func(localPath, filename string) string {
+ if store := c.GetMediaStore(); store != nil {
+ ref, err := store.Store(localPath, media.MediaMeta{
+ Filename: filename,
+ Source: "line",
+ }, scope)
+ if err == nil {
+ return ref
}
}
- }()
+ return localPath // fallback
+ }
switch msg.Type {
case "text":
@@ -330,22 +309,19 @@ func (c *LINEChannel) processEvent(event lineEvent) {
case "image":
localPath := c.downloadContent(msg.ID, "image.jpg")
if localPath != "" {
- localFiles = append(localFiles, localPath)
- mediaPaths = append(mediaPaths, localPath)
+ mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg"))
content = "[image]"
}
case "audio":
localPath := c.downloadContent(msg.ID, "audio.m4a")
if localPath != "" {
- localFiles = append(localFiles, localPath)
- mediaPaths = append(mediaPaths, localPath)
+ mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a"))
content = "[audio]"
}
case "video":
localPath := c.downloadContent(msg.ID, "video.mp4")
if localPath != "" {
- localFiles = append(localFiles, localPath)
- mediaPaths = append(mediaPaths, localPath)
+ mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4"))
content = "[video]"
}
case "file":
@@ -360,18 +336,29 @@ func (c *LINEChannel) processEvent(event lineEvent) {
return
}
+ // In group chats, apply unified group trigger filtering
+ if isGroup {
+ isMentioned := c.isBotMentioned(msg)
+ respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
+ if !respond {
+ logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{
+ "chat_id": chatID,
+ })
+ return
+ }
+ content = cleaned
+ }
+
metadata := map[string]string{
"platform": "line",
"source_type": event.Source.Type,
- "message_id": msg.ID,
}
+ var peer bus.Peer
if isGroup {
- metadata["peer_kind"] = "group"
- metadata["peer_id"] = chatID
+ peer = bus.Peer{Kind: "group", ID: chatID}
} else {
- metadata["peer_kind"] = "direct"
- metadata["peer_id"] = senderID
+ peer = bus.Peer{Kind: "direct", ID: senderID}
}
logger.DebugCF("line", "Received message", map[string]any{
@@ -382,10 +369,17 @@ func (c *LINEChannel) processEvent(event lineEvent) {
"preview": utils.Truncate(content, 50),
})
- // Show typing/loading indicator (requires user ID, not group ID)
- c.sendLoading(senderID)
+ sender := bus.SenderInfo{
+ Platform: "line",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("line", senderID),
+ }
- c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
+ if !c.IsAllowedSender(sender) {
+ return
+ }
+
+ c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender)
}
// isBotMentioned checks if the bot is mentioned in the message.
@@ -491,7 +485,7 @@ func (c *LINEChannel) resolveChatID(source lineSource) string {
// using a cached reply token, then falls back to the Push API.
func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
- return fmt.Errorf("line channel not running")
+ return channels.ErrNotRunning
}
// Load and consume quote token for this chat
@@ -519,6 +513,36 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
}
+// SendMedia implements the channels.MediaSender interface.
+// LINE requires media to be accessible via public URL; since we only have local files,
+// we fall back to sending a text message with the filename/caption.
+// For full support, an external file hosting service would be needed.
+func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ store := c.GetMediaStore()
+ if store == nil {
+ return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
+ }
+
+ // LINE Messaging API requires publicly accessible URLs for media messages.
+ // Since we only have local file paths, send caption text as fallback.
+ for _, part := range msg.Parts {
+ caption := part.Caption
+ if caption == "" {
+ caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
+ }
+
+ if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
// buildTextMessage creates a text message object, optionally with quoteToken.
func buildTextMessage(content, quoteToken string) map[string]string {
msg := map[string]string{
@@ -551,17 +575,58 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri
return c.callAPI(ctx, linePushEndpoint, payload)
}
+// StartTyping implements channels.TypingCapable using LINE's loading animation.
+//
+// NOTE: The LINE loading animation API only works for 1:1 chats.
+// Group/room chat IDs (starting with "C" or "R") are detected automatically;
+// for these, a no-op stop function is returned without calling the API.
+func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
+ if chatID == "" {
+ return func() {}, nil
+ }
+
+ // Group/room chats: LINE loading animation is 1:1 only.
+ if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") {
+ return func() {}, nil
+ }
+
+ typingCtx, cancel := context.WithCancel(ctx)
+ var once sync.Once
+ stop := func() { once.Do(cancel) }
+
+ // Send immediately, then refresh periodically for long-running tasks.
+ if err := c.sendLoading(typingCtx, chatID); err != nil {
+ stop()
+ return stop, err
+ }
+
+ ticker := time.NewTicker(50 * time.Second)
+ go func() {
+ defer ticker.Stop()
+ for {
+ select {
+ case <-typingCtx.Done():
+ return
+ case <-ticker.C:
+ if err := c.sendLoading(typingCtx, chatID); err != nil {
+ logger.DebugCF("line", "Failed to refresh loading indicator", map[string]any{
+ "error": err.Error(),
+ })
+ }
+ }
+ }
+ }()
+
+ return stop, nil
+}
+
// sendLoading sends a loading animation indicator to the chat.
-func (c *LINEChannel) sendLoading(chatID string) {
+func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
payload := map[string]any{
"chatId": chatID,
"loadingSeconds": 60,
}
- if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
- logger.DebugCF("line", "Failed to send loading indicator", map[string]any{
- "error": err.Error(),
- })
- }
+ return c.callAPI(ctx, lineLoadingEndpoint, payload)
}
// callAPI makes an authenticated POST request to the LINE API.
@@ -582,13 +647,13 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
- return fmt.Errorf("API request failed: %w", err)
+ return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody))
+ return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
}
return nil
diff --git a/pkg/channels/maixcam/init.go b/pkg/channels/maixcam/init.go
new file mode 100644
index 000000000..5a269b22b
--- /dev/null
+++ b/pkg/channels/maixcam/init.go
@@ -0,0 +1,13 @@
+package maixcam
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("maixcam", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewMaixCamChannel(cfg.Channels.MaixCam, b)
+ })
+}
diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam/maixcam.go
similarity index 78%
rename from pkg/channels/maixcam.go
rename to pkg/channels/maixcam/maixcam.go
index 34ce62b20..ff9a3ed1a 100644
--- a/pkg/channels/maixcam.go
+++ b/pkg/channels/maixcam/maixcam.go
@@ -1,4 +1,4 @@
-package channels
+package maixcam
import (
"context"
@@ -6,16 +6,21 @@ import (
"fmt"
"net"
"sync"
+ "time"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
)
type MaixCamChannel struct {
- *BaseChannel
+ *channels.BaseChannel
config config.MaixCamConfig
listener net.Listener
+ ctx context.Context
+ cancel context.CancelFunc
clients map[net.Conn]bool
clientsMux sync.RWMutex
}
@@ -28,7 +33,13 @@ type MaixCamMessage struct {
}
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
- base := NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom)
+ base := channels.NewBaseChannel(
+ "maixcam",
+ cfg,
+ bus,
+ cfg.AllowFrom,
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
return &MaixCamChannel{
BaseChannel: base,
@@ -40,37 +51,40 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC
func (c *MaixCamChannel) Start(ctx context.Context) error {
logger.InfoC("maixcam", "Starting MaixCam channel server")
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
listener, err := net.Listen("tcp", addr)
if err != nil {
+ c.cancel()
return fmt.Errorf("failed to listen on %s: %w", addr, err)
}
c.listener = listener
- c.setRunning(true)
+ c.SetRunning(true)
logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{
"host": c.config.Host,
"port": c.config.Port,
})
- go c.acceptConnections(ctx)
+ go c.acceptConnections()
return nil
}
-func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
+func (c *MaixCamChannel) acceptConnections() {
logger.DebugC("maixcam", "Starting connection acceptor")
for {
select {
- case <-ctx.Done():
+ case <-c.ctx.Done():
logger.InfoC("maixcam", "Stopping connection acceptor")
return
default:
conn, err := c.listener.Accept()
if err != nil {
- if c.running {
+ if c.IsRunning() {
logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{
"error": err.Error(),
})
@@ -86,12 +100,12 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
c.clients[conn] = true
c.clientsMux.Unlock()
- go c.handleConnection(conn, ctx)
+ go c.handleConnection(conn)
}
}
}
-func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
+func (c *MaixCamChannel) handleConnection(conn net.Conn) {
logger.DebugC("maixcam", "Handling MaixCam connection")
defer func() {
@@ -106,7 +120,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
for {
select {
- case <-ctx.Done():
+ case <-c.ctx.Done():
return
default:
var msg MaixCamMessage
@@ -170,11 +184,29 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
"y": fmt.Sprintf("%.0f", y),
"w": fmt.Sprintf("%.0f", w),
"h": fmt.Sprintf("%.0f", h),
- "peer_kind": "channel",
- "peer_id": "default",
}
- c.HandleMessage(senderID, chatID, content, []string{}, metadata)
+ sender := bus.SenderInfo{
+ Platform: "maixcam",
+ PlatformID: "maixcam",
+ CanonicalID: identity.BuildCanonicalID("maixcam", "maixcam"),
+ }
+
+ if !c.IsAllowedSender(sender) {
+ return
+ }
+
+ c.HandleMessage(
+ c.ctx,
+ bus.Peer{Kind: "channel", ID: "default"},
+ "",
+ senderID,
+ chatID,
+ content,
+ []string{},
+ metadata,
+ sender,
+ )
}
func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
@@ -185,7 +217,12 @@ func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
func (c *MaixCamChannel) Stop(ctx context.Context) error {
logger.InfoC("maixcam", "Stopping MaixCam channel")
- c.setRunning(false)
+ c.SetRunning(false)
+
+ // Cancel context first to signal goroutines to exit
+ if c.cancel != nil {
+ c.cancel()
+ }
if c.listener != nil {
c.listener.Close()
@@ -205,7 +242,14 @@ func (c *MaixCamChannel) Stop(ctx context.Context) error {
func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
- return fmt.Errorf("maixcam channel not running")
+ return channels.ErrNotRunning
+ }
+
+ // Check ctx before entering write path
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
}
c.clientsMux.RLock()
@@ -230,13 +274,15 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
var sendErr error
for conn := range c.clients {
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if _, err := conn.Write(data); err != nil {
logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{
"client": conn.RemoteAddr().String(),
"error": err.Error(),
})
- sendErr = err
+ sendErr = fmt.Errorf("maixcam send: %w", channels.ErrTemporary)
}
+ _ = conn.SetWriteDeadline(time.Time{})
}
return sendErr
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 75edaf49e..31af9672c 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -8,32 +8,152 @@ package channels
import (
"context"
+ "errors"
"fmt"
+ "math"
+ "net/http"
"sync"
+ "time"
+
+ "golang.org/x/time/rate"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
+ "github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
)
+const (
+ defaultChannelQueueSize = 16
+ defaultRateLimit = 10 // default 10 msg/s
+ maxRetries = 3
+ rateLimitDelay = 1 * time.Second
+ baseBackoff = 500 * time.Millisecond
+ maxBackoff = 8 * time.Second
+
+ janitorInterval = 10 * time.Second
+ typingStopTTL = 5 * time.Minute
+ placeholderTTL = 10 * time.Minute
+)
+
+// typingEntry wraps a typing stop function with a creation timestamp for TTL eviction.
+type typingEntry struct {
+ stop func()
+ createdAt time.Time
+}
+
+// reactionEntry wraps a reaction undo function with a creation timestamp for TTL eviction.
+type reactionEntry struct {
+ undo func()
+ createdAt time.Time
+}
+
+// placeholderEntry wraps a placeholder ID with a creation timestamp for TTL eviction.
+type placeholderEntry struct {
+ id string
+ createdAt time.Time
+}
+
+// channelRateConfig maps channel name to per-second rate limit.
+var channelRateConfig = map[string]float64{
+ "telegram": 20,
+ "discord": 1,
+ "slack": 1,
+ "line": 10,
+}
+
+type channelWorker struct {
+ ch Channel
+ queue chan bus.OutboundMessage
+ mediaQueue chan bus.OutboundMediaMessage
+ done chan struct{}
+ mediaDone chan struct{}
+ limiter *rate.Limiter
+}
+
type Manager struct {
- channels map[string]Channel
- bus *bus.MessageBus
- config *config.Config
- dispatchTask *asyncTask
- mu sync.RWMutex
+ channels map[string]Channel
+ workers map[string]*channelWorker
+ bus *bus.MessageBus
+ config *config.Config
+ mediaStore media.MediaStore
+ dispatchTask *asyncTask
+ mux *http.ServeMux
+ httpServer *http.Server
+ mu sync.RWMutex
+ placeholders sync.Map // "channel:chatID" → placeholderID (string)
+ typingStops sync.Map // "channel:chatID" → func()
+ reactionUndos sync.Map // "channel:chatID" → reactionEntry
}
type asyncTask struct {
cancel context.CancelFunc
}
-func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error) {
+// RecordPlaceholder registers a placeholder message for later editing.
+// Implements PlaceholderRecorder.
+func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
+ key := channel + ":" + chatID
+ m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})
+}
+
+// RecordTypingStop registers a typing stop function for later invocation.
+// Implements PlaceholderRecorder.
+func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
+ key := channel + ":" + chatID
+ m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()})
+}
+
+// RecordReactionUndo registers a reaction undo function for later invocation.
+// Implements PlaceholderRecorder.
+func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
+ key := channel + ":" + chatID
+ m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()})
+}
+
+// preSend handles typing stop, reaction undo, and placeholder editing before sending a message.
+// Returns true if the message was edited into a placeholder (skip Send).
+func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool {
+ key := name + ":" + msg.ChatID
+
+ // 1. Stop typing
+ if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
+ if entry, ok := v.(typingEntry); ok {
+ entry.stop() // idempotent, safe
+ }
+ }
+
+ // 2. Undo reaction
+ if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
+ if entry, ok := v.(reactionEntry); ok {
+ entry.undo() // idempotent, safe
+ }
+ }
+
+ // 3. Try editing placeholder
+ if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
+ if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
+ if editor, ok := ch.(MessageEditor); ok {
+ if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil {
+ return true // edited successfully, skip Send
+ }
+ // edit failed → fall through to normal Send
+ }
+ }
+ }
+
+ return false
+}
+
+func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) {
m := &Manager{
- channels: make(map[string]Channel),
- bus: messageBus,
- config: cfg,
+ channels: make(map[string]Channel),
+ workers: make(map[string]*channelWorker),
+ bus: messageBus,
+ config: cfg,
+ mediaStore: store,
}
if err := m.initChannels(); err != nil {
@@ -43,163 +163,104 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error
return m, nil
}
+// initChannel is a helper that looks up a factory by name and creates the channel.
+func (m *Manager) initChannel(name, displayName string) {
+ f, ok := getFactory(name)
+ if !ok {
+ logger.WarnCF("channels", "Factory not registered", map[string]any{
+ "channel": displayName,
+ })
+ return
+ }
+ logger.DebugCF("channels", "Attempting to initialize channel", map[string]any{
+ "channel": displayName,
+ })
+ ch, err := f(m.config, m.bus)
+ if err != nil {
+ logger.ErrorCF("channels", "Failed to initialize channel", map[string]any{
+ "channel": displayName,
+ "error": err.Error(),
+ })
+ } else {
+ // Inject MediaStore if channel supports it
+ if m.mediaStore != nil {
+ if setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok {
+ setter.SetMediaStore(m.mediaStore)
+ }
+ }
+ // Inject PlaceholderRecorder if channel supports it
+ if setter, ok := ch.(interface{ SetPlaceholderRecorder(r PlaceholderRecorder) }); ok {
+ setter.SetPlaceholderRecorder(m)
+ }
+ // Inject owner reference so BaseChannel.HandleMessage can auto-trigger typing/reaction
+ if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok {
+ setter.SetOwner(ch)
+ }
+ m.channels[name] = ch
+ logger.InfoCF("channels", "Channel enabled successfully", map[string]any{
+ "channel": displayName,
+ })
+ }
+}
+
func (m *Manager) initChannels() error {
logger.InfoC("channels", "Initializing channel manager")
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
- logger.DebugC("channels", "Attempting to initialize Telegram channel")
- telegram, err := NewTelegramChannel(m.config, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["telegram"] = telegram
- logger.InfoC("channels", "Telegram channel enabled successfully")
- }
+ m.initChannel("telegram", "Telegram")
}
- if m.config.Channels.WhatsApp.Enabled && m.config.Channels.WhatsApp.BridgeURL != "" {
- logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
- whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["whatsapp"] = whatsapp
- logger.InfoC("channels", "WhatsApp channel enabled successfully")
+ if m.config.Channels.WhatsApp.Enabled {
+ waCfg := m.config.Channels.WhatsApp
+ if waCfg.UseNative {
+ m.initChannel("whatsapp_native", "WhatsApp Native")
+ } else if waCfg.BridgeURL != "" {
+ m.initChannel("whatsapp", "WhatsApp")
}
}
if m.config.Channels.Feishu.Enabled {
- logger.DebugC("channels", "Attempting to initialize Feishu channel")
- feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["feishu"] = feishu
- logger.InfoC("channels", "Feishu channel enabled successfully")
- }
+ m.initChannel("feishu", "Feishu")
}
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
- logger.DebugC("channels", "Attempting to initialize Discord channel")
- discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["discord"] = discord
- logger.InfoC("channels", "Discord channel enabled successfully")
- }
+ m.initChannel("discord", "Discord")
}
if m.config.Channels.MaixCam.Enabled {
- logger.DebugC("channels", "Attempting to initialize MaixCam channel")
- maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["maixcam"] = maixcam
- logger.InfoC("channels", "MaixCam channel enabled successfully")
- }
+ m.initChannel("maixcam", "MaixCam")
}
if m.config.Channels.QQ.Enabled {
- logger.DebugC("channels", "Attempting to initialize QQ channel")
- qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["qq"] = qq
- logger.InfoC("channels", "QQ channel enabled successfully")
- }
+ m.initChannel("qq", "QQ")
}
if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" {
- logger.DebugC("channels", "Attempting to initialize DingTalk channel")
- dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["dingtalk"] = dingtalk
- logger.InfoC("channels", "DingTalk channel enabled successfully")
- }
+ m.initChannel("dingtalk", "DingTalk")
}
if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" {
- logger.DebugC("channels", "Attempting to initialize Slack channel")
- slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["slack"] = slackCh
- logger.InfoC("channels", "Slack channel enabled successfully")
- }
+ m.initChannel("slack", "Slack")
}
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
- logger.DebugC("channels", "Attempting to initialize LINE channel")
- line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["line"] = line
- logger.InfoC("channels", "LINE channel enabled successfully")
- }
+ m.initChannel("line", "LINE")
}
if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
- logger.DebugC("channels", "Attempting to initialize OneBot channel")
- onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["onebot"] = onebot
- logger.InfoC("channels", "OneBot channel enabled successfully")
- }
+ m.initChannel("onebot", "OneBot")
}
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
- logger.DebugC("channels", "Attempting to initialize WeCom channel")
- wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["wecom"] = wecom
- logger.InfoC("channels", "WeCom channel enabled successfully")
- }
+ m.initChannel("wecom", "WeCom")
}
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
- logger.DebugC("channels", "Attempting to initialize WeCom App channel")
- wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
- if err != nil {
- logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]any{
- "error": err.Error(),
- })
- } else {
- m.channels["wecom_app"] = wecomApp
- logger.InfoC("channels", "WeCom App channel enabled successfully")
- }
+ m.initChannel("wecom_app", "WeCom App")
+ }
+
+ if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token != "" {
+ m.initChannel("pico", "Pico")
}
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
@@ -209,6 +270,43 @@ func (m *Manager) initChannels() error {
return nil
}
+// SetupHTTPServer creates a shared HTTP server with the given listen address.
+// It registers health endpoints from the health server and discovers channels
+// that implement WebhookHandler and/or HealthChecker to register their handlers.
+func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) {
+ m.mux = http.NewServeMux()
+
+ // Register health endpoints
+ if healthServer != nil {
+ healthServer.RegisterOnMux(m.mux)
+ }
+
+ // Discover and register webhook handlers and health checkers
+ for name, ch := range m.channels {
+ if wh, ok := ch.(WebhookHandler); ok {
+ m.mux.Handle(wh.WebhookPath(), wh)
+ logger.InfoCF("channels", "Webhook handler registered", map[string]any{
+ "channel": name,
+ "path": wh.WebhookPath(),
+ })
+ }
+ if hc, ok := ch.(HealthChecker); ok {
+ m.mux.HandleFunc(hc.HealthPath(), hc.HealthHandler)
+ logger.InfoCF("channels", "Health endpoint registered", map[string]any{
+ "channel": name,
+ "path": hc.HealthPath(),
+ })
+ }
+ }
+
+ m.httpServer = &http.Server{
+ Addr: addr,
+ Handler: m.mux,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ }
+}
+
func (m *Manager) StartAll(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -223,8 +321,6 @@ func (m *Manager) StartAll(ctx context.Context) error {
dispatchCtx, cancel := context.WithCancel(ctx)
m.dispatchTask = &asyncTask{cancel: cancel}
- go m.dispatchOutbound(dispatchCtx)
-
for name, channel := range m.channels {
logger.InfoCF("channels", "Starting channel", map[string]any{
"channel": name,
@@ -234,7 +330,34 @@ func (m *Manager) StartAll(ctx context.Context) error {
"channel": name,
"error": err.Error(),
})
+ continue
}
+ // Lazily create worker only after channel starts successfully
+ w := newChannelWorker(name, channel)
+ m.workers[name] = w
+ go m.runWorker(dispatchCtx, name, w)
+ go m.runMediaWorker(dispatchCtx, name, w)
+ }
+
+ // Start the dispatcher that reads from the bus and routes to workers
+ go m.dispatchOutbound(dispatchCtx)
+ go m.dispatchOutboundMedia(dispatchCtx)
+
+ // Start the TTL janitor that cleans up stale typing/placeholder entries
+ go m.runTTLJanitor(dispatchCtx)
+
+ // Start shared HTTP server if configured
+ if m.httpServer != nil {
+ go func() {
+ logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
+ "addr": m.httpServer.Addr,
+ })
+ if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ logger.ErrorCF("channels", "Shared HTTP server error", map[string]any{
+ "error": err.Error(),
+ })
+ }
+ }()
}
logger.InfoC("channels", "All channels started")
@@ -247,11 +370,48 @@ func (m *Manager) StopAll(ctx context.Context) error {
logger.InfoC("channels", "Stopping all channels")
+ // Shutdown shared HTTP server first
+ if m.httpServer != nil {
+ shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ if err := m.httpServer.Shutdown(shutdownCtx); err != nil {
+ logger.ErrorCF("channels", "Shared HTTP server shutdown error", map[string]any{
+ "error": err.Error(),
+ })
+ }
+ m.httpServer = nil
+ }
+
+ // Cancel dispatcher
if m.dispatchTask != nil {
m.dispatchTask.cancel()
m.dispatchTask = nil
}
+ // Close all worker queues and wait for them to drain
+ for _, w := range m.workers {
+ if w != nil {
+ close(w.queue)
+ }
+ }
+ for _, w := range m.workers {
+ if w != nil {
+ <-w.done
+ }
+ }
+ // Close all media worker queues and wait for them to drain
+ for _, w := range m.workers {
+ if w != nil {
+ close(w.mediaQueue)
+ }
+ }
+ for _, w := range m.workers {
+ if w != nil {
+ <-w.mediaDone
+ }
+ }
+
+ // Stop all channels
for name, channel := range m.channels {
logger.InfoCF("channels", "Stopping channel", map[string]any{
"channel": name,
@@ -268,42 +428,316 @@ func (m *Manager) StopAll(ctx context.Context) error {
return nil
}
+// newChannelWorker creates a channelWorker with a rate limiter configured
+// for the given channel name.
+func newChannelWorker(name string, ch Channel) *channelWorker {
+ rateVal := float64(defaultRateLimit)
+ if r, ok := channelRateConfig[name]; ok {
+ rateVal = r
+ }
+ burst := int(math.Max(1, math.Ceil(rateVal/2)))
+
+ return &channelWorker{
+ ch: ch,
+ queue: make(chan bus.OutboundMessage, defaultChannelQueueSize),
+ mediaQueue: make(chan bus.OutboundMediaMessage, defaultChannelQueueSize),
+ done: make(chan struct{}),
+ mediaDone: make(chan struct{}),
+ limiter: rate.NewLimiter(rate.Limit(rateVal), burst),
+ }
+}
+
+// runWorker processes outbound messages for a single channel, splitting
+// messages that exceed the channel's maximum message length.
+func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) {
+ defer close(w.done)
+ for {
+ select {
+ case msg, ok := <-w.queue:
+ if !ok {
+ return
+ }
+ maxLen := 0
+ if mlp, ok := w.ch.(MessageLengthProvider); ok {
+ maxLen = mlp.MaxMessageLength()
+ }
+ if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
+ chunks := SplitMessage(msg.Content, maxLen)
+ for _, chunk := range chunks {
+ chunkMsg := msg
+ chunkMsg.Content = chunk
+ m.sendWithRetry(ctx, name, w, chunkMsg)
+ }
+ } else {
+ m.sendWithRetry(ctx, name, w, msg)
+ }
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+// sendWithRetry sends a message through the channel with rate limiting and
+// retry logic. It classifies errors to determine the retry strategy:
+// - ErrNotRunning / ErrSendFailed: permanent, no retry
+// - ErrRateLimit: fixed delay retry
+// - ErrTemporary / unknown: exponential backoff retry
+func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) {
+ // Rate limit: wait for token
+ if err := w.limiter.Wait(ctx); err != nil {
+ // ctx canceled, shutting down
+ return
+ }
+
+ // Pre-send: stop typing and try to edit placeholder
+ if m.preSend(ctx, name, msg, w.ch) {
+ return // placeholder was edited successfully, skip Send
+ }
+
+ var lastErr error
+ for attempt := 0; attempt <= maxRetries; attempt++ {
+ lastErr = w.ch.Send(ctx, msg)
+ if lastErr == nil {
+ return
+ }
+
+ // Permanent failures — don't retry
+ if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) {
+ break
+ }
+
+ // Last attempt exhausted — don't sleep
+ if attempt == maxRetries {
+ break
+ }
+
+ // Rate limit error — fixed delay
+ if errors.Is(lastErr, ErrRateLimit) {
+ select {
+ case <-time.After(rateLimitDelay):
+ continue
+ case <-ctx.Done():
+ return
+ }
+ }
+
+ // ErrTemporary or unknown error — exponential backoff
+ backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff)
+ select {
+ case <-time.After(backoff):
+ case <-ctx.Done():
+ return
+ }
+ }
+
+ // All retries exhausted or permanent failure
+ logger.ErrorCF("channels", "Send failed", map[string]any{
+ "channel": name,
+ "chat_id": msg.ChatID,
+ "error": lastErr.Error(),
+ "retries": maxRetries,
+ })
+}
+
func (m *Manager) dispatchOutbound(ctx context.Context) {
logger.InfoC("channels", "Outbound dispatcher started")
for {
- select {
- case <-ctx.Done():
+ msg, ok := m.bus.SubscribeOutbound(ctx)
+ if !ok {
logger.InfoC("channels", "Outbound dispatcher stopped")
return
- default:
- msg, ok := m.bus.SubscribeOutbound(ctx)
+ }
+
+ // 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 message", map[string]any{
+ "channel": msg.Channel,
+ })
+ continue
+ }
+
+ if wExists && w != nil {
+ select {
+ case w.queue <- msg:
+ case <-ctx.Done():
+ return
+ }
+ } else if exists {
+ logger.WarnCF("channels", "Channel has no active worker, skipping message", map[string]any{
+ "channel": msg.Channel,
+ })
+ }
+ }
+}
+
+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 {
+ select {
+ case w.mediaQueue <- msg:
+ case <-ctx.Done():
+ return
+ }
+ } else if exists {
+ logger.WarnCF("channels", "Channel has no active worker, skipping media message", map[string]any{
+ "channel": msg.Channel,
+ })
+ }
+ }
+}
+
+// runMediaWorker processes outbound media messages for a single channel.
+func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWorker) {
+ defer close(w.mediaDone)
+ for {
+ select {
+ case msg, ok := <-w.mediaQueue:
if !ok {
+ return
+ }
+ m.sendMediaWithRetry(ctx, name, w, msg)
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+// sendMediaWithRetry sends a media message through the channel with rate limiting and
+// retry logic. If the channel does not implement MediaSender, it silently skips.
+func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMediaMessage) {
+ ms, ok := w.ch.(MediaSender)
+ if !ok {
+ logger.DebugCF("channels", "Channel does not support MediaSender, skipping media", map[string]any{
+ "channel": name,
+ })
+ return
+ }
+
+ // Rate limit: wait for token
+ if err := w.limiter.Wait(ctx); err != nil {
+ return
+ }
+
+ var lastErr error
+ for attempt := 0; attempt <= maxRetries; attempt++ {
+ lastErr = ms.SendMedia(ctx, msg)
+ if lastErr == nil {
+ return
+ }
+
+ // Permanent failures — don't retry
+ if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) {
+ break
+ }
+
+ // Last attempt exhausted — don't sleep
+ if attempt == maxRetries {
+ break
+ }
+
+ // Rate limit error — fixed delay
+ if errors.Is(lastErr, ErrRateLimit) {
+ select {
+ case <-time.After(rateLimitDelay):
continue
+ case <-ctx.Done():
+ return
}
+ }
- // Silently skip internal channels
- if constants.IsInternalChannel(msg.Channel) {
- continue
- }
+ // ErrTemporary or unknown error — exponential backoff
+ backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff)
+ select {
+ case <-time.After(backoff):
+ case <-ctx.Done():
+ return
+ }
+ }
- m.mu.RLock()
- channel, exists := m.channels[msg.Channel]
- m.mu.RUnlock()
+ // All retries exhausted or permanent failure
+ logger.ErrorCF("channels", "SendMedia failed", map[string]any{
+ "channel": name,
+ "chat_id": msg.ChatID,
+ "error": lastErr.Error(),
+ "retries": maxRetries,
+ })
+}
- if !exists {
- logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{
- "channel": msg.Channel,
- })
- continue
- }
+// runTTLJanitor periodically scans the typingStops and placeholders maps
+// and evicts entries that have exceeded their TTL. This prevents memory
+// accumulation when outbound paths fail to trigger preSend (e.g. LLM errors).
+func (m *Manager) runTTLJanitor(ctx context.Context) {
+ ticker := time.NewTicker(janitorInterval)
+ defer ticker.Stop()
- if err := channel.Send(ctx, msg); err != nil {
- logger.ErrorCF("channels", "Error sending message to channel", map[string]any{
- "channel": msg.Channel,
- "error": err.Error(),
- })
- }
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case now := <-ticker.C:
+ m.typingStops.Range(func(key, value any) bool {
+ if entry, ok := value.(typingEntry); ok {
+ if now.Sub(entry.createdAt) > typingStopTTL {
+ if _, loaded := m.typingStops.LoadAndDelete(key); loaded {
+ entry.stop() // idempotent, safe
+ }
+ }
+ }
+ return true
+ })
+ m.reactionUndos.Range(func(key, value any) bool {
+ if entry, ok := value.(reactionEntry); ok {
+ if now.Sub(entry.createdAt) > typingStopTTL {
+ if _, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
+ entry.undo() // idempotent, safe
+ }
+ }
+ }
+ return true
+ })
+ m.placeholders.Range(func(key, value any) bool {
+ if entry, ok := value.(placeholderEntry); ok {
+ if now.Sub(entry.createdAt) > placeholderTTL {
+ m.placeholders.Delete(key)
+ }
+ }
+ return true
+ })
}
}
}
@@ -349,12 +783,20 @@ func (m *Manager) RegisterChannel(name string, channel Channel) {
func (m *Manager) UnregisterChannel(name string) {
m.mu.Lock()
defer m.mu.Unlock()
+ if w, ok := m.workers[name]; ok && w != nil {
+ close(w.queue)
+ <-w.done
+ close(w.mediaQueue)
+ <-w.mediaDone
+ }
+ delete(m.workers, name)
delete(m.channels, name)
}
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
m.mu.RLock()
- channel, exists := m.channels[channelName]
+ _, exists := m.channels[channelName]
+ w, wExists := m.workers[channelName]
m.mu.RUnlock()
if !exists {
@@ -367,5 +809,16 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten
Content: content,
}
+ if wExists && w != nil {
+ select {
+ case w.queue <- msg:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+
+ // Fallback: direct send (should not happen)
+ channel, _ := m.channels[channelName]
return channel.Send(ctx, msg)
}
diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go
new file mode 100644
index 000000000..6b9f151c3
--- /dev/null
+++ b/pkg/channels/manager_test.go
@@ -0,0 +1,864 @@
+package channels
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "golang.org/x/time/rate"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+)
+
+// mockChannel is a test double that delegates Send to a configurable function.
+type mockChannel struct {
+ BaseChannel
+ sendFn func(ctx context.Context, msg bus.OutboundMessage) error
+}
+
+func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ return m.sendFn(ctx, msg)
+}
+
+func (m *mockChannel) Start(ctx context.Context) error { return nil }
+func (m *mockChannel) Stop(ctx context.Context) error { return nil }
+
+// newTestManager creates a minimal Manager suitable for unit tests.
+func newTestManager() *Manager {
+ return &Manager{
+ channels: make(map[string]Channel),
+ workers: make(map[string]*channelWorker),
+ }
+}
+
+func TestSendWithRetry_Success(t *testing.T) {
+ m := newTestManager()
+ var callCount int
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ callCount++
+ return nil
+ },
+ }
+ w := &channelWorker{
+ ch: ch,
+ limiter: rate.NewLimiter(rate.Inf, 1),
+ }
+
+ ctx := context.Background()
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
+
+ m.sendWithRetry(ctx, "test", w, msg)
+
+ if callCount != 1 {
+ t.Fatalf("expected 1 Send call, got %d", callCount)
+ }
+}
+
+func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) {
+ m := newTestManager()
+ var callCount int
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ callCount++
+ if callCount <= 2 {
+ return fmt.Errorf("network error: %w", ErrTemporary)
+ }
+ return nil
+ },
+ }
+ w := &channelWorker{
+ ch: ch,
+ limiter: rate.NewLimiter(rate.Inf, 1),
+ }
+
+ ctx := context.Background()
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
+
+ m.sendWithRetry(ctx, "test", w, msg)
+
+ if callCount != 3 {
+ t.Fatalf("expected 3 Send calls (2 failures + 1 success), got %d", callCount)
+ }
+}
+
+func TestSendWithRetry_PermanentFailure(t *testing.T) {
+ m := newTestManager()
+ var callCount int
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ callCount++
+ return fmt.Errorf("bad chat ID: %w", ErrSendFailed)
+ },
+ }
+ w := &channelWorker{
+ ch: ch,
+ limiter: rate.NewLimiter(rate.Inf, 1),
+ }
+
+ ctx := context.Background()
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
+
+ m.sendWithRetry(ctx, "test", w, msg)
+
+ if callCount != 1 {
+ t.Fatalf("expected 1 Send call (no retry for permanent failure), got %d", callCount)
+ }
+}
+
+func TestSendWithRetry_NotRunning(t *testing.T) {
+ m := newTestManager()
+ var callCount int
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ callCount++
+ return ErrNotRunning
+ },
+ }
+ w := &channelWorker{
+ ch: ch,
+ limiter: rate.NewLimiter(rate.Inf, 1),
+ }
+
+ ctx := context.Background()
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
+
+ m.sendWithRetry(ctx, "test", w, msg)
+
+ if callCount != 1 {
+ t.Fatalf("expected 1 Send call (no retry for ErrNotRunning), got %d", callCount)
+ }
+}
+
+func TestSendWithRetry_RateLimitRetry(t *testing.T) {
+ m := newTestManager()
+ var callCount int
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ callCount++
+ if callCount == 1 {
+ return fmt.Errorf("429: %w", ErrRateLimit)
+ }
+ return nil
+ },
+ }
+ w := &channelWorker{
+ ch: ch,
+ limiter: rate.NewLimiter(rate.Inf, 1),
+ }
+
+ ctx := context.Background()
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
+
+ start := time.Now()
+ m.sendWithRetry(ctx, "test", w, msg)
+ elapsed := time.Since(start)
+
+ if callCount != 2 {
+ t.Fatalf("expected 2 Send calls (1 rate limit + 1 success), got %d", callCount)
+ }
+ // Should have waited at least rateLimitDelay (1s) but allow some slack
+ if elapsed < 900*time.Millisecond {
+ t.Fatalf("expected at least ~1s delay for rate limit retry, got %v", elapsed)
+ }
+}
+
+func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) {
+ m := newTestManager()
+ var callCount int
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ callCount++
+ return fmt.Errorf("timeout: %w", ErrTemporary)
+ },
+ }
+ w := &channelWorker{
+ ch: ch,
+ limiter: rate.NewLimiter(rate.Inf, 1),
+ }
+
+ ctx := context.Background()
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
+
+ m.sendWithRetry(ctx, "test", w, msg)
+
+ expected := maxRetries + 1 // initial attempt + maxRetries retries
+ if callCount != expected {
+ t.Fatalf("expected %d Send calls, got %d", expected, callCount)
+ }
+}
+
+func TestSendWithRetry_UnknownError(t *testing.T) {
+ m := newTestManager()
+ var callCount int
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ callCount++
+ if callCount == 1 {
+ return errors.New("random unexpected error")
+ }
+ return nil
+ },
+ }
+ w := &channelWorker{
+ ch: ch,
+ limiter: rate.NewLimiter(rate.Inf, 1),
+ }
+
+ ctx := context.Background()
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
+
+ m.sendWithRetry(ctx, "test", w, msg)
+
+ if callCount != 2 {
+ t.Fatalf("expected 2 Send calls (unknown error treated as temporary), got %d", callCount)
+ }
+}
+
+func TestSendWithRetry_ContextCancelled(t *testing.T) {
+ m := newTestManager()
+ var callCount int
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ callCount++
+ return fmt.Errorf("timeout: %w", ErrTemporary)
+ },
+ }
+ w := &channelWorker{
+ ch: ch,
+ limiter: rate.NewLimiter(rate.Inf, 1),
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
+
+ // Cancel context after first Send attempt returns
+ ch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error {
+ callCount++
+ cancel()
+ return fmt.Errorf("timeout: %w", ErrTemporary)
+ }
+
+ m.sendWithRetry(ctx, "test", w, msg)
+
+ // Should have called Send once, then noticed ctx canceled during backoff
+ if callCount != 1 {
+ t.Fatalf("expected 1 Send call before context cancellation, got %d", callCount)
+ }
+}
+
+func TestWorkerRateLimiter(t *testing.T) {
+ m := newTestManager()
+
+ var mu sync.Mutex
+ var sendTimes []time.Time
+
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ mu.Lock()
+ sendTimes = append(sendTimes, time.Now())
+ mu.Unlock()
+ return nil
+ },
+ }
+
+ // Create a worker with a low rate: 2 msg/s, burst 1
+ w := &channelWorker{
+ ch: ch,
+ queue: make(chan bus.OutboundMessage, 10),
+ done: make(chan struct{}),
+ limiter: rate.NewLimiter(2, 1),
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go m.runWorker(ctx, "test", w)
+
+ // Enqueue 4 messages
+ for i := 0; i < 4; i++ {
+ w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)}
+ }
+
+ // Wait enough time for all messages to be sent (4 msgs at 2/s = ~2s, give extra margin)
+ time.Sleep(3 * time.Second)
+
+ mu.Lock()
+ times := make([]time.Time, len(sendTimes))
+ copy(times, sendTimes)
+ mu.Unlock()
+
+ if len(times) != 4 {
+ t.Fatalf("expected 4 sends, got %d", len(times))
+ }
+
+ // Verify rate limiting: total duration should be at least 1s
+ // (first message immediate, then ~500ms between each subsequent one at 2/s)
+ totalDuration := times[len(times)-1].Sub(times[0])
+ if totalDuration < 1*time.Second {
+ t.Fatalf("expected total duration >= 1s for 4 msgs at 2/s rate, got %v", totalDuration)
+ }
+}
+
+func TestNewChannelWorker_DefaultRate(t *testing.T) {
+ ch := &mockChannel{}
+ w := newChannelWorker("unknown_channel", ch)
+
+ if w.limiter == nil {
+ t.Fatal("expected limiter to be non-nil")
+ }
+ if w.limiter.Limit() != rate.Limit(defaultRateLimit) {
+ t.Fatalf("expected rate limit %v, got %v", rate.Limit(defaultRateLimit), w.limiter.Limit())
+ }
+}
+
+func TestNewChannelWorker_ConfiguredRate(t *testing.T) {
+ ch := &mockChannel{}
+
+ for name, expectedRate := range channelRateConfig {
+ w := newChannelWorker(name, ch)
+ if w.limiter.Limit() != rate.Limit(expectedRate) {
+ t.Fatalf("channel %s: expected rate %v, got %v", name, expectedRate, w.limiter.Limit())
+ }
+ }
+}
+
+func TestRunWorker_MessageSplitting(t *testing.T) {
+ m := newTestManager()
+
+ var mu sync.Mutex
+ var received []string
+
+ ch := &mockChannelWithLength{
+ mockChannel: mockChannel{
+ sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
+ mu.Lock()
+ received = append(received, msg.Content)
+ mu.Unlock()
+ return nil
+ },
+ },
+ maxLen: 5,
+ }
+
+ w := &channelWorker{
+ ch: ch,
+ queue: make(chan bus.OutboundMessage, 10),
+ done: make(chan struct{}),
+ limiter: rate.NewLimiter(rate.Inf, 1),
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go m.runWorker(ctx, "test", w)
+
+ // Send a message that should be split
+ w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"}
+
+ time.Sleep(100 * time.Millisecond)
+
+ mu.Lock()
+ count := len(received)
+ mu.Unlock()
+
+ if count < 2 {
+ t.Fatalf("expected message to be split into at least 2 chunks, got %d", count)
+ }
+}
+
+// mockChannelWithLength implements MessageLengthProvider.
+type mockChannelWithLength struct {
+ mockChannel
+ maxLen int
+}
+
+func (m *mockChannelWithLength) MaxMessageLength() int {
+ return m.maxLen
+}
+
+func TestSendWithRetry_ExponentialBackoff(t *testing.T) {
+ m := newTestManager()
+
+ var callTimes []time.Time
+ var callCount atomic.Int32
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ callTimes = append(callTimes, time.Now())
+ callCount.Add(1)
+ return fmt.Errorf("timeout: %w", ErrTemporary)
+ },
+ }
+ w := &channelWorker{
+ ch: ch,
+ limiter: rate.NewLimiter(rate.Inf, 1),
+ }
+
+ ctx := context.Background()
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
+
+ start := time.Now()
+ m.sendWithRetry(ctx, "test", w, msg)
+ totalElapsed := time.Since(start)
+
+ // With maxRetries=3: attempts at 0, ~500ms, ~1.5s, ~3.5s
+ // Total backoff: 500ms + 1s + 2s = 3.5s
+ // Allow some margin
+ if totalElapsed < 3*time.Second {
+ t.Fatalf("expected total elapsed >= 3s for exponential backoff, got %v", totalElapsed)
+ }
+
+ if int(callCount.Load()) != maxRetries+1 {
+ t.Fatalf("expected %d calls, got %d", maxRetries+1, callCount.Load())
+ }
+}
+
+// --- Phase 10: preSend orchestration tests ---
+
+// mockMessageEditor is a channel that supports MessageEditor.
+type mockMessageEditor struct {
+ mockChannel
+ editFn func(ctx context.Context, chatID, messageID, content string) error
+}
+
+func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error {
+ return m.editFn(ctx, chatID, messageID, content)
+}
+
+func TestPreSend_PlaceholderEditSuccess(t *testing.T) {
+ m := newTestManager()
+ var sendCalled bool
+ var editCalled bool
+
+ ch := &mockMessageEditor{
+ mockChannel: mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ sendCalled = true
+ return nil
+ },
+ },
+ editFn: func(_ context.Context, chatID, messageID, content string) error {
+ editCalled = true
+ if chatID != "123" {
+ t.Fatalf("expected chatID 123, got %s", chatID)
+ }
+ if messageID != "456" {
+ t.Fatalf("expected messageID 456, got %s", messageID)
+ }
+ if content != "hello" {
+ t.Fatalf("expected content 'hello', got %s", content)
+ }
+ return nil
+ },
+ }
+
+ // Register placeholder
+ m.RecordPlaceholder("test", "123", "456")
+
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
+ edited := m.preSend(context.Background(), "test", msg, ch)
+
+ if !edited {
+ t.Fatal("expected preSend to return true (placeholder edited)")
+ }
+ if !editCalled {
+ t.Fatal("expected EditMessage to be called")
+ }
+ if sendCalled {
+ t.Fatal("expected Send to NOT be called when placeholder edited")
+ }
+}
+
+func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
+ m := newTestManager()
+
+ ch := &mockMessageEditor{
+ mockChannel: mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ return nil
+ },
+ },
+ editFn: func(_ context.Context, _, _, _ string) error {
+ return fmt.Errorf("edit failed")
+ },
+ }
+
+ m.RecordPlaceholder("test", "123", "456")
+
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
+ edited := m.preSend(context.Background(), "test", msg, ch)
+
+ if edited {
+ t.Fatal("expected preSend to return false when edit fails")
+ }
+}
+
+func TestPreSend_TypingStopCalled(t *testing.T) {
+ m := newTestManager()
+ var stopCalled bool
+
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ return nil
+ },
+ }
+
+ m.RecordTypingStop("test", "123", func() {
+ stopCalled = true
+ })
+
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
+ m.preSend(context.Background(), "test", msg, ch)
+
+ if !stopCalled {
+ t.Fatal("expected typing stop func to be called")
+ }
+}
+
+func TestPreSend_NoRegisteredState(t *testing.T) {
+ m := newTestManager()
+
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ return nil
+ },
+ }
+
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
+ edited := m.preSend(context.Background(), "test", msg, ch)
+
+ if edited {
+ t.Fatal("expected preSend to return false with no registered state")
+ }
+}
+
+func TestPreSend_TypingAndPlaceholder(t *testing.T) {
+ m := newTestManager()
+ var stopCalled bool
+ var editCalled bool
+
+ ch := &mockMessageEditor{
+ mockChannel: mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ return nil
+ },
+ },
+ editFn: func(_ context.Context, _, _, _ string) error {
+ editCalled = true
+ return nil
+ },
+ }
+
+ m.RecordTypingStop("test", "123", func() {
+ stopCalled = true
+ })
+ m.RecordPlaceholder("test", "123", "456")
+
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
+ edited := m.preSend(context.Background(), "test", msg, ch)
+
+ if !stopCalled {
+ t.Fatal("expected typing stop to be called")
+ }
+ if !editCalled {
+ t.Fatal("expected EditMessage to be called")
+ }
+ if !edited {
+ t.Fatal("expected preSend to return true")
+ }
+}
+
+func TestRecordPlaceholder_ConcurrentSafe(t *testing.T) {
+ m := newTestManager()
+
+ var wg sync.WaitGroup
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ chatID := fmt.Sprintf("chat_%d", i%10)
+ m.RecordPlaceholder("test", chatID, fmt.Sprintf("msg_%d", i))
+ }(i)
+ }
+ wg.Wait()
+}
+
+func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
+ m := newTestManager()
+
+ var wg sync.WaitGroup
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ chatID := fmt.Sprintf("chat_%d", i%10)
+ m.RecordTypingStop("test", chatID, func() {})
+ }(i)
+ }
+ wg.Wait()
+}
+
+func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {
+ m := newTestManager()
+ var sendCalled bool
+
+ ch := &mockMessageEditor{
+ mockChannel: mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ sendCalled = true
+ return nil
+ },
+ },
+ editFn: func(_ context.Context, _, _, _ string) error {
+ return nil // edit succeeds
+ },
+ }
+
+ m.RecordPlaceholder("test", "123", "456")
+
+ w := &channelWorker{
+ ch: ch,
+ limiter: rate.NewLimiter(rate.Inf, 1),
+ }
+
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
+ m.sendWithRetry(context.Background(), "test", w, msg)
+
+ if sendCalled {
+ t.Fatal("expected Send to NOT be called when placeholder was edited")
+ }
+}
+
+// --- Dispatcher exit tests (Step 1) ---
+
+func TestDispatcherExitsOnCancel(t *testing.T) {
+ mb := bus.NewMessageBus()
+ defer mb.Close()
+
+ m := &Manager{
+ channels: make(map[string]Channel),
+ workers: make(map[string]*channelWorker),
+ bus: mb,
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+
+ go func() {
+ m.dispatchOutbound(ctx)
+ close(done)
+ }()
+
+ // Cancel context and verify the dispatcher exits quickly
+ cancel()
+
+ select {
+ case <-done:
+ // success
+ case <-time.After(2 * time.Second):
+ t.Fatal("dispatchOutbound did not exit within 2s after context cancel")
+ }
+}
+
+func TestDispatcherMediaExitsOnCancel(t *testing.T) {
+ mb := bus.NewMessageBus()
+ defer mb.Close()
+
+ m := &Manager{
+ channels: make(map[string]Channel),
+ workers: make(map[string]*channelWorker),
+ bus: mb,
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+
+ go func() {
+ m.dispatchOutboundMedia(ctx)
+ close(done)
+ }()
+
+ cancel()
+
+ select {
+ case <-done:
+ // success
+ case <-time.After(2 * time.Second):
+ t.Fatal("dispatchOutboundMedia did not exit within 2s after context cancel")
+ }
+}
+
+// --- TTL Janitor tests (Step 2) ---
+
+func TestTypingStopJanitorEviction(t *testing.T) {
+ m := newTestManager()
+
+ var stopCalled atomic.Bool
+ // Store a typing entry with a creation time far in the past
+ m.typingStops.Store("test:123", typingEntry{
+ stop: func() { stopCalled.Store(true) },
+ createdAt: time.Now().Add(-10 * time.Minute), // well past typingStopTTL
+ })
+
+ // Run janitor with a short-lived context
+ ctx, cancel := context.WithCancel(context.Background())
+
+ // Manually trigger the janitor logic once by simulating a tick
+ go func() {
+ // Override janitor to run immediately
+ now := time.Now()
+ m.typingStops.Range(func(key, value any) bool {
+ if entry, ok := value.(typingEntry); ok {
+ if now.Sub(entry.createdAt) > typingStopTTL {
+ if _, loaded := m.typingStops.LoadAndDelete(key); loaded {
+ entry.stop()
+ }
+ }
+ }
+ return true
+ })
+ cancel()
+ }()
+
+ <-ctx.Done()
+
+ if !stopCalled.Load() {
+ t.Fatal("expected typing stop function to be called by janitor eviction")
+ }
+
+ // Verify entry was deleted
+ if _, loaded := m.typingStops.Load("test:123"); loaded {
+ t.Fatal("expected typing entry to be deleted after eviction")
+ }
+}
+
+func TestPlaceholderJanitorEviction(t *testing.T) {
+ m := newTestManager()
+
+ // Store a placeholder entry with a creation time far in the past
+ m.placeholders.Store("test:456", placeholderEntry{
+ id: "msg_old",
+ createdAt: time.Now().Add(-20 * time.Minute), // well past placeholderTTL
+ })
+
+ // Simulate janitor logic
+ now := time.Now()
+ m.placeholders.Range(func(key, value any) bool {
+ if entry, ok := value.(placeholderEntry); ok {
+ if now.Sub(entry.createdAt) > placeholderTTL {
+ m.placeholders.Delete(key)
+ }
+ }
+ return true
+ })
+
+ // Verify entry was deleted
+ if _, loaded := m.placeholders.Load("test:456"); loaded {
+ t.Fatal("expected placeholder entry to be deleted after eviction")
+ }
+}
+
+func TestPreSendStillWorksWithWrappedTypes(t *testing.T) {
+ m := newTestManager()
+ var stopCalled bool
+ var editCalled bool
+
+ ch := &mockMessageEditor{
+ mockChannel: mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ return nil
+ },
+ },
+ editFn: func(_ context.Context, chatID, messageID, content string) error {
+ editCalled = true
+ if messageID != "ph_id" {
+ t.Fatalf("expected messageID ph_id, got %s", messageID)
+ }
+ return nil
+ },
+ }
+
+ // Use the new wrapped types via the public API
+ m.RecordTypingStop("test", "chat1", func() {
+ stopCalled = true
+ })
+ m.RecordPlaceholder("test", "chat1", "ph_id")
+
+ msg := bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"}
+ edited := m.preSend(context.Background(), "test", msg, ch)
+
+ if !stopCalled {
+ t.Fatal("expected typing stop to be called via wrapped type")
+ }
+ if !editCalled {
+ t.Fatal("expected EditMessage to be called via wrapped type")
+ }
+ if !edited {
+ t.Fatal("expected preSend to return true")
+ }
+}
+
+// --- Lazy worker creation tests (Step 6) ---
+
+func TestLazyWorkerCreation(t *testing.T) {
+ m := newTestManager()
+
+ ch := &mockChannel{
+ sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
+ return nil
+ },
+ }
+
+ // RegisterChannel should NOT create a worker
+ m.RegisterChannel("lazy", ch)
+
+ m.mu.RLock()
+ _, chExists := m.channels["lazy"]
+ _, wExists := m.workers["lazy"]
+ m.mu.RUnlock()
+
+ if !chExists {
+ t.Fatal("expected channel to be registered")
+ }
+ if wExists {
+ t.Fatal("expected worker to NOT be created by RegisterChannel (lazy creation)")
+ }
+}
+
+// --- FastID uniqueness test (Step 5) ---
+
+func TestBuildMediaScope_FastIDUniqueness(t *testing.T) {
+ seen := make(map[string]bool)
+
+ for i := 0; i < 1000; i++ {
+ scope := BuildMediaScope("test", "chat1", "")
+ if seen[scope] {
+ t.Fatalf("duplicate scope generated: %s", scope)
+ }
+ seen[scope] = true
+ }
+
+ // Verify format: "channel:chatID:id"
+ scope := BuildMediaScope("telegram", "42", "")
+ parts := 0
+ for _, c := range scope {
+ if c == ':' {
+ parts++
+ }
+ }
+ if parts != 2 {
+ t.Fatalf("expected scope to have 2 colons (channel:chatID:id), got: %s", scope)
+ }
+}
+
+func TestBuildMediaScope_WithMessageID(t *testing.T) {
+ scope := BuildMediaScope("discord", "chat99", "msg123")
+ expected := "discord:chat99:msg123"
+ if scope != expected {
+ t.Fatalf("expected %s, got %s", expected, scope)
+ }
+}
diff --git a/pkg/channels/media.go b/pkg/channels/media.go
new file mode 100644
index 000000000..c645a6180
--- /dev/null
+++ b/pkg/channels/media.go
@@ -0,0 +1,15 @@
+package channels
+
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+)
+
+// MediaSender is an optional interface for channels that can send
+// media attachments (images, files, audio, video).
+// Manager discovers channels implementing this interface via type
+// assertion and routes OutboundMediaMessage to them.
+type MediaSender interface {
+ SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error
+}
diff --git a/pkg/channels/onebot/init.go b/pkg/channels/onebot/init.go
new file mode 100644
index 000000000..84c06dfd6
--- /dev/null
+++ b/pkg/channels/onebot/init.go
@@ -0,0 +1,13 @@
+package onebot
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("onebot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewOneBotChannel(cfg.Channels.OneBot, b)
+ })
+}
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot/onebot.go
similarity index 74%
rename from pkg/channels/onebot.go
rename to pkg/channels/onebot/onebot.go
index cee8ad9d3..89cba4ae0 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot/onebot.go
@@ -1,10 +1,9 @@
-package channels
+package onebot
import (
"context"
"encoding/json"
"fmt"
- "os"
"strconv"
"strings"
"sync"
@@ -14,30 +13,30 @@ import (
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
- "github.com/sipeed/picoclaw/pkg/voice"
)
type OneBotChannel struct {
- *BaseChannel
- config config.OneBotConfig
- conn *websocket.Conn
- ctx context.Context
- cancel context.CancelFunc
- dedup map[string]struct{}
- dedupRing []string
- dedupIdx int
- mu sync.Mutex
- writeMu sync.Mutex
- echoCounter int64
- selfID int64
- pending map[string]chan json.RawMessage
- pendingMu sync.Mutex
- transcriber *voice.GroqTranscriber
- lastMessageID sync.Map
- pendingEmojiMsg sync.Map
+ *channels.BaseChannel
+ config config.OneBotConfig
+ conn *websocket.Conn
+ ctx context.Context
+ cancel context.CancelFunc
+ dedup map[string]struct{}
+ dedupRing []string
+ dedupIdx int
+ mu sync.Mutex
+ writeMu sync.Mutex
+ echoCounter int64
+ selfID int64
+ pending map[string]chan json.RawMessage
+ pendingMu sync.Mutex
+ lastMessageID sync.Map
}
type oneBotRawEvent struct {
@@ -98,7 +97,10 @@ type oneBotMessageSegment struct {
}
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
- base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom)
+ base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom,
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
const dedupSize = 1024
return &OneBotChannel{
@@ -111,10 +113,6 @@ func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*One
}, nil
}
-func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
- c.transcriber = transcriber
-}
-
func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
go func() {
_, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{
@@ -131,6 +129,22 @@ func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool)
}()
}
+// ReactToMessage implements channels.ReactionCapable.
+// It adds an emoji reaction (ID 289) to group messages and returns an undo function.
+// Private messages return a no-op since reactions are only meaningful in groups.
+func (c *OneBotChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
+ // Only react in group chats
+ if !strings.HasPrefix(chatID, "group:") {
+ return func() {}, nil
+ }
+
+ c.setMsgEmojiLike(messageID, 289, true)
+
+ return func() {
+ c.setMsgEmojiLike(messageID, 289, false)
+ }, nil
+}
+
func (c *OneBotChannel) Start(ctx context.Context) error {
if c.config.WSUrl == "" {
return fmt.Errorf("OneBot ws_url not configured")
@@ -159,7 +173,7 @@ func (c *OneBotChannel) Start(ctx context.Context) error {
}
}
- c.setRunning(true)
+ c.SetRunning(true)
logger.InfoC("onebot", "OneBot channel started successfully")
return nil
@@ -174,7 +188,10 @@ func (c *OneBotChannel) connect() error {
header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
}
- conn, _, err := dialer.Dial(c.config.WSUrl, header)
+ conn, resp, err := dialer.Dial(c.config.WSUrl, header)
+ if resp != nil {
+ resp.Body.Close()
+ }
if err != nil {
return err
}
@@ -297,7 +314,9 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D
}
c.writeMu.Lock()
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
err = conn.WriteMessage(websocket.TextMessage, data)
+ _ = conn.SetWriteDeadline(time.Time{})
c.writeMu.Unlock()
if err != nil {
@@ -306,11 +325,14 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D
select {
case resp := <-ch:
+ if resp == nil {
+ return nil, fmt.Errorf("API request %s: channel stopped", action)
+ }
return resp, nil
case <-time.After(timeout):
return nil, fmt.Errorf("API request %s timed out after %v", action, timeout)
case <-c.ctx.Done():
- return nil, fmt.Errorf("context cancelled")
+ return nil, fmt.Errorf("context canceled")
}
}
@@ -346,7 +368,7 @@ func (c *OneBotChannel) reconnectLoop() {
func (c *OneBotChannel) Stop(ctx context.Context) error {
logger.InfoC("onebot", "Stopping OneBot channel")
- c.setRunning(false)
+ c.SetRunning(false)
if c.cancel != nil {
c.cancel()
@@ -354,7 +376,10 @@ func (c *OneBotChannel) Stop(ctx context.Context) error {
c.pendingMu.Lock()
for echo, ch := range c.pending {
- close(ch)
+ select {
+ case ch <- nil: // non-blocking wake for blocked sendAPIRequest goroutines
+ default:
+ }
delete(c.pending, echo)
}
c.pendingMu.Unlock()
@@ -371,7 +396,14 @@ func (c *OneBotChannel) Stop(ctx context.Context) error {
func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
- return fmt.Errorf("OneBot channel not running")
+ return channels.ErrNotRunning
+ }
+
+ // Check ctx before entering write path
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
}
c.mu.Lock()
@@ -401,20 +433,127 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
}
c.writeMu.Lock()
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
err = conn.WriteMessage(websocket.TextMessage, data)
+ _ = conn.SetWriteDeadline(time.Time{})
c.writeMu.Unlock()
if err != nil {
logger.ErrorCF("onebot", "Failed to send message", map[string]any{
"error": err.Error(),
})
- return err
+ return fmt.Errorf("onebot send: %w", channels.ErrTemporary)
}
- if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok {
- if mid, ok := msgID.(string); ok && mid != "" {
- c.setMsgEmojiLike(mid, 289, false)
+ return nil
+}
+
+// SendMedia implements the channels.MediaSender interface.
+func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+
+ if conn == nil {
+ return fmt.Errorf("OneBot WebSocket not connected")
+ }
+
+ store := c.GetMediaStore()
+ if store == nil {
+ return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
+ }
+
+ // Build media segments
+ var segments []oneBotMessageSegment
+ for _, part := range msg.Parts {
+ localPath, err := store.Resolve(part.Ref)
+ if err != nil {
+ logger.ErrorCF("onebot", "Failed to resolve media ref", map[string]any{
+ "ref": part.Ref,
+ "error": err.Error(),
+ })
+ continue
}
+
+ var segType string
+ switch part.Type {
+ case "image":
+ segType = "image"
+ case "video":
+ segType = "video"
+ case "audio":
+ segType = "record"
+ default:
+ segType = "file"
+ }
+
+ segments = append(segments, oneBotMessageSegment{
+ Type: segType,
+ Data: map[string]any{"file": "file://" + localPath},
+ })
+
+ if part.Caption != "" {
+ segments = append(segments, oneBotMessageSegment{
+ Type: "text",
+ Data: map[string]any{"text": part.Caption},
+ })
+ }
+ }
+
+ if len(segments) == 0 {
+ return nil
+ }
+
+ chatID := msg.ChatID
+ var action, idKey string
+ var rawID string
+ if rest, ok := strings.CutPrefix(chatID, "group:"); ok {
+ action, idKey, rawID = "send_group_msg", "group_id", rest
+ } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok {
+ action, idKey, rawID = "send_private_msg", "user_id", rest
+ } else {
+ action, idKey, rawID = "send_private_msg", "user_id", chatID
+ }
+
+ id, err := strconv.ParseInt(rawID, 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed)
+ }
+
+ echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1))
+
+ req := oneBotAPIRequest{
+ Action: action,
+ Params: map[string]any{idKey: id, "message": segments},
+ Echo: echo,
+ }
+
+ data, err := json.Marshal(req)
+ if err != nil {
+ return fmt.Errorf("failed to marshal OneBot request: %w", err)
+ }
+
+ c.writeMu.Lock()
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ err = conn.WriteMessage(websocket.TextMessage, data)
+ _ = conn.SetWriteDeadline(time.Time{})
+ c.writeMu.Unlock()
+
+ if err != nil {
+ logger.ErrorCF("onebot", "Failed to send media message", map[string]any{
+ "error": err.Error(),
+ })
+ return fmt.Errorf("onebot send media: %w", channels.ErrTemporary)
}
return nil
@@ -571,11 +710,15 @@ type parseMessageResult struct {
Text string
IsBotMentioned bool
Media []string
- LocalFiles []string
ReplyTo string
}
-func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) parseMessageResult {
+func (c *OneBotChannel) parseMessageSegments(
+ raw json.RawMessage,
+ selfID int64,
+ store media.MediaStore,
+ scope string,
+) parseMessageResult {
if len(raw) == 0 {
return parseMessageResult{}
}
@@ -602,10 +745,23 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
var textParts []string
mentioned := false
selfIDStr := strconv.FormatInt(selfID, 10)
- var media []string
- var localFiles []string
+ var mediaRefs []string
var replyTo string
+ // Helper to register a local file with the media store
+ storeFile := func(localPath, filename string) string {
+ if store != nil {
+ ref, err := store.Store(localPath, media.MediaMeta{
+ Filename: filename,
+ Source: "onebot",
+ }, scope)
+ if err == nil {
+ return ref
+ }
+ }
+ return localPath // fallback
+ }
+
for _, seg := range segments {
segType, _ := seg["type"].(string)
data, _ := seg["data"].(map[string]any)
@@ -641,8 +797,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
LoggerPrefix: "onebot",
})
if localPath != "" {
- media = append(media, localPath)
- localFiles = append(localFiles, localPath)
+ mediaRefs = append(mediaRefs, storeFile(localPath, filename))
textParts = append(textParts, fmt.Sprintf("[%s]", segType))
}
}
@@ -656,24 +811,8 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
LoggerPrefix: "onebot",
})
if localPath != "" {
- localFiles = append(localFiles, localPath)
- if c.transcriber != nil && c.transcriber.IsAvailable() {
- tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second)
- result, err := c.transcriber.Transcribe(tctx, localPath)
- tcancel()
- if err != nil {
- logger.WarnCF("onebot", "Voice transcription failed", map[string]any{
- "error": err.Error(),
- })
- textParts = append(textParts, "[voice (transcription failed)]")
- media = append(media, localPath)
- } else {
- textParts = append(textParts, fmt.Sprintf("[voice transcription: %s]", result.Text))
- }
- } else {
- textParts = append(textParts, "[voice]")
- media = append(media, localPath)
- }
+ textParts = append(textParts, "[voice]")
+ mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr"))
}
}
}
@@ -695,15 +834,13 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
textParts = append(textParts, "[forward message]")
default:
-
}
}
return parseMessageResult{
Text: strings.TrimSpace(strings.Join(textParts, "")),
IsBotMentioned: mentioned,
- Media: media,
- LocalFiles: localFiles,
+ Media: mediaRefs,
ReplyTo: replyTo,
}
}
@@ -712,7 +849,13 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
switch raw.PostType {
case "message":
if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
- if !c.IsAllowed(strconv.FormatInt(userID, 10)) {
+ // Build minimal sender for allowlist check
+ sender := bus.SenderInfo{
+ Platform: "onebot",
+ PlatformID: strconv.FormatInt(userID, 10),
+ CanonicalID: identity.BuildCanonicalID("onebot", strconv.FormatInt(userID, 10)),
+ }
+ if !c.IsAllowedSender(sender) {
logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{
"user_id": userID,
})
@@ -795,7 +938,17 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
selfID = atomic.LoadInt64(&c.selfID)
}
- parsed := c.parseMessageSegments(raw.Message, selfID)
+ // Compute scope for media store before parsing (parsing may download files)
+ var chatIDForScope string
+ switch raw.MessageType {
+ case "group":
+ chatIDForScope = "group:" + strconv.FormatInt(groupID, 10)
+ default:
+ chatIDForScope = "private:" + strconv.FormatInt(userID, 10)
+ }
+ scope := channels.BuildMediaScope("onebot", chatIDForScope, messageID)
+
+ parsed := c.parseMessageSegments(raw.Message, selfID, c.GetMediaStore(), scope)
isBotMentioned := parsed.IsBotMentioned
content := raw.RawMessage
@@ -824,20 +977,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
}
}
- // Clean up temp files when done
- if len(parsed.LocalFiles) > 0 {
- defer func() {
- for _, f := range parsed.LocalFiles {
- if err := os.Remove(f); err != nil {
- logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{
- "path": f,
- "error": err.Error(),
- })
- }
- }
- }()
- }
-
if c.isDuplicate(messageID) {
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{
"message_id": messageID,
@@ -855,9 +994,9 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
senderID := strconv.FormatInt(userID, 10)
var chatID string
- metadata := map[string]string{
- "message_id": messageID,
- }
+ var peer bus.Peer
+
+ metadata := map[string]string{}
if parsed.ReplyTo != "" {
metadata["reply_to_message_id"] = parsed.ReplyTo
@@ -866,14 +1005,12 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
switch raw.MessageType {
case "private":
chatID = "private:" + senderID
- metadata["peer_kind"] = "direct"
- metadata["peer_id"] = senderID
+ peer = bus.Peer{Kind: "direct", ID: senderID}
case "group":
groupIDStr := strconv.FormatInt(groupID, 10)
chatID = "group:" + groupIDStr
- metadata["peer_kind"] = "group"
- metadata["peer_id"] = groupIDStr
+ peer = bus.Peer{Kind: "group", ID: groupIDStr}
metadata["group_id"] = groupIDStr
senderUserID, _ := parseJSONInt64(sender.UserID)
@@ -887,8 +1024,8 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
metadata["sender_name"] = sender.Nickname
}
- triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned)
- if !triggered {
+ respond, strippedContent := c.ShouldRespondInGroup(isBotMentioned, content)
+ if !respond {
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{
"sender": senderID,
"group": groupIDStr,
@@ -923,12 +1060,21 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
c.lastMessageID.Store(chatID, messageID)
- if raw.MessageType == "group" && messageID != "" && messageID != "0" {
- c.setMsgEmojiLike(messageID, 289, true)
- c.pendingEmojiMsg.Store(chatID, messageID)
+ senderInfo := bus.SenderInfo{
+ Platform: "onebot",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("onebot", senderID),
+ DisplayName: sender.Nickname,
}
- c.HandleMessage(senderID, chatID, content, parsed.Media, metadata)
+ if !c.IsAllowedSender(senderInfo) {
+ logger.DebugCF("onebot", "Message rejected by allowlist (senderInfo)", map[string]any{
+ "sender": senderID,
+ })
+ return
+ }
+
+ c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata, senderInfo)
}
func (c *OneBotChannel) isDuplicate(messageID string) bool {
@@ -960,23 +1106,3 @@ func truncate(s string, n int) string {
}
return string(runes[:n]) + "..."
}
-
-func (c *OneBotChannel) checkGroupTrigger(
- content string,
- isBotMentioned bool,
-) (triggered bool, strippedContent string) {
- if isBotMentioned {
- return true, strings.TrimSpace(content)
- }
-
- for _, prefix := range c.config.GroupTriggerPrefix {
- if prefix == "" {
- continue
- }
- if strings.HasPrefix(content, prefix) {
- return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
- }
- }
-
- return false, content
-}
diff --git a/pkg/channels/pico/init.go b/pkg/channels/pico/init.go
new file mode 100644
index 000000000..96d764418
--- /dev/null
+++ b/pkg/channels/pico/init.go
@@ -0,0 +1,13 @@
+package pico
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewPicoChannel(cfg.Channels.Pico, b)
+ })
+}
diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go
new file mode 100644
index 000000000..2ae82d8da
--- /dev/null
+++ b/pkg/channels/pico/pico.go
@@ -0,0 +1,462 @@
+package pico
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/gorilla/websocket"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// picoConn represents a single WebSocket connection.
+type picoConn struct {
+ id string
+ conn *websocket.Conn
+ sessionID string
+ writeMu sync.Mutex
+ closed atomic.Bool
+}
+
+// writeJSON sends a JSON message to the connection with write locking.
+func (pc *picoConn) writeJSON(v any) error {
+ if pc.closed.Load() {
+ return fmt.Errorf("connection closed")
+ }
+ pc.writeMu.Lock()
+ defer pc.writeMu.Unlock()
+ return pc.conn.WriteJSON(v)
+}
+
+// close closes the connection.
+func (pc *picoConn) close() {
+ if pc.closed.CompareAndSwap(false, true) {
+ pc.conn.Close()
+ }
+}
+
+// PicoChannel implements the native Pico Protocol WebSocket channel.
+// It serves as the reference implementation for all optional capability interfaces.
+type PicoChannel struct {
+ *channels.BaseChannel
+ config config.PicoConfig
+ upgrader websocket.Upgrader
+ connections sync.Map // connID → *picoConn
+ connCount atomic.Int32
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// NewPicoChannel creates a new Pico Protocol channel.
+func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
+ if cfg.Token == "" {
+ return nil, fmt.Errorf("pico token is required")
+ }
+
+ base := channels.NewBaseChannel("pico", cfg, messageBus, cfg.AllowFrom)
+
+ allowOrigins := cfg.AllowOrigins
+ checkOrigin := func(r *http.Request) bool {
+ if len(allowOrigins) == 0 {
+ return true // allow all if not configured
+ }
+ origin := r.Header.Get("Origin")
+ for _, allowed := range allowOrigins {
+ if allowed == "*" || allowed == origin {
+ return true
+ }
+ }
+ return false
+ }
+
+ return &PicoChannel{
+ BaseChannel: base,
+ config: cfg,
+ upgrader: websocket.Upgrader{
+ CheckOrigin: checkOrigin,
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ },
+ }, nil
+}
+
+// Start implements Channel.
+func (c *PicoChannel) Start(ctx context.Context) error {
+ logger.InfoC("pico", "Starting Pico Protocol channel")
+ c.ctx, c.cancel = context.WithCancel(ctx)
+ c.SetRunning(true)
+ logger.InfoC("pico", "Pico Protocol channel started")
+ return nil
+}
+
+// Stop implements Channel.
+func (c *PicoChannel) Stop(ctx context.Context) error {
+ logger.InfoC("pico", "Stopping Pico Protocol channel")
+ c.SetRunning(false)
+
+ // Close all connections
+ c.connections.Range(func(key, value any) bool {
+ if pc, ok := value.(*picoConn); ok {
+ pc.close()
+ }
+ c.connections.Delete(key)
+ return true
+ })
+
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ logger.InfoC("pico", "Pico Protocol channel stopped")
+ return nil
+}
+
+// WebhookPath implements channels.WebhookHandler.
+func (c *PicoChannel) WebhookPath() string { return "/pico/" }
+
+// ServeHTTP implements http.Handler for the shared HTTP server.
+func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/pico")
+
+ switch {
+ case path == "/ws" || path == "/ws/":
+ c.handleWebSocket(w, r)
+ default:
+ http.NotFound(w, r)
+ }
+}
+
+// Send implements Channel — sends a message to the appropriate WebSocket connection.
+func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ outMsg := newMessage(TypeMessageCreate, map[string]any{
+ "content": msg.Content,
+ })
+
+ return c.broadcastToSession(msg.ChatID, outMsg)
+}
+
+// EditMessage implements channels.MessageEditor.
+func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
+ outMsg := newMessage(TypeMessageUpdate, map[string]any{
+ "message_id": messageID,
+ "content": content,
+ })
+ return c.broadcastToSession(chatID, outMsg)
+}
+
+// StartTyping implements channels.TypingCapable.
+func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
+ startMsg := newMessage(TypeTypingStart, nil)
+ if err := c.broadcastToSession(chatID, startMsg); err != nil {
+ return func() {}, err
+ }
+ return func() {
+ stopMsg := newMessage(TypeTypingStop, nil)
+ c.broadcastToSession(chatID, stopMsg)
+ }, nil
+}
+
+// SendPlaceholder implements channels.PlaceholderCapable.
+// It sends a placeholder message via the Pico Protocol that will later be
+// edited to the actual response via EditMessage (channels.MessageEditor).
+func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
+ if !c.config.Placeholder.Enabled {
+ return "", nil
+ }
+
+ text := c.config.Placeholder.Text
+ if text == "" {
+ text = "Thinking... 💭"
+ }
+
+ msgID := uuid.New().String()
+ outMsg := newMessage(TypeMessageCreate, map[string]any{
+ "content": text,
+ "message_id": msgID,
+ })
+
+ if err := c.broadcastToSession(chatID, outMsg); err != nil {
+ return "", err
+ }
+
+ return msgID, nil
+}
+
+// broadcastToSession sends a message to all connections with a matching session.
+func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
+ // chatID format: "pico:"
+ sessionID := strings.TrimPrefix(chatID, "pico:")
+ msg.SessionID = sessionID
+
+ var sent bool
+ c.connections.Range(func(key, value any) bool {
+ pc, ok := value.(*picoConn)
+ if !ok {
+ return true
+ }
+ if pc.sessionID == sessionID {
+ if err := pc.writeJSON(msg); err != nil {
+ logger.DebugCF("pico", "Write to connection failed", map[string]any{
+ "conn_id": pc.id,
+ "error": err.Error(),
+ })
+ } else {
+ sent = true
+ }
+ }
+ return true
+ })
+
+ if !sent {
+ return fmt.Errorf("no active connections for session %s: %w", sessionID, channels.ErrSendFailed)
+ }
+ return nil
+}
+
+// handleWebSocket upgrades the HTTP connection and manages the WebSocket lifecycle.
+func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
+ if !c.IsRunning() {
+ http.Error(w, "channel not running", http.StatusServiceUnavailable)
+ return
+ }
+
+ // Authenticate
+ if !c.authenticate(r) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ // Check connection limit
+ maxConns := c.config.MaxConnections
+ if maxConns <= 0 {
+ maxConns = 100
+ }
+ if int(c.connCount.Load()) >= maxConns {
+ http.Error(w, "too many connections", http.StatusServiceUnavailable)
+ return
+ }
+
+ conn, err := c.upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{
+ "error": err.Error(),
+ })
+ return
+ }
+
+ // Determine session ID from query param or generate one
+ sessionID := r.URL.Query().Get("session_id")
+ if sessionID == "" {
+ sessionID = uuid.New().String()
+ }
+
+ pc := &picoConn{
+ id: uuid.New().String(),
+ conn: conn,
+ sessionID: sessionID,
+ }
+
+ c.connections.Store(pc.id, pc)
+ c.connCount.Add(1)
+
+ logger.InfoCF("pico", "WebSocket client connected", map[string]any{
+ "conn_id": pc.id,
+ "session_id": sessionID,
+ })
+
+ go c.readLoop(pc)
+}
+
+// authenticate checks the Bearer token from the Authorization header.
+// Query parameter authentication is only allowed when AllowTokenQuery is explicitly enabled.
+func (c *PicoChannel) authenticate(r *http.Request) bool {
+ token := c.config.Token
+ if token == "" {
+ return false
+ }
+
+ // Check Authorization header
+ auth := r.Header.Get("Authorization")
+ if strings.HasPrefix(auth, "Bearer ") {
+ if strings.TrimPrefix(auth, "Bearer ") == token {
+ return true
+ }
+ }
+
+ // Check query parameter only when explicitly allowed
+ if c.config.AllowTokenQuery {
+ if r.URL.Query().Get("token") == token {
+ return true
+ }
+ }
+
+ return false
+}
+
+// readLoop reads messages from a WebSocket connection.
+func (c *PicoChannel) readLoop(pc *picoConn) {
+ defer func() {
+ pc.close()
+ c.connections.Delete(pc.id)
+ c.connCount.Add(-1)
+ logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{
+ "conn_id": pc.id,
+ "session_id": pc.sessionID,
+ })
+ }()
+
+ readTimeout := time.Duration(c.config.ReadTimeout) * time.Second
+ if readTimeout <= 0 {
+ readTimeout = 60 * time.Second
+ }
+
+ _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
+ pc.conn.SetPongHandler(func(appData string) error {
+ _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
+ return nil
+ })
+
+ // Start ping ticker
+ pingInterval := time.Duration(c.config.PingInterval) * time.Second
+ if pingInterval <= 0 {
+ pingInterval = 30 * time.Second
+ }
+ go c.pingLoop(pc, pingInterval)
+
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ default:
+ }
+
+ _, rawMsg, err := pc.conn.ReadMessage()
+ if err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
+ logger.DebugCF("pico", "WebSocket read error", map[string]any{
+ "conn_id": pc.id,
+ "error": err.Error(),
+ })
+ }
+ return
+ }
+
+ _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
+
+ var msg PicoMessage
+ if err := json.Unmarshal(rawMsg, &msg); err != nil {
+ errMsg := newError("invalid_message", "failed to parse message")
+ pc.writeJSON(errMsg)
+ continue
+ }
+
+ c.handleMessage(pc, msg)
+ }
+}
+
+// pingLoop sends periodic ping frames to keep the connection alive.
+func (c *PicoChannel) pingLoop(pc *picoConn, interval time.Duration) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ case <-ticker.C:
+ if pc.closed.Load() {
+ return
+ }
+ pc.writeMu.Lock()
+ err := pc.conn.WriteMessage(websocket.PingMessage, nil)
+ pc.writeMu.Unlock()
+ if err != nil {
+ return
+ }
+ }
+ }
+}
+
+// handleMessage processes an inbound Pico Protocol message.
+func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) {
+ switch msg.Type {
+ case TypePing:
+ pong := newMessage(TypePong, nil)
+ pong.ID = msg.ID
+ pc.writeJSON(pong)
+
+ case TypeMessageSend:
+ c.handleMessageSend(pc, msg)
+
+ default:
+ errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
+ pc.writeJSON(errMsg)
+ }
+}
+
+// handleMessageSend processes an inbound message.send from a client.
+func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
+ content, _ := msg.Payload["content"].(string)
+ if strings.TrimSpace(content) == "" {
+ errMsg := newError("empty_content", "message content is empty")
+ pc.writeJSON(errMsg)
+ return
+ }
+
+ sessionID := msg.SessionID
+ if sessionID == "" {
+ sessionID = pc.sessionID
+ }
+
+ chatID := "pico:" + sessionID
+ senderID := "pico-user"
+
+ peer := bus.Peer{Kind: "direct", ID: "pico:" + sessionID}
+
+ metadata := map[string]string{
+ "platform": "pico",
+ "session_id": sessionID,
+ "conn_id": pc.id,
+ }
+
+ logger.DebugCF("pico", "Received message", map[string]any{
+ "session_id": sessionID,
+ "preview": truncate(content, 50),
+ })
+
+ sender := bus.SenderInfo{
+ Platform: "pico",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("pico", senderID),
+ }
+
+ if !c.IsAllowedSender(sender) {
+ return
+ }
+
+ c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender)
+}
+
+// truncate truncates a string to maxLen runes.
+func truncate(s string, maxLen int) string {
+ runes := []rune(s)
+ if len(runes) <= maxLen {
+ return s
+ }
+ return string(runes[:maxLen]) + "..."
+}
diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go
new file mode 100644
index 000000000..0a630e193
--- /dev/null
+++ b/pkg/channels/pico/protocol.go
@@ -0,0 +1,46 @@
+package pico
+
+import "time"
+
+// Protocol message types.
+const (
+ // TypeMessageSend is sent from client to server.
+ TypeMessageSend = "message.send"
+ TypeMediaSend = "media.send"
+ TypePing = "ping"
+
+ // TypeMessageCreate is sent from server to client.
+ TypeMessageCreate = "message.create"
+ TypeMessageUpdate = "message.update"
+ TypeMediaCreate = "media.create"
+ TypeTypingStart = "typing.start"
+ TypeTypingStop = "typing.stop"
+ TypeError = "error"
+ TypePong = "pong"
+)
+
+// PicoMessage is the wire format for all Pico Protocol messages.
+type PicoMessage struct {
+ Type string `json:"type"`
+ ID string `json:"id,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
+ Timestamp int64 `json:"timestamp,omitempty"`
+ Payload map[string]any `json:"payload,omitempty"`
+}
+
+// newMessage creates a PicoMessage with the given type and payload.
+func newMessage(msgType string, payload map[string]any) PicoMessage {
+ return PicoMessage{
+ Type: msgType,
+ Timestamp: time.Now().UnixMilli(),
+ Payload: payload,
+ }
+}
+
+// newError creates an error PicoMessage.
+func newError(code, message string) PicoMessage {
+ return newMessage(TypeError, map[string]any{
+ "code": code,
+ "message": message,
+ })
+}
diff --git a/pkg/channels/qq/init.go b/pkg/channels/qq/init.go
new file mode 100644
index 000000000..15b955089
--- /dev/null
+++ b/pkg/channels/qq/init.go
@@ -0,0 +1,13 @@
+package qq
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("qq", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewQQChannel(cfg.Channels.QQ, b)
+ })
+}
diff --git a/pkg/channels/qq.go b/pkg/channels/qq/qq.go
similarity index 69%
rename from pkg/channels/qq.go
rename to pkg/channels/qq/qq.go
index e66cac533..112964143 100644
--- a/pkg/channels/qq.go
+++ b/pkg/channels/qq/qq.go
@@ -1,4 +1,4 @@
-package channels
+package qq
import (
"context"
@@ -14,12 +14,14 @@ import (
"golang.org/x/oauth2"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
)
type QQChannel struct {
- *BaseChannel
+ *channels.BaseChannel
config config.QQConfig
api openapi.OpenAPI
tokenSource oauth2.TokenSource
@@ -31,7 +33,10 @@ type QQChannel struct {
}
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
- base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom)
+ base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
return &QQChannel{
BaseChannel: base,
@@ -47,31 +52,31 @@ func (c *QQChannel) Start(ctx context.Context) error {
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
- // 创建 token source
+ // create token source
credentials := &token.QQBotCredentials{
AppID: c.config.AppID,
AppSecret: c.config.AppSecret,
}
c.tokenSource = token.NewQQBotTokenSource(credentials)
- // 创建子 context
+ // create child context
c.ctx, c.cancel = context.WithCancel(ctx)
- // 启动自动刷新 token 协程
+ // start auto-refresh token goroutine
if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
return fmt.Errorf("failed to start token refresh: %w", err)
}
- // 初始化 OpenAPI 客户端
+ // initialize OpenAPI client
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
- // 注册事件处理器
+ // register event handlers
intent := event.RegisterHandlers(
c.handleC2CMessage(),
c.handleGroupATMessage(),
)
- // 获取 WebSocket 接入点
+ // get WebSocket endpoint
wsInfo, err := c.api.WS(c.ctx, nil, "")
if err != nil {
return fmt.Errorf("failed to get websocket info: %w", err)
@@ -81,20 +86,20 @@ func (c *QQChannel) Start(ctx context.Context) error {
"shards": wsInfo.Shards,
})
- // 创建并保存 sessionManager
+ // create and save sessionManager
c.sessionManager = botgo.NewSessionManager()
- // 在 goroutine 中启动 WebSocket 连接,避免阻塞
+ // start WebSocket connection in goroutine to avoid blocking
go func() {
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
"error": err.Error(),
})
- c.setRunning(false)
+ c.SetRunning(false)
}
}()
- c.setRunning(true)
+ c.SetRunning(true)
logger.InfoC("qq", "QQ bot started successfully")
return nil
@@ -102,7 +107,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
func (c *QQChannel) Stop(ctx context.Context) error {
logger.InfoC("qq", "Stopping QQ bot")
- c.setRunning(false)
+ c.SetRunning(false)
if c.cancel != nil {
c.cancel()
@@ -113,35 +118,35 @@ func (c *QQChannel) Stop(ctx context.Context) error {
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
- return fmt.Errorf("QQ bot not running")
+ return channels.ErrNotRunning
}
- // 构造消息
+ // construct message
msgToCreate := &dto.MessageToCreate{
Content: msg.Content,
}
- // C2C 消息发送
+ // send C2C message
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
if err != nil {
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
"error": err.Error(),
})
- return err
+ return fmt.Errorf("qq send: %w", channels.ErrTemporary)
}
return nil
}
-// handleC2CMessage 处理 QQ 私聊消息
+// handleC2CMessage handles QQ private messages
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
- // 去重检查
+ // deduplication check
if c.isDuplicate(data.ID) {
return nil
}
- // 提取用户信息
+ // extract user info
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
@@ -150,7 +155,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return nil
}
- // 提取消息内容
+ // extract message content
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty message, ignoring")
@@ -163,27 +168,42 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
})
// 转发到消息总线
- metadata := map[string]string{
- "message_id": data.ID,
- "peer_kind": "direct",
- "peer_id": senderID,
+ metadata := map[string]string{}
+
+ sender := bus.SenderInfo{
+ Platform: "qq",
+ PlatformID: data.Author.ID,
+ CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
}
- c.HandleMessage(senderID, senderID, content, []string{}, metadata)
+ if !c.IsAllowedSender(sender) {
+ return nil
+ }
+
+ c.HandleMessage(c.ctx,
+ bus.Peer{Kind: "direct", ID: senderID},
+ data.ID,
+ senderID,
+ senderID,
+ content,
+ []string{},
+ metadata,
+ sender,
+ )
return nil
}
}
-// handleGroupATMessage 处理群@消息
+// handleGroupATMessage handles QQ group @ messages
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
- // 去重检查
+ // deduplication check
if c.isDuplicate(data.ID) {
return nil
}
- // 提取用户信息
+ // extract user info
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
@@ -192,13 +212,20 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return nil
}
- // 提取消息内容(去掉 @ 机器人部分)
+ // extract message content (remove @ bot part)
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty group message, ignoring")
return nil
}
+ // GroupAT event means bot is always mentioned; apply group trigger filtering
+ respond, cleaned := c.ShouldRespondInGroup(true, content)
+ if !respond {
+ return nil
+ }
+ content = cleaned
+
logger.InfoCF("qq", "Received group AT message", map[string]any{
"sender": senderID,
"group": data.GroupID,
@@ -207,13 +234,29 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
// 转发到消息总线(使用 GroupID 作为 ChatID)
metadata := map[string]string{
- "message_id": data.ID,
- "group_id": data.GroupID,
- "peer_kind": "group",
- "peer_id": data.GroupID,
+ "group_id": data.GroupID,
}
- c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)
+ sender := bus.SenderInfo{
+ Platform: "qq",
+ PlatformID: data.Author.ID,
+ CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
+ }
+
+ if !c.IsAllowedSender(sender) {
+ return nil
+ }
+
+ c.HandleMessage(c.ctx,
+ bus.Peer{Kind: "group", ID: data.GroupID},
+ data.ID,
+ senderID,
+ data.GroupID,
+ content,
+ []string{},
+ metadata,
+ sender,
+ )
return nil
}
diff --git a/pkg/channels/registry.go b/pkg/channels/registry.go
new file mode 100644
index 000000000..36a05bf3e
--- /dev/null
+++ b/pkg/channels/registry.go
@@ -0,0 +1,32 @@
+package channels
+
+import (
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// ChannelFactory is a constructor function that creates a Channel from config and message bus.
+// Each channel subpackage registers one or more factories via init().
+type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error)
+
+var (
+ factoriesMu sync.RWMutex
+ factories = map[string]ChannelFactory{}
+)
+
+// RegisterFactory registers a named channel factory. Called from subpackage init() functions.
+func RegisterFactory(name string, f ChannelFactory) {
+ factoriesMu.Lock()
+ defer factoriesMu.Unlock()
+ factories[name] = f
+}
+
+// getFactory looks up a channel factory by name.
+func getFactory(name string) (ChannelFactory, bool) {
+ factoriesMu.RLock()
+ defer factoriesMu.RUnlock()
+ f, ok := factories[name]
+ return f, ok
+}
diff --git a/pkg/channels/slack/init.go b/pkg/channels/slack/init.go
new file mode 100644
index 000000000..c131bb291
--- /dev/null
+++ b/pkg/channels/slack/init.go
@@ -0,0 +1,13 @@
+package slack
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("slack", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewSlackChannel(cfg.Channels.Slack, b)
+ })
+}
diff --git a/pkg/channels/slack.go b/pkg/channels/slack/slack.go
similarity index 65%
rename from pkg/channels/slack.go
rename to pkg/channels/slack/slack.go
index f7359cd6d..024b1b023 100644
--- a/pkg/channels/slack.go
+++ b/pkg/channels/slack/slack.go
@@ -1,32 +1,31 @@
-package channels
+package slack
import (
"context"
"fmt"
- "os"
"strings"
"sync"
- "time"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
- "github.com/sipeed/picoclaw/pkg/voice"
)
type SlackChannel struct {
- *BaseChannel
+ *channels.BaseChannel
config config.SlackConfig
api *slack.Client
socketClient *socketmode.Client
botUserID string
teamID string
- transcriber *voice.GroqTranscriber
ctx context.Context
cancel context.CancelFunc
pendingAcks sync.Map
@@ -49,7 +48,11 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
socketClient := socketmode.New(api)
- base := NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom)
+ base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom,
+ channels.WithMaxMessageLength(40000),
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
return &SlackChannel{
BaseChannel: base,
@@ -59,10 +62,6 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
}, nil
}
-func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
- c.transcriber = transcriber
-}
-
func (c *SlackChannel) Start(ctx context.Context) error {
logger.InfoC("slack", "Starting Slack channel (Socket Mode)")
@@ -92,7 +91,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
}
}()
- c.setRunning(true)
+ c.SetRunning(true)
logger.InfoC("slack", "Slack channel started (Socket Mode)")
return nil
}
@@ -104,14 +103,14 @@ func (c *SlackChannel) Stop(ctx context.Context) error {
c.cancel()
}
- c.setRunning(false)
+ c.SetRunning(false)
logger.InfoC("slack", "Slack channel stopped")
return nil
}
func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
- return fmt.Errorf("slack channel not running")
+ return channels.ErrNotRunning
}
channelID, threadTS := parseSlackChatID(msg.ChatID)
@@ -129,7 +128,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
_, _, err := c.api.PostMessageContext(ctx, channelID, opts...)
if err != nil {
- return fmt.Errorf("failed to send slack message: %w", err)
+ return fmt.Errorf("slack send: %w", channels.ErrTemporary)
}
if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {
@@ -148,6 +147,82 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return nil
}
+// SendMedia implements the channels.MediaSender interface.
+func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ channelID, _ := parseSlackChatID(msg.ChatID)
+ if channelID == "" {
+ return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
+ }
+
+ store := c.GetMediaStore()
+ if store == nil {
+ return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
+ }
+
+ for _, part := range msg.Parts {
+ localPath, err := store.Resolve(part.Ref)
+ if err != nil {
+ logger.ErrorCF("slack", "Failed to resolve media ref", map[string]any{
+ "ref": part.Ref,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ filename := part.Filename
+ if filename == "" {
+ filename = "file"
+ }
+
+ title := part.Caption
+ if title == "" {
+ title = filename
+ }
+
+ _, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
+ Channel: channelID,
+ File: localPath,
+ Filename: filename,
+ Title: title,
+ })
+ if err != nil {
+ logger.ErrorCF("slack", "Failed to upload media", map[string]any{
+ "filename": filename,
+ "error": err.Error(),
+ })
+ return fmt.Errorf("slack send media: %w", channels.ErrTemporary)
+ }
+ }
+
+ return nil
+}
+
+// ReactToMessage implements channels.ReactionCapable.
+// It adds an "eyes" (👀) reaction to the inbound message and returns an undo function
+// that removes the reaction.
+func (c *SlackChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
+ channelID, _ := parseSlackChatID(chatID)
+ if channelID == "" {
+ return func() {}, nil
+ }
+
+ c.api.AddReaction("eyes", slack.ItemRef{
+ Channel: channelID,
+ Timestamp: messageID,
+ })
+
+ return func() {
+ c.api.RemoveReaction("eyes", slack.ItemRef{
+ Channel: channelID,
+ Timestamp: messageID,
+ })
+ }, nil
+}
+
func (c *SlackChannel) eventLoop() {
for {
select {
@@ -200,8 +275,13 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
return
}
- // 检查白名单,避免为被拒绝的用户下载附件
- if !c.IsAllowed(ev.User) {
+ // check allowlist to avoid downloading attachments for rejected users
+ sender := bus.SenderInfo{
+ Platform: "slack",
+ PlatformID: ev.User,
+ CanonicalID: identity.BuildCanonicalID("slack", ev.User),
+ }
+ if !c.IsAllowedSender(sender) {
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
"user_id": ev.User,
})
@@ -218,11 +298,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
chatID = channelID + "/" + threadTS
}
- c.api.AddReaction("eyes", slack.ItemRef{
- Channel: channelID,
- Timestamp: messageTS,
- })
-
c.pendingAcks.Store(chatID, slackMessageRef{
ChannelID: channelID,
Timestamp: messageTS,
@@ -231,20 +306,32 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
content := ev.Text
content = c.stripBotMention(content)
- var mediaPaths []string
- localFiles := []string{} // 跟踪需要清理的本地文件
+ // In non-DM channels, apply group trigger filtering
+ if !strings.HasPrefix(channelID, "D") {
+ respond, cleaned := c.ShouldRespondInGroup(false, content)
+ if !respond {
+ return
+ }
+ content = cleaned
+ }
- // 确保临时文件在函数返回时被清理
- defer func() {
- for _, file := range localFiles {
- if err := os.Remove(file); err != nil {
- logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{
- "file": file,
- "error": err.Error(),
- })
+ var mediaPaths []string
+
+ scope := channels.BuildMediaScope("slack", chatID, messageTS)
+
+ // Helper to register a local file with the media store
+ storeMedia := func(localPath, filename string) string {
+ if store := c.GetMediaStore(); store != nil {
+ ref, err := store.Store(localPath, media.MediaMeta{
+ Filename: filename,
+ Source: "slack",
+ }, scope)
+ if err == nil {
+ return ref
}
}
- }()
+ return localPath // fallback
+ }
if ev.Message != nil && len(ev.Message.Files) > 0 {
for _, file := range ev.Message.Files {
@@ -252,23 +339,8 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
if localPath == "" {
continue
}
- localFiles = append(localFiles, localPath)
- mediaPaths = append(mediaPaths, localPath)
-
- if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() {
- ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
- defer cancel()
- result, err := c.transcriber.Transcribe(ctx, localPath)
-
- if err != nil {
- logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()})
- content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
- } else {
- content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
- }
- } else {
- content += fmt.Sprintf("\n[file: %s]", file.Name)
- }
+ mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
+ content += fmt.Sprintf("\n[file: %s]", file.Name)
}
}
@@ -283,13 +355,13 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
peerID = senderID
}
+ peer := bus.Peer{Kind: peerKind, ID: peerID}
+
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
- "peer_kind": peerKind,
- "peer_id": peerID,
"team_id": c.teamID,
}
@@ -300,7 +372,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
"has_thread": threadTS != "",
})
- c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
+ c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata, sender)
}
func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
@@ -308,7 +380,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
return
}
- if !c.IsAllowed(ev.User) {
+ if !c.IsAllowedSender(bus.SenderInfo{
+ Platform: "slack",
+ PlatformID: ev.User,
+ CanonicalID: identity.BuildCanonicalID("slack", ev.User),
+ }) {
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
"user_id": ev.User,
})
@@ -316,6 +392,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
}
senderID := ev.User
+ mentionSender := bus.SenderInfo{
+ Platform: "slack",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("slack", senderID),
+ }
channelID := ev.Channel
threadTS := ev.ThreadTimeStamp
messageTS := ev.TimeStamp
@@ -327,11 +408,6 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
chatID = channelID + "/" + messageTS
}
- c.api.AddReaction("eyes", slack.ItemRef{
- Channel: channelID,
- Timestamp: messageTS,
- })
-
c.pendingAcks.Store(chatID, slackMessageRef{
ChannelID: channelID,
Timestamp: messageTS,
@@ -350,18 +426,18 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
mentionPeerID = senderID
}
+ mentionPeer := bus.Peer{Kind: mentionPeerKind, ID: mentionPeerID}
+
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
"is_mention": "true",
- "peer_kind": mentionPeerKind,
- "peer_id": mentionPeerID,
"team_id": c.teamID,
}
- c.HandleMessage(senderID, chatID, content, nil, metadata)
+ c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender)
}
func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
@@ -374,7 +450,12 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
c.socketClient.Ack(*event.Request)
}
- if !c.IsAllowed(cmd.UserID) {
+ cmdSender := bus.SenderInfo{
+ Platform: "slack",
+ PlatformID: cmd.UserID,
+ CanonicalID: identity.BuildCanonicalID("slack", cmd.UserID),
+ }
+ if !c.IsAllowedSender(cmdSender) {
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
"user_id": cmd.UserID,
})
@@ -395,8 +476,6 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
"platform": "slack",
"is_command": "true",
"trigger_id": cmd.TriggerID,
- "peer_kind": "channel",
- "peer_id": channelID,
"team_id": c.teamID,
}
@@ -406,7 +485,17 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
"text": utils.Truncate(content, 50),
})
- c.HandleMessage(senderID, chatID, content, nil, metadata)
+ c.HandleMessage(
+ c.ctx,
+ bus.Peer{Kind: "channel", ID: channelID},
+ "",
+ senderID,
+ chatID,
+ content,
+ nil,
+ metadata,
+ cmdSender,
+ )
}
func (c *SlackChannel) downloadSlackFile(file slack.File) string {
@@ -439,5 +528,5 @@ func parseSlackChatID(chatID string) (channelID, threadTS string) {
if len(parts) > 1 {
threadTS = parts[1]
}
- return
+ return channelID, threadTS
}
diff --git a/pkg/channels/slack_test.go b/pkg/channels/slack/slack_test.go
similarity index 99%
rename from pkg/channels/slack_test.go
rename to pkg/channels/slack/slack_test.go
index 3707c2703..30e0d2d73 100644
--- a/pkg/channels/slack_test.go
+++ b/pkg/channels/slack/slack_test.go
@@ -1,4 +1,4 @@
-package channels
+package slack
import (
"testing"
diff --git a/pkg/channels/split.go b/pkg/channels/split.go
new file mode 100644
index 000000000..1c951a31f
--- /dev/null
+++ b/pkg/channels/split.go
@@ -0,0 +1,224 @@
+package channels
+
+import (
+ "strings"
+)
+
+// SplitMessage splits long messages into chunks, preserving code block integrity.
+// The maxLen parameter is measured in runes (Unicode characters), not bytes.
+// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
+// but may extend to maxLen when needed.
+// Call SplitMessage with the full text content and the maximum allowed length of a single message;
+// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.
+func SplitMessage(content string, maxLen int) []string {
+ if maxLen <= 0 {
+ if content == "" {
+ return nil
+ }
+ return []string{content}
+ }
+
+ runes := []rune(content)
+ totalLen := len(runes)
+ var messages []string
+
+ // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
+ codeBlockBuffer := maxLen / 10
+ if codeBlockBuffer < 50 {
+ codeBlockBuffer = 50
+ }
+ if codeBlockBuffer > maxLen/2 {
+ codeBlockBuffer = maxLen / 2
+ }
+
+ start := 0
+ for start < totalLen {
+ remaining := totalLen - start
+ if remaining <= maxLen {
+ messages = append(messages, string(runes[start:totalLen]))
+ break
+ }
+
+ // Effective split point: maxLen minus buffer, to leave room for code blocks
+ effectiveLimit := maxLen - codeBlockBuffer
+ if effectiveLimit < maxLen/2 {
+ effectiveLimit = maxLen / 2
+ }
+
+ end := start + effectiveLimit
+
+ // Find natural split point within the effective limit
+ msgEnd := findLastNewlineInRange(runes, start, end, 200)
+ if msgEnd <= start {
+ msgEnd = findLastSpaceInRange(runes, start, end, 100)
+ }
+ if msgEnd <= start {
+ msgEnd = end
+ }
+
+ // Check if this would end with an incomplete code block
+ unclosedIdx := findLastUnclosedCodeBlockInRange(runes, start, msgEnd)
+
+ if unclosedIdx >= 0 {
+ // Message would end with incomplete code block
+ // Try to extend up to maxLen to include the closing ```
+ if totalLen > msgEnd {
+ closingIdx := findNextClosingCodeBlockInRange(runes, msgEnd, totalLen)
+ if closingIdx > 0 && closingIdx-start <= maxLen {
+ // Extend to include the closing ```
+ msgEnd = closingIdx
+ } else {
+ // Code block is too long to fit in one chunk or missing closing fence.
+ // Try to split inside by injecting closing and reopening fences.
+ headerEnd := findNewlineFrom(runes, unclosedIdx)
+ var header string
+ if headerEnd == -1 {
+ header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+3]))
+ } else {
+ header = strings.TrimSpace(string(runes[unclosedIdx:headerEnd]))
+ }
+ headerEndIdx := unclosedIdx + len([]rune(header))
+ if headerEnd != -1 {
+ headerEndIdx = headerEnd
+ }
+
+ // If we have a reasonable amount of content after the header, split inside
+ if msgEnd > headerEndIdx+20 {
+ // Find a better split point closer to maxLen
+ innerLimit := start + maxLen - 5 // Leave room for "\n```"
+ if innerLimit > totalLen {
+ innerLimit = totalLen
+ }
+ betterEnd := findLastNewlineInRange(runes, start, innerLimit, 200)
+ if betterEnd > headerEndIdx {
+ msgEnd = betterEnd
+ } else {
+ msgEnd = innerLimit
+ }
+ chunk := strings.TrimRight(string(runes[start:msgEnd]), " \t\n\r") + "\n```"
+ messages = append(messages, chunk)
+ remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:totalLen]))
+ // Replace the tail of runes with the reconstructed remaining
+ runes = []rune(remaining)
+ totalLen = len(runes)
+ start = 0
+ continue
+ }
+
+ // Otherwise, try to split before the code block starts
+ newEnd := findLastNewlineInRange(runes, start, unclosedIdx, 200)
+ if newEnd <= start {
+ newEnd = findLastSpaceInRange(runes, start, unclosedIdx, 100)
+ }
+ if newEnd > start {
+ msgEnd = newEnd
+ } else {
+ // If we can't split before, we MUST split inside (last resort)
+ if unclosedIdx-start > 20 {
+ msgEnd = unclosedIdx
+ } else {
+ splitAt := start + maxLen - 5
+ if splitAt > totalLen {
+ splitAt = totalLen
+ }
+ chunk := strings.TrimRight(string(runes[start:splitAt]), " \t\n\r") + "\n```"
+ messages = append(messages, chunk)
+ remaining := strings.TrimSpace(header + "\n" + string(runes[splitAt:totalLen]))
+ runes = []rune(remaining)
+ totalLen = len(runes)
+ start = 0
+ continue
+ }
+ }
+ }
+ }
+ }
+
+ if msgEnd <= start {
+ msgEnd = start + effectiveLimit
+ }
+
+ messages = append(messages, string(runes[start:msgEnd]))
+ // Advance start, skipping leading whitespace of next chunk
+ start = msgEnd
+ for start < totalLen && (runes[start] == ' ' || runes[start] == '\t' || runes[start] == '\n' || runes[start] == '\r') {
+ start++
+ }
+ }
+
+ return messages
+}
+
+// findLastUnclosedCodeBlockInRange finds the last opening ``` that doesn't have a closing ```
+// within runes[start:end]. Returns the absolute rune index or -1.
+func findLastUnclosedCodeBlockInRange(runes []rune, start, end int) int {
+ inCodeBlock := false
+ lastOpenIdx := -1
+
+ for i := start; i < end; i++ {
+ if i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
+ if !inCodeBlock {
+ lastOpenIdx = i
+ }
+ inCodeBlock = !inCodeBlock
+ i += 2
+ }
+ }
+
+ if inCodeBlock {
+ return lastOpenIdx
+ }
+ return -1
+}
+
+// findNextClosingCodeBlockInRange finds the next closing ``` starting from startIdx
+// within runes[startIdx:end]. Returns the absolute index after the closing ``` or -1.
+func findNextClosingCodeBlockInRange(runes []rune, startIdx, end int) int {
+ for i := startIdx; i < end; i++ {
+ if i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
+ return i + 3
+ }
+ }
+ return -1
+}
+
+// findNewlineFrom finds the first newline character starting from the given index.
+// Returns the absolute index or -1 if not found.
+func findNewlineFrom(runes []rune, from int) int {
+ for i := from; i < len(runes); i++ {
+ if runes[i] == '\n' {
+ return i
+ }
+ }
+ return -1
+}
+
+// findLastNewlineInRange finds the last newline within the last searchWindow runes
+// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
+func findLastNewlineInRange(runes []rune, start, end, searchWindow int) int {
+ searchStart := end - searchWindow
+ if searchStart < start {
+ searchStart = start
+ }
+ for i := end - 1; i >= searchStart; i-- {
+ if runes[i] == '\n' {
+ return i
+ }
+ }
+ return start - 1
+}
+
+// findLastSpaceInRange finds the last space/tab within the last searchWindow runes
+// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
+func findLastSpaceInRange(runes []rune, start, end, searchWindow int) int {
+ searchStart := end - searchWindow
+ if searchStart < start {
+ searchStart = start
+ }
+ for i := end - 1; i >= searchStart; i-- {
+ if runes[i] == ' ' || runes[i] == '\t' {
+ return i
+ }
+ }
+ return start - 1
+}
diff --git a/pkg/channels/split_test.go b/pkg/channels/split_test.go
new file mode 100644
index 000000000..a922f9558
--- /dev/null
+++ b/pkg/channels/split_test.go
@@ -0,0 +1,362 @@
+package channels
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSplitMessage(t *testing.T) {
+ longText := strings.Repeat("a", 2500)
+ longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars
+
+ tests := []struct {
+ name string
+ content string
+ maxLen int
+ expectChunks int // Check number of chunks
+ checkContent func(t *testing.T, chunks []string) // Custom validation
+ }{
+ {
+ name: "Empty message",
+ content: "",
+ maxLen: 2000,
+ expectChunks: 0,
+ },
+ {
+ name: "Short message fits in one chunk",
+ content: "Hello world",
+ maxLen: 2000,
+ expectChunks: 1,
+ },
+ {
+ name: "Simple split regular text",
+ content: longText,
+ maxLen: 2000,
+ expectChunks: 2,
+ checkContent: func(t *testing.T, chunks []string) {
+ if len([]rune(chunks[0])) > 2000 {
+ t.Errorf("Chunk 0 too large: %d runes", len([]rune(chunks[0])))
+ }
+ if len([]rune(chunks[0]))+len([]rune(chunks[1])) != len([]rune(longText)) {
+ t.Errorf(
+ "Total rune length mismatch. Got %d, want %d",
+ len([]rune(chunks[0]))+len([]rune(chunks[1])),
+ len([]rune(longText)),
+ )
+ }
+ },
+ },
+ {
+ name: "Split at newline",
+ // 1750 chars then newline, then more chars.
+ // Dynamic buffer: 2000 / 10 = 200.
+ // Effective limit: 2000 - 200 = 1800.
+ // Split should happen at newline because it's at 1750 (< 1800).
+ // Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051.
+ content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300),
+ maxLen: 2000,
+ expectChunks: 2,
+ checkContent: func(t *testing.T, chunks []string) {
+ if len([]rune(chunks[0])) != 1750 {
+ t.Errorf("Expected chunk 0 to be 1750 runes (split at newline), got %d", len([]rune(chunks[0])))
+ }
+ if chunks[1] != strings.Repeat("b", 300) {
+ t.Errorf("Chunk 1 content mismatch. Len: %d", len([]rune(chunks[1])))
+ }
+ },
+ },
+ {
+ name: "Long code block split",
+ content: "Prefix\n" + longCode,
+ maxLen: 2000,
+ expectChunks: 2,
+ checkContent: func(t *testing.T, chunks []string) {
+ // Check that first chunk ends with closing fence
+ if !strings.HasSuffix(chunks[0], "\n```") {
+ t.Error("First chunk should end with injected closing fence")
+ }
+ // Check that second chunk starts with execution header
+ if !strings.HasPrefix(chunks[1], "```go") {
+ t.Error("Second chunk should start with injected code block header")
+ }
+ },
+ },
+ {
+ name: "Preserve Unicode characters (rune-aware)",
+ content: strings.Repeat("\u4e16", 2500), // 2500 runes, 7500 bytes
+ maxLen: 2000,
+ expectChunks: 2,
+ checkContent: func(t *testing.T, chunks []string) {
+ // Verify chunks contain valid unicode and don't split mid-rune
+ for i, chunk := range chunks {
+ runeCount := len([]rune(chunk))
+ if runeCount > 2000 {
+ t.Errorf("Chunk %d has %d runes, exceeds maxLen 2000", i, runeCount)
+ }
+ if !strings.Contains(chunk, "\u4e16") {
+ t.Errorf("Chunk %d should contain unicode characters", i)
+ }
+ }
+ // Verify total rune count is preserved
+ totalRunes := 0
+ for _, chunk := range chunks {
+ totalRunes += len([]rune(chunk))
+ }
+ if totalRunes != 2500 {
+ t.Errorf("Total rune count mismatch. Got %d, want 2500", totalRunes)
+ }
+ },
+ },
+ {
+ name: "Zero maxLen returns single chunk",
+ content: "Hello world",
+ maxLen: 0,
+ expectChunks: 1,
+ checkContent: func(t *testing.T, chunks []string) {
+ if chunks[0] != "Hello world" {
+ t.Errorf("Expected original content, got %q", chunks[0])
+ }
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := SplitMessage(tc.content, tc.maxLen)
+
+ if tc.expectChunks == 0 {
+ if len(got) != 0 {
+ t.Errorf("Expected 0 chunks, got %d", len(got))
+ }
+ return
+ }
+
+ if len(got) != tc.expectChunks {
+ t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got))
+ // Log sizes for debugging
+ for i, c := range got {
+ t.Logf("Chunk %d length: %d", i, len(c))
+ }
+ return // Stop further checks if count assumes specific split
+ }
+
+ if tc.checkContent != nil {
+ tc.checkContent(t, got)
+ }
+ })
+ }
+}
+
+// --- Helper function tests for index-based rune operations ---
+
+func TestFindLastNewlineInRange(t *testing.T) {
+ runes := []rune("aaa\nbbb\nccc")
+ // Indices: 0123 4567 89 10
+
+ tests := []struct {
+ name string
+ start, end int
+ searchWindow int
+ want int
+ }{
+ {"finds last newline in full range", 0, 11, 200, 7},
+ {"finds newline within search window", 0, 11, 4, 7},
+ {"narrow window misses newline outside window", 4, 11, 3, 3}, // returns start-1 (not found)
+ {"no newline in range", 0, 3, 200, -1}, // start-1 = -1
+ {"range limited to first segment", 0, 4, 200, 3},
+ {"search window of 1 at newline", 0, 8, 1, 7},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := findLastNewlineInRange(runes, tc.start, tc.end, tc.searchWindow)
+ if got != tc.want {
+ t.Errorf("findLastNewlineInRange(runes, %d, %d, %d) = %d, want %d",
+ tc.start, tc.end, tc.searchWindow, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestFindLastSpaceInRange(t *testing.T) {
+ runes := []rune("abc def\tghi")
+ // Indices: 0123 4567 89 10
+
+ tests := []struct {
+ name string
+ start, end int
+ searchWindow int
+ want int
+ }{
+ {"finds tab as last space/tab", 0, 11, 200, 7},
+ {"finds space when tab out of window", 0, 7, 200, 3},
+ {"no space in range", 0, 3, 200, -1},
+ {"narrow window finds tab", 5, 11, 4, 7},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := findLastSpaceInRange(runes, tc.start, tc.end, tc.searchWindow)
+ if got != tc.want {
+ t.Errorf("findLastSpaceInRange(runes, %d, %d, %d) = %d, want %d",
+ tc.start, tc.end, tc.searchWindow, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestFindNewlineFrom(t *testing.T) {
+ runes := []rune("hello\nworld\n")
+
+ tests := []struct {
+ name string
+ from int
+ want int
+ }{
+ {"from start", 0, 5},
+ {"from after first newline", 6, 11},
+ {"from past all newlines", 12, -1},
+ {"from newline itself", 5, 5},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := findNewlineFrom(runes, tc.from)
+ if got != tc.want {
+ t.Errorf("findNewlineFrom(runes, %d) = %d, want %d", tc.from, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestFindLastUnclosedCodeBlockInRange(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ start, end int
+ want int
+ }{
+ {
+ name: "no code blocks",
+ content: "hello world",
+ start: 0, end: 11,
+ want: -1,
+ },
+ {
+ name: "complete code block",
+ content: "```go\ncode\n```",
+ start: 0, end: 14,
+ want: -1,
+ },
+ {
+ name: "unclosed code block",
+ content: "text\n```go\ncode here",
+ start: 0, end: 20,
+ want: 5,
+ },
+ {
+ name: "closed then unclosed",
+ content: "```a\n```\n```b\ncode",
+ start: 0, end: 17,
+ want: 9,
+ },
+ {
+ name: "search within subrange",
+ content: "```a\n```\n```b\ncode",
+ start: 9, end: 17,
+ want: 9,
+ },
+ {
+ name: "subrange with no code blocks",
+ content: "```a\n```\nhello",
+ start: 9, end: 14,
+ want: -1,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ runes := []rune(tc.content)
+ got := findLastUnclosedCodeBlockInRange(runes, tc.start, tc.end)
+ if got != tc.want {
+ t.Errorf("findLastUnclosedCodeBlockInRange(%q, %d, %d) = %d, want %d",
+ tc.content, tc.start, tc.end, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestFindNextClosingCodeBlockInRange(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ startIdx int
+ end int
+ want int
+ }{
+ {
+ name: "finds closing fence",
+ content: "code\n```\nmore",
+ startIdx: 0, end: 13,
+ want: 8, // position after ```
+ },
+ {
+ name: "no closing fence",
+ content: "just code here",
+ startIdx: 0, end: 14,
+ want: -1,
+ },
+ {
+ name: "fence at start of search",
+ content: "```end",
+ startIdx: 0, end: 6,
+ want: 3,
+ },
+ {
+ name: "fence outside range",
+ content: "code\n```",
+ startIdx: 0, end: 4,
+ want: -1,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ runes := []rune(tc.content)
+ got := findNextClosingCodeBlockInRange(runes, tc.startIdx, tc.end)
+ if got != tc.want {
+ t.Errorf("findNextClosingCodeBlockInRange(%q, %d, %d) = %d, want %d",
+ tc.content, tc.startIdx, tc.end, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestSplitMessage_CodeBlockIntegrity(t *testing.T) {
+ // Focused test for the core requirement: splitting inside a code block preserves syntax highlighting
+
+ // 60 chars total approximately
+ content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```"
+ maxLen := 40
+
+ chunks := SplitMessage(content, maxLen)
+
+ if len(chunks) != 2 {
+ t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
+ }
+
+ // First chunk must end with "\n```"
+ if !strings.HasSuffix(chunks[0], "\n```") {
+ t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0])
+ }
+
+ // Second chunk must start with the header "```go"
+ if !strings.HasPrefix(chunks[1], "```go") {
+ t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1])
+ }
+
+ // First chunk should contain meaningful content
+ if len([]rune(chunks[0])) > 40 {
+ t.Errorf("First chunk exceeded maxLen: length %d runes", len([]rune(chunks[0])))
+ }
+}
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
deleted file mode 100644
index a0a1c8d0a..000000000
--- a/pkg/channels/telegram.go
+++ /dev/null
@@ -1,529 +0,0 @@
-package channels
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/url"
- "os"
- "regexp"
- "strings"
- "sync"
- "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/config"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/utils"
- "github.com/sipeed/picoclaw/pkg/voice"
-)
-
-type TelegramChannel struct {
- *BaseChannel
- bot *telego.Bot
- commands TelegramCommander
- config *config.Config
- chatIDs map[string]int64
- transcriber *voice.GroqTranscriber
- placeholders sync.Map // chatID -> messageID
- stopThinking sync.Map // chatID -> thinkingCancel
-}
-
-type thinkingCancel struct {
- fn context.CancelFunc
-}
-
-func (c *thinkingCancel) Cancel() {
- if c != nil && c.fn != nil {
- c.fn()
- }
-}
-
-func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
- var opts []telego.BotOption
- telegramCfg := cfg.Channels.Telegram
-
- if telegramCfg.Proxy != "" {
- proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
- if parseErr != nil {
- return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr)
- }
- opts = append(opts, telego.WithHTTPClient(&http.Client{
- Transport: &http.Transport{
- Proxy: http.ProxyURL(proxyURL),
- },
- }))
- } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
- // Use environment proxy if configured
- opts = append(opts, telego.WithHTTPClient(&http.Client{
- Transport: &http.Transport{
- Proxy: http.ProxyFromEnvironment,
- },
- }))
- }
-
- bot, err := telego.NewBot(telegramCfg.Token, opts...)
- if err != nil {
- return nil, fmt.Errorf("failed to create telegram bot: %w", err)
- }
-
- base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom)
-
- return &TelegramChannel{
- BaseChannel: base,
- commands: NewTelegramCommands(bot, cfg),
- bot: bot,
- config: cfg,
- chatIDs: make(map[string]int64),
- transcriber: nil,
- placeholders: sync.Map{},
- stopThinking: sync.Map{},
- }, nil
-}
-
-func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
- c.transcriber = transcriber
-}
-
-func (c *TelegramChannel) Start(ctx context.Context) error {
- logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
-
- updates, err := c.bot.UpdatesViaLongPolling(ctx, &telego.GetUpdatesParams{
- Timeout: 30,
- })
- if err != nil {
- return fmt.Errorf("failed to start long polling: %w", err)
- }
-
- bh, err := telegohandler.NewBotHandler(c.bot, updates)
- if err != nil {
- return fmt.Errorf("failed to create bot handler: %w", err)
- }
-
- 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())
-
- c.setRunning(true)
- logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
- "username": c.bot.Username(),
- })
-
- go bh.Start()
-
- go func() {
- <-ctx.Done()
- bh.Stop()
- }()
-
- return nil
-}
-
-func (c *TelegramChannel) Stop(ctx context.Context) error {
- logger.InfoC("telegram", "Stopping Telegram bot...")
- c.setRunning(false)
- return nil
-}
-
-func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return fmt.Errorf("telegram bot not running")
- }
-
- chatID, err := parseChatID(msg.ChatID)
- if err != nil {
- return fmt.Errorf("invalid chat ID: %w", err)
- }
-
- // Stop thinking animation
- if stop, ok := c.stopThinking.Load(msg.ChatID); ok {
- if cf, ok := stop.(*thinkingCancel); ok && cf != nil {
- cf.Cancel()
- }
- c.stopThinking.Delete(msg.ChatID)
- }
-
- htmlContent := markdownToTelegramHTML(msg.Content)
-
- // Try to edit placeholder
- if pID, ok := c.placeholders.Load(msg.ChatID); ok {
- c.placeholders.Delete(msg.ChatID)
- editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent)
- editMsg.ParseMode = telego.ModeHTML
-
- if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
- return nil
- }
- // Fallback to new message if edit fails
- }
-
- tgMsg := tu.Message(tu.ID(chatID), htmlContent)
- tgMsg.ParseMode = telego.ModeHTML
-
- if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
- logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
- "error": err.Error(),
- })
- tgMsg.ParseMode = ""
- _, err = c.bot.SendMessage(ctx, tgMsg)
- return err
- }
-
- return nil
-}
-
-func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
- if message == nil {
- return fmt.Errorf("message is nil")
- }
-
- user := message.From
- if user == nil {
- return fmt.Errorf("message sender (user) is nil")
- }
-
- senderID := fmt.Sprintf("%d", user.ID)
- if user.Username != "" {
- senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
- }
-
- // 检查白名单,避免为被拒绝的用户下载附件
- if !c.IsAllowed(senderID) {
- logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
- "user_id": senderID,
- })
- return nil
- }
-
- chatID := message.Chat.ID
- c.chatIDs[senderID] = chatID
-
- content := ""
- mediaPaths := []string{}
- localFiles := []string{} // 跟踪需要清理的本地文件
-
- // 确保临时文件在函数返回时被清理
- defer func() {
- for _, file := range localFiles {
- if err := os.Remove(file); err != nil {
- logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{
- "file": file,
- "error": err.Error(),
- })
- }
- }
- }()
-
- if message.Text != "" {
- content += message.Text
- }
-
- if message.Caption != "" {
- if content != "" {
- content += "\n"
- }
- content += message.Caption
- }
-
- if len(message.Photo) > 0 {
- photo := message.Photo[len(message.Photo)-1]
- photoPath := c.downloadPhoto(ctx, photo.FileID)
- if photoPath != "" {
- localFiles = append(localFiles, photoPath)
- mediaPaths = append(mediaPaths, photoPath)
- if content != "" {
- content += "\n"
- }
- content += "[image: photo]"
- }
- }
-
- if message.Voice != nil {
- voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
- if voicePath != "" {
- localFiles = append(localFiles, voicePath)
- mediaPaths = append(mediaPaths, voicePath)
-
- transcribedText := ""
- if c.transcriber != nil && c.transcriber.IsAvailable() {
- transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
- defer cancel()
-
- result, err := c.transcriber.Transcribe(transcriberCtx, voicePath)
- if err != nil {
- logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{
- "error": err.Error(),
- "path": voicePath,
- })
- transcribedText = "[voice (transcription failed)]"
- } else {
- transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
- logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{
- "text": result.Text,
- })
- }
- } else {
- transcribedText = "[voice]"
- }
-
- if content != "" {
- content += "\n"
- }
- content += transcribedText
- }
- }
-
- if message.Audio != nil {
- audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
- if audioPath != "" {
- localFiles = append(localFiles, audioPath)
- mediaPaths = append(mediaPaths, audioPath)
- if content != "" {
- content += "\n"
- }
- content += "[audio]"
- }
- }
-
- if message.Document != nil {
- docPath := c.downloadFile(ctx, message.Document.FileID, "")
- if docPath != "" {
- localFiles = append(localFiles, docPath)
- mediaPaths = append(mediaPaths, docPath)
- if content != "" {
- content += "\n"
- }
- content += "[file]"
- }
- }
-
- if content == "" {
- content = "[empty message]"
- }
-
- logger.DebugCF("telegram", "Received message", map[string]any{
- "sender_id": senderID,
- "chat_id": fmt.Sprintf("%d", chatID),
- "preview": utils.Truncate(content, 50),
- })
-
- // Thinking indicator
- err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
- if err != nil {
- logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{
- "error": err.Error(),
- })
- }
-
- // Stop any previous thinking animation
- chatIDStr := fmt.Sprintf("%d", chatID)
- if prevStop, ok := c.stopThinking.Load(chatIDStr); ok {
- if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil {
- cf.Cancel()
- }
- }
-
- // Create cancel function for thinking state
- _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
- c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel})
-
- pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭"))
- if err == nil {
- pID := pMsg.MessageID
- c.placeholders.Store(chatIDStr, pID)
- }
-
- peerKind := "direct"
- peerID := fmt.Sprintf("%d", user.ID)
- if message.Chat.Type != "private" {
- peerKind = "group"
- peerID = fmt.Sprintf("%d", chatID)
- }
-
- metadata := map[string]string{
- "message_id": fmt.Sprintf("%d", message.MessageID),
- "user_id": fmt.Sprintf("%d", user.ID),
- "username": user.Username,
- "first_name": user.FirstName,
- "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
- "peer_kind": peerKind,
- "peer_id": peerID,
- }
-
- c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
- return nil
-}
-
-func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
- file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
- if err != nil {
- logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{
- "error": err.Error(),
- })
- return ""
- }
-
- return c.downloadFileWithInfo(file, ".jpg")
-}
-
-func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string {
- if file.FilePath == "" {
- return ""
- }
-
- url := c.bot.FileDownloadURL(file.FilePath)
- logger.DebugCF("telegram", "File URL", map[string]any{"url": url})
-
- // Use FilePath as filename for better identification
- filename := file.FilePath + ext
- return utils.DownloadFile(url, filename, utils.DownloadOptions{
- LoggerPrefix: "telegram",
- })
-}
-
-func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
- file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
- if err != nil {
- logger.ErrorCF("telegram", "Failed to get file", map[string]any{
- "error": err.Error(),
- })
- return ""
- }
-
- return c.downloadFileWithInfo(file, ext)
-}
-
-func parseChatID(chatIDStr string) (int64, error) {
- var id int64
- _, err := fmt.Sscanf(chatIDStr, "%d", &id)
- return id, err
-}
-
-func markdownToTelegramHTML(text string) string {
- if text == "" {
- return ""
- }
-
- codeBlocks := extractCodeBlocks(text)
- text = codeBlocks.text
-
- inlineCodes := extractInlineCodes(text)
- text = inlineCodes.text
-
- text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1")
-
- text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1")
-
- text = escapeHTML(text)
-
- text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `$1`)
-
- text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "$1")
-
- text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "$1")
-
- reItalic := regexp.MustCompile(`_([^_]+)_`)
- text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
- match := reItalic.FindStringSubmatch(s)
- if len(match) < 2 {
- return s
- }
- return "" + match[1] + ""
- })
-
- text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "$1")
-
- text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ")
-
- for i, code := range inlineCodes.codes {
- escaped := escapeHTML(code)
- text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped))
- }
-
- for i, code := range codeBlocks.codes {
- escaped := escapeHTML(code)
- text = strings.ReplaceAll(
- text,
- fmt.Sprintf("\x00CB%d\x00", i),
- fmt.Sprintf("%s
", escaped),
- )
- }
-
- return text
-}
-
-type codeBlockMatch struct {
- text string
- codes []string
-}
-
-func extractCodeBlocks(text string) codeBlockMatch {
- re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
- matches := re.FindAllStringSubmatch(text, -1)
-
- codes := make([]string, 0, len(matches))
- for _, match := range matches {
- codes = append(codes, match[1])
- }
-
- i := 0
- text = re.ReplaceAllStringFunc(text, func(m string) string {
- placeholder := fmt.Sprintf("\x00CB%d\x00", i)
- i++
- return placeholder
- })
-
- return codeBlockMatch{text: text, codes: codes}
-}
-
-type inlineCodeMatch struct {
- text string
- codes []string
-}
-
-func extractInlineCodes(text string) inlineCodeMatch {
- re := regexp.MustCompile("`([^`]+)`")
- matches := re.FindAllStringSubmatch(text, -1)
-
- codes := make([]string, 0, len(matches))
- for _, match := range matches {
- codes = append(codes, match[1])
- }
-
- i := 0
- text = re.ReplaceAllStringFunc(text, func(m string) string {
- placeholder := fmt.Sprintf("\x00IC%d\x00", i)
- i++
- return placeholder
- })
-
- return inlineCodeMatch{text: text, codes: codes}
-}
-
-func escapeHTML(text string) string {
- text = strings.ReplaceAll(text, "&", "&")
- text = strings.ReplaceAll(text, "<", "<")
- text = strings.ReplaceAll(text, ">", ">")
- return text
-}
diff --git a/pkg/channels/telegram/init.go b/pkg/channels/telegram/init.go
new file mode 100644
index 000000000..ac87bb805
--- /dev/null
+++ b/pkg/channels/telegram/init.go
@@ -0,0 +1,13 @@
+package telegram
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewTelegramChannel(cfg, b)
+ })
+}
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
new file mode 100644
index 000000000..a11cf53b8
--- /dev/null
+++ b/pkg/channels/telegram/telegram.go
@@ -0,0 +1,709 @@
+package telegram
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "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/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+var (
+ reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
+ reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
+ reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
+ reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
+ reBoldUnder = regexp.MustCompile(`__(.+?)__`)
+ reItalic = regexp.MustCompile(`_([^_]+)_`)
+ reStrike = regexp.MustCompile(`~~(.+?)~~`)
+ reListItem = regexp.MustCompile(`^[-*]\s+`)
+ reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
+ reInlineCode = regexp.MustCompile("`([^`]+)`")
+)
+
+type TelegramChannel struct {
+ *channels.BaseChannel
+ bot *telego.Bot
+ bh *telegohandler.BotHandler
+ commands TelegramCommander
+ config *config.Config
+ chatIDs map[string]int64
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
+ var opts []telego.BotOption
+ telegramCfg := cfg.Channels.Telegram
+
+ if telegramCfg.Proxy != "" {
+ proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
+ if parseErr != nil {
+ return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr)
+ }
+ opts = append(opts, telego.WithHTTPClient(&http.Client{
+ Transport: &http.Transport{
+ Proxy: http.ProxyURL(proxyURL),
+ },
+ }))
+ } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
+ // Use environment proxy if configured
+ opts = append(opts, telego.WithHTTPClient(&http.Client{
+ Transport: &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ },
+ }))
+ }
+
+ bot, err := telego.NewBot(telegramCfg.Token, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create telegram bot: %w", err)
+ }
+
+ base := channels.NewBaseChannel(
+ "telegram",
+ telegramCfg,
+ bus,
+ telegramCfg.AllowFrom,
+ channels.WithMaxMessageLength(4096),
+ channels.WithGroupTrigger(telegramCfg.GroupTrigger),
+ channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
+ )
+
+ return &TelegramChannel{
+ BaseChannel: base,
+ commands: NewTelegramCommands(bot, cfg),
+ bot: bot,
+ config: cfg,
+ chatIDs: make(map[string]int64),
+ }, nil
+}
+
+func (c *TelegramChannel) Start(ctx context.Context) error {
+ logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
+
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
+ Timeout: 30,
+ })
+ if err != nil {
+ c.cancel()
+ return fmt.Errorf("failed to start long polling: %w", err)
+ }
+
+ bh, err := telegohandler.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())
+
+ c.SetRunning(true)
+ logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
+ "username": c.bot.Username(),
+ })
+
+ go bh.Start()
+
+ return nil
+}
+
+func (c *TelegramChannel) Stop(ctx context.Context) error {
+ logger.InfoC("telegram", "Stopping Telegram bot...")
+ c.SetRunning(false)
+
+ // Stop the bot handler
+ if c.bh != nil {
+ c.bh.Stop()
+ }
+
+ // Cancel our context (stops long polling)
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ return nil
+}
+
+func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ chatID, err := parseChatID(msg.ChatID)
+ if err != nil {
+ return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
+ }
+
+ htmlContent := markdownToTelegramHTML(msg.Content)
+
+ // Typing/placeholder handled by Manager.preSend — just send the message
+ tgMsg := tu.Message(tu.ID(chatID), htmlContent)
+ tgMsg.ParseMode = telego.ModeHTML
+
+ if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
+ logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
+ "error": err.Error(),
+ })
+ tgMsg.ParseMode = ""
+ if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
+ return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
+ }
+ }
+
+ return nil
+}
+
+// StartTyping implements channels.TypingCapable.
+// It sends ChatAction(typing) immediately and then repeats every 4 seconds
+// (Telegram's typing indicator expires after ~5s) in a background goroutine.
+// The returned stop function is idempotent and cancels the goroutine.
+func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
+ cid, err := parseChatID(chatID)
+ if err != nil {
+ return func() {}, err
+ }
+
+ // Send the first typing action immediately
+ _ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
+
+ typingCtx, cancel := context.WithCancel(ctx)
+ go func() {
+ ticker := time.NewTicker(4 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-typingCtx.Done():
+ return
+ case <-ticker.C:
+ _ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
+ }
+ }
+ }()
+
+ return cancel, nil
+}
+
+// EditMessage implements channels.MessageEditor.
+func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
+ cid, err := parseChatID(chatID)
+ if err != nil {
+ return err
+ }
+ mid, err := strconv.Atoi(messageID)
+ if err != nil {
+ return err
+ }
+ htmlContent := markdownToTelegramHTML(content)
+ editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent)
+ editMsg.ParseMode = telego.ModeHTML
+ _, err = c.bot.EditMessageText(ctx, editMsg)
+ return err
+}
+
+// SendPlaceholder implements channels.PlaceholderCapable.
+// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
+// edited to the actual response via EditMessage (channels.MessageEditor).
+func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
+ phCfg := c.config.Channels.Telegram.Placeholder
+ if !phCfg.Enabled {
+ return "", nil
+ }
+
+ text := phCfg.Text
+ if text == "" {
+ text = "Thinking... 💭"
+ }
+
+ cid, err := parseChatID(chatID)
+ if err != nil {
+ return "", err
+ }
+
+ pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text))
+ if err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("%d", pMsg.MessageID), nil
+}
+
+// SendMedia implements the channels.MediaSender interface.
+func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ chatID, err := parseChatID(msg.ChatID)
+ if err != nil {
+ return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
+ }
+
+ store := c.GetMediaStore()
+ if store == nil {
+ return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
+ }
+
+ for _, part := range msg.Parts {
+ localPath, err := store.Resolve(part.Ref)
+ if err != nil {
+ logger.ErrorCF("telegram", "Failed to resolve media ref", map[string]any{
+ "ref": part.Ref,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ file, err := os.Open(localPath)
+ if err != nil {
+ logger.ErrorCF("telegram", "Failed to open media file", map[string]any{
+ "path": localPath,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ switch part.Type {
+ case "image":
+ params := &telego.SendPhotoParams{
+ ChatID: tu.ID(chatID),
+ Photo: telego.InputFile{File: file},
+ Caption: part.Caption,
+ }
+ _, err = c.bot.SendPhoto(ctx, params)
+ case "audio":
+ params := &telego.SendAudioParams{
+ ChatID: tu.ID(chatID),
+ Audio: telego.InputFile{File: file},
+ Caption: part.Caption,
+ }
+ _, err = c.bot.SendAudio(ctx, params)
+ case "video":
+ params := &telego.SendVideoParams{
+ ChatID: tu.ID(chatID),
+ Video: telego.InputFile{File: file},
+ Caption: part.Caption,
+ }
+ _, err = c.bot.SendVideo(ctx, params)
+ default: // "file" or unknown types
+ params := &telego.SendDocumentParams{
+ ChatID: tu.ID(chatID),
+ Document: telego.InputFile{File: file},
+ Caption: part.Caption,
+ }
+ _, err = c.bot.SendDocument(ctx, params)
+ }
+
+ file.Close()
+
+ if err != nil {
+ logger.ErrorCF("telegram", "Failed to send media", map[string]any{
+ "type": part.Type,
+ "error": err.Error(),
+ })
+ return fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
+ }
+ }
+
+ return nil
+}
+
+func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
+ if message == nil {
+ return fmt.Errorf("message is nil")
+ }
+
+ user := message.From
+ if user == nil {
+ return fmt.Errorf("message sender (user) is nil")
+ }
+
+ platformID := fmt.Sprintf("%d", user.ID)
+ sender := bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: platformID,
+ CanonicalID: identity.BuildCanonicalID("telegram", platformID),
+ Username: user.Username,
+ DisplayName: user.FirstName,
+ }
+
+ // check allowlist to avoid downloading attachments for rejected users
+ if !c.IsAllowedSender(sender) {
+ logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
+ "user_id": platformID,
+ })
+ return nil
+ }
+
+ chatID := message.Chat.ID
+ c.chatIDs[platformID] = chatID
+
+ content := ""
+ mediaPaths := []string{}
+
+ chatIDStr := fmt.Sprintf("%d", chatID)
+ messageIDStr := fmt.Sprintf("%d", message.MessageID)
+ scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
+
+ // Helper to register a local file with the media store
+ storeMedia := func(localPath, filename string) string {
+ if store := c.GetMediaStore(); store != nil {
+ ref, err := store.Store(localPath, media.MediaMeta{
+ Filename: filename,
+ Source: "telegram",
+ }, scope)
+ if err == nil {
+ return ref
+ }
+ }
+ return localPath // fallback: use raw path
+ }
+
+ if message.Text != "" {
+ content += message.Text
+ }
+
+ if message.Caption != "" {
+ if content != "" {
+ content += "\n"
+ }
+ content += message.Caption
+ }
+
+ if len(message.Photo) > 0 {
+ photo := message.Photo[len(message.Photo)-1]
+ photoPath := c.downloadPhoto(ctx, photo.FileID)
+ if photoPath != "" {
+ mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg"))
+ if content != "" {
+ content += "\n"
+ }
+ content += "[image: photo]"
+ }
+ }
+
+ if message.Voice != nil {
+ voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
+ if voicePath != "" {
+ mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
+
+ if content != "" {
+ content += "\n"
+ }
+ content += "[voice]"
+ }
+ }
+
+ if message.Audio != nil {
+ audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
+ if audioPath != "" {
+ mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3"))
+ if content != "" {
+ content += "\n"
+ }
+ content += "[audio]"
+ }
+ }
+
+ if message.Document != nil {
+ docPath := c.downloadFile(ctx, message.Document.FileID, "")
+ if docPath != "" {
+ mediaPaths = append(mediaPaths, storeMedia(docPath, "document"))
+ if content != "" {
+ content += "\n"
+ }
+ content += "[file]"
+ }
+ }
+
+ if content == "" {
+ content = "[empty message]"
+ }
+
+ // In group chats, apply unified group trigger filtering
+ if message.Chat.Type != "private" {
+ isMentioned := c.isBotMentioned(message)
+ if isMentioned {
+ content = c.stripBotMention(content)
+ }
+ respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
+ if !respond {
+ return nil
+ }
+ content = cleaned
+ }
+
+ logger.DebugCF("telegram", "Received message", map[string]any{
+ "sender_id": sender.CanonicalID,
+ "chat_id": fmt.Sprintf("%d", chatID),
+ "preview": utils.Truncate(content, 50),
+ })
+
+ // Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable
+
+ peerKind := "direct"
+ peerID := fmt.Sprintf("%d", user.ID)
+ if message.Chat.Type != "private" {
+ peerKind = "group"
+ peerID = fmt.Sprintf("%d", chatID)
+ }
+
+ peer := bus.Peer{Kind: peerKind, ID: peerID}
+ messageID := fmt.Sprintf("%d", message.MessageID)
+
+ metadata := map[string]string{
+ "user_id": fmt.Sprintf("%d", user.ID),
+ "username": user.Username,
+ "first_name": user.FirstName,
+ "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
+ }
+
+ c.HandleMessage(c.ctx,
+ peer,
+ messageID,
+ platformID,
+ fmt.Sprintf("%d", chatID),
+ content,
+ mediaPaths,
+ metadata,
+ sender,
+ )
+ return nil
+}
+
+func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
+ file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
+ if err != nil {
+ logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{
+ "error": err.Error(),
+ })
+ return ""
+ }
+
+ return c.downloadFileWithInfo(file, ".jpg")
+}
+
+func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string {
+ if file.FilePath == "" {
+ return ""
+ }
+
+ url := c.bot.FileDownloadURL(file.FilePath)
+ logger.DebugCF("telegram", "File URL", map[string]any{"url": url})
+
+ // Use FilePath as filename for better identification
+ filename := file.FilePath + ext
+ return utils.DownloadFile(url, filename, utils.DownloadOptions{
+ LoggerPrefix: "telegram",
+ })
+}
+
+func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
+ file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
+ if err != nil {
+ logger.ErrorCF("telegram", "Failed to get file", map[string]any{
+ "error": err.Error(),
+ })
+ return ""
+ }
+
+ return c.downloadFileWithInfo(file, ext)
+}
+
+func parseChatID(chatIDStr string) (int64, error) {
+ var id int64
+ _, err := fmt.Sscanf(chatIDStr, "%d", &id)
+ return id, err
+}
+
+func markdownToTelegramHTML(text string) string {
+ if text == "" {
+ return ""
+ }
+
+ codeBlocks := extractCodeBlocks(text)
+ text = codeBlocks.text
+
+ inlineCodes := extractInlineCodes(text)
+ text = inlineCodes.text
+
+ text = reHeading.ReplaceAllString(text, "$1")
+
+ text = reBlockquote.ReplaceAllString(text, "$1")
+
+ text = escapeHTML(text)
+
+ text = reLink.ReplaceAllString(text, `$1`)
+
+ text = reBoldStar.ReplaceAllString(text, "$1")
+
+ text = reBoldUnder.ReplaceAllString(text, "$1")
+
+ text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
+ match := reItalic.FindStringSubmatch(s)
+ if len(match) < 2 {
+ return s
+ }
+ return "" + match[1] + ""
+ })
+
+ text = reStrike.ReplaceAllString(text, "$1")
+
+ text = reListItem.ReplaceAllString(text, "• ")
+
+ for i, code := range inlineCodes.codes {
+ escaped := escapeHTML(code)
+ text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped))
+ }
+
+ for i, code := range codeBlocks.codes {
+ escaped := escapeHTML(code)
+ text = strings.ReplaceAll(
+ text,
+ fmt.Sprintf("\x00CB%d\x00", i),
+ fmt.Sprintf("%s
", escaped),
+ )
+ }
+
+ return text
+}
+
+type codeBlockMatch struct {
+ text string
+ codes []string
+}
+
+func extractCodeBlocks(text string) codeBlockMatch {
+ matches := reCodeBlock.FindAllStringSubmatch(text, -1)
+
+ codes := make([]string, 0, len(matches))
+ for _, match := range matches {
+ codes = append(codes, match[1])
+ }
+
+ i := 0
+ text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
+ placeholder := fmt.Sprintf("\x00CB%d\x00", i)
+ i++
+ return placeholder
+ })
+
+ return codeBlockMatch{text: text, codes: codes}
+}
+
+type inlineCodeMatch struct {
+ text string
+ codes []string
+}
+
+func extractInlineCodes(text string) inlineCodeMatch {
+ matches := reInlineCode.FindAllStringSubmatch(text, -1)
+
+ codes := make([]string, 0, len(matches))
+ for _, match := range matches {
+ codes = append(codes, match[1])
+ }
+
+ i := 0
+ text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
+ placeholder := fmt.Sprintf("\x00IC%d\x00", i)
+ i++
+ return placeholder
+ })
+
+ return inlineCodeMatch{text: text, codes: codes}
+}
+
+func escapeHTML(text string) string {
+ text = strings.ReplaceAll(text, "&", "&")
+ text = strings.ReplaceAll(text, "<", "<")
+ text = strings.ReplaceAll(text, ">", ">")
+ return text
+}
+
+// isBotMentioned checks if the bot is mentioned in the message via entities.
+func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
+ botUsername := c.bot.Username()
+ if botUsername == "" {
+ return false
+ }
+
+ entities := message.Entities
+ if entities == nil {
+ entities = message.CaptionEntities
+ }
+
+ for _, entity := range entities {
+ if entity.Type == "mention" {
+ // Extract the mention text from the message
+ text := message.Text
+ if text == "" {
+ text = message.Caption
+ }
+ runes := []rune(text)
+ end := entity.Offset + entity.Length
+ if end <= len(runes) {
+ mention := string(runes[entity.Offset:end])
+ if strings.EqualFold(mention, "@"+botUsername) {
+ return true
+ }
+ }
+ }
+ if entity.Type == "text_mention" && entity.User != nil {
+ if entity.User.Username == botUsername {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// stripBotMention removes the @bot mention from the content.
+func (c *TelegramChannel) stripBotMention(content string) string {
+ botUsername := c.bot.Username()
+ if botUsername == "" {
+ return content
+ }
+ // Case-insensitive replacement
+ re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername))
+ content = re.ReplaceAllString(content, "")
+ return strings.TrimSpace(content)
+}
diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram/telegram_commands.go
similarity index 96%
rename from pkg/channels/telegram_commands.go
rename to pkg/channels/telegram/telegram_commands.go
index a084b641b..496fc5e4f 100644
--- a/pkg/channels/telegram_commands.go
+++ b/pkg/channels/telegram/telegram_commands.go
@@ -1,4 +1,4 @@
-package channels
+package telegram
import (
"context"
@@ -81,7 +81,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
switch args {
case "model":
response = fmt.Sprintf("Current Model: %s (Provider: %s)",
- c.config.Agents.Defaults.Model,
+ c.config.Agents.Defaults.GetModelName(),
c.config.Agents.Defaults.Provider)
case "channel":
response = "Current Channel: telegram"
@@ -119,8 +119,8 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
if provider == "" {
provider = "configured default"
}
- response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
- c.config.Agents.Defaults.Model, provider)
+ 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
diff --git a/pkg/channels/webhook.go b/pkg/channels/webhook.go
new file mode 100644
index 000000000..3cf27baf6
--- /dev/null
+++ b/pkg/channels/webhook.go
@@ -0,0 +1,20 @@
+package channels
+
+import "net/http"
+
+// WebhookHandler is an optional interface for channels that receive messages
+// via HTTP webhooks. Manager discovers channels implementing this interface
+// and registers them on the shared HTTP server.
+type WebhookHandler interface {
+ // WebhookPath returns the path to mount this handler on the shared server.
+ // Examples: "/webhook/line", "/webhook/wecom"
+ WebhookPath() string
+ http.Handler // ServeHTTP(w http.ResponseWriter, r *http.Request)
+}
+
+// HealthChecker is an optional interface for channels that expose
+// a health check endpoint on the shared HTTP server.
+type HealthChecker interface {
+ HealthPath() string
+ HealthHandler(w http.ResponseWriter, r *http.Request)
+}
diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom/app.go
similarity index 71%
rename from pkg/channels/wecom_app.go
rename to pkg/channels/wecom/app.go
index 715c48707..42a74e8c9 100644
--- a/pkg/channels/wecom_app.go
+++ b/pkg/channels/wecom/app.go
@@ -1,8 +1,4 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// WeCom App (企业微信自建应用) channel implementation
-// Supports receiving messages via webhook callback and sending messages proactively
-
-package channels
+package wecom
import (
"bytes"
@@ -11,14 +7,19 @@ import (
"encoding/xml"
"fmt"
"io"
+ "mime/multipart"
"net/http"
"net/url"
+ "os"
+ "path/filepath"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
@@ -29,9 +30,8 @@ const (
// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)
type WeComAppChannel struct {
- *BaseChannel
+ *channels.BaseChannel
config config.WeComAppConfig
- server *http.Server
accessToken string
tokenExpiry time.Time
tokenMu sync.RWMutex
@@ -123,7 +123,11 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
}
- base := NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom)
+ base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom,
+ channels.WithMaxMessageLength(2048),
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
return &WeComAppChannel{
BaseChannel: base,
@@ -137,7 +141,7 @@ func (c *WeComAppChannel) Name() string {
return "wecom_app"
}
-// Start initializes the WeCom App channel with HTTP webhook server
+// Start initializes the WeCom App channel
func (c *WeComAppChannel) Start(ctx context.Context) error {
logger.InfoC("wecom_app", "Starting WeCom App channel...")
@@ -153,37 +157,8 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
// Start token refresh goroutine
go c.tokenRefreshLoop()
- // Setup HTTP server for webhook
- mux := http.NewServeMux()
- webhookPath := c.config.WebhookPath
- if webhookPath == "" {
- webhookPath = "/webhook/wecom-app"
- }
- mux.HandleFunc(webhookPath, c.handleWebhook)
-
- // Health check endpoint
- mux.HandleFunc("/health/wecom-app", c.handleHealth)
-
- addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
- c.server = &http.Server{
- Addr: addr,
- Handler: mux,
- }
-
- c.setRunning(true)
- logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{
- "address": addr,
- "path": webhookPath,
- })
-
- // Start server in goroutine
- go func() {
- if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{
- "error": err.Error(),
- })
- }
- }()
+ c.SetRunning(true)
+ logger.InfoC("wecom_app", "WeCom App channel started")
return nil
}
@@ -196,13 +171,7 @@ func (c *WeComAppChannel) Stop(ctx context.Context) error {
c.cancel()
}
- if c.server != nil {
- shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
- c.server.Shutdown(shutdownCtx)
- }
-
- c.setRunning(false)
+ c.SetRunning(false)
logger.InfoC("wecom_app", "WeCom App channel stopped")
return nil
}
@@ -210,7 +179,7 @@ func (c *WeComAppChannel) Stop(ctx context.Context) error {
// Send sends a message to WeCom user proactively using access token
func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
- return fmt.Errorf("wecom_app channel not running")
+ return channels.ErrNotRunning
}
accessToken := c.getAccessToken()
@@ -226,6 +195,220 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)
}
+// SendMedia implements the channels.MediaSender interface.
+func (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ accessToken := c.getAccessToken()
+ if accessToken == "" {
+ return fmt.Errorf("no valid access token available: %w", channels.ErrTemporary)
+ }
+
+ store := c.GetMediaStore()
+ if store == nil {
+ return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
+ }
+
+ for _, part := range msg.Parts {
+ localPath, err := store.Resolve(part.Ref)
+ if err != nil {
+ logger.ErrorCF("wecom_app", "Failed to resolve media ref", map[string]any{
+ "ref": part.Ref,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ // Map part type to WeCom media type
+ var mediaType string
+ switch part.Type {
+ case "image":
+ mediaType = "image"
+ case "audio":
+ mediaType = "voice"
+ case "video":
+ mediaType = "video"
+ default:
+ mediaType = "file"
+ }
+
+ // Upload media to get media_id
+ mediaID, err := c.uploadMedia(ctx, accessToken, mediaType, localPath)
+ if err != nil {
+ logger.ErrorCF("wecom_app", "Failed to upload media", map[string]any{
+ "type": mediaType,
+ "error": err.Error(),
+ })
+ // Fallback: send caption as text
+ if part.Caption != "" {
+ _ = c.sendTextMessage(ctx, accessToken, msg.ChatID, part.Caption)
+ }
+ continue
+ }
+
+ // Send media message using the media_id
+ if mediaType == "image" {
+ err = c.sendImageMessage(ctx, accessToken, msg.ChatID, mediaID)
+ } else {
+ // For non-image types, send as text fallback with caption
+ caption := part.Caption
+ if caption == "" {
+ caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
+ }
+ err = c.sendTextMessage(ctx, accessToken, msg.ChatID, caption)
+ }
+
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// uploadMedia uploads a local file to WeCom temporary media storage.
+func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaType, localPath string) (string, error) {
+ apiURL := fmt.Sprintf("%s/cgi-bin/media/upload?access_token=%s&type=%s",
+ wecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaType))
+
+ file, err := os.Open(localPath)
+ if err != nil {
+ return "", fmt.Errorf("failed to open file: %w", err)
+ }
+ defer file.Close()
+
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+
+ filename := filepath.Base(localPath)
+ formFile, err := writer.CreateFormFile("media", filename)
+ if err != nil {
+ return "", fmt.Errorf("failed to create form file: %w", err)
+ }
+
+ if _, err = io.Copy(formFile, file); err != nil {
+ return "", fmt.Errorf("failed to copy file content: %w", err)
+ }
+ writer.Close()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body)
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", channels.ClassifyNetError(err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ respBody, _ := io.ReadAll(resp.Body)
+ return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody)))
+ }
+
+ var result struct {
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+ MediaID string `json:"media_id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", fmt.Errorf("failed to parse upload response: %w", err)
+ }
+
+ if result.ErrCode != 0 {
+ return "", fmt.Errorf("upload API error: %s (code: %d)", result.ErrMsg, result.ErrCode)
+ }
+
+ return result.MediaID, nil
+}
+
+// sendImageMessage sends an image message using a media_id.
+func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) 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)
+ if err != nil {
+ return fmt.Errorf("failed to marshal message: %w", err)
+ }
+
+ timeout := c.config.ReplyTimeout
+ if timeout <= 0 {
+ timeout = 5
+ }
+
+ reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return channels.ClassifyNetError(err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ respBody, _ := io.ReadAll(resp.Body)
+ return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody)))
+ }
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read response: %w", err)
+ }
+
+ var sendResp WeComSendMessageResponse
+ if err := json.Unmarshal(respBody, &sendResp); err != nil {
+ return fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ if sendResp.ErrCode != 0 {
+ return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
+ }
+
+ return nil
+}
+
+// WebhookPath returns the path for registering on the shared HTTP server.
+func (c *WeComAppChannel) WebhookPath() string {
+ if c.config.WebhookPath != "" {
+ return c.config.WebhookPath
+ }
+ return "/webhook/wecom-app"
+}
+
+// ServeHTTP implements http.Handler for the shared HTTP server.
+func (c *WeComAppChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ c.handleWebhook(w, r)
+}
+
+// HealthPath returns the health check endpoint path.
+func (c *WeComAppChannel) HealthPath() string {
+ return "/health/wecom-app"
+}
+
+// HealthHandler handles health check requests.
+func (c *WeComAppChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
+ c.handleHealth(w, r)
+}
+
// handleWebhook handles incoming webhook requests from WeCom
func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -279,7 +462,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
}
// Verify signature
- if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
+ if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
"token": c.config.Token,
"msg_signature": msgSignature,
@@ -298,7 +481,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
"encoding_aes_key": c.config.EncodingAESKey,
"corp_id": c.config.CorpID,
})
- decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
+ decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
"error": err.Error(),
@@ -357,7 +540,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
// Verify signature
- if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
+ if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
logger.WarnC("wecom_app", "Message signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
@@ -365,7 +548,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
// Decrypt message with CorpID verification
// For WeCom App (自建应用), receiveid should be corp_id
- decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
+ decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
"error": err.Error(),
@@ -428,6 +611,9 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
// Build metadata
// WeCom App only supports direct messages (private chat)
+ peer := bus.Peer{Kind: "direct", ID: senderID}
+ messageID := fmt.Sprintf("%d", msg.MsgId)
+
metadata := map[string]string{
"msg_type": msg.MsgType,
"msg_id": fmt.Sprintf("%d", msg.MsgId),
@@ -435,8 +621,6 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
"platform": "wecom_app",
"media_id": msg.MediaId,
"create_time": fmt.Sprintf("%d", msg.CreateTime),
- "peer_kind": "direct",
- "peer_id": senderID,
}
content := msg.Content
@@ -447,8 +631,15 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
"preview": utils.Truncate(content, 50),
})
+ // Build sender info
+ appSender := bus.SenderInfo{
+ Platform: "wecom",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("wecom", senderID),
+ }
+
// Handle the message through the base channel
- c.HandleMessage(senderID, chatID, content, nil, metadata)
+ c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender)
}
// tokenRefreshLoop periodically refreshes the access token
@@ -550,65 +741,15 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
resp, err := client.Do(req)
if err != nil {
- return fmt.Errorf("failed to send message: %w", err)
+ return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return fmt.Errorf("failed to read response: %w", err)
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(body)))
}
- 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
-}
-
-// sendMarkdownMessage sends a markdown message to a user
-func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, userID, content string) error {
- apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
-
- msg := WeComMarkdownMessage{
- ToUser: userID,
- MsgType: "markdown",
- AgentID: c.config.AgentID,
- }
- msg.Markdown.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")
-
- client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
- resp, err := client.Do(req)
- if err != nil {
- return fmt.Errorf("failed to send message: %w", err)
- }
- defer resp.Body.Close()
-
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom/app_test.go
similarity index 95%
rename from pkg/channels/wecom_app_test.go
rename to pkg/channels/wecom/app_test.go
index abf15c52b..5420949de 100644
--- a/pkg/channels/wecom_app_test.go
+++ b/pkg/channels/wecom/app_test.go
@@ -1,7 +1,4 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// WeCom App (企业微信自建应用) channel tests
-
-package channels
+package wecom
import (
"bytes"
@@ -197,7 +194,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
msgEncrypt := "test_message"
expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt)
- if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
+ if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
t.Error("valid signature should pass verification")
}
})
@@ -207,7 +204,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
nonce := "test_nonce"
msgEncrypt := "test_message"
- if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
+ if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification")
}
})
@@ -221,7 +218,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
}
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
- if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
+ if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true")
}
})
@@ -243,7 +240,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
plainText := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
- result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
+ result, err := decryptMessage(encoded, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -268,7 +265,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
t.Fatalf("failed to encrypt test message: %v", err)
}
- result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
+ result, err := decryptMessage(encrypted, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -286,7 +283,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComAppChannel(cfg, msgBus)
- _, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
+ _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid base64, got nil")
}
@@ -301,7 +298,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComAppChannel(cfg, msgBus)
- _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
+ _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid AES key, got nil")
}
@@ -319,7 +316,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
// Encrypt a very short message that results in ciphertext less than block size
shortData := make([]byte, 8)
- _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)
+ _, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for short ciphertext, got nil")
}
@@ -361,7 +358,7 @@ func TestWeComAppPKCS7Unpad(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result, err := pkcs7UnpadWeCom(tt.input)
+ result, err := pkcs7Unpad(tt.input)
if tt.expected == nil {
// This case should return an error
if err == nil {
@@ -852,6 +849,28 @@ func TestWeComAppMessageStructures(t *testing.T) {
}
})
+ t.Run("WeComImageMessage structure", func(t *testing.T) {
+ msg := WeComImageMessage{
+ ToUser: "user123",
+ MsgType: "image",
+ AgentID: 1000002,
+ }
+ msg.Image.MediaID = "media_123456"
+
+ if msg.Image.MediaID != "media_123456" {
+ t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456")
+ }
+ if msg.ToUser != "user123" {
+ t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123")
+ }
+ if msg.MsgType != "image" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "image")
+ }
+ if msg.AgentID != 1000002 {
+ t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002)
+ }
+ })
+
t.Run("WeComAccessTokenResponse structure", func(t *testing.T) {
jsonData := `{
"errcode": 0,
diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom/bot.go
similarity index 66%
rename from pkg/channels/wecom.go
rename to pkg/channels/wecom/bot.go
index f8daf89de..4c576b84b 100644
--- a/pkg/channels/wecom.go
+++ b/pkg/channels/wecom/bot.go
@@ -1,29 +1,21 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// WeCom Bot (企业微信智能机器人) channel implementation
-// Uses webhook callback mode for receiving messages and webhook API for sending replies
-
-package channels
+package wecom
import (
"bytes"
"context"
- "crypto/aes"
- "crypto/cipher"
- "crypto/sha1"
- "encoding/base64"
- "encoding/binary"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
- "sort"
"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"
)
@@ -31,9 +23,8 @@ import (
// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人)
// Uses webhook callback mode - simpler than WeCom App but only supports passive replies
type WeComBotChannel struct {
- *BaseChannel
+ *channels.BaseChannel
config config.WeComConfig
- server *http.Server
ctx context.Context
cancel context.CancelFunc
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
@@ -96,7 +87,11 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
return nil, fmt.Errorf("wecom token and webhook_url are required")
}
- base := NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom)
+ base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom,
+ channels.WithMaxMessageLength(2048),
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
return &WeComBotChannel{
BaseChannel: base,
@@ -110,43 +105,14 @@ func (c *WeComBotChannel) Name() string {
return "wecom"
}
-// Start initializes the WeCom Bot channel with HTTP webhook server
+// Start initializes the WeCom Bot channel
func (c *WeComBotChannel) Start(ctx context.Context) error {
logger.InfoC("wecom", "Starting WeCom Bot channel...")
c.ctx, c.cancel = context.WithCancel(ctx)
- // Setup HTTP server for webhook
- mux := http.NewServeMux()
- webhookPath := c.config.WebhookPath
- if webhookPath == "" {
- webhookPath = "/webhook/wecom"
- }
- mux.HandleFunc(webhookPath, c.handleWebhook)
-
- // Health check endpoint
- mux.HandleFunc("/health/wecom", c.handleHealth)
-
- addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
- c.server = &http.Server{
- Addr: addr,
- Handler: mux,
- }
-
- c.setRunning(true)
- logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{
- "address": addr,
- "path": webhookPath,
- })
-
- // Start server in goroutine
- go func() {
- if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("wecom", "HTTP server error", map[string]any{
- "error": err.Error(),
- })
- }
- }()
+ c.SetRunning(true)
+ logger.InfoC("wecom", "WeCom Bot channel started")
return nil
}
@@ -159,13 +125,7 @@ func (c *WeComBotChannel) Stop(ctx context.Context) error {
c.cancel()
}
- if c.server != nil {
- shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
- c.server.Shutdown(shutdownCtx)
- }
-
- c.setRunning(false)
+ c.SetRunning(false)
logger.InfoC("wecom", "WeCom Bot channel stopped")
return nil
}
@@ -175,7 +135,7 @@ func (c *WeComBotChannel) Stop(ctx context.Context) error {
// For delayed responses, we use the webhook URL
func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
- return fmt.Errorf("wecom channel not running")
+ return channels.ErrNotRunning
}
logger.DebugCF("wecom", "Sending message via webhook", map[string]any{
@@ -186,6 +146,29 @@ func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return c.sendWebhookReply(ctx, msg.ChatID, msg.Content)
}
+// WebhookPath returns the path for registering on the shared HTTP server.
+func (c *WeComBotChannel) WebhookPath() string {
+ if c.config.WebhookPath != "" {
+ return c.config.WebhookPath
+ }
+ return "/webhook/wecom"
+}
+
+// ServeHTTP implements http.Handler for the shared HTTP server.
+func (c *WeComBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ c.handleWebhook(w, r)
+}
+
+// HealthPath returns the health check endpoint path.
+func (c *WeComBotChannel) HealthPath() string {
+ return "/health/wecom"
+}
+
+// HealthHandler handles health check requests.
+func (c *WeComBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
+ c.handleHealth(w, r)
+}
+
// handleWebhook handles incoming webhook requests from WeCom
func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -219,7 +202,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
}
// Verify signature
- if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
+ if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
logger.WarnC("wecom", "Signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
@@ -228,7 +211,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
// Decrypt echostr
// For AIBOT (智能机器人), receiveid should be empty string ""
// Reference: https://developer.work.weixin.qq.com/document/path/101033
- decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
+ decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
"error": err.Error(),
@@ -281,7 +264,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
// Verify signature
- if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
+ if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
logger.WarnC("wecom", "Message signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
@@ -290,7 +273,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
// Decrypt message
// For AIBOT (智能机器人), receiveid should be empty string ""
// Reference: https://developer.work.weixin.qq.com/document/path/101033
- decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
+ decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
"error": err.Error(),
@@ -387,12 +370,21 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
}
// Build metadata
+ peer := bus.Peer{Kind: peerKind, ID: peerID}
+
+ // In group chats, apply unified group trigger filtering
+ if isGroupChat {
+ respond, cleaned := c.ShouldRespondInGroup(false, content)
+ if !respond {
+ return
+ }
+ content = cleaned
+ }
+
metadata := map[string]string{
"msg_type": msg.MsgType,
"msg_id": msg.MsgID,
"platform": "wecom",
- "peer_kind": peerKind,
- "peer_id": peerID,
"response_url": msg.ResponseURL,
}
if isGroupChat {
@@ -408,8 +400,19 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
"preview": utils.Truncate(content, 50),
})
+ // Build sender info
+ sender := bus.SenderInfo{
+ Platform: "wecom",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("wecom", senderID),
+ }
+
+ if !c.IsAllowedSender(sender) {
+ return
+ }
+
// Handle the message through the base channel
- c.HandleMessage(senderID, chatID, content, nil, metadata)
+ c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender)
}
// sendWebhookReply sends a reply using the webhook URL
@@ -442,10 +445,15 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
resp, err := client.Do(req)
if err != nil {
- return fmt.Errorf("failed to send webhook reply: %w", err)
+ return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body)))
+ }
+
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
@@ -477,129 +485,3 @@ func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
-
-// WeCom common utilities for both WeCom Bot and WeCom App
-// The following functions were moved from wecom_common.go
-
-// WeComVerifySignature verifies the message signature for WeCom
-// This is a common function used by both WeCom Bot and WeCom App
-func WeComVerifySignature(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
-}
-
-// WeComDecryptMessage decrypts the encrypted message using AES
-// This is a common function used by both WeCom Bot and WeCom App
-// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
-func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
- return WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
-}
-
-// WeComDecryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
-// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
-func WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
- if encodingAESKey == "" {
- // No encryption, return as is (base64 decode)
- decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
- if err != nil {
- return "", err
- }
- return string(decoded), nil
- }
-
- // Decode AES key (base64)
- aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
- if err != nil {
- return "", fmt.Errorf("failed to decode AES key: %w", err)
- }
-
- // Decode encrypted message
- cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
- if err != nil {
- return "", fmt.Errorf("failed to decode message: %w", err)
- }
-
- // AES decrypt
- block, err := aes.NewCipher(aesKey)
- if err != nil {
- return "", 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)
-
- // Remove PKCS7 padding
- plainText, err = pkcs7UnpadWeCom(plainText)
- if err != nil {
- return "", fmt.Errorf("failed to unpad: %w", err)
- }
-
- // Parse message structure
- // Format: random(16) + msg_len(4) + msg + receiveid
- if len(plainText) < 20 {
- return "", fmt.Errorf("decrypted message too short")
- }
-
- msgLen := binary.BigEndian.Uint32(plainText[16:20])
- if int(msgLen) > len(plainText)-20 {
- return "", fmt.Errorf("invalid message length")
- }
-
- msg := plainText[20 : 20+msgLen]
-
- // Verify receiveid if provided
- if receiveid != "" && len(plainText) > 20+int(msgLen) {
- actualReceiveID := string(plainText[20+msgLen:])
- if actualReceiveID != receiveid {
- return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
- }
- }
-
- return string(msg), nil
-}
-
-// pkcs7UnpadWeCom removes PKCS7 padding with validation
-// WeCom uses block size of 32 (not standard AES block size of 16)
-const wecomBlockSize = 32
-
-func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
- if len(data) == 0 {
- return data, nil
- }
- padding := int(data[len(data)-1])
- // WeCom uses 32-byte block size for PKCS7 padding
- if padding == 0 || padding > wecomBlockSize {
- return nil, fmt.Errorf("invalid padding size: %d", padding)
- }
- if padding > len(data) {
- return nil, fmt.Errorf("padding size larger than data")
- }
- // Verify all padding bytes
- for i := 0; i < padding; i++ {
- if data[len(data)-1-i] != byte(padding) {
- return nil, fmt.Errorf("invalid padding byte at position %d", i)
- }
- }
- return data[:len(data)-padding], nil
-}
diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom/bot_test.go
similarity index 95%
rename from pkg/channels/wecom_test.go
rename to pkg/channels/wecom/bot_test.go
index 8afa7e8c3..328b145c2 100644
--- a/pkg/channels/wecom_test.go
+++ b/pkg/channels/wecom/bot_test.go
@@ -1,7 +1,4 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// WeCom Bot (企业微信智能机器人) channel tests
-
-package channels
+package wecom
import (
"bytes"
@@ -177,7 +174,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
msgEncrypt := "test_message"
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
- if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
+ if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
t.Error("valid signature should pass verification")
}
})
@@ -187,7 +184,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
nonce := "test_nonce"
msgEncrypt := "test_message"
- if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
+ if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification")
}
})
@@ -202,7 +199,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
config: cfgEmpty,
}
- if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
+ if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true")
}
})
@@ -223,7 +220,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
plainText := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
- result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
+ result, err := decryptMessage(encoded, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -247,7 +244,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
t.Fatalf("failed to encrypt test message: %v", err)
}
- result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
+ result, err := decryptMessage(encrypted, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -264,7 +261,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
- _, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
+ _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid base64, got nil")
}
@@ -278,7 +275,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
- _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
+ _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid AES key, got nil")
}
@@ -320,20 +317,20 @@ func TestWeComBotPKCS7Unpad(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result, err := pkcs7UnpadWeCom(tt.input)
+ result, err := pkcs7Unpad(tt.input)
if tt.expected == nil {
// This case should return an error
if err == nil {
- t.Errorf("pkcs7UnpadWeCom() expected error for invalid padding, got result: %v", result)
+ t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result)
}
return
}
if err != nil {
- t.Errorf("pkcs7UnpadWeCom() unexpected error: %v", err)
+ t.Errorf("pkcs7Unpad() unexpected error: %v", err)
return
}
if !bytes.Equal(result, tt.expected) {
- t.Errorf("pkcs7UnpadWeCom() = %v, want %v", result, tt.expected)
+ t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected)
}
})
}
diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go
new file mode 100644
index 000000000..3c1629577
--- /dev/null
+++ b/pkg/channels/wecom/common.go
@@ -0,0 +1,134 @@
+package wecom
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/sha1"
+ "encoding/base64"
+ "encoding/binary"
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// blockSize is the PKCS7 block size used by WeCom (32)
+const blockSize = 32
+
+// 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
+}
+
+// decryptMessage decrypts the encrypted message using AES
+// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
+func decryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
+ return decryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
+}
+
+// decryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
+// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
+func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
+ if encodingAESKey == "" {
+ // No encryption, return as is (base64 decode)
+ decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
+ if err != nil {
+ return "", err
+ }
+ return string(decoded), nil
+ }
+
+ // Decode AES key (base64)
+ aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
+ if err != nil {
+ return "", fmt.Errorf("failed to decode AES key: %w", err)
+ }
+
+ // Decode encrypted message
+ cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
+ if err != nil {
+ return "", fmt.Errorf("failed to decode message: %w", err)
+ }
+
+ // AES decrypt
+ block, err := aes.NewCipher(aesKey)
+ if err != nil {
+ return "", 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)
+
+ // Remove PKCS7 padding
+ plainText, err = pkcs7Unpad(plainText)
+ if err != nil {
+ return "", fmt.Errorf("failed to unpad: %w", err)
+ }
+
+ // Parse message structure
+ // Format: random(16) + msg_len(4) + msg + receiveid
+ if len(plainText) < 20 {
+ return "", fmt.Errorf("decrypted message too short")
+ }
+
+ msgLen := binary.BigEndian.Uint32(plainText[16:20])
+ if int(msgLen) > len(plainText)-20 {
+ return "", fmt.Errorf("invalid message length")
+ }
+
+ msg := plainText[20 : 20+msgLen]
+
+ // Verify receiveid if provided
+ if receiveid != "" && len(plainText) > 20+int(msgLen) {
+ actualReceiveID := string(plainText[20+msgLen:])
+ if actualReceiveID != receiveid {
+ return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
+ }
+ }
+
+ return string(msg), nil
+}
+
+// pkcs7Unpad removes PKCS7 padding with validation
+func pkcs7Unpad(data []byte) ([]byte, error) {
+ if len(data) == 0 {
+ return data, nil
+ }
+ padding := int(data[len(data)-1])
+ // WeCom uses 32-byte block size for PKCS7 padding
+ if padding == 0 || padding > blockSize {
+ return nil, fmt.Errorf("invalid padding size: %d", padding)
+ }
+ if padding > len(data) {
+ return nil, fmt.Errorf("padding size larger than data")
+ }
+ // Verify all padding bytes
+ for i := 0; i < padding; i++ {
+ if data[len(data)-1-i] != byte(padding) {
+ return nil, fmt.Errorf("invalid padding byte at position %d", i)
+ }
+ }
+ return data[:len(data)-padding], nil
+}
diff --git a/pkg/channels/wecom/init.go b/pkg/channels/wecom/init.go
new file mode 100644
index 000000000..3ef1ecdf3
--- /dev/null
+++ b/pkg/channels/wecom/init.go
@@ -0,0 +1,16 @@
+package wecom
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewWeComBotChannel(cfg.Channels.WeCom, b)
+ })
+ channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewWeComAppChannel(cfg.Channels.WeComApp, b)
+ })
+}
diff --git a/pkg/channels/whatsapp/init.go b/pkg/channels/whatsapp/init.go
new file mode 100644
index 000000000..d9c2669c3
--- /dev/null
+++ b/pkg/channels/whatsapp/init.go
@@ -0,0 +1,13 @@
+package whatsapp
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("whatsapp", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewWhatsAppChannel(cfg.Channels.WhatsApp, b)
+ })
+}
diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go
similarity index 53%
rename from pkg/channels/whatsapp.go
rename to pkg/channels/whatsapp/whatsapp.go
index 958d850bb..70b3e02bf 100644
--- a/pkg/channels/whatsapp.go
+++ b/pkg/channels/whatsapp/whatsapp.go
@@ -1,31 +1,42 @@
-package channels
+package whatsapp
import (
"context"
"encoding/json"
"fmt"
- "log"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
+ "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
type WhatsAppChannel struct {
- *BaseChannel
+ *channels.BaseChannel
conn *websocket.Conn
config config.WhatsAppConfig
url string
+ ctx context.Context
+ cancel context.CancelFunc
mu sync.Mutex
connected bool
}
func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
- base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom)
+ base := channels.NewBaseChannel(
+ "whatsapp",
+ cfg,
+ bus,
+ cfg.AllowFrom,
+ channels.WithMaxMessageLength(65536),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
return &WhatsAppChannel{
BaseChannel: base,
@@ -36,13 +47,21 @@ func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsA
}
func (c *WhatsAppChannel) Start(ctx context.Context) error {
- log.Printf("Starting WhatsApp channel connecting to %s...", c.url)
+ logger.InfoCF("whatsapp", "Starting WhatsApp channel", map[string]any{
+ "bridge_url": c.url,
+ })
+
+ c.ctx, c.cancel = context.WithCancel(ctx)
dialer := websocket.DefaultDialer
dialer.HandshakeTimeout = 10 * time.Second
- conn, _, err := dialer.Dial(c.url, nil)
+ conn, resp, err := dialer.Dial(c.url, nil)
+ if resp != nil {
+ resp.Body.Close()
+ }
if err != nil {
+ c.cancel()
return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err)
}
@@ -51,39 +70,57 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error {
c.connected = true
c.mu.Unlock()
- c.setRunning(true)
- log.Println("WhatsApp channel connected")
+ c.SetRunning(true)
+ logger.InfoC("whatsapp", "WhatsApp channel connected")
- go c.listen(ctx)
+ go c.listen()
return nil
}
func (c *WhatsAppChannel) Stop(ctx context.Context) error {
- log.Println("Stopping WhatsApp channel...")
+ logger.InfoC("whatsapp", "Stopping WhatsApp channel...")
+
+ // Cancel context first to signal listen goroutine to exit
+ if c.cancel != nil {
+ c.cancel()
+ }
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
if err := c.conn.Close(); err != nil {
- log.Printf("Error closing WhatsApp connection: %v", err)
+ logger.ErrorCF("whatsapp", "Error closing WhatsApp connection", map[string]any{
+ "error": err.Error(),
+ })
}
c.conn = nil
}
c.connected = false
- c.setRunning(false)
+ c.SetRunning(false)
return nil
}
func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ // Check ctx before acquiring lock
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
- return fmt.Errorf("whatsapp connection not established")
+ return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
payload := map[string]any{
@@ -97,17 +134,20 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("failed to marshal message: %w", err)
}
+ _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
- return fmt.Errorf("failed to send message: %w", err)
+ _ = c.conn.SetWriteDeadline(time.Time{})
+ return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
}
+ _ = c.conn.SetWriteDeadline(time.Time{})
return nil
}
-func (c *WhatsAppChannel) listen(ctx context.Context) {
+func (c *WhatsAppChannel) listen() {
for {
select {
- case <-ctx.Done():
+ case <-c.ctx.Done():
return
default:
c.mu.Lock()
@@ -121,14 +161,18 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
_, message, err := conn.ReadMessage()
if err != nil {
- log.Printf("WhatsApp read error: %v", err)
+ logger.ErrorCF("whatsapp", "WhatsApp read error", map[string]any{
+ "error": err.Error(),
+ })
time.Sleep(2 * time.Second)
continue
}
var msg map[string]any
if err := json.Unmarshal(message, &msg); err != nil {
- log.Printf("Failed to unmarshal WhatsApp message: %v", err)
+ logger.ErrorCF("whatsapp", "Failed to unmarshal WhatsApp message", map[string]any{
+ "error": err.Error(),
+ })
continue
}
@@ -171,22 +215,38 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
}
metadata := make(map[string]string)
- if messageID, ok := msg["id"].(string); ok {
- metadata["message_id"] = messageID
+ var messageID string
+ if mid, ok := msg["id"].(string); ok {
+ messageID = mid
}
if userName, ok := msg["from_name"].(string); ok {
metadata["user_name"] = userName
}
+ var peer bus.Peer
if chatID == senderID {
- metadata["peer_kind"] = "direct"
- metadata["peer_id"] = senderID
+ peer = bus.Peer{Kind: "direct", ID: senderID}
} else {
- metadata["peer_kind"] = "group"
- metadata["peer_id"] = chatID
+ peer = bus.Peer{Kind: "group", ID: chatID}
}
- log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50))
+ logger.InfoCF("whatsapp", "WhatsApp message received", map[string]any{
+ "sender": senderID,
+ "preview": utils.Truncate(content, 50),
+ })
- c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
+ sender := bus.SenderInfo{
+ Platform: "whatsapp",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("whatsapp", senderID),
+ }
+ if display, ok := metadata["user_name"]; ok {
+ sender.DisplayName = display
+ }
+
+ if !c.IsAllowedSender(sender) {
+ return
+ }
+
+ c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
}
diff --git a/pkg/channels/whatsapp_native/init.go b/pkg/channels/whatsapp_native/init.go
new file mode 100644
index 000000000..df13e8539
--- /dev/null
+++ b/pkg/channels/whatsapp_native/init.go
@@ -0,0 +1,20 @@
+package whatsapp
+
+import (
+ "path/filepath"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("whatsapp_native", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ waCfg := cfg.Channels.WhatsApp
+ storePath := waCfg.SessionStorePath
+ if storePath == "" {
+ storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp")
+ }
+ return NewWhatsAppNativeChannel(waCfg, b, storePath)
+ })
+}
diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go
new file mode 100644
index 000000000..23115bda7
--- /dev/null
+++ b/pkg/channels/whatsapp_native/whatsapp_native.go
@@ -0,0 +1,341 @@
+//go:build whatsapp_native
+
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package whatsapp
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/mdp/qrterminal/v3"
+ "go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/proto/waE2E"
+ "go.mau.fi/whatsmeow/store/sqlstore"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+ waLog "go.mau.fi/whatsmeow/util/log"
+ "google.golang.org/protobuf/proto"
+ _ "modernc.org/sqlite"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+const (
+ sqliteDriver = "sqlite"
+ whatsappDBName = "store.db"
+
+ reconnectInitial = 5 * time.Second
+ reconnectMax = 5 * time.Minute
+ reconnectMultiplier = 2.0
+)
+
+// WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge).
+type WhatsAppNativeChannel struct {
+ *channels.BaseChannel
+ config config.WhatsAppConfig
+ storePath string
+ client *whatsmeow.Client
+ container *sqlstore.Container
+ mu sync.Mutex
+ runCtx context.Context
+ runCancel context.CancelFunc
+ reconnectMu sync.Mutex
+ reconnecting bool
+}
+
+// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
+// storePath is the directory for the SQLite session store (e.g. workspace/whatsapp).
+func NewWhatsAppNativeChannel(
+ cfg config.WhatsAppConfig,
+ bus *bus.MessageBus,
+ storePath string,
+) (channels.Channel, error) {
+ base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536))
+ if storePath == "" {
+ storePath = "whatsapp"
+ }
+ c := &WhatsAppNativeChannel{
+ BaseChannel: base,
+ config: cfg,
+ storePath: storePath,
+ }
+ return c, nil
+}
+
+func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
+ logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath})
+
+ if err := os.MkdirAll(c.storePath, 0o700); err != nil {
+ return fmt.Errorf("create session store dir: %w", err)
+ }
+
+ dbPath := filepath.Join(c.storePath, whatsappDBName)
+ connStr := "file:" + dbPath + "?_foreign_keys=on"
+
+ db, err := sql.Open(sqliteDriver, connStr)
+ if err != nil {
+ return fmt.Errorf("open whatsapp store: %w", err)
+ }
+ db.SetMaxOpenConns(1)
+ db.SetMaxIdleConns(1)
+ if _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
+ _ = db.Close()
+ return fmt.Errorf("enable foreign keys: %w", err)
+ }
+
+ waLogger := waLog.Stdout("WhatsApp", "WARN", true)
+ container := sqlstore.NewWithDB(db, sqliteDriver, waLogger)
+ if err = container.Upgrade(ctx); err != nil {
+ _ = db.Close()
+ return fmt.Errorf("open whatsapp store: %w", err)
+ }
+
+ deviceStore, err := container.GetFirstDevice(ctx)
+ if err != nil {
+ _ = container.Close()
+ return fmt.Errorf("get device store: %w", err)
+ }
+
+ client := whatsmeow.NewClient(deviceStore, waLogger)
+ client.AddEventHandler(c.eventHandler)
+
+ c.mu.Lock()
+ c.container = container
+ c.client = client
+ c.mu.Unlock()
+
+ if client.Store.ID == nil {
+ qrChan, err := client.GetQRChannel(ctx)
+ if err != nil {
+ _ = container.Close()
+ return fmt.Errorf("get QR channel: %w", err)
+ }
+ if err := client.Connect(); err != nil {
+ _ = container.Close()
+ return fmt.Errorf("connect: %w", err)
+ }
+ for evt := range qrChan {
+ if evt.Event == "code" {
+ logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil)
+ qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
+ Level: qrterminal.L,
+ Writer: os.Stdout,
+ HalfBlocks: true,
+ })
+ } else {
+ logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event})
+ }
+ }
+ } else {
+ if err := client.Connect(); err != nil {
+ _ = container.Close()
+ return fmt.Errorf("connect: %w", err)
+ }
+ }
+
+ c.runCtx, c.runCancel = context.WithCancel(ctx)
+ c.SetRunning(true)
+ logger.InfoC("whatsapp", "WhatsApp native channel connected")
+ return nil
+}
+
+func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
+ logger.InfoC("whatsapp", "Stopping WhatsApp native channel")
+ if c.runCancel != nil {
+ c.runCancel()
+ }
+ c.mu.Lock()
+ client := c.client
+ container := c.container
+ c.client = nil
+ c.container = nil
+ c.mu.Unlock()
+
+ if client != nil {
+ client.Disconnect()
+ }
+ if container != nil {
+ _ = container.Close()
+ }
+ c.SetRunning(false)
+ return nil
+}
+
+func (c *WhatsAppNativeChannel) eventHandler(evt any) {
+ switch evt.(type) {
+ case *events.Message:
+ c.handleIncoming(evt.(*events.Message))
+ case *events.Disconnected:
+ logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil)
+ c.reconnectMu.Lock()
+ if c.reconnecting {
+ c.reconnectMu.Unlock()
+ return
+ }
+ c.reconnecting = true
+ c.reconnectMu.Unlock()
+ go c.reconnectWithBackoff()
+ }
+}
+
+func (c *WhatsAppNativeChannel) reconnectWithBackoff() {
+ defer func() {
+ c.reconnectMu.Lock()
+ c.reconnecting = false
+ c.reconnectMu.Unlock()
+ }()
+
+ backoff := reconnectInitial
+ for {
+ select {
+ case <-c.runCtx.Done():
+ return
+ default:
+ }
+
+ c.mu.Lock()
+ client := c.client
+ c.mu.Unlock()
+ if client == nil {
+ return
+ }
+
+ logger.InfoCF("whatsapp", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()})
+ err := client.Connect()
+ if err == nil {
+ logger.InfoC("whatsapp", "WhatsApp reconnected")
+ return
+ }
+
+ logger.WarnCF("whatsapp", "WhatsApp reconnect failed", map[string]any{"error": err.Error()})
+
+ select {
+ case <-c.runCtx.Done():
+ return
+ case <-time.After(backoff):
+ if backoff < reconnectMax {
+ next := time.Duration(float64(backoff) * reconnectMultiplier)
+ if next > reconnectMax {
+ next = reconnectMax
+ }
+ backoff = next
+ }
+ }
+ }
+}
+
+func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
+ if evt.Message == nil {
+ return
+ }
+ senderID := evt.Info.Sender.String()
+ chatID := evt.Info.Chat.String()
+ content := evt.Message.GetConversation()
+ if content == "" && evt.Message.ExtendedTextMessage != nil {
+ content = evt.Message.ExtendedTextMessage.GetText()
+ }
+ content = utils.SanitizeMessageContent(content)
+
+ if content == "" {
+ return
+ }
+
+ var mediaPaths []string
+
+ metadata := make(map[string]string)
+ metadata["message_id"] = evt.Info.ID
+ if evt.Info.PushName != "" {
+ metadata["user_name"] = evt.Info.PushName
+ }
+ if evt.Info.Chat.Server == types.GroupServer {
+ metadata["peer_kind"] = "group"
+ metadata["peer_id"] = chatID
+ } else {
+ metadata["peer_kind"] = "direct"
+ metadata["peer_id"] = senderID
+ }
+
+ peerKind := "direct"
+ if evt.Info.Chat.Server == types.GroupServer {
+ peerKind = "group"
+ }
+ peer := bus.Peer{Kind: peerKind, ID: chatID}
+ messageID := evt.Info.ID
+ sender := bus.SenderInfo{
+ Platform: "whatsapp",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("whatsapp", senderID),
+ DisplayName: evt.Info.PushName,
+ }
+
+ if !c.IsAllowedSender(sender) {
+ return
+ }
+
+ logger.DebugCF(
+ "whatsapp",
+ "WhatsApp message received",
+ map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)},
+ )
+ c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
+}
+
+func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ c.mu.Lock()
+ client := c.client
+ c.mu.Unlock()
+
+ if client == nil || !client.IsConnected() {
+ return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
+ }
+
+ to, err := parseJID(msg.ChatID)
+ if err != nil {
+ return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
+ }
+
+ waMsg := &waE2E.Message{
+ Conversation: proto.String(msg.Content),
+ }
+
+ if _, err = client.SendMessage(ctx, to, waMsg); err != nil {
+ return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
+ }
+ return nil
+}
+
+// parseJID converts a chat ID (phone number or JID string) to types.JID.
+func parseJID(s string) (types.JID, error) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return types.JID{}, fmt.Errorf("empty chat id")
+ }
+ if strings.Contains(s, "@") {
+ return types.ParseJID(s)
+ }
+ return types.NewJID(s, types.DefaultUserServer), nil
+}
diff --git a/pkg/channels/whatsapp_native/whatsapp_native_stub.go b/pkg/channels/whatsapp_native/whatsapp_native_stub.go
new file mode 100644
index 000000000..984af23e7
--- /dev/null
+++ b/pkg/channels/whatsapp_native/whatsapp_native_stub.go
@@ -0,0 +1,21 @@
+//go:build !whatsapp_native
+
+package whatsapp
+
+import (
+ "fmt"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native.
+// Build with: go build -tags whatsapp_native ./cmd/...
+func NewWhatsAppNativeChannel(
+ cfg config.WhatsAppConfig,
+ bus *bus.MessageBus,
+ storePath string,
+) (channels.Channel, error) {
+ return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native")
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index c76615228..d2898a0bc 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -4,10 +4,11 @@ import (
"encoding/json"
"fmt"
"os"
- "path/filepath"
"sync/atomic"
"github.com/caarlos0/env/v11"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
// rrCounter is a global counter for round-robin load balancing across models.
@@ -170,7 +171,8 @@ 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"`
- Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
+ 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"`
@@ -179,6 +181,15 @@ type AgentDefaults struct {
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
+// GetModelName returns the effective model name for the agent defaults.
+// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
+func (d *AgentDefaults) GetModelName() string {
+ if d.ModelName != "" {
+ return d.ModelName
+ }
+ return d.Model
+}
+
type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"`
@@ -192,108 +203,173 @@ type ChannelsConfig struct {
OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"`
+ Pico PicoConfig `json:"pico"`
+}
+
+// GroupTriggerConfig controls when the bot responds in group chats.
+type GroupTriggerConfig struct {
+ MentionOnly bool `json:"mention_only,omitempty"`
+ Prefixes []string `json:"prefixes,omitempty"`
+}
+
+// TypingConfig controls typing indicator behavior (Phase 10).
+type TypingConfig struct {
+ Enabled bool `json:"enabled,omitempty"`
+}
+
+// PlaceholderConfig controls placeholder message behavior (Phase 10).
+type PlaceholderConfig struct {
+ Enabled bool `json:"enabled,omitempty"`
+ Text string `json:"text,omitempty"`
}
type WhatsAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
- BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
+ BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
+ UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
+ SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
+ 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"`
- 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"`
+ Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
}
type FeishuConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
- EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
- VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
+ 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"`
}
type DiscordConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
- MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
+ MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
}
type MaixCamConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
- Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
- Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
+ Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
+ Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
}
type QQConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
+ AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
}
type DingTalkConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
- ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
- ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
+ ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
+ ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
}
type SlackConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
- BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
- AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
+ BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
+ AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
}
type LINEConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
- ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
- ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
+ ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
+ ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
}
type OneBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
- WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
- ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
- GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
+ WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
+ ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
+ GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
}
type WeComConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
- EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
- WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
+ EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
+ WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
}
type WeComAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
- CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
- CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
- AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
- EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
+ CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
+ CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
+ AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
+ EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
+}
+
+type PicoConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
+ AllowTokenQuery bool `json:"allow_token_query,omitempty"`
+ AllowOrigins []string `json:"allow_origins,omitempty"`
+ PingInterval int `json:"ping_interval,omitempty"`
+ ReadTimeout int `json:"read_timeout,omitempty"`
+ WriteTimeout int `json:"write_timeout,omitempty"`
+ MaxConnections int `json:"max_connections,omitempty"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
type HeartbeatConfig struct {
@@ -324,6 +400,7 @@ type ProvidersConfig struct {
GitHubCopilot ProviderConfig `json:"github_copilot"`
Antigravity ProviderConfig `json:"antigravity"`
Qwen ProviderConfig `json:"qwen"`
+ Mistral ProviderConfig `json:"mistral"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@@ -345,7 +422,8 @@ func (p ProvidersConfig) IsEmpty() bool {
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.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
+ p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
@@ -359,11 +437,12 @@ func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
}
type ProviderConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
- APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
- AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
- ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
+ APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
+ APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
+ RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"`
+ AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
}
type OpenAIProviderConfig struct {
@@ -394,6 +473,7 @@ type ModelConfig struct {
// 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")
+ RequestTimeout int `json:"request_timeout,omitempty"`
}
// Validate checks if the ModelConfig has all required fields.
@@ -418,6 +498,13 @@ type BraveConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
+type TavilyConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
+}
+
type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
@@ -431,8 +518,12 @@ type PerplexityConfig struct {
type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
+ Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"`
+ // 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"`
}
type CronToolsConfig struct {
@@ -444,12 +535,19 @@ type ExecConfig struct {
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"`
- MCP MCPConfig `json:"mcp"`
+ 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"`
}
type SkillsToolsConfig struct {
@@ -518,6 +616,20 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
+ // Pre-scan the JSON to check how many model_list entries the user provided.
+ // Go's JSON decoder reuses existing slice backing-array elements rather than
+ // zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
+ // would silently inherit values from the DefaultConfig template at the same
+ // index position. We only reset cfg.ModelList when the user actually provides
+ // entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
+ var tmp Config
+ if err := json.Unmarshal(data, &tmp); err != nil {
+ return nil, err
+ }
+ if len(tmp.ModelList) > 0 {
+ cfg.ModelList = nil
+ }
+
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
@@ -526,6 +638,9 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
+ // Migrate legacy channel config fields to new unified structures
+ cfg.migrateChannelConfigs()
+
// Auto-migrate: if only legacy providers config exists, convert to model_list
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
cfg.ModelList = ConvertProvidersToModelList(cfg)
@@ -539,18 +654,26 @@ func LoadConfig(path string) (*Config, error) {
return cfg, nil
}
+func (c *Config) migrateChannelConfigs() {
+ // Discord: mention_only -> group_trigger.mention_only
+ if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
+ c.Channels.Discord.GroupTrigger.MentionOnly = true
+ }
+
+ // OneBot: group_trigger_prefix -> group_trigger.prefixes
+ if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
+ c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
+ }
+}
+
func SaveConfig(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
-
- return os.WriteFile(path, data, 0o600)
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ return fileutil.WriteFileAtomic(path, data, 0o600)
}
func (c *Config) WorkspacePath() string {
@@ -665,7 +788,8 @@ func (c *Config) HasProvidersConfig() bool {
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.Qwen.APIKey != "" || v.Qwen.APIBase != "" ||
+ v.Mistral.APIKey != "" || v.Mistral.APIBase != ""
}
// ValidateModelList validates all ModelConfig entries in the model_list.
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 0898217d6..12fd10b50 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
+ "strings"
"testing"
)
@@ -210,8 +211,8 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
func TestDefaultConfig_Model(t *testing.T) {
cfg := DefaultConfig()
- if cfg.Agents.Defaults.Model == "" {
- t.Error("Model should not be empty")
+ if cfg.Agents.Defaults.Model != "" {
+ t.Error("Model should be empty")
}
}
@@ -246,7 +247,7 @@ func TestDefaultConfig_Temperature(t *testing.T) {
func TestDefaultConfig_Gateway(t *testing.T) {
cfg := DefaultConfig()
- if cfg.Gateway.Host != "0.0.0.0" {
+ if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {
@@ -324,6 +325,25 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
}
}
+func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "config.json")
+
+ cfg := DefaultConfig()
+ if err := SaveConfig(path, cfg); err != nil {
+ t.Fatalf("SaveConfig failed: %v", err)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("ReadFile failed: %v", err)
+ }
+
+ if !strings.Contains(string(data), `"model": ""`) {
+ t.Fatalf("saved config should include empty legacy model field, got: %s", string(data))
+ }
+}
+
// TestConfig_Complete verifies all config fields are set
func TestConfig_Complete(t *testing.T) {
cfg := DefaultConfig()
@@ -331,8 +351,8 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should not be empty")
}
- if cfg.Agents.Defaults.Model == "" {
- t.Error("Model should not be empty")
+ if cfg.Agents.Defaults.Model != "" {
+ t.Error("Model should be empty")
}
if cfg.Agents.Defaults.Temperature != nil {
t.Error("Temperature should be nil when not provided")
@@ -343,7 +363,7 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.MaxToolIterations == 0 {
t.Error("MaxToolIterations should not be zero")
}
- if cfg.Gateway.Host != "0.0.0.0" {
+ if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {
@@ -392,3 +412,33 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
t.Fatal("OpenAI codex web search should be false when disabled in config file")
}
}
+
+func TestLoadConfig_WebToolsProxy(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+ configJSON := `{
+ "agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
+ "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}],
+ "tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
+}`
+ if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
+ t.Fatalf("os.WriteFile() error: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error: %v", err)
+ }
+ if cfg.Tools.Web.Proxy != "http://127.0.0.1:7890" {
+ t.Fatalf("Tools.Web.Proxy = %q, want %q", cfg.Tools.Web.Proxy, "http://127.0.0.1:7890")
+ }
+}
+
+// TestDefaultConfig_DMScope verifies the default dm_scope value
+func TestDefaultConfig_DMScope(t *testing.T) {
+ cfg := DefaultConfig()
+
+ if cfg.Session.DMScope != "per-channel-peer" {
+ t.Errorf("Session.DMScope = %q, want 'per-channel-peer'", cfg.Session.DMScope)
+ }
+}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index f9492bffd..5337aee2f 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -13,26 +13,33 @@ func DefaultConfig() *Config {
Workspace: "~/.picoclaw/workspace",
RestrictToWorkspace: true,
Provider: "",
- Model: "glm-4.7",
- MaxTokens: 8192,
+ Model: "",
+ MaxTokens: 32768,
Temperature: nil, // nil means use provider default
- MaxToolIterations: 20,
+ MaxToolIterations: 50,
},
},
Bindings: []AgentBinding{},
Session: SessionConfig{
- DMScope: "main",
+ DMScope: "per-channel-peer",
},
Channels: ChannelsConfig{
WhatsApp: WhatsAppConfig{
- Enabled: false,
- BridgeURL: "ws://localhost:3001",
- AllowFrom: FlexibleStringSlice{},
+ Enabled: false,
+ BridgeURL: "ws://localhost:3001",
+ UseNative: false,
+ SessionStorePath: "",
+ AllowFrom: FlexibleStringSlice{},
},
Telegram: TelegramConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
+ Typing: TypingConfig{Enabled: true},
+ Placeholder: PlaceholderConfig{
+ Enabled: true,
+ Text: "Thinking... 💭",
+ },
},
Feishu: FeishuConfig{
Enabled: false,
@@ -80,6 +87,7 @@ func DefaultConfig() *Config {
WebhookPort: 18791,
WebhookPath: "/webhook/line",
AllowFrom: FlexibleStringSlice{},
+ GroupTrigger: GroupTriggerConfig{MentionOnly: true},
},
OneBot: OneBotConfig{
Enabled: false,
@@ -113,6 +121,15 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5,
},
+ Pico: PicoConfig{
+ Enabled: false,
+ Token: "",
+ PingInterval: 30,
+ ReadTimeout: 60,
+ WriteTimeout: 10,
+ MaxConnections: 100,
+ AllowFrom: FlexibleStringSlice{},
+ },
},
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{WebSearch: true},
@@ -255,6 +272,14 @@ func DefaultConfig() *Config {
APIKey: "ollama",
},
+ // Mistral AI - https://console.mistral.ai/api-keys
+ {
+ ModelName: "mistral-small",
+ Model: "mistral/mistral-small-latest",
+ APIBase: "https://api.mistral.ai/v1",
+ APIKey: "",
+ },
+
// VLLM (local) - http://localhost:8000
{
ModelName: "local-model",
@@ -264,11 +289,17 @@ func DefaultConfig() *Config {
},
},
Gateway: GatewayConfig{
- Host: "0.0.0.0",
+ Host: "127.0.0.1",
Port: 18790,
},
Tools: ToolsConfig{
+ MediaCleanup: MediaCleanupConfig{
+ Enabled: true,
+ MaxAge: 30,
+ Interval: 5,
+ },
Web: WebToolsConfig{
+ Proxy: "",
Brave: BraveConfig{
Enabled: false,
APIKey: "",
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index 689e2312f..5deb09270 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -41,7 +41,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
// Get user's configured provider and model
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
- userModel := cfg.Agents.Defaults.Model
+ userModel := cfg.Agents.Defaults.GetModelName()
p := cfg.Providers
@@ -60,12 +60,13 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "openai",
- Model: "openai/gpt-5.2",
- APIKey: p.OpenAI.APIKey,
- APIBase: p.OpenAI.APIBase,
- Proxy: p.OpenAI.Proxy,
- AuthMethod: p.OpenAI.AuthMethod,
+ ModelName: "openai",
+ Model: "openai/gpt-5.2",
+ APIKey: p.OpenAI.APIKey,
+ APIBase: p.OpenAI.APIBase,
+ Proxy: p.OpenAI.Proxy,
+ RequestTimeout: p.OpenAI.RequestTimeout,
+ AuthMethod: p.OpenAI.AuthMethod,
}, true
},
},
@@ -77,12 +78,13 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "anthropic",
- Model: "anthropic/claude-sonnet-4.6",
- APIKey: p.Anthropic.APIKey,
- APIBase: p.Anthropic.APIBase,
- Proxy: p.Anthropic.Proxy,
- AuthMethod: p.Anthropic.AuthMethod,
+ ModelName: "anthropic",
+ Model: "anthropic/claude-sonnet-4.6",
+ APIKey: p.Anthropic.APIKey,
+ APIBase: p.Anthropic.APIBase,
+ Proxy: p.Anthropic.Proxy,
+ RequestTimeout: p.Anthropic.RequestTimeout,
+ AuthMethod: p.Anthropic.AuthMethod,
}, true
},
},
@@ -94,11 +96,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "openrouter",
- Model: "openrouter/auto",
- APIKey: p.OpenRouter.APIKey,
- APIBase: p.OpenRouter.APIBase,
- Proxy: p.OpenRouter.Proxy,
+ ModelName: "openrouter",
+ Model: "openrouter/auto",
+ APIKey: p.OpenRouter.APIKey,
+ APIBase: p.OpenRouter.APIBase,
+ Proxy: p.OpenRouter.Proxy,
+ RequestTimeout: p.OpenRouter.RequestTimeout,
}, true
},
},
@@ -110,11 +113,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "groq",
- Model: "groq/llama-3.1-70b-versatile",
- APIKey: p.Groq.APIKey,
- APIBase: p.Groq.APIBase,
- Proxy: p.Groq.Proxy,
+ ModelName: "groq",
+ Model: "groq/llama-3.1-70b-versatile",
+ APIKey: p.Groq.APIKey,
+ APIBase: p.Groq.APIBase,
+ Proxy: p.Groq.Proxy,
+ RequestTimeout: p.Groq.RequestTimeout,
}, true
},
},
@@ -126,11 +130,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "zhipu",
- Model: "zhipu/glm-4",
- APIKey: p.Zhipu.APIKey,
- APIBase: p.Zhipu.APIBase,
- Proxy: p.Zhipu.Proxy,
+ ModelName: "zhipu",
+ Model: "zhipu/glm-4",
+ APIKey: p.Zhipu.APIKey,
+ APIBase: p.Zhipu.APIBase,
+ Proxy: p.Zhipu.Proxy,
+ RequestTimeout: p.Zhipu.RequestTimeout,
}, true
},
},
@@ -142,11 +147,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "vllm",
- Model: "vllm/auto",
- APIKey: p.VLLM.APIKey,
- APIBase: p.VLLM.APIBase,
- Proxy: p.VLLM.Proxy,
+ ModelName: "vllm",
+ Model: "vllm/auto",
+ APIKey: p.VLLM.APIKey,
+ APIBase: p.VLLM.APIBase,
+ Proxy: p.VLLM.Proxy,
+ RequestTimeout: p.VLLM.RequestTimeout,
}, true
},
},
@@ -158,11 +164,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "gemini",
- Model: "gemini/gemini-pro",
- APIKey: p.Gemini.APIKey,
- APIBase: p.Gemini.APIBase,
- Proxy: p.Gemini.Proxy,
+ ModelName: "gemini",
+ Model: "gemini/gemini-pro",
+ APIKey: p.Gemini.APIKey,
+ APIBase: p.Gemini.APIBase,
+ Proxy: p.Gemini.Proxy,
+ RequestTimeout: p.Gemini.RequestTimeout,
}, true
},
},
@@ -174,11 +181,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "nvidia",
- Model: "nvidia/meta/llama-3.1-8b-instruct",
- APIKey: p.Nvidia.APIKey,
- APIBase: p.Nvidia.APIBase,
- Proxy: p.Nvidia.Proxy,
+ ModelName: "nvidia",
+ Model: "nvidia/meta/llama-3.1-8b-instruct",
+ APIKey: p.Nvidia.APIKey,
+ APIBase: p.Nvidia.APIBase,
+ Proxy: p.Nvidia.Proxy,
+ RequestTimeout: p.Nvidia.RequestTimeout,
}, true
},
},
@@ -190,11 +198,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "ollama",
- Model: "ollama/llama3",
- APIKey: p.Ollama.APIKey,
- APIBase: p.Ollama.APIBase,
- Proxy: p.Ollama.Proxy,
+ ModelName: "ollama",
+ Model: "ollama/llama3",
+ APIKey: p.Ollama.APIKey,
+ APIBase: p.Ollama.APIBase,
+ Proxy: p.Ollama.Proxy,
+ RequestTimeout: p.Ollama.RequestTimeout,
}, true
},
},
@@ -206,11 +215,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "moonshot",
- Model: "moonshot/kimi",
- APIKey: p.Moonshot.APIKey,
- APIBase: p.Moonshot.APIBase,
- Proxy: p.Moonshot.Proxy,
+ ModelName: "moonshot",
+ Model: "moonshot/kimi",
+ APIKey: p.Moonshot.APIKey,
+ APIBase: p.Moonshot.APIBase,
+ Proxy: p.Moonshot.Proxy,
+ RequestTimeout: p.Moonshot.RequestTimeout,
}, true
},
},
@@ -222,11 +232,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "shengsuanyun",
- Model: "shengsuanyun/auto",
- APIKey: p.ShengSuanYun.APIKey,
- APIBase: p.ShengSuanYun.APIBase,
- Proxy: p.ShengSuanYun.Proxy,
+ ModelName: "shengsuanyun",
+ Model: "shengsuanyun/auto",
+ APIKey: p.ShengSuanYun.APIKey,
+ APIBase: p.ShengSuanYun.APIBase,
+ Proxy: p.ShengSuanYun.Proxy,
+ RequestTimeout: p.ShengSuanYun.RequestTimeout,
}, true
},
},
@@ -238,11 +249,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "deepseek",
- Model: "deepseek/deepseek-chat",
- APIKey: p.DeepSeek.APIKey,
- APIBase: p.DeepSeek.APIBase,
- Proxy: p.DeepSeek.Proxy,
+ ModelName: "deepseek",
+ Model: "deepseek/deepseek-chat",
+ APIKey: p.DeepSeek.APIKey,
+ APIBase: p.DeepSeek.APIBase,
+ Proxy: p.DeepSeek.Proxy,
+ RequestTimeout: p.DeepSeek.RequestTimeout,
}, true
},
},
@@ -254,11 +266,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "cerebras",
- Model: "cerebras/llama-3.3-70b",
- APIKey: p.Cerebras.APIKey,
- APIBase: p.Cerebras.APIBase,
- Proxy: p.Cerebras.Proxy,
+ ModelName: "cerebras",
+ Model: "cerebras/llama-3.3-70b",
+ APIKey: p.Cerebras.APIKey,
+ APIBase: p.Cerebras.APIBase,
+ Proxy: p.Cerebras.Proxy,
+ RequestTimeout: p.Cerebras.RequestTimeout,
}, true
},
},
@@ -270,11 +283,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "volcengine",
- Model: "volcengine/doubao-pro",
- APIKey: p.VolcEngine.APIKey,
- APIBase: p.VolcEngine.APIBase,
- Proxy: p.VolcEngine.Proxy,
+ ModelName: "volcengine",
+ Model: "volcengine/doubao-pro",
+ APIKey: p.VolcEngine.APIKey,
+ APIBase: p.VolcEngine.APIBase,
+ Proxy: p.VolcEngine.Proxy,
+ RequestTimeout: p.VolcEngine.RequestTimeout,
}, true
},
},
@@ -316,11 +330,29 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "qwen",
- Model: "qwen/qwen-max",
- APIKey: p.Qwen.APIKey,
- APIBase: p.Qwen.APIBase,
- Proxy: p.Qwen.Proxy,
+ ModelName: "qwen",
+ Model: "qwen/qwen-max",
+ APIKey: p.Qwen.APIKey,
+ APIBase: p.Qwen.APIBase,
+ Proxy: p.Qwen.Proxy,
+ RequestTimeout: p.Qwen.RequestTimeout,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"mistral"},
+ protocol: "mistral",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "mistral",
+ Model: "mistral/mistral-small-latest",
+ APIKey: p.Mistral.APIKey,
+ APIBase: p.Mistral.APIBase,
+ Proxy: p.Mistral.Proxy,
+ RequestTimeout: p.Mistral.RequestTimeout,
}, true
},
},
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index 1e8139e68..db8f4657d 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -131,14 +131,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
Antigravity: ProviderConfig{AuthMethod: "oauth"},
Qwen: ProviderConfig{APIKey: "key17"},
+ Mistral: ProviderConfig{APIKey: "key18"},
},
}
result := ConvertProvidersToModelList(cfg)
- // All 17 providers should be converted
- if len(result) != 17 {
- t.Errorf("len(result) = %d, want 17", len(result))
+ // All 18 providers should be converted
+ if len(result) != 18 {
+ t.Errorf("len(result) = %d, want 18", len(result))
}
}
@@ -165,6 +166,27 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) {
}
}
+func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
+ cfg := &Config{
+ Providers: ProvidersConfig{
+ Ollama: ProviderConfig{
+ APIKey: "ollama-key",
+ RequestTimeout: 300,
+ },
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ if result[0].RequestTimeout != 300 {
+ t.Errorf("RequestTimeout = %d, want %d", result[0].RequestTimeout, 300)
+ }
+}
+
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go
index 3c411dc0f..084f50a82 100644
--- a/pkg/config/model_config_test.go
+++ b/pkg/config/model_config_test.go
@@ -6,6 +6,7 @@
package config
import (
+ "encoding/json"
"strings"
"sync"
"testing"
@@ -114,6 +115,137 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
}
}
+func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) {
+ tests := []struct {
+ name string
+ defaults AgentDefaults
+ wantName string
+ }{
+ {
+ name: "new model_name field only",
+ defaults: AgentDefaults{ModelName: "new-model"},
+ wantName: "new-model",
+ },
+ {
+ name: "old model field only",
+ defaults: AgentDefaults{Model: "legacy-model"},
+ wantName: "legacy-model",
+ },
+ {
+ name: "both fields - model_name takes precedence",
+ defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"},
+ wantName: "new-model",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.defaults.GetModelName(); got != tt.wantName {
+ t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
+ }
+ })
+ }
+}
+
+func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
+ tests := []struct {
+ name string
+ json string
+ wantName string
+ }{
+ {
+ name: "new model_name field",
+ json: `{"model_name": "gpt4"}`,
+ wantName: "gpt4",
+ },
+ {
+ name: "old model field",
+ json: `{"model": "gpt4"}`,
+ wantName: "gpt4",
+ },
+ {
+ name: "both fields - model_name wins",
+ json: `{"model_name": "new", "model": "old"}`,
+ wantName: "new",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var defaults AgentDefaults
+ if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {
+ t.Fatalf("Unmarshal error: %v", err)
+ }
+ if got := defaults.GetModelName(); got != tt.wantName {
+ t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
+ }
+ })
+ }
+}
+
+func TestFullConfig_JSON_BackwardCompat(t *testing.T) {
+ // Test complete config with both old and new formats
+ oldFormat := `{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "gpt4",
+ "max_tokens": 4096
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-4o",
+ "api_key": "test-key"
+ }
+ ]
+ }`
+
+ newFormat := `{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model_name": "gpt4",
+ "max_tokens": 4096
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-4o",
+ "api_key": "test-key"
+ }
+ ]
+ }`
+
+ for name, jsonStr := range map[string]string{
+ "old format (model)": oldFormat,
+ "new format (model_name)": newFormat,
+ } {
+ t.Run(name, func(t *testing.T) {
+ cfg := &Config{}
+ if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
+ t.Fatalf("Unmarshal error: %v", err)
+ }
+
+ // Check that GetModelName returns correct value
+ if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" {
+ t.Errorf("GetModelName() = %q, want %q", got, "gpt4")
+ }
+
+ // Check that GetModelConfig works
+ modelCfg, err := cfg.GetModelConfig("gpt4")
+ if err != nil {
+ t.Fatalf("GetModelConfig error: %v", err)
+ }
+ if modelCfg.Model != "openai/gpt-4o" {
+ t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o")
+ }
+ })
+ }
+}
+
func TestModelConfig_Validate(t *testing.T) {
tests := []struct {
name string
@@ -233,3 +365,38 @@ func TestConfig_ValidateModelList(t *testing.T) {
})
}
}
+
+func TestModelConfig_RequestTimeoutParsing(t *testing.T) {
+ jsonData := `{
+ "model_name": "slow-local",
+ "model": "openai/local-model",
+ "api_base": "http://localhost:11434/v1",
+ "request_timeout": 300
+ }`
+
+ var cfg ModelConfig
+ if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if cfg.RequestTimeout != 300 {
+ t.Fatalf("RequestTimeout = %d, want 300", cfg.RequestTimeout)
+ }
+}
+
+func TestModelConfig_RequestTimeoutDefaultZeroValue(t *testing.T) {
+ jsonData := `{
+ "model_name": "default-timeout",
+ "model": "openai/gpt-4o",
+ "api_key": "test-key"
+ }`
+
+ var cfg ModelConfig
+ if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if cfg.RequestTimeout != 0 {
+ t.Fatalf("RequestTimeout = %d, want 0", cfg.RequestTimeout)
+ }
+}
diff --git a/pkg/cron/service.go b/pkg/cron/service.go
index e699a44b5..6962041c1 100644
--- a/pkg/cron/service.go
+++ b/pkg/cron/service.go
@@ -7,11 +7,12 @@ import (
"fmt"
"log"
"os"
- "path/filepath"
"sync"
"time"
"github.com/adhocore/gronx"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
type CronSchedule struct {
@@ -330,17 +331,13 @@ func (cs *CronService) loadStore() error {
}
func (cs *CronService) saveStoreUnsafe() error {
- dir := filepath.Dir(cs.storePath)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
-
data, err := json.MarshalIndent(cs.store, "", " ")
if err != nil {
return err
}
- return os.WriteFile(cs.storePath, data, 0o600)
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ return fileutil.WriteFileAtomic(cs.storePath, data, 0o600)
}
func (cs *CronService) AddJob(
diff --git a/pkg/devices/service.go b/pkg/devices/service.go
index 1541d3c57..1bafe6085 100644
--- a/pkg/devices/service.go
+++ b/pkg/devices/service.go
@@ -4,6 +4,7 @@ import (
"context"
"strings"
"sync"
+ "time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/constants"
@@ -127,7 +128,9 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
}
msg := ev.FormatMessage()
- msgBus.PublishOutbound(bus.OutboundMessage{
+ pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer pubCancel()
+ msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: platform,
ChatID: userID,
Content: msg,
diff --git a/pkg/devices/sources/usb_linux.go b/pkg/devices/sources/usb_linux.go
index be0193cfb..2bb38941f 100644
--- a/pkg/devices/sources/usb_linux.go
+++ b/pkg/devices/sources/usb_linux.go
@@ -35,9 +35,8 @@ var usbClassToCapability = map[string]string{
}
type USBMonitor struct {
- cmd *exec.Cmd
- cancel context.CancelFunc
- mu sync.Mutex
+ cmd *exec.Cmd
+ mu sync.Mutex
}
func NewUSBMonitor() *USBMonitor {
diff --git a/pkg/fileutil/file.go b/pkg/fileutil/file.go
new file mode 100644
index 000000000..7ca872374
--- /dev/null
+++ b/pkg/fileutil/file.go
@@ -0,0 +1,119 @@
+// 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 fileutil provides file manipulation utilities.
+package fileutil
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern.
+//
+// This guarantees that the target file is either:
+// - Completely written with the new data
+// - Unchanged (if any step fails before rename)
+//
+// The function:
+// 1. Creates a temp file in the same directory (original untouched)
+// 2. Writes data to temp file
+// 3. Syncs data to disk (critical for SD cards/flash storage)
+// 4. Sets file permissions
+// 5. Syncs directory metadata (ensures rename is durable)
+// 6. Atomically renames temp file to target path
+//
+// Safety guarantees:
+// - Original file is NEVER modified until successful rename
+// - Temp file is always cleaned up on error
+// - Data is flushed to physical storage before rename
+// - Directory entry is synced to prevent orphaned inodes
+//
+// Parameters:
+// - path: Target file path
+// - data: Data to write
+// - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable)
+//
+// Returns:
+// - Error if any step fails, nil on success
+//
+// Example:
+//
+// // Secure config file (owner read/write only)
+// err := utils.WriteFileAtomic("config.json", data, 0o600)
+//
+// // Public readable file
+// err := utils.WriteFileAtomic("public.txt", data, 0o644)
+func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create directory: %w", err)
+ }
+
+ // Create temp file in the same directory (ensures atomic rename works)
+ // Using a hidden prefix (.tmp-) to avoid issues with some tools
+ tmpFile, err := os.OpenFile(
+ filepath.Join(dir, fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())),
+ os.O_WRONLY|os.O_CREATE|os.O_EXCL,
+ perm,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to create temp file: %w", err)
+ }
+
+ tmpPath := tmpFile.Name()
+ cleanup := true
+
+ defer func() {
+ if cleanup {
+ tmpFile.Close()
+ _ = os.Remove(tmpPath)
+ }
+ }()
+
+ // Write data to temp file
+ // Note: Original file is untouched at this point
+ if _, err := tmpFile.Write(data); err != nil {
+ return fmt.Errorf("failed to write temp file: %w", err)
+ }
+
+ // CRITICAL: Force sync to storage medium before any other operations.
+ // This ensures data is physically written to disk, not just cached.
+ // Essential for SD cards, eMMC, and other flash storage on edge devices.
+ if err := tmpFile.Sync(); err != nil {
+ return fmt.Errorf("failed to sync temp file: %w", err)
+ }
+
+ // Set file permissions before closing
+ if err := tmpFile.Chmod(perm); err != nil {
+ return fmt.Errorf("failed to set permissions: %w", err)
+ }
+
+ // Close file before rename (required on Windows)
+ if err := tmpFile.Close(); err != nil {
+ return fmt.Errorf("failed to close temp file: %w", err)
+ }
+
+ // Atomic rename: temp file becomes the target
+ // On POSIX: rename() is atomic
+ // On Windows: Rename() is atomic for files
+ if err := os.Rename(tmpPath, path); err != nil {
+ return fmt.Errorf("failed to rename temp file: %w", err)
+ }
+
+ // Sync directory to ensure rename is durable
+ // This prevents the renamed file from disappearing after a crash
+ if dirFile, err := os.Open(dir); err == nil {
+ _ = dirFile.Sync()
+ dirFile.Close()
+ }
+
+ // Success: skip cleanup (file was renamed, no temp to remove)
+ cleanup = false
+ return nil
+}
diff --git a/pkg/health/server.go b/pkg/health/server.go
index 77b36034d..de1ff60fe 100644
--- a/pkg/health/server.go
+++ b/pkg/health/server.go
@@ -156,6 +156,13 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
})
}
+// RegisterOnMux registers /health and /ready handlers onto the given mux.
+// This allows the health endpoints to be served by a shared HTTP server.
+func (s *Server) RegisterOnMux(mux *http.ServeMux) {
+ mux.HandleFunc("/health", s.healthHandler)
+ mux.HandleFunc("/ready", s.readyHandler)
+}
+
func statusString(ok bool) string {
if ok {
return "ok"
diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go
index 75d6248b9..09c93fc6b 100644
--- a/pkg/heartbeat/service.go
+++ b/pkg/heartbeat/service.go
@@ -7,6 +7,7 @@
package heartbeat
import (
+ "context"
"fmt"
"os"
"path/filepath"
@@ -16,6 +17,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/constants"
+ "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
@@ -166,7 +168,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
}
if handler == nil {
- hs.logError("Heartbeat handler not configured")
+ hs.logErrorf("Heartbeat handler not configured")
return
}
@@ -175,23 +177,23 @@ func (hs *HeartbeatService) executeHeartbeat() {
channel, chatID := hs.parseLastChannel(lastChannel)
// Debug log for channel resolution
- hs.logInfo("Resolved channel: %s, chatID: %s (from lastChannel: %s)", channel, chatID, lastChannel)
+ hs.logInfof("Resolved channel: %s, chatID: %s (from lastChannel: %s)", channel, chatID, lastChannel)
result := handler(prompt, channel, chatID)
if result == nil {
- hs.logInfo("Heartbeat handler returned nil result")
+ hs.logInfof("Heartbeat handler returned nil result")
return
}
// Handle different result types
if result.IsError {
- hs.logError("Heartbeat error: %s", result.ForLLM)
+ hs.logErrorf("Heartbeat error: %s", result.ForLLM)
return
}
if result.Async {
- hs.logInfo("Async task started: %s", result.ForLLM)
+ hs.logInfof("Async task started: %s", result.ForLLM)
logger.InfoCF("heartbeat", "Async heartbeat task started",
map[string]any{
"message": result.ForLLM,
@@ -201,7 +203,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
// Check if silent
if result.Silent {
- hs.logInfo("Heartbeat OK - silent")
+ hs.logInfof("Heartbeat OK - silent")
return
}
@@ -212,7 +214,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
hs.sendResponse(result.ForLLM)
}
- hs.logInfo("Heartbeat completed: %s", result.ForLLM)
+ hs.logInfof("Heartbeat completed: %s", result.ForLLM)
}
// buildPrompt builds the heartbeat prompt from HEARTBEAT.md
@@ -225,7 +227,7 @@ func (hs *HeartbeatService) buildPrompt() string {
hs.createDefaultHeartbeatTemplate()
return ""
}
- hs.logError("Error reading HEARTBEAT.md: %v", err)
+ hs.logErrorf("Error reading HEARTBEAT.md: %v", err)
return ""
}
@@ -275,10 +277,10 @@ This file contains tasks for the heartbeat service to check periodically.
Add your heartbeat tasks below this line:
`
- if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
- hs.logError("Failed to create default HEARTBEAT.md: %v", err)
+ if err := fileutil.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
+ hs.logErrorf("Failed to create default HEARTBEAT.md: %v", err)
} else {
- hs.logInfo("Created default HEARTBEAT.md template")
+ hs.logInfof("Created default HEARTBEAT.md template")
}
}
@@ -289,14 +291,14 @@ func (hs *HeartbeatService) sendResponse(response string) {
hs.mu.RUnlock()
if msgBus == nil {
- hs.logInfo("No message bus configured, heartbeat result not sent")
+ hs.logInfof("No message bus configured, heartbeat result not sent")
return
}
// Get last channel from state
lastChannel := hs.state.GetLastChannel()
if lastChannel == "" {
- hs.logInfo("No last channel recorded, heartbeat result not sent")
+ hs.logInfof("No last channel recorded, heartbeat result not sent")
return
}
@@ -307,13 +309,15 @@ func (hs *HeartbeatService) sendResponse(response string) {
return
}
- msgBus.PublishOutbound(bus.OutboundMessage{
+ pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer pubCancel()
+ msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: platform,
ChatID: userID,
Content: response,
})
- hs.logInfo("Heartbeat result sent to %s", platform)
+ hs.logInfof("Heartbeat result sent to %s", platform)
}
// parseLastChannel parses the last channel string into platform and userID.
@@ -326,7 +330,7 @@ func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, user
// Parse channel format: "platform:user_id" (e.g., "telegram:123456")
parts := strings.SplitN(lastChannel, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
- hs.logError("Invalid last channel format: %s", lastChannel)
+ hs.logErrorf("Invalid last channel format: %s", lastChannel)
return "", ""
}
@@ -334,25 +338,25 @@ func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, user
// Skip internal channels
if constants.IsInternalChannel(platform) {
- hs.logInfo("Skipping internal channel: %s", platform)
+ hs.logInfof("Skipping internal channel: %s", platform)
return "", ""
}
return platform, userID
}
-// logInfo logs an informational message to the heartbeat log
-func (hs *HeartbeatService) logInfo(format string, args ...any) {
- hs.log("INFO", format, args...)
+// logInfof logs an informational message to the heartbeat log
+func (hs *HeartbeatService) logInfof(format string, args ...any) {
+ hs.logf("INFO", format, args...)
}
-// logError logs an error message to the heartbeat log
-func (hs *HeartbeatService) logError(format string, args ...any) {
- hs.log("ERROR", format, args...)
+// logErrorf logs an error message to the heartbeat log
+func (hs *HeartbeatService) logErrorf(format string, args ...any) {
+ hs.logf("ERROR", format, args...)
}
-// log writes a message to the heartbeat log file
-func (hs *HeartbeatService) log(level, format string, args ...any) {
+// logf writes a message to the heartbeat log file
+func (hs *HeartbeatService) logf(level, format string, args ...any) {
logFile := filepath.Join(hs.workspace, "heartbeat.log")
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go
index a4dfa7a72..a7aef8c3a 100644
--- a/pkg/heartbeat/service_test.go
+++ b/pkg/heartbeat/service_test.go
@@ -191,7 +191,7 @@ func TestLogPath(t *testing.T) {
hs := NewHeartbeatService(tmpDir, 30, true)
// Write a log entry
- hs.log("INFO", "Test log entry")
+ hs.logf("INFO", "Test log entry")
// Verify log file exists at workspace root
expectedLogPath := filepath.Join(tmpDir, "heartbeat.log")
diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go
new file mode 100644
index 000000000..6bc09c210
--- /dev/null
+++ b/pkg/identity/identity.go
@@ -0,0 +1,107 @@
+// Package identity provides unified user identity utilities for PicoClaw.
+// It introduces a canonical "platform:id" format and matching logic
+// that is backward-compatible with all legacy allow-list formats.
+package identity
+
+import (
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+)
+
+// BuildCanonicalID constructs a canonical "platform:id" identifier.
+// Both platform and platformID are lowercased and trimmed.
+func BuildCanonicalID(platform, platformID string) string {
+ p := strings.ToLower(strings.TrimSpace(platform))
+ id := strings.TrimSpace(platformID)
+ if p == "" || id == "" {
+ return ""
+ }
+ return p + ":" + id
+}
+
+// ParseCanonicalID splits a canonical ID ("platform:id") into its parts.
+// Returns ok=false if the input does not contain a colon separator.
+func ParseCanonicalID(canonical string) (platform, id string, ok bool) {
+ canonical = strings.TrimSpace(canonical)
+ idx := strings.Index(canonical, ":")
+ if idx <= 0 || idx == len(canonical)-1 {
+ return "", "", false
+ }
+ return canonical[:idx], canonical[idx+1:], true
+}
+
+// MatchAllowed checks whether the given sender matches a single allow-list entry.
+// It is backward-compatible with all legacy formats:
+//
+// - "123456" → matches sender.PlatformID
+// - "@alice" → matches sender.Username
+// - "123456|alice" → matches PlatformID or Username
+// - "telegram:123456" → exact match on sender.CanonicalID
+func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
+ allowed = strings.TrimSpace(allowed)
+ if allowed == "" {
+ return false
+ }
+
+ // Try canonical match first: "platform:id" format
+ if platform, id, ok := ParseCanonicalID(allowed); ok {
+ // Only treat as canonical if the platform portion looks like a known platform name
+ // (not a pure-numeric string, which could be a compound ID)
+ if !isNumeric(platform) {
+ candidate := BuildCanonicalID(platform, id)
+ if candidate != "" && sender.CanonicalID != "" {
+ return strings.EqualFold(sender.CanonicalID, candidate)
+ }
+ // If sender has no canonical ID, try matching platform + platformID
+ return strings.EqualFold(platform, sender.Platform) &&
+ sender.PlatformID == id
+ }
+ }
+
+ // Strip leading "@" for username matching
+ trimmed := strings.TrimPrefix(allowed, "@")
+
+ // Split compound "id|username" format
+ allowedID := trimmed
+ allowedUser := ""
+ if idx := strings.Index(trimmed, "|"); idx > 0 {
+ allowedID = trimmed[:idx]
+ allowedUser = trimmed[idx+1:]
+ }
+
+ // Match against PlatformID
+ if sender.PlatformID != "" && sender.PlatformID == allowedID {
+ return true
+ }
+
+ // Match against Username
+ if sender.Username != "" {
+ if sender.Username == trimmed || sender.Username == allowedUser {
+ return true
+ }
+ }
+
+ // Match compound sender format against allowed parts
+ if allowedUser != "" && sender.PlatformID != "" && sender.PlatformID == allowedID {
+ return true
+ }
+ if allowedUser != "" && sender.Username != "" && sender.Username == allowedUser {
+ return true
+ }
+
+ return false
+}
+
+// isNumeric returns true if s consists entirely of digits.
+func isNumeric(s string) bool {
+ if s == "" {
+ return false
+ }
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go
new file mode 100644
index 000000000..3d24bd794
--- /dev/null
+++ b/pkg/identity/identity_test.go
@@ -0,0 +1,229 @@
+package identity
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+)
+
+func TestBuildCanonicalID(t *testing.T) {
+ tests := []struct {
+ platform string
+ platformID string
+ want string
+ }{
+ {"telegram", "123456", "telegram:123456"},
+ {"Discord", "98765432", "discord:98765432"},
+ {"SLACK", "U123ABC", "slack:U123ABC"},
+ {"", "123", ""},
+ {"telegram", "", ""},
+ {" telegram ", " 123 ", "telegram:123"},
+ }
+
+ for _, tt := range tests {
+ got := BuildCanonicalID(tt.platform, tt.platformID)
+ if got != tt.want {
+ t.Errorf("BuildCanonicalID(%q, %q) = %q, want %q",
+ tt.platform, tt.platformID, got, tt.want)
+ }
+ }
+}
+
+func TestParseCanonicalID(t *testing.T) {
+ tests := []struct {
+ input string
+ wantPlatform string
+ wantID string
+ wantOk bool
+ }{
+ {"telegram:123456", "telegram", "123456", true},
+ {"discord:98765432", "discord", "98765432", true},
+ {"slack:U123ABC", "slack", "U123ABC", true},
+ {"nocolon", "", "", false},
+ {"", "", "", false},
+ {":missing", "", "", false},
+ {"missing:", "", "", false},
+ }
+
+ for _, tt := range tests {
+ platform, id, ok := ParseCanonicalID(tt.input)
+ if ok != tt.wantOk || platform != tt.wantPlatform || id != tt.wantID {
+ t.Errorf("ParseCanonicalID(%q) = (%q, %q, %v), want (%q, %q, %v)",
+ tt.input, platform, id, ok,
+ tt.wantPlatform, tt.wantID, tt.wantOk)
+ }
+ }
+}
+
+func TestMatchAllowed(t *testing.T) {
+ telegramSender := bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: "123456",
+ CanonicalID: "telegram:123456",
+ Username: "alice",
+ DisplayName: "Alice Smith",
+ }
+
+ discordSender := bus.SenderInfo{
+ Platform: "discord",
+ PlatformID: "98765432",
+ CanonicalID: "discord:98765432",
+ Username: "bob",
+ DisplayName: "bob#1234",
+ }
+
+ noCanonicalSender := bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: "999",
+ Username: "carol",
+ }
+
+ tests := []struct {
+ name string
+ sender bus.SenderInfo
+ allowed string
+ want bool
+ }{
+ // Pure numeric ID matching
+ {
+ name: "numeric ID matches PlatformID",
+ sender: telegramSender,
+ allowed: "123456",
+ want: true,
+ },
+ {
+ name: "numeric ID does not match",
+ sender: telegramSender,
+ allowed: "654321",
+ want: false,
+ },
+ // Username matching
+ {
+ name: "@username matches Username",
+ sender: telegramSender,
+ allowed: "@alice",
+ want: true,
+ },
+ {
+ name: "@username does not match",
+ sender: telegramSender,
+ allowed: "@bob",
+ want: false,
+ },
+ // Compound format "id|username"
+ {
+ name: "compound matches by ID",
+ sender: telegramSender,
+ allowed: "123456|alice",
+ want: true,
+ },
+ {
+ name: "compound matches by username",
+ sender: telegramSender,
+ allowed: "999|alice",
+ want: true,
+ },
+ {
+ name: "compound does not match",
+ sender: telegramSender,
+ allowed: "654321|bob",
+ want: false,
+ },
+ // Canonical format "platform:id"
+ {
+ name: "canonical matches exactly",
+ sender: telegramSender,
+ allowed: "telegram:123456",
+ want: true,
+ },
+ {
+ name: "canonical case-insensitive platform",
+ sender: telegramSender,
+ allowed: "Telegram:123456",
+ want: true,
+ },
+ {
+ name: "canonical wrong platform",
+ sender: telegramSender,
+ allowed: "discord:123456",
+ want: false,
+ },
+ {
+ name: "canonical wrong ID",
+ sender: telegramSender,
+ allowed: "telegram:654321",
+ want: false,
+ },
+ // Cross-platform canonical
+ {
+ name: "discord canonical match",
+ sender: discordSender,
+ allowed: "discord:98765432",
+ want: true,
+ },
+ {
+ name: "telegram canonical does not match discord sender",
+ sender: discordSender,
+ allowed: "telegram:98765432",
+ want: false,
+ },
+ // Sender without canonical ID
+ {
+ name: "canonical match falls back to platform+platformID",
+ sender: noCanonicalSender,
+ allowed: "telegram:999",
+ want: true,
+ },
+ {
+ name: "platform mismatch on fallback",
+ sender: noCanonicalSender,
+ allowed: "discord:999",
+ want: false,
+ },
+ // Empty allowed string
+ {
+ name: "empty allowed never matches",
+ sender: telegramSender,
+ allowed: "",
+ want: false,
+ },
+ // Whitespace handling
+ {
+ name: "trimmed allowed matches",
+ sender: telegramSender,
+ allowed: " 123456 ",
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := MatchAllowed(tt.sender, tt.allowed)
+ if got != tt.want {
+ t.Errorf("MatchAllowed(%+v, %q) = %v, want %v",
+ tt.sender, tt.allowed, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsNumeric(t *testing.T) {
+ tests := []struct {
+ input string
+ want bool
+ }{
+ {"123456", true},
+ {"0", true},
+ {"", false},
+ {"abc", false},
+ {"12a34", false},
+ {"telegram", false},
+ }
+
+ for _, tt := range tests {
+ got := isNumeric(tt.input)
+ if got != tt.want {
+ t.Errorf("isNumeric(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ }
+}
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index 54de66bf9..56dc87a53 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -119,13 +119,15 @@ func logMessage(level LogLevel, component string, message string, fields map[str
if logger.file != nil {
jsonData, err := json.Marshal(entry)
if err == nil {
- logger.file.WriteString(string(jsonData) + "\n")
+ logger.file.Write(append(jsonData, '\n'))
}
}
var fieldStr string
if len(fields) > 0 {
fieldStr = " " + formatFields(fields)
+ } else {
+ fieldStr = ""
}
logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
@@ -151,7 +153,7 @@ func formatComponent(component string) string {
}
func formatFields(fields map[string]any) string {
- var parts []string
+ parts := make([]string, 0, len(fields))
for k, v := range fields {
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
}
diff --git a/pkg/media/store.go b/pkg/media/store.go
new file mode 100644
index 000000000..30220986c
--- /dev/null
+++ b/pkg/media/store.go
@@ -0,0 +1,271 @@
+package media
+
+import (
+ "fmt"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// MediaMeta holds metadata about a stored media file.
+type MediaMeta struct {
+ Filename string
+ ContentType string
+ Source string // "telegram", "discord", "tool:image-gen", etc.
+}
+
+// MediaStore manages the lifecycle of media files associated with processing scopes.
+type MediaStore interface {
+ // Store registers an existing local file under the given scope.
+ // Returns a ref identifier (e.g. "media://").
+ // Store does not move or copy the file; it only records the mapping.
+ Store(localPath string, meta MediaMeta, scope string) (ref string, err error)
+
+ // Resolve returns the local file path for a given ref.
+ Resolve(ref string) (localPath string, err error)
+
+ // ResolveWithMeta returns the local file path and metadata for a given ref.
+ ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error)
+
+ // ReleaseAll deletes all files registered under the given scope
+ // and removes the mapping entries. File-not-exist errors are ignored.
+ ReleaseAll(scope string) error
+}
+
+// mediaEntry holds the path and metadata for a stored media file.
+type mediaEntry struct {
+ path string
+ meta MediaMeta
+ storedAt time.Time
+}
+
+// MediaCleanerConfig configures the background TTL cleanup.
+type MediaCleanerConfig struct {
+ Enabled bool
+ MaxAge time.Duration
+ Interval time.Duration
+}
+
+// FileMediaStore is a pure in-memory implementation of MediaStore.
+// Files are expected to already exist on disk (e.g. in /tmp/picoclaw_media/).
+type FileMediaStore struct {
+ mu sync.RWMutex
+ refs map[string]mediaEntry
+ scopeToRefs map[string]map[string]struct{}
+ refToScope map[string]string
+
+ cleanerCfg MediaCleanerConfig
+ stop chan struct{}
+ startOnce sync.Once
+ stopOnce sync.Once
+ nowFunc func() time.Time // for testing
+}
+
+// NewFileMediaStore creates a new FileMediaStore without background cleanup.
+func NewFileMediaStore() *FileMediaStore {
+ return &FileMediaStore{
+ refs: make(map[string]mediaEntry),
+ scopeToRefs: make(map[string]map[string]struct{}),
+ refToScope: make(map[string]string),
+ nowFunc: time.Now,
+ }
+}
+
+// NewFileMediaStoreWithCleanup creates a FileMediaStore with TTL-based background cleanup.
+func NewFileMediaStoreWithCleanup(cfg MediaCleanerConfig) *FileMediaStore {
+ return &FileMediaStore{
+ refs: make(map[string]mediaEntry),
+ scopeToRefs: make(map[string]map[string]struct{}),
+ refToScope: make(map[string]string),
+ cleanerCfg: cfg,
+ stop: make(chan struct{}),
+ nowFunc: time.Now,
+ }
+}
+
+// Store registers a local file under the given scope. The file must exist.
+func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) (string, error) {
+ if _, err := os.Stat(localPath); err != nil {
+ return "", fmt.Errorf("media store: %s: %w", localPath, err)
+ }
+
+ ref := "media://" + uuid.New().String()
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.refs[ref] = mediaEntry{path: localPath, meta: meta, storedAt: s.nowFunc()}
+ if s.scopeToRefs[scope] == nil {
+ s.scopeToRefs[scope] = make(map[string]struct{})
+ }
+ s.scopeToRefs[scope][ref] = struct{}{}
+ s.refToScope[ref] = scope
+
+ return ref, nil
+}
+
+// Resolve returns the local path for the given ref.
+func (s *FileMediaStore) Resolve(ref string) (string, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ entry, ok := s.refs[ref]
+ if !ok {
+ return "", fmt.Errorf("media store: unknown ref: %s", ref)
+ }
+ return entry.path, nil
+}
+
+// ResolveWithMeta returns the local path and metadata for the given ref.
+func (s *FileMediaStore) ResolveWithMeta(ref string) (string, MediaMeta, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ entry, ok := s.refs[ref]
+ if !ok {
+ return "", MediaMeta{}, fmt.Errorf("media store: unknown ref: %s", ref)
+ }
+ return entry.path, entry.meta, nil
+}
+
+// ReleaseAll removes all files under the given scope and cleans up mappings.
+// Phase 1 (under lock): remove entries from maps.
+// Phase 2 (no lock): delete files from disk.
+func (s *FileMediaStore) ReleaseAll(scope string) error {
+ // Phase 1: collect paths and remove from maps under lock
+ var paths []string
+
+ s.mu.Lock()
+ refs, ok := s.scopeToRefs[scope]
+ if !ok {
+ s.mu.Unlock()
+ return nil
+ }
+
+ for ref := range refs {
+ if entry, exists := s.refs[ref]; exists {
+ paths = append(paths, entry.path)
+ }
+ delete(s.refs, ref)
+ delete(s.refToScope, ref)
+ }
+ delete(s.scopeToRefs, scope)
+ s.mu.Unlock()
+
+ // Phase 2: delete files without holding the lock
+ for _, p := range paths {
+ if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
+ logger.WarnCF("media", "release: failed to remove file", map[string]any{
+ "path": p,
+ "error": err.Error(),
+ })
+ }
+ }
+
+ return nil
+}
+
+// CleanExpired removes all entries older than MaxAge.
+// Phase 1 (under lock): identify expired entries and remove from maps.
+// Phase 2 (no lock): delete files from disk to minimize lock contention.
+func (s *FileMediaStore) CleanExpired() int {
+ if s.cleanerCfg.MaxAge <= 0 {
+ return 0
+ }
+
+ // Phase 1: collect expired entries under lock
+ type expiredEntry struct {
+ ref string
+ path string
+ }
+
+ s.mu.Lock()
+ cutoff := s.nowFunc().Add(-s.cleanerCfg.MaxAge)
+ var expired []expiredEntry
+
+ for ref, entry := range s.refs {
+ if entry.storedAt.Before(cutoff) {
+ expired = append(expired, expiredEntry{ref: ref, path: entry.path})
+
+ if scope, ok := s.refToScope[ref]; ok {
+ if scopeRefs, ok := s.scopeToRefs[scope]; ok {
+ delete(scopeRefs, ref)
+ if len(scopeRefs) == 0 {
+ delete(s.scopeToRefs, scope)
+ }
+ }
+ }
+
+ delete(s.refs, ref)
+ delete(s.refToScope, ref)
+ }
+ }
+ s.mu.Unlock()
+
+ // Phase 2: delete files without holding the lock
+ for _, e := range expired {
+ if err := os.Remove(e.path); err != nil && !os.IsNotExist(err) {
+ logger.WarnCF("media", "cleanup: failed to remove file", map[string]any{
+ "path": e.path,
+ "error": err.Error(),
+ })
+ }
+ }
+
+ return len(expired)
+}
+
+// Start begins the background cleanup goroutine if cleanup is enabled.
+// Safe to call multiple times; only the first call starts the goroutine.
+func (s *FileMediaStore) Start() {
+ if !s.cleanerCfg.Enabled || s.stop == nil {
+ return
+ }
+ if s.cleanerCfg.Interval <= 0 || s.cleanerCfg.MaxAge <= 0 {
+ logger.WarnCF("media", "cleanup: skipped due to invalid config", map[string]any{
+ "interval": s.cleanerCfg.Interval.String(),
+ "max_age": s.cleanerCfg.MaxAge.String(),
+ })
+ return
+ }
+
+ s.startOnce.Do(func() {
+ logger.InfoCF("media", "cleanup enabled", map[string]any{
+ "interval": s.cleanerCfg.Interval.String(),
+ "max_age": s.cleanerCfg.MaxAge.String(),
+ })
+
+ go func() {
+ ticker := time.NewTicker(s.cleanerCfg.Interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ if n := s.CleanExpired(); n > 0 {
+ logger.InfoCF("media", "cleanup: removed expired entries", map[string]any{
+ "count": n,
+ })
+ }
+ case <-s.stop:
+ return
+ }
+ }
+ }()
+ })
+}
+
+// Stop terminates the background cleanup goroutine.
+// Safe to call multiple times; only the first call closes the channel.
+func (s *FileMediaStore) Stop() {
+ if s.stop == nil {
+ return
+ }
+ s.stopOnce.Do(func() {
+ close(s.stop)
+ })
+}
diff --git a/pkg/media/store_test.go b/pkg/media/store_test.go
new file mode 100644
index 000000000..989f90d7c
--- /dev/null
+++ b/pkg/media/store_test.go
@@ -0,0 +1,530 @@
+package media
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func createTempFile(t *testing.T, dir, name string) string {
+ t.Helper()
+ path := filepath.Join(dir, name)
+ if err := os.WriteFile(path, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to create temp file: %v", err)
+ }
+ return path
+}
+
+func TestStoreAndResolve(t *testing.T) {
+ dir := t.TempDir()
+ store := NewFileMediaStore()
+
+ path := createTempFile(t, dir, "photo.jpg")
+
+ ref, err := store.Store(path, MediaMeta{Filename: "photo.jpg", Source: "telegram"}, "scope1")
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ if !strings.HasPrefix(ref, "media://") {
+ t.Errorf("ref should start with media://, got %q", ref)
+ }
+
+ resolved, err := store.Resolve(ref)
+ if err != nil {
+ t.Fatalf("Resolve failed: %v", err)
+ }
+ if resolved != path {
+ t.Errorf("Resolve returned %q, want %q", resolved, path)
+ }
+}
+
+func TestReleaseAll(t *testing.T) {
+ dir := t.TempDir()
+ store := NewFileMediaStore()
+
+ paths := make([]string, 3)
+ refs := make([]string, 3)
+ for i := 0; i < 3; i++ {
+ paths[i] = createTempFile(t, dir, strings.Repeat("a", i+1)+".jpg")
+ var err error
+ refs[i], err = store.Store(paths[i], MediaMeta{Source: "test"}, "scope1")
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+ }
+
+ if err := store.ReleaseAll("scope1"); err != nil {
+ t.Fatalf("ReleaseAll failed: %v", err)
+ }
+
+ // Files should be deleted
+ for _, p := range paths {
+ if _, err := os.Stat(p); !os.IsNotExist(err) {
+ t.Errorf("file %q should have been deleted", p)
+ }
+ }
+
+ // Refs should be unresolvable
+ for _, ref := range refs {
+ if _, err := store.Resolve(ref); err == nil {
+ t.Errorf("Resolve(%q) should fail after ReleaseAll", ref)
+ }
+ }
+}
+
+func TestMultiScopeIsolation(t *testing.T) {
+ dir := t.TempDir()
+ store := NewFileMediaStore()
+
+ pathA := createTempFile(t, dir, "fileA.jpg")
+ pathB := createTempFile(t, dir, "fileB.jpg")
+
+ refA, _ := store.Store(pathA, MediaMeta{Source: "test"}, "scopeA")
+ refB, _ := store.Store(pathB, MediaMeta{Source: "test"}, "scopeB")
+
+ // Release only scopeA
+ if err := store.ReleaseAll("scopeA"); err != nil {
+ t.Fatalf("ReleaseAll(scopeA) failed: %v", err)
+ }
+
+ // scopeA file should be gone
+ if _, err := os.Stat(pathA); !os.IsNotExist(err) {
+ t.Error("file A should have been deleted")
+ }
+ if _, err := store.Resolve(refA); err == nil {
+ t.Error("refA should be unresolvable after release")
+ }
+
+ // scopeB file should still exist
+ if _, err := os.Stat(pathB); err != nil {
+ t.Error("file B should still exist")
+ }
+ resolved, err := store.Resolve(refB)
+ if err != nil {
+ t.Fatalf("refB should still resolve: %v", err)
+ }
+ if resolved != pathB {
+ t.Errorf("resolved %q, want %q", resolved, pathB)
+ }
+}
+
+func TestReleaseAllIdempotent(t *testing.T) {
+ store := NewFileMediaStore()
+
+ // ReleaseAll on non-existent scope should not error
+ if err := store.ReleaseAll("nonexistent"); err != nil {
+ t.Fatalf("ReleaseAll on empty scope should not error: %v", err)
+ }
+
+ // Create and release, then release again
+ dir := t.TempDir()
+ path := createTempFile(t, dir, "file.jpg")
+ _, _ = store.Store(path, MediaMeta{Source: "test"}, "scope1")
+
+ if err := store.ReleaseAll("scope1"); err != nil {
+ t.Fatalf("first ReleaseAll failed: %v", err)
+ }
+ if err := store.ReleaseAll("scope1"); err != nil {
+ t.Fatalf("second ReleaseAll should not error: %v", err)
+ }
+}
+
+func TestReleaseAllCleansMappingsIfRefsMissing(t *testing.T) {
+ dir := t.TempDir()
+ store := NewFileMediaStore()
+
+ path := createTempFile(t, dir, "file.jpg")
+ ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1")
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ // Simulate internal inconsistency: scopeToRefs/refToScope contains ref but refs map doesn't.
+ store.mu.Lock()
+ delete(store.refs, ref)
+ store.mu.Unlock()
+
+ if err := store.ReleaseAll("scope1"); err != nil {
+ t.Fatalf("ReleaseAll failed: %v", err)
+ }
+
+ // ReleaseAll should still clean mappings (even if it can't delete the file without the path).
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+ if _, ok := store.refToScope[ref]; ok {
+ t.Error("refToScope should not contain ref after ReleaseAll")
+ }
+ if _, ok := store.scopeToRefs["scope1"]; ok {
+ t.Error("scopeToRefs should not contain scope1 after ReleaseAll")
+ }
+}
+
+func TestStoreNonexistentFile(t *testing.T) {
+ store := NewFileMediaStore()
+
+ _, err := store.Store("/nonexistent/path/file.jpg", MediaMeta{Source: "test"}, "scope1")
+ if err == nil {
+ t.Error("Store should fail for nonexistent file")
+ }
+ // Error message should include the underlying os error, not just "file does not exist"
+ if !strings.Contains(err.Error(), "no such file or directory") &&
+ !strings.Contains(err.Error(), "cannot find") {
+ t.Errorf("Error should contain OS error detail, got: %v", err)
+ }
+}
+
+func TestResolveWithMeta(t *testing.T) {
+ dir := t.TempDir()
+ store := NewFileMediaStore()
+
+ path := createTempFile(t, dir, "image.png")
+ meta := MediaMeta{
+ Filename: "image.png",
+ ContentType: "image/png",
+ Source: "telegram",
+ }
+
+ ref, err := store.Store(path, meta, "scope1")
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ resolvedPath, resolvedMeta, err := store.ResolveWithMeta(ref)
+ if err != nil {
+ t.Fatalf("ResolveWithMeta failed: %v", err)
+ }
+ if resolvedPath != path {
+ t.Errorf("ResolveWithMeta path = %q, want %q", resolvedPath, path)
+ }
+ if resolvedMeta.Filename != meta.Filename {
+ t.Errorf("ResolveWithMeta Filename = %q, want %q", resolvedMeta.Filename, meta.Filename)
+ }
+ if resolvedMeta.ContentType != meta.ContentType {
+ t.Errorf("ResolveWithMeta ContentType = %q, want %q", resolvedMeta.ContentType, meta.ContentType)
+ }
+ if resolvedMeta.Source != meta.Source {
+ t.Errorf("ResolveWithMeta Source = %q, want %q", resolvedMeta.Source, meta.Source)
+ }
+
+ // Unknown ref should fail
+ _, _, err = store.ResolveWithMeta("media://nonexistent")
+ if err == nil {
+ t.Error("ResolveWithMeta should fail for unknown ref")
+ }
+}
+
+func TestConcurrentSafety(t *testing.T) {
+ dir := t.TempDir()
+ store := NewFileMediaStore()
+
+ const goroutines = 20
+ const filesPerGoroutine = 5
+
+ var wg sync.WaitGroup
+ wg.Add(goroutines)
+
+ for g := 0; g < goroutines; g++ {
+ go func(gIdx int) {
+ defer wg.Done()
+ scope := strings.Repeat("s", gIdx+1)
+
+ for i := 0; i < filesPerGoroutine; i++ {
+ path := createTempFile(t, dir, strings.Repeat("f", gIdx*filesPerGoroutine+i+1)+".tmp")
+ ref, err := store.Store(path, MediaMeta{Source: "test"}, scope)
+ if err != nil {
+ t.Errorf("Store failed: %v", err)
+ return
+ }
+
+ if _, err := store.Resolve(ref); err != nil {
+ t.Errorf("Resolve failed: %v", err)
+ }
+ }
+
+ if err := store.ReleaseAll(scope); err != nil {
+ t.Errorf("ReleaseAll failed: %v", err)
+ }
+ }(g)
+ }
+
+ wg.Wait()
+}
+
+// --- TTL cleanup tests ---
+
+func newTestStoreWithCleanup(maxAge time.Duration) *FileMediaStore {
+ s := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
+ Enabled: true,
+ MaxAge: maxAge,
+ Interval: time.Hour, // won't tick in tests
+ })
+ return s
+}
+
+func TestCleanExpiredRemovesOldEntries(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Now()
+ store := newTestStoreWithCleanup(10 * time.Minute)
+ store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }
+
+ path := createTempFile(t, dir, "old.jpg")
+ ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1")
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ // Advance clock to present
+ store.nowFunc = func() time.Time { return now }
+ removed := store.CleanExpired()
+
+ if removed != 1 {
+ t.Errorf("expected 1 removed, got %d", removed)
+ }
+ if _, err := store.Resolve(ref); err == nil {
+ t.Error("expired ref should be unresolvable")
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Error("expired file should be deleted")
+ }
+}
+
+func TestCleanExpiredKeepsNonExpired(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Now()
+ store := newTestStoreWithCleanup(10 * time.Minute)
+ store.nowFunc = func() time.Time { return now }
+
+ path := createTempFile(t, dir, "fresh.jpg")
+ ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1")
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ removed := store.CleanExpired()
+ if removed != 0 {
+ t.Errorf("expected 0 removed, got %d", removed)
+ }
+
+ if _, err := store.Resolve(ref); err != nil {
+ t.Errorf("fresh ref should still resolve: %v", err)
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Error("fresh file should still exist")
+ }
+}
+
+func TestCleanExpiredMixedAges(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Now()
+ store := newTestStoreWithCleanup(10 * time.Minute)
+
+ // Store old entry
+ store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }
+ oldPath := createTempFile(t, dir, "old.jpg")
+ oldRef, _ := store.Store(oldPath, MediaMeta{Source: "test"}, "scope1")
+
+ // Store fresh entry
+ store.nowFunc = func() time.Time { return now }
+ freshPath := createTempFile(t, dir, "fresh.jpg")
+ freshRef, _ := store.Store(freshPath, MediaMeta{Source: "test"}, "scope1")
+
+ removed := store.CleanExpired()
+ if removed != 1 {
+ t.Errorf("expected 1 removed, got %d", removed)
+ }
+
+ if _, err := store.Resolve(oldRef); err == nil {
+ t.Error("old ref should be gone")
+ }
+ if _, err := store.Resolve(freshRef); err != nil {
+ t.Errorf("fresh ref should still resolve: %v", err)
+ }
+}
+
+func TestCleanExpiredCleansEmptyScopes(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Now()
+ store := newTestStoreWithCleanup(10 * time.Minute)
+
+ // Store old entry as the only one in scope
+ store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }
+ path := createTempFile(t, dir, "only.jpg")
+ store.Store(path, MediaMeta{Source: "test"}, "lonely_scope")
+
+ store.nowFunc = func() time.Time { return now }
+ store.CleanExpired()
+
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+ if _, ok := store.scopeToRefs["lonely_scope"]; ok {
+ t.Error("empty scope should be cleaned up")
+ }
+}
+
+func TestStartStopLifecycle(t *testing.T) {
+ store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
+ Enabled: true,
+ MaxAge: time.Minute,
+ Interval: 50 * time.Millisecond,
+ })
+
+ // Start and stop should not panic
+ store.Start()
+ // Double start should not spawn a second goroutine
+ store.Start()
+ time.Sleep(100 * time.Millisecond)
+ store.Stop()
+
+ // Double stop should not panic
+ store.Stop()
+}
+
+func TestCleanExpiredZeroMaxAge(t *testing.T) {
+ store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
+ Enabled: true,
+ MaxAge: 0,
+ Interval: time.Hour,
+ })
+
+ dir := t.TempDir()
+ path := createTempFile(t, dir, "file.jpg")
+ ref, _ := store.Store(path, MediaMeta{Source: "test"}, "scope1")
+
+ // Zero MaxAge should be a no-op
+ removed := store.CleanExpired()
+ if removed != 0 {
+ t.Errorf("expected 0 removed with zero MaxAge, got %d", removed)
+ }
+ if _, err := store.Resolve(ref); err != nil {
+ t.Errorf("ref should still resolve: %v", err)
+ }
+}
+
+func TestStartDisabledIsNoop(t *testing.T) {
+ store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
+ Enabled: false,
+ MaxAge: time.Minute,
+ Interval: time.Minute,
+ })
+ // Should not start any goroutine or panic
+ store.Start()
+ store.Stop()
+}
+
+func TestStartZeroIntervalNoPanic(t *testing.T) {
+ store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
+ Enabled: true,
+ MaxAge: time.Minute,
+ Interval: 0,
+ })
+ // Zero interval should not panic (time.NewTicker panics on <= 0)
+ store.Start()
+ store.Stop()
+}
+
+func TestStartZeroMaxAgeNoPanic(t *testing.T) {
+ store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
+ Enabled: true,
+ MaxAge: 0,
+ Interval: time.Minute,
+ })
+ store.Start()
+ store.Stop()
+}
+
+func TestConcurrentCleanupSafety(t *testing.T) {
+ dir := t.TempDir()
+ store := newTestStoreWithCleanup(50 * time.Millisecond)
+ store.nowFunc = time.Now
+
+ const workers = 10
+ const ops = 20
+ var wg sync.WaitGroup
+ wg.Add(workers * 4)
+
+ // Store workers
+ for w := 0; w < workers; w++ {
+ go func(wIdx int) {
+ defer wg.Done()
+ scope := fmt.Sprintf("scope-%d", wIdx)
+ for i := 0; i < ops; i++ {
+ p := createTempFile(t, dir, fmt.Sprintf("w%d-f%d.tmp", wIdx, i))
+ store.Store(p, MediaMeta{Source: "test"}, scope)
+ }
+ }(w)
+ }
+
+ // Resolve workers
+ for w := 0; w < workers; w++ {
+ go func() {
+ defer wg.Done()
+ for i := 0; i < ops; i++ {
+ store.Resolve("media://nonexistent")
+ }
+ }()
+ }
+
+ // ReleaseAll workers
+ for w := 0; w < workers; w++ {
+ go func(wIdx int) {
+ defer wg.Done()
+ for i := 0; i < ops; i++ {
+ store.ReleaseAll(fmt.Sprintf("scope-%d", wIdx))
+ }
+ }(w)
+ }
+
+ // CleanExpired workers
+ for w := 0; w < workers; w++ {
+ go func() {
+ defer wg.Done()
+ for i := 0; i < ops; i++ {
+ store.CleanExpired()
+ }
+ }()
+ }
+
+ wg.Wait()
+}
+
+func TestRefToScopeConsistency(t *testing.T) {
+ dir := t.TempDir()
+ store := NewFileMediaStore()
+
+ // Store entries in two scopes
+ ref1, _ := store.Store(createTempFile(t, dir, "a.jpg"), MediaMeta{Source: "test"}, "s1")
+ ref2, _ := store.Store(createTempFile(t, dir, "b.jpg"), MediaMeta{Source: "test"}, "s1")
+ ref3, _ := store.Store(createTempFile(t, dir, "c.jpg"), MediaMeta{Source: "test"}, "s2")
+
+ store.mu.RLock()
+ checkRef := func(ref, expectedScope string) {
+ t.Helper()
+ if scope, ok := store.refToScope[ref]; !ok || scope != expectedScope {
+ t.Errorf("refToScope[%s] = %q, want %q", ref, scope, expectedScope)
+ }
+ }
+ checkRef(ref1, "s1")
+ checkRef(ref2, "s1")
+ checkRef(ref3, "s2")
+ store.mu.RUnlock()
+
+ // Release s1 and verify refToScope is cleaned
+ store.ReleaseAll("s1")
+
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+ if _, ok := store.refToScope[ref1]; ok {
+ t.Error("refToScope should not contain ref1 after ReleaseAll")
+ }
+ if _, ok := store.refToScope[ref2]; ok {
+ t.Error("refToScope should not contain ref2 after ReleaseAll")
+ }
+ if _, ok := store.refToScope[ref3]; !ok {
+ t.Error("refToScope should still contain ref3")
+ }
+}
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
index 2237a1429..ea91565e8 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -22,6 +22,7 @@ var supportedProviders = map[string]bool{
"qwen": true,
"deepseek": true,
"github_copilot": true,
+ "mistral": true,
}
var supportedChannels = map[string]bool{
@@ -72,7 +73,10 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
if agents, ok := getMap(data, "agents"); ok {
if defaults, ok := getMap(agents, "defaults"); ok {
- if v, ok := getString(defaults, "model"); ok {
+ // Prefer model_name, fallback to model for backward compatibility
+ if v, ok := getString(defaults, "model_name"); ok {
+ cfg.Agents.Defaults.ModelName = v
+ } else if v, ok := getString(defaults, "model"); ok {
cfg.Agents.Defaults.Model = v
}
if v, ok := getFloat(defaults, "max_tokens"); ok {
@@ -161,6 +165,12 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
if v, ok := getString(cMap, "bridge_url"); ok {
cfg.Channels.WhatsApp.BridgeURL = v
}
+ if v, ok := getBool(cMap, "use_native"); ok {
+ cfg.Channels.WhatsApp.UseNative = v
+ }
+ if v, ok := getString(cMap, "session_store_path"); ok {
+ cfg.Channels.WhatsApp.SessionStorePath = v
+ }
case "feishu":
cfg.Channels.Feishu.Enabled = enabled
cfg.Channels.Feishu.AllowFrom = allowFrom
diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go
index b6b3d70aa..9216442bb 100644
--- a/pkg/migrate/migrate_test.go
+++ b/pkg/migrate/migrate_test.go
@@ -296,8 +296,8 @@ func TestConvertConfig(t *testing.T) {
if len(warnings) != 0 {
t.Errorf("expected no warnings, got %v", warnings)
}
- if cfg.Agents.Defaults.Model != "glm-4.7" {
- t.Errorf("default model should be glm-4.7, got %q", cfg.Agents.Defaults.Model)
+ if cfg.Agents.Defaults.Model != "" {
+ t.Errorf("default model should be nil, got %q", cfg.Agents.Defaults.Model)
}
})
}
diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go
index 35f6b8f62..9162174c9 100644
--- a/pkg/providers/anthropic/provider.go
+++ b/pkg/providers/anthropic/provider.go
@@ -113,7 +113,20 @@ func buildParams(
for _, msg := range messages {
switch msg.Role {
case "system":
- system = append(system, anthropic.TextBlockParam{Text: msg.Content})
+ // Prefer structured SystemParts for per-block cache_control.
+ // This enables LLM-side KV cache reuse: the static block's prefix
+ // hash stays stable across requests while dynamic parts change freely.
+ if len(msg.SystemParts) > 0 {
+ for _, part := range msg.SystemParts {
+ block := anthropic.TextBlockParam{Text: part.Text}
+ if part.CacheControl != nil && part.CacheControl.Type == "ephemeral" {
+ block.CacheControl = anthropic.NewCacheControlEphemeralParam()
+ }
+ system = append(system, block)
+ }
+ } else {
+ system = append(system, anthropic.TextBlockParam{Text: msg.Content})
+ }
case "user":
if msg.ToolCallID != "" {
anthropicMessages = append(anthropicMessages,
diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go
index cff67c88c..d4ee528b7 100644
--- a/pkg/providers/antigravity_provider.go
+++ b/pkg/providers/antigravity_provider.go
@@ -404,64 +404,6 @@ type antigravityJSONResponse struct {
} `json:"usageMetadata"`
}
-func (p *AntigravityProvider) parseJSONResponse(body []byte) (*LLMResponse, error) {
- var resp antigravityJSONResponse
- if err := json.Unmarshal(body, &resp); err != nil {
- return nil, fmt.Errorf("parsing antigravity response: %w", err)
- }
-
- if len(resp.Candidates) == 0 {
- return nil, fmt.Errorf("antigravity: no candidates in response")
- }
-
- candidate := resp.Candidates[0]
- var contentParts []string
- var toolCalls []ToolCall
-
- for _, part := range candidate.Content.Parts {
- if part.Text != "" {
- contentParts = append(contentParts, part.Text)
- }
- if part.FunctionCall != nil {
- argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
- toolCalls = append(toolCalls, ToolCall{
- ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
- Name: part.FunctionCall.Name,
- Arguments: part.FunctionCall.Args,
- Function: &FunctionCall{
- Name: part.FunctionCall.Name,
- Arguments: string(argumentsJSON),
- ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
- },
- })
- }
- }
-
- finishReason := "stop"
- if len(toolCalls) > 0 {
- finishReason = "tool_calls"
- }
- if candidate.FinishReason == "MAX_TOKENS" {
- finishReason = "length"
- }
-
- var usage *UsageInfo
- if resp.UsageMetadata.TotalTokenCount > 0 {
- usage = &UsageInfo{
- PromptTokens: resp.UsageMetadata.PromptTokenCount,
- CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
- TotalTokens: resp.UsageMetadata.TotalTokenCount,
- }
- }
-
- return &LLMResponse{
- Content: strings.Join(contentParts, ""),
- ToolCalls: toolCalls,
- FinishReason: finishReason,
- Usage: usage,
- }, nil
-}
-
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
var contentParts []string
var toolCalls []ToolCall
diff --git a/pkg/providers/codex_cli_credentials_test.go b/pkg/providers/codex_cli_credentials_test.go
index 43b21700a..1e88c1120 100644
--- a/pkg/providers/codex_cli_credentials_test.go
+++ b/pkg/providers/codex_cli_credentials_test.go
@@ -43,12 +43,18 @@ func TestReadCodexCliCredentials_Valid(t *testing.T) {
}
}
+// readCodexCliCredentialsErr calls ReadCodexCliCredentials and returns only the
+// error, for tests that only need to assert on failure.
+func readCodexCliCredentialsErr() error {
+ _, _, _, err := ReadCodexCliCredentials() //nolint:dogsled
+ return err
+}
+
func TestReadCodexCliCredentials_MissingFile(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("CODEX_HOME", tmpDir)
- _, _, _, err := ReadCodexCliCredentials()
- if err == nil {
+ if err := readCodexCliCredentialsErr(); err == nil {
t.Fatal("expected error for missing auth.json")
}
}
@@ -64,8 +70,7 @@ func TestReadCodexCliCredentials_EmptyToken(t *testing.T) {
t.Setenv("CODEX_HOME", tmpDir)
- _, _, _, err := ReadCodexCliCredentials()
- if err == nil {
+ if err := readCodexCliCredentialsErr(); err == nil {
t.Fatal("expected error for empty access_token")
}
}
@@ -80,8 +85,7 @@ func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {
t.Setenv("CODEX_HOME", tmpDir)
- _, _, _, err := ReadCodexCliCredentials()
- if err == nil {
+ if err := readCodexCliCredentialsErr(); err == nil {
t.Fatal("expected error for invalid JSON")
}
}
diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go
index ecc983642..dcc740ba4 100644
--- a/pkg/providers/codex_provider.go
+++ b/pkg/providers/codex_provider.go
@@ -106,8 +106,8 @@ func (p *CodexProvider) Chat(
if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" {
evtResp := evt.Response
if evtResp.ID != "" {
- copy := evtResp
- resp = ©
+ evtRespCopy := evtResp
+ resp = &evtRespCopy
}
}
}
@@ -208,6 +208,11 @@ func buildCodexParams(
for _, msg := range messages {
switch msg.Role {
case "system":
+ // Use the full concatenated system prompt (static + dynamic + summary)
+ // as instructions. This keeps behavior consistent with Anthropic and
+ // OpenAI-compat adapters where the complete system context lives in
+ // one place. Prefix caching is handled by prompt_cache_key below,
+ // not by splitting content across instructions vs input messages.
instructions = msg.Content
case "user":
if msg.ToolCallID != "" {
@@ -289,6 +294,13 @@ func buildCodexParams(
params.Instructions = openai.Opt(defaultCodexInstructions)
}
+ // Prompt caching: pass a stable cache key so OpenAI can bucket requests
+ // and reuse prefix KV cache across calls with the same key.
+ // See: https://platform.openai.com/docs/guides/prompt-caching
+ if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
+ params.PromptCacheKey = openai.Opt(cacheKey)
+ }
+
if len(tools) > 0 || enableWebSearch {
params.Tools = translateToolsForCodex(tools, enableWebSearch)
}
diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go
index b6f1b5e21..11af14da4 100644
--- a/pkg/providers/factory.go
+++ b/pkg/providers/factory.go
@@ -36,7 +36,7 @@ type providerSelection struct {
}
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
- model := cfg.Agents.Defaults.Model
+ model := cfg.Agents.Defaults.GetModelName()
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
lowerModel := strings.ToLower(model)
@@ -172,6 +172,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.model = "deepseek-chat"
}
}
+ case "mistral":
+ if cfg.Providers.Mistral.APIKey != "" {
+ sel.apiKey = cfg.Providers.Mistral.APIKey
+ sel.apiBase = cfg.Providers.Mistral.APIBase
+ sel.proxy = cfg.Providers.Mistral.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.mistral.ai/v1"
+ }
+ }
case "github_copilot", "copilot":
sel.providerType = providerTypeGitHubCopilot
if cfg.Providers.GitHubCopilot.APIBase != "" {
@@ -275,6 +284,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
if sel.apiBase == "" {
sel.apiBase = "http://localhost:11434/v1"
}
+ case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
+ sel.apiKey = cfg.Providers.Mistral.APIKey
+ sel.apiBase = cfg.Providers.Mistral.APIBase
+ sel.proxy = cfg.Providers.Mistral.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.mistral.ai/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 74fe8a36c..53f7a08a0 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -84,11 +84,17 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
- return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ cfg.APIKey,
+ apiBase,
+ cfg.Proxy,
+ cfg.MaxTokensField,
+ cfg.RequestTimeout,
+ ), modelID, nil
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
- "volcengine", "vllm", "qwen":
+ "volcengine", "vllm", "qwen", "mistral":
// 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)
@@ -97,7 +103,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
- return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ cfg.APIKey,
+ apiBase,
+ cfg.Proxy,
+ cfg.MaxTokensField,
+ cfg.RequestTimeout,
+ ), modelID, nil
case "anthropic":
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
@@ -116,7 +128,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if cfg.APIKey == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
}
- return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ cfg.APIKey,
+ apiBase,
+ cfg.Proxy,
+ cfg.MaxTokensField,
+ cfg.RequestTimeout,
+ ), modelID, nil
case "antigravity":
return NewAntigravityProvider(), modelID, nil
@@ -186,6 +204,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
case "vllm":
return "http://localhost:8000/v1"
+ case "mistral":
+ return "https://api.mistral.ai/v1"
default:
return ""
}
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index 6b133101a..e0c0eddef 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -6,7 +6,11 @@
package providers
import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
"testing"
+ "time"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -247,3 +251,42 @@ func TestCreateProviderFromConfig_EmptyModel(t *testing.T) {
t.Fatal("CreateProviderFromConfig() expected error for empty model")
}
}
+
+func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(1500 * time.Millisecond)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`))
+ }))
+ defer server.Close()
+
+ cfg := &config.ModelConfig{
+ ModelName: "test-timeout",
+ Model: "openai/gpt-4o",
+ APIBase: server.URL,
+ RequestTimeout: 1,
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if modelID != "gpt-4o" {
+ t.Fatalf("modelID = %q, want %q", modelID, "gpt-4o")
+ }
+
+ _, err = provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ modelID,
+ nil,
+ )
+ if err == nil {
+ t.Fatal("Chat() expected timeout error, got nil")
+ }
+ errMsg := err.Error()
+ if !strings.Contains(errMsg, "context deadline exceeded") && !strings.Contains(errMsg, "Client.Timeout exceeded") {
+ t.Fatalf("Chat() error = %q, want timeout-related error", errMsg)
+ }
+}
diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go
index ecd451ec9..7ba563b66 100644
--- a/pkg/providers/fallback.go
+++ b/pkg/providers/fallback.go
@@ -43,11 +43,26 @@ func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {
// ResolveCandidates parses model config into a deduplicated candidate list.
func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
+ return ResolveCandidatesWithLookup(cfg, defaultProvider, nil)
+}
+
+func ResolveCandidatesWithLookup(
+ cfg ModelConfig,
+ defaultProvider string,
+ lookup func(raw string) (resolved string, ok bool),
+) []FallbackCandidate {
seen := make(map[string]bool)
var candidates []FallbackCandidate
addCandidate := func(raw string) {
- ref := ParseModelRef(raw, defaultProvider)
+ candidateRaw := strings.TrimSpace(raw)
+ if lookup != nil {
+ if resolved, ok := lookup(candidateRaw); ok {
+ candidateRaw = resolved
+ }
+ }
+
+ ref := ParseModelRef(candidateRaw, defaultProvider)
if ref == nil {
return
}
diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go
index e872c672e..1783ebcb5 100644
--- a/pkg/providers/fallback_test.go
+++ b/pkg/providers/fallback_test.go
@@ -17,12 +17,6 @@ func successRun(content string) func(ctx context.Context, provider, model string
}
}
-func failRun(err error) func(ctx context.Context, provider, model string) (*LLMResponse, error) {
- return func(ctx context.Context, provider, model string) (*LLMResponse, error) {
- return nil, err
- }
-}
-
func TestFallback_SingleCandidate_Success(t *testing.T) {
ct := NewCooldownTracker()
fc := NewFallbackChain(ct)
@@ -459,6 +453,75 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) {
}
}
+func TestResolveCandidatesWithLookup_AliasResolvesToNestedModel(t *testing.T) {
+ cfg := ModelConfig{
+ Primary: "step-3.5-flash",
+ Fallbacks: nil,
+ }
+
+ lookup := func(raw string) (string, bool) {
+ if raw == "step-3.5-flash" {
+ return "openrouter/stepfun/step-3.5-flash:free", true
+ }
+ return "", false
+ }
+
+ candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
+ if len(candidates) != 1 {
+ t.Fatalf("candidates = %d, want 1", len(candidates))
+ }
+ if candidates[0].Provider != "openrouter" {
+ t.Fatalf("provider = %q, want openrouter", candidates[0].Provider)
+ }
+ if candidates[0].Model != "stepfun/step-3.5-flash:free" {
+ t.Fatalf("model = %q, want stepfun/step-3.5-flash:free", candidates[0].Model)
+ }
+}
+
+func TestResolveCandidatesWithLookup_DeduplicateAfterLookup(t *testing.T) {
+ cfg := ModelConfig{
+ Primary: "step-3.5-flash",
+ Fallbacks: []string{"openrouter/stepfun/step-3.5-flash:free"},
+ }
+
+ lookup := func(raw string) (string, bool) {
+ if raw == "step-3.5-flash" {
+ return "openrouter/stepfun/step-3.5-flash:free", true
+ }
+ return "", false
+ }
+
+ candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
+ if len(candidates) != 1 {
+ t.Fatalf("candidates = %d, want 1", len(candidates))
+ }
+}
+
+func TestResolveCandidatesWithLookup_AliasWithoutProtocolUsesDefaultProvider(t *testing.T) {
+ cfg := ModelConfig{
+ Primary: "glm-5",
+ Fallbacks: nil,
+ }
+
+ lookup := func(raw string) (string, bool) {
+ if raw == "glm-5" {
+ return "glm-5", true
+ }
+ return "", false
+ }
+
+ candidates := ResolveCandidatesWithLookup(cfg, "openai", lookup)
+ if len(candidates) != 1 {
+ t.Fatalf("candidates = %d, want 1", len(candidates))
+ }
+ if candidates[0].Provider != "openai" {
+ t.Fatalf("provider = %q, want openai", candidates[0].Provider)
+ }
+ if candidates[0].Model != "glm-5" {
+ t.Fatalf("model = %q, want glm-5", candidates[0].Model)
+ }
+}
+
func TestFallbackExhaustedError_Message(t *testing.T) {
e := &FallbackExhaustedError{
Attempts: []FallbackAttempt{
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index 6124881f7..3fb15db2f 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -4,60 +4,83 @@ import (
"context"
"encoding/json"
"fmt"
+ "sync"
copilot "github.com/github/copilot-sdk/go"
)
type GitHubCopilotProvider struct {
uri string
- connectMode string // `stdio` or `grpc``
+ connectMode string // "stdio" or "grpc"
+ client *copilot.Client
session *copilot.Session
+
+ mu sync.Mutex
}
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
- var session *copilot.Session
if connectMode == "" {
connectMode = "grpc"
}
- switch connectMode {
+ switch connectMode {
case "stdio":
- // todo
+ // TODO:
+ return nil, fmt.Errorf("stdio mode not implemented")
case "grpc":
client := copilot.NewClient(&copilot.ClientOptions{
CLIUrl: uri,
})
if err := client.Start(context.Background()); err != nil {
return nil, fmt.Errorf(
- "Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details",
+ "can't connect to Github Copilot: %w; `https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server` for details",
+ err,
)
}
- defer client.Stop()
- session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
+
+ session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
Model: model,
Hooks: &copilot.SessionHooks{},
})
+ if err != nil {
+ client.Stop()
+ return nil, fmt.Errorf("create session failed: %w", err)
+ }
+ return &GitHubCopilotProvider{
+ uri: uri,
+ connectMode: connectMode,
+ client: client,
+ session: session,
+ }, nil
+ default:
+ return nil, fmt.Errorf("unknown connect mode: %s", connectMode)
+ }
+}
+
+func (p *GitHubCopilotProvider) Close() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.client != nil {
+ p.client.Stop()
+ p.client = nil
+ p.session = nil
}
-
- return &GitHubCopilotProvider{
- uri: uri,
- connectMode: connectMode,
- session: session,
- }, nil
}
-// Chat sends a chat request to GitHub Copilot
func (p *GitHubCopilotProvider) Chat(
- ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
) (*LLMResponse, error) {
type tempMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
out := make([]tempMessage, 0, len(messages))
-
for _, msg := range messages {
out = append(out, tempMessage{
Role: msg.Role,
@@ -65,12 +88,30 @@ func (p *GitHubCopilotProvider) Chat(
})
}
- fullcontent, _ := json.Marshal(out)
+ fullcontent, err := json.Marshal(out)
+ if err != nil {
+ return nil, fmt.Errorf("marshal messages: %w", err)
+ }
+ p.mu.Lock()
+ session := p.session
+ p.mu.Unlock()
- content, _ := p.session.Send(ctx, copilot.MessageOptions{
+ if session == nil {
+ return nil, fmt.Errorf("provider closed")
+ }
+
+ resp, _ := session.SendAndWait(ctx, copilot.MessageOptions{
Prompt: string(fullcontent),
})
+ if resp == nil {
+ return nil, fmt.Errorf("empty response from copilot")
+ }
+ if resp.Data.Content == nil {
+ return nil, fmt.Errorf("no content in copilot response")
+ }
+ content := *resp.Data.Content
+
return &LLMResponse{
FinishReason: "stop",
Content: content,
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index d0c4344f3..5c328f418 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -8,6 +8,7 @@ package providers
import (
"context"
+ "time"
"github.com/sipeed/picoclaw/pkg/providers/openai_compat"
)
@@ -23,8 +24,21 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
}
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0)
+}
+
+func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ apiKey, apiBase, proxy, maxTokensField string,
+ requestTimeoutSeconds int,
+) *HTTPProvider {
return &HTTPProvider{
- delegate: openai_compat.NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField),
+ delegate: openai_compat.NewProvider(
+ apiKey,
+ apiBase,
+ proxy,
+ openai_compat.WithMaxTokensField(maxTokensField),
+ openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
+ ),
}
}
diff --git a/pkg/providers/legacy_provider.go b/pkg/providers/legacy_provider.go
index eb13cec65..26905159f 100644
--- a/pkg/providers/legacy_provider.go
+++ b/pkg/providers/legacy_provider.go
@@ -16,11 +16,23 @@ import (
// The old providers config is automatically converted to model_list during config loading.
// Returns the provider, the model ID to use, and any error.
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
- model := cfg.Agents.Defaults.Model
+ model := cfg.Agents.Defaults.GetModelName()
- // Ensure model_list is populated (should be done by LoadConfig, but handle edge cases)
- if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
- cfg.ModelList = config.ConvertProvidersToModelList(cfg)
+ // Ensure model_list is populated from providers config if needed
+ // This handles two cases:
+ // 1. ModelList is empty - convert all providers
+ // 2. ModelList has some entries but not all providers - merge missing ones
+ if cfg.HasProvidersConfig() {
+ providerModels := config.ConvertProvidersToModelList(cfg)
+ existingModelNames := make(map[string]bool)
+ for _, m := range cfg.ModelList {
+ existingModelNames[m.ModelName] = true
+ }
+ for _, pm := range providerModels {
+ if !existingModelNames[pm.ModelName] {
+ cfg.ModelList = append(cfg.ModelList, pm)
+ }
+ }
}
// Must have model_list at this point
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index b8528953a..5dab9b03e 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -25,6 +25,7 @@ type (
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
ExtraContent = protocoltypes.ExtraContent
GoogleExtra = protocoltypes.GoogleExtra
+ ReasoningDetail = protocoltypes.ReasoningDetail
)
type Provider struct {
@@ -34,13 +35,27 @@ type Provider struct {
httpClient *http.Client
}
-func NewProvider(apiKey, apiBase, proxy string) *Provider {
- return NewProviderWithMaxTokensField(apiKey, apiBase, proxy, "")
+type Option func(*Provider)
+
+const defaultRequestTimeout = 120 * time.Second
+
+func WithMaxTokensField(maxTokensField string) Option {
+ return func(p *Provider) {
+ p.maxTokensField = maxTokensField
+ }
}
-func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
+func WithRequestTimeout(timeout time.Duration) Option {
+ return func(p *Provider) {
+ if timeout > 0 {
+ p.httpClient.Timeout = timeout
+ }
+ }
+}
+
+func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
client := &http.Client{
- Timeout: 120 * time.Second,
+ Timeout: defaultRequestTimeout,
}
if proxy != "" {
@@ -54,12 +69,36 @@ func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string
}
}
- return &Provider{
- apiKey: apiKey,
- apiBase: strings.TrimRight(apiBase, "/"),
- maxTokensField: maxTokensField,
- httpClient: client,
+ p := &Provider{
+ apiKey: apiKey,
+ apiBase: strings.TrimRight(apiBase, "/"),
+ httpClient: client,
}
+
+ for _, opt := range opts {
+ if opt != nil {
+ opt(p)
+ }
+ }
+
+ return p
+}
+
+func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
+ return NewProvider(apiKey, apiBase, proxy, WithMaxTokensField(maxTokensField))
+}
+
+func NewProviderWithMaxTokensFieldAndTimeout(
+ apiKey, apiBase, proxy, maxTokensField string,
+ requestTimeoutSeconds int,
+) *Provider {
+ return NewProvider(
+ apiKey,
+ apiBase,
+ proxy,
+ WithMaxTokensField(maxTokensField),
+ WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
+ )
}
func (p *Provider) Chat(
@@ -77,7 +116,7 @@ func (p *Provider) Chat(
requestBody := map[string]any{
"model": model,
- "messages": messages,
+ "messages": stripSystemParts(messages),
}
if len(tools) > 0 {
@@ -111,6 +150,18 @@ func (p *Provider) Chat(
}
}
+ // Prompt caching: pass a stable cache key so OpenAI can bucket requests
+ // with the same key and reuse prefix KV cache across calls.
+ // The key is typically the agent ID — stable per agent, shared across requests.
+ // See: https://platform.openai.com/docs/guides/prompt-caching
+ // Prompt caching is only supported by OpenAI-native endpoints.
+ // Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs.
+ if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
+ if !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") {
+ requestBody["prompt_cache_key"] = cacheKey
+ }
+ }
+
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
@@ -148,8 +199,11 @@ func parseResponse(body []byte) (*LLMResponse, error) {
var apiResponse struct {
Choices []struct {
Message struct {
- Content string `json:"content"`
- ToolCalls []struct {
+ Content string `json:"content"`
+ ReasoningContent string `json:"reasoning_content"`
+ Reasoning string `json:"reasoning"`
+ ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
+ ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
@@ -221,13 +275,42 @@ func parseResponse(body []byte) (*LLMResponse, error) {
}
return &LLMResponse{
- Content: choice.Message.Content,
- ToolCalls: toolCalls,
- FinishReason: choice.FinishReason,
- Usage: apiResponse.Usage,
+ Content: choice.Message.Content,
+ ReasoningContent: choice.Message.ReasoningContent,
+ Reasoning: choice.Message.Reasoning,
+ ReasoningDetails: choice.Message.ReasoningDetails,
+ ToolCalls: toolCalls,
+ FinishReason: choice.FinishReason,
+ Usage: apiResponse.Usage,
}, nil
}
+// openaiMessage is the wire-format message for OpenAI-compatible APIs.
+// It mirrors protocoltypes.Message but omits SystemParts, which is an
+// internal field that would be unknown to third-party endpoints.
+type openaiMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
+}
+
+// stripSystemParts converts []Message to []openaiMessage, dropping the
+// SystemParts field so it doesn't leak into the JSON payload sent to
+// OpenAI-compatible APIs (some strict endpoints reject unknown fields).
+func stripSystemParts(messages []Message) []openaiMessage {
+ out := make([]openaiMessage, len(messages))
+ for i, m := range messages {
+ out[i] = openaiMessage{
+ Role: m.Role,
+ Content: m.Content,
+ ToolCalls: m.ToolCalls,
+ ToolCallID: m.ToolCallID,
+ }
+ }
+ return out
+}
+
func normalizeModel(model, apiBase string) string {
idx := strings.Index(model, "/")
if idx == -1 {
@@ -240,7 +323,7 @@ func normalizeModel(model, apiBase string) string {
prefix := strings.ToLower(model[:idx])
switch prefix {
- case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu":
+ case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
return model[idx+1:]
default:
return model
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index 42f9d42ab..7247fea3e 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"net/url"
"testing"
+ "time"
)
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
@@ -101,6 +102,50 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) {
}
}
+func TestProviderChat_ParsesReasoningContent(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]any{
+ "choices": []map[string]any{
+ {
+ "message": map[string]any{
+ "content": "The answer is 2",
+ "reasoning_content": "Let me think step by step... 1+1=2",
+ "tool_calls": []map[string]any{
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": map[string]any{
+ "name": "calculator",
+ "arguments": "{\"expr\":\"1+1\"}",
+ },
+ },
+ },
+ },
+ "finish_reason": "tool_calls",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "1+1=?"}}, nil, "kimi-k2.5", nil)
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if out.ReasoningContent != "Let me think step by step... 1+1=2" {
+ t.Fatalf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think step by step... 1+1=2")
+ }
+ if out.Content != "The answer is 2" {
+ t.Fatalf("Content = %q, want %q", out.Content, "The answer is 2")
+ }
+ if len(out.ToolCalls) != 1 {
+ t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
+ }
+}
+
func TestProviderChat_HTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest)
@@ -281,3 +326,38 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
}
}
+
+func TestProvider_RequestTimeoutDefault(t *testing.T) {
+ p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 0)
+ if p.httpClient.Timeout != defaultRequestTimeout {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
+ }
+}
+
+func TestProvider_RequestTimeoutOverride(t *testing.T) {
+ p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 300)
+ if p.httpClient.Timeout != 300*time.Second {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
+ }
+}
+
+func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) {
+ p := NewProvider("key", "https://example.com/v1", "", WithMaxTokensField("max_completion_tokens"))
+ if p.maxTokensField != "max_completion_tokens" {
+ t.Fatalf("maxTokensField = %q, want %q", p.maxTokensField, "max_completion_tokens")
+ }
+}
+
+func TestProvider_FunctionalOptionRequestTimeout(t *testing.T) {
+ p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(45*time.Second))
+ if p.httpClient.Timeout != 45*time.Second {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 45*time.Second)
+ }
+}
+
+func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) {
+ p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(-1*time.Second))
+ if p.httpClient.Timeout != defaultRequestTimeout {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
+ }
+}
diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go
index 3a089ca47..99f13334e 100644
--- a/pkg/providers/protocoltypes/types.go
+++ b/pkg/providers/protocoltypes/types.go
@@ -4,8 +4,8 @@ type ToolCall struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
Function *FunctionCall `json:"function,omitempty"`
- Name string `json:"name,omitempty"`
- Arguments map[string]any `json:"arguments,omitempty"`
+ Name string `json:"-"`
+ Arguments map[string]any `json:"-"`
ThoughtSignature string `json:"-"` // Internal use only
ExtraContent *ExtraContent `json:"extra_content,omitempty"`
}
@@ -25,10 +25,20 @@ type FunctionCall struct {
}
type LLMResponse struct {
- Content string `json:"content"`
- ToolCalls []ToolCall `json:"tool_calls,omitempty"`
- FinishReason string `json:"finish_reason"`
- Usage *UsageInfo `json:"usage,omitempty"`
+ Content string `json:"content"`
+ ReasoningContent string `json:"reasoning_content,omitempty"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ FinishReason string `json:"finish_reason"`
+ Usage *UsageInfo `json:"usage,omitempty"`
+ Reasoning string `json:"reasoning"`
+ ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
+}
+
+type ReasoningDetail struct {
+ Format string `json:"format"`
+ Index int `json:"index"`
+ Type string `json:"type"`
+ Text string `json:"text"`
}
type UsageInfo struct {
@@ -37,11 +47,28 @@ type UsageInfo struct {
TotalTokens int `json:"total_tokens"`
}
+// CacheControl marks a content block for LLM-side prefix caching.
+// Currently only "ephemeral" is supported (used by Anthropic).
+type CacheControl struct {
+ Type string `json:"type"` // "ephemeral"
+}
+
+// ContentBlock represents a structured segment of a system message.
+// Adapters that understand SystemParts can use these blocks to set
+// per-block cache control (e.g. Anthropic's cache_control: ephemeral).
+type ContentBlock struct {
+ Type string `json:"type"` // "text"
+ Text string `json:"text"`
+ CacheControl *CacheControl `json:"cache_control,omitempty"`
+}
+
type Message struct {
- Role string `json:"role"`
- Content string `json:"content"`
- ToolCalls []ToolCall `json:"tool_calls,omitempty"`
- ToolCallID string `json:"tool_call_id,omitempty"`
+ Role string `json:"role"`
+ Content string `json:"content"`
+ 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"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
}
type ToolDefinition struct {
diff --git a/pkg/providers/types.go b/pkg/providers/types.go
index f711e7803..f0c168bc6 100644
--- a/pkg/providers/types.go
+++ b/pkg/providers/types.go
@@ -17,6 +17,8 @@ type (
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
ExtraContent = protocoltypes.ExtraContent
GoogleExtra = protocoltypes.GoogleExtra
+ ContentBlock = protocoltypes.ContentBlock
+ CacheControl = protocoltypes.CacheControl
)
type LLMProvider interface {
@@ -30,6 +32,11 @@ type LLMProvider interface {
GetDefaultModel() string
}
+type StatefulProvider interface {
+ LLMProvider
+ Close()
+}
+
// FailoverReason classifies why an LLM request failed for fallback decisions.
type FailoverReason string
diff --git a/pkg/routing/session_key.go b/pkg/routing/session_key.go
index e12f0d1d8..eab592bec 100644
--- a/pkg/routing/session_key.go
+++ b/pkg/routing/session_key.go
@@ -163,6 +163,15 @@ func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID stri
scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID))
candidates[scopedCandidate] = true
}
+
+ // If peerID is already in canonical "platform:id" format, also add the
+ // bare ID part as a candidate for backward compatibility with identity_links
+ // that use raw IDs (e.g. "123" instead of "telegram:123").
+ if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
+ bareID := rawCandidate[idx+1:]
+ candidates[bareID] = true
+ }
+
if len(candidates) == 0 {
return ""
}
diff --git a/pkg/routing/session_key_test.go b/pkg/routing/session_key_test.go
index 81e4ce018..ad7a1ca02 100644
--- a/pkg/routing/session_key_test.go
+++ b/pkg/routing/session_key_test.go
@@ -115,6 +115,51 @@ func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) {
}
}
+func TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) {
+ // When peerID is already in canonical "platform:id" format,
+ // it should match identity_links that use the bare ID.
+ links := map[string][]string{
+ "john": {"123"},
+ }
+ got := resolveLinkedPeerID(links, "telegram", "telegram:123")
+ if got != "john" {
+ t.Errorf("resolveLinkedPeerID with canonical peerID = %q, want %q", got, "john")
+ }
+}
+
+func TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) {
+ // When identity_links contain canonical IDs and peerID is canonical too
+ links := map[string][]string{
+ "john": {"telegram:123", "discord:456"},
+ }
+ got := resolveLinkedPeerID(links, "telegram", "telegram:123")
+ if got != "john" {
+ t.Errorf("resolveLinkedPeerID canonical in links = %q, want %q", got, "john")
+ }
+}
+
+func TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) {
+ // When peerID is bare "123" and links have "telegram:123",
+ // the scoped candidate "telegram:123" should match.
+ links := map[string][]string{
+ "john": {"telegram:123"},
+ }
+ got := resolveLinkedPeerID(links, "telegram", "123")
+ if got != "john" {
+ t.Errorf("resolveLinkedPeerID bare peer matches canonical link = %q, want %q", got, "john")
+ }
+}
+
+func TestResolveLinkedPeerID_NoMatch(t *testing.T) {
+ links := map[string][]string{
+ "john": {"telegram:123"},
+ }
+ got := resolveLinkedPeerID(links, "discord", "999")
+ if got != "" {
+ t.Errorf("resolveLinkedPeerID no match = %q, want empty", got)
+ }
+}
+
func TestParseAgentSessionKey_Valid(t *testing.T) {
parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123")
if parsed == nil {
diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go
index 3210509df..20f6a49d9 100644
--- a/pkg/skills/installer.go
+++ b/pkg/skills/installer.go
@@ -9,6 +9,9 @@ import (
"os"
"path/filepath"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
type SkillInstaller struct {
@@ -44,7 +47,7 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
return fmt.Errorf("failed to create request: %w", err)
}
- resp, err := client.Do(req)
+ resp, err := utils.DoRequestWithRetry(client, req)
if err != nil {
return fmt.Errorf("failed to fetch skill: %w", err)
}
@@ -64,7 +67,9 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
}
skillPath := filepath.Join(skillDir, "SKILL.md")
- if err := os.WriteFile(skillPath, body, 0o644); err != nil {
+
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ if err := fileutil.WriteFileAtomic(skillPath, body, 0o600); err != nil {
return fmt.Errorf("failed to write skill file: %w", err)
}
@@ -94,7 +99,7 @@ func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableS
return nil, fmt.Errorf("failed to create request: %w", err)
}
- resp, err := client.Do(req)
+ resp, err := utils.DoRequestWithRetry(client, req)
if err != nil {
return nil, fmt.Errorf("failed to fetch skills list: %w", err)
}
diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go
index eb0d5f322..67d3e70e0 100644
--- a/pkg/skills/loader.go
+++ b/pkg/skills/loader.go
@@ -13,7 +13,11 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
)
-var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
+var (
+ namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
+ reFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
+ reStripFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
+)
const (
MaxNameLength = 64
@@ -55,9 +59,9 @@ func (info SkillInfo) validate() error {
type SkillsLoader struct {
workspace string
- workspaceSkills string // workspace skills (项目级别)
- globalSkills string // 全局 skills (~/.picoclaw/skills)
- builtinSkills string // 内置 skills
+ workspaceSkills string // workspace skills (project-level)
+ globalSkills string // global skills (~/.picoclaw/skills)
+ builtinSkills string // builtin skills
}
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
@@ -71,118 +75,56 @@ func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string
func (sl *SkillsLoader) ListSkills() []SkillInfo {
skills := make([]SkillInfo, 0)
+ seen := make(map[string]bool)
- if sl.workspaceSkills != "" {
- if dirs, err := os.ReadDir(sl.workspaceSkills); err == nil {
- for _, dir := range dirs {
- if dir.IsDir() {
- skillFile := filepath.Join(sl.workspaceSkills, dir.Name(), "SKILL.md")
- if _, err := os.Stat(skillFile); err == nil {
- info := SkillInfo{
- Name: dir.Name(),
- Path: skillFile,
- Source: "workspace",
- }
- metadata := sl.getSkillMetadata(skillFile)
- if metadata != nil {
- info.Description = metadata.Description
- info.Name = metadata.Name
- }
- if err := info.validate(); err != nil {
- slog.Warn("invalid skill from workspace", "name", info.Name, "error", err)
- continue
- }
- skills = append(skills, info)
- }
- }
+ addSkills := func(dir, source string) {
+ if dir == "" {
+ return
+ }
+ dirs, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+ for _, d := range dirs {
+ if !d.IsDir() {
+ continue
}
+ skillFile := filepath.Join(dir, d.Name(), "SKILL.md")
+ if _, err := os.Stat(skillFile); err != nil {
+ continue
+ }
+ info := SkillInfo{
+ Name: d.Name(),
+ Path: skillFile,
+ Source: source,
+ }
+ metadata := sl.getSkillMetadata(skillFile)
+ if metadata != nil {
+ info.Description = metadata.Description
+ info.Name = metadata.Name
+ }
+ if err := info.validate(); err != nil {
+ slog.Warn("invalid skill from "+source, "name", info.Name, "error", err)
+ continue
+ }
+ if seen[info.Name] {
+ continue
+ }
+ seen[info.Name] = true
+ skills = append(skills, info)
}
}
- // 全局 skills (~/.picoclaw/skills) - 被 workspace skills 覆盖
- if sl.globalSkills != "" {
- if dirs, err := os.ReadDir(sl.globalSkills); err == nil {
- for _, dir := range dirs {
- if dir.IsDir() {
- skillFile := filepath.Join(sl.globalSkills, dir.Name(), "SKILL.md")
- if _, err := os.Stat(skillFile); err == nil {
- // 检查是否已被 workspace skills 覆盖
- exists := false
- for _, s := range skills {
- if s.Name == dir.Name() && s.Source == "workspace" {
- exists = true
- break
- }
- }
- if exists {
- continue
- }
-
- info := SkillInfo{
- Name: dir.Name(),
- Path: skillFile,
- Source: "global",
- }
- metadata := sl.getSkillMetadata(skillFile)
- if metadata != nil {
- info.Description = metadata.Description
- info.Name = metadata.Name
- }
- if err := info.validate(); err != nil {
- slog.Warn("invalid skill from global", "name", info.Name, "error", err)
- continue
- }
- skills = append(skills, info)
- }
- }
- }
- }
- }
-
- if sl.builtinSkills != "" {
- if dirs, err := os.ReadDir(sl.builtinSkills); err == nil {
- for _, dir := range dirs {
- if dir.IsDir() {
- skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md")
- if _, err := os.Stat(skillFile); err == nil {
- // 检查是否已被 workspace 或 global skills 覆盖
- exists := false
- for _, s := range skills {
- if s.Name == dir.Name() && (s.Source == "workspace" || s.Source == "global") {
- exists = true
- break
- }
- }
- if exists {
- continue
- }
-
- info := SkillInfo{
- Name: dir.Name(),
- Path: skillFile,
- Source: "builtin",
- }
- metadata := sl.getSkillMetadata(skillFile)
- if metadata != nil {
- info.Description = metadata.Description
- info.Name = metadata.Name
- }
- if err := info.validate(); err != nil {
- slog.Warn("invalid skill from builtin", "name", info.Name, "error", err)
- continue
- }
- skills = append(skills, info)
- }
- }
- }
- }
- }
+ // Priority: workspace > global > builtin
+ addSkills(sl.workspaceSkills, "workspace")
+ addSkills(sl.globalSkills, "global")
+ addSkills(sl.builtinSkills, "builtin")
return skills
}
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
- // 1. 优先从 workspace skills 加载(项目级别)
+ // 1. load from workspace skills first (project-level)
if sl.workspaceSkills != "" {
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
@@ -190,7 +132,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
}
}
- // 2. 其次从全局 skills 加载 (~/.picoclaw/skills)
+ // 2. then load from global skills (~/.picoclaw/skills)
if sl.globalSkills != "" {
skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
@@ -198,7 +140,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
}
}
- // 3. 最后从内置 skills 加载
+ // 3. finally load from builtin skills
if sl.builtinSkills != "" {
skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
@@ -319,10 +261,7 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
func (sl *SkillsLoader) extractFrontmatter(content string) string {
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
- // (?s) enables DOTALL so . matches newlines;
- // ^--- at start, then ... --- at start of line, honoring all three line ending types
- re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
- match := re.FindStringSubmatch(content)
+ match := reFrontmatter.FindStringSubmatch(content)
if len(match) > 1 {
return match[1]
}
@@ -330,12 +269,7 @@ func (sl *SkillsLoader) extractFrontmatter(content string) string {
}
func (sl *SkillsLoader) stripFrontmatter(content string) string {
- // Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
- // (?s) enables DOTALL so . matches newlines;
- // ^--- at start, then ... --- at start of line, honoring all three line ending types
- // Match zero or more trailing line endings after closing --- (handles both with and without blank lines)
- re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
- return re.ReplaceAllString(content, "")
+ return reStripFrontmatter.ReplaceAllString(content, "")
}
func escapeXML(s string) string {
diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go
index aca901d33..9428bea62 100644
--- a/pkg/skills/loader_test.go
+++ b/pkg/skills/loader_test.go
@@ -1,9 +1,12 @@
package skills
import (
+ "os"
+ "path/filepath"
"testing"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestSkillsInfoValidate(t *testing.T) {
@@ -135,6 +138,134 @@ func TestExtractFrontmatter(t *testing.T) {
}
}
+// createSkillDir creates a skill directory with a SKILL.md file containing the given frontmatter.
+func createSkillDir(t *testing.T, base, dirName, name, description string) {
+ t.Helper()
+ dir := filepath.Join(base, dirName)
+ require.NoError(t, os.MkdirAll(dir, 0o755))
+ content := "---\nname: " + name + "\ndescription: " + description + "\n---\n\n# " + name
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644))
+}
+
+func TestListSkillsWorkspaceOverridesGlobal(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ createSkillDir(t, filepath.Join(ws, "skills"), "my-skill", "my-skill", "workspace version")
+ createSkillDir(t, global, "my-skill", "my-skill", "global version")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "workspace", skills[0].Source)
+ assert.Equal(t, "workspace version", skills[0].Description)
+}
+
+func TestListSkillsGlobalOverridesBuiltin(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+ builtin := filepath.Join(tmp, "builtin")
+
+ createSkillDir(t, global, "my-skill", "my-skill", "global version")
+ createSkillDir(t, builtin, "my-skill", "my-skill", "builtin version")
+
+ sl := NewSkillsLoader(ws, global, builtin)
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "global", skills[0].Source)
+ assert.Equal(t, "global version", skills[0].Description)
+}
+
+func TestListSkillsMetadataNameDedup(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ // Different directory names but same metadata name
+ createSkillDir(t, filepath.Join(ws, "skills"), "dir-a", "shared-name", "workspace version")
+ createSkillDir(t, global, "dir-b", "shared-name", "global version")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "shared-name", skills[0].Name)
+ assert.Equal(t, "workspace", skills[0].Source)
+}
+
+func TestListSkillsMultipleDistinctSkills(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+ builtin := filepath.Join(tmp, "builtin")
+
+ createSkillDir(t, filepath.Join(ws, "skills"), "skill-a", "skill-a", "desc a")
+ createSkillDir(t, global, "skill-b", "skill-b", "desc b")
+ createSkillDir(t, builtin, "skill-c", "skill-c", "desc c")
+
+ sl := NewSkillsLoader(ws, global, builtin)
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 3)
+ names := map[string]string{}
+ for _, s := range skills {
+ names[s.Name] = s.Source
+ }
+ assert.Equal(t, "workspace", names["skill-a"])
+ assert.Equal(t, "global", names["skill-b"])
+ assert.Equal(t, "builtin", names["skill-c"])
+}
+
+func TestListSkillsInvalidSkillSkipped(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ // Invalid name (underscore)
+ createSkillDir(t, filepath.Join(ws, "skills"), "bad_skill", "bad_skill", "desc")
+ // Valid skill
+ createSkillDir(t, global, "good-skill", "good-skill", "desc")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "good-skill", skills[0].Name)
+}
+
+func TestListSkillsEmptyAndNonexistentDirs(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ emptyDir := filepath.Join(tmp, "empty")
+ require.NoError(t, os.MkdirAll(emptyDir, 0o755))
+
+ sl := NewSkillsLoader(ws, emptyDir, filepath.Join(tmp, "nonexistent"))
+ skills := sl.ListSkills()
+
+ assert.Empty(t, skills)
+}
+
+func TestListSkillsDirWithoutSkillMD(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ // Directory exists but has no SKILL.md
+ require.NoError(t, os.MkdirAll(filepath.Join(global, "no-skillmd"), 0o755))
+ // Valid skill alongside
+ createSkillDir(t, global, "real-skill", "real-skill", "desc")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "real-skill", skills[0].Name)
+}
+
func TestStripFrontmatter(t *testing.T) {
sl := &SkillsLoader{}
diff --git a/pkg/state/state.go b/pkg/state/state.go
index 1a92f82ed..1663faa4c 100644
--- a/pkg/state/state.go
+++ b/pkg/state/state.go
@@ -8,6 +8,8 @@ import (
"path/filepath"
"sync"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
// State represents the persistent state for a workspace.
@@ -124,33 +126,20 @@ func (sm *Manager) GetTimestamp() time.Time {
// saveAtomic performs an atomic save using temp file + rename.
// This ensures that the state file is never corrupted:
// 1. Write to a temp file
-// 2. Rename temp file to target (atomic on POSIX systems)
-// 3. If rename fails, cleanup the temp file
+// 2. Sync to disk (critical for SD cards/flash storage)
+// 3. Rename temp file to target (atomic on POSIX systems)
+// 4. If rename fails, cleanup the temp file
//
// Must be called with the lock held.
func (sm *Manager) saveAtomic() error {
- // Create temp file in the same directory as the target
- tempFile := sm.stateFile + ".tmp"
-
- // Marshal state to JSON
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ // Using 0o600 (owner read/write only) for secure default permissions.
data, err := json.MarshalIndent(sm.state, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
- // Write to temp file
- if err := os.WriteFile(tempFile, data, 0o644); err != nil {
- return fmt.Errorf("failed to write temp file: %w", err)
- }
-
- // Atomic rename from temp to target
- if err := os.Rename(tempFile, sm.stateFile); err != nil {
- // Cleanup temp file if rename fails
- os.Remove(tempFile)
- return fmt.Errorf("failed to rename temp file: %w", err)
- }
-
- return nil
+ return fileutil.WriteFileAtomic(sm.stateFile, data, 0o600)
}
// load loads the state from disk.
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index 562fffc84..52f914622 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -294,7 +294,9 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, result.ForLLM)
}
- t.msgBus.PublishOutbound(bus.OutboundMessage{
+ pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer pubCancel()
+ t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: output,
@@ -304,7 +306,9 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
// If deliver=true, send message directly without agent processing
if job.Payload.Deliver {
- t.msgBus.PublishOutbound(bus.OutboundMessage{
+ pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer pubCancel()
+ t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: job.Payload.Message,
diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go
index c28ca6ca2..d3ab267bf 100644
--- a/pkg/tools/edit.go
+++ b/pkg/tools/edit.go
@@ -2,24 +2,27 @@ package tools
import (
"context"
+ "errors"
"fmt"
- "os"
+ "io/fs"
"strings"
)
// EditFileTool edits a file by replacing old_text with new_text.
// The old_text must exist exactly in the file.
type EditFileTool struct {
- allowedDir string
- restrict bool
+ fs fileSystem
}
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
-func NewEditFileTool(allowedDir string, restrict bool) *EditFileTool {
- return &EditFileTool{
- allowedDir: allowedDir,
- restrict: restrict,
+func NewEditFileTool(workspace string, restrict bool) *EditFileTool {
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
}
+ return &EditFileTool{fs: fs}
}
func (t *EditFileTool) Name() string {
@@ -67,49 +70,24 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("new_text is required")
}
- resolvedPath, err := validatePath(path, t.allowedDir, t.restrict)
- if err != nil {
+ if err := editFile(t.fs, path, oldText, newText); err != nil {
return ErrorResult(err.Error())
}
-
- if _, err = os.Stat(resolvedPath); os.IsNotExist(err) {
- return ErrorResult(fmt.Sprintf("file not found: %s", path))
- }
-
- content, err := os.ReadFile(resolvedPath)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
- }
-
- contentStr := string(content)
-
- if !strings.Contains(contentStr, oldText) {
- return ErrorResult("old_text not found in file. Make sure it matches exactly")
- }
-
- count := strings.Count(contentStr, oldText)
- if count > 1 {
- return ErrorResult(
- fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count),
- )
- }
-
- newContent := strings.Replace(contentStr, oldText, newText, 1)
-
- if err := os.WriteFile(resolvedPath, []byte(newContent), 0o644); err != nil {
- return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("File edited: %s", path))
}
type AppendFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool {
- return &AppendFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &AppendFileTool{fs: fs}
}
func (t *AppendFileTool) Name() string {
@@ -148,20 +126,52 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool
return ErrorResult("content is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
+ if err := appendFile(t.fs, path, content); err != nil {
return ErrorResult(err.Error())
}
-
- f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to open file: %v", err))
- }
- defer f.Close()
-
- if _, err := f.WriteString(content); err != nil {
- return ErrorResult(fmt.Sprintf("failed to append to file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("Appended to %s", path))
}
+
+// editFile reads the file via sysFs, performs the replacement, and writes back.
+// It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes.
+func editFile(sysFs fileSystem, path, oldText, newText string) error {
+ content, err := sysFs.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ newContent, err := replaceEditContent(content, oldText, newText)
+ if err != nil {
+ return err
+ }
+
+ return sysFs.WriteFile(path, newContent)
+}
+
+// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back.
+func appendFile(sysFs fileSystem, path, appendContent string) error {
+ content, err := sysFs.ReadFile(path)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+
+ newContent := append(content, []byte(appendContent)...)
+ return sysFs.WriteFile(path, newContent)
+}
+
+// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText.
+func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) {
+ contentStr := string(content)
+
+ if !strings.Contains(contentStr, oldText) {
+ return nil, fmt.Errorf("old_text not found in file. Make sure it matches exactly")
+ }
+
+ count := strings.Count(contentStr, oldText)
+ if count > 1 {
+ return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count)
+ }
+
+ newContent := strings.Replace(contentStr, oldText, newText, 1)
+ return []byte(newContent), nil
+}
diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go
index 6780dd9f6..83a7e778c 100644
--- a/pkg/tools/edit_test.go
+++ b/pkg/tools/edit_test.go
@@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"
+
+ "github.com/stretchr/testify/assert"
)
// TestEditTool_EditFile_Success verifies successful file editing
@@ -151,14 +153,18 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
result := tool.Execute(ctx, args)
// Should return error result
- if !result.IsError {
- t.Errorf("Expected error when path is outside allowed directory")
- }
+ assert.True(t, result.IsError, "Expected error when path is outside allowed directory")
// Should mention outside allowed directory
- if !strings.Contains(result.ForLLM, "outside") && !strings.Contains(result.ForUser, "outside") {
- t.Errorf("Expected 'outside allowed' message, got ForLLM: %s", result.ForLLM)
- }
+ // Note: ErrorResult only sets ForLLM by default, so ForUser might be empty.
+ // We check ForLLM as it's the primary error channel.
+ assert.True(
+ t,
+ strings.Contains(result.ForLLM, "outside") || strings.Contains(result.ForLLM, "access denied") ||
+ strings.Contains(result.ForLLM, "escapes"),
+ "Expected 'outside allowed' or 'access denied' message, got ForLLM: %s",
+ result.ForLLM,
+ )
}
// TestEditTool_EditFile_MissingPath verifies error handling for missing path
@@ -287,3 +293,145 @@ func TestEditTool_AppendFile_MissingContent(t *testing.T) {
t.Errorf("Expected error when content is missing")
}
}
+
+// TestReplaceEditContent verifies the helper function replaceEditContent
+func TestReplaceEditContent(t *testing.T) {
+ tests := []struct {
+ name string
+ content []byte
+ oldText string
+ newText string
+ expected []byte
+ expectError bool
+ }{
+ {
+ name: "successful replacement",
+ content: []byte("hello world"),
+ oldText: "world",
+ newText: "universe",
+ expected: []byte("hello universe"),
+ expectError: false,
+ },
+ {
+ name: "old text not found",
+ content: []byte("hello world"),
+ oldText: "golang",
+ newText: "rust",
+ expected: nil,
+ expectError: true,
+ },
+ {
+ name: "multiple matches found",
+ content: []byte("test text test"),
+ oldText: "test",
+ newText: "done",
+ expected: nil,
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := replaceEditContent(tt.content, tt.oldText, tt.newText)
+ if tt.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.expected, result)
+ }
+ })
+ }
+}
+
+// TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode
+// can append to a file that does not yet exist — it should silently create the file.
+// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW.
+func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewAppendFileTool(workspace, true)
+ ctx := context.Background()
+
+ args := map[string]any{
+ "path": "brand_new_file.txt",
+ "content": "first content",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(
+ t,
+ result.IsError,
+ "Expected success when appending to non-existent file in restricted mode, got: %s",
+ result.ForLLM,
+ )
+
+ // Verify the file was created with correct content
+ data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt"))
+ assert.NoError(t, err)
+ assert.Equal(t, "first content", string(data))
+}
+
+// TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode
+// correctly appends to an existing file within the sandbox.
+func TestAppendFileTool_Restricted_Success(t *testing.T) {
+ workspace := t.TempDir()
+ testFile := "existing.txt"
+ err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644)
+ assert.NoError(t, err)
+
+ tool := NewAppendFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": testFile,
+ "content": " appended",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+ assert.True(t, result.Silent)
+
+ data, err := os.ReadFile(filepath.Join(workspace, testFile))
+ assert.NoError(t, err)
+ assert.Equal(t, "initial appended", string(data))
+}
+
+// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode
+// correctly edits a file using the single-open editFileInRoot path.
+func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
+ workspace := t.TempDir()
+ testFile := "edit_target.txt"
+ err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0o644)
+ assert.NoError(t, err)
+
+ tool := NewEditFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": testFile,
+ "old_text": "World",
+ "new_text": "Go",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+ assert.True(t, result.Silent)
+
+ data, err := os.ReadFile(filepath.Join(workspace, testFile))
+ assert.NoError(t, err)
+ assert.Equal(t, "Hello Go", string(data))
+}
+
+// TestEditFileTool_Restricted_FileNotFound verifies that editFileInRoot returns a proper
+// error message when the target file does not exist.
+func TestEditFileTool_Restricted_FileNotFound(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewEditFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": "no_such_file.txt",
+ "old_text": "old",
+ "new_text": "new",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "not found")
+}
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 1bf50906e..03d461dcc 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -3,15 +3,19 @@ package tools
import (
"context"
"fmt"
+ "io/fs"
"os"
"path/filepath"
"strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
// validatePath ensures the given path is within the workspace if restrict is true.
func validatePath(path, workspace string, restrict bool) (string, error) {
if workspace == "" {
- return path, nil
+ return path, fmt.Errorf("workspace is not defined")
}
absWorkspace, err := filepath.Abs(workspace)
@@ -76,16 +80,21 @@ func resolveExistingAncestor(path string) (string, error) {
func isWithinWorkspace(candidate, workspace string) bool {
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
- return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
+ return err == nil && filepath.IsLocal(rel)
}
type ReadFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
- return &ReadFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &ReadFileTool{fs: fs}
}
func (t *ReadFileTool) Name() string {
@@ -115,26 +124,25 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("path is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
+ content, err := t.fs.ReadFile(path)
if err != nil {
return ErrorResult(err.Error())
}
-
- content, err := os.ReadFile(resolvedPath)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
- }
-
return NewToolResult(string(content))
}
type WriteFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
- return &WriteFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &WriteFileTool{fs: fs}
}
func (t *WriteFileTool) Name() string {
@@ -173,30 +181,25 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
return ErrorResult("content is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
+ if err := t.fs.WriteFile(path, []byte(content)); err != nil {
return ErrorResult(err.Error())
}
- dir := filepath.Dir(resolvedPath)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
- }
-
- if err := os.WriteFile(resolvedPath, []byte(content), 0o644); err != nil {
- return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("File written: %s", path))
}
type ListDirTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewListDirTool(workspace string, restrict bool) *ListDirTool {
- return &ListDirTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &ListDirTool{fs: fs}
}
func (t *ListDirTool) Name() string {
@@ -226,24 +229,189 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes
path = "."
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
- return ErrorResult(err.Error())
- }
-
- entries, err := os.ReadDir(resolvedPath)
+ entries, err := t.fs.ReadDir(path)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
}
+ return formatDirEntries(entries)
+}
- result := ""
+func formatDirEntries(entries []os.DirEntry) *ToolResult {
+ var result strings.Builder
for _, entry := range entries {
if entry.IsDir() {
- result += "DIR: " + entry.Name() + "\n"
+ result.WriteString("DIR: " + entry.Name() + "\n")
} else {
- result += "FILE: " + entry.Name() + "\n"
+ result.WriteString("FILE: " + entry.Name() + "\n")
+ }
+ }
+ return NewToolResult(result.String())
+}
+
+// fileSystem abstracts reading, writing, and listing files, allowing both
+// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface.
+type fileSystem interface {
+ ReadFile(path string) ([]byte, error)
+ WriteFile(path string, data []byte) error
+ ReadDir(path string) ([]os.DirEntry, error)
+}
+
+// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
+type hostFs struct{}
+
+func (h *hostFs) ReadFile(path string) ([]byte, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, fmt.Errorf("failed to read file: file not found: %w", err)
+ }
+ if os.IsPermission(err) {
+ return nil, fmt.Errorf("failed to read file: access denied: %w", err)
+ }
+ return nil, fmt.Errorf("failed to read file: %w", err)
+ }
+ return content, nil
+}
+
+func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
+ return os.ReadDir(path)
+}
+
+func (h *hostFs) WriteFile(path string, data []byte) error {
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ // Using 0o600 (owner read/write only) for secure default permissions.
+ return fileutil.WriteFileAtomic(path, data, 0o600)
+}
+
+// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
+type sandboxFs struct {
+ workspace string
+}
+
+func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error {
+ if r.workspace == "" {
+ return fmt.Errorf("workspace is not defined")
+ }
+
+ root, err := os.OpenRoot(r.workspace)
+ if err != nil {
+ return fmt.Errorf("failed to open workspace: %w", err)
+ }
+ defer root.Close()
+
+ relPath, err := getSafeRelPath(r.workspace, path)
+ if err != nil {
+ return err
+ }
+
+ return fn(root, relPath)
+}
+
+func (r *sandboxFs) ReadFile(path string) ([]byte, error) {
+ var content []byte
+ err := r.execute(path, func(root *os.Root, relPath string) error {
+ fileContent, err := root.ReadFile(relPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("failed to read file: file not found: %w", err)
+ }
+ // os.Root returns "escapes from parent" for paths outside the root
+ if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") ||
+ strings.Contains(err.Error(), "permission denied") {
+ return fmt.Errorf("failed to read file: access denied: %w", err)
+ }
+ return fmt.Errorf("failed to read file: %w", err)
+ }
+ content = fileContent
+ return nil
+ })
+ return content, err
+}
+
+func (r *sandboxFs) WriteFile(path string, data []byte) error {
+ return r.execute(path, func(root *os.Root, relPath string) error {
+ dir := filepath.Dir(relPath)
+ if dir != "." && dir != "/" {
+ if err := root.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create parent directories: %w", err)
+ }
+ }
+
+ // Use atomic write pattern with explicit sync for flash storage reliability.
+ // Using 0o600 (owner read/write only) for secure default permissions.
+ tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())
+
+ tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to open temp file: %w", err)
+ }
+
+ if _, err := tmpFile.Write(data); err != nil {
+ tmpFile.Close()
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to write temp file: %w", err)
+ }
+
+ // CRITICAL: Force sync to storage medium before rename.
+ // This ensures data is physically written to disk, not just cached.
+ if err := tmpFile.Sync(); err != nil {
+ tmpFile.Close()
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to sync temp file: %w", err)
+ }
+
+ if err := tmpFile.Close(); err != nil {
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to close temp file: %w", err)
+ }
+
+ if err := root.Rename(tmpRelPath, relPath); err != nil {
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to rename temp file over target: %w", err)
+ }
+
+ // Sync directory to ensure rename is durable
+ if dirFile, err := root.Open("."); err == nil {
+ _ = dirFile.Sync()
+ dirFile.Close()
+ }
+
+ return nil
+ })
+}
+
+func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
+ var entries []os.DirEntry
+ err := r.execute(path, func(root *os.Root, relPath string) error {
+ dirEntries, err := fs.ReadDir(root.FS(), relPath)
+ if err != nil {
+ return err
+ }
+ entries = dirEntries
+ return nil
+ })
+ return entries, err
+}
+
+// Helper to get a safe relative path for os.Root usage
+func getSafeRelPath(workspace, path string) (string, error) {
+ if workspace == "" {
+ return "", fmt.Errorf("workspace is not defined")
+ }
+
+ rel := filepath.Clean(path)
+ if filepath.IsAbs(rel) {
+ var err error
+ rel, err = filepath.Rel(workspace, rel)
+ if err != nil {
+ return "", fmt.Errorf("failed to calculate relative path: %w", err)
}
}
- return NewToolResult(result)
+ if !filepath.IsLocal(rel) {
+ return "", fmt.Errorf("path escapes workspace: %s", path)
+ }
+
+ return rel, nil
}
diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go
index 5daa3dcea..6f896e22d 100644
--- a/pkg/tools/filesystem_test.go
+++ b/pkg/tools/filesystem_test.go
@@ -2,10 +2,13 @@ package tools
import (
"context"
+ "io"
"os"
"path/filepath"
"strings"
"testing"
+
+ "github.com/stretchr/testify/assert"
)
// TestFilesystemTool_ReadFile_Success verifies successful file reading
@@ -14,7 +17,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0o644)
- tool := &ReadFileTool{}
+ tool := NewReadFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -41,7 +44,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
- tool := &ReadFileTool{}
+ tool := NewReadFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": "/nonexistent_file_12345.txt",
@@ -84,7 +87,7 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "newfile.txt")
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -123,7 +126,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "subdir", "newfile.txt")
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -149,7 +152,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path
func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"content": "test",
@@ -165,7 +168,7 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content
func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": "/tmp/test.txt",
@@ -192,7 +195,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644)
os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
args := map[string]any{
"path": tmpDir,
@@ -216,7 +219,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory
func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
args := map[string]any{
"path": "/nonexistent_directory_12345",
@@ -237,7 +240,7 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
args := map[string]any{}
@@ -275,7 +278,211 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
if !result.IsError {
t.Fatalf("expected symlink escape to be blocked")
}
- if !strings.Contains(result.ForLLM, "symlink resolves outside workspace") {
+ // os.Root might return different errors depending on platform/implementation
+ // but it definitely should error.
+ // Our wrapper returns "access denied or file not found"
+ if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
+ !strings.Contains(result.ForLLM, "no such file") {
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
}
}
+
+func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
+ tool := NewReadFileTool("", true) // restrict=true but workspace=""
+
+ // Try to read a sensitive file (simulated by a temp file outside workspace)
+ tmpDir := t.TempDir()
+ secretFile := filepath.Join(tmpDir, "shadow")
+ os.WriteFile(secretFile, []byte("secret data"), 0o600)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": secretFile,
+ })
+
+ // We EXPECT IsError=true (access blocked due to empty workspace)
+ assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
+
+ // Verify it failed for the right reason
+ assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
+}
+
+// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
+// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path.
+func TestRootMkdirAll(t *testing.T) {
+ workspace := t.TempDir()
+ root, err := os.OpenRoot(workspace)
+ if err != nil {
+ t.Fatalf("failed to open root: %v", err)
+ }
+ defer root.Close()
+
+ // Case 1: Single directory
+ err = root.MkdirAll("dir1", 0o755)
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(workspace, "dir1"))
+ assert.NoError(t, err)
+
+ // Case 2: Deeply nested directory
+ err = root.MkdirAll("a/b/c/d", 0o755)
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(workspace, "a/b/c/d"))
+ assert.NoError(t, err)
+
+ // Case 3: Already exists — must be idempotent
+ err = root.MkdirAll("a/b/c/d", 0o755)
+ assert.NoError(t, err)
+
+ // Case 4: A regular file blocks directory creation — must error
+ err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644)
+ assert.NoError(t, err)
+ err = root.MkdirAll("file_exists", 0o755)
+ assert.Error(t, err, "expected error when a file exists at the directory path")
+}
+
+func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewWriteFileTool(workspace, true)
+ ctx := context.Background()
+
+ testFile := "deep/nested/path/to/file.txt"
+ content := "deep content"
+ args := map[string]any{
+ "path": testFile,
+ "content": content,
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+
+ // Verify file content
+ actualPath := filepath.Join(workspace, testFile)
+ data, err := os.ReadFile(actualPath)
+ assert.NoError(t, err)
+ assert.Equal(t, content, string(data))
+}
+
+// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors.
+func TestHostRW_Read_PermissionDenied(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("skipping permission test: running as root")
+ }
+ tmpDir := t.TempDir()
+ protected := filepath.Join(tmpDir, "protected.txt")
+ err := os.WriteFile(protected, []byte("secret"), 0o000)
+ assert.NoError(t, err)
+ defer os.Chmod(protected, 0o644) // ensure cleanup
+
+ _, err = (&hostFs{}).ReadFile(protected)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "access denied")
+}
+
+// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path.
+func TestHostRW_Read_Directory(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ _, err := (&hostFs{}).ReadFile(tmpDir)
+ assert.Error(t, err, "expected error when reading a directory as a file")
+}
+
+// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory.
+func TestRootRW_Read_Directory(t *testing.T) {
+ workspace := t.TempDir()
+ root, err := os.OpenRoot(workspace)
+ assert.NoError(t, err)
+ defer root.Close()
+
+ // Create a subdirectory
+ err = root.Mkdir("subdir", 0o755)
+ assert.NoError(t, err)
+
+ _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir")
+ assert.Error(t, err, "expected error when reading a directory as a file")
+}
+
+// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically.
+func TestHostRW_Write_ParentDirMissing(t *testing.T) {
+ tmpDir := t.TempDir()
+ target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
+
+ err := (&hostFs{}).WriteFile(target, []byte("hello"))
+ assert.NoError(t, err)
+
+ data, err := os.ReadFile(target)
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", string(data))
+}
+
+// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates
+// nested parent directories automatically within the sandbox.
+func TestRootRW_Write_ParentDirMissing(t *testing.T) {
+ workspace := t.TempDir()
+
+ relPath := "x/y/z/file.txt"
+ err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested"))
+ assert.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(workspace, relPath))
+ assert.NoError(t, err)
+ assert.Equal(t, "nested", string(data))
+}
+
+// TestHostRW_Write verifies the hostRW.Write helper function
+func TestHostRW_Write(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "atomic_test.txt")
+ testData := []byte("atomic test content")
+
+ err := (&hostFs{}).WriteFile(testFile, testData)
+ assert.NoError(t, err)
+
+ content, err := os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, testData, content)
+
+ // Verify it overwrites correctly
+ newData := []byte("new atomic content")
+ err = (&hostFs{}).WriteFile(testFile, newData)
+ assert.NoError(t, err)
+
+ content, err = os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, newData, content)
+}
+
+// TestRootRW_Write verifies the rootRW.Write helper function
+func TestRootRW_Write(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ relPath := "atomic_root_test.txt"
+ testData := []byte("atomic root test content")
+
+ erw := &sandboxFs{workspace: tmpDir}
+ err := erw.WriteFile(relPath, testData)
+ assert.NoError(t, err)
+
+ root, err := os.OpenRoot(tmpDir)
+ assert.NoError(t, err)
+ defer root.Close()
+
+ f, err := root.Open(relPath)
+ assert.NoError(t, err)
+ defer f.Close()
+
+ content, err := io.ReadAll(f)
+ assert.NoError(t, err)
+ assert.Equal(t, testData, content)
+
+ // Verify it overwrites correctly
+ newData := []byte("new root atomic content")
+ err = erw.WriteFile(relPath, newData)
+ assert.NoError(t, err)
+
+ f2, err := root.Open(relPath)
+ assert.NoError(t, err)
+ defer f2.Close()
+
+ content, err = io.ReadAll(f2)
+ assert.NoError(t, err)
+ assert.Equal(t, newData, content)
+}
diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go
index 0387a26d3..779b1d5a7 100644
--- a/pkg/tools/i2c.go
+++ b/pkg/tools/i2c.go
@@ -117,13 +117,19 @@ func (t *I2CTool) detect() *ToolResult {
return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
}
+// Helper functions for I2C operations (used by platform-specific implementations)
+
// isValidBusID checks that a bus identifier is a simple number (prevents path injection)
+//
+//nolint:unused // Used by i2c_linux.go
func isValidBusID(id string) bool {
matched, _ := regexp.MatchString(`^\d+$`, id)
return matched
}
// parseI2CAddress extracts and validates an I2C address from args
+//
+//nolint:unused // Used by i2c_linux.go
func parseI2CAddress(args map[string]any) (int, *ToolResult) {
addrFloat, ok := args["address"].(float64)
if !ok {
@@ -137,6 +143,8 @@ func parseI2CAddress(args map[string]any) (int, *ToolResult) {
}
// parseI2CBus extracts and validates an I2C bus from args
+//
+//nolint:unused // Used by i2c_linux.go
func parseI2CBus(args map[string]any) (string, *ToolResult) {
bus, ok := args["bus"].(string)
if !ok || bus == "" {
diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go
index 6ecb8ae7c..d37a093a8 100644
--- a/pkg/tools/registry.go
+++ b/pkg/tools/registry.go
@@ -3,6 +3,7 @@ package tools
import (
"context"
"fmt"
+ "sort"
"sync"
"time"
@@ -107,13 +108,27 @@ func (r *ToolRegistry) ExecuteWithContext(
return result
}
+// sortedToolNames returns tool names in sorted order for deterministic iteration.
+// This is critical for KV cache stability: non-deterministic map iteration would
+// produce different system prompts and tool definitions on each call, invalidating
+// the LLM's prefix cache even when no tools have changed.
+func (r *ToolRegistry) sortedToolNames() []string {
+ names := make([]string, 0, len(r.tools))
+ for name := range r.tools {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ return names
+}
+
func (r *ToolRegistry) GetDefinitions() []map[string]any {
r.mu.RLock()
defer r.mu.RUnlock()
- definitions := make([]map[string]any, 0, len(r.tools))
- for _, tool := range r.tools {
- definitions = append(definitions, ToolToSchema(tool))
+ sorted := r.sortedToolNames()
+ definitions := make([]map[string]any, 0, len(sorted))
+ for _, name := range sorted {
+ definitions = append(definitions, ToolToSchema(r.tools[name]))
}
return definitions
}
@@ -124,8 +139,10 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
- definitions := make([]providers.ToolDefinition, 0, len(r.tools))
- for _, tool := range r.tools {
+ sorted := r.sortedToolNames()
+ definitions := make([]providers.ToolDefinition, 0, len(sorted))
+ for _, name := range sorted {
+ tool := r.tools[name]
schema := ToolToSchema(tool)
// Safely extract nested values with type checks
@@ -155,11 +172,7 @@ func (r *ToolRegistry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
- names := make([]string, 0, len(r.tools))
- for name := range r.tools {
- names = append(names, name)
- }
- return names
+ return r.sortedToolNames()
}
// Count returns the number of registered tools.
@@ -175,8 +188,10 @@ func (r *ToolRegistry) GetSummaries() []string {
r.mu.RLock()
defer r.mu.RUnlock()
- summaries := make([]string, 0, len(r.tools))
- for _, tool := range r.tools {
+ sorted := r.sortedToolNames()
+ summaries := make([]string, 0, len(sorted))
+ for _, name := range sorted {
+ tool := r.tools[name]
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description()))
}
return summaries
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
new file mode 100644
index 000000000..8ae13b20c
--- /dev/null
+++ b/pkg/tools/registry_test.go
@@ -0,0 +1,350 @@
+package tools
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// --- mock types ---
+
+type mockRegistryTool struct {
+ name string
+ desc string
+ params map[string]any
+ result *ToolResult
+}
+
+func (m *mockRegistryTool) Name() string { return m.name }
+func (m *mockRegistryTool) Description() string { return m.desc }
+func (m *mockRegistryTool) Parameters() map[string]any { return m.params }
+func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
+ return m.result
+}
+
+type mockCtxTool struct {
+ mockRegistryTool
+ channel string
+ chatID string
+}
+
+func (m *mockCtxTool) SetContext(channel, chatID string) {
+ m.channel = channel
+ m.chatID = chatID
+}
+
+type mockAsyncRegistryTool struct {
+ mockRegistryTool
+ cb AsyncCallback
+}
+
+func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) {
+ m.cb = cb
+}
+
+// --- helpers ---
+
+func newMockTool(name, desc string) *mockRegistryTool {
+ return &mockRegistryTool{
+ name: name,
+ desc: desc,
+ params: map[string]any{"type": "object"},
+ result: SilentResult("ok"),
+ }
+}
+
+// --- tests ---
+
+func TestNewToolRegistry(t *testing.T) {
+ r := NewToolRegistry()
+ if r.Count() != 0 {
+ t.Errorf("expected empty registry, got count %d", r.Count())
+ }
+ if len(r.List()) != 0 {
+ t.Errorf("expected empty list, got %v", r.List())
+ }
+}
+
+func TestToolRegistry_RegisterAndGet(t *testing.T) {
+ r := NewToolRegistry()
+ tool := newMockTool("echo", "echoes input")
+ r.Register(tool)
+
+ got, ok := r.Get("echo")
+ if !ok {
+ t.Fatal("expected to find registered tool")
+ }
+ if got.Name() != "echo" {
+ t.Errorf("expected name 'echo', got %q", got.Name())
+ }
+}
+
+func TestToolRegistry_Get_NotFound(t *testing.T) {
+ r := NewToolRegistry()
+ _, ok := r.Get("nonexistent")
+ if ok {
+ t.Error("expected ok=false for unregistered tool")
+ }
+}
+
+func TestToolRegistry_RegisterOverwrite(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("dup", "first"))
+ r.Register(newMockTool("dup", "second"))
+
+ if r.Count() != 1 {
+ t.Errorf("expected count 1 after overwrite, got %d", r.Count())
+ }
+ tool, _ := r.Get("dup")
+ if tool.Description() != "second" {
+ t.Errorf("expected overwritten description 'second', got %q", tool.Description())
+ }
+}
+
+func TestToolRegistry_Execute_Success(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(&mockRegistryTool{
+ name: "greet",
+ desc: "says hello",
+ params: map[string]any{},
+ result: SilentResult("hello"),
+ })
+
+ result := r.Execute(context.Background(), "greet", nil)
+ if result.IsError {
+ t.Errorf("expected success, got error: %s", result.ForLLM)
+ }
+ if result.ForLLM != "hello" {
+ t.Errorf("expected ForLLM 'hello', got %q", result.ForLLM)
+ }
+}
+
+func TestToolRegistry_Execute_NotFound(t *testing.T) {
+ r := NewToolRegistry()
+ result := r.Execute(context.Background(), "missing", nil)
+ if !result.IsError {
+ t.Error("expected error for missing tool")
+ }
+ if !strings.Contains(result.ForLLM, "not found") {
+ t.Errorf("expected 'not found' in error, got %q", result.ForLLM)
+ }
+ if result.Err == nil {
+ t.Error("expected Err to be set via WithError")
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
+ r := NewToolRegistry()
+ ct := &mockCtxTool{
+ mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
+ }
+ r.Register(ct)
+
+ r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
+
+ if ct.channel != "telegram" {
+ t.Errorf("expected channel 'telegram', got %q", ct.channel)
+ }
+ if ct.chatID != "chat-42" {
+ t.Errorf("expected chatID 'chat-42', got %q", ct.chatID)
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
+ r := NewToolRegistry()
+ ct := &mockCtxTool{
+ mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
+ }
+ r.Register(ct)
+
+ r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
+
+ if ct.channel != "" || ct.chatID != "" {
+ t.Error("SetContext should not be called with empty channel/chatID")
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
+ r := NewToolRegistry()
+ at := &mockAsyncRegistryTool{
+ mockRegistryTool: *newMockTool("async_tool", "async work"),
+ }
+ at.result = AsyncResult("started")
+ r.Register(at)
+
+ called := false
+ cb := func(_ context.Context, _ *ToolResult) { called = true }
+
+ result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb)
+ if at.cb == nil {
+ t.Error("expected SetCallback to have been called")
+ }
+ if !result.Async {
+ t.Error("expected async result")
+ }
+
+ at.cb(context.Background(), SilentResult("done"))
+ if !called {
+ t.Error("expected callback to be invoked")
+ }
+}
+
+func TestToolRegistry_GetDefinitions(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("alpha", "tool A"))
+
+ defs := r.GetDefinitions()
+ if len(defs) != 1 {
+ t.Fatalf("expected 1 definition, got %d", len(defs))
+ }
+ if defs[0]["type"] != "function" {
+ t.Errorf("expected type 'function', got %v", defs[0]["type"])
+ }
+ fn, ok := defs[0]["function"].(map[string]any)
+ if !ok {
+ t.Fatal("expected 'function' key to be a map")
+ }
+ if fn["name"] != "alpha" {
+ t.Errorf("expected name 'alpha', got %v", fn["name"])
+ }
+ if fn["description"] != "tool A" {
+ t.Errorf("expected description 'tool A', got %v", fn["description"])
+ }
+}
+
+func TestToolRegistry_ToProviderDefs(t *testing.T) {
+ r := NewToolRegistry()
+ params := map[string]any{"type": "object", "properties": map[string]any{}}
+ r.Register(&mockRegistryTool{
+ name: "beta",
+ desc: "tool B",
+ params: params,
+ result: SilentResult("ok"),
+ })
+
+ defs := r.ToProviderDefs()
+ if len(defs) != 1 {
+ t.Fatalf("expected 1 provider def, got %d", len(defs))
+ }
+
+ want := providers.ToolDefinition{
+ Type: "function",
+ Function: providers.ToolFunctionDefinition{
+ Name: "beta",
+ Description: "tool B",
+ Parameters: params,
+ },
+ }
+ got := defs[0]
+ if got.Type != want.Type {
+ t.Errorf("Type: want %q, got %q", want.Type, got.Type)
+ }
+ if got.Function.Name != want.Function.Name {
+ t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name)
+ }
+ if got.Function.Description != want.Function.Description {
+ t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description)
+ }
+}
+
+func TestToolRegistry_List(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("x", ""))
+ r.Register(newMockTool("y", ""))
+
+ names := r.List()
+ if len(names) != 2 {
+ t.Fatalf("expected 2 names, got %d", len(names))
+ }
+
+ nameSet := map[string]bool{}
+ for _, n := range names {
+ nameSet[n] = true
+ }
+ if !nameSet["x"] || !nameSet["y"] {
+ t.Errorf("expected names {x, y}, got %v", names)
+ }
+}
+
+func TestToolRegistry_Count(t *testing.T) {
+ r := NewToolRegistry()
+ if r.Count() != 0 {
+ t.Errorf("expected 0, got %d", r.Count())
+ }
+
+ r.Register(newMockTool("a", ""))
+ r.Register(newMockTool("b", ""))
+ if r.Count() != 2 {
+ t.Errorf("expected 2, got %d", r.Count())
+ }
+
+ r.Register(newMockTool("a", "replaced"))
+ if r.Count() != 2 {
+ t.Errorf("expected 2 after overwrite, got %d", r.Count())
+ }
+}
+
+func TestToolRegistry_GetSummaries(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("read_file", "Reads a file"))
+
+ summaries := r.GetSummaries()
+ if len(summaries) != 1 {
+ t.Fatalf("expected 1 summary, got %d", len(summaries))
+ }
+ if !strings.Contains(summaries[0], "`read_file`") {
+ t.Errorf("expected backtick-quoted name in summary, got %q", summaries[0])
+ }
+ if !strings.Contains(summaries[0], "Reads a file") {
+ t.Errorf("expected description in summary, got %q", summaries[0])
+ }
+}
+
+func TestToolToSchema(t *testing.T) {
+ tool := newMockTool("demo", "demo tool")
+ schema := ToolToSchema(tool)
+
+ if schema["type"] != "function" {
+ t.Errorf("expected type 'function', got %v", schema["type"])
+ }
+ fn, ok := schema["function"].(map[string]any)
+ if !ok {
+ t.Fatal("expected 'function' to be a map")
+ }
+ if fn["name"] != "demo" {
+ t.Errorf("expected name 'demo', got %v", fn["name"])
+ }
+ if fn["description"] != "demo tool" {
+ t.Errorf("expected description 'demo tool', got %v", fn["description"])
+ }
+ if fn["parameters"] == nil {
+ t.Error("expected parameters to be set")
+ }
+}
+
+func TestToolRegistry_ConcurrentAccess(t *testing.T) {
+ r := NewToolRegistry()
+ var wg sync.WaitGroup
+
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ name := string(rune('A' + n%26))
+ r.Register(newMockTool(name, "concurrent"))
+ r.Get(name)
+ r.Count()
+ r.List()
+ r.GetDefinitions()
+ }(i)
+ }
+
+ wg.Wait()
+
+ if r.Count() == 0 {
+ t.Error("expected tools to be registered after concurrent access")
+ }
+}
diff --git a/pkg/tools/result.go b/pkg/tools/result.go
index b13055b1c..cab833284 100644
--- a/pkg/tools/result.go
+++ b/pkg/tools/result.go
@@ -30,6 +30,10 @@ type ToolResult struct {
// Err is the underlying error (not JSON serialized).
// Used for internal error handling and logging.
Err error `json:"-"`
+
+ // Media contains media store refs produced by this tool.
+ // When non-empty, the agent will publish these as OutboundMediaMessage.
+ Media []string `json:"media,omitempty"`
}
// NewToolResult creates a basic ToolResult with content for the LLM.
@@ -120,6 +124,19 @@ func UserResult(content string) *ToolResult {
}
}
+// MediaResult creates a ToolResult with media refs for the user.
+// The agent will publish these refs as OutboundMediaMessage.
+//
+// Example:
+//
+// result := MediaResult("Image generated successfully", []string{"media://abc123"})
+func MediaResult(forLLM string, mediaRefs []string) *ToolResult {
+ return &ToolResult{
+ ForLLM: forLLM,
+ Media: mediaRefs,
+ }
+}
+
// MarshalJSON implements custom JSON serialization.
// The Err field is excluded from JSON output via the json:"-" tag.
func (tr *ToolResult) MarshalJSON() ([]byte, error) {
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index a1ee0b6e1..ad1664b5b 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -76,11 +76,11 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool {
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool {
denyPatterns := make([]*regexp.Regexp, 0)
- enableDenyPatterns := true
if config != nil {
execConfig := config.Tools.Exec
- enableDenyPatterns = execConfig.EnableDenyPatterns
+ enableDenyPatterns := execConfig.EnableDenyPatterns
if enableDenyPatterns {
+ denyPatterns = append(denyPatterns, defaultDenyPatterns...)
if len(execConfig.CustomDenyPatterns) > 0 {
fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
for _, pattern := range execConfig.CustomDenyPatterns {
@@ -91,8 +91,6 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
}
denyPatterns = append(denyPatterns, re)
}
- } else {
- denyPatterns = append(denyPatterns, defaultDenyPatterns...)
}
} else {
// If deny patterns are disabled, we won't add any patterns, allowing all commands.
diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go
index 55c0b678d..71bfe730b 100644
--- a/pkg/tools/skills_install.go
+++ b/pkg/tools/skills_install.go
@@ -9,6 +9,7 @@ import (
"sync"
"time"
+ "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
@@ -197,5 +198,6 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
return err
}
- return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644)
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
}
diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go
index 73d385cb0..8b166b41f 100644
--- a/pkg/tools/spawn.go
+++ b/pkg/tools/spawn.go
@@ -3,6 +3,7 @@ package tools
import (
"context"
"fmt"
+ "strings"
)
type SpawnTool struct {
@@ -66,8 +67,8 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string)
- if !ok {
- return ErrorResult("task is required")
+ if !ok || strings.TrimSpace(task) == "" {
+ return ErrorResult("task is required and must be a non-empty string")
}
label, _ := args["label"].(string)
diff --git a/pkg/tools/spawn_test.go b/pkg/tools/spawn_test.go
new file mode 100644
index 000000000..0646c82a9
--- /dev/null
+++ b/pkg/tools/spawn_test.go
@@ -0,0 +1,79 @@
+package tools
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
+ provider := &MockLLMProvider{}
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ tool := NewSpawnTool(manager)
+
+ ctx := context.Background()
+
+ tests := []struct {
+ name string
+ args map[string]any
+ }{
+ {"empty string", map[string]any{"task": ""}},
+ {"whitespace only", map[string]any{"task": " "}},
+ {"tabs and newlines", map[string]any{"task": "\t\n "}},
+ {"missing task key", map[string]any{"label": "test"}},
+ {"wrong type", map[string]any{"task": 123}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tool.Execute(ctx, tt.args)
+ if result == nil {
+ t.Fatal("Result should not be nil")
+ }
+ if !result.IsError {
+ t.Error("Expected error for invalid task parameter")
+ }
+ if !strings.Contains(result.ForLLM, "task is required") {
+ t.Errorf("Error message should mention 'task is required', got: %s", result.ForLLM)
+ }
+ })
+ }
+}
+
+func TestSpawnTool_Execute_ValidTask(t *testing.T) {
+ provider := &MockLLMProvider{}
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ tool := NewSpawnTool(manager)
+
+ ctx := context.Background()
+ args := map[string]any{
+ "task": "Write a haiku about coding",
+ "label": "haiku-task",
+ }
+
+ result := tool.Execute(ctx, args)
+ if result == nil {
+ t.Fatal("Result should not be nil")
+ }
+ if result.IsError {
+ t.Errorf("Expected success for valid task, got error: %s", result.ForLLM)
+ }
+ if !result.Async {
+ t.Error("SpawnTool should return async result")
+ }
+}
+
+func TestSpawnTool_Execute_NilManager(t *testing.T) {
+ tool := NewSpawnTool(nil)
+
+ ctx := context.Background()
+ args := map[string]any{"task": "test task"}
+
+ result := tool.Execute(ctx, args)
+ if !result.IsError {
+ t.Error("Expected error for nil manager")
+ }
+ if !strings.Contains(result.ForLLM, "Subagent manager not configured") {
+ t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go
index d6a88a5b0..0ca17e84f 100644
--- a/pkg/tools/spi.go
+++ b/pkg/tools/spi.go
@@ -119,7 +119,11 @@ func (t *SPITool) list() *ToolResult {
return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result)))
}
+// Helper function for SPI operations (used by platform-specific implementations)
+
// parseSPIArgs extracts and validates common SPI parameters
+//
+//nolint:unused // Used by spi_linux.go
func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
dev, ok := args["device"].(string)
if !ok || dev == "" {
diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go
index 91ebff636..69f1a49a2 100644
--- a/pkg/tools/subagent.go
+++ b/pkg/tools/subagent.go
@@ -132,12 +132,12 @@ After completing the task, provide a clear summary of what was done.`
},
}
- // Check if context is already cancelled before starting
+ // Check if context is already canceled before starting
select {
case <-ctx.Done():
sm.mu.Lock()
- task.Status = "cancelled"
- task.Result = "Task cancelled before execution"
+ task.Status = "canceled"
+ task.Result = "Task canceled before execution"
sm.mu.Unlock()
return
default:
@@ -185,10 +185,10 @@ After completing the task, provide a clear summary of what was done.`
if err != nil {
task.Status = "failed"
task.Result = fmt.Sprintf("Error: %v", err)
- // Check if it was cancelled
+ // Check if it was canceled
if ctx.Err() != nil {
- task.Status = "cancelled"
- task.Result = "Task cancelled during execution"
+ task.Status = "canceled"
+ task.Result = "Task canceled during execution"
}
result = &ToolResult{
ForLLM: task.Result,
@@ -218,7 +218,9 @@ After completing the task, provide a clear summary of what was done.`
// Send announce message back to main agent
if sm.bus != nil {
announceContent := fmt.Sprintf("Task '%s' completed.\n\nResult:\n%s", task.Label, task.Result)
- sm.bus.PublishInbound(bus.InboundMessage{
+ pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer pubCancel()
+ sm.bus.PublishInbound(pubCtx, bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("subagent:%s", task.ID),
// Format: "original_channel:original_chat_id" for routing back
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index 301e00daf..8ba2a723a 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -1,6 +1,7 @@
package tools
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
@@ -16,12 +17,63 @@ const (
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
+// Pre-compiled regexes for HTML text extraction
+var (
+ reScript = regexp.MustCompile(``)
- result := re.ReplaceAllLiteralString(htmlContent, "")
- re = regexp.MustCompile(`