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 339f17113..b67c1c8c8 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
@@ -43,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)
@@ -90,14 +93,50 @@ build: generate
echo "Install hint skipped: /usr/local/bin command is for Linux/macOS (current: $(PLATFORM))."; \
fi
+## 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"
@@ -150,6 +189,10 @@ fmt:
lint:
@$(GOLANGCI_LINT) run
+## fix: Fix linting issues
+fix:
+ @$(GOLANGCI_LINT) run --fix
+
## deps: Download dependencies
deps:
@$(GO) mod download
@@ -175,7 +218,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 f63077c8d..dc91f432f 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -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 96cf26fb2..eb6e886b0 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
```
### 🚀 クイックスタート(ネイティブ)
@@ -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": {
@@ -217,6 +226,9 @@ picoclaw onboard
}
```
+> **新機能**: `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)
@@ -949,6 +961,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 が自動的にラウンドロビンで分散します:
diff --git a/README.md b/README.md
index 5186cd3df..2a9e1f15b 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,9 @@
+
+
+
%s", escaped))
- }
-
- for i, table := range tables.tables {
- escaped := escapeHTML(table)
- text = strings.ReplaceAll(text, fmt.Sprintf("\x00TB%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
-}
-
-type tableBlockMatch struct {
- text string
- tables []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 := "\x00CB" + strconv.Itoa(i) + "\x00"
- 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 := "\x00IC" + strconv.Itoa(i) + "\x00"
- i++
- return placeholder
- })
-
- return inlineCodeMatch{text: text, codes: codes}
-}
-
-func extractMarkdownTables(text string) tableBlockMatch {
- lines := strings.Split(text, "\n")
- out := make([]string, 0, len(lines))
- tables := make([]string, 0, 4)
- placeholderIdx := 0
-
- for i := 0; i < len(lines); {
- if i+1 < len(lines) && isTableRowLine(lines[i]) && isTableSeparatorLine(lines[i+1]) {
- start := i
- i += 2
- for i < len(lines) && isTableRowLine(lines[i]) {
- i++
- }
- block := lines[start:i]
- formatted := formatMarkdownTable(block)
- placeholder := "\x00TB" + strconv.Itoa(placeholderIdx) + "\x00"
- placeholderIdx++
- tables = append(tables, formatted)
- out = append(out, placeholder)
- continue
- }
- out = append(out, lines[i])
- i++
- }
-
- return tableBlockMatch{
- text: strings.Join(out, "\n"),
- tables: tables,
- }
-}
-
-func isTableRowLine(line string) bool {
- trimmed := strings.TrimSpace(line)
- return strings.Count(trimmed, "|") >= 2
-}
-
-func isTableSeparatorLine(line string) bool {
- trimmed := strings.TrimSpace(line)
- if trimmed == "" {
- return false
- }
- trimmed = strings.Trim(trimmed, "|")
- parts := strings.Split(trimmed, "|")
- if len(parts) == 0 {
- return false
- }
- for _, p := range parts {
- c := strings.TrimSpace(p)
- if c == "" {
- return false
- }
- for _, r := range c {
- if r != '-' && r != ':' && r != ' ' {
- return false
- }
- }
- if !strings.Contains(c, "-") {
- return false
- }
- }
- return true
-}
-
-func parseTableCells(line string) []string {
- trimmed := strings.TrimSpace(line)
- trimmed = strings.Trim(trimmed, "|")
- raw := strings.Split(trimmed, "|")
- cells := make([]string, 0, len(raw))
- for _, c := range raw {
- cells = append(cells, strings.TrimSpace(c))
- }
- return cells
-}
-
-func formatMarkdownTable(lines []string) string {
- if len(lines) < 2 {
- return strings.Join(lines, "\n")
- }
-
- rows := make([][]string, 0, len(lines)-1)
- rows = append(rows, parseTableCells(lines[0])) // header
- for _, l := range lines[2:] { // skip separator
- rows = append(rows, parseTableCells(l))
- }
-
- cols := 0
- for _, r := range rows {
- if len(r) > cols {
- cols = len(r)
- }
- }
- if cols == 0 {
- return strings.Join(lines, "\n")
- }
-
- widths := make([]int, cols)
- for _, r := range rows {
- for i := 0; i < cols; i++ {
- cell := ""
- if i < len(r) {
- cell = r[i]
- }
- w := displayWidth(cell)
- if w > widths[i] {
- widths[i] = w
- }
- }
- }
-
- // Enforce horizontal width limit by shrinking widest columns first.
- totalWidth := func(ws []int) int {
- sum := 0
- for _, w := range ws {
- sum += w
- }
- if len(ws) == 0 {
- return 0
- }
- // "| " + " | ".join(cols) + " |"
- return sum + 4 + (len(ws)-1)*3
- }
-
- for totalWidth(widths) > markdownTableMaxWidth {
- maxIdx := -1
- maxW := 0
- for i, w := range widths {
- if w > maxW && w > markdownTableMinColWidth {
- maxW = w
- maxIdx = i
- }
- }
- if maxIdx == -1 {
- break
- }
- widths[maxIdx]--
- }
-
- padToWidth := func(s string, w int) string {
- dw := displayWidth(s)
- if dw >= w {
- return s
- }
- return s + strings.Repeat(" ", w-dw)
- }
-
- renderLogicalRow := func(r []string) []string {
- wrapped := make([][]string, cols)
- maxLines := 1
- for c := 0; c < cols; c++ {
- cell := ""
- if c < len(r) {
- cell = r[c]
- }
- lines := wrapByDisplayWidth(cell, widths[c])
- if len(lines) == 0 {
- lines = []string{""}
- }
- wrapped[c] = lines
- if len(lines) > maxLines {
- maxLines = len(lines)
- }
- }
-
- out := make([]string, 0, maxLines)
- for lineIdx := 0; lineIdx < maxLines; lineIdx++ {
- var rowBuilder strings.Builder
- rowBuilder.WriteString("| ")
- for c := 0; c < cols; c++ {
- cellLine := ""
- if lineIdx < len(wrapped[c]) {
- cellLine = wrapped[c][lineIdx]
- }
- rowBuilder.WriteString(padToWidth(cellLine, widths[c]))
- if c == cols-1 {
- rowBuilder.WriteString(" |")
- } else {
- rowBuilder.WriteString(" | ")
- }
- }
- out = append(out, rowBuilder.String())
- }
- return out
- }
-
- var b strings.Builder
- for rIdx, r := range rows {
- rowLines := renderLogicalRow(r)
- for _, line := range rowLines {
- b.WriteString(line)
- b.WriteString("\n")
- }
- if rIdx == 0 {
- b.WriteString("| ")
- for c := 0; c < cols; c++ {
- b.WriteString(strings.Repeat("-", widths[c]))
- if c == cols-1 {
- b.WriteString(" |\n")
- } else {
- b.WriteString(" | ")
- }
- }
- }
- }
- return strings.TrimRight(b.String(), "\n")
-}
-
-func runeDisplayWidth(r rune) int {
- switch {
- case r == '\u200d' || r == '\u200c' || r == '\ufe0f':
- return 0
- case unicode.Is(unicode.Mn, r):
- return 0
- case isEmojiRune(r):
- return 3
- case unicode.In(r,
- unicode.Han,
- unicode.Hiragana,
- unicode.Katakana,
- unicode.Hangul):
- return 2
- case (r >= 0x3000 && r <= 0x303F) || (r >= 0xFF00 && r <= 0xFFEF):
- // CJK symbols/punctuation and half/fullwidth forms.
- return 2
- default:
- return 1
- }
-}
-
-func displayWidth(s string) int {
- w := 0
- for _, r := range s {
- w += runeDisplayWidth(r)
- }
- return w
-}
-
-func isEmojiRune(r rune) bool {
- // Common emoji blocks + Dingbats/Stars used in rating tables.
- return (r >= 0x1F300 && r <= 0x1FAFF) || // Misc emoji/pictographs/symbols
- (r >= 0x2600 && r <= 0x27BF) || // Misc symbols + dingbats
- r == 0x2B50 // WHITE MEDIUM STAR (⭐)
-}
-
-func wrapByDisplayWidth(s string, maxWidth int) []string {
- if maxWidth <= 0 {
- return []string{s}
- }
- if s == "" {
- return []string{""}
- }
-
- lines := make([]string, 0, 1)
- var cur strings.Builder
- curWidth := 0
-
- flush := func() {
- lines = append(lines, strings.TrimRight(cur.String(), " "))
- cur.Reset()
- curWidth = 0
- }
-
- for _, r := range s {
- if r == '\n' {
- flush()
- continue
- }
- rw := runeDisplayWidth(r)
- if rw == 0 {
- cur.WriteRune(r)
- continue
- }
- if curWidth+rw > maxWidth && cur.Len() > 0 {
- flush()
- }
- cur.WriteRune(r)
- curWidth += rw
- }
-
- if cur.Len() > 0 || len(lines) == 0 {
- flush()
- }
-
- return lines
-}
-
-var htmlEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">")
-
-func escapeHTML(text string) string {
- return htmlEscaper.Replace(text)
-}
-
-// statusToHTML converts status message content to Telegram HTML.
-// It HTML-escapes the text and converts backtick code fences to blocks.
-func statusToHTML(content string) string {
- parts := strings.Split(content, "```")
- if len(parts) < 3 {
- return escapeHTML(content)
- }
- var sb strings.Builder
- for i, part := range parts {
- if i%2 == 0 {
- sb.WriteString(escapeHTML(part))
- } else {
- // Strip optional language tag on the opening line
- body := part
- if nl := strings.Index(body, "\n"); nl >= 0 {
- tag := strings.TrimSpace(body[:nl])
- if tag == "" || !strings.ContainsAny(tag, " \t") {
- body = body[nl+1:]
- }
- }
- sb.WriteString("")
- sb.WriteString(escapeHTML(body))
- sb.WriteString("")
- }
- }
- return sb.String()
-}
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 51fb79660..aa7e4c147 100644
--- a/pkg/channels/telegram_commands.go
+++ b/pkg/channels/telegram/telegram_commands.go
@@ -1,4 +1,4 @@
-package channels
+package telegram
import (
"context"
@@ -85,7 +85,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"
@@ -123,8 +123,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/telegram_test.go b/pkg/channels/telegram_test.go
deleted file mode 100644
index 3f3ccaca8..000000000
--- a/pkg/channels/telegram_test.go
+++ /dev/null
@@ -1,183 +0,0 @@
-package channels
-
-import (
- "strings"
- "testing"
-)
-
-func TestSanitizeTelegramOutgoingContent_PlainText(t *testing.T) {
- in := " ユーザー向け本文 "
- got := sanitizeTelegramOutgoingContent(in)
- want := "ユーザー向け本文"
- if got != want {
- t.Fatalf("sanitizeTelegramOutgoingContent() = %q, want %q", got, want)
- }
-}
-
-func TestSanitizeTelegramOutgoingContent_Empty(t *testing.T) {
- got := sanitizeTelegramOutgoingContent("")
- want := "(empty response)"
- if got != want {
- t.Fatalf("sanitizeTelegramOutgoingContent() = %q, want %q", got, want)
- }
-}
-
-func TestSanitizeTelegramOutgoingContent_WhitespaceOnly(t *testing.T) {
- got := sanitizeTelegramOutgoingContent(" \n\t ")
- want := "(empty response)"
- if got != want {
- t.Fatalf("sanitizeTelegramOutgoingContent() = %q, want %q", got, want)
- }
-}
-
-func TestMarkdownToTelegramHTML_Heading(t *testing.T) {
- in := "### メッセージ\n- A\n- B"
- got := markdownToTelegramHTML(in)
- if !strings.Contains(got, "メッセージ") {
- t.Fatalf("expected heading to be bold, got: %q", got)
- }
-}
-
-func TestMarkdownToTelegramHTML_Table(t *testing.T) {
- in := "| col1 | col2 |\n| --- | --- |\n| a | b |"
- got := markdownToTelegramHTML(in)
- if !strings.Contains(got, "") {
- t.Fatalf("expected table to render as code block, got: %q", got)
- }
- if !strings.Contains(got, "col1") || !strings.Contains(got, "a") {
- t.Fatalf("expected table content to remain, got: %q", got)
- }
-}
-
-func TestMarkdownToTelegramHTML_TableCJKAlignment(t *testing.T) {
- in := "| 項目 | value |\n| --- | --- |\n| 温度 | 23.5 |\n| 状態 | 正常 |"
- got := markdownToTelegramHTML(in)
-
- mustContain := []string{
- "",
- "| 項目",
- "| value",
- "| ----",
- "| 温度",
- "| 状態",
- }
- for _, s := range mustContain {
- if !strings.Contains(got, s) {
- t.Fatalf("expected output to contain %q, got: %q", s, got)
- }
- }
-}
-
-func TestMarkdownToTelegramHTML_TableWrapsLongCell(t *testing.T) {
- in := "| col1 | col2 |\n| --- | --- |\n| short | this is a very very very very long cell that should wrap |"
- got := markdownToTelegramHTML(in)
-
- if !strings.Contains(got, "") {
- t.Fatalf("expected table to render as code block, got: %q", got)
- }
- if !strings.Contains(got, "| col1") || !strings.Contains(got, "| col2") {
- t.Fatalf("expected header row, got: %q", got)
- }
- if !strings.Contains(got, "| short") {
- t.Fatalf("expected data row with short cell, got: %q", got)
- }
- // With markdownTableMaxWidth=42 the long cell MUST be wrapped into
- // multiple visual lines. Verify the continuation line exists.
- if !strings.Contains(got, "long cell that should wrap") {
- t.Fatalf("expected wrapped continuation line, got: %q", got)
- }
- // The continuation row must have an empty first column (padding only).
- if !strings.Contains(got, "| | long cell") {
- t.Fatalf("expected continuation row with empty first col, got: %q", got)
- }
-}
-
-func TestFormatMarkdownTable_Width42(t *testing.T) {
- lines := []string{
- "| col1 | col2 |",
- "| --- | --- |",
- "| short | this is a very very very very long cell that should wrap |",
- }
- got := formatMarkdownTable(lines)
-
- // Every line must fit within markdownTableMaxWidth (42).
- for i, line := range strings.Split(got, "\n") {
- w := displayWidth(line)
- if w > markdownTableMaxWidth {
- t.Errorf("line %d width %d > %d: %q", i, w, markdownTableMaxWidth, line)
- }
- }
-
- // Must produce more lines than a non-wrapped table (header + sep + 1 data = 3).
- // With wrapping the data row becomes 2 visual lines → total 4.
- lineCount := len(strings.Split(got, "\n"))
- if lineCount < 4 {
- t.Errorf("expected at least 4 lines (wrap must occur), got %d:\n%s", lineCount, got)
- }
-
- // Verify continuation row has blank first column.
- if !strings.Contains(got, "| | long cell that should wrap") {
- t.Errorf("expected continuation row, got:\n%s", got)
- }
-}
-
-// TestFormatMarkdownTable_MultiColWrap verifies that a multi-column,
-// multi-row table correctly wraps one long cell while leaving other
-// rows and columns unaffected.
-// With markdownTableMaxWidth=42, col widths shrink to [7, 5, 20].
-// Bob's "Needs more practice in writing" (30 chars) wraps at width 20.
-func TestFormatMarkdownTable_MultiColWrap(t *testing.T) {
- lines := []string{
- "| Name | Score | Comment |",
- "| --- | --- | --- |",
- "| Alice | 95 | Great job |",
- "| Bob | 72 | Needs more practice in writing |",
- "| Charlie | 88 | Good |",
- }
- got := formatMarkdownTable(lines)
- rows := strings.Split(got, "\n")
-
- wantLines := []string{
- "| Name | Score | Comment |",
- "| ------- | ----- | -------------------- |",
- "| Alice | 95 | Great job |",
- "| Bob | 72 | Needs more practice |",
- "| | | in writing |",
- "| Charlie | 88 | Good |",
- }
-
- if len(rows) != len(wantLines) {
- t.Fatalf("line count: got %d, want %d\nactual:\n%s", len(rows), len(wantLines), got)
- }
-
- for i, want := range wantLines {
- if rows[i] != want {
- t.Errorf("line %d:\n got: %q\n want: %q", i, rows[i], want)
- }
- }
-
- // Every line must be exactly markdownTableMaxWidth.
- for i, line := range rows {
- w := displayWidth(line)
- if w != markdownTableMaxWidth {
- t.Errorf("line %d: displayWidth=%d, want %d: %q", i, w, markdownTableMaxWidth, line)
- }
- }
-}
-
-func TestDisplayWidth_EmojiIsThree(t *testing.T) {
- got := displayWidth("⭐⭐⭐⭐⭐")
- if got != 15 {
- t.Fatalf("displayWidth(stars) = %d, want 15", got)
- }
-}
-
-func TestParseChatID_Plain(t *testing.T) {
- id, err := parseChatID("123456789")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if id != 123456789 {
- t.Errorf("expected 123456789, got %d", id)
- }
-}
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 af3f228ca..39317d97f 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(),
@@ -384,12 +367,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 {
@@ -405,8 +397,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
@@ -439,10 +442,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)
@@ -474,129 +482,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..188a7c8fa
--- /dev/null
+++ b/pkg/channels/whatsapp_native/whatsapp_native.go
@@ -0,0 +1,448 @@
+//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"
+ "sync/atomic"
+ "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
+ stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls
+ wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect)
+}
+
+// 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})
+
+ // Reset lifecycle state from any previous Stop() so a restarted channel
+ // behaves correctly. Use reconnectMu to be consistent with eventHandler
+ // and Stop() which coordinate under the same lock.
+ c.reconnectMu.Lock()
+ c.stopping.Store(false)
+ c.reconnecting = false
+ c.reconnectMu.Unlock()
+
+ 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)
+
+ // Create runCtx/runCancel BEFORE registering event handler and starting
+ // goroutines so that Stop() can cancel them at any time, including during
+ // the QR-login flow.
+ c.runCtx, c.runCancel = context.WithCancel(ctx)
+
+ client.AddEventHandler(c.eventHandler)
+
+ c.mu.Lock()
+ c.container = container
+ c.client = client
+ c.mu.Unlock()
+
+ // cleanupOnError clears struct references and releases resources when
+ // Start() fails after fields are already assigned. This prevents
+ // Stop() from operating on stale references (double-close, disconnect
+ // of a partially-initialized client, or stray event handler callbacks).
+ startOK := false
+ defer func() {
+ if startOK {
+ return
+ }
+ c.runCancel()
+ client.Disconnect()
+ c.mu.Lock()
+ c.client = nil
+ c.container = nil
+ c.mu.Unlock()
+ _ = container.Close()
+ }()
+
+ if client.Store.ID == nil {
+ qrChan, err := client.GetQRChannel(c.runCtx)
+ if err != nil {
+ return fmt.Errorf("get QR channel: %w", err)
+ }
+ if err := client.Connect(); err != nil {
+ return fmt.Errorf("connect: %w", err)
+ }
+ // Handle QR events in a background goroutine so Start() returns
+ // promptly. The goroutine is tracked via c.wg and respects
+ // c.runCtx for cancellation.
+ // Guard wg.Add with reconnectMu + stopping check (same protocol
+ // as eventHandler) so a concurrent Stop() cannot enter wg.Wait()
+ // while we call wg.Add(1).
+ c.reconnectMu.Lock()
+ if c.stopping.Load() {
+ c.reconnectMu.Unlock()
+ return fmt.Errorf("channel stopped during QR setup")
+ }
+ c.wg.Add(1)
+ c.reconnectMu.Unlock()
+ go func() {
+ defer c.wg.Done()
+ for {
+ select {
+ case <-c.runCtx.Done():
+ return
+ case evt, ok := <-qrChan:
+ if !ok {
+ return
+ }
+ 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 {
+ return fmt.Errorf("connect: %w", err)
+ }
+ }
+
+ startOK = true
+ 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")
+
+ // Mark as stopping under reconnectMu so the flag is visible to
+ // eventHandler atomically with respect to its wg.Add(1) call.
+ // This closes the TOCTOU window where eventHandler could check
+ // stopping (false), then Stop sets it true + enters wg.Wait,
+ // then eventHandler calls wg.Add(1) — causing a panic.
+ c.reconnectMu.Lock()
+ c.stopping.Store(true)
+ c.reconnectMu.Unlock()
+
+ if c.runCancel != nil {
+ c.runCancel()
+ }
+
+ // Disconnect the client first so any blocking Connect()/reconnect loops
+ // can be interrupted before we wait on the goroutines.
+ c.mu.Lock()
+ client := c.client
+ container := c.container
+ c.mu.Unlock()
+
+ if client != nil {
+ client.Disconnect()
+ }
+
+ // Wait for background goroutines (QR handler, reconnect) to finish in a
+ // context-aware way so Stop can be bounded by ctx.
+ done := make(chan struct{})
+ go func() {
+ c.wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ // All goroutines have finished.
+ case <-ctx.Done():
+ // Context canceled or timed out; log and proceed with best-effort cleanup.
+ logger.WarnC("whatsapp", fmt.Sprintf("Stop context canceled before all goroutines finished: %v", ctx.Err()))
+ }
+
+ // Now it is safe to clear and close resources.
+ c.mu.Lock()
+ c.client = nil
+ c.container = nil
+ c.mu.Unlock()
+
+ 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
+ }
+ // Check stopping while holding the lock so the check and wg.Add
+ // are atomic with respect to Stop() setting the flag + calling
+ // wg.Wait(). This prevents the TOCTOU race.
+ if c.stopping.Load() {
+ c.reconnectMu.Unlock()
+ return
+ }
+ c.reconnecting = true
+ c.wg.Add(1)
+ c.reconnectMu.Unlock()
+ go func() {
+ defer c.wg.Done()
+ 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)
+ }
+
+ // Detect unpaired state: the client is connected (to WhatsApp servers)
+ // but has not completed QR-login yet, so sending would fail.
+ if client.Store.ID == nil {
+ return fmt.Errorf("whatsapp not yet paired (QR login pending): %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 299f7334a..82d5e09ed 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -4,11 +4,12 @@ import (
"encoding/json"
"fmt"
"os"
- "path/filepath"
"strings"
"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.
@@ -173,7 +174,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"`
@@ -186,6 +188,15 @@ type AgentDefaults struct {
Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
}
+// 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"`
@@ -199,109 +210,174 @@ 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"`
- WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
+ Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
+ WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ 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 {
@@ -369,11 +445,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 {
@@ -405,6 +482,7 @@ type ModelConfig struct {
RPM int `json:"rpm,omitempty"` // Requests per minute limit
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
Stream *bool `json:"stream,omitempty"` // Use SSE streaming (default: protocol-dependent)
+ RequestTimeout int `json:"request_timeout,omitempty"`
}
// Validate checks if the ModelConfig has all required fields.
@@ -452,6 +530,9 @@ type WebToolsConfig struct {
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 {
@@ -463,11 +544,18 @@ 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"`
+ Web WebToolsConfig `json:"web"`
+ Cron CronToolsConfig `json:"cron"`
+ Exec ExecConfig `json:"exec"`
+ Skills SkillsToolsConfig `json:"skills"`
+ MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
}
type SkillsToolsConfig struct {
@@ -508,6 +596,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
}
@@ -516,6 +618,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)
@@ -529,18 +634,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 {
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 8fb64ec1f..41b7d0706 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 {
@@ -512,3 +532,33 @@ func TestAgentConfig_PlanModel_OverridesDefaults(t *testing.T) {
t.Errorf("defaults.PlanModel = %q, want 'default-plan-model'", cfg.Agents.Defaults.PlanModel)
}
}
+
+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 333ae078e..58022c761 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -13,27 +13,34 @@ 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,
TaskReminderInterval: 5,
},
},
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,
@@ -81,6 +88,7 @@ func DefaultConfig() *Config {
WebhookPort: 18791,
WebhookPath: "/webhook/line",
AllowFrom: FlexibleStringSlice{},
+ GroupTrigger: GroupTriggerConfig{MentionOnly: true},
},
OneBot: OneBotConfig{
Enabled: false,
@@ -114,6 +122,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},
@@ -273,11 +290,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 d23331bfe..7a0d8a94a 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,12 @@ 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
},
},
@@ -332,11 +347,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "mistral",
- Model: "mistral/mistral-small-latest",
- APIKey: p.Mistral.APIKey,
- APIBase: p.Mistral.APIBase,
- Proxy: p.Mistral.Proxy,
+ 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 42165cb71..db8f4657d 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -166,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 b761a17a3..34f2dba3b 100644
--- a/pkg/health/server.go
+++ b/pkg/health/server.go
@@ -180,6 +180,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 e1d08a908..60e5f3214 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"
@@ -177,7 +179,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
}
if handler == nil {
- hs.logError("Heartbeat handler not configured")
+ hs.logErrorf("Heartbeat handler not configured")
return
}
@@ -186,23 +188,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,
@@ -212,7 +214,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
// Check if silent
if result.Silent {
- hs.logInfo("Heartbeat OK - silent")
+ hs.logInfof("Heartbeat OK - silent")
return
}
@@ -221,7 +223,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
suppressed := !hs.lastNotifiedAt.IsZero() && time.Since(hs.lastNotifiedAt) < suppressionTTL
hs.mu.RUnlock()
if suppressed {
- hs.logInfo("Heartbeat suppressed (already notified user recently)")
+ hs.logInfof("Heartbeat suppressed (already notified user recently)")
return
}
@@ -236,7 +238,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
hs.lastNotifiedAt = time.Now()
hs.mu.Unlock()
- hs.logInfo("Heartbeat completed: %s", result.ForLLM)
+ hs.logInfof("Heartbeat completed: %s", result.ForLLM)
}
// buildPrompt builds the heartbeat prompt from HEARTBEAT.md
@@ -249,7 +251,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 ""
}
@@ -299,10 +301,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")
}
}
@@ -313,14 +315,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
}
@@ -331,13 +333,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.
@@ -350,7 +354,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 "", ""
}
@@ -358,25 +362,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 49cba2170..3efa6fd1c 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -221,13 +221,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",
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 24ce33e94..ea91565e8 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -73,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 {
@@ -162,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 268cefbfd..98ac3e7e8 100644
--- a/pkg/providers/factory.go
+++ b/pkg/providers/factory.go
@@ -40,7 +40,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
}
func resolveProviderSelectionByName(cfg *config.Config, providerName string) (providerSelection, error) {
- model := cfg.Agents.Defaults.Model
+ model := cfg.Agents.Defaults.GetModelName()
lowerModel := strings.ToLower(model)
sel := providerSelection{
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index d5ddf6d92..65a321eba 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -88,6 +88,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, openai_compat.Options{
MaxTokensField: cfg.MaxTokensField,
Stream: boolDefault(cfg.Stream, false),
+ RequestTimeout: cfg.RequestTimeout,
}), modelID, nil
case "minimax":
@@ -103,6 +104,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
EndpointPath: "/text/chatcompletion_v2",
MaxTokensField: cfg.MaxTokensField,
Stream: boolDefault(cfg.Stream, true),
+ RequestTimeout: cfg.RequestTimeout,
}), modelID, nil
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
@@ -119,6 +121,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, openai_compat.Options{
MaxTokensField: cfg.MaxTokensField,
Stream: boolDefault(cfg.Stream, false),
+ RequestTimeout: cfg.RequestTimeout,
}), modelID, nil
case "anthropic":
@@ -138,7 +141,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
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 7572a3837..f3c6d6a2e 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -23,8 +23,18 @@ 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.NewProviderWithOptions(apiKey, apiBase, proxy, openai_compat.Options{
+ MaxTokensField: maxTokensField,
+ RequestTimeout: requestTimeoutSeconds,
+ }),
}
}
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 e09485edf..3e9b889b6 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -26,6 +26,7 @@ type (
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
ExtraContent = protocoltypes.ExtraContent
GoogleExtra = protocoltypes.GoogleExtra
+ ReasoningDetail = protocoltypes.ReasoningDetail
)
type Provider struct {
@@ -42,21 +43,16 @@ type Options struct {
EndpointPath string // API path appended to apiBase (default: "/chat/completions")
MaxTokensField string // Field name for max tokens parameter
Stream bool // Use SSE streaming internally
+ RequestTimeout int // Request timeout in seconds (0 = default 120s)
}
-func NewProvider(apiKey, apiBase, proxy string) *Provider {
- return NewProviderWithMaxTokensField(apiKey, apiBase, proxy, "")
-}
-
-func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
- return NewProviderWithOptions(apiKey, apiBase, proxy, Options{
- MaxTokensField: maxTokensField,
- })
-}
+const defaultRequestTimeout = 120 * time.Second
func NewProviderWithOptions(apiKey, apiBase, proxy string, opts Options) *Provider {
- timeout := 120 * time.Second
- if opts.Stream {
+ timeout := defaultRequestTimeout
+ if opts.RequestTimeout > 0 {
+ timeout = time.Duration(opts.RequestTimeout) * time.Second
+ } else if opts.Stream {
timeout = 5 * time.Minute
}
client := &http.Client{
@@ -89,6 +85,26 @@ func NewProviderWithOptions(apiKey, apiBase, proxy string, opts Options) *Provid
}
}
+func NewProvider(apiKey, apiBase, proxy string) *Provider {
+ return NewProviderWithOptions(apiKey, apiBase, proxy, Options{})
+}
+
+func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
+ return NewProviderWithOptions(apiKey, apiBase, proxy, Options{
+ MaxTokensField: maxTokensField,
+ })
+}
+
+func NewProviderWithMaxTokensFieldAndTimeout(
+ apiKey, apiBase, proxy, maxTokensField string,
+ requestTimeoutSeconds int,
+) *Provider {
+ return NewProviderWithOptions(apiKey, apiBase, proxy, Options{
+ MaxTokensField: maxTokensField,
+ RequestTimeout: requestTimeoutSeconds,
+ })
+}
+
// streamBufferSize is the channel buffer size for ChatStream events.
const streamBufferSize = 32
@@ -109,7 +125,7 @@ func (p *Provider) buildHTTPRequest(
requestBody := map[string]any{
"model": model,
- "messages": messages,
+ "messages": stripSystemParts(messages),
}
if len(tools) > 0 {
@@ -147,6 +163,14 @@ func (p *Provider) buildHTTPRequest(
requestBody["stream"] = true
}
+ // Prompt caching: pass a stable cache key so OpenAI can bucket requests
+ // with the same key and reuse prefix KV cache across calls.
+ 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)
@@ -378,8 +402,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 {
@@ -451,13 +478,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 {
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index ffb8cb761..e3c5df086 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -9,6 +9,7 @@ import (
"net/url"
"strings"
"testing"
+ "time"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
@@ -106,6 +107,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)
@@ -674,3 +719,24 @@ func TestCanStream(t *testing.T) {
t.Error("CanStream() = false for stream provider")
}
}
+
+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_RequestTimeoutNonPositive(t *testing.T) {
+ p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", -1)
+ 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 8329739fb..1c11359f9 100644
--- a/pkg/providers/protocoltypes/types.go
+++ b/pkg/providers/protocoltypes/types.go
@@ -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 bdce83765..2ac38f86d 100644
--- a/pkg/providers/types.go
+++ b/pkg/providers/types.go
@@ -19,6 +19,8 @@ type (
GoogleExtra = protocoltypes.GoogleExtra
StreamEvent = protocoltypes.StreamEvent
StreamToolCallDelta = protocoltypes.StreamToolCallDelta
+ ContentBlock = protocoltypes.ContentBlock
+ CacheControl = protocoltypes.CacheControl
)
type LLMProvider interface {
@@ -32,6 +34,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 c27301780..763c0e5ae 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, 20)
+ 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 {
@@ -324,10 +266,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]
}
@@ -335,12 +274,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/bg_monitor_test.go b/pkg/tools/bg_monitor_test.go
index b884f1a9b..0aca596cf 100644
--- a/pkg/tools/bg_monitor_test.go
+++ b/pkg/tools/bg_monitor_test.go
@@ -9,7 +9,7 @@ import (
)
func TestBgMonitor_List(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
monitor := NewBgMonitorTool(tool)
// List with no processes
@@ -64,7 +64,7 @@ func TestBgMonitor_List(t *testing.T) {
}
func TestBgMonitor_Watch_Match(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
monitor := NewBgMonitorTool(tool)
var cmd string
@@ -103,7 +103,7 @@ func TestBgMonitor_Watch_Match(t *testing.T) {
}
func TestBgMonitor_Watch_Timeout(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
monitor := NewBgMonitorTool(tool)
var cmd string
@@ -139,7 +139,7 @@ func TestBgMonitor_Watch_Timeout(t *testing.T) {
}
func TestBgMonitor_Watch_ProcessExit(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
monitor := NewBgMonitorTool(tool)
var cmd string
@@ -178,7 +178,7 @@ func TestBgMonitor_Watch_ProcessExit(t *testing.T) {
}
func TestBgMonitor_Tail(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
monitor := NewBgMonitorTool(tool)
var cmd string
@@ -216,7 +216,7 @@ func TestBgMonitor_Tail(t *testing.T) {
}
func TestBgMonitor_InvalidAction(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
monitor := NewBgMonitorTool(tool)
result := monitor.Execute(context.Background(), map[string]any{"action": "invalid"})
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index 2e5bab0e8..e7a380a2c 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -34,15 +34,19 @@ type CronTool struct {
func NewCronTool(
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
execTimeout time.Duration, config *config.Config,
-) *CronTool {
- execTool := NewExecToolWithConfig(workspace, restrict, config)
+) (*CronTool, error) {
+ execTool, err := NewExecToolWithConfig(workspace, restrict, config)
+ if err != nil {
+ return nil, fmt.Errorf("unable to configure exec tool: %w", err)
+ }
+
execTool.SetTimeout(execTimeout)
return &CronTool{
cronService: cronService,
executor: executor,
msgBus: msgBus,
execTool: execTool,
- }
+ }, nil
}
// Name returns the tool name
@@ -296,7 +300,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,
@@ -306,7 +312,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/filesystem.go b/pkg/tools/filesystem.go
index 8130262c1..79a5fe972 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -8,6 +8,8 @@ import (
"path/filepath"
"strings"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
// validatePath ensures the given path is within the workspace if restrict is true.
@@ -283,25 +285,9 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
}
func (h *hostFs) WriteFile(path string, data []byte) error {
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return fmt.Errorf("failed to create parent directories: %w", err)
- }
-
- // We use a "write-then-rename" pattern here to ensure an atomic write.
- // This prevents the target file from being left in a truncated or partial state
- // if the operation is interrupted, as the rename operation is atomic on Linux.
- tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
- if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
- os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file
- return fmt.Errorf("failed to write temp file: %w", err)
- }
-
- if err := os.Rename(tmpPath, path); err != nil {
- os.Remove(tmpPath)
- return fmt.Errorf("failed to replace original file: %w", err)
- }
- return nil
+ // 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.
@@ -358,20 +344,46 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
}
}
- // We use a "write-then-rename" pattern here to ensure an atomic write.
- // This prevents the target file from being left in a truncated or partial state
- // if the operation is interrupted, as the rename operation is atomic on Linux.
- tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano())
+ // 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())
- if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil {
- root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file
- return fmt.Errorf("failed to write to temp file: %w", err)
+ 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
})
}
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 6f85ad8ab..111f3c361 100644
--- a/pkg/tools/registry.go
+++ b/pkg/tools/registry.go
@@ -3,6 +3,7 @@ package tools
import (
"context"
"fmt"
+ "sort"
"strings"
"sync"
"time"
@@ -132,13 +133,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
}
@@ -149,8 +164,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
@@ -180,11 +197,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.
@@ -220,8 +233,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/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 86246cd98..c3d1a292d 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -178,30 +178,27 @@ var defaultDenyPatterns = []*regexp.Regexp{
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
}
-func NewExecTool(workingDir string, restrict bool) *ExecTool {
+func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
return NewExecToolWithConfig(workingDir, restrict, nil)
}
-func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool {
+func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) {
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 {
re, err := regexp.Compile(pattern)
if err != nil {
- fmt.Printf("Invalid custom deny pattern %q: %v\n", pattern, err)
- continue
+ return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err)
}
denyPatterns = append(denyPatterns, re)
}
- } else {
- denyPatterns = append(denyPatterns, defaultDenyPatterns...)
}
} else {
// If deny patterns are disabled, we won't add any patterns, allowing all commands.
@@ -222,7 +219,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
bgProcesses: make(map[string]*bgProcess),
bgCtx: bgCtx,
bgShutdown: bgCancel,
- }
+ }, nil
}
func (t *ExecTool) Name() string {
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index 6fe9b6b9b..8b2e137ea 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -13,7 +13,10 @@ import (
// TestShellTool_Success verifies successful command execution
func TestShellTool_Success(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{
@@ -40,7 +43,10 @@ func TestShellTool_Success(t *testing.T) {
// TestShellTool_Failure verifies failed command execution
func TestShellTool_Failure(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{
@@ -67,7 +73,11 @@ func TestShellTool_Failure(t *testing.T) {
// TestShellTool_Timeout verifies command timeout handling
func TestShellTool_Timeout(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
+
tool.SetTimeout(100 * time.Millisecond)
ctx := context.Background()
@@ -95,7 +105,10 @@ func TestShellTool_WorkingDir(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0o644)
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{
@@ -116,7 +129,10 @@ func TestShellTool_WorkingDir(t *testing.T) {
// TestShellTool_DangerousCommand verifies safety guard blocks dangerous commands
func TestShellTool_DangerousCommand(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{
@@ -137,7 +153,10 @@ func TestShellTool_DangerousCommand(t *testing.T) {
// TestShellTool_MissingCommand verifies error handling for missing command
func TestShellTool_MissingCommand(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{}
@@ -152,7 +171,10 @@ func TestShellTool_MissingCommand(t *testing.T) {
// TestShellTool_StderrCapture verifies stderr is captured and included
func TestShellTool_StderrCapture(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{
@@ -172,7 +194,10 @@ func TestShellTool_StderrCapture(t *testing.T) {
// TestShellTool_OutputTruncation verifies long output is truncated
func TestShellTool_OutputTruncation(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
// Generate long output (>10000 chars)
@@ -200,7 +225,11 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
t.Fatalf("failed to create outside dir: %v", err)
}
- tool := NewExecTool(workspace, true)
+ tool, err := NewExecTool(workspace, true)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
+
result := tool.Execute(context.Background(), map[string]any{
"command": "pwd",
"working_dir": outsideDir,
@@ -234,7 +263,11 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
t.Skipf("symlinks not supported in this environment: %v", err)
}
- tool := NewExecTool(workspace, true)
+ tool, err := NewExecTool(workspace, true)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
+
result := tool.Execute(context.Background(), map[string]any{
"command": "cat secret.txt",
"working_dir": link,
@@ -251,7 +284,11 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
// TestShellTool_RestrictToWorkspace verifies workspace restriction
func TestShellTool_RestrictToWorkspace(t *testing.T) {
tmpDir := t.TempDir()
- tool := NewExecTool(tmpDir, false)
+ tool, err := NewExecTool(tmpDir, false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
+
tool.SetRestrictToWorkspace(true)
ctx := context.Background()
@@ -283,7 +320,7 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) {
// matching "/cold/test.py" from "tests/cold/test.py" as an absolute path.
func TestGuardCommand_RelativePathWithSlashes(t *testing.T) {
workspace := t.TempDir()
- tool := NewExecTool(workspace, true)
+ tool, _ := NewExecTool(workspace, true)
cmds := []string{
"pytest tests/cold/test_solver.py -v --tb=short",
@@ -305,7 +342,7 @@ func TestGuardCommand_RelativePathWithSlashes(t *testing.T) {
// (they are relative paths, not absolute).
func TestGuardCommand_VenvBinary(t *testing.T) {
workspace := t.TempDir()
- tool := NewExecTool(workspace, true)
+ tool, _ := NewExecTool(workspace, true)
cmds := []string{
".venv/bin/python -m pytest",
@@ -335,7 +372,7 @@ func TestGuardCommand_ExecutableBinaryAllowed(t *testing.T) {
execPath := filepath.Join(externalDir, "mybin")
os.WriteFile(execPath, []byte("#!/bin/sh\necho ok"), 0755)
- tool := NewExecTool(workspace, true)
+ tool, _ := NewExecTool(workspace, true)
cmd := execPath + " --help"
result := tool.guardCommand(cmd, workspace)
@@ -358,7 +395,7 @@ func TestGuardCommand_ExecutableBinaryAllowed_Windows(t *testing.T) {
execPath := filepath.Join(externalDir, "tool.exe")
os.WriteFile(execPath, []byte("MZ"), 0644)
- tool := NewExecTool(workspace, true)
+ tool, _ := NewExecTool(workspace, true)
cmd := execPath + " --version"
result := tool.guardCommand(cmd, workspace)
@@ -381,7 +418,7 @@ func TestGuardCommand_NonExecutableOutsideBlocked(t *testing.T) {
dataFile := filepath.Join(externalDir, "secret.txt")
os.WriteFile(dataFile, []byte("secret data"), 0644)
- tool := NewExecTool(workspace, true)
+ tool, _ := NewExecTool(workspace, true)
cmd := "cat " + dataFile
result := tool.guardCommand(cmd, workspace)
@@ -397,7 +434,7 @@ func TestGuardCommand_NonExecutableOutsideBlocked(t *testing.T) {
// paths that don't exist are blocked (could be file creation outside workspace).
func TestGuardCommand_NonExistentAbsolutePathBlocked(t *testing.T) {
workspace := t.TempDir()
- tool := NewExecTool(workspace, true)
+ tool, _ := NewExecTool(workspace, true)
// Use platform-appropriate absolute path
var cmd string
@@ -417,7 +454,7 @@ func TestGuardCommand_NonExistentAbsolutePathBlocked(t *testing.T) {
// because the token starts with "-", not "/".
func TestGuardCommand_FlagEmbeddedPathSkipped(t *testing.T) {
workspace := t.TempDir()
- tool := NewExecTool(workspace, true)
+ tool, _ := NewExecTool(workspace, true)
cmds := []string{
"gcc -I/usr/local/include -L/usr/lib main.c",
@@ -437,7 +474,7 @@ func TestGuardCommand_FlagEmbeddedPathSkipped(t *testing.T) {
// within the workspace are always allowed.
func TestGuardCommand_AbsolutePathInsideWorkspace(t *testing.T) {
workspace := t.TempDir()
- tool := NewExecTool(workspace, true)
+ tool, _ := NewExecTool(workspace, true)
innerDir := filepath.Join(workspace, "projects", "myapp")
os.MkdirAll(innerDir, 0755)
@@ -453,7 +490,7 @@ func TestGuardCommand_AbsolutePathInsideWorkspace(t *testing.T) {
// patterns are blocked.
func TestGuardCommand_PathTraversal(t *testing.T) {
workspace := t.TempDir()
- tool := NewExecTool(workspace, true)
+ tool, _ := NewExecTool(workspace, true)
cmds := []string{
"cat ../../etc/passwd",
@@ -479,7 +516,7 @@ func TestGuardCommand_CdWithAbsoluteWorkspacePath(t *testing.T) {
innerDir := filepath.Join(workspace, "projects", "foo")
os.MkdirAll(innerDir, 0755)
- tool := NewExecTool(workspace, true)
+ tool, _ := NewExecTool(workspace, true)
cmd := "cd " + innerDir + " && ls -la"
result := tool.guardCommand(cmd, workspace)
@@ -490,7 +527,7 @@ func TestGuardCommand_CdWithAbsoluteWorkspacePath(t *testing.T) {
func TestGuardCommand_AgentCLISlashCommand(t *testing.T) {
workspace := t.TempDir()
- tool := NewExecTool(workspace, true)
+ tool, _ := NewExecTool(workspace, true)
// Agent CLI slash commands (e.g., "/review") are not file paths.
// They should be allowed because they don't exist on disk.
@@ -519,7 +556,7 @@ func TestGuardCommand_AgentCLISlashCommand(t *testing.T) {
// --- Background process tests ---
func TestExecTool_Bg_StartAndOutput(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
defer tool.Shutdown()
var cmd string
@@ -560,7 +597,7 @@ func TestExecTool_Bg_StartAndOutput(t *testing.T) {
}
func TestExecTool_Bg_Kill(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
defer tool.Shutdown()
var cmd string
@@ -598,7 +635,7 @@ func TestExecTool_Bg_Kill(t *testing.T) {
}
func TestExecTool_Bg_ExitedProcess(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
defer tool.Shutdown()
var cmd string
@@ -636,7 +673,7 @@ func TestExecTool_Bg_ExitedProcess(t *testing.T) {
}
func TestExecTool_Bg_InvalidID(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
defer tool.Shutdown()
// Output for non-existent ID
@@ -662,7 +699,7 @@ func TestExecTool_Bg_InvalidID(t *testing.T) {
}
func TestExecTool_Bg_InitialOutputCapture(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
defer tool.Shutdown()
var cmd string
@@ -688,7 +725,7 @@ func TestExecTool_Bg_InitialOutputCapture(t *testing.T) {
}
func TestExecTool_Bg_RuntimeStatus(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
defer tool.Shutdown()
// No bg processes — should return empty
@@ -721,7 +758,7 @@ func TestExecTool_Bg_RuntimeStatus(t *testing.T) {
}
func TestExecTool_Bg_Shutdown(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
var cmd string
if runtime.GOOS == "windows" {
@@ -836,7 +873,7 @@ func TestRingBuffer(t *testing.T) {
}
func TestExecTool_Bg_RingBufferOverflow(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, _ := NewExecTool("", false)
defer tool.Shutdown()
// Generate output larger than 32KB ring buffer
diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go
index 04ef8e441..357e1276e 100644
--- a/pkg/tools/shell_timeout_unix_test.go
+++ b/pkg/tools/shell_timeout_unix_test.go
@@ -22,7 +22,11 @@ func processExists(pid int) bool {
}
func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
- tool := NewExecTool(t.TempDir(), false)
+ tool, err := NewExecTool(t.TempDir(), false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
+
tool.SetTimeout(500 * time.Millisecond)
args := map[string]any{
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 5e6e59384..d826809a6 100644
--- a/pkg/tools/spawn.go
+++ b/pkg/tools/spawn.go
@@ -3,6 +3,7 @@ package tools
import (
"context"
"fmt"
+ "strings"
)
type SpawnTool struct {
@@ -71,8 +72,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..b5652784a
--- /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, nil, WebSearchToolOptions{})
+ 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, nil, WebSearchToolOptions{})
+ 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 6e227f788..4b951013d 100644
--- a/pkg/tools/subagent.go
+++ b/pkg/tools/subagent.go
@@ -53,7 +53,7 @@ func NewSubagentManager(
reporter = orch.Noop
}
// Create a shared exec tool for all presets
- execTool := NewExecTool(workspace, true)
+ execTool, _ := NewExecTool(workspace, true)
return &SubagentManager{
tasks: make(map[string]*SubagentTask),
provider: provider,
@@ -164,12 +164,12 @@ After completing, provide a clear summary of what was done and how it was verifi
},
}
- // 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:
@@ -226,12 +226,12 @@ After completing, provide a clear summary of what was done and how it was verifi
if err != nil {
task.Status = "failed"
task.Result = fmt.Sprintf("Error: %v", err)
- // Check if it was cancelled
+ // Check if it was canceled
gcReason := "failed"
if ctx.Err() != nil {
- task.Status = "cancelled"
- task.Result = "Task cancelled during execution"
- gcReason = "cancelled"
+ task.Status = "canceled"
+ task.Result = "Task canceled during execution"
+ gcReason = "canceled"
}
sm.reporter.ReportGC(task.ID, gcReason)
result = &ToolResult{
@@ -265,7 +265,9 @@ After completing, provide a clear summary of what was done and how it was verifi
// 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 445c8d718..38a42b5eb 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -17,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(`