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..8ac57f16c 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.
@@ -170,15 +171,16 @@ type SessionConfig struct {
}
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"`
- ModelFallbacks []string `json:"model_fallbacks,omitempty"`
- ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
- ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
- PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
- PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
+ Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
+ RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
+ Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
+ ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
+ Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
+ ModelFallbacks []string `json:"model_fallbacks,omitempty"`
+ ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
+ ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
+ PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
+ PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
@@ -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..4a64bac72 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 {
@@ -415,7 +435,8 @@ func TestAgentDefaults_PlanModel_StringParse(t *testing.T) {
if cfg.Agents.Defaults.PlanModel != "anthropic/claude-sonnet-4-6" {
t.Errorf("PlanModel = %q, want 'anthropic/claude-sonnet-4-6'", cfg.Agents.Defaults.PlanModel)
}
- if len(cfg.Agents.Defaults.PlanModelFallbacks) != 1 || cfg.Agents.Defaults.PlanModelFallbacks[0] != "openai/gpt-4o" {
+ if len(cfg.Agents.Defaults.PlanModelFallbacks) != 1 ||
+ cfg.Agents.Defaults.PlanModelFallbacks[0] != "openai/gpt-4o" {
t.Errorf("PlanModelFallbacks = %v, want [openai/gpt-4o]", cfg.Agents.Defaults.PlanModelFallbacks)
}
}
@@ -512,3 +533,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..22cc2a822 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -10,30 +10,37 @@ func DefaultConfig() *Config {
return &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
- Workspace: "~/.picoclaw/workspace",
- RestrictToWorkspace: true,
- Provider: "",
- Model: "glm-4.7",
- MaxTokens: 8192,
- Temperature: nil, // nil means use provider default
- MaxToolIterations: 20,
+ Workspace: "~/.picoclaw/workspace",
+ RestrictToWorkspace: true,
+ Provider: "",
+ Model: "",
+ MaxTokens: 32768,
+ Temperature: nil, // nil means use provider default
+ 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/git/worktree_test.go b/pkg/git/worktree_test.go
index 09d01cbc6..41d2d97aa 100644
--- a/pkg/git/worktree_test.go
+++ b/pkg/git/worktree_test.go
@@ -17,7 +17,10 @@ func TestSanitizeBranchName(t *testing.T) {
{" spaces ", "plan/spaces"},
{"UPPER-case_Mix", "plan/upper-case-mix"},
{"a/b/c", "plan/a-b-c"},
- {"very long task name that exceeds the forty character limit for safety", "plan/very-long-task-name-that-exceeds-the-for"},
+ {
+ "very long task name that exceeds the forty character limit for safety",
+ "plan/very-long-task-name-that-exceeds-the-for",
+ },
{"---leading-trailing---", "plan/leading-trailing"},
{"special!@#$%chars", "plan/special-chars"},
}
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/logger/logger_test.go b/pkg/logger/logger_test.go
index ef7270380..741b61209 100644
--- a/pkg/logger/logger_test.go
+++ b/pkg/logger/logger_test.go
@@ -294,10 +294,10 @@ func TestUnsubscribe_ClosesChannel(t *testing.T) {
func TestSanitizeFields(t *testing.T) {
tests := []struct {
- name string
- input map[string]any
- maskedK []string // keys that should be "***"
- safeK []string // keys that should keep original value
+ name string
+ input map[string]any
+ maskedK []string // keys that should be "***"
+ safeK []string // keys that should keep original value
}{
{
name: "nil fields",
@@ -310,23 +310,41 @@ func TestSanitizeFields(t *testing.T) {
maskedK: nil,
},
{
- name: "sensitive keys masked",
- input: map[string]any{"token": "abc123", "api_key": "sk-xxx", "secret": "s3cr3t", "password": "pass", "authorization": "Bearer tok"},
+ name: "sensitive keys masked",
+ input: map[string]any{
+ "token": "abc123",
+ "api_key": "sk-xxx",
+ "secret": "s3cr3t",
+ "password": "pass",
+ "authorization": "Bearer tok",
+ },
maskedK: []string{"token", "api_key", "secret", "password", "authorization"},
},
{
- name: "case insensitive",
- input: map[string]any{"Token": "abc", "API_KEY": "xyz", "Secret": "s", "PASSWORD": "p", "Authorization": "a", "Credential": "c"},
+ name: "case insensitive",
+ input: map[string]any{
+ "Token": "abc",
+ "API_KEY": "xyz",
+ "Secret": "s",
+ "PASSWORD": "p",
+ "Authorization": "a",
+ "Credential": "c",
+ },
maskedK: []string{"Token", "API_KEY", "Secret", "PASSWORD", "Authorization", "Credential"},
},
{
- name: "safe keys preserved",
- input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"},
- safeK: []string{"error", "count", "user_id", "component"},
+ name: "safe keys preserved",
+ input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"},
+ safeK: []string{"error", "count", "user_id", "component"},
},
{
- name: "mixed keys",
- input: map[string]any{"token": "sensitive", "msg_signature": "safe", "corp_secret": "sensitive2", "nonce": "safe2"},
+ name: "mixed keys",
+ input: map[string]any{
+ "token": "sensitive",
+ "msg_signature": "safe",
+ "corp_secret": "sensitive2",
+ "nonce": "safe2",
+ },
maskedK: []string{"token", "corp_secret"},
safeK: []string{"msg_signature", "nonce"},
},
@@ -363,9 +381,9 @@ func TestRecentLogsSanitizesFields(t *testing.T) {
SetLevel(DEBUG)
InfoCF("sanitize-test", "log with sensitive fields", map[string]any{
- "token": "my-secret-token",
- "api_key": "sk-12345",
- "user_id": "safe-value",
+ "token": "my-secret-token",
+ "api_key": "sk-12345",
+ "user_id": "safe-value",
})
got := RecentLogs(DEBUG, "sanitize-test", 100)
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/miniapp/api.go b/pkg/miniapp/api.go
index 697dafa79..a385c8a54 100644
--- a/pkg/miniapp/api.go
+++ b/pkg/miniapp/api.go
@@ -10,19 +10,16 @@ import (
"time"
)
-
func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) {
skillsList := h.provider.ListSkills()
writeJSON(w, skillsList)
}
-
func (h *Handler) apiPlan(w http.ResponseWriter, r *http.Request) {
info := h.provider.GetPlanInfo()
writeJSON(w, info)
}
-
func (h *Handler) apiSessions(w http.ResponseWriter, r *http.Request) {
sessions := h.provider.GetActiveSessions()
if sessions == nil {
@@ -31,7 +28,6 @@ func (h *Handler) apiSessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, sessions)
}
-
func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) {
s := h.provider.GetSessionStats()
if s == nil {
@@ -41,17 +37,14 @@ func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s)
}
-
func (h *Handler) apiContext(w http.ResponseWriter, r *http.Request) {
writeJSON(w, h.provider.GetContextInfo())
}
-
func (h *Handler) apiPrompt(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"prompt": h.provider.GetSystemPrompt()})
}
-
func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
repo := r.URL.Query().Get("repo")
if repo == "" {
@@ -61,7 +54,6 @@ func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
}
}
-
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
@@ -99,7 +91,6 @@ func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}
-
func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
@@ -143,12 +134,17 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext)
- sendSSEIfChanged(w, flusher, "prompt", map[string]string{"prompt": h.provider.GetSystemPrompt()}, &lastPrompt)
+ sendSSEIfChanged(
+ w,
+ flusher,
+ "prompt",
+ map[string]string{"prompt": h.provider.GetSystemPrompt()},
+ &lastPrompt,
+ )
}
}
}
-
func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) {
data, _ := json.Marshal(v)
if !bytes.Equal(data, *last) {
@@ -158,11 +154,9 @@ func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any
}
}
-
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// apiDevConsole receives console output from dev preview iframes.
-
diff --git a/pkg/miniapp/dev.go b/pkg/miniapp/dev.go
index 1dfbaa7fa..67bfcd898 100644
--- a/pkg/miniapp/dev.go
+++ b/pkg/miniapp/dev.go
@@ -17,7 +17,6 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
)
-
// validateLocalhostURL parses and validates that a URL targets localhost.
func validateLocalhostURL(target string) (*url.URL, error) {
u, err := url.Parse(target)
@@ -33,7 +32,6 @@ func validateLocalhostURL(target string) (*url.URL, error) {
// RegisterDevTarget registers a new dev server target. Only localhost targets are allowed.
-
// RegisterDevTarget registers a new dev server target. Only localhost targets are allowed.
func (h *Handler) RegisterDevTarget(name, target string) (string, error) {
if _, err := validateLocalhostURL(target); err != nil {
@@ -55,7 +53,6 @@ func (h *Handler) RegisterDevTarget(name, target string) (string, error) {
// UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled.
-
// UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled.
func (h *Handler) UnregisterDevTarget(id string) error {
h.devMu.Lock()
@@ -79,7 +76,6 @@ func (h *Handler) UnregisterDevTarget(id string) error {
// ActivateDevTarget sets the reverse proxy to the registered target with the given ID.
-
// ActivateDevTarget sets the reverse proxy to the registered target with the given ID.
func (h *Handler) ActivateDevTarget(id string) error {
h.devMu.Lock()
@@ -148,7 +144,6 @@ p{color:#8e8e93;font-size:14px;margin:0}
// DeactivateDevTarget disables the reverse proxy without removing registrations.
-
// DeactivateDevTarget disables the reverse proxy without removing registrations.
func (h *Handler) DeactivateDevTarget() error {
h.devMu.Lock()
@@ -165,7 +160,6 @@ func (h *Handler) DeactivateDevTarget() error {
// GetDevTarget returns the current dev proxy target URL, or empty string if disabled.
-
// GetDevTarget returns the current dev proxy target URL, or empty string if disabled.
func (h *Handler) GetDevTarget() string {
h.devMu.RLock()
@@ -178,7 +172,6 @@ func (h *Handler) GetDevTarget() string {
// ListDevTargets returns all registered dev targets.
-
// ListDevTargets returns all registered dev targets.
func (h *Handler) ListDevTargets() []DevTarget {
h.devMu.RLock()
@@ -198,7 +191,6 @@ func (h *Handler) ListDevTargets() []DevTarget {
// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount.
// It also captures console.log/warn/error/info and forwards them to the server.
-
// devProxyScript is the JavaScript injected into HTML responses from the dev proxy.
// It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like
// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount.
@@ -255,7 +247,6 @@ const devProxyScript = ``)
+ reStyle = regexp.MustCompile(``)
- result = re.ReplaceAllLiteralString(result, "")
- re = regexp.MustCompile(`<[^>]+>`)
- result = re.ReplaceAllLiteralString(result, "")
+ result := reScript.ReplaceAllLiteralString(htmlContent, "")
+ result = reStyle.ReplaceAllLiteralString(result, "")
+ result = reTags.ReplaceAllLiteralString(result, "")
result = strings.TrimSpace(result)
- re = regexp.MustCompile(`[^\S\n]+`)
- result = re.ReplaceAllString(result, " ")
- re = regexp.MustCompile(`\n{3,}`)
- result = re.ReplaceAllString(result, "\n\n")
+ result = reWhitespace.ReplaceAllString(result, " ")
+ result = reBlankLines.ReplaceAllString(result, "\n\n")
lines := strings.Split(result, "\n")
var sb strings.Builder
diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go
index 75e0d8d16..2cd79eb24 100644
--- a/pkg/tools/web_test.go
+++ b/pkg/tools/web_test.go
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"strings"
"testing"
+ "time"
)
// TestWebTool_WebFetch_Success verifies successful URL fetching
@@ -334,6 +335,172 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
}
}
+func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
+ client, err := createHTTPClient("http://127.0.0.1:7890", 12*time.Second)
+ if err != nil {
+ t.Fatalf("createHTTPClient() error: %v", err)
+ }
+ if client.Timeout != 12*time.Second {
+ t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second)
+ }
+
+ tr, ok := client.Transport.(*http.Transport)
+ if !ok {
+ t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
+ }
+ if tr.Proxy == nil {
+ t.Fatal("transport.Proxy is nil, want non-nil")
+ }
+
+ req, err := http.NewRequest("GET", "https://example.com", nil)
+ if err != nil {
+ t.Fatalf("http.NewRequest() error: %v", err)
+ }
+ proxyURL, err := tr.Proxy(req)
+ if err != nil {
+ t.Fatalf("transport.Proxy(req) error: %v", err)
+ }
+ if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" {
+ t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890")
+ }
+}
+
+func TestCreateHTTPClient_InvalidProxy(t *testing.T) {
+ _, err := createHTTPClient("://bad-proxy", 10*time.Second)
+ if err == nil {
+ t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil")
+ }
+}
+
+func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {
+ client, err := createHTTPClient("socks5://127.0.0.1:1080", 8*time.Second)
+ if err != nil {
+ t.Fatalf("createHTTPClient() error: %v", err)
+ }
+
+ tr, ok := client.Transport.(*http.Transport)
+ if !ok {
+ t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
+ }
+ req, err := http.NewRequest("GET", "https://example.com", nil)
+ if err != nil {
+ t.Fatalf("http.NewRequest() error: %v", err)
+ }
+ proxyURL, err := tr.Proxy(req)
+ if err != nil {
+ t.Fatalf("transport.Proxy(req) error: %v", err)
+ }
+ if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" {
+ t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080")
+ }
+}
+
+func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) {
+ _, err := createHTTPClient("ftp://127.0.0.1:21", 10*time.Second)
+ if err == nil {
+ t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil")
+ }
+ if !strings.Contains(err.Error(), "unsupported proxy scheme") {
+ t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme")
+ }
+}
+
+func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) {
+ t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888")
+ t.Setenv("http_proxy", "http://127.0.0.1:8888")
+ t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888")
+ t.Setenv("https_proxy", "http://127.0.0.1:8888")
+ t.Setenv("ALL_PROXY", "")
+ t.Setenv("all_proxy", "")
+ t.Setenv("NO_PROXY", "")
+ t.Setenv("no_proxy", "")
+
+ client, err := createHTTPClient("", 10*time.Second)
+ if err != nil {
+ t.Fatalf("createHTTPClient() error: %v", err)
+ }
+
+ tr, ok := client.Transport.(*http.Transport)
+ if !ok {
+ t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
+ }
+ if tr.Proxy == nil {
+ t.Fatal("transport.Proxy is nil, want proxy function from environment")
+ }
+
+ req, err := http.NewRequest("GET", "https://example.com", nil)
+ if err != nil {
+ t.Fatalf("http.NewRequest() error: %v", err)
+ }
+ if _, err := tr.Proxy(req); err != nil {
+ t.Fatalf("transport.Proxy(req) error: %v", err)
+ }
+}
+
+func TestNewWebFetchToolWithProxy(t *testing.T) {
+ tool := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890")
+ if tool.maxChars != 1024 {
+ t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024)
+ }
+ if tool.proxy != "http://127.0.0.1:7890" {
+ t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890")
+ }
+
+ tool = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890")
+ if tool.maxChars != 50000 {
+ t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000)
+ }
+}
+
+func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
+ t.Run("perplexity", func(t *testing.T) {
+ tool := NewWebSearchTool(WebSearchToolOptions{
+ PerplexityEnabled: true,
+ PerplexityAPIKey: "k",
+ PerplexityMaxResults: 3,
+ Proxy: "http://127.0.0.1:7890",
+ })
+ p, ok := tool.provider.(*PerplexitySearchProvider)
+ if !ok {
+ t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider)
+ }
+ if p.proxy != "http://127.0.0.1:7890" {
+ t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
+ }
+ })
+
+ t.Run("brave", func(t *testing.T) {
+ tool := NewWebSearchTool(WebSearchToolOptions{
+ BraveEnabled: true,
+ BraveAPIKey: "k",
+ BraveMaxResults: 3,
+ Proxy: "http://127.0.0.1:7890",
+ })
+ p, ok := tool.provider.(*BraveSearchProvider)
+ if !ok {
+ t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider)
+ }
+ if p.proxy != "http://127.0.0.1:7890" {
+ t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
+ }
+ })
+
+ t.Run("duckduckgo", func(t *testing.T) {
+ tool := NewWebSearchTool(WebSearchToolOptions{
+ DuckDuckGoEnabled: true,
+ DuckDuckGoMaxResults: 3,
+ Proxy: "http://127.0.0.1:7890",
+ })
+ p, ok := tool.provider.(*DuckDuckGoSearchProvider)
+ if !ok {
+ t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider)
+ }
+ if p.proxy != "http://127.0.0.1:7890" {
+ t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
+ }
+ })
+}
+
// TestWebTool_TavilySearch_Success verifies successful Tavily search
func TestWebTool_TavilySearch_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/pkg/tools/workspace_ctx.go b/pkg/tools/workspace_ctx.go
index 7be742ce9..12bc3e33d 100644
--- a/pkg/tools/workspace_ctx.go
+++ b/pkg/tools/workspace_ctx.go
@@ -6,8 +6,10 @@ import (
"strings"
)
-type workspaceOverrideKey struct{}
-type overrideFsKey struct{}
+type (
+ workspaceOverrideKey struct{}
+ overrideFsKey struct{}
+)
// WithWorkspaceOverride returns a context carrying a workspace override path
// and a pre-built sandboxFs for that workspace. Tools will resolve file
diff --git a/pkg/utils/http_retry.go b/pkg/utils/http_retry.go
new file mode 100644
index 000000000..e90fa2129
--- /dev/null
+++ b/pkg/utils/http_retry.go
@@ -0,0 +1,57 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+const maxRetries = 3
+
+var retryDelayUnit = time.Second
+
+func shouldRetry(statusCode int) bool {
+ return statusCode == http.StatusTooManyRequests ||
+ statusCode >= 500
+}
+
+func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response, error) {
+ var resp *http.Response
+ var err error
+
+ for i := range maxRetries {
+ if i > 0 && resp != nil {
+ resp.Body.Close()
+ }
+
+ resp, err = client.Do(req)
+ if err == nil {
+ if resp.StatusCode == http.StatusOK {
+ break
+ }
+ if !shouldRetry(resp.StatusCode) {
+ break
+ }
+ }
+
+ if i < maxRetries-1 {
+ if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil {
+ return nil, fmt.Errorf("failed to sleep: %w", err)
+ }
+ }
+ }
+ return resp, err
+}
+
+func sleepWithCtx(ctx context.Context, d time.Duration) error {
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+}
diff --git a/pkg/utils/http_retry_test.go b/pkg/utils/http_retry_test.go
new file mode 100644
index 000000000..1c2dbe115
--- /dev/null
+++ b/pkg/utils/http_retry_test.go
@@ -0,0 +1,118 @@
+package utils
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDoRequestWithRetry(t *testing.T) {
+ retryDelayUnit = time.Millisecond
+ t.Cleanup(func() { retryDelayUnit = time.Second })
+
+ testcases := []struct {
+ name string
+ serverBehavior func(*httptest.Server) int
+ wantSuccess bool
+ wantAttempts int
+ }{
+ {
+ name: "success-on-first-attempt",
+ serverBehavior: func(server *httptest.Server) int {
+ return 0
+ },
+ wantSuccess: true,
+ wantAttempts: 1,
+ },
+ {
+ name: "fail-all-attempts",
+ serverBehavior: func(server *httptest.Server) int {
+ return 4
+ },
+ wantSuccess: false,
+ wantAttempts: 3,
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ attempts := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ if attempts <= tc.serverBehavior(nil) {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("success"))
+ }))
+
+ t.Cleanup(func() {
+ server.Close()
+ })
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+
+ resp, err := DoRequestWithRetry(client, req)
+
+ if tc.wantSuccess {
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ resp.Body.Close()
+ } else {
+ require.NotNil(t, resp)
+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
+ resp.Body.Close()
+ }
+
+ assert.Equal(t, tc.wantAttempts, attempts)
+ })
+ }
+}
+
+func TestDoRequestWithRetry_Delay(t *testing.T) {
+ retryDelayUnit = time.Millisecond
+ t.Cleanup(func() { retryDelayUnit = time.Second })
+
+ var start time.Time
+ delays := []time.Duration{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if len(delays) == 0 {
+ delays = append(delays, 0)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ if len(delays) == 1 {
+ start = time.Now()
+ delays = append(delays, 0)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ if len(delays) == 2 {
+ elapsed := time.Since(start)
+ delays = append(delays, elapsed)
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("success"))
+ }
+ }))
+ defer server.Close()
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+
+ resp, err := DoRequestWithRetry(client, req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ resp.Body.Close()
+
+ assert.GreaterOrEqual(t, delays[2], time.Millisecond)
+}
diff --git a/pkg/utils/media.go b/pkg/utils/media.go
index 0092d1de4..8d08e5805 100644
--- a/pkg/utils/media.go
+++ b/pkg/utils/media.go
@@ -20,7 +20,6 @@ var (
// IsAudioFile checks if a file is an audio file based on its filename extension and content type.
func IsAudioFile(filename, contentType string) bool {
-
for _, ext := range audioExtensions {
if strings.HasSuffix(strings.ToLower(filename), ext) {
return true
diff --git a/pkg/utils/message.go b/pkg/utils/message.go
deleted file mode 100644
index 1d05950d9..000000000
--- a/pkg/utils/message.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package utils
-
-import (
- "strings"
-)
-
-// SplitMessage splits long messages into chunks, preserving code block integrity.
-// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
-// but may extend to maxLen when needed.
-// Call SplitMessage with the full text content and the maximum allowed length of a single message;
-// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.
-func SplitMessage(content string, maxLen int) []string {
- var messages []string
-
- // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
- codeBlockBuffer := maxLen / 10
- if codeBlockBuffer < 50 {
- codeBlockBuffer = 50
- }
- if codeBlockBuffer > maxLen/2 {
- codeBlockBuffer = maxLen / 2
- }
-
- for len(content) > 0 {
- if len(content) <= maxLen {
- messages = append(messages, content)
- break
- }
-
- // Effective split point: maxLen minus buffer, to leave room for code blocks
- effectiveLimit := maxLen - codeBlockBuffer
- if effectiveLimit < maxLen/2 {
- effectiveLimit = maxLen / 2
- }
-
- // Find natural split point within the effective limit
- msgEnd := findLastNewline(content[:effectiveLimit], 200)
- if msgEnd <= 0 {
- msgEnd = findLastSpace(content[:effectiveLimit], 100)
- }
- if msgEnd <= 0 {
- msgEnd = effectiveLimit
- }
-
- // Check if this would end with an incomplete code block
- candidate := content[:msgEnd]
- unclosedIdx := findLastUnclosedCodeBlock(candidate)
-
- if unclosedIdx >= 0 {
- // Message would end with incomplete code block
- // Try to extend up to maxLen to include the closing ```
- if len(content) > msgEnd {
- closingIdx := findNextClosingCodeBlock(content, msgEnd)
- if closingIdx > 0 && closingIdx <= maxLen {
- // Extend to include the closing ```
- msgEnd = closingIdx
- } else {
- // Code block is too long to fit in one chunk or missing closing fence.
- // Try to split inside by injecting closing and reopening fences.
- headerEnd := strings.Index(content[unclosedIdx:], "\n")
- if headerEnd == -1 {
- headerEnd = unclosedIdx + 3
- } else {
- headerEnd += unclosedIdx
- }
- header := strings.TrimSpace(content[unclosedIdx:headerEnd])
-
- // If we have a reasonable amount of content after the header, split inside
- if msgEnd > headerEnd+20 {
- // Find a better split point closer to maxLen
- innerLimit := maxLen - 5 // Leave room for "\n```"
- betterEnd := findLastNewline(content[:innerLimit], 200)
- if betterEnd > headerEnd {
- msgEnd = betterEnd
- } else {
- msgEnd = innerLimit
- }
- messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
- content = strings.TrimSpace(header + "\n" + content[msgEnd:])
- continue
- }
-
- // Otherwise, try to split before the code block starts
- newEnd := findLastNewline(content[:unclosedIdx], 200)
- if newEnd <= 0 {
- newEnd = findLastSpace(content[:unclosedIdx], 100)
- }
- if newEnd > 0 {
- msgEnd = newEnd
- } else {
- // If we can't split before, we MUST split inside (last resort)
- if unclosedIdx > 20 {
- msgEnd = unclosedIdx
- } else {
- msgEnd = maxLen - 5
- messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
- content = strings.TrimSpace(header + "\n" + content[msgEnd:])
- continue
- }
- }
- }
- }
- }
-
- if msgEnd <= 0 {
- msgEnd = effectiveLimit
- }
-
- messages = append(messages, content[:msgEnd])
- content = strings.TrimSpace(content[msgEnd:])
- }
-
- return messages
-}
-
-// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```
-// Returns the position of the opening ``` or -1 if all code blocks are complete
-func findLastUnclosedCodeBlock(text string) int {
- inCodeBlock := false
- lastOpenIdx := -1
-
- for i := 0; i < len(text); i++ {
- if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
- // Toggle code block state on each fence
- if !inCodeBlock {
- // Entering a code block: record this opening fence
- lastOpenIdx = i
- }
- inCodeBlock = !inCodeBlock
- i += 2
- }
- }
-
- if inCodeBlock {
- return lastOpenIdx
- }
- return -1
-}
-
-// findNextClosingCodeBlock finds the next closing ``` starting from a position
-// Returns the position after the closing ``` or -1 if not found
-func findNextClosingCodeBlock(text string, startIdx int) int {
- for i := startIdx; i < len(text); i++ {
- if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
- return i + 3
- }
- }
- return -1
-}
-
-// findLastNewline finds the last newline character within the last N characters
-// Returns the position of the newline or -1 if not found
-func findLastNewline(s string, searchWindow int) int {
- searchStart := len(s) - searchWindow
- if searchStart < 0 {
- searchStart = 0
- }
- for i := len(s) - 1; i >= searchStart; i-- {
- if s[i] == '\n' {
- return i
- }
- }
- return -1
-}
-
-// findLastSpace finds the last space character within the last N characters
-// Returns the position of the space or -1 if not found
-func findLastSpace(s string, searchWindow int) int {
- searchStart := len(s) - searchWindow
- if searchStart < 0 {
- searchStart = 0
- }
- for i := len(s) - 1; i >= searchStart; i-- {
- if s[i] == ' ' || s[i] == '\t' {
- return i
- }
- }
- return -1
-}
diff --git a/pkg/utils/message_test.go b/pkg/utils/message_test.go
deleted file mode 100644
index 338509437..000000000
--- a/pkg/utils/message_test.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package utils
-
-import (
- "strings"
- "testing"
-)
-
-func TestSplitMessage(t *testing.T) {
- longText := strings.Repeat("a", 2500)
- longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars
-
- tests := []struct {
- name string
- content string
- maxLen int
- expectChunks int // Check number of chunks
- checkContent func(t *testing.T, chunks []string) // Custom validation
- }{
- {
- name: "Empty message",
- content: "",
- maxLen: 2000,
- expectChunks: 0,
- },
- {
- name: "Short message fits in one chunk",
- content: "Hello world",
- maxLen: 2000,
- expectChunks: 1,
- },
- {
- name: "Simple split regular text",
- content: longText,
- maxLen: 2000,
- expectChunks: 2,
- checkContent: func(t *testing.T, chunks []string) {
- if len(chunks[0]) > 2000 {
- t.Errorf("Chunk 0 too large: %d", len(chunks[0]))
- }
- if len(chunks[0])+len(chunks[1]) != len(longText) {
- t.Errorf("Total length mismatch. Got %d, want %d", len(chunks[0])+len(chunks[1]), len(longText))
- }
- },
- },
- {
- name: "Split at newline",
- // 1750 chars then newline, then more chars.
- // Dynamic buffer: 2000 / 10 = 200.
- // Effective limit: 2000 - 200 = 1800.
- // Split should happen at newline because it's at 1750 (< 1800).
- // Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051.
- content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300),
- maxLen: 2000,
- expectChunks: 2,
- checkContent: func(t *testing.T, chunks []string) {
- if len(chunks[0]) != 1750 {
- t.Errorf("Expected chunk 0 to be 1750 length (split at newline), got %d", len(chunks[0]))
- }
- if chunks[1] != strings.Repeat("b", 300) {
- t.Errorf("Chunk 1 content mismatch. Len: %d", len(chunks[1]))
- }
- },
- },
- {
- name: "Long code block split",
- content: "Prefix\n" + longCode,
- maxLen: 2000,
- expectChunks: 2,
- checkContent: func(t *testing.T, chunks []string) {
- // Check that first chunk ends with closing fence
- if !strings.HasSuffix(chunks[0], "\n```") {
- t.Error("First chunk should end with injected closing fence")
- }
- // Check that second chunk starts with execution header
- if !strings.HasPrefix(chunks[1], "```go") {
- t.Error("Second chunk should start with injected code block header")
- }
- },
- },
- {
- name: "Preserve Unicode characters",
- content: strings.Repeat("\u4e16", 1000), // 3000 bytes
- maxLen: 2000,
- expectChunks: 2,
- checkContent: func(t *testing.T, chunks []string) {
- // Just verify we didn't panic and got valid strings.
- // Go strings are UTF-8, if we split mid-rune it would be bad,
- // but standard slicing might do that.
- // Let's assume standard behavior is acceptable or check if it produces invalid rune?
- if !strings.Contains(chunks[0], "\u4e16") {
- t.Error("Chunk should contain unicode characters")
- }
- },
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- got := SplitMessage(tc.content, tc.maxLen)
-
- if tc.expectChunks == 0 {
- if len(got) != 0 {
- t.Errorf("Expected 0 chunks, got %d", len(got))
- }
- return
- }
-
- if len(got) != tc.expectChunks {
- t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got))
- // Log sizes for debugging
- for i, c := range got {
- t.Logf("Chunk %d length: %d", i, len(c))
- }
- return // Stop further checks if count assumes specific split
- }
-
- if tc.checkContent != nil {
- tc.checkContent(t, got)
- }
- })
- }
-}
-
-func TestSplitMessage_CodeBlockIntegrity(t *testing.T) {
- // Focused test for the core requirement: splitting inside a code block preserves syntax highlighting
-
- // 60 chars total approximately
- content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```"
- maxLen := 40
-
- chunks := SplitMessage(content, maxLen)
-
- if len(chunks) != 2 {
- t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
- }
-
- // First chunk must end with "\n```"
- if !strings.HasSuffix(chunks[0], "\n```") {
- t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0])
- }
-
- // Second chunk must start with the header "```go"
- if !strings.HasPrefix(chunks[1], "```go") {
- t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1])
- }
-
- // First chunk should contain meaningful content
- if len(chunks[0]) > 40 {
- t.Errorf("First chunk exceeded maxLen: length %d", len(chunks[0]))
- }
-}
diff --git a/pkg/utils/string.go b/pkg/utils/string.go
index f52c98d60..4a3af779c 100644
--- a/pkg/utils/string.go
+++ b/pkg/utils/string.go
@@ -3,6 +3,7 @@ package utils
import (
"regexp"
"strings"
+ "unicode"
)
// Repetition detection constants.
@@ -94,6 +95,22 @@ func DetectRepetitionLoop(text string) bool {
return ratio < repetitionUniqueThreshold
}
+// SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides,
+// zero-width characters), and other non-graphic characters that could confuse an LLM
+// or cause display issues in the agent UI.
+func SanitizeMessageContent(input string) string {
+ var sb strings.Builder
+ sb.Grow(len(input))
+
+ for _, r := range input {
+ if unicode.IsGraphic(r) || r == '\n' || r == '\r' || r == '\t' {
+ sb.WriteRune(r)
+ }
+ }
+
+ return sb.String()
+}
+
// Truncate returns a truncated version of s with at most maxLen runes.
// Handles multi-byte Unicode characters properly.
// If the string is truncated, "..." is appended to indicate truncation.
diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go
index eb754ad3f..7b4b54098 100644
--- a/pkg/utils/string_test.go
+++ b/pkg/utils/string_test.go
@@ -59,7 +59,7 @@ func TestStripThinkBlocks_ClosedThenUnclosed(t *testing.T) {
func TestDetectRepetitionLoop_HighRepetition(t *testing.T) {
// Repeat a short phrase many times → should be detected
- phrase := "結構本格的なコード"
+ phrase := "結構本格的なコード" //nolint:gosmopolitan // CJK test data
repeated := strings.Repeat(phrase, 300)
if !DetectRepetitionLoop(repeated) {
t.Fatal("DetectRepetitionLoop should return true for highly repetitive text")
@@ -289,3 +289,27 @@ func TestTruncate(t *testing.T) {
})
}
}
+
+func TestSanitizeMessageContent(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"empty", "", ""},
+ {"plain text unchanged", "Hello world", "Hello world"},
+ {"strip ZWSP", "Hello\u200bworld", "Helloworld"},
+ {"strip RTL override", "Hi\u202eevil", "Hievil"},
+ {"strip BOM", "\uFEFFcontent", "content"},
+ {"strip multiple", "a\u200c\u202ab\u202cc", "abc"},
+ {"unicode letters preserved", "café \u65e5\u672c\u8a9e", "café \u65e5\u672c\u8a9e"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := SanitizeMessageContent(tt.input)
+ if got != tt.want {
+ t.Errorf("SanitizeMessageContent(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}