Merge branch 'sipeed:main' into main
This commit is contained in:
commit
056b9f5845
189 changed files with 17878 additions and 9118 deletions
5
.github/workflows/pr.yml
vendored
5
.github/workflows/pr.yml
vendored
|
|
@ -23,10 +23,13 @@ jobs:
|
|||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.10.1
|
||||
args: --build-tags=goolm,stdjson
|
||||
|
||||
vuln_check:
|
||||
name: Security Check
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOFLAGS: -tags=goolm,stdjson
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
|
@ -59,4 +62,4 @@ jobs:
|
|||
run: go generate ./...
|
||||
|
||||
- name: Run go test
|
||||
run: go test ./...
|
||||
run: go test -tags goolm,stdjson ./...
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -25,6 +25,9 @@ build/
|
|||
# Secrets & Config (keep templates, ignore actual secrets)
|
||||
.env
|
||||
config/config.json
|
||||
.security.yml
|
||||
onboard
|
||||
|
||||
|
||||
# Test
|
||||
coverage.txt
|
||||
|
|
@ -40,6 +43,7 @@ tasks/
|
|||
|
||||
# Plans
|
||||
docs/plans/
|
||||
docs/superpowers/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
version: 2
|
||||
|
||||
git:
|
||||
ignore_tags:
|
||||
- nightly
|
||||
- ".*-nightly.*"
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
|
@ -15,6 +20,7 @@ builds:
|
|||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- goolm
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
|
|
@ -57,6 +63,7 @@ builds:
|
|||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- goolm
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
|
|
@ -95,6 +102,7 @@ builds:
|
|||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- goolm
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
|
|
|
|||
78
Makefile
78
Makefile
|
|
@ -17,7 +17,13 @@ LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COM
|
|||
# Go variables
|
||||
GO?=CGO_ENABLED=0 go
|
||||
WEB_GO?=$(GO)
|
||||
GOFLAGS?=-v -tags stdjson
|
||||
GO_BUILD_TAGS?=goolm,stdjson
|
||||
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
|
||||
comma:=,
|
||||
empty:=
|
||||
space:=$(empty) $(empty)
|
||||
GO_BUILD_TAGS_NO_GOOLM:=$(subst $(space),$(comma),$(strip $(filter-out goolm,$(subst $(comma),$(space),$(GO_BUILD_TAGS)))))
|
||||
GOFLAGS_NO_GOOLM?=-v -tags $(GO_BUILD_TAGS_NO_GOOLM)
|
||||
|
||||
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
||||
#
|
||||
|
|
@ -41,6 +47,13 @@ define PATCH_MIPS_FLAGS
|
|||
fi
|
||||
endef
|
||||
|
||||
# Patch creack/pty for loong64 support (upstream doesn't have ztypes_loong64.go)
|
||||
PTY_PATCH_LOONG64=pty_dir=$$(go env GOMODCACHE)/github.com/creack/pty@v1.1.9; \
|
||||
if [ -d "$$pty_dir" ] && [ ! -f "$$pty_dir/ztypes_loong64.go" ]; then \
|
||||
chmod +w "$$pty_dir" 2>/dev/null || true; \
|
||||
printf '//go:build linux && loong64\npackage pty\ntype (_C_int int32; _C_uint uint32)\n' > "$$pty_dir/ztypes_loong64.go"; \
|
||||
fi
|
||||
|
||||
# Golangci-lint
|
||||
GOLANGCI_LINT?=golangci-lint
|
||||
|
||||
|
|
@ -125,20 +138,28 @@ build-launcher:
|
|||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||
|
||||
## build-launcher-tui: Build the picoclaw-launcher TUI binary
|
||||
build-launcher-tui:
|
||||
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui
|
||||
@ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui"
|
||||
|
||||
## 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 "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags $(GO_BUILD_TAGS_NO_GOOLM),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@echo "Build complete"
|
||||
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
|
@ -147,21 +168,21 @@ build-whatsapp-native: generate
|
|||
build-linux-arm: generate
|
||||
@echo "Building for linux/arm (GOARM=7)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(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 "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||
|
||||
## build-linux-mipsle: Build for Linux MIPS32 LE
|
||||
build-linux-mipsle: generate
|
||||
@echo "Building for linux/mipsle (softfloat)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
||||
|
||||
|
|
@ -173,18 +194,19 @@ build-pi-zero: build-linux-arm build-linux-arm64
|
|||
build-all: generate
|
||||
@echo "Building for multiple platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
@$(PTY_PATCH_LOONG64)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
GOOS=netbsd GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
||||
GOOS=netbsd GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
||||
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
||||
@echo "All builds complete"
|
||||
|
||||
## install: Install picoclaw to system and copy builtin skills
|
||||
|
|
@ -221,13 +243,13 @@ clean:
|
|||
|
||||
## vet: Run go vet for static analysis
|
||||
vet: generate
|
||||
@packages="$$(go list ./...)" && \
|
||||
$(GO) vet $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
|
||||
@packages="$$($(GO) list $(GOFLAGS) ./...)" && \
|
||||
$(GO) vet $(GOFLAGS) $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
|
||||
@cd web/backend && $(WEB_GO) vet ./...
|
||||
|
||||
## test: Test Go code
|
||||
test: generate
|
||||
@$(GO) test $$(go list ./... | grep -v github.com/sipeed/picoclaw/web/)
|
||||
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
|
||||
@cd web && make test
|
||||
|
||||
## fmt: Format Go code
|
||||
|
|
@ -236,11 +258,11 @@ fmt:
|
|||
|
||||
## lint: Run linters
|
||||
lint:
|
||||
@$(GOLANGCI_LINT) run
|
||||
@$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
|
||||
|
||||
## fix: Fix linting issues
|
||||
fix:
|
||||
@$(GOLANGCI_LINT) run --fix
|
||||
@$(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS)
|
||||
|
||||
## deps: Download dependencies
|
||||
deps:
|
||||
|
|
|
|||
|
|
@ -524,7 +524,7 @@ Connectez PicoClaw au réseau social des Agents simplement en envoyant un seul m
|
|||
| Commande | Description |
|
||||
| ------------------------- | ---------------------------------------- |
|
||||
| `picoclaw onboard` | Initialiser la config & le workspace |
|
||||
| `picoclaw onboard weixin` | Connecter un compte WeChat via QR |
|
||||
| `picoclaw auth weixin` | Connecter un compte WeChat via QR |
|
||||
| `picoclaw agent -m "..."` | Chatter avec l'agent |
|
||||
| `picoclaw agent` | Mode chat interactif |
|
||||
| `picoclaw gateway` | Démarrer le gateway |
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan mel
|
|||
| Perintah | Deskripsi |
|
||||
| -------------------------- | -------------------------------- |
|
||||
| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
|
||||
| `picoclaw onboard weixin` | Hubungkan akun WeChat via QR |
|
||||
| `picoclaw auth weixin` | Hubungkan akun WeChat via QR |
|
||||
| `picoclaw agent -m "..."` | Chat dengan agent |
|
||||
| `picoclaw agent` | Mode chat interaktif |
|
||||
| `picoclaw gateway` | Mulai gateway |
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol
|
|||
| Comando | Descrizione |
|
||||
| ------------------------- | ---------------------------------- |
|
||||
| `picoclaw onboard` | Inizializza config & workspace |
|
||||
| `picoclaw onboard weixin` | Connetti account WeChat tramite QR |
|
||||
| `picoclaw auth weixin` | Connetti account WeChat tramite QR |
|
||||
| `picoclaw agent -m "..."` | Chatta con l'agent |
|
||||
| `picoclaw agent` | Modalità chat interattiva |
|
||||
| `picoclaw gateway` | Avvia il gateway |
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ CLI または統合チャットアプリからメッセージを 1 つ送るだ
|
|||
| コマンド | 説明 |
|
||||
| ------------------------- | ------------------------------ |
|
||||
| `picoclaw onboard` | 設定&ワークスペースの初期化 |
|
||||
| `picoclaw onboard weixin` | WeChat アカウントを QR で接続 |
|
||||
| `picoclaw auth weixin` | WeChat アカウントを QR で接続 |
|
||||
| `picoclaw agent -m "..."` | Agent とチャット |
|
||||
| `picoclaw agent` | インタラクティブチャットモード |
|
||||
| `picoclaw gateway` | Gateway を起動 |
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -322,14 +322,17 @@ This creates `~/.picoclaw/config.json` and the workspace directory.
|
|||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
"model": "openai/gpt-5.4"
|
||||
// api_key is now loaded from .security.yml
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> See `config/config.example.json` in the repo for a complete configuration template with all available options.
|
||||
>
|
||||
> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security_configuration.md` for more details.
|
||||
|
||||
|
||||
**3. Chat**
|
||||
|
||||
|
|
@ -373,6 +376,9 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
|
|||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS |
|
||||
|
||||
> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
|
||||
|
||||
<details>
|
||||
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
|
||||
|
|
@ -520,7 +526,7 @@ Connect PicoClaw to the Agent Social Network simply by sending a single message
|
|||
| Command | Description |
|
||||
| ------------------------- | -------------------------------- |
|
||||
| `picoclaw onboard` | Initialize config & workspace |
|
||||
| `picoclaw onboard weixin` | Connect WeChat account via QR |
|
||||
| `picoclaw auth weixin` | Connect WeChat account via QR |
|
||||
| `picoclaw agent -m "..."` | Chat with the agent |
|
||||
| `picoclaw agent` | Interactive chat mode |
|
||||
| `picoclaw gateway` | Start the gateway |
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ Conecte o PicoClaw à Rede Social de Agents simplesmente enviando uma única men
|
|||
| Comando | Descrição |
|
||||
| ------------------------- | -------------------------------------- |
|
||||
| `picoclaw onboard` | Inicializar config e workspace |
|
||||
| `picoclaw onboard weixin` | Conectar conta WeChat via QR |
|
||||
| `picoclaw auth weixin` | Conectar conta WeChat via QR |
|
||||
| `picoclaw agent -m "..."` | Conversar com o agent |
|
||||
| `picoclaw agent` | Modo de chat interativo |
|
||||
| `picoclaw gateway` | Iniciar o gateway |
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một
|
|||
| Lệnh | Mô tả |
|
||||
| ------------------------- | ---------------------------------------- |
|
||||
| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
|
||||
| `picoclaw onboard weixin` | Kết nối tài khoản WeChat qua QR |
|
||||
| `picoclaw auth weixin` | Kết nối tài khoản WeChat qua QR |
|
||||
| `picoclaw agent -m "..."` | Trò chuyện với agent |
|
||||
| `picoclaw agent` | Chế độ trò chuyện tương tác |
|
||||
| `picoclaw gateway` | Khởi động gateway |
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ PicoClaw 原生支持 [MCP](https://modelcontextprotocol.io/) — 连接任意 M
|
|||
| 命令 | 说明 |
|
||||
| ------------------------- | ---------------------- |
|
||||
| `picoclaw onboard` | 初始化配置与工作区 |
|
||||
| `picoclaw onboard weixin` | 扫码连接微信个人号 |
|
||||
| `picoclaw auth weixin` | 扫码连接微信个人号 |
|
||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||
| `picoclaw agent` | 交互式对话模式 |
|
||||
| `picoclaw gateway` | 启动网关 |
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 357 KiB |
69
cmd/picoclaw-launcher-tui/README.md
Normal file
69
cmd/picoclaw-launcher-tui/README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Picoclaw Launcher TUI
|
||||
|
||||
This directory contains the terminal-based TUI launcher for `picoclaw`.
|
||||
It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment.
|
||||
|
||||
## Architecture
|
||||
|
||||
The TUI launcher is implemented purely in Go with no external runtime dependencies:
|
||||
* **`main.go`**: Application entry point, handles initialization and main event loop
|
||||
* **`ui/`**: TUI interface components built on tview + tcell framework:
|
||||
- `home.go`: Main dashboard with navigation menu
|
||||
- `schemes.go`: AI model scheme management
|
||||
- `users.go`: User and API key management for model providers
|
||||
- `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor
|
||||
- `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status)
|
||||
- `app.go`: Core TUI application framework and navigation logic
|
||||
- `models.go`: Data structures and state management
|
||||
* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Go 1.25+
|
||||
* Terminal with 256-color support (most modern terminals are compatible)
|
||||
|
||||
### Development
|
||||
|
||||
Run the TUI launcher directly in development mode:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
go run ./cmd/picoclaw-launcher-tui
|
||||
|
||||
# Or from this directory
|
||||
go run .
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
Build the standalone TUI launcher binary:
|
||||
|
||||
```bash
|
||||
# From project root (recommended)
|
||||
make build-launcher-tui
|
||||
|
||||
# Output will be at:
|
||||
# build/picoclaw-launcher-tui-<platform>-<arch>
|
||||
# with symlink build/picoclaw-launcher-tui
|
||||
|
||||
# Or build directly from this directory
|
||||
go build -o picoclaw-launcher-tui .
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments
|
||||
* ⚙️ AI model scheme and API key management
|
||||
* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.)
|
||||
* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring)
|
||||
* 💬 One-click launch of interactive AI chat session
|
||||
* 🎯 Keyboard-first design with intuitive shortcuts
|
||||
|
||||
### Other Commands
|
||||
|
||||
```bash
|
||||
# Run with custom config file path
|
||||
go run . /path/to/custom/config.json
|
||||
```
|
||||
|
|
@ -28,6 +28,8 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
|||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
logger.ConfigureFromEnv()
|
||||
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ func NewAuthCommand() *cobra.Command {
|
|||
newLogoutCommand(),
|
||||
newStatusCommand(),
|
||||
newModelsCommand(),
|
||||
newWeixinCommand(),
|
||||
newWeComCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ func TestNewAuthCommand(t *testing.T) {
|
|||
"logout",
|
||||
"status",
|
||||
"models",
|
||||
"weixin",
|
||||
"wecom",
|
||||
}
|
||||
|
||||
subcommands := cmd.Commands()
|
||||
|
|
|
|||
407
cmd/picoclaw/internal/auth/wecom.go
Normal file
407
cmd/picoclaw/internal/auth/wecom.go
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
wecomQRSourceID = "picoclaw"
|
||||
wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate"
|
||||
wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result"
|
||||
wecomQRPageEndpoint = "https://work.weixin.qq.com/ai/qc/gen"
|
||||
wecomQRHTTPTimeout = 15 * time.Second
|
||||
wecomQRPollInterval = 3 * time.Second
|
||||
wecomQRPollTimeout = 5 * time.Minute
|
||||
wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com"
|
||||
)
|
||||
|
||||
type wecomQRScanner func(context.Context, wecomQRFlowOptions) (wecomQRBotInfo, error)
|
||||
|
||||
type wecomQRFlowOptions struct {
|
||||
HTTPClient *http.Client
|
||||
GenerateURL string
|
||||
QueryURL string
|
||||
QRCodePageURL string
|
||||
SourceID string
|
||||
PollInterval time.Duration
|
||||
PollTimeout time.Duration
|
||||
Writer io.Writer
|
||||
}
|
||||
|
||||
type wecomQRBotInfo struct {
|
||||
BotID string
|
||||
Secret string
|
||||
}
|
||||
|
||||
type wecomQRSession struct {
|
||||
SCode string
|
||||
AuthURL string
|
||||
}
|
||||
|
||||
type wecomQRGenerateResponse struct {
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Data struct {
|
||||
SCode string `json:"scode"`
|
||||
AuthURL string `json:"auth_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type wecomQRQueryResponse struct {
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
BotInfo struct {
|
||||
BotID string `json:"botid"`
|
||||
Secret string `json:"secret"`
|
||||
} `json:"bot_info"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func newWeComCommand() *cobra.Command {
|
||||
var timeout time.Duration
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "wecom",
|
||||
Short: "Scan a WeCom QR code and configure channels.wecom",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return authWeComCmd(timeout)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().DurationVar(&timeout, "timeout", wecomQRPollTimeout, "How long to wait for QR confirmation")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func authWeComCmd(timeout time.Duration) error {
|
||||
return authWeComCmdWithScanner(context.Background(), os.Stdout, timeout, scanWeComQRCodeInteractive)
|
||||
}
|
||||
|
||||
func authWeComCmdWithScanner(
|
||||
ctx context.Context,
|
||||
writer io.Writer,
|
||||
timeout time.Duration,
|
||||
scanner wecomQRScanner,
|
||||
) error {
|
||||
if scanner == nil {
|
||||
return fmt.Errorf("wecom QR scanner is nil")
|
||||
}
|
||||
if writer == nil {
|
||||
writer = os.Stdout
|
||||
}
|
||||
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
opts := defaultWeComQRFlowOptions(timeout)
|
||||
opts.Writer = writer
|
||||
|
||||
botInfo, err := scanner(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applyWeComAuthResult(cfg, botInfo)
|
||||
|
||||
if saveErr := config.SaveConfig(internal.GetConfigPath(), cfg); saveErr != nil {
|
||||
return fmt.Errorf("failed to save config: %w", saveErr)
|
||||
}
|
||||
|
||||
fmt.Fprintln(writer)
|
||||
fmt.Fprintln(writer, "WeCom connected.")
|
||||
fmt.Fprintf(writer, "Bot ID: %s\n", botInfo.BotID)
|
||||
fmt.Fprintf(writer, "Config: %s\n", internal.GetConfigPath())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions {
|
||||
if timeout <= 0 {
|
||||
timeout = wecomQRPollTimeout
|
||||
}
|
||||
|
||||
return wecomQRFlowOptions{
|
||||
HTTPClient: &http.Client{Timeout: wecomQRHTTPTimeout},
|
||||
GenerateURL: wecomQRGenerateEndpoint,
|
||||
QueryURL: wecomQRQueryEndpoint,
|
||||
QRCodePageURL: wecomQRPageEndpoint,
|
||||
SourceID: wecomQRSourceID,
|
||||
PollInterval: wecomQRPollInterval,
|
||||
PollTimeout: timeout,
|
||||
Writer: os.Stdout,
|
||||
}
|
||||
}
|
||||
|
||||
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
|
||||
cfg.Channels.WeCom.Enabled = true
|
||||
cfg.Channels.WeCom.BotID = botInfo.BotID
|
||||
cfg.Channels.WeCom.SetSecret(botInfo.Secret)
|
||||
if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
|
||||
cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
|
||||
}
|
||||
}
|
||||
|
||||
func scanWeComQRCodeInteractive(ctx context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
|
||||
opts = normalizeWeComQRFlowOptions(opts)
|
||||
|
||||
fmt.Fprintln(opts.Writer, "Requesting WeCom QR code...")
|
||||
|
||||
session, err := fetchWeComQRCode(ctx, opts)
|
||||
if err != nil {
|
||||
return wecomQRBotInfo{}, err
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.Writer)
|
||||
fmt.Fprintln(opts.Writer, "=======================================================")
|
||||
fmt.Fprintln(opts.Writer, "Please scan the following QR code with WeCom:")
|
||||
fmt.Fprintln(opts.Writer, "=======================================================")
|
||||
fmt.Fprintln(opts.Writer)
|
||||
|
||||
qrterminal.GenerateWithConfig(session.AuthURL, qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
Writer: opts.Writer,
|
||||
HalfBlocks: true,
|
||||
})
|
||||
|
||||
pageURL, err := buildWeComQRCodePageURL(opts.QRCodePageURL, opts.SourceID, session.SCode)
|
||||
if err != nil {
|
||||
return wecomQRBotInfo{}, err
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.Writer)
|
||||
fmt.Fprintf(opts.Writer, "QR Code Link: %s\n", pageURL)
|
||||
fmt.Fprintln(opts.Writer)
|
||||
fmt.Fprintln(opts.Writer, "Waiting for scan...")
|
||||
|
||||
return pollWeComQRCodeResult(ctx, opts, session.SCode)
|
||||
}
|
||||
|
||||
func normalizeWeComQRFlowOptions(opts wecomQRFlowOptions) wecomQRFlowOptions {
|
||||
if opts.HTTPClient == nil {
|
||||
opts.HTTPClient = &http.Client{Timeout: wecomQRHTTPTimeout}
|
||||
}
|
||||
if strings.TrimSpace(opts.GenerateURL) == "" {
|
||||
opts.GenerateURL = wecomQRGenerateEndpoint
|
||||
}
|
||||
if strings.TrimSpace(opts.QueryURL) == "" {
|
||||
opts.QueryURL = wecomQRQueryEndpoint
|
||||
}
|
||||
if strings.TrimSpace(opts.QRCodePageURL) == "" {
|
||||
opts.QRCodePageURL = wecomQRPageEndpoint
|
||||
}
|
||||
if strings.TrimSpace(opts.SourceID) == "" {
|
||||
opts.SourceID = wecomQRSourceID
|
||||
}
|
||||
if opts.PollInterval <= 0 {
|
||||
opts.PollInterval = wecomQRPollInterval
|
||||
}
|
||||
if opts.PollTimeout <= 0 {
|
||||
opts.PollTimeout = wecomQRPollTimeout
|
||||
}
|
||||
if opts.Writer == nil {
|
||||
opts.Writer = os.Stdout
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func fetchWeComQRCode(ctx context.Context, opts wecomQRFlowOptions) (wecomQRSession, error) {
|
||||
generateURL, err := buildWeComQRGenerateURL(opts.GenerateURL, opts.SourceID, wecomPlatformCode())
|
||||
if err != nil {
|
||||
return wecomQRSession{}, err
|
||||
}
|
||||
|
||||
var resp wecomQRGenerateResponse
|
||||
if err := doWeComJSONGet(ctx, opts.HTTPClient, generateURL, &resp); err != nil {
|
||||
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: %w", err)
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return wecomQRSession{}, fmt.Errorf(
|
||||
"failed to get WeCom QR code: errcode=%d errmsg=%s",
|
||||
resp.ErrCode,
|
||||
resp.ErrMsg,
|
||||
)
|
||||
}
|
||||
if resp.Data.SCode == "" || resp.Data.AuthURL == "" {
|
||||
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: response missing scode or auth_url")
|
||||
}
|
||||
|
||||
return wecomQRSession{
|
||||
SCode: resp.Data.SCode,
|
||||
AuthURL: resp.Data.AuthURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pollWeComQRCodeResult(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRBotInfo, error) {
|
||||
if strings.TrimSpace(scode) == "" {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("missing WeCom QR scode")
|
||||
}
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, opts.PollTimeout)
|
||||
defer cancel()
|
||||
|
||||
var scannedPrinted bool
|
||||
|
||||
for {
|
||||
status, err := queryWeComQRCodeStatus(timeoutCtx, opts, scode)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
|
||||
}
|
||||
return wecomQRBotInfo{}, err
|
||||
}
|
||||
|
||||
switch strings.ToLower(status.Data.Status) {
|
||||
case "success":
|
||||
if status.Data.BotInfo.BotID == "" || status.Data.BotInfo.Secret == "" {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan succeeded but bot credentials are missing")
|
||||
}
|
||||
return wecomQRBotInfo{
|
||||
BotID: status.Data.BotInfo.BotID,
|
||||
Secret: status.Data.BotInfo.Secret,
|
||||
}, nil
|
||||
case "expired":
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR code expired, please retry")
|
||||
case "scaned", "scanned":
|
||||
if !scannedPrinted {
|
||||
fmt.Fprintln(opts.Writer, "QR code scanned. Confirm the login in WeCom.")
|
||||
scannedPrinted = true
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
if errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
|
||||
}
|
||||
return wecomQRBotInfo{}, timeoutCtx.Err()
|
||||
case <-time.After(opts.PollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func queryWeComQRCodeStatus(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRQueryResponse, error) {
|
||||
queryURL, err := buildWeComQRQueryURL(opts.QueryURL, scode)
|
||||
if err != nil {
|
||||
return wecomQRQueryResponse{}, err
|
||||
}
|
||||
|
||||
var resp wecomQRQueryResponse
|
||||
if err := doWeComJSONGet(ctx, opts.HTTPClient, queryURL, &resp); err != nil {
|
||||
return wecomQRQueryResponse{}, fmt.Errorf("failed to query WeCom QR result: %w", err)
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return wecomQRQueryResponse{}, fmt.Errorf(
|
||||
"failed to query WeCom QR result: errcode=%d errmsg=%s",
|
||||
resp.ErrCode,
|
||||
resp.ErrMsg,
|
||||
)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func buildWeComQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err)
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("source", sourceID)
|
||||
query.Set("sourceID", sourceID)
|
||||
query.Set("plat", strconv.Itoa(platformCode))
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func buildWeComQRQueryURL(baseURL, scode string) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid WeCom QR query URL: %w", err)
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("scode", scode)
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func buildWeComQRCodePageURL(baseURL, sourceID, scode string) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid WeCom QR page URL: %w", err)
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("source", sourceID)
|
||||
query.Set("sourceID", sourceID)
|
||||
query.Set("scode", scode)
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func doWeComJSONGet(ctx context.Context, client *http.Client, targetURL string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("unexpected status %s", resp.Status)
|
||||
}
|
||||
return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decode JSON response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomPlatformCode() int {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return 1
|
||||
case "windows":
|
||||
return 2
|
||||
case "linux":
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
157
cmd/picoclaw/internal/auth/wecom_test.go
Normal file
157
cmd/picoclaw/internal/auth/wecom_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestNewWeComCommand(t *testing.T) {
|
||||
cmd := newWeComCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
assert.Equal(t, "wecom", cmd.Use)
|
||||
assert.Equal(t, "Scan a WeCom QR code and configure channels.wecom", cmd.Short)
|
||||
assert.NotNil(t, cmd.Flags().Lookup("timeout"))
|
||||
}
|
||||
|
||||
func TestBuildWeComQRGenerateURL(t *testing.T) {
|
||||
rawURL, err := buildWeComQRGenerateURL("https://example.com/ai/qc/generate", wecomQRSourceID, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
|
||||
assert.Equal(t, "3", parsed.Query().Get("plat"))
|
||||
}
|
||||
|
||||
func TestBuildWeComQRCodePageURL(t *testing.T) {
|
||||
rawURL, err := buildWeComQRCodePageURL("https://example.com/ai/qc/gen", wecomQRSourceID, "scode-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
|
||||
assert.Equal(t, "scode-1", parsed.Query().Get("scode"))
|
||||
}
|
||||
|
||||
func TestFetchWeComQRCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/generate", r.URL.Path)
|
||||
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source"))
|
||||
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID"))
|
||||
assert.Equal(t, strconv.Itoa(wecomPlatformCode()), r.URL.Query().Get("plat"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||
HTTPClient: server.Client(),
|
||||
GenerateURL: server.URL + "/generate",
|
||||
Writer: bytes.NewBuffer(nil),
|
||||
})
|
||||
|
||||
session, err := fetchWeComQRCode(context.Background(), opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "scode-1", session.SCode)
|
||||
assert.Equal(t, "https://example.com/qr", session.AuthURL)
|
||||
}
|
||||
|
||||
func TestPollWeComQRCodeResult(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
call := calls.Add(1)
|
||||
assert.Equal(t, "/query", r.URL.Path)
|
||||
assert.Equal(t, "scode-1", r.URL.Query().Get("scode"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch call {
|
||||
case 1:
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"wait"}}`))
|
||||
case 2:
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"scaned"}}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var output bytes.Buffer
|
||||
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||
HTTPClient: server.Client(),
|
||||
QueryURL: server.URL + "/query",
|
||||
PollInterval: time.Millisecond,
|
||||
PollTimeout: time.Second,
|
||||
Writer: &output,
|
||||
})
|
||||
|
||||
botInfo, err := pollWeComQRCodeResult(context.Background(), opts, "scode-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "bot-1", botInfo.BotID)
|
||||
assert.Equal(t, "secret-1", botInfo.Secret)
|
||||
assert.Contains(t, output.String(), "QR code scanned. Confirm the login in WeCom.")
|
||||
}
|
||||
|
||||
func TestApplyWeComAuthResult(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Channels.WeCom.WebSocketURL = ""
|
||||
|
||||
applyWeComAuthResult(cfg, wecomQRBotInfo{
|
||||
BotID: "bot-1",
|
||||
Secret: "secret-1",
|
||||
})
|
||||
|
||||
assert.True(t, cfg.Channels.WeCom.Enabled)
|
||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
||||
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret())
|
||||
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||
}
|
||||
|
||||
func TestAuthWeComCmdWithScanner(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
t.Setenv(config.EnvHome, tmpDir)
|
||||
t.Setenv(config.EnvConfig, configPath)
|
||||
|
||||
var output bytes.Buffer
|
||||
err := authWeComCmdWithScanner(
|
||||
context.Background(),
|
||||
&output,
|
||||
time.Second,
|
||||
func(_ context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
|
||||
assert.Equal(t, wecomQRSourceID, opts.SourceID)
|
||||
return wecomQRBotInfo{
|
||||
BotID: "bot-1",
|
||||
Secret: "secret-1",
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.LoadConfig(internal.GetConfigPath())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, cfg.Channels.WeCom.Enabled)
|
||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
||||
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret())
|
||||
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||
assert.Contains(t, output.String(), "WeCom connected.")
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package onboard
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -27,7 +27,7 @@ to authorize your account. On success, the bot token is saved to the picoclaw
|
|||
config so you can start the gateway immediately.
|
||||
|
||||
Example:
|
||||
picoclaw onboard weixin`,
|
||||
picoclaw auth weixin`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second)
|
||||
},
|
||||
|
|
@ -16,7 +16,7 @@ func NewOnboardCommand() *cobra.Command {
|
|||
cmd := &cobra.Command{
|
||||
Use: "onboard",
|
||||
Aliases: []string{"o"},
|
||||
Short: "Initialize picoclaw configuration, workspace, and channel accounts",
|
||||
Short: "Initialize picoclaw configuration and workspace",
|
||||
// Run without subcommands → original onboard flow
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) == 0 {
|
||||
|
|
@ -30,8 +30,5 @@ func NewOnboardCommand() *cobra.Command {
|
|||
cmd.Flags().BoolVar(&encrypt, "enc", false,
|
||||
"Enable credential encryption (generates SSH key and prompts for passphrase)")
|
||||
|
||||
// Channel onboarding subcommands
|
||||
cmd.AddCommand(newWeixinCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ func TestNewOnboardCommand(t *testing.T) {
|
|||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "onboard", cmd.Use)
|
||||
assert.Equal(t, "Initialize picoclaw configuration, workspace, and channel accounts", cmd.Short)
|
||||
assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 1)
|
||||
assert.True(t, cmd.HasAlias("o"))
|
||||
|
|
@ -28,6 +28,5 @@ func TestNewOnboardCommand(t *testing.T) {
|
|||
encFlag := cmd.Flags().Lookup("enc")
|
||||
require.NotNil(t, encFlag, "expected --enc flag to be registered")
|
||||
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
assert.NotNil(t, cmd.Commands())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"max_tool_iterations": 20,
|
||||
"summarize_message_threshold": 20,
|
||||
"summarize_token_percent": 75,
|
||||
"split_on_marker": false,
|
||||
"tool_feedback": {
|
||||
"enabled": false,
|
||||
"max_args_length": 300
|
||||
|
|
@ -129,6 +130,10 @@
|
|||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": [],
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"random_reaction_emoji": [],
|
||||
"is_lark": false
|
||||
|
|
@ -160,9 +165,11 @@
|
|||
},
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": "Thinking... 💭"
|
||||
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||
},
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"crypto_database_path": "",
|
||||
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
|
||||
},
|
||||
"line": {
|
||||
"enabled": false,
|
||||
|
|
@ -182,39 +189,13 @@
|
|||
"reasoning_channel_id": ""
|
||||
},
|
||||
"wecom": {
|
||||
"_comment": "WeCom Bot - Easier setup, supports group chats",
|
||||
"enabled": false,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5,
|
||||
"reasoning_channel_id": ""
|
||||
},
|
||||
"wecom_app": {
|
||||
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.",
|
||||
"enabled": false,
|
||||
"corp_id": "YOUR_CORP_ID",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5,
|
||||
"reasoning_channel_id": ""
|
||||
},
|
||||
"wecom_aibot": {
|
||||
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
|
||||
"_comment": "WeCom AI Bot over WebSocket.",
|
||||
"enabled": false,
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-aibot",
|
||||
"max_steps": 10,
|
||||
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
},
|
||||
"pico": {
|
||||
|
|
@ -247,13 +228,8 @@
|
|||
"nickserv_password": "",
|
||||
"sasl_user": "",
|
||||
"sasl_password": "",
|
||||
"channels": [
|
||||
"#mychannel"
|
||||
],
|
||||
"request_caps": [
|
||||
"server-time",
|
||||
"message-tags"
|
||||
],
|
||||
"channels": ["#mychannel"],
|
||||
"request_caps": ["server-time", "message-tags"],
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
|
|
@ -264,79 +240,6 @@
|
|||
"reasoning_channel_id": ""
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"_comment": "DEPRECATED: Use model_list instead. This will be removed in a future version",
|
||||
"anthropic": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
},
|
||||
"openai": {
|
||||
"api_key": "",
|
||||
"api_base": "",
|
||||
"web_search": true
|
||||
},
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx",
|
||||
"api_base": ""
|
||||
},
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx",
|
||||
"api_base": ""
|
||||
},
|
||||
"zhipu": {
|
||||
"api_key": "YOUR_ZHIPU_API_KEY",
|
||||
"api_base": ""
|
||||
},
|
||||
"gemini": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
},
|
||||
"vllm": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
},
|
||||
"nvidia": {
|
||||
"api_key": "nvapi-xxx",
|
||||
"api_base": "",
|
||||
"proxy": "http://127.0.0.1:7890"
|
||||
},
|
||||
"moonshot": {
|
||||
"api_key": "sk-xxx",
|
||||
"api_base": ""
|
||||
},
|
||||
"qwen": {
|
||||
"api_key": "sk-xxx",
|
||||
"api_base": ""
|
||||
},
|
||||
"ollama": {
|
||||
"api_key": "",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
},
|
||||
"cerebras": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
},
|
||||
"volcengine": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
},
|
||||
"mistral": {
|
||||
"api_key": "",
|
||||
"api_base": "https://api.mistral.ai/v1"
|
||||
},
|
||||
"avian": {
|
||||
"api_key": "",
|
||||
"api_base": "https://api.avian.io/v1"
|
||||
},
|
||||
"longcat": {
|
||||
"api_key": "",
|
||||
"api_base": "https://api.longcat.chat/openai"
|
||||
},
|
||||
"modelscope": {
|
||||
"api_key": "",
|
||||
"api_base": "https://api-inference.modelscope.cn/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"allow_read_paths": null,
|
||||
"allow_write_paths": null,
|
||||
|
|
@ -348,9 +251,7 @@
|
|||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"api_keys": [
|
||||
"YOUR_BRAVE_API_KEY"
|
||||
],
|
||||
"api_keys": ["YOUR_BRAVE_API_KEY"],
|
||||
"max_results": 5
|
||||
},
|
||||
"tavily": {
|
||||
|
|
@ -366,9 +267,7 @@
|
|||
"perplexity": {
|
||||
"enabled": false,
|
||||
"api_key": "pplx-xxx",
|
||||
"api_keys": [
|
||||
"pplx-xxx"
|
||||
],
|
||||
"api_keys": ["pplx-xxx"],
|
||||
"max_results": 5
|
||||
},
|
||||
"searxng": {
|
||||
|
|
@ -383,6 +282,12 @@
|
|||
"search_engine": "search_std",
|
||||
"max_results": 5
|
||||
},
|
||||
"baidu_search": {
|
||||
"enabled": false,
|
||||
"api_key": "",
|
||||
"base_url": "https://qianfan.baidubce.com/v2/ai_search/web_search",
|
||||
"max_results": 10
|
||||
},
|
||||
"fetch_limit_bytes": 10485760,
|
||||
"private_host_whitelist": []
|
||||
},
|
||||
|
|
@ -411,19 +316,12 @@
|
|||
"filesystem": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp"
|
||||
]
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
},
|
||||
"github": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-github"
|
||||
],
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
||||
}
|
||||
|
|
@ -431,10 +329,7 @@
|
|||
"brave-search": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-brave-search"
|
||||
],
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"env": {
|
||||
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
|
||||
}
|
||||
|
|
@ -451,10 +346,7 @@
|
|||
"slack": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-slack"
|
||||
],
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
||||
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
||||
|
|
|
|||
|
|
@ -22,10 +22,12 @@ Add this to `config.json`:
|
|||
},
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": "Thinking..."
|
||||
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"message_format": "richtext"
|
||||
"message_format": "richtext",
|
||||
"crypto_database_path": "",
|
||||
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,9 +45,18 @@ Add this to `config.json`:
|
|||
| join_on_invite | bool | No | Auto-join invited rooms |
|
||||
| allow_from | []string | No | User whitelist (Matrix user IDs) |
|
||||
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
|
||||
| placeholder | object | No | Placeholder message config |
|
||||
| placeholder | object | No | Placeholder message config (see below) |
|
||||
| reasoning_channel_id | string | No | Target channel for reasoning output |
|
||||
| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only |
|
||||
| crypto_database_path | string | No | Path to store the crypto database (uses workspace path `~/.picoclaw/workspace` if empty) |
|
||||
| crypto_passphrase | string | No | Serialization key for encrypting session keys in the database; must remain unchanged once set |
|
||||
|
||||
### Placeholder Config
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------|----------------|----------|-------------|
|
||||
| enabled | bool | No | Enable placeholder messages (default: false) |
|
||||
| text | string/[]string | No | Placeholder text(s). Can be a single string or array of strings. If multiple texts are provided, one is randomly selected at runtime. Default: "Thinking..." |
|
||||
|
||||
## 3. Currently Supported
|
||||
|
||||
|
|
@ -58,6 +69,7 @@ Add this to `config.json`:
|
|||
- Typing state (`m.typing`)
|
||||
- Placeholder message + final reply replacement
|
||||
- Auto-join invited rooms (can be disabled)
|
||||
- End-to-end encryption (E2EE) support for encrypted messages
|
||||
|
||||
## 4. TODO
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,12 @@
|
|||
},
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": "Thinking... 💭"
|
||||
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||
},
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"message_format": "richtext",
|
||||
"crypto_database_path": "",
|
||||
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +48,15 @@
|
|||
| placeholder | object | 否 | 占位消息配置 |
|
||||
| reasoning_channel_id | string | 否 | 思维链输出目标通道 |
|
||||
| message_format | string | 否 | 消息格式:`richtext`(富文本)或 `plain`(纯文本) |
|
||||
| crypto_database_path | string | 否 | 加密数据库存储路径(为空时使用工作空间路径 `~/.picoclaw/workspace`) |
|
||||
| crypto_passphrase | string | 否 | 加密数据库中 session key 的序列化密钥;设置后不能更改 |
|
||||
|
||||
### 占位消息配置 (Placeholder)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---------|-----------------|------|------|
|
||||
| enabled | bool | 否 | 是否启用占位消息(默认:false) |
|
||||
| text | string/[]string | 否 | 占位文本。可以是单个字符串或字符串数组。如果提供多个文本,运行时会随机选择一个。默认:"Thinking..." |
|
||||
|
||||
## 3. 当前支持
|
||||
|
||||
|
|
@ -56,6 +68,7 @@
|
|||
- Typing 状态(`m.typing`)
|
||||
- 占位消息(`Thinking... 💭`)+ 最终回复替换
|
||||
- 自动加入邀请房间(可关闭)
|
||||
- 端对端加密(E2EE)消息支持
|
||||
|
||||
## 4. TODO
|
||||
|
||||
|
|
|
|||
104
docs/channels/wecom/README.md
Normal file
104
docs/channels/wecom/README.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
> Back to [README](../../../README.md)
|
||||
|
||||
# WeCom
|
||||
|
||||
PicoClaw now exposes WeCom as a single `channels.wecom` channel built on the official WeCom AI Bot WebSocket API.
|
||||
This replaces the legacy `wecom`, `wecom_app`, and `wecom_aibot` split with one configuration model.
|
||||
|
||||
## What This Channel Supports
|
||||
|
||||
- Direct chat and group chat delivery
|
||||
- Channel-side streaming replies over WeCom's AI Bot protocol
|
||||
- Incoming text, voice, image, file, video, and mixed messages
|
||||
- Outbound text and media replies (`image`, `file`, `voice`, `video`)
|
||||
- QR-based CLI onboarding with `picoclaw auth wecom`
|
||||
- Shared allowlist and `reasoning_channel_id` routing
|
||||
|
||||
> No public webhook callback URL is required for this channel. PicoClaw opens an outbound WebSocket connection to WeCom.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: QR Login From CLI
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
picoclaw auth wecom
|
||||
```
|
||||
|
||||
The command prints a QR code in the terminal, waits for confirmation in WeCom, and then writes the resulting
|
||||
`bot_id` and `secret` into `channels.wecom`.
|
||||
|
||||
Use `--timeout` if you want to wait longer:
|
||||
|
||||
```bash
|
||||
picoclaw auth wecom --timeout 10m
|
||||
```
|
||||
|
||||
### Option 2: Configure Manually
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ----- | ---- | -------- | ----------- |
|
||||
| `enabled` | bool | No | Enables the WeCom channel. |
|
||||
| `bot_id` | string | Yes | WeCom AI Bot identifier. Required when the channel is enabled. |
|
||||
| `secret` | string | Yes | WeCom AI Bot secret. Required when the channel is enabled. |
|
||||
| `websocket_url` | string | No | WebSocket endpoint. Defaults to `wss://openws.work.weixin.qq.com`. |
|
||||
| `send_thinking_message` | bool | No | Sends an initial `Processing...` chunk before the final streamed reply. Defaults to `true`. |
|
||||
| `allow_from` | array | No | Sender allowlist. Empty means allow all senders. |
|
||||
| `reasoning_channel_id` | string | No | Optional destination for reasoning/thinking output. |
|
||||
|
||||
## Runtime Behavior
|
||||
|
||||
- PicoClaw keeps the active WeCom turn so normal replies can continue the same stream when possible.
|
||||
- If streaming is no longer available, replies fall back to active push delivery to the resolved chat route.
|
||||
- Incoming media is downloaded into the media store before being handed to the agent.
|
||||
- Outbound media is uploaded to WeCom in temporary chunks and then sent as a regular media message.
|
||||
|
||||
## Migration Notes
|
||||
|
||||
This branch removes the old multi-channel WeCom model.
|
||||
|
||||
| Previous config | Now |
|
||||
| --------------- | --- |
|
||||
| `channels.wecom` webhook bot | Replace with `channels.wecom` using `bot_id` + `secret`. |
|
||||
| `channels.wecom_app` | Remove it and use `channels.wecom`. |
|
||||
| `channels.wecom_aibot` | Move the config to `channels.wecom`. |
|
||||
| `token`, `encoding_aes_key`, `webhook_url`, `webhook_path` | No longer used by the WeCom channel. |
|
||||
| `corp_id`, `corp_secret`, `agent_id` | No longer used by the WeCom channel. |
|
||||
| `welcome_message`, `processing_message`, `max_steps` under WeCom | No longer part of the WeCom channel config. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `picoclaw auth wecom` times out
|
||||
|
||||
- Re-run with a larger `--timeout`.
|
||||
- Make sure the QR code was confirmed inside WeCom, not only scanned.
|
||||
|
||||
### WebSocket connection fails
|
||||
|
||||
- Verify `bot_id` and `secret`.
|
||||
- Confirm the host can reach `wss://openws.work.weixin.qq.com`.
|
||||
|
||||
### Replies do not arrive
|
||||
|
||||
- Check whether `allow_from` blocks the sender.
|
||||
- Check launcher or startup validation for missing `channels.wecom.bot_id` / `channels.wecom.secret`.
|
||||
|
||||
104
docs/channels/wecom/README.zh.md
Normal file
104
docs/channels/wecom/README.zh.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
> 返回 [README](../../../README.zh.md)
|
||||
|
||||
# 企业微信
|
||||
|
||||
PicoClaw 现在将企业微信统一为一个 `channels.wecom` 渠道,并基于企业微信官方 AI Bot WebSocket 协议实现。
|
||||
这取代了旧的 `wecom`、`wecom_app`、`wecom_aibot` 三套配置模型。
|
||||
|
||||
## 当前渠道能力
|
||||
|
||||
- 支持私聊和群聊
|
||||
- 支持企业微信侧流式回复
|
||||
- 支持接收文本、语音、图片、文件、视频和 mixed 消息
|
||||
- 支持发送文本与媒体消息(`image`、`file`、`voice`、`video`)
|
||||
- 支持通过 `picoclaw auth wecom` 扫码写入配置
|
||||
- 支持统一白名单与 `reasoning_channel_id`
|
||||
|
||||
> 这个渠道不再需要公网 webhook 回调地址。PicoClaw 会主动向企业微信发起 WebSocket 连接。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式 1:命令行扫码登录
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
picoclaw auth wecom
|
||||
```
|
||||
|
||||
该命令会在终端打印二维码,等待你在企业微信中确认,然后把生成的 `bot_id` 和 `secret` 写入
|
||||
`channels.wecom`。
|
||||
|
||||
如果需要更长等待时间,可以加 `--timeout`:
|
||||
|
||||
```bash
|
||||
picoclaw auth wecom --timeout 10m
|
||||
```
|
||||
|
||||
### 方式 2:手动配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 配置字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ---- | ---- | ---- | ---- |
|
||||
| `enabled` | bool | 否 | 是否启用企业微信渠道。 |
|
||||
| `bot_id` | string | 是 | 企业微信 AI Bot 标识。渠道启用时必填。 |
|
||||
| `secret` | string | 是 | 企业微信 AI Bot 密钥。渠道启用时必填。 |
|
||||
| `websocket_url` | string | 否 | WebSocket 地址,默认 `wss://openws.work.weixin.qq.com`。 |
|
||||
| `send_thinking_message` | bool | 否 | 是否在流式最终回复前先发送一段 `Processing...` 开场消息,默认 `true`。 |
|
||||
| `allow_from` | array | 否 | 发送者白名单;空数组表示允许所有发送者。 |
|
||||
| `reasoning_channel_id` | string | 否 | 可选的 reasoning/thinking 输出目标。 |
|
||||
|
||||
## 运行时行为
|
||||
|
||||
- PicoClaw 会保留当前会话对应的企业微信 turn,优先继续同一个流式回复。
|
||||
- 如果流式上下文已经失效,回复会自动回退到主动推送消息。
|
||||
- 收到的媒体会先下载到 media store,再交给 Agent 处理。
|
||||
- 发出的媒体会先按分片上传到企业微信,再作为普通媒体消息发送。
|
||||
|
||||
## 迁移说明
|
||||
|
||||
这个分支移除了旧的多通道企业微信模型。
|
||||
|
||||
| 旧配置 | 现在怎么做 |
|
||||
| ------ | ---------- |
|
||||
| `channels.wecom` webhook 机器人 | 改为使用 `bot_id` + `secret` 的 `channels.wecom`。 |
|
||||
| `channels.wecom_app` | 删除,统一迁移到 `channels.wecom`。 |
|
||||
| `channels.wecom_aibot` | 配置迁移到 `channels.wecom`。 |
|
||||
| `token`、`encoding_aes_key`、`webhook_url`、`webhook_path` | 企业微信渠道不再使用这些字段。 |
|
||||
| `corp_id`、`corp_secret`、`agent_id` | 企业微信渠道不再使用这些字段。 |
|
||||
| 企业微信下的 `welcome_message`、`processing_message`、`max_steps` | 不再属于企业微信渠道配置。 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `picoclaw auth wecom` 超时
|
||||
|
||||
- 用更大的 `--timeout` 重新执行。
|
||||
- 确认是在企业微信里完成了确认,而不只是扫描二维码。
|
||||
|
||||
### WebSocket 连接失败
|
||||
|
||||
- 检查 `bot_id` 和 `secret` 是否正确。
|
||||
- 确认运行环境可以访问 `wss://openws.work.weixin.qq.com`。
|
||||
|
||||
### 消息没有回到企业微信
|
||||
|
||||
- 检查 `allow_from` 是否拦截了发送者。
|
||||
- 检查启动日志或 launcher 校验,确认 `channels.wecom.bot_id` / `channels.wecom.secret` 已填写。
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ PicoClaw supports connecting to your personal WeChat account using the official
|
|||
The easiest way to set up the Weixin channel is using the interactive onboarding command:
|
||||
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
picoclaw auth weixin
|
||||
```
|
||||
|
||||
This command will:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ PicoClaw 支持使用腾讯官方 iLink API 连接您的个人微信账号。
|
|||
最简单的方法是使用交互式 onboarding 命令进行一键激活:
|
||||
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
picoclaw auth weixin
|
||||
```
|
||||
|
||||
该命令将:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol)
|
||||
|
||||
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
|
||||
> **Note**: Channels that rely on HTTP callbacks share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Socket/stream-based channels such as Feishu, DingTalk, and WeCom do not rely on the shared webhook server for inbound delivery.
|
||||
|
||||
| Channel | Difficulty | Description | Documentation |
|
||||
| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -19,7 +19,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
|
|||
| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](channels/qq/README.md) |
|
||||
| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](channels/dingtalk/README.md) |
|
||||
| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](channels/line/README.md) |
|
||||
| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Group Bot (Webhook), custom App (API), AI Bot | [Bot](channels/wecom/wecom_bot/README.md) / [App](channels/wecom/wecom_app/README.md) / [AI Bot](channels/wecom/wecom_aibot/README.md) |
|
||||
| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Official AI Bot over WebSocket, streaming + media | [Docs](channels/wecom/README.md) |
|
||||
| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](channels/feishu/README.md) |
|
||||
| **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) |
|
||||
| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](channels/onebot/README.md) |
|
||||
|
|
@ -190,7 +190,7 @@ PicoClaw supports connecting to your personal WeChat account using the official
|
|||
|
||||
Run the interactive QR login flow:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
picoclaw auth weixin
|
||||
```
|
||||
Scan the printed QR code with your WeChat mobile app. On success, the token is saved to your config.
|
||||
|
||||
|
|
@ -380,102 +380,34 @@ picoclaw gateway
|
|||
<details>
|
||||
<summary><b>WeCom (企业微信)</b></summary>
|
||||
|
||||
PicoClaw supports three types of WeCom integration:
|
||||
PicoClaw now exposes WeCom as a single AI Bot channel over WebSocket.
|
||||
No public webhook callback URL is required.
|
||||
|
||||
**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
|
||||
**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
|
||||
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
|
||||
See [WeCom Configuration Guide](channels/wecom/README.md) for the full configuration reference and migration notes.
|
||||
|
||||
See [WeCom AI Bot Configuration Guide](channels/wecom/wecom_aibot/README.md) for detailed setup instructions.
|
||||
**Quick Setup - Recommended**
|
||||
|
||||
**Quick Setup - WeCom Bot:**
|
||||
**1. Authenticate**
|
||||
|
||||
**1. Create a bot**
|
||||
```bash
|
||||
picoclaw auth wecom
|
||||
```
|
||||
|
||||
* Go to WeCom Admin Console → Group Chat → Add Group Bot
|
||||
* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||
This command shows a QR code, waits for approval in WeCom, and writes `bot_id` + `secret` into `channels.wecom`.
|
||||
|
||||
**2. Configure**
|
||||
**2. Configure manually if needed**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
|
||||
|
||||
**Quick Setup - WeCom App:**
|
||||
|
||||
**1. Create an app**
|
||||
|
||||
* Go to WeCom Admin Console → App Management → Create App
|
||||
* Copy **AgentId** and **Secret**
|
||||
* Go to "My Company" page, copy **CorpID**
|
||||
|
||||
**2. Configure receive message**
|
||||
|
||||
* In App details, click "Receive Message" → "Set API"
|
||||
* Set URL to `http://your-server:18790/webhook/wecom-app`
|
||||
* Generate **Token** and **EncodingAESKey**
|
||||
|
||||
**3. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_app": {
|
||||
"enabled": true,
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.
|
||||
|
||||
**Quick Setup - WeCom AI Bot:**
|
||||
|
||||
**1. Create an AI Bot**
|
||||
|
||||
* Go to WeCom Admin Console → App Management → AI Bot
|
||||
* In the AI Bot settings, configure callback URL: `http://your-server:18790/webhook/wecom-aibot`
|
||||
* Copy **Token** and click "Random Generate" for **EncodingAESKey**
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_aibot": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-aibot",
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"allow_from": [],
|
||||
"welcome_message": "Hello! How can I help you?",
|
||||
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
|
||||
"reasoning_channel_id": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -487,7 +419,7 @@ picoclaw gateway
|
|||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
|
||||
> Legacy `wecom_app` and `wecom_aibot` entries are replaced by the unified `channels.wecom` config in this branch.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
|||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Gateway Log Level
|
||||
|
||||
`gateway.log_level` controls Gateway log verbosity and is configurable in `config.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"log_level": "fatal"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When omitted, the default is `fatal`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`.
|
||||
|
||||
You can also override this with the environment variable `PICOCLAW_LOG_LEVEL`.
|
||||
|
||||
### Workspace Layout
|
||||
|
||||
PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):
|
||||
|
|
@ -454,6 +470,70 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
- **Load balancing**: Distribute requests across multiple endpoints
|
||||
- **Centralized configuration**: Manage all providers in one place
|
||||
|
||||
#### 🔒 Security Configuration (Recommended)
|
||||
|
||||
PicoClaw supports separating sensitive data (API keys, tokens, secrets) from your main configuration by storing them in a `.security.yml` file.
|
||||
|
||||
**Key Benefits:**
|
||||
- **Security**: Sensitive data is never in your main config file
|
||||
- **Easy sharing**: Share config.json without exposing API keys
|
||||
- **Version control**: Add `.security.yml` to `.gitignore`
|
||||
- **Flexible deployment**: Different environments can use different security files
|
||||
|
||||
**Quick Setup:**
|
||||
|
||||
1. Create `~/.picoclaw/.security.yml` with your API keys:
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-proj-your-actual-openai-key"
|
||||
claude-sonnet-4.6:
|
||||
api_keys:
|
||||
- "sk-ant-your-actual-anthropic-key"
|
||||
channels:
|
||||
telegram:
|
||||
token: "your-telegram-bot-token"
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSAyour-brave-api-key"
|
||||
glm_search:
|
||||
api_key: "your-glm-search-api-key"
|
||||
```
|
||||
|
||||
2. Set proper permissions:
|
||||
```bash
|
||||
chmod 600 ~/.picoclaw/.security.yml
|
||||
```
|
||||
|
||||
3. Remove sensitive fields from `config.json` (recommended):
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4"
|
||||
// api_key loaded from .security.yml
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true"
|
||||
// token loaded from .security.yml
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Values from `.security.yml` are automatically mapped to config fields
|
||||
- No special syntax needed — just omit sensitive fields from config.json
|
||||
- If a field exists in both files, `.security.yml` value takes precedence
|
||||
- You can mix direct values in config.json with security values
|
||||
|
||||
For complete documentation, see [`security_configuration.md`](security_configuration.md).
|
||||
|
||||
#### All Supported Vendors
|
||||
|
||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||
|
|
@ -515,16 +595,20 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
}
|
||||
```
|
||||
|
||||
> **Security Note**: You can remove `api_key` fields from your config and store them in `.security.yml` instead. See [Security Configuration](#-security-configuration-recommended) above for details.
|
||||
|
||||
#### Vendor-Specific Examples
|
||||
|
||||
> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended).
|
||||
|
||||
<details>
|
||||
<summary><b>OpenAI</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
"model": "openai/gpt-5.4"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -536,8 +620,8 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
"model": "volcengine/ark-code-latest"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -549,8 +633,8 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
"model": "zhipu/glm-4.7"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -562,8 +646,8 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-..."
|
||||
"model": "deepseek/deepseek-chat"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -575,8 +659,8 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key"
|
||||
"model": "anthropic/claude-sonnet-4.6"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -616,8 +700,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
|||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_key": "sk-..."
|
||||
"api_base": "https://my-proxy.com/v1"
|
||||
// api_key: set in .security.yml
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -629,6 +713,33 @@ PicoClaw strips only the outer `litellm/` prefix before sending the request, so
|
|||
|
||||
Configure multiple endpoints for the same model name — PicoClaw will automatically round-robin between them:
|
||||
|
||||
**Option 1: Multiple API Keys in .security.yml (Recommended)**
|
||||
|
||||
```yaml
|
||||
# .security.yml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-proj-key-1"
|
||||
- "sk-proj-key-2"
|
||||
```
|
||||
|
||||
```json
|
||||
// config.json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
// api_keys loaded from .security.yml
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Option 2: Multiple Model Entries**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
|
|
@ -685,6 +796,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The `providers` format is deprecated. Use the new `model_list` format with `.security.yml` for better security.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
@ -701,18 +814,10 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
|
|||
"dm_scope": "per-channel-peer",
|
||||
"backlog_limit": 20
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"enabled": true"
|
||||
// token: set in .security.yml
|
||||
"allow_from": ["123456789"]
|
||||
}
|
||||
},
|
||||
|
|
@ -731,6 +836,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: Sensitive fields (`api_key`, `token`, etc.) can be omitted and stored in `.security.yml` for better security.
|
||||
|
||||
</details>
|
||||
|
||||
### Scheduled Tasks / Reminders
|
||||
|
|
@ -754,6 +861,8 @@ Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace
|
|||
|
||||
| Topic | Description |
|
||||
| ----- | ----------- |
|
||||
| [Security Configuration](security_configuration.md) | Store API keys and secrets in separate `.security.yml` file |
|
||||
| [Sensitive Data Filtering](sensitive_data_filtering.md) | Filter API keys and tokens from tool results before sending to LLM |
|
||||
| [Hook System](hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks |
|
||||
| [Steering](steering.md) | Inject messages into a running agent loop between tool calls |
|
||||
| [SubTurn](subturn.md) | Subagent coordination, concurrency control, lifecycle |
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ enc://AAAA...base64...
|
|||
{
|
||||
"model_name": "gpt-4o",
|
||||
"model": "openai/gpt-4o",
|
||||
"api_key": "enc://AAAA...base64...",
|
||||
// "api_key": "enc://AAAA...base64..." move to .security.yml
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|||
> [!TIP]
|
||||
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
|
||||
|
||||
> [!NOTE]
|
||||
> The `gateway` profile only serves the webhook handlers (including Pico when enabled) and health endpoints on the gateway port, so it does not expose generic REST chat endpoints such as `/chat` or `/a2a`. Launcher mode adds the browser UI plus `/api/pico/token` and a `/pico/ws` proxy on the launcher port, but `/pico/ws` is also available directly on the gateway whenever the Pico channel is enabled.
|
||||
|
||||
```bash
|
||||
# 5. Check logs
|
||||
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ PicoClaw prend en charge la connexion à votre compte WeChat personnel via l'API
|
|||
|
||||
Lancez le flux de connexion interactif par QR code :
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
picoclaw auth weixin
|
||||
```
|
||||
Scannez le QR code affiché avec votre application WeChat mobile. Une fois connecté, le token est sauvegardé dans votre configuration.
|
||||
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ PicoClaw は Tencent iLink 公式 API を使用して WeChat 個人アカウン
|
|||
|
||||
インタラクティブな QR ログインフローを実行します:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
picoclaw auth weixin
|
||||
```
|
||||
WeChat モバイルアプリで表示された QR コードをスキャンしてください。ログイン成功後、トークンが設定ファイルに保存されます。
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
||||
| `zai-coding` | LLM (Z.AI Coding Plan) | [z.ai](https://z.ai/manage-apikey/apikey-list) |
|
||||
| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
| `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) |
|
||||
| `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) |
|
||||
| `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) |
|
||||
| `mimo` | LLM (Xiaomi MiMo direct) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
||||
|
||||
### Model Configuration (model_list)
|
||||
|
||||
|
|
@ -46,6 +48,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
|
|
@ -63,6 +66,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
|
||||
| **Xiaomi MiMo** | `mimo/` | `https://api.xiaomimimo.com/v1` | OpenAI | [Get Key](https://platform.xiaomimimo.com) |
|
||||
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
|
@ -160,6 +164,17 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
|||
}
|
||||
```
|
||||
|
||||
**Z.AI Coding Plan (GLM)**
|
||||
> Z.AI and 智谱 AI are two brands of the same provider. For the Z.AI Coding Plan use the `openai` model key and the api base as follows, rather than the zhipu config
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "openai/glm-4.7",
|
||||
"api_key": "your-z.ai-key"
|
||||
"api_base": "https://api.z.ai/api/coding/paas/v4"
|
||||
}
|
||||
```
|
||||
|
||||
**DeepSeek**
|
||||
|
||||
```json
|
||||
|
|
@ -236,6 +251,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
|||
|
||||
PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
|
||||
|
||||
**Z.AI Coding Plan**
|
||||
|
||||
If the standard Zhipu endpoint (`https://open.bigmodel.cn/api/paas/v4`) returns 429 (code 1113: insufficient balance), try using the Z.AI Coding Plan endpoint instead:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "openai/glm-4.7",
|
||||
"api_key": "your-zhipu-api-key",
|
||||
"api_base": "https://api.z.ai/api/coding/paas/v4"
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The Z.AI Coding Plan endpoint and standard Zhipu endpoint use the same API key format but have separate billing. If you encounter 429 errors with the standard Zhipu endpoint, the Z.AI Coding Plan endpoint may have available balance.
|
||||
|
||||
#### Load Balancing
|
||||
|
||||
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ O PicoClaw suporta conexão com sua conta pessoal do WeChat usando a API oficial
|
|||
|
||||
Execute o fluxo de login interativo por QR code:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
picoclaw auth weixin
|
||||
```
|
||||
Escaneie o QR code exibido com seu aplicativo WeChat mobile. Após o login bem-sucedido, o token é salvo na sua configuração.
|
||||
|
||||
|
|
|
|||
644
docs/security_configuration.md
Normal file
644
docs/security_configuration.md
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
# Security Configuration
|
||||
|
||||
## Overview
|
||||
|
||||
PicoClaw supports separating sensitive data (API keys, tokens, secrets, passwords) from the main configuration by storing them in a `.security.yml` file. This improves security by:
|
||||
|
||||
1. **Separation of concerns**: Configuration settings and secrets are in separate files
|
||||
2. **Easier sharing**: The main config can be shared without exposing sensitive data
|
||||
3. **Better version control**: `.security.yml` should be added to `.gitignore`
|
||||
4. **Flexible deployment**: Different environments can use different security files
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
~/.picoclaw/
|
||||
├── config.json # Main configuration (safe to share)
|
||||
└── .security.yml # Security data (never share)
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The security configuration works through **direct field mapping**, NOT through `ref:` string references. The system automatically loads values from `.security.yml` and applies them to the corresponding fields in `config.json`.
|
||||
|
||||
### Key Points:
|
||||
|
||||
- Values in `.security.yml` are automatically mapped to corresponding fields in the config
|
||||
- The mapping is based on field names and structure, not on reference strings
|
||||
- If a value exists in `.security.yml`, it **overrides** the value in `config.json`
|
||||
- You can omit sensitive fields from `config.json` entirely (recommended)
|
||||
|
||||
## Security Configuration Structure
|
||||
|
||||
### Complete Example: .security.yml
|
||||
|
||||
```yaml
|
||||
# Model API Keys
|
||||
# All models MUST use `api_keys` (plural) array format
|
||||
# Even a single key must be provided as an array with one element
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-proj-your-actual-openai-key-1"
|
||||
- "sk-proj-your-actual-openai-key-2" # Optional: Multiple keys for failover
|
||||
claude-sonnet-4.6:
|
||||
api_keys:
|
||||
- "sk-ant-your-actual-anthropic-key" # Single key in array format
|
||||
|
||||
# Channel Tokens
|
||||
channels:
|
||||
telegram:
|
||||
token: "your-telegram-bot-token"
|
||||
feishu:
|
||||
app_secret: "your-feishu-app-secret"
|
||||
encrypt_key: "your-feishu-encrypt-key"
|
||||
verification_token: "your-feishu-verification-token"
|
||||
discord:
|
||||
token: "your-discord-bot-token"
|
||||
weixin:
|
||||
token: "your-weixin-token"
|
||||
qq:
|
||||
app_secret: "your-qq-app-secret"
|
||||
dingtalk:
|
||||
client_secret: "your-dingtalk-client-secret"
|
||||
slack:
|
||||
bot_token: "your-slack-bot-token"
|
||||
app_token: "your-slack-app-token"
|
||||
matrix:
|
||||
access_token: "your-matrix-access-token"
|
||||
line:
|
||||
channel_secret: "your-line-channel-secret"
|
||||
channel_access_token: "your-line-channel-access-token"
|
||||
onebot:
|
||||
access_token: "your-onebot-access-token"
|
||||
wecom:
|
||||
token: "your-wecom-token"
|
||||
encoding_aes_key: "your-wecom-encoding-aes-key"
|
||||
wecom_app:
|
||||
corp_secret: "your-wecom-app-corp-secret"
|
||||
token: "your-wecom-app-token"
|
||||
encoding_aes_key: "your-wecom-app-encoding-aes-key"
|
||||
wecom_aibot:
|
||||
secret: "your-wecom-aibot-secret"
|
||||
token: "your-wecom-aibot-token"
|
||||
encoding_aes_key: "your-wecom-aibot-encoding-aes-key"
|
||||
pico:
|
||||
token: "your-pico-token"
|
||||
irc:
|
||||
password: "your-irc-password"
|
||||
nickserv_password: "your-irc-nickserv-password"
|
||||
sasl_password: "your-irc-sasl-password"
|
||||
|
||||
# Web Tool API Keys
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSAyour-brave-api-key-1"
|
||||
- "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover
|
||||
tavily:
|
||||
api_keys:
|
||||
- "tvly-your-tavily-api-key" # Single key in array format
|
||||
perplexity:
|
||||
api_keys:
|
||||
- "pplx-your-perplexity-api-key" # Single key in array format
|
||||
glm_search:
|
||||
api_key: "your-glm-search-api-key" # GLMSearch uses single key format (not array)
|
||||
baidu_search:
|
||||
api_key: "your-baidu-search-api-key"
|
||||
|
||||
# Skills Registry Tokens
|
||||
skills:
|
||||
github:
|
||||
token: "your-github-token"
|
||||
clawhub:
|
||||
auth_token: "your-clawhub-auth-token"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Step 1: Create .security.yml
|
||||
|
||||
Create or copy the security file:
|
||||
```bash
|
||||
cp security.example.yml ~/.picoclaw/.security.yml
|
||||
```
|
||||
|
||||
### Step 2: Fill in your actual values
|
||||
|
||||
Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual API keys and tokens.
|
||||
|
||||
### Step 3: Set proper permissions
|
||||
|
||||
```bash
|
||||
chmod 600 ~/.picoclaw/.security.yml
|
||||
```
|
||||
|
||||
### Step 4: Simplify config.json (Recommended)
|
||||
|
||||
You can now remove sensitive fields from `config.json` since they're loaded from `.security.yml`:
|
||||
|
||||
**Before:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"api_key": "sk-your-actual-api-key-here"
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
// api_key is now loaded from .security.yml
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true"
|
||||
// token is now loaded from .security.yml
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Verify
|
||||
|
||||
Restart PicoClaw and verify it loads correctly:
|
||||
```bash
|
||||
picoclaw --version
|
||||
```
|
||||
|
||||
## Field Mapping Rules
|
||||
|
||||
### Models
|
||||
|
||||
**In .security.yml:**
|
||||
```yaml
|
||||
model_list:
|
||||
<model_name>:
|
||||
api_keys:
|
||||
- "key-1"
|
||||
- "key-2"
|
||||
```
|
||||
|
||||
**Mapping:**
|
||||
- Field `api_keys` (array) maps to the model's API keys
|
||||
- The `<model_name>` must match the `model_name` field in `config.json`
|
||||
- Supports indexed names (e.g., "gpt-5.4:0") - the system will also try the base name ("gpt-5.4")
|
||||
|
||||
### Channels
|
||||
|
||||
Each channel maps its fields directly:
|
||||
|
||||
**In .security.yml:**
|
||||
```yaml
|
||||
channels:
|
||||
telegram:
|
||||
token: "value"
|
||||
feishu:
|
||||
app_secret: "value"
|
||||
encrypt_key: "value"
|
||||
verification_token: "value"
|
||||
discord:
|
||||
token: "value"
|
||||
```
|
||||
|
||||
**Mapping:**
|
||||
- `channels.telegram.token` → `config.channels.telegram.token`
|
||||
- `channels.feishu.app_secret` → `config.channels.feishu.app_secret`
|
||||
- etc.
|
||||
|
||||
### Web Tools
|
||||
|
||||
**Brave, Tavily, Perplexity:**
|
||||
```yaml
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "key-1"
|
||||
- "key-2"
|
||||
```
|
||||
- Use `api_keys` (plural) array format
|
||||
|
||||
**GLMSearch:**
|
||||
```yaml
|
||||
web:
|
||||
glm_search:
|
||||
api_key: "single-key-here"
|
||||
```
|
||||
- Use `api_key` (singular) single string format
|
||||
|
||||
**BaiduSearch:**
|
||||
```yaml
|
||||
web:
|
||||
baidu_search:
|
||||
api_key: "your-key"
|
||||
```
|
||||
- Use `api_key` (singular) single string format
|
||||
|
||||
### Skills
|
||||
|
||||
**In .security.yml:**
|
||||
```yaml
|
||||
skills:
|
||||
github:
|
||||
token: "value"
|
||||
clawhub:
|
||||
auth_token: "value"
|
||||
```
|
||||
|
||||
## API Key Formats
|
||||
|
||||
### Models - Single key
|
||||
|
||||
Use array format with one element:
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-your-key"
|
||||
```
|
||||
|
||||
### Models - Multiple keys (Load Balancing & Failover)
|
||||
|
||||
Use array format with multiple elements:
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-your-key-1"
|
||||
- "sk-your-key-2"
|
||||
- "sk-your-key-3"
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- **Load balancing**: Requests are distributed across multiple keys
|
||||
- **Failover**: Automatic switching to another key if one fails
|
||||
- **Rate limit management**: Distribute usage across multiple keys
|
||||
- **High availability**: Reduce downtime during API provider issues
|
||||
|
||||
### Web Tools (Brave/Tavily/Perplexity) - Single key
|
||||
|
||||
```yaml
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSA-your-key"
|
||||
```
|
||||
|
||||
### Web Tools (Brave/Tavily/Perplexity) - Multiple keys
|
||||
|
||||
```yaml
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSA-key-1"
|
||||
- "BSA-key-2"
|
||||
```
|
||||
|
||||
### Web Tool (GLMSearch/BaiduSearch) - Single key only
|
||||
|
||||
```yaml
|
||||
web:
|
||||
glm_search:
|
||||
api_key: "your-glm-key" # Single string (NOT array)
|
||||
baidu_search:
|
||||
api_key: "your-baidu-key" # Single string (NOT array)
|
||||
```
|
||||
|
||||
## Model Name Matching
|
||||
|
||||
The system supports intelligent model name matching in `.security.yml`:
|
||||
|
||||
### Example 1: Exact Match
|
||||
|
||||
**config.json:**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4:0"
|
||||
}
|
||||
```
|
||||
|
||||
**.security.yml (exact match with index):**
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:0:
|
||||
api_keys: ["key-1"]
|
||||
```
|
||||
|
||||
### Example 2: Base Name Match
|
||||
|
||||
**config.json:**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4:0"
|
||||
}
|
||||
```
|
||||
|
||||
**.security.yml (base name without index):**
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys: ["key-1", "key-2"]
|
||||
```
|
||||
|
||||
Both methods work. The base name match allows you to use simpler keys in `.security.yml` even when your config uses indexed model names for load balancing.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The system maintains full backward compatibility:
|
||||
|
||||
1. **Direct values**: You can still use direct values in `config.json` (not recommended for production)
|
||||
2. **Mixed usage**: You can have some fields in `.security.yml` and others in `config.json`
|
||||
3. **Optional security file**: If `.security.yml` doesn't exist, the system will only use values from `config.json`
|
||||
4. **Override behavior**: If a field exists in both files, `.security.yml` value takes precedence
|
||||
|
||||
## Environment Variables
|
||||
|
||||
You can override any security value using environment variables:
|
||||
|
||||
**For models:**
|
||||
```bash
|
||||
export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env"
|
||||
```
|
||||
|
||||
**For channels:**
|
||||
```bash
|
||||
export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env"
|
||||
export PICOCLAW_CHANNELS_FEISHU_APP_SECRET="secret-from-env"
|
||||
```
|
||||
|
||||
**For web tools:**
|
||||
```bash
|
||||
export PICOCLAW_TOOLS_WEB_BRAVE_API_KEY="key-from-env"
|
||||
export PICOCLAW_TOOLS_WEB_BAIDU_API_KEY="baidu-key-from-env"
|
||||
```
|
||||
|
||||
Environment variables have the highest priority and will override both `config.json` and `.security.yml` values.
|
||||
|
||||
The pattern is: `PICOCLAW_<SECTION>_<KEY>_<FIELD>` with underscores separating path segments and converted to uppercase.
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit `.security.yml`** to version control
|
||||
2. **Add to .gitignore**: Ensure `.security.yml` is in your `.gitignore` file
|
||||
3. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml`
|
||||
4. **Use different keys** for different environments (dev, staging, production)
|
||||
5. **Rotate keys regularly** and update `.security.yml`
|
||||
6. **Backup securely**: Encrypt backups containing `.security.yml`
|
||||
7. **Review access**: Ensure only authorized users have read access to the file
|
||||
|
||||
## API
|
||||
|
||||
### loadSecurityConfig
|
||||
|
||||
```go
|
||||
func loadSecurityConfig(securityPath string) (*SecurityConfig, error)
|
||||
```
|
||||
|
||||
Loads the security configuration from `.security.yml`. Returns an empty `SecurityConfig` if the file doesn't exist.
|
||||
|
||||
### saveSecurityConfig
|
||||
|
||||
```go
|
||||
func saveSecurityConfig(securityPath string, sec *SecurityConfig) error
|
||||
```
|
||||
|
||||
Saves the security configuration to `.security.yml` with `0o600` permissions.
|
||||
|
||||
### applySecurityConfig
|
||||
|
||||
```go
|
||||
func applySecurityConfig(cfg *Config, sec *SecurityConfig) error
|
||||
```
|
||||
|
||||
Applies security configuration to the main config by copying values from `.security.yml` to the corresponding fields in the config.
|
||||
|
||||
### securityPath
|
||||
|
||||
```go
|
||||
func securityPath(configPath string) string
|
||||
```
|
||||
|
||||
Returns the path to `.security.yml` relative to the config file.
|
||||
|
||||
## Example: Complete Configuration
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/picoclaw-workspace",
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_base": "https://api.anthropic.com/v1"
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .security.yml
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-proj-actual-openai-key-1"
|
||||
- "sk-proj-actual-openai-key-2"
|
||||
claude-sonnet-4.6:
|
||||
api_keys:
|
||||
- "sk-ant-actual-anthropic-key"
|
||||
|
||||
channels:
|
||||
telegram:
|
||||
token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSAactualbravekey-1"
|
||||
- "BSAactualbravekey-2"
|
||||
tavily:
|
||||
api_keys:
|
||||
- "tvly-your-tavily-key"
|
||||
glm_search:
|
||||
api_key: "your-glm-key"
|
||||
baidu_search:
|
||||
api_key: "your-baidu-key"
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the security configuration tests:
|
||||
|
||||
```bash
|
||||
go test ./pkg/config -run TestSecurityConfig
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: "failed to load security config"
|
||||
|
||||
- Verify `.security.yml` exists in the same directory as `config.json`
|
||||
- Check the YAML syntax is valid (use a YAML validator)
|
||||
- Ensure file permissions allow reading
|
||||
|
||||
### Error: "model security entry not found"
|
||||
|
||||
- Ensure the model name in `config.json` matches exactly in `.security.yml`
|
||||
- Check that the `model_list` section exists in `.security.yml`
|
||||
- For models with indexed names (e.g., "gpt-5.4:0"), ensure the exact name is used or check the base name without index
|
||||
- Verify the YAML structure is correct (proper indentation)
|
||||
|
||||
### Multiple API Keys Not Working
|
||||
|
||||
- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch/BaiduSearch)
|
||||
- Check that the array format is correct in YAML (proper indentation with dashes)
|
||||
- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
|
||||
- GLMSearch and BaiduSearch MUST use `api_key` (single string format)
|
||||
|
||||
### Load Balancing/Failover Issues
|
||||
|
||||
- Verify all API keys in the `api_keys` array are valid
|
||||
- Check that all keys have the same rate limits and permissions
|
||||
- Monitor logs to see which keys are being used and failing
|
||||
- Ensure the `api_keys` array is properly formatted in YAML
|
||||
|
||||
### Keys Not Being Applied
|
||||
|
||||
- Check that `.security.yml` is in the same directory as `config.json`
|
||||
- Verify the file permissions allow reading (`chmod 600 ~/.picoclaw/.security.yml`)
|
||||
- Ensure the YAML structure matches the expected format
|
||||
- Check for typos in field names (case-sensitive)
|
||||
- Verify the model/channel names match exactly (case-sensitive)
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Step 1: Backup your config
|
||||
|
||||
```bash
|
||||
cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup
|
||||
```
|
||||
|
||||
### Step 2: Create .security.yml
|
||||
|
||||
```bash
|
||||
cp security.example.yml ~/.picoclaw/.security.yml
|
||||
```
|
||||
|
||||
### Step 3: Fill in your API keys
|
||||
|
||||
Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual keys.
|
||||
|
||||
### Step 4: Remove sensitive fields from config.json
|
||||
|
||||
Remove or comment out sensitive fields from `config.json`:
|
||||
- `api_key` fields from `model_list` entries
|
||||
- `token` fields from `channels`
|
||||
- `api_key` fields from `tools.web`
|
||||
- `token`/`auth_token` fields from `tools.skills`
|
||||
|
||||
### Step 5: Set proper permissions
|
||||
|
||||
```bash
|
||||
chmod 600 ~/.picoclaw/.security.yml
|
||||
```
|
||||
|
||||
### Step 6: Test
|
||||
|
||||
```bash
|
||||
picoclaw --version
|
||||
```
|
||||
|
||||
### Step 7: Verify functionality
|
||||
|
||||
Test your models and channels to ensure everything works correctly.
|
||||
|
||||
### Step 8: Clean up (optional)
|
||||
|
||||
If everything works, you can delete the backup:
|
||||
```bash
|
||||
rm ~/.picoclaw/config.json.backup
|
||||
```
|
||||
|
||||
## Advanced: Encrypted API Keys
|
||||
|
||||
PicoClaw supports encrypting API keys in the security file for additional protection.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Set a passphrase via environment variable:
|
||||
```bash
|
||||
export PICOCLAW_CREDENTIAL_PASSPHRASE="your-secure-passphrase"
|
||||
```
|
||||
|
||||
2. When saving config, API keys will be encrypted automatically:
|
||||
```go
|
||||
SaveConfig(path, config)
|
||||
```
|
||||
|
||||
### Encrypted Format
|
||||
|
||||
Encrypted keys are stored as:
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "enc://encrypted-base64-string"
|
||||
```
|
||||
|
||||
The system automatically decrypts keys at runtime when loading the configuration.
|
||||
|
||||
### Benefits
|
||||
|
||||
- Additional layer of security
|
||||
- Keys are encrypted at rest
|
||||
- Passphrase can be managed separately from the config file
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Always backup your passphrase securely
|
||||
- If you lose the passphrase, you'll lose access to encrypted keys
|
||||
- Use a strong, unique passphrase
|
||||
- Never commit the passphrase to version control
|
||||
107
docs/sensitive_data_filtering.md
Normal file
107
docs/sensitive_data_filtering.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Sensitive Data Filtering
|
||||
|
||||
PicoClaw can filter sensitive values (API keys, tokens, secrets, passwords) from tool call results before they are sent to the LLM. This prevents the LLM from seeing its own credentials, which could otherwise leak through tool output or cause confusing behavior.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
When the LLM uses a tool that returns its own credentials (e.g., a tool that echoes the API key being used), those values are automatically replaced with `[FILTERED]` in the message sent to the LLM.
|
||||
|
||||
Sensitive values are collected from [`.security.yml`](./credential_encryption.md) — the centralized storage for all sensitive configuration (API keys, tokens, secrets stored alongside `config.json`). This includes:
|
||||
|
||||
- Model API keys
|
||||
- Channel tokens (Telegram, Discord, Slack, Matrix, etc.)
|
||||
- Web tool API keys (Brave, Tavily, Perplexity, etc.)
|
||||
- Skills tokens (GitHub, ClawHub)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Sensitive data filtering is configured in the `tools` section of `config.json`:
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `filter_sensitive_data` | bool | `true` | Enable/disable filtering. When `false`, no filtering is performed. |
|
||||
| `filter_min_length` | int | `8` | Minimum content length to trigger filtering. Short content is skipped for performance. |
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"filter_sensitive_data": true,
|
||||
"filter_min_length": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variable
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA` | Set to `true` or `false` to override the config value |
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **On startup**: All sensitive values are collected from `.security.yml` using reflection and compiled into a `strings.Replacer` (O(n+m) performance, computed once).
|
||||
|
||||
2. **Per tool result**: Before sending any tool result content to the LLM:
|
||||
- If `filter_sensitive_data` is `false`, content is passed through unchanged
|
||||
- If content length < `filter_min_length`, content is passed through unchanged (fast path)
|
||||
- Otherwise, all sensitive values are replaced with `[FILTERED]`
|
||||
|
||||
3. **Replacement**: Uses `strings.Replacer` for efficient O(n+m) string substitution, where n = content length and m = total sensitive value length.
|
||||
|
||||
---
|
||||
|
||||
## Example
|
||||
|
||||
Given the following `.security.yml`:
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
my-model:
|
||||
api_keys:
|
||||
- sk-secret-key-12345
|
||||
|
||||
channels:
|
||||
telegram:
|
||||
token: "123456:ABC-DEF"
|
||||
```
|
||||
|
||||
And a tool result containing:
|
||||
|
||||
```
|
||||
The model is using API key sk-secret-key-12345 and Telegram bot 123456:ABC-DEF
|
||||
```
|
||||
|
||||
The LLM will receive:
|
||||
|
||||
```
|
||||
The model is using API key [FILTERED] and Telegram bot [FILTERED]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- **Fast path**: Content shorter than `filter_min_length` (default 8) is returned unchanged without any string scanning
|
||||
- **Efficient replacement**: Uses `strings.Replacer` with O(n+m) complexity instead of regex
|
||||
- **Lazy initialization**: The replacement map is built once on first access via `sync.Once`
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Credential exposure prevention**: Without filtering, tools that echo credentials could cause the LLM to see its own API keys, potentially leading to confusion or credential leakage in logs
|
||||
- **Defense in depth**: Filtering complements (but does not replace) credential encryption — both features should be used together
|
||||
- **No false positives**: Only values explicitly stored in `.security.yml` are filtered; the LLM's general knowledge is unaffected
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Credential Encryption](./credential_encryption.md) — encrypting API keys in config
|
||||
- [Tools Configuration](./tools_configuration.md)
|
||||
|
|
@ -26,6 +26,17 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
|
|||
}
|
||||
```
|
||||
|
||||
## Sensitive Data Filtering
|
||||
|
||||
Before tool results are sent to the LLM, PicoClaw can filter sensitive values (API keys, tokens, secrets) from the output. This prevents the LLM from seeing its own credentials.
|
||||
|
||||
See [Sensitive Data Filtering](../sensitive_data_filtering.md) for full documentation.
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `filter_sensitive_data` | bool | `true` | Enable/disable filtering |
|
||||
| `filter_min_length` | int | `8` | Minimum content length to trigger filtering |
|
||||
|
||||
## Web Tools
|
||||
|
||||
Web tools are used for web search and fetching.
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ PicoClaw hỗ trợ kết nối với tài khoản WeChat cá nhân của bạn
|
|||
|
||||
Chạy luồng đăng nhập QR tương tác:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
picoclaw auth weixin
|
||||
```
|
||||
Quét mã QR được in ra bằng ứng dụng WeChat trên điện thoại. Sau khi đăng nhập thành công, token sẽ được lưu vào cấu hình.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
||||
|
||||
> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。
|
||||
> **注意**: 依赖 HTTP 回调的渠道共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。飞书、钉钉、企业微信这类 Socket/Stream 模式渠道不依赖共享 webhook 服务器来接收入站消息。
|
||||
|
||||
### 核心渠道
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
|||
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](../channels/qq/README.zh.md) |
|
||||
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](../channels/dingtalk/README.zh.md) |
|
||||
| **LINE** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](../channels/line/README.zh.md) |
|
||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](../channels/wecom/wecom_bot/README.zh.md) / [App 文档](../channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](../channels/wecom/wecom_aibot/README.zh.md) |
|
||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 官方 AI Bot WebSocket 接入,支持流式回复和媒体消息 | [查看文档](../channels/wecom/README.zh.md) |
|
||||
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) |
|
||||
| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) |
|
||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) |
|
||||
|
|
@ -191,7 +191,7 @@ PicoClaw 通过腾讯 iLink 官方 API 支持连接微信个人号。
|
|||
|
||||
运行交互式扫码登录流程:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
picoclaw auth weixin
|
||||
```
|
||||
用微信手机端扫描打印出的二维码。登录成功后,token 会自动保存到配置文件。
|
||||
|
||||
|
|
@ -492,102 +492,34 @@ picoclaw gateway
|
|||
<details>
|
||||
<summary><b>企业微信 (WeCom)</b></summary>
|
||||
|
||||
PicoClaw 支持三种企业微信集成方式:
|
||||
PicoClaw 现在将企业微信统一为一个基于 WebSocket 的 AI Bot 渠道。
|
||||
它不再需要公网 webhook 回调地址。
|
||||
|
||||
**方式 1: 群机器人 (Bot)** — 设置简单,支持群聊
|
||||
**方式 2: 自建应用 (App)** — 功能更多,支持主动推送,仅私聊
|
||||
**方式 3: 智能机器人 (AI Bot)** — 官方 AI Bot,流式回复,支持群聊和私聊
|
||||
完整配置说明和迁移说明请参考 [企业微信配置指南](../channels/wecom/README.zh.md)。
|
||||
|
||||
详细设置请参考 [企业微信 AI Bot 配置指南](../channels/wecom/wecom_aibot/README.zh.md)。
|
||||
**推荐快速接入**
|
||||
|
||||
**快速设置 — 群机器人:**
|
||||
**1. 认证**
|
||||
|
||||
**1. 创建 Bot**
|
||||
```bash
|
||||
picoclaw auth wecom
|
||||
```
|
||||
|
||||
* 企业微信管理后台 → 群聊 → 添加群机器人
|
||||
* 复制 Webhook URL(格式:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||
该命令会显示二维码,等待你在企业微信里确认,然后把 `bot_id` 和 `secret` 写入 `channels.wecom`。
|
||||
|
||||
**2. 配置**
|
||||
**2. 如需手动配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> WeCom Webhook 挂载在共享 Gateway 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。
|
||||
|
||||
**快速设置 — 自建应用:**
|
||||
|
||||
**1. 创建应用**
|
||||
|
||||
* 企业微信管理后台 → 应用管理 → 创建应用
|
||||
* 复制 **AgentId** 和 **Secret**
|
||||
* 前往"我的企业"页面,复制 **CorpID**
|
||||
|
||||
**2. 配置接收消息**
|
||||
|
||||
* 在应用详情中,点击"接收消息" → "设置 API"
|
||||
* 设置 URL 为 `http://your-server:18790/webhook/wecom-app`
|
||||
* 生成 **Token** 和 **EncodingAESKey**
|
||||
|
||||
**3. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_app": {
|
||||
"enabled": true,
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **注意**: WeCom Webhook 回调挂载在 Gateway 端口(默认 18790)。使用反向代理配置 HTTPS。
|
||||
|
||||
**快速设置 — 智能机器人 (AI Bot):**
|
||||
|
||||
**1. 创建 AI Bot**
|
||||
|
||||
* 企业微信管理后台 → 应用管理 → AI Bot
|
||||
* 在 AI Bot 设置中配置回调 URL:`http://your-server:18790/webhook/wecom-aibot`
|
||||
* 复制 **Token** 并点击"随机生成" **EncodingAESKey**
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_aibot": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-aibot",
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
"allow_from": [],
|
||||
"welcome_message": "你好!有什么可以帮你的?",
|
||||
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
|
||||
"reasoning_channel_id": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -599,7 +531,7 @@ picoclaw gateway
|
|||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **注意**: 企业微信 AI Bot 使用流式拉取协议,无回复超时问题。长任务(>30 秒)会自动切换到 `response_url` 推送投递。
|
||||
> 这个分支中旧的 `wecom_app` 和 `wecom_aibot` 配置已经被统一的 `channels.wecom` 替代。
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -623,6 +623,7 @@ PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设
|
|||
|
||||
| 主题 | 说明 |
|
||||
| ---- | ---- |
|
||||
| [敏感数据过滤](../sensitive_data_filtering.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 |
|
||||
| [Hook 系统](../hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
|
||||
| [Steering](../steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
|
||||
| [SubTurn](../subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
| `mistral` | LLM (Mistral 直连) | [console.mistral.ai](https://console.mistral.ai) |
|
||||
| `longcat` | LLM (Longcat 直连) | [longcat.ai](https://longcat.ai) |
|
||||
| `modelscope` | LLM (ModelScope 直连) | [modelscope.cn](https://modelscope.cn) |
|
||||
| `mimo` | LLM (小米 MiMo 直连) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
||||
|
||||
### 模型配置 (model_list)
|
||||
|
||||
|
|
@ -62,6 +63,7 @@
|
|||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) |
|
||||
| **小米 MiMo** | `mimo/` | `https://api.xiaomimimo.com/v1` | OpenAI | [获取密钥](https://platform.xiaomimimo.com) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
|
|
|
|||
107
docs/zh/sensitive_data_filtering.md
Normal file
107
docs/zh/sensitive_data_filtering.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# 敏感数据过滤
|
||||
|
||||
PicoClaw 可以从工具调用结果中过滤敏感值(API 密钥、令牌、密码等),然后再发送给 LLM。这可以防止 LLM 看到自己的凭据,避免通过工具输出泄露或产生混淆行为。
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
当 LLM 使用的工具返回其自身的凭据时(例如,一个回显正在使用的 API 密钥的工具),这些值会自动替换为 `[FILTERED]` 再发送给 LLM。
|
||||
|
||||
敏感值从 `.security.yml` 中收集 —— 这是所有敏感配置的集中存储,包括:
|
||||
|
||||
- 模型 API 密钥
|
||||
- 频道令牌(Telegram、Discord、Slack、Matrix 等)
|
||||
- Web 工具 API 密钥(Brave、Tavily、Perplexity 等)
|
||||
- 技能令牌(GitHub、ClawHub)
|
||||
|
||||
---
|
||||
|
||||
## 配置
|
||||
|
||||
敏感数据过滤在 `config.json` 的 `tools` 部分配置:
|
||||
|
||||
| 配置 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `filter_sensitive_data` | bool | `true` | 启用/禁用过滤。为 `false` 时,不进行任何过滤。 |
|
||||
| `filter_min_length` | int | `8` | 触发过滤的最小内容长度。短内容会被跳过以提高性能。 |
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"filter_sensitive_data": true,
|
||||
"filter_min_length": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA` | 设置为 `true` 或 `false` 以覆盖配置值 |
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **启动时**:使用反射从 `.security.yml` 中收集所有敏感值,并编译成 `strings.Replacer`(O(n+m) 性能,仅计算一次)。
|
||||
|
||||
2. **每个工具结果**:在将任何工具结果发送给 LLM 之前:
|
||||
- 如果 `filter_sensitive_data` 为 `false`,内容原样传递
|
||||
- 如果内容长度 < `filter_min_length`,内容原样传递(快速路径)
|
||||
- 否则,所有敏感值都会被替换为 `[FILTERED]`
|
||||
|
||||
3. **替换**:使用 `strings.Replacer` 进行高效的 O(n+m) 字符串替换,其中 n = 内容长度,m = 敏感值总长度。
|
||||
|
||||
---
|
||||
|
||||
## 示例
|
||||
|
||||
给定以下 `.security.yml`:
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
my-model:
|
||||
api_keys:
|
||||
- sk-secret-key-12345
|
||||
|
||||
channels:
|
||||
telegram:
|
||||
token: "123456:ABC-DEF"
|
||||
```
|
||||
|
||||
以及包含以下内容的工具结果:
|
||||
|
||||
```
|
||||
The model is using API key sk-secret-key-12345 and Telegram bot 123456:ABC-DEF
|
||||
```
|
||||
|
||||
LLM 将收到:
|
||||
|
||||
```
|
||||
The model is using API key [FILTERED] and Telegram bot [FILTERED]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能
|
||||
|
||||
- **快速路径**:短于 `filter_min_length`(默认 8)的内容会直接返回,不进行任何字符串扫描
|
||||
- **高效替换**:使用 `strings.Replacer`,复杂度为 O(n+m),而非正则表达式
|
||||
- **延迟初始化**:替换映射通过 `sync.Once` 在首次访问时构建一次
|
||||
|
||||
---
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
- **凭据泄露防护**:如果没有过滤,返回凭据的工具可能导致 LLM 看到自己的 API 密钥,可能导致日志中泄露凭据或产生混淆
|
||||
- **纵深防御**:过滤是对凭据加密的补充(而非替代)—— 应同时使用这两个功能
|
||||
- **无误报**:只有明确存储在 `.security.yml` 中的值才会被过滤;LLM 的通用知识不受影响
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [凭据加密](../credential_encryption.md) — 配置中 API 密钥的加密
|
||||
- [工具配置](../tools_configuration.md)
|
||||
|
|
@ -28,6 +28,17 @@ PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。
|
|||
}
|
||||
```
|
||||
|
||||
## 敏感数据过滤
|
||||
|
||||
在将工具结果发送给 LLM 之前,PicoClaw 可以从输出中过滤敏感值(API 密钥、令牌、密码)。这可以防止 LLM 看到自己的凭据。
|
||||
|
||||
详细说明请参阅[敏感数据过滤](../sensitive_data_filtering.md)。
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 描述 |
|
||||
|--------|------|--------|------|
|
||||
| `filter_sensitive_data` | bool | `true` | 启用/禁用过滤 |
|
||||
| `filter_min_length` | int | `8` | 触发过滤的最小内容长度 |
|
||||
|
||||
## Web 工具
|
||||
|
||||
Web 工具用于网页搜索和抓取。
|
||||
|
|
|
|||
24
go.mod
24
go.mod
|
|
@ -7,8 +7,12 @@ require (
|
|||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/ergochat/irc-go v0.6.0
|
||||
github.com/ergochat/readline v0.1.3
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
|
|
@ -28,6 +32,7 @@ require (
|
|||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tencent-connect/botgo v0.2.1
|
||||
go.mau.fi/util v0.9.7
|
||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.41.0
|
||||
|
|
@ -36,10 +41,24 @@ require (
|
|||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.26.4
|
||||
modernc.org/sqlite v1.46.1
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/beeper/argo-go v1.1.2 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
|
|
@ -51,6 +70,7 @@ require (
|
|||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
|
|
@ -61,13 +81,11 @@ require (
|
|||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||
go.mau.fi/libsignal v0.2.1 // indirect
|
||||
go.mau.fi/util v0.9.7 // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -96,5 +114,5 @@ require (
|
|||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/sys v0.42.0
|
||||
)
|
||||
|
|
|
|||
34
go.sum
34
go.sum
|
|
@ -17,6 +17,38 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
|||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 h1:x0eGAWpd1B5I/vMtrB4Q4Zuc3CXWI8wjHfPPqBSrKmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2/go.mod h1:V9oTWSDC2MtS1DR71hbNET/bZ8psQp022amEBe1grJc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
|
|
@ -38,6 +70,8 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p
|
|||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type ContextBuilder struct {
|
|||
memory *MemoryStore
|
||||
toolDiscoveryBM25 bool
|
||||
toolDiscoveryRegex bool
|
||||
splitOnMarker bool
|
||||
|
||||
// Cache for system prompt to avoid rebuilding on every call.
|
||||
// This fixes issue #607: repeated reprocessing of the entire context.
|
||||
|
|
@ -52,6 +53,11 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil
|
|||
return cb
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder {
|
||||
cb.splitOnMarker = enabled
|
||||
return cb
|
||||
}
|
||||
|
||||
func getGlobalConfigDir() string {
|
||||
if home := os.Getenv(config.EnvHome); home != "" {
|
||||
return home
|
||||
|
|
@ -157,6 +163,14 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
|
|||
parts = append(parts, "# Memory\n\n"+memoryContext)
|
||||
}
|
||||
|
||||
// Multi-Message Sending (if enabled)
|
||||
if cb.splitOnMarker {
|
||||
parts = append(parts, `# MULTI-MESSAGE OUTPUT
|
||||
You MUST frequently use <|[SPLIT]|> to break your responses into multiple short messages. NEVER output a single long wall of text. Actively split distinct concepts or parts. Example: Message part 1<|[SPLIT]|>Message part 2<|[SPLIT]|>Message part 3
|
||||
|
||||
Each part separated by the marker will be sent as an independent message.`)
|
||||
}
|
||||
|
||||
// Join with "---" separator
|
||||
return strings.Join(parts, "\n\n---\n\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,10 +103,12 @@ func NewAgentInstance(
|
|||
sessions := initSessionStore(sessionsDir)
|
||||
|
||||
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
|
||||
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
|
||||
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
||||
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
|
||||
)
|
||||
contextBuilder := NewContextBuilder(workspace).
|
||||
WithToolDiscovery(
|
||||
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
||||
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
|
||||
).
|
||||
WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker)
|
||||
|
||||
agentID := routing.DefaultAgentID
|
||||
agentName := ""
|
||||
|
|
|
|||
|
|
@ -236,8 +236,9 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
|||
t.Fatal("exec tool not registered")
|
||||
}
|
||||
execResult := execTool.Execute(context.Background(), map[string]any{
|
||||
"command": "cat " + filepath.Base(mediaPath),
|
||||
"working_dir": mediaDir,
|
||||
"action": "run",
|
||||
"command": "cat " + filepath.Base(mediaPath),
|
||||
"cwd": mediaDir,
|
||||
})
|
||||
if execResult.IsError {
|
||||
t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM)
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ type processOptions struct {
|
|||
DefaultResponse string // Response when LLM returns empty
|
||||
EnableSummary bool // Whether to trigger summarization
|
||||
SendResponse bool // Whether to send response via bus
|
||||
SuppressToolFeedback bool // Whether to suppress inline tool feedback messages
|
||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
||||
SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue)
|
||||
}
|
||||
|
|
@ -96,14 +97,15 @@ type continuationTarget struct {
|
|||
}
|
||||
|
||||
const (
|
||||
defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit."
|
||||
toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps."
|
||||
sessionKeyAgentPrefix = "agent:"
|
||||
metadataKeyAccountID = "account_id"
|
||||
metadataKeyGuildID = "guild_id"
|
||||
metadataKeyTeamID = "team_id"
|
||||
metadataKeyParentPeerKind = "parent_peer_kind"
|
||||
metadataKeyParentPeerID = "parent_peer_id"
|
||||
defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit."
|
||||
toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps."
|
||||
handledToolResponseSummary = "Requested output delivered via tool attachment."
|
||||
sessionKeyAgentPrefix = "agent:"
|
||||
metadataKeyAccountID = "account_id"
|
||||
metadataKeyGuildID = "guild_id"
|
||||
metadataKeyTeamID = "team_id"
|
||||
metadataKeyParentPeerKind = "parent_peer_kind"
|
||||
metadataKeyParentPeerID = "parent_peer_id"
|
||||
)
|
||||
|
||||
func NewAgentLoop(
|
||||
|
|
@ -1030,13 +1032,13 @@ func (al *AgentLoop) GetConfig() *config.Config {
|
|||
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||
al.mediaStore = s
|
||||
|
||||
// Propagate store to send_file tools in all agents.
|
||||
// Propagate store to all registered tools that can emit media.
|
||||
registry := al.GetRegistry()
|
||||
registry.ForEachTool("send_file", func(t tools.Tool) {
|
||||
if sf, ok := t.(*tools.SendFileTool); ok {
|
||||
sf.SetMediaStore(s)
|
||||
for _, agentID := range registry.ListAgentIDs() {
|
||||
if agent, ok := registry.GetAgent(agentID); ok {
|
||||
agent.Tools.SetMediaStore(s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SetTranscriber injects a voice transcriber for agent-level audio transcription.
|
||||
|
|
@ -1241,14 +1243,15 @@ func (al *AgentLoop) ProcessHeartbeat(
|
|||
return "", fmt.Errorf("no default agent for heartbeat")
|
||||
}
|
||||
return al.runAgentLoop(ctx, agent, processOptions{
|
||||
SessionKey: "heartbeat",
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
UserMessage: content,
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
NoHistory: true, // Don't load session history for heartbeat
|
||||
SessionKey: "heartbeat",
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
UserMessage: content,
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
SuppressToolFeedback: true,
|
||||
NoHistory: true, // Don't load session history for heartbeat
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1733,7 +1736,8 @@ turnLoop:
|
|||
select {
|
||||
case result, ok := <-ts.pendingResults:
|
||||
if ok && result != nil && result.ForLLM != "" {
|
||||
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", result.ForLLM)}
|
||||
content := al.cfg.FilterSensitiveData(result.ForLLM)
|
||||
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
|
||||
pendingMessages = append(pendingMessages, msg)
|
||||
}
|
||||
default:
|
||||
|
|
@ -1945,6 +1949,7 @@ turnLoop:
|
|||
|
||||
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
|
||||
strings.Contains(errMsg, "context window") ||
|
||||
strings.Contains(errMsg, "context_window") ||
|
||||
strings.Contains(errMsg, "maximum context length") ||
|
||||
strings.Contains(errMsg, "token limit") ||
|
||||
strings.Contains(errMsg, "too many tokens") ||
|
||||
|
|
@ -2091,9 +2096,13 @@ turnLoop:
|
|||
}
|
||||
}
|
||||
|
||||
reasoningContent := response.Reasoning
|
||||
if reasoningContent == "" {
|
||||
reasoningContent = response.ReasoningContent
|
||||
}
|
||||
go al.handleReasoning(
|
||||
turnCtx,
|
||||
response.Reasoning,
|
||||
reasoningContent,
|
||||
ts.channel,
|
||||
al.targetReasoningChannelID(ts.channel),
|
||||
)
|
||||
|
|
@ -2160,6 +2169,7 @@ turnLoop:
|
|||
"iteration": iteration,
|
||||
})
|
||||
|
||||
allResponsesHandled := len(normalizedToolCalls) > 0
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: response.Content,
|
||||
|
|
@ -2216,6 +2226,7 @@ turnLoop:
|
|||
toolArgs = toolReq.Arguments
|
||||
}
|
||||
case HookActionDenyTool:
|
||||
allResponsesHandled = false
|
||||
denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason)
|
||||
al.emitEvent(
|
||||
EventKindToolExecSkipped,
|
||||
|
|
@ -2255,6 +2266,7 @@ turnLoop:
|
|||
ChatID: ts.chatID,
|
||||
})
|
||||
if !approval.Approved {
|
||||
allResponsesHandled = false
|
||||
denyContent := hookDeniedToolContent("Tool execution denied by approval hook", approval.Reason)
|
||||
al.emitEvent(
|
||||
EventKindToolExecSkipped,
|
||||
|
|
@ -2296,7 +2308,9 @@ turnLoop:
|
|||
)
|
||||
|
||||
// Send tool feedback to chat channel if enabled (from HEAD)
|
||||
if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && ts.channel != "" {
|
||||
if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() &&
|
||||
ts.channel != "" &&
|
||||
!ts.opts.SuppressToolFeedback {
|
||||
feedbackPreview := utils.Truncate(
|
||||
string(argsJSON),
|
||||
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
|
||||
|
|
@ -2328,14 +2342,14 @@ turnLoop:
|
|||
}
|
||||
|
||||
// Determine content for the agent loop (ForLLM or error).
|
||||
content := result.ForLLM
|
||||
if content == "" && result.Err != nil {
|
||||
content = result.Err.Error()
|
||||
}
|
||||
content := result.ContentForLLM()
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Filter sensitive data before publishing
|
||||
content = al.cfg.FilterSensitiveData(content)
|
||||
|
||||
logger.InfoCF("agent", "Async tool completed, publishing result",
|
||||
map[string]any{
|
||||
"tool": asyncToolName,
|
||||
|
|
@ -2412,6 +2426,50 @@ turnLoop:
|
|||
if toolResult == nil {
|
||||
toolResult = tools.ErrorResult("hook returned nil tool result")
|
||||
}
|
||||
if len(toolResult.Media) > 0 && toolResult.ResponseHandled {
|
||||
parts := make([]bus.MediaPart, 0, len(toolResult.Media))
|
||||
for _, ref := range toolResult.Media {
|
||||
part := bus.MediaPart{Ref: ref}
|
||||
if al.mediaStore != nil {
|
||||
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
|
||||
part.Filename = meta.Filename
|
||||
part.ContentType = meta.ContentType
|
||||
part.Type = inferMediaType(meta.Filename, meta.ContentType)
|
||||
}
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
outboundMedia := bus.OutboundMediaMessage{
|
||||
Channel: ts.channel,
|
||||
ChatID: ts.chatID,
|
||||
Parts: parts,
|
||||
}
|
||||
if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) {
|
||||
if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil {
|
||||
logger.WarnCF("agent", "Failed to deliver handled tool media",
|
||||
map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"tool": toolName,
|
||||
"channel": ts.channel,
|
||||
"chat_id": ts.chatID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
toolResult = tools.ErrorResult(fmt.Sprintf("failed to deliver attachment: %v", err)).WithError(err)
|
||||
}
|
||||
} else if al.bus != nil {
|
||||
al.bus.PublishOutboundMedia(ctx, outboundMedia)
|
||||
// Queuing media is only best-effort; it has not been delivered yet.
|
||||
toolResult.ResponseHandled = false
|
||||
}
|
||||
}
|
||||
|
||||
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
|
||||
toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media)
|
||||
}
|
||||
|
||||
if !toolResult.ResponseHandled {
|
||||
allResponsesHandled = false
|
||||
}
|
||||
|
||||
if !toolResult.Silent && toolResult.ForUser != "" && ts.opts.SendResponse {
|
||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||
|
|
@ -2426,29 +2484,11 @@ turnLoop:
|
|||
})
|
||||
}
|
||||
|
||||
if len(toolResult.Media) > 0 {
|
||||
parts := make([]bus.MediaPart, 0, len(toolResult.Media))
|
||||
for _, ref := range toolResult.Media {
|
||||
part := bus.MediaPart{Ref: ref}
|
||||
if al.mediaStore != nil {
|
||||
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
|
||||
part.Filename = meta.Filename
|
||||
part.ContentType = meta.ContentType
|
||||
part.Type = inferMediaType(meta.Filename, meta.ContentType)
|
||||
}
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{
|
||||
Channel: ts.channel,
|
||||
ChatID: ts.chatID,
|
||||
Parts: parts,
|
||||
})
|
||||
}
|
||||
contentForLLM := toolResult.ContentForLLM()
|
||||
|
||||
contentForLLM := toolResult.ForLLM
|
||||
if contentForLLM == "" && toolResult.Err != nil {
|
||||
contentForLLM = toolResult.Err.Error()
|
||||
// Filter sensitive data (API keys, tokens, secrets) before sending to LLM
|
||||
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
|
||||
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
||||
}
|
||||
|
||||
toolResultMsg := providers.Message{
|
||||
|
|
@ -2528,7 +2568,8 @@ turnLoop:
|
|||
select {
|
||||
case result, ok := <-ts.pendingResults:
|
||||
if ok && result != nil && result.ForLLM != "" {
|
||||
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", result.ForLLM)}
|
||||
content := al.cfg.FilterSensitiveData(result.ForLLM)
|
||||
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
|
||||
messages = append(messages, msg)
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
||||
}
|
||||
|
|
@ -2538,6 +2579,70 @@ turnLoop:
|
|||
}
|
||||
}
|
||||
|
||||
if allResponsesHandled {
|
||||
if len(pendingMessages) > 0 {
|
||||
logger.InfoCF("agent", "Pending steering exists after handled tool delivery; continuing turn before finalizing",
|
||||
map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"steering_count": len(pendingMessages),
|
||||
"session_key": ts.sessionKey,
|
||||
})
|
||||
finalContent = ""
|
||||
goto turnLoop
|
||||
}
|
||||
|
||||
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
|
||||
logger.InfoCF("agent", "Steering arrived after handled tool delivery; continuing turn before finalizing",
|
||||
map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"steering_count": len(steerMsgs),
|
||||
"session_key": ts.sessionKey,
|
||||
})
|
||||
pendingMessages = append(pendingMessages, steerMsgs...)
|
||||
finalContent = ""
|
||||
goto turnLoop
|
||||
}
|
||||
|
||||
summaryMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: handledToolResponseSummary,
|
||||
}
|
||||
|
||||
if !ts.opts.NoHistory {
|
||||
ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content)
|
||||
ts.recordPersistedMessage(summaryMsg)
|
||||
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {
|
||||
turnStatus = TurnEndStatusError
|
||||
al.emitEvent(
|
||||
EventKindError,
|
||||
ts.eventMeta("runTurn", "turn.error"),
|
||||
ErrorPayload{
|
||||
Stage: "session_save",
|
||||
Message: err.Error(),
|
||||
},
|
||||
)
|
||||
return turnResult{}, err
|
||||
}
|
||||
}
|
||||
if ts.opts.EnableSummary {
|
||||
al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope)
|
||||
}
|
||||
|
||||
ts.setPhase(TurnPhaseCompleted)
|
||||
ts.setFinalContent("")
|
||||
logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM",
|
||||
map[string]any{
|
||||
"agent_id": ts.agent.ID,
|
||||
"iteration": iteration,
|
||||
"tool_count": len(normalizedToolCalls),
|
||||
})
|
||||
return turnResult{
|
||||
finalContent: "",
|
||||
status: turnStatus,
|
||||
followUps: append([]bus.InboundMessage(nil), ts.followUps...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
ts.agent.Tools.TickTTL()
|
||||
logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{
|
||||
"agent_id": ts.agent.ID, "iteration": iteration,
|
||||
|
|
@ -3145,6 +3250,97 @@ func (al *AgentLoop) handleCommand(
|
|||
}
|
||||
}
|
||||
|
||||
func activeSkillNames(agent *AgentInstance, opts processOptions) []string {
|
||||
if agent == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
combined := make([]string, 0, len(agent.SkillsFilter)+len(opts.ForcedSkills))
|
||||
combined = append(combined, agent.SkillsFilter...)
|
||||
combined = append(combined, opts.ForcedSkills...)
|
||||
if len(combined) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var resolved []string
|
||||
seen := make(map[string]struct{}, len(combined))
|
||||
for _, name := range combined {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if agent.ContextBuilder != nil {
|
||||
if canonical, ok := agent.ContextBuilder.ResolveSkillName(name); ok {
|
||||
name = canonical
|
||||
}
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
resolved = append(resolved, name)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (al *AgentLoop) applyExplicitSkillCommand(
|
||||
raw string,
|
||||
agent *AgentInstance,
|
||||
opts *processOptions,
|
||||
) (matched bool, handled bool, reply string) {
|
||||
cmdName, ok := commands.CommandName(raw)
|
||||
if !ok || cmdName != "use" {
|
||||
return false, false, ""
|
||||
}
|
||||
|
||||
if agent == nil || agent.ContextBuilder == nil {
|
||||
return true, true, commandsUnavailableSkillMessage()
|
||||
}
|
||||
|
||||
parts := strings.Fields(strings.TrimSpace(raw))
|
||||
if len(parts) < 2 {
|
||||
return true, true, buildUseCommandHelp(agent)
|
||||
}
|
||||
|
||||
arg := strings.TrimSpace(parts[1])
|
||||
if strings.EqualFold(arg, "clear") || strings.EqualFold(arg, "off") {
|
||||
if opts != nil {
|
||||
al.clearPendingSkills(opts.SessionKey)
|
||||
}
|
||||
return true, true, "Cleared pending skill override."
|
||||
}
|
||||
|
||||
skillName, ok := agent.ContextBuilder.ResolveSkillName(arg)
|
||||
if !ok {
|
||||
return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", arg)
|
||||
}
|
||||
|
||||
if len(parts) < 3 {
|
||||
if opts == nil || strings.TrimSpace(opts.SessionKey) == "" {
|
||||
return true, true, commandsUnavailableSkillMessage()
|
||||
}
|
||||
al.setPendingSkills(opts.SessionKey, []string{skillName})
|
||||
return true, true, fmt.Sprintf(
|
||||
"Skill %q is armed for your next message. Send your next prompt normally, or use /use clear to cancel.",
|
||||
skillName,
|
||||
)
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(strings.Join(parts[2:], " "))
|
||||
if message == "" {
|
||||
return true, true, buildUseCommandHelp(agent)
|
||||
}
|
||||
|
||||
if opts != nil {
|
||||
opts.ForcedSkills = append(opts.ForcedSkills, skillName)
|
||||
opts.UserMessage = message
|
||||
}
|
||||
|
||||
return true, false, ""
|
||||
}
|
||||
|
||||
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime {
|
||||
registry := al.GetRegistry()
|
||||
cfg := al.GetConfig()
|
||||
|
|
@ -3185,6 +3381,9 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
|
|||
return al.reloadFunc()
|
||||
}
|
||||
if agent != nil {
|
||||
if agent.ContextBuilder != nil {
|
||||
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
|
||||
}
|
||||
rt.GetModelInfo = func() (string, string) {
|
||||
return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider)
|
||||
}
|
||||
|
|
@ -3237,79 +3436,6 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
|
|||
return rt
|
||||
}
|
||||
|
||||
func activeSkillNames(agent *AgentInstance, opts processOptions) []string {
|
||||
var out []string
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
appendNames := func(names []string) {
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
|
||||
if agent != nil {
|
||||
appendNames(agent.SkillsFilter)
|
||||
}
|
||||
appendNames(opts.ForcedSkills)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (al *AgentLoop) applyExplicitSkillCommand(
|
||||
raw string,
|
||||
agent *AgentInstance,
|
||||
opts *processOptions,
|
||||
) (matched bool, handled bool, reply string) {
|
||||
commandName, ok := commands.CommandName(raw)
|
||||
if !ok || commandName != "use" {
|
||||
return false, false, ""
|
||||
}
|
||||
|
||||
if agent == nil || agent.ContextBuilder == nil {
|
||||
return true, true, commandsUnavailableSkillMessage()
|
||||
}
|
||||
|
||||
fields := strings.Fields(strings.TrimSpace(raw))
|
||||
if len(fields) < 2 {
|
||||
return true, true, buildUseCommandHelp(agent)
|
||||
}
|
||||
|
||||
if strings.EqualFold(fields[1], "clear") || strings.EqualFold(fields[1], "off") {
|
||||
al.clearPendingSkills(opts.SessionKey)
|
||||
return true, true, "Cleared pending skill override."
|
||||
}
|
||||
|
||||
canonicalSkill, ok := agent.ContextBuilder.ResolveSkillName(fields[1])
|
||||
if !ok {
|
||||
return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", fields[1])
|
||||
}
|
||||
|
||||
if len(fields) == 2 {
|
||||
al.setPendingSkills(opts.SessionKey, []string{canonicalSkill})
|
||||
return true, true, fmt.Sprintf(
|
||||
"Skill %q is armed for your next message.\nSend your next request normally, or use /use clear to cancel.",
|
||||
canonicalSkill,
|
||||
)
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(strings.Join(fields[2:], " "))
|
||||
if message == "" {
|
||||
return true, true, buildUseCommandHelp(agent)
|
||||
}
|
||||
|
||||
opts.UserMessage = message
|
||||
opts.ForcedSkills = append(opts.ForcedSkills, canonicalSkill)
|
||||
return true, false, ""
|
||||
}
|
||||
|
||||
func commandsUnavailableSkillMessage() string {
|
||||
return "Skill selection is unavailable in the current context."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,24 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
|||
return result
|
||||
}
|
||||
|
||||
func buildArtifactTags(store media.MediaStore, refs []string) []string {
|
||||
if store == nil || len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := make([]string, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
localPath, meta, err := store.ResolveWithMeta(ref)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mime := detectMIME(localPath, meta)
|
||||
tags = append(tags, buildPathTag(mime, localPath))
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// detectMIME determines the MIME type from metadata or magic-bytes detection.
|
||||
// Returns empty string if detection fails.
|
||||
func detectMIME(localPath string, meta media.MediaMeta) string {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package agent
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
|
@ -33,6 +34,41 @@ func (f *fakeChannel) IsAllowed(string) bool {
|
|||
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
|
||||
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
|
||||
|
||||
type fakeMediaChannel struct {
|
||||
fakeChannel
|
||||
sentMedia []bus.OutboundMediaMessage
|
||||
}
|
||||
|
||||
func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||
f.sentMedia = append(f.sentMedia, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newStartedTestChannelManager(
|
||||
t *testing.T,
|
||||
msgBus *bus.MessageBus,
|
||||
store media.MediaStore,
|
||||
name string,
|
||||
ch channels.Channel,
|
||||
) *channels.Manager {
|
||||
t.Helper()
|
||||
|
||||
cm, err := channels.NewManager(&config.Config{}, msgBus, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager() error = %v", err)
|
||||
}
|
||||
cm.RegisterChannel(name, ch)
|
||||
if err := cm.StartAll(context.Background()); err != nil {
|
||||
t.Fatalf("StartAll() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cm.StopAll(context.Background()); err != nil {
|
||||
t.Fatalf("StopAll() error = %v", err)
|
||||
}
|
||||
})
|
||||
return cm
|
||||
}
|
||||
|
||||
type recordingProvider struct {
|
||||
lastMessages []providers.Message
|
||||
}
|
||||
|
|
@ -289,6 +325,86 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestApplyExplicitSkillCommand_ArmsSkillForNextMessage(t *testing.T) {
|
||||
al, cfg, _, _, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(skill) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news", "SKILL.md"),
|
||||
[]byte("# Finance News\n\nUse web tools for current finance updates.\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile(SKILL.md) error = %v", err)
|
||||
}
|
||||
|
||||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
if agent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
opts := &processOptions{SessionKey: "agent:main:test"}
|
||||
matched, handled, reply := al.applyExplicitSkillCommand("/use finance-news", agent, opts)
|
||||
if !matched {
|
||||
t.Fatal("expected /use command to match")
|
||||
}
|
||||
if !handled {
|
||||
t.Fatal("expected /use without inline message to be handled immediately")
|
||||
}
|
||||
if !strings.Contains(reply, `Skill "finance-news" is armed for your next message`) {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
|
||||
pending := al.takePendingSkills(opts.SessionKey)
|
||||
if len(pending) != 1 || pending[0] != "finance-news" {
|
||||
t.Fatalf("pending skills = %#v, want [finance-news]", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExplicitSkillCommand_InlineMessageMutatesOptions(t *testing.T) {
|
||||
al, cfg, _, _, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(skill) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news", "SKILL.md"),
|
||||
[]byte("# Finance News\n\nUse web tools for current finance updates.\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile(SKILL.md) error = %v", err)
|
||||
}
|
||||
|
||||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
if agent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
opts := &processOptions{
|
||||
SessionKey: "agent:main:test",
|
||||
UserMessage: "/use finance-news dammi le ultime news",
|
||||
}
|
||||
matched, handled, reply := al.applyExplicitSkillCommand(opts.UserMessage, agent, opts)
|
||||
if !matched {
|
||||
t.Fatal("expected /use command to match")
|
||||
}
|
||||
if handled {
|
||||
t.Fatal("expected /use with inline message to fall through into normal agent execution")
|
||||
}
|
||||
if reply != "" {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
if opts.UserMessage != "dammi le ultime news" {
|
||||
t.Fatalf("opts.UserMessage = %q, want %q", opts.UserMessage, "dammi le ultime news")
|
||||
}
|
||||
if len(opts.ForcedSkills) != 1 || opts.ForcedSkills[0] != "finance-news" {
|
||||
t.Fatalf("opts.ForcedSkills = %#v, want [finance-news]", opts.ForcedSkills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordLastChannel(t *testing.T) {
|
||||
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
|
@ -455,6 +571,217 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &handledMediaProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
al.SetMediaStore(store)
|
||||
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}}
|
||||
al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel))
|
||||
|
||||
imagePath := filepath.Join(tmpDir, "screen.png")
|
||||
if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(imagePath) error = %v", err)
|
||||
}
|
||||
|
||||
al.RegisterTool(&handledMediaTool{
|
||||
store: store,
|
||||
path: imagePath,
|
||||
})
|
||||
|
||||
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat1",
|
||||
SenderID: "user1",
|
||||
Content: "take a screenshot of the screen and send it to me",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if response != "" {
|
||||
t.Fatalf("expected no final response when media tool already handled delivery, got %q", response)
|
||||
}
|
||||
if provider.calls != 1 {
|
||||
t.Fatalf("expected exactly 1 LLM call, got %d", provider.calls)
|
||||
}
|
||||
if len(provider.toolCounts) != 1 {
|
||||
t.Fatalf("expected tool counts for 1 provider call, got %d", len(provider.toolCounts))
|
||||
}
|
||||
if provider.toolCounts[0] == 0 {
|
||||
t.Fatal("expected tools to be available on the first LLM call")
|
||||
}
|
||||
|
||||
if len(telegramChannel.sentMedia) != 1 {
|
||||
t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia))
|
||||
}
|
||||
if telegramChannel.sentMedia[0].Channel != "telegram" || telegramChannel.sentMedia[0].ChatID != "chat1" {
|
||||
t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0])
|
||||
}
|
||||
if len(telegramChannel.sentMedia[0].Parts) != 1 {
|
||||
t.Fatalf("expected exactly 1 sent media part, got %d", len(telegramChannel.sentMedia[0].Parts))
|
||||
}
|
||||
|
||||
select {
|
||||
case extra := <-msgBus.OutboundMediaChan():
|
||||
t.Fatalf("expected handled media to bypass async queue, got %+v", extra)
|
||||
default:
|
||||
}
|
||||
|
||||
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
route, _, err := al.resolveMessageRoute(bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat1",
|
||||
SenderID: "user1",
|
||||
Content: "take a screenshot of the screen and send it to me",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMessageRoute() error = %v", err)
|
||||
}
|
||||
sessionKey := resolveScopeKey(route, "")
|
||||
history := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||
if len(history) == 0 {
|
||||
t.Fatal("expected session history to be saved")
|
||||
}
|
||||
last := history[len(history)-1]
|
||||
if last.Role != "assistant" || last.Content != "Requested output delivered via tool attachment." {
|
||||
t.Fatalf("expected handled assistant summary in history, got %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &handledMediaWithSteeringProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
al.SetMediaStore(store)
|
||||
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}}
|
||||
al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel))
|
||||
|
||||
imagePath := filepath.Join(tmpDir, "screen-steering.png")
|
||||
if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(imagePath) error = %v", err)
|
||||
}
|
||||
|
||||
al.RegisterTool(&handledMediaWithSteeringTool{
|
||||
store: store,
|
||||
path: imagePath,
|
||||
loop: al,
|
||||
})
|
||||
|
||||
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat1",
|
||||
SenderID: "user1",
|
||||
Content: "take a screenshot of the screen and send it to me",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if response != "Handled the queued steering message." {
|
||||
t.Fatalf("response = %q, want queued steering response", response)
|
||||
}
|
||||
if provider.calls != 2 {
|
||||
t.Fatalf("expected 2 LLM calls after queued steering, got %d", provider.calls)
|
||||
}
|
||||
if len(telegramChannel.sentMedia) != 1 {
|
||||
t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Workspace = tmpDir
|
||||
cfg.Agents.Defaults.ModelName = "test-model"
|
||||
cfg.Agents.Defaults.MaxTokens = 4096
|
||||
cfg.Agents.Defaults.MaxToolIterations = 10
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &artifactThenSendProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
al.SetMediaStore(store)
|
||||
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}}
|
||||
al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel))
|
||||
|
||||
mediaDir := media.TempDir()
|
||||
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll(mediaDir) error = %v", err)
|
||||
}
|
||||
imagePath := filepath.Join(mediaDir, "artifact-screen.png")
|
||||
if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(imagePath) error = %v", err)
|
||||
}
|
||||
|
||||
al.RegisterTool(&mediaArtifactTool{
|
||||
store: store,
|
||||
path: imagePath,
|
||||
})
|
||||
|
||||
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat1",
|
||||
SenderID: "user1",
|
||||
Content: "take a screenshot of the screen and send it to me",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if response != "" {
|
||||
t.Fatalf("expected no final response after send_file handled delivery, got %q", response)
|
||||
}
|
||||
if provider.calls != 2 {
|
||||
t.Fatalf("expected 2 LLM calls (artifact + send_file), got %d", provider.calls)
|
||||
}
|
||||
|
||||
if len(telegramChannel.sentMedia) != 1 {
|
||||
t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia))
|
||||
}
|
||||
if telegramChannel.sentMedia[0].Channel != "telegram" || telegramChannel.sentMedia[0].ChatID != "chat1" {
|
||||
t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0])
|
||||
}
|
||||
if len(telegramChannel.sentMedia[0].Parts) != 1 {
|
||||
t.Fatalf("expected exactly 1 sent media part, got %d", len(telegramChannel.sentMedia[0].Parts))
|
||||
}
|
||||
|
||||
select {
|
||||
case extra := <-msgBus.OutboundMediaChan():
|
||||
t.Fatalf("expected synchronous send_file delivery to bypass async queue, got %+v", extra)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentLoop_GetStartupInfo verifies startup info contains tools
|
||||
func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
|
|
@ -554,6 +881,29 @@ func (m *simpleMockProvider) GetDefaultModel() string {
|
|||
return "mock-model"
|
||||
}
|
||||
|
||||
type reasoningContentProvider struct {
|
||||
response string
|
||||
reasoningContent string
|
||||
}
|
||||
|
||||
func (m *reasoningContentProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
return &providers.LLMResponse{
|
||||
Content: m.response,
|
||||
ReasoningContent: m.reasoningContent,
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *reasoningContentProvider) GetDefaultModel() string {
|
||||
return "reasoning-content-model"
|
||||
}
|
||||
|
||||
type countingMockProvider struct {
|
||||
response string
|
||||
calls int
|
||||
|
|
@ -577,6 +927,132 @@ func (m *countingMockProvider) GetDefaultModel() string {
|
|||
return "counting-mock-model"
|
||||
}
|
||||
|
||||
type handledMediaProvider struct {
|
||||
calls int
|
||||
toolCounts []int
|
||||
}
|
||||
|
||||
func (m *handledMediaProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
m.calls++
|
||||
m.toolCounts = append(m.toolCounts, len(tools))
|
||||
if m.calls == 1 {
|
||||
return &providers.LLMResponse{
|
||||
Content: "Taking the screenshot now.",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_handled_media",
|
||||
Type: "function",
|
||||
Name: "handled_media_tool",
|
||||
Arguments: map[string]any{},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
return &providers.LLMResponse{}, nil
|
||||
}
|
||||
|
||||
func (m *handledMediaProvider) GetDefaultModel() string {
|
||||
return "handled-media-model"
|
||||
}
|
||||
|
||||
type artifactThenSendProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *artifactThenSendProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
m.calls++
|
||||
if m.calls == 1 {
|
||||
return &providers.LLMResponse{
|
||||
Content: "Taking the screenshot now.",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_artifact_media",
|
||||
Type: "function",
|
||||
Name: "media_artifact_tool",
|
||||
Arguments: map[string]any{},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var artifactPath string
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role != "tool" {
|
||||
continue
|
||||
}
|
||||
start := strings.Index(messages[i].Content, "[file:")
|
||||
if start < 0 {
|
||||
continue
|
||||
}
|
||||
rest := messages[i].Content[start+len("[file:"):]
|
||||
end := strings.Index(rest, "]")
|
||||
if end < 0 {
|
||||
continue
|
||||
}
|
||||
artifactPath = rest[:end]
|
||||
break
|
||||
}
|
||||
if artifactPath == "" {
|
||||
return nil, fmt.Errorf("provider did not receive artifact path in tool result")
|
||||
}
|
||||
|
||||
return &providers.LLMResponse{
|
||||
Content: "",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_send_file",
|
||||
Type: "function",
|
||||
Name: "send_file",
|
||||
Arguments: map[string]any{"path": artifactPath},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *artifactThenSendProvider) GetDefaultModel() string {
|
||||
return "artifact-then-send-model"
|
||||
}
|
||||
|
||||
type toolFeedbackProvider struct {
|
||||
filePath string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *toolFeedbackProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
m.calls++
|
||||
if m.calls == 1 {
|
||||
return &providers.LLMResponse{
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_heartbeat_read_file",
|
||||
Type: "function",
|
||||
Name: "read_file",
|
||||
Arguments: map[string]any{"path": m.filePath},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &providers.LLMResponse{
|
||||
Content: "HEARTBEAT_OK",
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *toolFeedbackProvider) GetDefaultModel() string {
|
||||
return "heartbeat-tool-feedback-model"
|
||||
}
|
||||
|
||||
type toolLimitOnlyProvider struct{}
|
||||
|
||||
func (m *toolLimitOnlyProvider) Chat(
|
||||
|
|
@ -613,8 +1089,9 @@ func (m *mockCustomTool) Description() string {
|
|||
|
||||
func (m *mockCustomTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
"additionalProperties": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -622,6 +1099,135 @@ func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tool
|
|||
return tools.SilentResult("Custom tool executed")
|
||||
}
|
||||
|
||||
type handledMediaTool struct {
|
||||
store media.MediaStore
|
||||
path string
|
||||
}
|
||||
|
||||
func (m *handledMediaTool) Name() string { return "handled_media_tool" }
|
||||
func (m *handledMediaTool) Description() string {
|
||||
return "Returns a media attachment and fully handles the user response"
|
||||
}
|
||||
|
||||
func (m *handledMediaTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *handledMediaTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
ref, err := m.store.Store(m.path, media.MediaMeta{
|
||||
Filename: filepath.Base(m.path),
|
||||
ContentType: "image/png",
|
||||
Source: "test:handled_media_tool",
|
||||
}, "test:handled_media")
|
||||
if err != nil {
|
||||
return tools.ErrorResult(err.Error()).WithError(err)
|
||||
}
|
||||
return tools.MediaResult("Attachment delivered by tool.", []string{ref}).WithResponseHandled()
|
||||
}
|
||||
|
||||
type handledMediaWithSteeringProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *handledMediaWithSteeringProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
m.calls++
|
||||
if m.calls == 1 {
|
||||
return &providers.LLMResponse{
|
||||
Content: "Taking the screenshot now.",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_handled_media_steering",
|
||||
Type: "function",
|
||||
Name: "handled_media_with_steering_tool",
|
||||
Arguments: map[string]any{},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.Role == "user" && msg.Content == "what about this instead?" {
|
||||
return &providers.LLMResponse{Content: "Handled the queued steering message."}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("provider did not receive queued steering message")
|
||||
}
|
||||
|
||||
func (m *handledMediaWithSteeringProvider) GetDefaultModel() string {
|
||||
return "handled-media-with-steering-model"
|
||||
}
|
||||
|
||||
type handledMediaWithSteeringTool struct {
|
||||
store media.MediaStore
|
||||
path string
|
||||
loop *AgentLoop
|
||||
}
|
||||
|
||||
func (m *handledMediaWithSteeringTool) Name() string { return "handled_media_with_steering_tool" }
|
||||
func (m *handledMediaWithSteeringTool) Description() string {
|
||||
return "Returns handled media and enqueues a steering message during execution"
|
||||
}
|
||||
|
||||
func (m *handledMediaWithSteeringTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *handledMediaWithSteeringTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
if err := m.loop.Steer(providers.Message{Role: "user", Content: "what about this instead?"}); err != nil {
|
||||
return tools.ErrorResult(err.Error()).WithError(err)
|
||||
}
|
||||
|
||||
ref, err := m.store.Store(m.path, media.MediaMeta{
|
||||
Filename: filepath.Base(m.path),
|
||||
ContentType: "image/png",
|
||||
Source: "test:handled_media_with_steering_tool",
|
||||
}, "test:handled_media_with_steering")
|
||||
if err != nil {
|
||||
return tools.ErrorResult(err.Error()).WithError(err)
|
||||
}
|
||||
return tools.MediaResult("Attachment delivered by tool.", []string{ref}).WithResponseHandled()
|
||||
}
|
||||
|
||||
type mediaArtifactTool struct {
|
||||
store media.MediaStore
|
||||
path string
|
||||
}
|
||||
|
||||
func (m *mediaArtifactTool) Name() string { return "media_artifact_tool" }
|
||||
func (m *mediaArtifactTool) Description() string {
|
||||
return "Returns a media artifact that the agent can forward or save later"
|
||||
}
|
||||
|
||||
func (m *mediaArtifactTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mediaArtifactTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
ref, err := m.store.Store(m.path, media.MediaMeta{
|
||||
Filename: filepath.Base(m.path),
|
||||
ContentType: "image/png",
|
||||
Source: "test:media_artifact_tool",
|
||||
}, "test:media_artifact")
|
||||
if err != nil {
|
||||
return tools.ErrorResult(err.Error()).WithError(err)
|
||||
}
|
||||
return tools.MediaResult("Artifact created.", []string{ref})
|
||||
}
|
||||
|
||||
type toolLimitTestTool struct{}
|
||||
|
||||
func (m *toolLimitTestTool) Name() string {
|
||||
|
|
@ -1471,18 +2077,17 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
|
|||
t.Fatalf("Failed to create channel manager: %v", err)
|
||||
}
|
||||
for name, id := range map[string]string{
|
||||
"whatsapp": "rid-whatsapp",
|
||||
"telegram": "rid-telegram",
|
||||
"feishu": "rid-feishu",
|
||||
"discord": "rid-discord",
|
||||
"maixcam": "rid-maixcam",
|
||||
"qq": "rid-qq",
|
||||
"dingtalk": "rid-dingtalk",
|
||||
"slack": "rid-slack",
|
||||
"line": "rid-line",
|
||||
"onebot": "rid-onebot",
|
||||
"wecom": "rid-wecom",
|
||||
"wecom_app": "rid-wecom-app",
|
||||
"whatsapp": "rid-whatsapp",
|
||||
"telegram": "rid-telegram",
|
||||
"feishu": "rid-feishu",
|
||||
"discord": "rid-discord",
|
||||
"maixcam": "rid-maixcam",
|
||||
"qq": "rid-qq",
|
||||
"dingtalk": "rid-dingtalk",
|
||||
"slack": "rid-slack",
|
||||
"line": "rid-line",
|
||||
"onebot": "rid-onebot",
|
||||
"wecom": "rid-wecom",
|
||||
} {
|
||||
chManager.RegisterChannel(name, &fakeChannel{id: id})
|
||||
}
|
||||
|
|
@ -1502,7 +2107,6 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
|
|||
{channel: "line", wantID: "rid-line"},
|
||||
{channel: "onebot", wantID: "rid-onebot"},
|
||||
{channel: "wecom", wantID: "rid-wecom"},
|
||||
{channel: "wecom_app", wantID: "rid-wecom-app"},
|
||||
{channel: "unknown", wantID: ""},
|
||||
}
|
||||
|
||||
|
|
@ -1688,6 +2292,168 @@ func TestHandleReasoning(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &reasoningContentProvider{
|
||||
response: "final answer",
|
||||
reasoningContent: "thinking trace",
|
||||
}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
chManager, err := channels.NewManager(&config.Config{}, msgBus, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create channel manager: %v", err)
|
||||
}
|
||||
chManager.RegisterChannel("telegram", &fakeChannel{id: "reason-chat"})
|
||||
al.SetChannelManager(chManager)
|
||||
|
||||
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
SenderID: "user1",
|
||||
ChatID: "chat1",
|
||||
Content: "hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if response != "final answer" {
|
||||
t.Fatalf("processMessage() response = %q, want %q", response, "final answer")
|
||||
}
|
||||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
if outbound.Channel != "telegram" {
|
||||
t.Fatalf("reasoning channel = %q, want %q", outbound.Channel, "telegram")
|
||||
}
|
||||
if outbound.ChatID != "reason-chat" {
|
||||
t.Fatalf("reasoning chatID = %q, want %q", outbound.ChatID, "reason-chat")
|
||||
}
|
||||
if outbound.Content != "thinking trace" {
|
||||
t.Fatalf("reasoning content = %q, want %q", outbound.Content, "thinking trace")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("expected reasoning content to be published to reasoning channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")
|
||||
if err := os.WriteFile(heartbeatFile, []byte("heartbeat task"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
ToolFeedback: config.ToolFeedbackConfig{
|
||||
Enabled: true,
|
||||
MaxArgsLength: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
Tools: config.ToolsConfig{
|
||||
ReadFile: config.ReadFileToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &toolFeedbackProvider{filePath: heartbeatFile}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
response, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessHeartbeat() error = %v", err)
|
||||
}
|
||||
if response != "HEARTBEAT_OK" {
|
||||
t.Fatalf("ProcessHeartbeat() response = %q, want %q", response, "HEARTBEAT_OK")
|
||||
}
|
||||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
t.Fatalf("expected no outbound tool feedback during heartbeat, got %+v", outbound)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
heartbeatFile := filepath.Join(tmpDir, "tool-feedback.txt")
|
||||
if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
ToolFeedback: config.ToolFeedbackConfig{
|
||||
Enabled: true,
|
||||
MaxArgsLength: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
Tools: config.ToolsConfig{
|
||||
ReadFile: config.ReadFileToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &toolFeedbackProvider{filePath: heartbeatFile}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
SenderID: "user-1",
|
||||
ChatID: "chat-1",
|
||||
Content: "check tool feedback",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if response != "HEARTBEAT_OK" {
|
||||
t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK")
|
||||
}
|
||||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
if outbound.Channel != "telegram" {
|
||||
t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram")
|
||||
}
|
||||
if outbound.ChatID != "chat-1" {
|
||||
t.Fatalf("tool feedback chatID = %q, want %q", outbound.ChatID, "chat-1")
|
||||
}
|
||||
if !strings.Contains(outbound.Content, "`read_file`") {
|
||||
t.Fatalf("tool feedback content = %q, want read_file preview", outbound.Content)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("expected outbound tool feedback for regular messages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
dir := t.TempDir()
|
||||
|
|
@ -2052,3 +2818,111 @@ func TestFilterClientWebSearch_EmptyInput(t *testing.T) {
|
|||
t.Fatalf("len(result) = %d, want 0", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
type overflowProvider struct {
|
||||
calls int
|
||||
lastMessages []providers.Message
|
||||
chatFunc func(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any) (*providers.LLMResponse, error)
|
||||
}
|
||||
|
||||
func (p *overflowProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.calls++
|
||||
p.lastMessages = append([]providers.Message(nil), messages...)
|
||||
|
||||
if p.chatFunc != nil {
|
||||
return p.chatFunc(ctx, messages, tools, model, opts)
|
||||
}
|
||||
|
||||
if p.calls == 1 {
|
||||
return nil, errors.New("context_window_exceeded")
|
||||
}
|
||||
|
||||
return &providers.LLMResponse{
|
||||
Content: "Recovered from overflow",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *overflowProvider) GetDefaultModel() string {
|
||||
return "test-model"
|
||||
}
|
||||
|
||||
func TestProcessMessage_ContextOverflowRecovery(t *testing.T) {
|
||||
al, cfg, _, _, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
_ = cfg
|
||||
|
||||
provider := &overflowProvider{}
|
||||
al.registry = NewAgentRegistry(al.cfg, provider)
|
||||
|
||||
sessionKey := "agent:main:test-session"
|
||||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: "heavy message"})
|
||||
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "assistant", Content: "response"})
|
||||
}
|
||||
|
||||
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||
Channel: "test",
|
||||
ChatID: "chat1",
|
||||
SenderID: "user1",
|
||||
SessionKey: "test-session",
|
||||
Content: "trigger recovery",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if response != "Recovered from overflow" {
|
||||
t.Fatalf("response = %q, want %q", response, "Recovered from overflow")
|
||||
}
|
||||
|
||||
if provider.calls != 2 {
|
||||
t.Fatalf("expected 2 calls, got %d", provider.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_ContextOverflow_AnthropicStyle(t *testing.T) {
|
||||
al, cfg, _, _, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
_ = cfg
|
||||
|
||||
provider := &overflowProvider{}
|
||||
al.registry = NewAgentRegistry(al.cfg, provider)
|
||||
|
||||
recoveryMsg := "error: status 400: context_window_exceeded"
|
||||
|
||||
provider.chatFunc = func(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
if provider.calls == 1 {
|
||||
return nil, errors.New(recoveryMsg)
|
||||
}
|
||||
return &providers.LLMResponse{Content: "Anthropic recovery success"}, nil
|
||||
}
|
||||
|
||||
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||
Channel: "test",
|
||||
ChatID: "chat1",
|
||||
SenderID: "user1",
|
||||
Content: "hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(response, "Anthropic recovery success") {
|
||||
t.Fatalf("response = %q, want success message", response)
|
||||
}
|
||||
if provider.calls != 2 {
|
||||
t.Fatalf("expected 2 calls for retry, got %d", provider.calls)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1255,8 +1255,7 @@ make test # Full test suite
|
|||
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
|
||||
| `pkg/channels/dingtalk/` | `"dingtalk"` | — |
|
||||
| `pkg/channels/feishu/` | `"feishu"` | — (architecture-specific build tags: `feishu_32.go` / `feishu_64.go`) |
|
||||
| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/wecom/` | `"wecom"` | MediaSender |
|
||||
| `pkg/channels/qq/` | `"qq"` | — |
|
||||
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) |
|
||||
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) |
|
||||
|
|
@ -1371,7 +1370,7 @@ agentLoop.Stop() // Stop Agent
|
|||
|
||||
2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`). Feishu uses the SDK's WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`.
|
||||
|
||||
3. **WeCom has two factories**: `"wecom"` (Bot mode, webhook only) and `"wecom_app"` (App mode, supports MediaSender) are registered separately. Both implement `WebhookHandler` and `HealthChecker`.
|
||||
3. **WeCom is now a single channel**: `"wecom"` is implemented as a WebSocket-based AI Bot channel with route persistence. Access control uses the shared channel allowlist mechanism. It no longer exposes the legacy webhook/app split.
|
||||
|
||||
4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via WebSocket webhook (`/pico/ws`).
|
||||
|
||||
|
|
@ -1381,4 +1380,4 @@ agentLoop.Stop() // Stop Agent
|
|||
|
||||
7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in 6 channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico) can actually use placeholder message editing. The rest are reserved fields.
|
||||
|
||||
8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom, WeComApp). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method.
|
||||
8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method.
|
||||
|
|
|
|||
|
|
@ -1254,8 +1254,7 @@ make test # 全量测试
|
|||
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
|
||||
| `pkg/channels/dingtalk/` | `"dingtalk"` | — |
|
||||
| `pkg/channels/feishu/` | `"feishu"` | — (架构特定 build tags: `feishu_32.go` / `feishu_64.go`) |
|
||||
| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/wecom/` | `"wecom"` | MediaSender |
|
||||
| `pkg/channels/qq/` | `"qq"` | — |
|
||||
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) |
|
||||
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) |
|
||||
|
|
@ -1370,7 +1369,7 @@ agentLoop.Stop() // 停止 Agent
|
|||
|
||||
2. **Feishu 架构特定编译**:Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`)。Feishu 使用 SDK 的 WebSocket 模式(非 HTTP webhook),因此不实现 `WebhookHandler`。
|
||||
|
||||
3. **WeCom 有两个工厂**:`"wecom"`(Bot 模式,纯 webhook)和 `"wecom_app"`(应用模式,支持 MediaSender)分别注册。两者都实现了 `WebhookHandler` 和 `HealthChecker`。
|
||||
3. **WeCom 现在只有一个 channel**:`"wecom"` 采用 WebSocket AI Bot 实现,带路由持久化;访问控制走统一的 channel 白名单机制,不再保留旧的 webhook/app 双分支。
|
||||
|
||||
4. **Pico Protocol**:`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel,通过 WebSocket webhook (`/pico/ws`) 接收消息。
|
||||
|
||||
|
|
@ -1380,4 +1379,4 @@ agentLoop.Stop() // 停止 Agent
|
|||
|
||||
7. **PlaceholderConfig 的配置与实现**:`PlaceholderConfig` 出现在 6 个 channel config 中(Telegram、Discord、Slack、LINE、OneBot、Pico),但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channel(Telegram、Discord、Pico)能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。
|
||||
|
||||
8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。
|
||||
8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。
|
||||
|
|
|
|||
|
|
@ -254,10 +254,7 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st
|
|||
return "", nil
|
||||
}
|
||||
|
||||
text := c.config.Placeholder.Text
|
||||
if text == "" {
|
||||
text = "Thinking... 💭"
|
||||
}
|
||||
text := c.config.Placeholder.GetRandomText()
|
||||
|
||||
msg, err := c.session.ChannelMessageSend(chatID, text)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -211,10 +211,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
|
|||
return "", nil
|
||||
}
|
||||
|
||||
text := c.config.Placeholder.Text
|
||||
if text == "" {
|
||||
text = "Thinking..."
|
||||
}
|
||||
text := c.config.Placeholder.GetRandomText()
|
||||
|
||||
cardContent, err := buildMarkdownCard(text)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -206,6 +206,40 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
|
|||
return false
|
||||
}
|
||||
|
||||
// preSendMedia handles typing stop, reaction undo, and placeholder cleanup
|
||||
// before sending media attachments. Unlike preSend for text messages, media
|
||||
// delivery never edits the placeholder because there is no text payload to
|
||||
// replace it with; it only attempts to delete the placeholder when possible.
|
||||
func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.OutboundMediaMessage, ch Channel) {
|
||||
key := name + ":" + msg.ChatID
|
||||
|
||||
// 1. Stop typing
|
||||
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
|
||||
if entry, ok := v.(typingEntry); ok {
|
||||
entry.stop() // idempotent, safe
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Undo reaction
|
||||
if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
|
||||
if entry, ok := v.(reactionEntry); ok {
|
||||
entry.undo() // idempotent, safe
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Clear any finalized stream marker for this chat before media delivery.
|
||||
m.streamActive.LoadAndDelete(key)
|
||||
|
||||
// 4. Delete placeholder if present.
|
||||
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
||||
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
|
||||
if deleter, ok := ch.(MessageDeleter); ok {
|
||||
deleter.DeleteMessage(ctx, msg.ChatID, entry.id) // best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) {
|
||||
m := &Manager{
|
||||
channels: make(map[string]Channel),
|
||||
|
|
@ -371,19 +405,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
|||
m.initChannel("onebot", "OneBot")
|
||||
}
|
||||
|
||||
if channels.WeCom.Enabled && channels.WeCom.Token() != "" {
|
||||
if channels.WeCom.Enabled && channels.WeCom.BotID != "" && channels.WeCom.Secret() != "" {
|
||||
m.initChannel("wecom", "WeCom")
|
||||
}
|
||||
|
||||
if channels.WeComAIBot.Enabled && (channels.WeComAIBot.Token() != "" ||
|
||||
(channels.WeComAIBot.Secret() != "" && channels.WeComAIBot.BotID != "")) {
|
||||
m.initChannel("wecom_aibot", "WeCom AI Bot")
|
||||
}
|
||||
|
||||
if channels.WeComApp.Enabled && channels.WeComApp.CorpID != "" {
|
||||
m.initChannel("wecom_app", "WeCom App")
|
||||
}
|
||||
|
||||
if channels.Weixin.Enabled && channels.Weixin.Token() != "" {
|
||||
m.initChannel("weixin", "Weixin")
|
||||
}
|
||||
|
|
@ -583,8 +608,10 @@ func newChannelWorker(name string, ch Channel) *channelWorker {
|
|||
}
|
||||
}
|
||||
|
||||
// runWorker processes outbound messages for a single channel, splitting
|
||||
// messages that exceed the channel's maximum message length.
|
||||
// runWorker processes outbound messages for a single channel.
|
||||
// Message processing follows this order:
|
||||
// 1. SplitByMarker (if enabled in config) - LLM semantic marker-based splitting
|
||||
// 2. SplitMessage - channel-specific length-based splitting (MaxMessageLength)
|
||||
func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) {
|
||||
defer close(w.done)
|
||||
for {
|
||||
|
|
@ -597,15 +624,29 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
|
|||
if mlp, ok := w.ch.(MessageLengthProvider); ok {
|
||||
maxLen = mlp.MaxMessageLength()
|
||||
}
|
||||
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
|
||||
chunks := SplitMessage(msg.Content, maxLen)
|
||||
for _, chunk := range chunks {
|
||||
chunkMsg := msg
|
||||
chunkMsg.Content = chunk
|
||||
m.sendWithRetry(ctx, name, w, chunkMsg)
|
||||
|
||||
// Collect all message chunks to send
|
||||
var chunks []string
|
||||
|
||||
// Step 1: Try marker-based splitting if enabled
|
||||
if m.config != nil && m.config.Agents.Defaults.SplitOnMarker {
|
||||
if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 {
|
||||
for _, chunk := range markerChunks {
|
||||
chunks = append(chunks, splitByLength(chunk, maxLen)...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m.sendWithRetry(ctx, name, w, msg)
|
||||
}
|
||||
|
||||
// Step 2: Fallback to length-based splitting if no chunks from marker
|
||||
if len(chunks) == 0 {
|
||||
chunks = splitByLength(msg.Content, maxLen)
|
||||
}
|
||||
|
||||
// Step 3: Send all chunks
|
||||
for _, chunk := range chunks {
|
||||
chunkMsg := msg
|
||||
chunkMsg.Content = chunk
|
||||
m.sendWithRetry(ctx, name, w, chunkMsg)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
|
@ -613,6 +654,14 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
|
|||
}
|
||||
}
|
||||
|
||||
// splitByLength splits content by maxLen if needed, otherwise returns single chunk.
|
||||
func splitByLength(content string, maxLen int) []string {
|
||||
if maxLen > 0 && len([]rune(content)) > maxLen {
|
||||
return SplitMessage(content, maxLen)
|
||||
}
|
||||
return []string{content}
|
||||
}
|
||||
|
||||
// sendWithRetry sends a message through the channel with rate limiting and
|
||||
// retry logic. It classifies errors to determine the retry strategy:
|
||||
// - ErrNotRunning / ErrSendFailed: permanent, no retry
|
||||
|
|
@ -774,7 +823,7 @@ func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWor
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
m.sendMediaWithRetry(ctx, name, w, msg)
|
||||
_ = m.sendMediaWithRetry(ctx, name, w, msg)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
|
@ -782,26 +831,37 @@ func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWor
|
|||
}
|
||||
|
||||
// sendMediaWithRetry sends a media message through the channel with rate limiting and
|
||||
// retry logic. If the channel does not implement MediaSender, it silently skips.
|
||||
func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMediaMessage) {
|
||||
// retry logic. It returns nil on success, or the last error after retries,
|
||||
// including when the channel does not support MediaSender.
|
||||
func (m *Manager) sendMediaWithRetry(
|
||||
ctx context.Context,
|
||||
name string,
|
||||
w *channelWorker,
|
||||
msg bus.OutboundMediaMessage,
|
||||
) error {
|
||||
ms, ok := w.ch.(MediaSender)
|
||||
if !ok {
|
||||
logger.DebugCF("channels", "Channel does not support MediaSender, skipping media", map[string]any{
|
||||
err := fmt.Errorf("channel %q does not support media sending", name)
|
||||
logger.WarnCF("channels", "Channel does not support MediaSender", map[string]any{
|
||||
"channel": name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Rate limit: wait for token
|
||||
if err := w.limiter.Wait(ctx); err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Pre-send: stop typing and clean up any placeholder before sending media.
|
||||
m.preSendMedia(ctx, name, msg, w.ch)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
lastErr = ms.SendMedia(ctx, msg)
|
||||
if lastErr == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Permanent failures — don't retry
|
||||
|
|
@ -820,7 +880,7 @@ func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channe
|
|||
case <-time.After(rateLimitDelay):
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -829,7 +889,7 @@ func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channe
|
|||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -840,6 +900,7 @@ func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channe
|
|||
"error": lastErr.Error(),
|
||||
"retries": maxRetries,
|
||||
})
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// runTTLJanitor periodically scans the typingStops and placeholders maps
|
||||
|
|
@ -1032,6 +1093,26 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
return nil
|
||||
}
|
||||
|
||||
// SendMedia sends outbound media synchronously through the channel worker's
|
||||
// rate limiter and retry logic. It blocks until the media is delivered (or all
|
||||
// retries are exhausted), which preserves ordering when later agent behavior
|
||||
// depends on actual media delivery.
|
||||
func (m *Manager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||
m.mu.RLock()
|
||||
_, exists := m.channels[msg.Channel]
|
||||
w, wExists := m.workers[msg.Channel]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("channel %s not found", msg.Channel)
|
||||
}
|
||||
if !wExists || w == nil {
|
||||
return fmt.Errorf("channel %s has no active worker", msg.Channel)
|
||||
}
|
||||
|
||||
return m.sendMediaWithRetry(ctx, msg.Channel, w, msg)
|
||||
}
|
||||
|
||||
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
|
||||
m.mu.RLock()
|
||||
_, exists := m.channels[channelName]
|
||||
|
|
|
|||
|
|
@ -49,15 +49,7 @@ func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
|
|||
value["token"] = ch.LINE.ChannelAccessToken()
|
||||
value["secret"] = ch.LINE.ChannelSecret()
|
||||
case "wecom":
|
||||
value["token"] = ch.WeCom.Token()
|
||||
value["key"] = ch.WeCom.EncodingAESKey()
|
||||
case "wecom_app":
|
||||
value["token"] = ch.WeComApp.Token()
|
||||
value["secret"] = ch.WeComApp.CorpSecret()
|
||||
case "wecom_aibot":
|
||||
value["token"] = ch.WeComAIBot.Token()
|
||||
value["key"] = ch.WeComAIBot.EncodingAESKey()
|
||||
value["secret"] = ch.WeComAIBot.Secret()
|
||||
value["secret"] = ch.WeCom.Secret()
|
||||
case "dingtalk":
|
||||
value["secret"] = ch.QQ.AppSecret()
|
||||
case "qq":
|
||||
|
|
@ -156,16 +148,7 @@ func updateKeys(newcfg, old *config.ChannelsConfig) {
|
|||
newcfg.LINE.SetChannelSecret(old.LINE.ChannelSecret())
|
||||
}
|
||||
if newcfg.WeCom.Enabled {
|
||||
newcfg.WeCom.SetToken(old.WeCom.Token())
|
||||
newcfg.WeCom.SetEncodingAESKey(old.WeCom.EncodingAESKey())
|
||||
}
|
||||
if newcfg.WeComApp.Enabled {
|
||||
newcfg.WeComApp.SetToken(old.WeComApp.Token())
|
||||
newcfg.WeComApp.SetCorpSecret(old.WeComApp.CorpSecret())
|
||||
}
|
||||
if newcfg.WeComAIBot.Enabled {
|
||||
newcfg.WeComAIBot.SetToken(old.WeComAIBot.Token())
|
||||
newcfg.WeComAIBot.SetEncodingAESKey(old.WeComAIBot.EncodingAESKey())
|
||||
newcfg.WeCom.SetSecret(old.WeCom.Secret())
|
||||
}
|
||||
if newcfg.DingTalk.Enabled {
|
||||
newcfg.DingTalk.SetClientSecret(old.DingTalk.ClientSecret())
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
|
@ -43,6 +44,40 @@ func (m *mockChannel) EditMessage(ctx context.Context, chatID, messageID, conten
|
|||
return nil
|
||||
}
|
||||
|
||||
type mockMediaChannel struct {
|
||||
mockChannel
|
||||
sendMediaFn func(ctx context.Context, msg bus.OutboundMediaMessage) error
|
||||
sentMediaMessages []bus.OutboundMediaMessage
|
||||
}
|
||||
|
||||
func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||
m.sentMediaMessages = append(m.sentMediaMessages, msg)
|
||||
if m.sendMediaFn != nil {
|
||||
return m.sendMediaFn(ctx, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockDeletingMediaChannel struct {
|
||||
mockMediaChannel
|
||||
deleteCalls int
|
||||
lastDeleted struct {
|
||||
chatID string
|
||||
messageID string
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockDeletingMediaChannel) DeleteMessage(
|
||||
_ context.Context,
|
||||
chatID string,
|
||||
messageID string,
|
||||
) error {
|
||||
m.deleteCalls++
|
||||
m.lastDeleted.chatID = chatID
|
||||
m.lastDeleted.messageID = messageID
|
||||
return nil
|
||||
}
|
||||
|
||||
// newTestManager creates a minimal Manager suitable for unit tests.
|
||||
func newTestManager() *Manager {
|
||||
return &Manager{
|
||||
|
|
@ -208,6 +243,125 @@ func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_Success(t *testing.T) {
|
||||
m := newTestManager()
|
||||
var callCount int
|
||||
ch := &mockMediaChannel{
|
||||
sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error {
|
||||
callCount++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
w := &channelWorker{
|
||||
ch: ch,
|
||||
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||
}
|
||||
m.channels["test"] = ch
|
||||
m.workers["test"] = w
|
||||
|
||||
err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
Channel: "test",
|
||||
ChatID: "chat1",
|
||||
Parts: []bus.MediaPart{{Ref: "media://abc"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("expected 1 SendMedia call, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_PropagatesFailure(t *testing.T) {
|
||||
m := newTestManager()
|
||||
ch := &mockMediaChannel{
|
||||
sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error {
|
||||
return fmt.Errorf("bad upload: %w", ErrSendFailed)
|
||||
},
|
||||
}
|
||||
w := &channelWorker{
|
||||
ch: ch,
|
||||
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||
}
|
||||
m.channels["test"] = ch
|
||||
m.workers["test"] = w
|
||||
|
||||
err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
Channel: "test",
|
||||
ChatID: "chat1",
|
||||
Parts: []bus.MediaPart{{Ref: "media://abc"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected SendMedia to return error")
|
||||
}
|
||||
if !errors.Is(err, ErrSendFailed) {
|
||||
t.Fatalf("expected ErrSendFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_UnsupportedChannelReturnsError(t *testing.T) {
|
||||
m := newTestManager()
|
||||
ch := &mockChannel{
|
||||
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
w := &channelWorker{
|
||||
ch: ch,
|
||||
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||
}
|
||||
m.channels["test"] = ch
|
||||
m.workers["test"] = w
|
||||
|
||||
err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
Channel: "test",
|
||||
ChatID: "chat1",
|
||||
Parts: []bus.MediaPart{{Ref: "media://abc"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected SendMedia to return error for unsupported channel")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not support media sending") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_DeletesPlaceholderBeforeSending(t *testing.T) {
|
||||
m := newTestManager()
|
||||
ch := &mockDeletingMediaChannel{
|
||||
mockMediaChannel: mockMediaChannel{
|
||||
sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
w := &channelWorker{
|
||||
ch: ch,
|
||||
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||
}
|
||||
m.channels["test"] = ch
|
||||
m.workers["test"] = w
|
||||
m.RecordPlaceholder("test", "chat1", "placeholder-1")
|
||||
|
||||
err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
Channel: "test",
|
||||
ChatID: "chat1",
|
||||
Parts: []bus.MediaPart{{Ref: "media://abc"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
if ch.deleteCalls != 1 {
|
||||
t.Fatalf("expected placeholder delete to be called once, got %d", ch.deleteCalls)
|
||||
}
|
||||
if ch.lastDeleted.chatID != "chat1" || ch.lastDeleted.messageID != "placeholder-1" {
|
||||
t.Fatalf("unexpected placeholder deletion target: %+v", ch.lastDeleted)
|
||||
}
|
||||
if len(ch.sentMediaMessages) != 1 {
|
||||
t.Fatalf("expected media to be sent once, got %d", len(ch.sentMediaMessages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithRetry_UnknownError(t *testing.T) {
|
||||
m := newTestManager()
|
||||
var callCount int
|
||||
|
|
|
|||
37
pkg/channels/marker.go
Normal file
37
pkg/channels/marker.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// 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 channels
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MessageSplitMarker is the delimiter used to split a message into multiple outbound messages.
|
||||
// When SplitOnMarker is enabled in config, the Manager will split messages on this marker
|
||||
// and send each part as a separate message.
|
||||
const MessageSplitMarker = "<|[SPLIT]|>"
|
||||
|
||||
// SplitByMarker splits a message by the MessageSplitMarker and returns the parts.
|
||||
// Empty parts (including from consecutive markers) are filtered out.
|
||||
// If no marker is found, returns a single-element slice containing the original content.
|
||||
func SplitByMarker(content string) []string {
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(content, MessageSplitMarker)
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return []string{content}
|
||||
}
|
||||
return result
|
||||
}
|
||||
141
pkg/channels/marker_test.go
Normal file
141
pkg/channels/marker_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package channels
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitByMarker_Basic(t *testing.T) {
|
||||
content := "Hello <|[SPLIT]|>World"
|
||||
chunks := SplitByMarker(content)
|
||||
|
||||
if len(chunks) != 2 {
|
||||
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
|
||||
}
|
||||
if chunks[0] != "Hello" {
|
||||
t.Errorf("Expected first chunk 'Hello', got %q", chunks[0])
|
||||
}
|
||||
if chunks[1] != "World" {
|
||||
t.Errorf("Expected second chunk 'World', got %q", chunks[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitByMarker_NoMarker(t *testing.T) {
|
||||
content := "Hello World"
|
||||
chunks := SplitByMarker(content)
|
||||
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("Expected 1 chunk, got %d: %q", len(chunks), chunks)
|
||||
}
|
||||
if chunks[0] != "Hello World" {
|
||||
t.Errorf("Expected chunk 'Hello World', got %q", chunks[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitByMarker_MultipleMarkers(t *testing.T) {
|
||||
content := "Part1 <|[SPLIT]|> Part2 <|[SPLIT]|> Part3"
|
||||
chunks := SplitByMarker(content)
|
||||
|
||||
if len(chunks) != 3 {
|
||||
t.Fatalf("Expected 3 chunks, got %d: %q", len(chunks), chunks)
|
||||
}
|
||||
if chunks[0] != "Part1" || chunks[1] != "Part2" || chunks[2] != "Part3" {
|
||||
t.Errorf("Unexpected chunks: %q", chunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitByMarker_EmptyParts(t *testing.T) {
|
||||
// Test consecutive markers and leading/trailing markers
|
||||
content := "<|[SPLIT]|>Hello <|[SPLIT]|><|[SPLIT]|>World<|[SPLIT]|>"
|
||||
chunks := SplitByMarker(content)
|
||||
|
||||
if len(chunks) != 2 {
|
||||
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
|
||||
}
|
||||
if chunks[0] != "Hello" || chunks[1] != "World" {
|
||||
t.Errorf("Unexpected chunks: %q", chunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitByMarker_WhitespaceTrimmed(t *testing.T) {
|
||||
content := " Hello <|[SPLIT]|> World "
|
||||
chunks := SplitByMarker(content)
|
||||
|
||||
if len(chunks) != 2 {
|
||||
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
|
||||
}
|
||||
if chunks[0] != "Hello" || chunks[1] != "World" {
|
||||
t.Errorf("Whitespace should be trimmed: %q", chunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitByMarker_EmptyInput(t *testing.T) {
|
||||
chunks := SplitByMarker("")
|
||||
if len(chunks) != 0 {
|
||||
t.Errorf("Expected empty slice for empty input, got %d chunks", len(chunks))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkerAndLengthSplitIntegration tests that SplitByMarker and SplitMessage work together correctly.
|
||||
// Marker splitting happens first (per-agent config), then length splitting happens (per-channel config).
|
||||
func TestMarkerAndLengthSplitIntegration(t *testing.T) {
|
||||
maxLen := 10
|
||||
|
||||
// Original content: "Short <|[SPLIT]|> ThisIsAVeryLongString"
|
||||
content := "Short <|[SPLIT]|> ThisIsAVeryLongString"
|
||||
markerChunks := SplitByMarker(content)
|
||||
|
||||
// Step 1: Marker split should give us 2 chunks
|
||||
if len(markerChunks) != 2 {
|
||||
t.Fatalf("Expected 2 marker chunks, got %d: %q", len(markerChunks), markerChunks)
|
||||
}
|
||||
|
||||
// Step 2: Length split should be applied to each marker chunk
|
||||
var finalChunks []string
|
||||
for _, chunk := range markerChunks {
|
||||
if len([]rune(chunk)) > maxLen {
|
||||
lengthChunks := SplitMessage(chunk, maxLen)
|
||||
finalChunks = append(finalChunks, lengthChunks...)
|
||||
} else {
|
||||
finalChunks = append(finalChunks, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// "Short" is 6 chars, within limit
|
||||
// "ThisIsAVeryLongString" is 22 chars, should be split into multiple chunks
|
||||
// SplitMessage with maxLen=10 splits: "ThisIsAVeryLongString" -> ["ThisI", "sAVer", "yLong", "String"] (5 chunks)
|
||||
if len(finalChunks) != 5 {
|
||||
t.Errorf("Expected 5 final chunks, got %d: %q", len(finalChunks), finalChunks)
|
||||
}
|
||||
|
||||
// Verify first chunk is unchanged
|
||||
if finalChunks[0] != "Short" {
|
||||
t.Errorf("First chunk should be 'Short', got %q", finalChunks[0])
|
||||
}
|
||||
|
||||
// Verify all length-split chunks are within limit
|
||||
for i, chunk := range finalChunks[1:] {
|
||||
if len([]rune(chunk)) > maxLen {
|
||||
t.Errorf("Chunk %d exceeds maxLen: %q (%d chars)", i+1, chunk, len([]rune(chunk)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkerSplitPreservesCodeBlockIntegrity tests that marker split preserves code block boundaries
|
||||
func TestMarkerSplitPreservesCodeBlockIntegrity(t *testing.T) {
|
||||
content := "Hello <|[SPLIT]|>```go\npackage main\n```<|[SPLIT]|>World"
|
||||
chunks := SplitByMarker(content)
|
||||
|
||||
if len(chunks) != 3 {
|
||||
t.Fatalf("Expected 3 chunks, got %d: %q", len(chunks), chunks)
|
||||
}
|
||||
|
||||
// Verify code block is intact in middle chunk
|
||||
if chunks[1] != "```go\npackage main\n```" {
|
||||
t.Errorf("Code block not preserved correctly: %q", chunks[1])
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package matrix
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
|
|
@ -8,6 +10,11 @@ import (
|
|||
|
||||
func init() {
|
||||
channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
return NewMatrixChannel(cfg.Channels.Matrix, b)
|
||||
matrixCfg := cfg.Channels.Matrix
|
||||
cryptoDatabasePath := matrixCfg.CryptoDatabasePath
|
||||
if cryptoDatabasePath == "" {
|
||||
cryptoDatabasePath = filepath.Join(cfg.WorkspacePath(), "matrix")
|
||||
}
|
||||
return NewMatrixChannel(matrixCfg, b, cryptoDatabasePath)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package matrix
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
|
|
@ -17,9 +18,12 @@ import (
|
|||
"github.com/gomarkdown/markdown"
|
||||
mdhtml "github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"go.mau.fi/util/dbutil"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
|
|
@ -30,6 +34,9 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
sqliteDriver = "sqlite"
|
||||
dbName = "store.db"
|
||||
|
||||
typingRefreshInterval = 20 * time.Second
|
||||
typingServerTTL = 30 * time.Second
|
||||
roomKindCacheTTL = 5 * time.Minute
|
||||
|
|
@ -181,9 +188,16 @@ type MatrixChannel struct {
|
|||
|
||||
roomKindCache *roomKindCache
|
||||
localpartMentionR *regexp.Regexp
|
||||
|
||||
cryptoHelper *cryptohelper.CryptoHelper
|
||||
cryptoDbPath string
|
||||
}
|
||||
|
||||
func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) {
|
||||
func NewMatrixChannel(
|
||||
cfg config.MatrixConfig,
|
||||
messageBus *bus.MessageBus,
|
||||
cryptoDatabasePath string,
|
||||
) (*MatrixChannel, error) {
|
||||
homeserver := strings.TrimSpace(cfg.Homeserver)
|
||||
userID := strings.TrimSpace(cfg.UserID)
|
||||
accessToken := strings.TrimSpace(cfg.AccessToken())
|
||||
|
|
@ -230,6 +244,7 @@ func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*Mat
|
|||
roomKindCache: newRoomKindCache(roomKindCacheMaxEntries, roomKindCacheTTL),
|
||||
localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)),
|
||||
typingMu: sync.Mutex{},
|
||||
cryptoDbPath: cryptoDatabasePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -239,7 +254,21 @@ func (c *MatrixChannel) Start(ctx context.Context) error {
|
|||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
c.startTime = time.Now()
|
||||
|
||||
// Initialize crypto helper if database and passphrase are configured
|
||||
if c.cryptoDbPath != "" && c.config.CryptoPassphrase != "" {
|
||||
if err := c.initCrypto(ctx); err != nil {
|
||||
logger.WarnCF(
|
||||
"matrix",
|
||||
"Failed to initialize crypto, continuing without encryption support",
|
||||
map[string]any{
|
||||
"error": err.Error(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
c.syncer.OnEventType(event.EventMessage, c.handleMessageEvent)
|
||||
c.syncer.OnEventType(event.EventEncrypted, c.handleMessageEvent)
|
||||
c.syncer.OnEventType(event.StateMember, c.handleMemberEvent)
|
||||
|
||||
c.SetRunning(true)
|
||||
|
|
@ -266,10 +295,84 @@ func (c *MatrixChannel) Stop(ctx context.Context) error {
|
|||
}
|
||||
c.stopTypingSessions(ctx)
|
||||
|
||||
// Close crypto helper if initialized
|
||||
if c.cryptoHelper != nil {
|
||||
c.cryptoHelper.Close()
|
||||
c.cryptoHelper = nil
|
||||
c.client.Crypto = nil
|
||||
}
|
||||
|
||||
logger.InfoC("matrix", "Matrix channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MatrixChannel) initCrypto(ctx context.Context) error {
|
||||
logger.InfoC("matrix", "Initializing crypto helper")
|
||||
|
||||
// Ensure the crypto database directory exists
|
||||
if err := os.MkdirAll(c.cryptoDbPath, 0o700); err != nil {
|
||||
return fmt.Errorf("create crypto database directory: %w", err)
|
||||
}
|
||||
|
||||
// Create database with sqlite driver (modernc.org/sqlite)
|
||||
dbPath := filepath.Join(c.cryptoDbPath, dbName)
|
||||
connStr := "file:" + dbPath + "?_foreign_keys=on"
|
||||
|
||||
db, err := sql.Open(sqliteDriver, connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open crypto database: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
// Execute PRAGMA statements
|
||||
// This is equivalent to the "sqlite3-fk-wal" dialect used by cryptohelper
|
||||
pragmaStmts := []string{
|
||||
"PRAGMA foreign_keys = ON",
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA synchronous = NORMAL",
|
||||
"PRAGMA busy_timeout = 5000",
|
||||
}
|
||||
for _, pragma := range pragmaStmts {
|
||||
if _, err = db.ExecContext(ctx, pragma); err != nil {
|
||||
_ = db.Close()
|
||||
return fmt.Errorf("execute %s: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap with dbutil for dialect support
|
||||
wrappedDB, err := dbutil.NewWithDB(db, sqliteDriver)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return fmt.Errorf("wrap database: %w", err)
|
||||
}
|
||||
|
||||
cryptoHelper, err := cryptohelper.NewCryptoHelper(c.client, []byte(c.config.CryptoPassphrase), wrappedDB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create crypto helper: %w", err)
|
||||
}
|
||||
|
||||
if c.client.DeviceID == "" {
|
||||
resp, whoamiErr := c.client.Whoami(ctx)
|
||||
if whoamiErr != nil {
|
||||
_ = db.Close()
|
||||
return fmt.Errorf("get device ID via whoami: %w", whoamiErr)
|
||||
}
|
||||
c.client.DeviceID = resp.DeviceID
|
||||
}
|
||||
|
||||
if err = cryptoHelper.Init(ctx); err != nil {
|
||||
cryptoHelper.Close()
|
||||
return fmt.Errorf("init crypto helper: %w", err)
|
||||
}
|
||||
|
||||
c.client.Crypto = cryptoHelper
|
||||
c.cryptoHelper = cryptoHelper
|
||||
|
||||
logger.InfoC("matrix", "Crypto helper initialized successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownToHTML(md string) string {
|
||||
p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
|
||||
renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags})
|
||||
|
|
@ -470,10 +573,7 @@ func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (str
|
|||
return "", fmt.Errorf("matrix room ID is empty")
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(c.config.Placeholder.Text)
|
||||
if text == "" {
|
||||
text = "Thinking... 💭"
|
||||
}
|
||||
text := c.config.Placeholder.GetRandomText()
|
||||
|
||||
resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
|
||||
MsgType: event.MsgNotice,
|
||||
|
|
@ -548,9 +648,26 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event
|
|||
return
|
||||
}
|
||||
|
||||
msgEvt := evt.Content.AsMessage()
|
||||
if msgEvt == nil {
|
||||
return
|
||||
var msgEvt *event.MessageEventContent
|
||||
switch evt.Type {
|
||||
case event.EventMessage:
|
||||
// When crypto is enabled, events marked WasEncrypted=true are
|
||||
// re-dispatched by c.cryptoHelper after decryption and will be
|
||||
// processed again in the EventEncrypted branch. Skip to avoid duplication.
|
||||
if c.client.Crypto != nil && evt.Mautrix.WasEncrypted {
|
||||
return
|
||||
}
|
||||
|
||||
msgEvt = evt.Content.AsMessage()
|
||||
if msgEvt == nil || msgEvt.MsgType == "" {
|
||||
return
|
||||
}
|
||||
case event.EventEncrypted:
|
||||
var ok bool
|
||||
msgEvt, ok = c.decryptEvent(ctx, evt)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore edits.
|
||||
|
|
@ -642,6 +759,36 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event
|
|||
)
|
||||
}
|
||||
|
||||
// decryptEvent decrypts an encrypted event and returns the decrypted message event content.
|
||||
// It returns the decrypted content and a boolean indicating whether decryption was successful.
|
||||
func (c *MatrixChannel) decryptEvent(ctx context.Context, evt *event.Event) (*event.MessageEventContent, bool) {
|
||||
if c.client.Crypto == nil {
|
||||
logger.DebugCF("matrix", "Received encrypted message but crypto is not enabled", map[string]any{
|
||||
"room_id": evt.RoomID.String(),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
decrypted, err := c.client.Crypto.Decrypt(ctx, evt)
|
||||
if err != nil {
|
||||
logger.WarnCF("matrix", "Failed to decrypt message", map[string]any{
|
||||
"room_id": evt.RoomID.String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if decrypted.Type != event.EventMessage {
|
||||
logger.DebugCF("matrix", "Decrypted event is not a message event", map[string]any{
|
||||
"room_id": evt.RoomID.String(),
|
||||
"type": decrypted.Type.String(),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return decrypted.Content.AsMessage(), true
|
||||
}
|
||||
|
||||
func (c *MatrixChannel) extractInboundContent(
|
||||
ctx context.Context,
|
||||
msgEvt *event.MessageEventContent,
|
||||
|
|
|
|||
|
|
@ -54,12 +54,13 @@ func (pc *picoConn) close() {
|
|||
// It serves as the reference implementation for all optional capability interfaces.
|
||||
type PicoChannel struct {
|
||||
*channels.BaseChannel
|
||||
config config.PicoConfig
|
||||
upgrader websocket.Upgrader
|
||||
connections sync.Map // connID → *picoConn
|
||||
connCount atomic.Int32
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
config config.PicoConfig
|
||||
upgrader websocket.Upgrader
|
||||
connections map[string]*picoConn // connID -> *picoConn
|
||||
sessionConnections map[string]map[string]*picoConn // sessionID -> connID -> *picoConn
|
||||
connsMu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewPicoChannel creates a new Pico Protocol channel.
|
||||
|
|
@ -92,9 +93,104 @@ func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoCha
|
|||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
},
|
||||
connections: make(map[string]*picoConn),
|
||||
sessionConnections: make(map[string]map[string]*picoConn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createAndAddConnection checks MaxConnections and registers a connection atomically.
|
||||
func (c *PicoChannel) createAndAddConnection(conn *websocket.Conn, sessionID string, maxConns int) (*picoConn, error) {
|
||||
c.connsMu.Lock()
|
||||
defer c.connsMu.Unlock()
|
||||
if len(c.connections) >= maxConns {
|
||||
return nil, channels.ErrTemporary
|
||||
}
|
||||
|
||||
var connID string
|
||||
for {
|
||||
connID = uuid.New().String()
|
||||
if _, exists := c.connections[connID]; !exists {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pc := &picoConn{
|
||||
id: connID,
|
||||
conn: conn,
|
||||
sessionID: sessionID,
|
||||
}
|
||||
|
||||
c.connections[pc.id] = pc
|
||||
bySession, ok := c.sessionConnections[pc.sessionID]
|
||||
if !ok {
|
||||
bySession = make(map[string]*picoConn)
|
||||
c.sessionConnections[pc.sessionID] = bySession
|
||||
}
|
||||
bySession[pc.id] = pc
|
||||
|
||||
return pc, nil
|
||||
}
|
||||
|
||||
// removeConnection deletes a connection from indexes and returns it when found.
|
||||
func (c *PicoChannel) removeConnection(connID string) *picoConn {
|
||||
c.connsMu.Lock()
|
||||
defer c.connsMu.Unlock()
|
||||
|
||||
pc, ok := c.connections[connID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(c.connections, connID)
|
||||
if bySession, ok := c.sessionConnections[pc.sessionID]; ok {
|
||||
delete(bySession, connID)
|
||||
if len(bySession) == 0 {
|
||||
delete(c.sessionConnections, pc.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
return pc
|
||||
}
|
||||
|
||||
// takeAllConnections snapshots and clears all connection indexes.
|
||||
func (c *PicoChannel) takeAllConnections() []*picoConn {
|
||||
c.connsMu.Lock()
|
||||
defer c.connsMu.Unlock()
|
||||
|
||||
all := make([]*picoConn, 0, len(c.connections))
|
||||
for _, pc := range c.connections {
|
||||
all = append(all, pc)
|
||||
}
|
||||
clear(c.connections)
|
||||
clear(c.sessionConnections)
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
// sessionConnectionsSnapshot returns all active connections for a session.
|
||||
func (c *PicoChannel) sessionConnectionsSnapshot(sessionID string) []*picoConn {
|
||||
c.connsMu.RLock()
|
||||
defer c.connsMu.RUnlock()
|
||||
|
||||
bySession, ok := c.sessionConnections[sessionID]
|
||||
if !ok || len(bySession) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
conns := make([]*picoConn, 0, len(bySession))
|
||||
for _, pc := range bySession {
|
||||
conns = append(conns, pc)
|
||||
}
|
||||
return conns
|
||||
}
|
||||
|
||||
// currentConnCount returns a lock-protected snapshot of active connection count.
|
||||
func (c *PicoChannel) currentConnCount() int {
|
||||
c.connsMu.RLock()
|
||||
defer c.connsMu.RUnlock()
|
||||
return len(c.connections)
|
||||
}
|
||||
|
||||
// Start implements Channel.
|
||||
func (c *PicoChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("pico", "Starting Pico Protocol channel")
|
||||
|
|
@ -110,13 +206,9 @@ func (c *PicoChannel) Stop(ctx context.Context) error {
|
|||
c.SetRunning(false)
|
||||
|
||||
// Close all connections
|
||||
c.connections.Range(func(key, value any) bool {
|
||||
if pc, ok := value.(*picoConn); ok {
|
||||
pc.close()
|
||||
}
|
||||
c.connections.Delete(key)
|
||||
return true
|
||||
})
|
||||
for _, pc := range c.takeAllConnections() {
|
||||
pc.close()
|
||||
}
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
|
|
@ -133,8 +225,8 @@ func (c *PicoChannel) WebhookPath() string { return "/pico/" }
|
|||
func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/pico")
|
||||
|
||||
switch {
|
||||
case path == "/ws" || path == "/ws/":
|
||||
switch path {
|
||||
case "/ws", "/ws/":
|
||||
c.handleWebSocket(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
|
|
@ -183,10 +275,7 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin
|
|||
return "", nil
|
||||
}
|
||||
|
||||
text := c.config.Placeholder.Text
|
||||
if text == "" {
|
||||
text = "Thinking... 💭"
|
||||
}
|
||||
text := c.config.Placeholder.GetRandomText()
|
||||
|
||||
msgID := uuid.New().String()
|
||||
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||
|
|
@ -208,23 +297,16 @@ func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
|
|||
msg.SessionID = sessionID
|
||||
|
||||
var sent bool
|
||||
c.connections.Range(func(key, value any) bool {
|
||||
pc, ok := value.(*picoConn)
|
||||
if !ok {
|
||||
return true
|
||||
for _, pc := range c.sessionConnectionsSnapshot(sessionID) {
|
||||
if err := pc.writeJSON(msg); err != nil {
|
||||
logger.DebugCF("pico", "Write to connection failed", map[string]any{
|
||||
"conn_id": pc.id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
sent = true
|
||||
}
|
||||
if pc.sessionID == sessionID {
|
||||
if err := pc.writeJSON(msg); err != nil {
|
||||
logger.DebugCF("pico", "Write to connection failed", map[string]any{
|
||||
"conn_id": pc.id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
sent = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if !sent {
|
||||
return fmt.Errorf("no active connections for session %s: %w", sessionID, channels.ErrSendFailed)
|
||||
|
|
@ -250,7 +332,7 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||
if maxConns <= 0 {
|
||||
maxConns = 100
|
||||
}
|
||||
if int(c.connCount.Load()) >= maxConns {
|
||||
if c.currentConnCount() >= maxConns {
|
||||
http.Error(w, "too many connections", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
|
@ -275,15 +357,17 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||
sessionID = uuid.New().String()
|
||||
}
|
||||
|
||||
pc := &picoConn{
|
||||
id: uuid.New().String(),
|
||||
conn: conn,
|
||||
sessionID: sessionID,
|
||||
pc, err := c.createAndAddConnection(conn, sessionID, maxConns)
|
||||
if err != nil {
|
||||
_ = conn.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "too many connections"),
|
||||
time.Now().Add(2*time.Second),
|
||||
)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
c.connections.Store(pc.id, pc)
|
||||
c.connCount.Add(1)
|
||||
|
||||
logger.InfoCF("pico", "WebSocket client connected", map[string]any{
|
||||
"conn_id": pc.id,
|
||||
"session_id": sessionID,
|
||||
|
|
@ -341,12 +425,12 @@ func (c *PicoChannel) matchedSubprotocol(r *http.Request) string {
|
|||
func (c *PicoChannel) readLoop(pc *picoConn) {
|
||||
defer func() {
|
||||
pc.close()
|
||||
c.connections.Delete(pc.id)
|
||||
c.connCount.Add(-1)
|
||||
logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{
|
||||
"conn_id": pc.id,
|
||||
"session_id": pc.sessionID,
|
||||
})
|
||||
if removed := c.removeConnection(pc.id); removed != nil {
|
||||
logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{
|
||||
"conn_id": removed.id,
|
||||
"session_id": removed.sessionID,
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
readTimeout := time.Duration(c.config.ReadTimeout) * time.Second
|
||||
|
|
|
|||
144
pkg/channels/pico/pico_test.go
Normal file
144
pkg/channels/pico/pico_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package pico
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func newTestPicoChannel(t *testing.T) *PicoChannel {
|
||||
t.Helper()
|
||||
|
||||
cfg := config.PicoConfig{}
|
||||
cfg.SetToken("test-token")
|
||||
ch, err := NewPicoChannel(cfg, bus.NewMessageBus())
|
||||
if err != nil {
|
||||
t.Fatalf("NewPicoChannel: %v", err)
|
||||
}
|
||||
|
||||
ch.ctx = context.Background()
|
||||
return ch
|
||||
}
|
||||
|
||||
func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) {
|
||||
ch := newTestPicoChannel(t)
|
||||
|
||||
const (
|
||||
maxConns = 5
|
||||
goroutines = 64
|
||||
sessionID = "session-a"
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
successCount := 0
|
||||
errCount := 0
|
||||
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
pc, err := ch.createAndAddConnection(nil, sessionID, maxConns)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if err == nil {
|
||||
successCount++
|
||||
if pc == nil {
|
||||
t.Errorf("pc is nil on success")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, channels.ErrTemporary) {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
errCount++
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if successCount > maxConns {
|
||||
t.Fatalf("successCount=%d > maxConns=%d", successCount, maxConns)
|
||||
}
|
||||
if successCount+errCount != goroutines {
|
||||
t.Fatalf("success=%d err=%d total=%d want=%d", successCount, errCount, successCount+errCount, goroutines)
|
||||
}
|
||||
if got := ch.currentConnCount(); got != maxConns {
|
||||
t.Fatalf("currentConnCount=%d want=%d", got, maxConns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveConnection_CleansBothIndexes(t *testing.T) {
|
||||
ch := newTestPicoChannel(t)
|
||||
|
||||
pc, err := ch.createAndAddConnection(nil, "session-cleanup", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("createAndAddConnection: %v", err)
|
||||
}
|
||||
|
||||
removed := ch.removeConnection(pc.id)
|
||||
if removed == nil {
|
||||
t.Fatal("removeConnection returned nil")
|
||||
}
|
||||
|
||||
ch.connsMu.RLock()
|
||||
defer ch.connsMu.RUnlock()
|
||||
|
||||
if _, ok := ch.connections[pc.id]; ok {
|
||||
t.Fatalf("connID %s still exists in connections", pc.id)
|
||||
}
|
||||
if _, ok := ch.sessionConnections[pc.sessionID]; ok {
|
||||
t.Fatalf("session %s still exists in sessionConnections", pc.sessionID)
|
||||
}
|
||||
if got := len(ch.connections); got != 0 {
|
||||
t.Fatalf("len(connections)=%d want=0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastToSession_TargetsOnlyRequestedSession(t *testing.T) {
|
||||
ch := newTestPicoChannel(t)
|
||||
|
||||
target := &picoConn{id: "target", sessionID: "s-target"}
|
||||
target.closed.Store(true)
|
||||
ch.addConnForTest(target)
|
||||
|
||||
other := &picoConn{id: "other", sessionID: "s-other"}
|
||||
ch.addConnForTest(other)
|
||||
|
||||
err := ch.broadcastToSession("pico:s-target", newMessage(TypeMessageCreate, map[string]any{"content": "hello"}))
|
||||
if err == nil {
|
||||
t.Fatal("expected send failure due to closed target connection")
|
||||
}
|
||||
if !errors.Is(err, channels.ErrSendFailed) {
|
||||
t.Fatalf("expected ErrSendFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PicoChannel) addConnForTest(pc *picoConn) {
|
||||
c.connsMu.Lock()
|
||||
defer c.connsMu.Unlock()
|
||||
if c.connections == nil {
|
||||
c.connections = make(map[string]*picoConn)
|
||||
}
|
||||
if c.sessionConnections == nil {
|
||||
c.sessionConnections = make(map[string]map[string]*picoConn)
|
||||
}
|
||||
if _, exists := c.connections[pc.id]; exists {
|
||||
panic(fmt.Sprintf("duplicate conn id in test: %s", pc.id))
|
||||
}
|
||||
c.connections[pc.id] = pc
|
||||
bySession, ok := c.sessionConnections[pc.sessionID]
|
||||
if !ok {
|
||||
bySession = make(map[string]*picoConn)
|
||||
c.sessionConnections[pc.sessionID] = bySession
|
||||
}
|
||||
bySession[pc.id] = pc
|
||||
}
|
||||
|
|
@ -357,6 +357,7 @@ type qqMediaUpload struct {
|
|||
FileType uint64 `json:"file_type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
FileData string `json:"file_data,omitempty"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
SrvSendMsg bool `json:"srv_send_msg,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -393,6 +394,7 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error)
|
|||
if isHTTPURL(mediaRef) {
|
||||
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
||||
payload.URL = mediaRef
|
||||
payload.FileName = qqUploadFilename(part, mediaRef, payload.FileType)
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
|
|
@ -415,9 +417,11 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error)
|
|||
if isHTTPURL(resolved) {
|
||||
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
||||
payload.URL = resolved
|
||||
payload.FileName = qqUploadFilename(part, resolved, payload.FileType)
|
||||
return payload, nil
|
||||
}
|
||||
payload.FileType = qqFileType(c.outboundMediaType(part, resolved))
|
||||
payload.FileName = qqUploadFilename(part, resolved, payload.FileType)
|
||||
|
||||
if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 {
|
||||
info, statErr := os.Stat(resolved)
|
||||
|
|
@ -444,6 +448,28 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error)
|
|||
return payload, nil
|
||||
}
|
||||
|
||||
func qqUploadFilename(part bus.MediaPart, resolved string, fileType uint64) string {
|
||||
if fileType != qqFileType("file") {
|
||||
return ""
|
||||
}
|
||||
if part.Filename != "" {
|
||||
return part.Filename
|
||||
}
|
||||
if isHTTPURL(resolved) {
|
||||
if parsed, err := url.Parse(resolved); err == nil {
|
||||
if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" {
|
||||
return base
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if base := filepath.Base(resolved); base != "" && base != "." {
|
||||
return base
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *QQChannel) outboundMediaType(part bus.MediaPart, localPath string) string {
|
||||
if part.Type != "audio" {
|
||||
return part.Type
|
||||
|
|
|
|||
|
|
@ -444,6 +444,9 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) {
|
|||
if upload.body.FileType != 4 {
|
||||
t.Fatalf("upload file_type = %d, want 4", upload.body.FileType)
|
||||
}
|
||||
if upload.body.FileName != "report.pdf" {
|
||||
t.Fatalf("upload file_name = %q, want report.pdf", upload.body.FileName)
|
||||
}
|
||||
|
||||
if len(api.c2cMessages) != 1 {
|
||||
t.Fatalf("c2cMessages = %d, want 1", len(api.c2cMessages))
|
||||
|
|
@ -460,6 +463,59 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
store := media.NewFileMediaStore()
|
||||
|
||||
localPath := writeTempFile(t, t.TempDir(), "report.pdf", []byte("fake-pdf"))
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: "report.pdf",
|
||||
ContentType: "application/pdf",
|
||||
}, "qq:test")
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
api := &fakeQQAPI{
|
||||
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("local-file-info")}),
|
||||
}
|
||||
ch := &QQChannel{
|
||||
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
||||
api: api,
|
||||
dedup: make(map[string]time.Time),
|
||||
done: make(chan struct{}),
|
||||
ctx: context.Background(),
|
||||
}
|
||||
ch.SetRunning(true)
|
||||
ch.SetMediaStore(store)
|
||||
ch.chatType.Store("user-1", "direct")
|
||||
|
||||
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "user-1",
|
||||
Parts: []bus.MediaPart{{
|
||||
Type: "file",
|
||||
Ref: ref,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
|
||||
if len(api.transportCalls) != 1 {
|
||||
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
|
||||
}
|
||||
upload := api.transportCalls[0]
|
||||
if upload.body.FileType != 4 {
|
||||
t.Fatalf("upload file_type = %d, want 4", upload.body.FileType)
|
||||
}
|
||||
if upload.body.FileName != "report.pdf" {
|
||||
t.Fatalf("upload file_name = %q, want report.pdf", upload.body.FileName)
|
||||
}
|
||||
if upload.body.FileData == "" {
|
||||
t.Fatal("upload file_data = empty, want base64 payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &QQChannel{
|
||||
|
|
|
|||
|
|
@ -402,10 +402,7 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
|
|||
return "", nil
|
||||
}
|
||||
|
||||
text := phCfg.Text
|
||||
if text == "" {
|
||||
text = "Thinking... 💭"
|
||||
}
|
||||
text := phCfg.GetRandomText()
|
||||
|
||||
cid, threadID, err := parseTelegramChatID(chatID)
|
||||
if err != nil {
|
||||
|
|
@ -481,13 +478,26 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
|||
_, err = c.bot.SendDocument(ctx, docParams)
|
||||
}
|
||||
case "audio":
|
||||
params := &telego.SendAudioParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageThreadID: threadID,
|
||||
Audio: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
// Send OGG files with "voice" in the filename as Telegram voice
|
||||
// bubbles (SendVoice) instead of audio attachments (SendAudio).
|
||||
fn := strings.ToLower(part.Filename)
|
||||
if strings.Contains(fn, "voice") && (strings.HasSuffix(fn, ".ogg") || strings.HasSuffix(fn, ".oga")) {
|
||||
vparams := &telego.SendVoiceParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageThreadID: threadID,
|
||||
Voice: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
}
|
||||
_, err = c.bot.SendVoice(ctx, vparams)
|
||||
} else {
|
||||
params := &telego.SendAudioParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageThreadID: threadID,
|
||||
Audio: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
}
|
||||
_, err = c.bot.SendAudio(ctx, params)
|
||||
}
|
||||
_, err = c.bot.SendAudio(ctx, params)
|
||||
case "video":
|
||||
params := &telego.SendVideoParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
|
|
@ -629,8 +639,12 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
}
|
||||
}
|
||||
|
||||
if content == "" && len(mediaPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
content = "[empty message]"
|
||||
content = "[media only]"
|
||||
}
|
||||
|
||||
// In group chats, apply unified group trigger filtering
|
||||
|
|
|
|||
|
|
@ -641,3 +641,35 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
|
|||
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
|
||||
assert.Empty(t, inbound.Metadata["parent_peer_id"])
|
||||
}
|
||||
|
||||
func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &TelegramChannel{
|
||||
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||
chatIDs: make(map[string]int64),
|
||||
ctx: context.Background(),
|
||||
}
|
||||
|
||||
// Service message with no text/caption/media (like ForumTopicCreated)
|
||||
msg := &telego.Message{
|
||||
MessageID: 123,
|
||||
Chat: telego.Chat{
|
||||
ID: 456,
|
||||
Type: "group",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 789,
|
||||
FirstName: "User",
|
||||
},
|
||||
}
|
||||
|
||||
err := ch.handleMessage(context.Background(), msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should NOT publish to message bus
|
||||
select {
|
||||
case <-messageBus.InboundChan():
|
||||
t.Fatal("Empty message should not be published to message bus")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,559 +0,0 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// ---- Webhook mode tests ----
|
||||
|
||||
func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) {
|
||||
t.Run("success with valid config", func(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{}
|
||||
cfg.Enabled = true
|
||||
cfg.SetToken("test_token")
|
||||
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||
cfg.WebhookPath = "/webhook/test"
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if ch == nil {
|
||||
t.Fatal("Expected channel to be created")
|
||||
}
|
||||
if ch.Name() != "wecom_aibot" {
|
||||
t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
|
||||
}
|
||||
// Webhook mode must implement WebhookHandler.
|
||||
if _, ok := ch.(channels.WebhookHandler); !ok {
|
||||
t.Error("Webhook mode channel should implement WebhookHandler")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error with missing token", func(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{}
|
||||
cfg.Enabled = true
|
||||
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing token, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error with missing encoding key", func(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{}
|
||||
cfg.Enabled = true
|
||||
cfg.SetToken("test_token")
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing encoding key, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComAIBotWebhookChannelStartStop(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{
|
||||
Enabled: true,
|
||||
}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create channel: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ch.Start(ctx); err != nil {
|
||||
t.Fatalf("Failed to start channel: %v", err)
|
||||
}
|
||||
if !ch.IsRunning() {
|
||||
t.Error("Expected channel to be running after Start")
|
||||
}
|
||||
|
||||
if err := ch.Stop(ctx); err != nil {
|
||||
t.Fatalf("Failed to stop channel: %v", err)
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Error("Expected channel to be stopped after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeComAIBotChannelWebhookPath(t *testing.T) {
|
||||
t.Run("default path", func(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{}
|
||||
cfg.Enabled = true
|
||||
cfg.SetToken("test_token")
|
||||
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||
|
||||
wh, ok := ch.(channels.WebhookHandler)
|
||||
if !ok {
|
||||
t.Fatal("Expected channel to implement WebhookHandler")
|
||||
}
|
||||
expectedPath := "/webhook/wecom-aibot"
|
||||
if wh.WebhookPath() != expectedPath {
|
||||
t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, wh.WebhookPath())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("custom path", func(t *testing.T) {
|
||||
customPath := "/custom/webhook"
|
||||
cfg := config.WeComAIBotConfig{}
|
||||
cfg.Enabled = true
|
||||
cfg.SetToken("test_token")
|
||||
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||
cfg.WebhookPath = customPath
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||
|
||||
wh, ok := ch.(channels.WebhookHandler)
|
||||
if !ok {
|
||||
t.Fatal("Expected channel to implement WebhookHandler")
|
||||
}
|
||||
if wh.WebhookPath() != customPath {
|
||||
t.Errorf("Expected webhook path '%s', got '%s'", customPath, wh.WebhookPath())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) {
|
||||
validAESKey := "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"
|
||||
|
||||
t.Run("uses default processing message", func(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{
|
||||
Enabled: true,
|
||||
}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.SetEncodingAESKey(validAESKey)
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
channel, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create channel: %v", err)
|
||||
}
|
||||
ch, ok := channel.(*WeComAIBotChannel)
|
||||
if !ok {
|
||||
t.Fatal("Expected webhook mode channel")
|
||||
}
|
||||
|
||||
task := &streamTask{
|
||||
StreamID: "stream-default",
|
||||
ChatID: "chat-default",
|
||||
Deadline: time.Now().Add(-time.Second),
|
||||
}
|
||||
ch.streamTasks[task.StreamID] = task
|
||||
ch.chatTasks[task.ChatID] = []*streamTask{task}
|
||||
|
||||
resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce"))
|
||||
|
||||
if !resp.Stream.Finish {
|
||||
t.Fatal("Expected finished stream response after deadline")
|
||||
}
|
||||
if resp.Stream.Content != config.DefaultWeComAIBotProcessingMessage {
|
||||
t.Fatalf("Expected default processing message %q, got %q",
|
||||
config.DefaultWeComAIBotProcessingMessage, resp.Stream.Content)
|
||||
}
|
||||
if !task.StreamClosed {
|
||||
t.Fatal("Expected task stream to be marked closed")
|
||||
}
|
||||
if _, ok := ch.streamTasks[task.StreamID]; ok {
|
||||
t.Fatal("Expected closed stream task to be removed from streamTasks")
|
||||
}
|
||||
if len(ch.chatTasks[task.ChatID]) != 1 {
|
||||
t.Fatalf("Expected task to remain queued for response_url delivery, got %d entries",
|
||||
len(ch.chatTasks[task.ChatID]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses custom processing message", func(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{
|
||||
Enabled: true,
|
||||
ProcessingMessage: "Please wait a moment. The result will be delivered in a follow-up message.",
|
||||
}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.SetEncodingAESKey(validAESKey)
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
channel, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create channel: %v", err)
|
||||
}
|
||||
ch, ok := channel.(*WeComAIBotChannel)
|
||||
if !ok {
|
||||
t.Fatal("Expected webhook mode channel")
|
||||
}
|
||||
|
||||
task := &streamTask{
|
||||
StreamID: "stream-custom",
|
||||
ChatID: "chat-custom",
|
||||
Deadline: time.Now().Add(-time.Second),
|
||||
}
|
||||
|
||||
resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce"))
|
||||
|
||||
if resp.Stream.Content != cfg.ProcessingMessage {
|
||||
t.Fatalf("Expected custom processing message %q, got %q", cfg.ProcessingMessage, resp.Stream.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateStreamID(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{}
|
||||
cfg.Enabled = true
|
||||
cfg.SetToken("test_token")
|
||||
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||
webhookCh, ok := ch.(*WeComAIBotChannel)
|
||||
if !ok {
|
||||
t.Fatal("Expected webhook mode channel")
|
||||
}
|
||||
|
||||
ids := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
id := webhookCh.generateStreamID()
|
||||
if len(id) != 10 {
|
||||
t.Errorf("Expected stream ID length 10, got %d", len(id))
|
||||
}
|
||||
if ids[id] {
|
||||
t.Errorf("Duplicate stream ID generated: %s", id)
|
||||
}
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
// Use a valid 43-character base64 key (企业微信标准格式)
|
||||
cfg := config.WeComAIBotConfig{}
|
||||
cfg.Enabled = true
|
||||
cfg.SetToken("test_token")
|
||||
cfg.SetEncodingAESKey("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") // 43 characters
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||
webhookCh, ok := ch.(*WeComAIBotChannel)
|
||||
if !ok {
|
||||
t.Fatal("Expected webhook mode channel")
|
||||
}
|
||||
|
||||
plaintext := "Hello, World!"
|
||||
receiveid := ""
|
||||
|
||||
encrypted, err := webhookCh.encryptMessage(plaintext, receiveid)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt message: %v", err)
|
||||
}
|
||||
if encrypted == "" {
|
||||
t.Fatal("Encrypted message is empty")
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey(), receiveid)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decrypt message: %v", err)
|
||||
}
|
||||
if decrypted != plaintext {
|
||||
t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSignature(t *testing.T) {
|
||||
token := "test_token"
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
encrypt := "encrypted_msg"
|
||||
|
||||
signature := computeSignature(token, timestamp, nonce, encrypt)
|
||||
if signature == "" {
|
||||
t.Error("Generated signature is empty")
|
||||
}
|
||||
if !verifySignature(token, signature, timestamp, nonce, encrypt) {
|
||||
t.Error("Generated signature does not verify correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStreamResponse(t *testing.T, ch *WeComAIBotChannel, encryptedResponse string) WeComAIBotStreamResponse {
|
||||
t.Helper()
|
||||
|
||||
var wrapped WeComAIBotEncryptedResponse
|
||||
if err := json.Unmarshal([]byte(encryptedResponse), &wrapped); err != nil {
|
||||
t.Fatalf("Failed to unmarshal encrypted response: %v", err)
|
||||
}
|
||||
|
||||
plaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decrypt response: %v", err)
|
||||
}
|
||||
|
||||
var resp WeComAIBotStreamResponse
|
||||
if err := json.Unmarshal([]byte(plaintext), &resp); err != nil {
|
||||
t.Fatalf("Failed to unmarshal decrypted response: %v", err)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// ---- WebSocket long-connection mode tests ----
|
||||
|
||||
func TestNewWeComAIBotChannel_WSMode(t *testing.T) {
|
||||
t.Run("success with bot_id and secret", func(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{
|
||||
Enabled: true,
|
||||
BotID: "test_bot_id",
|
||||
}
|
||||
cfg.SetSecret("test_secret")
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if ch == nil {
|
||||
t.Fatal("Expected channel to be created")
|
||||
}
|
||||
if ch.Name() != "wecom_aibot" {
|
||||
t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
|
||||
}
|
||||
// WebSocket mode must NOT implement WebhookHandler.
|
||||
if _, ok := ch.(channels.WebhookHandler); ok {
|
||||
t.Error("WebSocket mode channel should NOT implement WebhookHandler")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ws mode takes priority over webhook fields", func(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{
|
||||
Enabled: true,
|
||||
BotID: "test_bot_id",
|
||||
}
|
||||
cfg.SetSecret("test_secret")
|
||||
cfg.SetToken("also_set")
|
||||
cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if _, ok := ch.(*WeComAIBotWSChannel); !ok {
|
||||
t.Error("Expected WebSocket mode channel when both BotID+secret and Token+Key are set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error with missing bot_id", func(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{
|
||||
Enabled: true,
|
||||
}
|
||||
cfg.SetSecret("test_secret")
|
||||
messageBus := bus.NewMessageBus()
|
||||
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||
// Missing bot_id alone means neither WS mode nor webhook mode is fully configured.
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing bot_id, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error with missing secret", func(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{
|
||||
Enabled: true,
|
||||
BotID: "test_bot_id",
|
||||
}
|
||||
messageBus := bus.NewMessageBus()
|
||||
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing secret, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComAIBotWSChannelStartStop(t *testing.T) {
|
||||
cfg := config.WeComAIBotConfig{
|
||||
Enabled: true,
|
||||
BotID: "test_bot_id",
|
||||
}
|
||||
cfg.SetSecret("test_secret")
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create channel: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Start launches a background goroutine; it should not block or return an error.
|
||||
if err := ch.Start(ctx); err != nil {
|
||||
t.Fatalf("Failed to start channel: %v", err)
|
||||
}
|
||||
if !ch.IsRunning() {
|
||||
t.Error("Expected channel to be running after Start")
|
||||
}
|
||||
|
||||
// Stop should work regardless of whether the WebSocket actually connected.
|
||||
if err := ch.Stop(ctx); err != nil {
|
||||
t.Fatalf("Failed to stop channel: %v", err)
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Error("Expected channel to be stopped after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRandomID(t *testing.T) {
|
||||
ids := make(map[string]bool)
|
||||
for i := 0; i < 200; i++ {
|
||||
id := generateRandomID(10)
|
||||
if len(id) != 10 {
|
||||
t.Errorf("Expected ID length 10, got %d", len(id))
|
||||
}
|
||||
if ids[id] {
|
||||
t.Errorf("Duplicate ID generated: %s", id)
|
||||
}
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSGenerateID(t *testing.T) {
|
||||
ids := make(map[string]bool)
|
||||
for i := 0; i < 200; i++ {
|
||||
id := wsGenerateID()
|
||||
if len(id) != 10 {
|
||||
t.Errorf("Expected ID length 10, got %d", len(id))
|
||||
}
|
||||
if ids[id] {
|
||||
t.Errorf("Duplicate wsGenerateID result: %s", id)
|
||||
}
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Webhook streaming fallback tests ----
|
||||
|
||||
// makeWebhookChannel creates a started WeComAIBotChannel for testing.
|
||||
func makeWebhookChannel(t *testing.T) *WeComAIBotChannel {
|
||||
t.Helper()
|
||||
cfg := config.WeComAIBotConfig{
|
||||
Enabled: true,
|
||||
}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.SetEncodingAESKey("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG")
|
||||
ch, err := NewWeComAIBotChannel(cfg, bus.NewMessageBus())
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
wc := ch.(*WeComAIBotChannel)
|
||||
wc.ctx, wc.cancel = context.WithCancel(context.Background())
|
||||
return wc
|
||||
}
|
||||
|
||||
// makeStreamTask creates and registers a streamTask for testing.
|
||||
func makeStreamTask(t *testing.T, ch *WeComAIBotChannel, streamID, chatID string, deadline time.Time) *streamTask {
|
||||
t.Helper()
|
||||
task := &streamTask{
|
||||
StreamID: streamID,
|
||||
ChatID: chatID,
|
||||
Deadline: deadline,
|
||||
answerCh: make(chan string, 1),
|
||||
}
|
||||
task.ctx, task.cancel = context.WithCancel(ch.ctx)
|
||||
ch.taskMu.Lock()
|
||||
ch.streamTasks[streamID] = task
|
||||
ch.chatTasks[chatID] = append(ch.chatTasks[chatID], task)
|
||||
ch.taskMu.Unlock()
|
||||
return task
|
||||
}
|
||||
|
||||
// TestGetStreamResponse_ImmediateAnswer verifies that when the agent has already
|
||||
// placed its answer in answerCh, getStreamResponse returns a finish=true response
|
||||
// and fully removes the task.
|
||||
func TestGetStreamResponse_ImmediateAnswer(t *testing.T) {
|
||||
ch := makeWebhookChannel(t)
|
||||
defer ch.cancel()
|
||||
|
||||
task := makeStreamTask(t, ch, "stream-1", "chat-1", time.Now().Add(30*time.Second))
|
||||
task.answerCh <- "hello from agent"
|
||||
|
||||
result := ch.getStreamResponse(task, "ts123", "nonce123")
|
||||
if result == "" {
|
||||
t.Fatal("expected non-empty encrypted response")
|
||||
}
|
||||
|
||||
ch.taskMu.RLock()
|
||||
_, exists := ch.streamTasks["stream-1"]
|
||||
ch.taskMu.RUnlock()
|
||||
if exists {
|
||||
t.Error("task should have been removed from streamTasks after normal finish")
|
||||
}
|
||||
if !task.Finished {
|
||||
t.Error("task.Finished should be true after normal finish")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetStreamResponse_DeadlinePassed verifies that when the stream deadline has
|
||||
// elapsed (no agent reply yet), getStreamResponse closes the stream but keeps the
|
||||
// task alive so the response_url fallback can still deliver the answer.
|
||||
func TestGetStreamResponse_DeadlinePassed(t *testing.T) {
|
||||
ch := makeWebhookChannel(t)
|
||||
defer ch.cancel()
|
||||
|
||||
task := makeStreamTask(t, ch, "stream-2", "chat-2", time.Now().Add(-time.Millisecond))
|
||||
|
||||
result := ch.getStreamResponse(task, "ts456", "nonce456")
|
||||
if result == "" {
|
||||
t.Fatal("expected non-empty encrypted response")
|
||||
}
|
||||
|
||||
ch.taskMu.RLock()
|
||||
_, stillStreaming := ch.streamTasks["stream-2"]
|
||||
ch.taskMu.RUnlock()
|
||||
if stillStreaming {
|
||||
t.Error("task should have been removed from streamTasks after deadline")
|
||||
}
|
||||
if !task.StreamClosed {
|
||||
t.Error("task.StreamClosed should be true after deadline")
|
||||
}
|
||||
if task.Finished {
|
||||
t.Error("task.Finished must remain false: agent reply still expected via response_url")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetStreamResponse_StillPending verifies that when neither the agent has
|
||||
// replied nor the deadline has passed, getStreamResponse returns without altering
|
||||
// task state (client should poll again).
|
||||
func TestGetStreamResponse_StillPending(t *testing.T) {
|
||||
ch := makeWebhookChannel(t)
|
||||
defer ch.cancel()
|
||||
|
||||
task := makeStreamTask(t, ch, "stream-3", "chat-3", time.Now().Add(30*time.Second))
|
||||
|
||||
result := ch.getStreamResponse(task, "ts789", "nonce789")
|
||||
if result == "" {
|
||||
t.Fatal("expected non-empty encrypted response")
|
||||
}
|
||||
|
||||
ch.taskMu.RLock()
|
||||
_, exists := ch.streamTasks["stream-3"]
|
||||
ch.taskMu.RUnlock()
|
||||
if !exists {
|
||||
t.Error("pending task should still be in streamTasks")
|
||||
}
|
||||
if task.Finished || task.StreamClosed {
|
||||
t.Error("pending task should not be finished or stream-closed")
|
||||
}
|
||||
// Cleanup.
|
||||
ch.removeTask(task)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,295 +0,0 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
)
|
||||
|
||||
// newTestWSChannel creates a WeComAIBotWSChannel ready for unit testing.
|
||||
func newTestWSChannel(t *testing.T) *WeComAIBotWSChannel {
|
||||
t.Helper()
|
||||
cfg := config.WeComAIBotConfig{
|
||||
Enabled: true,
|
||||
BotID: "test_bot_id",
|
||||
}
|
||||
cfg.SetSecret("test_secret")
|
||||
ch, err := newWeComAIBotWSChannel(cfg, bus.NewMessageBus())
|
||||
if err != nil {
|
||||
t.Fatalf("create WS channel: %v", err)
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// TestStoreWSMedia_NilStore verifies that storeWSMedia returns an error when no
|
||||
// MediaStore has been injected.
|
||||
func TestStoreWSMedia_NilStore(t *testing.T) {
|
||||
ch := newTestWSChannel(t)
|
||||
_, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", "http://any", "", ".jpg")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no MediaStore is set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreWSMedia_HTTPError verifies that storeWSMedia propagates HTTP errors
|
||||
// from the media server.
|
||||
func TestStoreWSMedia_HTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestWSChannel(t)
|
||||
ch.SetMediaStore(media.NewFileMediaStore())
|
||||
|
||||
_, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", srv.URL, "", ".jpg")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 404")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreWSMedia_ServerUnavailable verifies that storeWSMedia returns a clear
|
||||
// error when the media server cannot be reached.
|
||||
func TestStoreWSMedia_ServerUnavailable(t *testing.T) {
|
||||
ch := newTestWSChannel(t)
|
||||
ch.SetMediaStore(media.NewFileMediaStore())
|
||||
|
||||
// Port 1 is reserved and will refuse the connection immediately.
|
||||
_, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", "http://127.0.0.1:1", "", ".jpg")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreachable server")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreWSMedia_Success_NoAES verifies the happy path: the media is downloaded,
|
||||
// a media ref is returned, and the file persists and is readable via Resolve until
|
||||
// ReleaseAll is called. The server returns no Content-Type, so the defaultExt is used.
|
||||
func TestStoreWSMedia_Success_NoAES(t *testing.T) {
|
||||
imageData := bytes.Repeat([]byte("x"), 256)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(imageData)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestWSChannel(t)
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
ref, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", srv.URL, "", ".jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if ref == "" {
|
||||
t.Fatal("expected non-empty ref")
|
||||
}
|
||||
|
||||
// File must be accessible after storeWSMedia returns (no premature deletion).
|
||||
path, err := store.Resolve(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("ref should resolve: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("file should exist at %s: %v", path, err)
|
||||
}
|
||||
if !bytes.Equal(got, imageData) {
|
||||
t.Errorf("content mismatch: got len=%d, want len=%d", len(got), len(imageData))
|
||||
}
|
||||
|
||||
// ReleaseAll must delete the file (store owns lifecycle).
|
||||
scope := channels.BuildMediaScope("wecom_aibot", "chat1", "msg1")
|
||||
if err := store.ReleaseAll(scope); err != nil {
|
||||
t.Fatalf("ReleaseAll failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("file should have been deleted by ReleaseAll, stat err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreWSMedia_MultipleMessages verifies that concurrent media messages with
|
||||
// different msgIDs do not collide and each resolve to distinct files.
|
||||
func TestStoreWSMedia_MultipleMessages(t *testing.T) {
|
||||
imageA := bytes.Repeat([]byte("a"), 64)
|
||||
imageB := bytes.Repeat([]byte("b"), 64)
|
||||
|
||||
srvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(imageA)
|
||||
}))
|
||||
defer srvA.Close()
|
||||
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(imageB)
|
||||
}))
|
||||
defer srvB.Close()
|
||||
|
||||
ch := newTestWSChannel(t)
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
refA, err := ch.storeWSMedia(context.Background(), "chat1", "msgA", srvA.URL, "", ".jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("storeWSMedia A: %v", err)
|
||||
}
|
||||
refB, err := ch.storeWSMedia(context.Background(), "chat1", "msgB", srvB.URL, "", ".jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("storeWSMedia B: %v", err)
|
||||
}
|
||||
if refA == refB {
|
||||
t.Fatal("distinct messages must produce distinct refs")
|
||||
}
|
||||
|
||||
pathA, _ := store.Resolve(refA)
|
||||
pathB, _ := store.Resolve(refB)
|
||||
if pathA == pathB {
|
||||
t.Fatal("distinct messages must be stored at distinct paths")
|
||||
}
|
||||
|
||||
gotA, _ := os.ReadFile(pathA)
|
||||
gotB, _ := os.ReadFile(pathB)
|
||||
if !bytes.Equal(gotA, imageA) {
|
||||
t.Errorf("content mismatch for message A")
|
||||
}
|
||||
if !bytes.Equal(gotB, imageB) {
|
||||
t.Errorf("content mismatch for message B")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreWSMedia_ContentTypeExt verifies that the file extension is inferred
|
||||
// from the HTTP Content-Type header and the defaultExt fallback is used when the
|
||||
// type is absent or unrecognized.
|
||||
func TestStoreWSMedia_ContentTypeExt(t *testing.T) {
|
||||
tests := []struct {
|
||||
contentType string
|
||||
wantExt string
|
||||
}{
|
||||
{"image/jpeg", ".jpg"},
|
||||
{"image/png", ".png"},
|
||||
{"video/mp4", ".mp4"},
|
||||
{"application/pdf", ".pdf"},
|
||||
{"application/zip", ".zip"},
|
||||
// With parameters stripped.
|
||||
{"video/mp4; codecs=avc1", ".mp4"},
|
||||
// Unknown type → falls back to defaultExt.
|
||||
{"", ""},
|
||||
{"application/octet-stream", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := wsMediaExtFromContentType(tc.contentType)
|
||||
if got != tc.wantExt {
|
||||
t.Errorf("wsMediaExtFromContentType(%q) = %q, want %q", tc.contentType, got, tc.wantExt)
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: server returns Content-Type: video/mp4, defaultExt is .bin.
|
||||
// The stored file should carry the .mp4 extension, not .bin.
|
||||
payload := bytes.Repeat([]byte("v"), 128)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(payload)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ch := newTestWSChannel(t)
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
ref, err := ch.storeWSMedia(context.Background(), "chat1", "vid1", srv.URL, "", ".bin")
|
||||
if err != nil {
|
||||
t.Fatalf("storeWSMedia: %v", err)
|
||||
}
|
||||
path, err := store.Resolve(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if ext := path[len(path)-4:]; ext != ".mp4" {
|
||||
t.Errorf("expected .mp4 extension from Content-Type, got %q", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitWSContent verifies byte-aware splitting of stream content.
|
||||
func TestSplitWSContent(t *testing.T) {
|
||||
t.Run("short content is not split", func(t *testing.T) {
|
||||
chunks := splitWSContent("hello", 20480)
|
||||
if len(chunks) != 1 || chunks[0] != "hello" {
|
||||
t.Fatalf("unexpected chunks: %v", chunks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ASCII content split at byte boundary", func(t *testing.T) {
|
||||
// Build a string just over the limit.
|
||||
content := strings.Repeat("a", 20481)
|
||||
chunks := splitWSContent(content, 20480)
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("expected >= 2 chunks, got %d", len(chunks))
|
||||
}
|
||||
for i, c := range chunks {
|
||||
if len(c) > 20480 {
|
||||
t.Errorf("chunk %d has %d bytes, want <= 20480", i, len(c))
|
||||
}
|
||||
}
|
||||
// Reassembled content must equal the original (possibly without leading
|
||||
// whitespace that splitWSContent trims between chunks).
|
||||
joined := strings.Join(chunks, "")
|
||||
if len(joined) < len(content)-len(chunks) {
|
||||
t.Errorf("joined length %d too short (original %d)", len(joined), len(content))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CJK content split within byte limit", func(t *testing.T) {
|
||||
// Each CJK rune is 3 bytes in UTF-8.
|
||||
// 7000 CJK chars = 21000 bytes, which exceeds 20480.
|
||||
content := strings.Repeat("\u4e2d", 7000)
|
||||
chunks := splitWSContent(content, 20480)
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("expected >= 2 chunks for 21000-byte CJK content, got %d", len(chunks))
|
||||
}
|
||||
for i, c := range chunks {
|
||||
if len(c) > 20480 {
|
||||
t.Errorf("chunk %d has %d bytes, want <= 20480", i, len(c))
|
||||
}
|
||||
// Every chunk must be valid UTF-8.
|
||||
if !strings.ContainsRune(c, '\u4e2d') && len(c) > 0 {
|
||||
// quick plausibility check — content was pure CJK
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSplitAtByteBoundary verifies the last-resort byte-boundary splitter.
|
||||
func TestSplitAtByteBoundary(t *testing.T) {
|
||||
t.Run("ASCII fits in one chunk", func(t *testing.T) {
|
||||
parts := splitAtByteBoundary("hello world", 100)
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("expected 1 part, got %d", len(parts))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("splits at byte boundary, never mid-rune", func(t *testing.T) {
|
||||
// 10 CJK characters = 30 bytes; split at 20 bytes.
|
||||
s := strings.Repeat("\u6587", 10) // 10 × 3 bytes = 30 bytes
|
||||
parts := splitAtByteBoundary(s, 20)
|
||||
for i, p := range parts {
|
||||
if len(p) > 20 {
|
||||
t.Errorf("part %d has %d bytes, want <= 20", i, len(p))
|
||||
}
|
||||
// Must be valid UTF-8 (no torn multi-byte sequences).
|
||||
for j, r := range p {
|
||||
if r == '\uFFFD' {
|
||||
t.Errorf("part %d has replacement rune at position %d: torn UTF-8", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,756 +0,0 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
wecomAPIBase = "https://qyapi.weixin.qq.com"
|
||||
)
|
||||
|
||||
// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)
|
||||
type WeComAppChannel struct {
|
||||
*channels.BaseChannel
|
||||
config config.WeComAppConfig
|
||||
client *http.Client
|
||||
accessToken string
|
||||
tokenExpiry time.Time
|
||||
tokenMu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
processedMsgs *MessageDeduplicator
|
||||
}
|
||||
|
||||
// WeComXMLMessage represents the XML message structure from WeCom
|
||||
type WeComXMLMessage struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime int64 `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"`
|
||||
Content string `xml:"Content"`
|
||||
MsgId int64 `xml:"MsgId"`
|
||||
AgentID int64 `xml:"AgentID"`
|
||||
PicUrl string `xml:"PicUrl"`
|
||||
MediaId string `xml:"MediaId"`
|
||||
Format string `xml:"Format"`
|
||||
ThumbMediaId string `xml:"ThumbMediaId"`
|
||||
LocationX float64 `xml:"Location_X"`
|
||||
LocationY float64 `xml:"Location_Y"`
|
||||
Scale int `xml:"Scale"`
|
||||
Label string `xml:"Label"`
|
||||
Title string `xml:"Title"`
|
||||
Description string `xml:"Description"`
|
||||
Url string `xml:"Url"`
|
||||
Event string `xml:"Event"`
|
||||
EventKey string `xml:"EventKey"`
|
||||
}
|
||||
|
||||
// WeComTextMessage represents text message for sending
|
||||
type WeComTextMessage struct {
|
||||
ToUser string `json:"touser"`
|
||||
MsgType string `json:"msgtype"`
|
||||
AgentID int64 `json:"agentid"`
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text"`
|
||||
Safe int `json:"safe,omitempty"`
|
||||
}
|
||||
|
||||
// WeComMarkdownMessage represents markdown message for sending
|
||||
type WeComMarkdownMessage struct {
|
||||
ToUser string `json:"touser"`
|
||||
MsgType string `json:"msgtype"`
|
||||
AgentID int64 `json:"agentid"`
|
||||
Markdown struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"markdown"`
|
||||
}
|
||||
|
||||
// WeComImageMessage represents image message for sending
|
||||
type WeComImageMessage struct {
|
||||
ToUser string `json:"touser"`
|
||||
MsgType string `json:"msgtype"`
|
||||
AgentID int64 `json:"agentid"`
|
||||
Image struct {
|
||||
MediaID string `json:"media_id"`
|
||||
} `json:"image"`
|
||||
}
|
||||
|
||||
// WeComAccessTokenResponse represents the access token API response
|
||||
type WeComAccessTokenResponse struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// WeComSendMessageResponse represents the send message API response
|
||||
type WeComSendMessageResponse struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
InvalidUser string `json:"invaliduser"`
|
||||
InvalidParty string `json:"invalidparty"`
|
||||
InvalidTag string `json:"invalidtag"`
|
||||
}
|
||||
|
||||
// PKCS7Padding adds PKCS7 padding
|
||||
type PKCS7Padding struct{}
|
||||
|
||||
// NewWeComAppChannel creates a new WeCom App channel instance
|
||||
func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) {
|
||||
if cfg.CorpID == "" || cfg.CorpSecret() == "" || cfg.AgentID == 0 {
|
||||
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
|
||||
}
|
||||
|
||||
base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom,
|
||||
channels.WithMaxMessageLength(2048),
|
||||
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||
)
|
||||
|
||||
// Client timeout must be >= the configured ReplyTimeout so the
|
||||
// per-request context deadline is always the effective limit.
|
||||
clientTimeout := 30 * time.Second
|
||||
if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout {
|
||||
clientTimeout = d
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &WeComAppChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
client: &http.Client{Timeout: clientTimeout},
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the channel name
|
||||
func (c *WeComAppChannel) Name() string {
|
||||
return "wecom_app"
|
||||
}
|
||||
|
||||
// Start initializes the WeCom App channel
|
||||
func (c *WeComAppChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("wecom_app", "Starting WeCom App channel...")
|
||||
|
||||
// Cancel the context created in the constructor to avoid a resource leak.
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
// Get initial access token
|
||||
if err := c.refreshAccessToken(); err != nil {
|
||||
logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Start token refresh goroutine
|
||||
go c.tokenRefreshLoop()
|
||||
|
||||
c.SetRunning(true)
|
||||
logger.InfoC("wecom_app", "WeCom App channel started")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the WeCom App channel
|
||||
func (c *WeComAppChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("wecom_app", "Stopping WeCom App channel...")
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
c.SetRunning(false)
|
||||
logger.InfoC("wecom_app", "WeCom App channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send sends a message to WeCom user proactively using access token
|
||||
func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return channels.ErrNotRunning
|
||||
}
|
||||
|
||||
accessToken := c.getAccessToken()
|
||||
if accessToken == "" {
|
||||
return fmt.Errorf("no valid access token available")
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom_app", "Sending message", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"preview": utils.Truncate(msg.Content, 100),
|
||||
})
|
||||
|
||||
return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)
|
||||
}
|
||||
|
||||
// SendMedia implements the channels.MediaSender interface.
|
||||
func (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return channels.ErrNotRunning
|
||||
}
|
||||
|
||||
accessToken := c.getAccessToken()
|
||||
if accessToken == "" {
|
||||
return fmt.Errorf("no valid access token available: %w", channels.ErrTemporary)
|
||||
}
|
||||
|
||||
store := c.GetMediaStore()
|
||||
if store == nil {
|
||||
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
for _, part := range msg.Parts {
|
||||
localPath, err := store.Resolve(part.Ref)
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to resolve media ref", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Map part type to WeCom media type
|
||||
var mediaType string
|
||||
switch part.Type {
|
||||
case "image":
|
||||
mediaType = "image"
|
||||
case "audio":
|
||||
mediaType = "voice"
|
||||
case "video":
|
||||
mediaType = "video"
|
||||
default:
|
||||
mediaType = "file"
|
||||
}
|
||||
|
||||
// Upload media to get media_id
|
||||
mediaID, err := c.uploadMedia(ctx, accessToken, mediaType, localPath)
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to upload media", map[string]any{
|
||||
"type": mediaType,
|
||||
"error": err.Error(),
|
||||
})
|
||||
// Fallback: send caption as text
|
||||
if part.Caption != "" {
|
||||
_ = c.sendTextMessage(ctx, accessToken, msg.ChatID, part.Caption)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Send media message using the media_id
|
||||
if mediaType == "image" {
|
||||
err = c.sendImageMessage(ctx, accessToken, msg.ChatID, mediaID)
|
||||
} else {
|
||||
// For non-image types, send as text fallback with caption
|
||||
caption := part.Caption
|
||||
if caption == "" {
|
||||
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
|
||||
}
|
||||
err = c.sendTextMessage(ctx, accessToken, msg.ChatID, caption)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploadMedia uploads a local file to WeCom temporary media storage.
|
||||
func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaType, localPath string) (string, error) {
|
||||
apiURL := fmt.Sprintf("%s/cgi-bin/media/upload?access_token=%s&type=%s",
|
||||
wecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaType))
|
||||
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
filename := filepath.Base(localPath)
|
||||
formFile, err := writer.CreateFormFile("media", filename)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create form file: %w", err)
|
||||
}
|
||||
|
||||
if _, err = io.Copy(formFile, file); err != nil {
|
||||
return "", fmt.Errorf("failed to copy file content: %w", err)
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", channels.ClassifyNetError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return "", channels.ClassifySendError(
|
||||
resp.StatusCode,
|
||||
fmt.Errorf("reading wecom upload error response: %w", readErr),
|
||||
)
|
||||
}
|
||||
return "", channels.ClassifySendError(
|
||||
resp.StatusCode,
|
||||
fmt.Errorf("wecom upload error: %s", string(respBody)),
|
||||
)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
MediaID string `json:"media_id"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse upload response: %w", err)
|
||||
}
|
||||
|
||||
if result.ErrCode != 0 {
|
||||
return "", fmt.Errorf("upload API error: %s (code: %d)", result.ErrMsg, result.ErrCode)
|
||||
}
|
||||
|
||||
return result.MediaID, nil
|
||||
}
|
||||
|
||||
// sendWeComMessage marshals payload and POSTs it to the WeCom message API.
|
||||
func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken string, payload any) error {
|
||||
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
timeout := c.config.ReplyTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return channels.ClassifyNetError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return channels.ClassifySendError(
|
||||
resp.StatusCode,
|
||||
fmt.Errorf("reading wecom_app error response: %w", readErr),
|
||||
)
|
||||
}
|
||||
return channels.ClassifySendError(
|
||||
resp.StatusCode,
|
||||
fmt.Errorf("wecom_app API error: %s", string(respBody)),
|
||||
)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var sendResp WeComSendMessageResponse
|
||||
if err := json.Unmarshal(respBody, &sendResp); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if sendResp.ErrCode != 0 {
|
||||
return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendImageMessage sends an image message using a media_id.
|
||||
func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error {
|
||||
msg := WeComImageMessage{
|
||||
ToUser: userID,
|
||||
MsgType: "image",
|
||||
AgentID: c.config.AgentID,
|
||||
}
|
||||
msg.Image.MediaID = mediaID
|
||||
return c.sendWeComMessage(ctx, accessToken, msg)
|
||||
}
|
||||
|
||||
// WebhookPath returns the path for registering on the shared HTTP server.
|
||||
func (c *WeComAppChannel) WebhookPath() string {
|
||||
if c.config.WebhookPath != "" {
|
||||
return c.config.WebhookPath
|
||||
}
|
||||
return "/webhook/wecom-app"
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler for the shared HTTP server.
|
||||
func (c *WeComAppChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleWebhook(w, r)
|
||||
}
|
||||
|
||||
// HealthPath returns the health check endpoint path.
|
||||
func (c *WeComAppChannel) HealthPath() string {
|
||||
return "/health/wecom-app"
|
||||
}
|
||||
|
||||
// HealthHandler handles health check requests.
|
||||
func (c *WeComAppChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleHealth(w, r)
|
||||
}
|
||||
|
||||
// handleWebhook handles incoming webhook requests from WeCom
|
||||
func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Log all incoming requests for debugging
|
||||
logger.DebugCF("wecom_app", "Received webhook request", map[string]any{
|
||||
"method": r.Method,
|
||||
"url": r.URL.String(),
|
||||
"path": r.URL.Path,
|
||||
"query": r.URL.RawQuery,
|
||||
})
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
// Handle verification request
|
||||
c.handleVerification(ctx, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
// Handle message callback
|
||||
c.handleMessageCallback(ctx, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
logger.WarnCF("wecom_app", "Method not allowed", map[string]any{
|
||||
"method": r.Method,
|
||||
})
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// handleVerification handles the URL verification request from WeCom
|
||||
func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
msgSignature := query.Get("msg_signature")
|
||||
timestamp := query.Get("timestamp")
|
||||
nonce := query.Get("nonce")
|
||||
echostr := query.Get("echostr")
|
||||
|
||||
logger.DebugCF("wecom_app", "Handling verification request", map[string]any{
|
||||
"msg_signature": msgSignature,
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
"echostr": echostr,
|
||||
"corp_id": c.config.CorpID,
|
||||
})
|
||||
|
||||
if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" {
|
||||
logger.ErrorC("wecom_app", "Missing parameters in verification request")
|
||||
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, echostr) {
|
||||
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
|
||||
"token": c.config.Token(),
|
||||
"msg_signature": msgSignature,
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
})
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
logger.DebugC("wecom_app", "Signature verification passed")
|
||||
|
||||
// Decrypt echostr with CorpID verification
|
||||
// For WeCom App (自建应用), receiveid should be corp_id
|
||||
logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{
|
||||
"encoding_aes_key": c.config.EncodingAESKey(),
|
||||
"corp_id": c.config.CorpID,
|
||||
})
|
||||
decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey(), c.config.CorpID)
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
|
||||
"error": err.Error(),
|
||||
"encoding_aes_key": c.config.EncodingAESKey,
|
||||
"corp_id": c.config.CorpID,
|
||||
})
|
||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{
|
||||
"decrypted": decryptedEchoStr,
|
||||
})
|
||||
|
||||
// Remove BOM and whitespace as per WeCom documentation
|
||||
// The response must be plain text without quotes, BOM, or newlines
|
||||
decryptedEchoStr = strings.TrimSpace(decryptedEchoStr)
|
||||
decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM
|
||||
w.Write([]byte(decryptedEchoStr))
|
||||
}
|
||||
|
||||
// handleMessageCallback handles incoming messages from WeCom
|
||||
func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
msgSignature := query.Get("msg_signature")
|
||||
timestamp := query.Get("timestamp")
|
||||
nonce := query.Get("nonce")
|
||||
|
||||
if msgSignature == "" || timestamp == "" || nonce == "" {
|
||||
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse XML to get encrypted message
|
||||
var encryptedMsg struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
AgentID string `xml:"AgentID"`
|
||||
}
|
||||
|
||||
if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||
logger.WarnC("wecom_app", "Message signature verification failed")
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt message with CorpID verification
|
||||
// For WeCom App (自建应用), receiveid should be corp_id
|
||||
decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey(), c.config.CorpID)
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse decrypted XML message
|
||||
var msg WeComXMLMessage
|
||||
if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Process the message with the channel's long-lived context (not the HTTP
|
||||
// request context, which is canceled as soon as we return the response).
|
||||
go c.processMessage(c.ctx, msg)
|
||||
|
||||
// Return success response immediately
|
||||
// WeCom App requires response within configured timeout (default 5 seconds)
|
||||
w.Write([]byte("success"))
|
||||
}
|
||||
|
||||
// processMessage processes the received message
|
||||
func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) {
|
||||
// Skip non-text messages for now (can be extended)
|
||||
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" {
|
||||
logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{
|
||||
"msg_type": msg.MsgType,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Message deduplication: Use msg_id to prevent duplicate processing
|
||||
// As per WeCom documentation, use msg_id for deduplication
|
||||
msgID := fmt.Sprintf("%d", msg.MsgId)
|
||||
if !c.processedMsgs.MarkMessageProcessed(msgID) {
|
||||
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
|
||||
"msg_id": msgID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
senderID := msg.FromUserName
|
||||
chatID := senderID // WeCom App uses user ID as chat ID for direct messages
|
||||
|
||||
// Build metadata
|
||||
// WeCom App only supports direct messages (private chat)
|
||||
peer := bus.Peer{Kind: "direct", ID: senderID}
|
||||
messageID := fmt.Sprintf("%d", msg.MsgId)
|
||||
|
||||
metadata := map[string]string{
|
||||
"msg_type": msg.MsgType,
|
||||
"msg_id": fmt.Sprintf("%d", msg.MsgId),
|
||||
"agent_id": fmt.Sprintf("%d", msg.AgentID),
|
||||
"platform": "wecom_app",
|
||||
"media_id": msg.MediaId,
|
||||
"create_time": fmt.Sprintf("%d", msg.CreateTime),
|
||||
}
|
||||
|
||||
content := msg.Content
|
||||
|
||||
logger.DebugCF("wecom_app", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"msg_type": msg.MsgType,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Build sender info
|
||||
appSender := bus.SenderInfo{
|
||||
Platform: "wecom",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("wecom", senderID),
|
||||
}
|
||||
|
||||
// Handle the message through the base channel
|
||||
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender)
|
||||
}
|
||||
|
||||
// tokenRefreshLoop periodically refreshes the access token
|
||||
func (c *WeComAppChannel) tokenRefreshLoop() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := c.refreshAccessToken(); err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshAccessToken gets a new access token from WeCom API
|
||||
func (c *WeComAppChannel) refreshAccessToken() error {
|
||||
apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
|
||||
wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret()))
|
||||
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to request access token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var tokenResp WeComAccessTokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.ErrCode != 0 {
|
||||
return fmt.Errorf("API error: %s (code: %d)", tokenResp.ErrMsg, tokenResp.ErrCode)
|
||||
}
|
||||
|
||||
c.tokenMu.Lock()
|
||||
c.accessToken = tokenResp.AccessToken
|
||||
c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) // Refresh 5 minutes early
|
||||
c.tokenMu.Unlock()
|
||||
|
||||
logger.DebugC("wecom_app", "Access token refreshed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAccessToken returns the current valid access token
|
||||
func (c *WeComAppChannel) getAccessToken() string {
|
||||
c.tokenMu.RLock()
|
||||
defer c.tokenMu.RUnlock()
|
||||
|
||||
if time.Now().After(c.tokenExpiry) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return c.accessToken
|
||||
}
|
||||
|
||||
// sendTextMessage sends a text message to a user.
|
||||
func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error {
|
||||
msg := WeComTextMessage{
|
||||
ToUser: userID,
|
||||
MsgType: "text",
|
||||
AgentID: c.config.AgentID,
|
||||
}
|
||||
msg.Text.Content = content
|
||||
return c.sendWeComMessage(ctx, accessToken, msg)
|
||||
}
|
||||
|
||||
// handleHealth handles health check requests
|
||||
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
status := map[string]any{
|
||||
"status": "ok",
|
||||
"running": c.IsRunning(),
|
||||
"has_token": c.getAccessToken() != "",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,499 +0,0 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人)
|
||||
// Uses webhook callback mode - simpler than WeCom App but only supports passive replies
|
||||
type WeComBotChannel struct {
|
||||
*channels.BaseChannel
|
||||
config config.WeComConfig
|
||||
client *http.Client
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
processedMsgs *MessageDeduplicator
|
||||
}
|
||||
|
||||
// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
|
||||
type WeComBotMessage struct {
|
||||
MsgID string `json:"msgid"`
|
||||
AIBotID string `json:"aibotid"`
|
||||
ChatID string `json:"chatid"` // Session ID, only present for group chats
|
||||
ChatType string `json:"chattype"` // "single" for DM, "group" for group chat
|
||||
From struct {
|
||||
UserID string `json:"userid"`
|
||||
} `json:"from"`
|
||||
ResponseURL string `json:"response_url"`
|
||||
MsgType string `json:"msgtype"` // text, image, voice, file, mixed
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text"`
|
||||
Image struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"image"`
|
||||
Voice struct {
|
||||
Content string `json:"content"` // Voice to text content
|
||||
} `json:"voice"`
|
||||
File struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"file"`
|
||||
Mixed struct {
|
||||
MsgItem []struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text"`
|
||||
Image struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"image"`
|
||||
} `json:"msg_item"`
|
||||
} `json:"mixed"`
|
||||
Quote struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text"`
|
||||
} `json:"quote"`
|
||||
}
|
||||
|
||||
// WeComBotReplyMessage represents the reply message structure
|
||||
type WeComBotReplyMessage struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// NewWeComBotChannel creates a new WeCom Bot channel instance
|
||||
func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) {
|
||||
if cfg.Token() == "" || cfg.WebhookURL == "" {
|
||||
return nil, fmt.Errorf("wecom token and webhook_url are required")
|
||||
}
|
||||
|
||||
base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom,
|
||||
channels.WithMaxMessageLength(2048),
|
||||
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||
)
|
||||
|
||||
// Client timeout must be >= the configured ReplyTimeout so the
|
||||
// per-request context deadline is always the effective limit.
|
||||
clientTimeout := 30 * time.Second
|
||||
if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout {
|
||||
clientTimeout = d
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &WeComBotChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
client: &http.Client{Timeout: clientTimeout},
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the channel name
|
||||
func (c *WeComBotChannel) Name() string {
|
||||
return "wecom"
|
||||
}
|
||||
|
||||
// Start initializes the WeCom Bot channel
|
||||
func (c *WeComBotChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("wecom", "Starting WeCom Bot channel...")
|
||||
|
||||
// Cancel the context created in the constructor to avoid a resource leak.
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
c.SetRunning(true)
|
||||
logger.InfoC("wecom", "WeCom Bot channel started")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the WeCom Bot channel
|
||||
func (c *WeComBotChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("wecom", "Stopping WeCom Bot channel...")
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
c.SetRunning(false)
|
||||
logger.InfoC("wecom", "WeCom Bot channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send sends a message to WeCom user via webhook API
|
||||
// Note: WeCom Bot can only reply within the configured timeout (default 5 seconds) of receiving a message
|
||||
// For delayed responses, we use the webhook URL
|
||||
func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return channels.ErrNotRunning
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom", "Sending message via webhook", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"preview": utils.Truncate(msg.Content, 100),
|
||||
})
|
||||
|
||||
return c.sendWebhookReply(ctx, msg.ChatID, msg.Content)
|
||||
}
|
||||
|
||||
// WebhookPath returns the path for registering on the shared HTTP server.
|
||||
func (c *WeComBotChannel) WebhookPath() string {
|
||||
if c.config.WebhookPath != "" {
|
||||
return c.config.WebhookPath
|
||||
}
|
||||
return "/webhook/wecom"
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler for the shared HTTP server.
|
||||
func (c *WeComBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleWebhook(w, r)
|
||||
}
|
||||
|
||||
// HealthPath returns the health check endpoint path.
|
||||
func (c *WeComBotChannel) HealthPath() string {
|
||||
return "/health/wecom"
|
||||
}
|
||||
|
||||
// HealthHandler handles health check requests.
|
||||
func (c *WeComBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleHealth(w, r)
|
||||
}
|
||||
|
||||
// handleWebhook handles incoming webhook requests from WeCom
|
||||
func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
// Handle verification request
|
||||
c.handleVerification(ctx, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
// Handle message callback
|
||||
c.handleMessageCallback(ctx, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// handleVerification handles the URL verification request from WeCom
|
||||
func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
msgSignature := query.Get("msg_signature")
|
||||
timestamp := query.Get("timestamp")
|
||||
nonce := query.Get("nonce")
|
||||
echostr := query.Get("echostr")
|
||||
|
||||
if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" {
|
||||
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, echostr) {
|
||||
logger.WarnC("wecom", "Signature verification failed")
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt echostr
|
||||
// For AIBOT (智能机器人), receiveid should be empty string ""
|
||||
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||
decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey(), "")
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove BOM and whitespace as per WeCom documentation
|
||||
// The response must be plain text without quotes, BOM, or newlines
|
||||
decryptedEchoStr = strings.TrimSpace(decryptedEchoStr)
|
||||
decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM
|
||||
w.Write([]byte(decryptedEchoStr))
|
||||
}
|
||||
|
||||
// handleMessageCallback handles incoming messages from WeCom
|
||||
func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
msgSignature := query.Get("msg_signature")
|
||||
timestamp := query.Get("timestamp")
|
||||
nonce := query.Get("nonce")
|
||||
|
||||
if msgSignature == "" || timestamp == "" || nonce == "" {
|
||||
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse XML to get encrypted message
|
||||
var encryptedMsg struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
AgentID string `xml:"AgentID"`
|
||||
}
|
||||
|
||||
if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||
logger.WarnC("wecom", "Message signature verification failed")
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt message
|
||||
// For AIBOT (智能机器人), receiveid should be empty string ""
|
||||
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||
decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey(), "")
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse decrypted JSON message (AIBOT uses JSON format)
|
||||
var msg WeComBotMessage
|
||||
if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Process the message with the channel's long-lived context (not the HTTP
|
||||
// request context, which is canceled as soon as we return the response).
|
||||
go c.processMessage(c.ctx, msg)
|
||||
|
||||
// Return success response immediately
|
||||
// WeCom Bot requires response within configured timeout (default 5 seconds)
|
||||
w.Write([]byte("success"))
|
||||
}
|
||||
|
||||
// processMessage processes the received message
|
||||
func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) {
|
||||
// Skip unsupported message types
|
||||
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" &&
|
||||
msg.MsgType != "mixed" {
|
||||
logger.DebugCF("wecom", "Skipping non-supported message type", map[string]any{
|
||||
"msg_type": msg.MsgType,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Message deduplication: Use msg_id to prevent duplicate processing
|
||||
msgID := msg.MsgID
|
||||
if !c.processedMsgs.MarkMessageProcessed(msgID) {
|
||||
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
|
||||
"msg_id": msgID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
senderID := msg.From.UserID
|
||||
|
||||
// Determine if this is a group chat or direct message
|
||||
// ChatType: "single" for DM, "group" for group chat
|
||||
isGroupChat := msg.ChatType == "group"
|
||||
|
||||
var chatID, peerKind, peerID string
|
||||
if isGroupChat {
|
||||
// Group chat: use ChatID as chatID and peer_id
|
||||
chatID = msg.ChatID
|
||||
peerKind = "group"
|
||||
peerID = msg.ChatID
|
||||
} else {
|
||||
// Direct message: use senderID as chatID and peer_id
|
||||
chatID = senderID
|
||||
peerKind = "direct"
|
||||
peerID = senderID
|
||||
}
|
||||
|
||||
// Extract content based on message type
|
||||
var content string
|
||||
switch msg.MsgType {
|
||||
case "text":
|
||||
content = msg.Text.Content
|
||||
case "voice":
|
||||
content = msg.Voice.Content // Voice to text content
|
||||
case "mixed":
|
||||
// For mixed messages, concatenate text items
|
||||
for _, item := range msg.Mixed.MsgItem {
|
||||
if item.MsgType == "text" {
|
||||
content += item.Text.Content
|
||||
}
|
||||
}
|
||||
case "image", "file":
|
||||
// For image and file, we don't have text content
|
||||
content = ""
|
||||
}
|
||||
|
||||
// Build metadata
|
||||
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
||||
|
||||
// In group chats, apply unified group trigger filtering
|
||||
if isGroupChat {
|
||||
respond, cleaned := c.ShouldRespondInGroup(false, content)
|
||||
if !respond {
|
||||
return
|
||||
}
|
||||
content = cleaned
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"msg_type": msg.MsgType,
|
||||
"msg_id": msg.MsgID,
|
||||
"platform": "wecom",
|
||||
"response_url": msg.ResponseURL,
|
||||
}
|
||||
if isGroupChat {
|
||||
metadata["chat_id"] = msg.ChatID
|
||||
metadata["sender_id"] = senderID
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"msg_type": msg.MsgType,
|
||||
"peer_kind": peerKind,
|
||||
"is_group_chat": isGroupChat,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Build sender info
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "wecom",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("wecom", senderID),
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle the message through the base channel
|
||||
c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender)
|
||||
}
|
||||
|
||||
// sendWebhookReply sends a reply using the webhook URL
|
||||
func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error {
|
||||
reply := WeComBotReplyMessage{
|
||||
MsgType: "text",
|
||||
}
|
||||
reply.Text.Content = content
|
||||
|
||||
jsonData, err := json.Marshal(reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal reply: %w", err)
|
||||
}
|
||||
|
||||
// Use configurable timeout (default 5 seconds)
|
||||
timeout := c.config.ReplyTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.config.WebhookURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return channels.ClassifyNetError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return channels.ClassifySendError(
|
||||
resp.StatusCode,
|
||||
fmt.Errorf("reading webhook error response: %w", readErr),
|
||||
)
|
||||
}
|
||||
return channels.ClassifySendError(
|
||||
resp.StatusCode,
|
||||
fmt.Errorf("webhook API error: %s", string(body)),
|
||||
)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Check response
|
||||
var result struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if result.ErrCode != 0 {
|
||||
return fmt.Errorf("webhook API error: %s (code: %d)", result.ErrMsg, result.ErrCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleHealth handles health check requests
|
||||
func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
status := map[string]any{
|
||||
"status": "ok",
|
||||
"running": c.IsRunning(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
|
@ -1,734 +0,0 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// generateTestAESKey generates a valid test AES key
|
||||
func generateTestAESKey() string {
|
||||
// AES key needs to be 32 bytes (256 bits) for AES-256
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
// Return base64 encoded key without padding
|
||||
return base64.StdEncoding.EncodeToString(key)[:43]
|
||||
}
|
||||
|
||||
// encryptTestMessage encrypts a message for testing (AIBOT JSON format)
|
||||
func encryptTestMessage(message, aesKey string) (string, error) {
|
||||
// Decode AES key
|
||||
key, err := base64.StdEncoding.DecodeString(aesKey + "=")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Prepare message: random(16) + msg_len(4) + msg + receiveid
|
||||
random := make([]byte, 0, 16)
|
||||
for i := range 16 {
|
||||
random = append(random, byte(i))
|
||||
}
|
||||
|
||||
msgBytes := []byte(message)
|
||||
receiveID := []byte("test_aibot_id")
|
||||
|
||||
msgLen := uint32(len(msgBytes))
|
||||
lenBytes := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(lenBytes, msgLen)
|
||||
|
||||
plainText := append(random, lenBytes...)
|
||||
plainText = append(plainText, msgBytes...)
|
||||
plainText = append(plainText, receiveID...)
|
||||
|
||||
// PKCS7 padding
|
||||
blockSize := aes.BlockSize
|
||||
padding := blockSize - len(plainText)%blockSize
|
||||
padText := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
plainText = append(plainText, padText...)
|
||||
|
||||
// Encrypt
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize])
|
||||
cipherText := make([]byte, len(plainText))
|
||||
mode.CryptBlocks(cipherText, plainText)
|
||||
|
||||
return base64.StdEncoding.EncodeToString(cipherText), nil
|
||||
}
|
||||
|
||||
// generateSignature generates a signature for testing
|
||||
func generateSignature(token, timestamp, nonce, msgEncrypt string) string {
|
||||
params := []string{token, timestamp, nonce, msgEncrypt}
|
||||
sort.Strings(params)
|
||||
str := strings.Join(params, "")
|
||||
hash := sha1.Sum([]byte(str))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
func TestNewWeComBotChannel(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
t.Run("missing token", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
_, err := NewWeComBotChannel(cfg, msgBus)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing token, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing webhook_url", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = ""
|
||||
_, err := NewWeComBotChannel(cfg, msgBus)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing webhook_url, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
cfg.AllowFrom = []string{"user1", "user2"}
|
||||
ch, err := NewWeComBotChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ch.Name() != "wecom" {
|
||||
t.Errorf("Name() = %q, want %q", ch.Name(), "wecom")
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Error("new channel should not be running")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotChannelIsAllowed(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
t.Run("empty allowlist allows all", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
cfg.AllowFrom = []string{}
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
if !ch.IsAllowed("any_user") {
|
||||
t.Error("empty allowlist should allow all users")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowlist restricts users", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
cfg.AllowFrom = []string{"allowed_user"}
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
if !ch.IsAllowed("allowed_user") {
|
||||
t.Error("allowed user should pass allowlist check")
|
||||
}
|
||||
if ch.IsAllowed("blocked_user") {
|
||||
t.Error("non-allowed user should be blocked")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotVerifySignature(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
|
||||
t.Run("valid signature", func(t *testing.T) {
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
msgEncrypt := "test_message"
|
||||
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
|
||||
|
||||
if !verifySignature(ch.config.Token(), expectedSig, timestamp, nonce, msgEncrypt) {
|
||||
t.Error("valid signature should pass verification")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid signature", func(t *testing.T) {
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
msgEncrypt := "test_message"
|
||||
|
||||
if verifySignature(ch.config.Token(), "invalid_sig", timestamp, nonce, msgEncrypt) {
|
||||
t.Error("invalid signature should fail verification")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
|
||||
cfgEmpty := config.WeComConfig{}
|
||||
cfgEmpty.SetToken("")
|
||||
cfgEmpty.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
chEmpty := &WeComBotChannel{
|
||||
config: cfgEmpty,
|
||||
}
|
||||
|
||||
if verifySignature(chEmpty.config.Token(), "any_sig", "any_ts", "any_nonce", "any_msg") {
|
||||
t.Error("empty token should reject verification (fail-closed)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotDecryptMessage(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
t.Run("decrypt without AES key", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
cfg.SetEncodingAESKey("")
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
|
||||
// Without AES key, message should be base64 decoded only
|
||||
plainText := "hello world"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
||||
|
||||
result, err := decryptMessage(encoded, ch.config.EncodingAESKey())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != plainText {
|
||||
t.Errorf("decryptMessage() = %q, want %q", result, plainText)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decrypt with AES key", func(t *testing.T) {
|
||||
aesKey := generateTestAESKey()
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
cfg.SetEncodingAESKey(aesKey)
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
|
||||
originalMsg := "<xml><Content>Hello</Content></xml>"
|
||||
encrypted, err := encryptTestMessage(originalMsg, aesKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt test message: %v", err)
|
||||
}
|
||||
|
||||
result, err := decryptMessage(encrypted, ch.config.EncodingAESKey())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != originalMsg {
|
||||
t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid base64", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
cfg.SetEncodingAESKey("")
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
|
||||
_, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey())
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid base64, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid AES key", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
cfg.SetEncodingAESKey("invalid_key")
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
|
||||
_, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey())
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid AES key, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotPKCS7Unpad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected []byte
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: []byte{},
|
||||
expected: []byte{},
|
||||
},
|
||||
{
|
||||
name: "valid padding 3 bytes",
|
||||
input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...),
|
||||
expected: []byte("hello"),
|
||||
},
|
||||
{
|
||||
name: "valid padding 16 bytes (full block)",
|
||||
input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...),
|
||||
expected: []byte("123456789012345"),
|
||||
},
|
||||
{
|
||||
name: "invalid padding larger than data",
|
||||
input: []byte{20},
|
||||
expected: nil, // should return error
|
||||
},
|
||||
{
|
||||
name: "invalid padding zero",
|
||||
input: append([]byte("test"), byte(0)),
|
||||
expected: nil, // should return error
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := pkcs7Unpad(tt.input)
|
||||
if tt.expected == nil {
|
||||
// This case should return an error
|
||||
if err == nil {
|
||||
t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("pkcs7Unpad() unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(result, tt.expected) {
|
||||
t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeComBotHandleVerification(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
aesKey := generateTestAESKey()
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.SetEncodingAESKey(aesKey)
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
|
||||
t.Run("valid verification request", func(t *testing.T) {
|
||||
echostr := "test_echostr_123"
|
||||
encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleVerification(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if w.Body.String() != echostr {
|
||||
t.Errorf("response body = %q, want %q", w.Body.String(), echostr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing parameters", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=sig×tamp=ts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleVerification(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid signature", func(t *testing.T) {
|
||||
echostr := "test_echostr"
|
||||
encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleVerification(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotHandleMessageCallback(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
aesKey := generateTestAESKey()
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.SetEncodingAESKey(aesKey)
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
|
||||
runBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
|
||||
encryptedWrapper := struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
}{
|
||||
Encrypt: encrypted,
|
||||
}
|
||||
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encrypted)
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
t.Run("valid direct message callback", func(t *testing.T) {
|
||||
w := runBotMessageCallback(t, `{
|
||||
"msgid": "test_msg_id_123",
|
||||
"aibotid": "test_aibot_id",
|
||||
"chattype": "single",
|
||||
"from": {"userid": "user123"},
|
||||
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
"msgtype": "text",
|
||||
"text": {"content": "Hello World"}
|
||||
}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if w.Body.String() != "success" {
|
||||
t.Errorf("response body = %q, want %q", w.Body.String(), "success")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid group message callback", func(t *testing.T) {
|
||||
w := runBotMessageCallback(t, `{
|
||||
"msgid": "test_msg_id_456",
|
||||
"aibotid": "test_aibot_id",
|
||||
"chatid": "group_chat_id_123",
|
||||
"chattype": "group",
|
||||
"from": {"userid": "user456"},
|
||||
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
"msgtype": "text",
|
||||
"text": {"content": "Hello Group"}
|
||||
}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if w.Body.String() != "success" {
|
||||
t.Errorf("response body = %q, want %q", w.Body.String(), "success")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing parameters", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=sig", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid XML", func(t *testing.T) {
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, "")
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
strings.NewReader("invalid xml"),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid signature", func(t *testing.T) {
|
||||
encryptedWrapper := struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
}{
|
||||
Encrypt: "encrypted_data",
|
||||
}
|
||||
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotProcessMessage(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
|
||||
t.Run("process direct text message", func(t *testing.T) {
|
||||
msg := WeComBotMessage{
|
||||
MsgID: "test_msg_id_123",
|
||||
AIBotID: "test_aibot_id",
|
||||
ChatType: "single",
|
||||
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
MsgType: "text",
|
||||
}
|
||||
msg.From.UserID = "user123"
|
||||
msg.Text.Content = "Hello World"
|
||||
|
||||
// Should not panic
|
||||
ch.processMessage(context.Background(), msg)
|
||||
})
|
||||
|
||||
t.Run("process group text message", func(t *testing.T) {
|
||||
msg := WeComBotMessage{
|
||||
MsgID: "test_msg_id_456",
|
||||
AIBotID: "test_aibot_id",
|
||||
ChatID: "group_chat_id_123",
|
||||
ChatType: "group",
|
||||
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
MsgType: "text",
|
||||
}
|
||||
msg.From.UserID = "user456"
|
||||
msg.Text.Content = "Hello Group"
|
||||
|
||||
// Should not panic
|
||||
ch.processMessage(context.Background(), msg)
|
||||
})
|
||||
|
||||
t.Run("process voice message", func(t *testing.T) {
|
||||
msg := WeComBotMessage{
|
||||
MsgID: "test_msg_id_789",
|
||||
AIBotID: "test_aibot_id",
|
||||
ChatType: "single",
|
||||
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
MsgType: "voice",
|
||||
}
|
||||
msg.From.UserID = "user123"
|
||||
msg.Voice.Content = "Voice message text"
|
||||
|
||||
// Should not panic
|
||||
ch.processMessage(context.Background(), msg)
|
||||
})
|
||||
|
||||
t.Run("skip unsupported message type", func(t *testing.T) {
|
||||
msg := WeComBotMessage{
|
||||
MsgID: "test_msg_id_000",
|
||||
AIBotID: "test_aibot_id",
|
||||
ChatType: "single",
|
||||
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
MsgType: "video",
|
||||
}
|
||||
msg.From.UserID = "user123"
|
||||
|
||||
// Should not panic
|
||||
ch.processMessage(context.Background(), msg)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotHandleWebhook(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
|
||||
t.Run("GET request calls verification", func(t *testing.T) {
|
||||
echostr := "test_echostr"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(echostr))
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encoded)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleWebhook(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST request calls message callback", func(t *testing.T) {
|
||||
encryptedWrapper := struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
}{
|
||||
Encrypt: base64.StdEncoding.EncodeToString([]byte("test")),
|
||||
}
|
||||
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleWebhook(w, req)
|
||||
|
||||
// Should not be method not allowed
|
||||
if w.Code == http.StatusMethodNotAllowed {
|
||||
t.Error("POST request should not return Method Not Allowed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported method", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPut, "/webhook/wecom", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleWebhook(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotHandleHealth(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := config.WeComConfig{}
|
||||
cfg.SetToken("test_token")
|
||||
cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
|
||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleHealth(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want %q", contentType, "application/json")
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "status") || !strings.Contains(body, "running") {
|
||||
t.Errorf("response body should contain status and running fields, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeComBotReplyMessage(t *testing.T) {
|
||||
msg := WeComBotReplyMessage{
|
||||
MsgType: "text",
|
||||
}
|
||||
msg.Text.Content = "Hello World"
|
||||
|
||||
if msg.MsgType != "text" {
|
||||
t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
|
||||
}
|
||||
if msg.Text.Content != "Hello World" {
|
||||
t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeComBotMessageStructure(t *testing.T) {
|
||||
jsonData := `{
|
||||
"msgid": "test_msg_id_123",
|
||||
"aibotid": "test_aibot_id",
|
||||
"chatid": "group_chat_id_123",
|
||||
"chattype": "group",
|
||||
"from": {"userid": "user123"},
|
||||
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
"msgtype": "text",
|
||||
"text": {"content": "Hello World"}
|
||||
}`
|
||||
|
||||
var msg WeComBotMessage
|
||||
err := json.Unmarshal([]byte(jsonData), &msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal JSON: %v", err)
|
||||
}
|
||||
|
||||
if msg.MsgID != "test_msg_id_123" {
|
||||
t.Errorf("MsgID = %q, want %q", msg.MsgID, "test_msg_id_123")
|
||||
}
|
||||
if msg.AIBotID != "test_aibot_id" {
|
||||
t.Errorf("AIBotID = %q, want %q", msg.AIBotID, "test_aibot_id")
|
||||
}
|
||||
if msg.ChatID != "group_chat_id_123" {
|
||||
t.Errorf("ChatID = %q, want %q", msg.ChatID, "group_chat_id_123")
|
||||
}
|
||||
if msg.ChatType != "group" {
|
||||
t.Errorf("ChatType = %q, want %q", msg.ChatType, "group")
|
||||
}
|
||||
if msg.From.UserID != "user123" {
|
||||
t.Errorf("From.UserID = %q, want %q", msg.From.UserID, "user123")
|
||||
}
|
||||
if msg.MsgType != "text" {
|
||||
t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
|
||||
}
|
||||
if msg.Text.Content != "Hello World" {
|
||||
t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// blockSize is the PKCS7 block size used by WeCom (32)
|
||||
const blockSize = 32
|
||||
|
||||
// computeSignature computes the WeCom message signature from the given parameters.
|
||||
// It sorts [token, timestamp, nonce, encrypt], concatenates them and returns the SHA1 hex digest.
|
||||
func computeSignature(token, timestamp, nonce, encrypt string) string {
|
||||
params := []string{token, timestamp, nonce, encrypt}
|
||||
sort.Strings(params)
|
||||
str := strings.Join(params, "")
|
||||
hash := sha1.Sum([]byte(str))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// verifySignature verifies the message signature for WeCom
|
||||
// This is a common function used by both WeCom Bot and WeCom App
|
||||
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature
|
||||
}
|
||||
|
||||
// decryptMessage decrypts the encrypted message using AES
|
||||
// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
|
||||
func decryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
|
||||
return decryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
|
||||
}
|
||||
|
||||
// decryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
|
||||
// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
|
||||
func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
|
||||
if encodingAESKey == "" {
|
||||
// No encryption, return as is (base64 decode)
|
||||
decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
aesKey, err := decodeWeComAESKey(encodingAESKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode message: %w", err)
|
||||
}
|
||||
|
||||
plainText, err := decryptAESCBC(aesKey, cipherText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return unpackWeComFrame(plainText, receiveid)
|
||||
}
|
||||
|
||||
// decodeWeComAESKey base64-decodes the 43-character EncodingAESKey (trailing "=" is
|
||||
// appended automatically) and validates that the result is exactly 32 bytes.
|
||||
// It is the single place that handles this repeated pattern in both encrypt and decrypt paths.
|
||||
func decodeWeComAESKey(encodingAESKey string) ([]byte, error) {
|
||||
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode AES key: %w", err)
|
||||
}
|
||||
if len(aesKey) != 32 {
|
||||
return nil, fmt.Errorf("invalid AES key length: %d", len(aesKey))
|
||||
}
|
||||
return aesKey, nil
|
||||
}
|
||||
|
||||
// encryptAESCBC encrypts plaintext using AES-CBC with the given key, mirroring
|
||||
// decryptAESCBC. IV = aesKey[:aes.BlockSize]. The caller must PKCS7-pad the
|
||||
// plaintext to a multiple of aes.BlockSize before calling.
|
||||
func encryptAESCBC(aesKey, plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(aesKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
iv := aesKey[:aes.BlockSize]
|
||||
ciphertext := make([]byte, len(plaintext))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plaintext)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// packWeComFrame builds the WeCom wire format:
|
||||
//
|
||||
// random(16 ASCII digits) + msg_len(4, big-endian) + msg + receiveid
|
||||
func packWeComFrame(msg, receiveid string) ([]byte, error) {
|
||||
randomBytes := make([]byte, 16)
|
||||
for i := range 16 {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(10))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate random: %w", err)
|
||||
}
|
||||
randomBytes[i] = byte('0' + n.Int64())
|
||||
}
|
||||
msgBytes := []byte(msg)
|
||||
msgLenBytes := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(msgLenBytes, uint32(len(msgBytes)))
|
||||
var buf bytes.Buffer
|
||||
buf.Write(randomBytes)
|
||||
buf.Write(msgLenBytes)
|
||||
buf.Write(msgBytes)
|
||||
buf.WriteString(receiveid)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// unpackWeComFrame parses the WeCom wire format produced by packWeComFrame.
|
||||
// If receiveid is non-empty it verifies the frame's trailing receiveid field.
|
||||
func unpackWeComFrame(data []byte, receiveid string) (string, error) {
|
||||
if len(data) < 20 {
|
||||
return "", fmt.Errorf("decrypted frame too short: %d bytes", len(data))
|
||||
}
|
||||
msgLen := binary.BigEndian.Uint32(data[16:20])
|
||||
if int(msgLen) > len(data)-20 {
|
||||
return "", fmt.Errorf("invalid message length: %d", msgLen)
|
||||
}
|
||||
msg := data[20 : 20+msgLen]
|
||||
if receiveid != "" && len(data) > 20+int(msgLen) {
|
||||
actualReceiveID := string(data[20+msgLen:])
|
||||
if actualReceiveID != receiveid {
|
||||
return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
|
||||
}
|
||||
}
|
||||
return string(msg), nil
|
||||
}
|
||||
|
||||
// decryptAESCBC decrypts ciphertext using AES-CBC with the given key.
|
||||
// IV = aesKey[:aes.BlockSize]. PKCS7 padding is stripped from the returned plaintext.
|
||||
func decryptAESCBC(aesKey, ciphertext []byte) ([]byte, error) {
|
||||
if len(ciphertext) == 0 {
|
||||
return nil, fmt.Errorf("ciphertext is empty")
|
||||
}
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size", len(ciphertext))
|
||||
}
|
||||
block, err := aes.NewCipher(aesKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
iv := aesKey[:aes.BlockSize]
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plaintext, ciphertext)
|
||||
plaintext, err = pkcs7Unpad(plaintext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unpad: %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// pkcs7Pad adds PKCS7 padding
|
||||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||
padding := blockSize - (len(data) % blockSize)
|
||||
if padding == 0 {
|
||||
padding = blockSize
|
||||
}
|
||||
padText := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
return append(data, padText...)
|
||||
}
|
||||
|
||||
// pkcs7Unpad removes PKCS7 padding with validation
|
||||
func pkcs7Unpad(data []byte) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
padding := int(data[len(data)-1])
|
||||
// WeCom uses 32-byte block size for PKCS7 padding
|
||||
if padding == 0 || padding > blockSize {
|
||||
return nil, fmt.Errorf("invalid padding size: %d", padding)
|
||||
}
|
||||
if padding > len(data) {
|
||||
return nil, fmt.Errorf("padding size larger than data")
|
||||
}
|
||||
// Verify all padding bytes
|
||||
for i := range padding {
|
||||
if data[len(data)-1-i] != byte(padding) {
|
||||
return nil, fmt.Errorf("invalid padding byte at position %d", i)
|
||||
}
|
||||
}
|
||||
return data[:len(data)-padding], nil
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package wecom
|
||||
|
||||
import "sync"
|
||||
|
||||
const wecomMaxProcessedMessages = 1000
|
||||
|
||||
// MessageDeduplicator provides thread-safe message deduplication using a circular queue (ring buffer)
|
||||
// combined with a hash map. This ensures fast O(1) lookups while naturally evicting the oldest
|
||||
// messages without causing "amnesia cliffs" when the limit is reached.
|
||||
type MessageDeduplicator struct {
|
||||
mu sync.Mutex
|
||||
msgs map[string]bool
|
||||
ring []string
|
||||
idx int
|
||||
max int
|
||||
}
|
||||
|
||||
// NewMessageDeduplicator creates a new deduplicator with the specified capacity.
|
||||
func NewMessageDeduplicator(maxEntries int) *MessageDeduplicator {
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = wecomMaxProcessedMessages
|
||||
}
|
||||
return &MessageDeduplicator{
|
||||
msgs: make(map[string]bool, maxEntries),
|
||||
ring: make([]string, maxEntries),
|
||||
max: maxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
// MarkMessageProcessed marks msgID as processed and returns false for duplicates.
|
||||
func (d *MessageDeduplicator) MarkMessageProcessed(msgID string) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
// 1. Check for duplicate
|
||||
if d.msgs[msgID] {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Evict the oldest message at our current ring position (if any)
|
||||
oldestID := d.ring[d.idx]
|
||||
if oldestID != "" {
|
||||
delete(d.msgs, oldestID)
|
||||
}
|
||||
|
||||
// 3. Store the new message
|
||||
d.msgs[msgID] = true
|
||||
d.ring[d.idx] = msgID
|
||||
|
||||
// 4. Advance the circle queue index
|
||||
d.idx = (d.idx + 1) % d.max
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMessageDeduplicator_DuplicateDetection(t *testing.T) {
|
||||
d := NewMessageDeduplicator(wecomMaxProcessedMessages)
|
||||
|
||||
if ok := d.MarkMessageProcessed("msg-1"); !ok {
|
||||
t.Fatalf("first message should be accepted")
|
||||
}
|
||||
|
||||
if ok := d.MarkMessageProcessed("msg-1"); ok {
|
||||
t.Fatalf("duplicate message should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageDeduplicator_ConcurrentSameMessage(t *testing.T) {
|
||||
d := NewMessageDeduplicator(wecomMaxProcessedMessages)
|
||||
|
||||
const goroutines = 64
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
results := make(chan bool, goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results <- d.MarkMessageProcessed("msg-concurrent")
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
successes := 0
|
||||
for ok := range results {
|
||||
if ok {
|
||||
successes++
|
||||
}
|
||||
}
|
||||
|
||||
if successes != 1 {
|
||||
t.Fatalf("expected exactly 1 successful mark, got %d", successes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageDeduplicator_CircularQueueEviction(t *testing.T) {
|
||||
// Create a deduplicator with a very small capacity to test eviction easily.
|
||||
capacity := 3
|
||||
d := NewMessageDeduplicator(capacity)
|
||||
|
||||
// Fill the queue.
|
||||
d.MarkMessageProcessed("msg-1")
|
||||
d.MarkMessageProcessed("msg-2")
|
||||
d.MarkMessageProcessed("msg-3")
|
||||
|
||||
// At this point, the queue is full. msg-1 is the oldest.
|
||||
if len(d.msgs) != 3 {
|
||||
t.Fatalf("expected map size to be 3, got %d", len(d.msgs))
|
||||
}
|
||||
|
||||
// This should evict msg-1 and add msg-4.
|
||||
if ok := d.MarkMessageProcessed("msg-4"); !ok {
|
||||
t.Fatalf("msg-4 should be accepted")
|
||||
}
|
||||
|
||||
if len(d.msgs) != 3 {
|
||||
t.Fatalf("expected map size to remain at max capacity (3), got %d", len(d.msgs))
|
||||
}
|
||||
|
||||
// msg-1 should now be forgotten (evicted).
|
||||
if ok := d.MarkMessageProcessed("msg-1"); !ok {
|
||||
t.Fatalf("msg-1 should be accepted again because it was evicted")
|
||||
}
|
||||
|
||||
// msg-2 should have been evicted when we added msg-1 back.
|
||||
if ok := d.MarkMessageProcessed("msg-2"); !ok {
|
||||
t.Fatalf("msg-2 should be accepted again because it was evicted")
|
||||
}
|
||||
}
|
||||
|
|
@ -8,12 +8,6 @@ import (
|
|||
|
||||
func init() {
|
||||
channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
return NewWeComBotChannel(cfg.Channels.WeCom, b)
|
||||
})
|
||||
channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
return NewWeComAppChannel(cfg.Channels.WeComApp, b)
|
||||
})
|
||||
channels.RegisterFactory("wecom_aibot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
return NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b)
|
||||
return NewChannel(cfg.Channels.WeCom, b)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
802
pkg/channels/wecom/media.go
Normal file
802
pkg/channels/wecom/media.go
Normal file
|
|
@ -0,0 +1,802 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/h2non/filetype"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
)
|
||||
|
||||
const (
|
||||
wecomOutboundMediaMaxBytes = 20 << 20
|
||||
wecomOutboundImageMaxBytes = 2 << 20
|
||||
wecomOutboundVoiceMaxBytes = 2 << 20
|
||||
wecomOutboundVideoMaxBytes = 10 << 20
|
||||
wecomUploadChunkMaxBytes = 512 << 10
|
||||
wecomUploadMaxChunks = 100
|
||||
wecomUploadMinBytes = 5
|
||||
)
|
||||
|
||||
type wecomOutboundMedia struct {
|
||||
MsgType string
|
||||
MediaID string
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (m *wecomOutboundMedia) respondBody() wecomRespondMsgBody {
|
||||
body := wecomRespondMsgBody{MsgType: m.MsgType}
|
||||
switch m.MsgType {
|
||||
case "file":
|
||||
body.File = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||
case "image":
|
||||
body.Image = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||
case "voice":
|
||||
body.Voice = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||
case "video":
|
||||
body.Video = &wecomVideoContent{
|
||||
MediaID: m.MediaID,
|
||||
Title: m.Title,
|
||||
Description: m.Description,
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *wecomOutboundMedia) sendBody(chatID string, chatType uint32) wecomSendMsgBody {
|
||||
body := wecomSendMsgBody{
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
MsgType: m.MsgType,
|
||||
}
|
||||
switch m.MsgType {
|
||||
case "file":
|
||||
body.File = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||
case "image":
|
||||
body.Image = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||
case "voice":
|
||||
body.Voice = &wecomMediaRefContent{MediaID: m.MediaID}
|
||||
case "video":
|
||||
body.Video = &wecomVideoContent{
|
||||
MediaID: m.MediaID,
|
||||
Title: m.Title,
|
||||
Description: m.Description,
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func decodeMediaAESKey(value string) ([]byte, error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(value)
|
||||
if err == nil && len(key) == 32 {
|
||||
return key, nil
|
||||
}
|
||||
key, err = base64.StdEncoding.DecodeString(value + "=")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode AES key: %w", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("invalid AES key length %d", len(key))
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func decryptAESCBC(key, ciphertext []byte) ([]byte, error) {
|
||||
if len(ciphertext) == 0 {
|
||||
return nil, fmt.Errorf("ciphertext is empty")
|
||||
}
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size", len(ciphertext))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
iv := key[:aes.BlockSize]
|
||||
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plaintext, ciphertext)
|
||||
return pkcs7Unpad(plaintext)
|
||||
}
|
||||
|
||||
func pkcs7Unpad(data []byte) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("empty plaintext")
|
||||
}
|
||||
padding := int(data[len(data)-1])
|
||||
if padding == 0 || padding > 32 || padding > len(data) {
|
||||
return nil, fmt.Errorf("invalid padding size %d", padding)
|
||||
}
|
||||
for i := 0; i < padding; i++ {
|
||||
if data[len(data)-1-i] != byte(padding) {
|
||||
return nil, fmt.Errorf("invalid padding byte")
|
||||
}
|
||||
}
|
||||
return data[:len(data)-padding], nil
|
||||
}
|
||||
|
||||
func inferMediaExt(contentType, fallback string) string {
|
||||
contentType = normalizeWeComContentType(contentType)
|
||||
switch contentType {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return ".jpg"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "application/pdf":
|
||||
return ".pdf"
|
||||
case "video/mp4":
|
||||
return ".mp4"
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeWeComContentType(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if idx := strings.Index(value, ";"); idx >= 0 {
|
||||
value = strings.TrimSpace(value[:idx])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isGenericWeComContentType(value string) bool {
|
||||
switch normalizeWeComContentType(value) {
|
||||
case "", "application/octet-stream", "binary/octet-stream", "application/unknown", "application/binary":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeWeComFilename(name string) string {
|
||||
name = filepath.Base(strings.TrimSpace(name))
|
||||
if name == "." || name == "/" || name == "" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func candidateWeComFilename(resourceURL, contentDisposition, fallbackName string) string {
|
||||
if _, params, err := mime.ParseMediaType(contentDisposition); err == nil {
|
||||
if name := sanitizeWeComFilename(params["filename"]); name != "" {
|
||||
return name
|
||||
}
|
||||
if name := sanitizeWeComFilename(params["filename*"]); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
if parsed, err := url.Parse(resourceURL); err == nil {
|
||||
query := parsed.Query()
|
||||
for _, key := range []string{"filename", "file_name", "name"} {
|
||||
if name := sanitizeWeComFilename(query.Get(key)); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
if name := sanitizeWeComFilename(parsed.Path); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizeWeComFilename(fallbackName)
|
||||
}
|
||||
|
||||
func detectWeComFiletype(data []byte) (string, string) {
|
||||
kind, err := filetype.Match(data)
|
||||
if err != nil || kind == filetype.Unknown {
|
||||
return "", ""
|
||||
}
|
||||
ext := ""
|
||||
if kind.Extension != "" {
|
||||
ext = "." + strings.ToLower(kind.Extension)
|
||||
}
|
||||
return normalizeWeComContentType(kind.MIME.Value), ext
|
||||
}
|
||||
|
||||
func detectWeComMediaMetadata(
|
||||
data []byte,
|
||||
fallbackName, fallbackContentType, resourceURL, contentDisposition string,
|
||||
) (string, string) {
|
||||
filename := candidateWeComFilename(resourceURL, contentDisposition, fallbackName)
|
||||
if filename == "" {
|
||||
filename = "media"
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
contentType := normalizeWeComContentType(fallbackContentType)
|
||||
detectedType, detectedExt := detectWeComFiletype(data)
|
||||
|
||||
if ext != "" && isGenericWeComContentType(contentType) {
|
||||
if byExt := normalizeWeComContentType(mime.TypeByExtension(ext)); byExt != "" {
|
||||
contentType = byExt
|
||||
}
|
||||
}
|
||||
|
||||
if detectedType != "" {
|
||||
switch {
|
||||
case contentType == "":
|
||||
contentType = detectedType
|
||||
case isGenericWeComContentType(contentType):
|
||||
contentType = detectedType
|
||||
case strings.HasPrefix(detectedType, "image/") && !strings.HasPrefix(contentType, "image/"):
|
||||
contentType = detectedType
|
||||
case strings.HasPrefix(detectedType, "audio/") && !strings.HasPrefix(contentType, "audio/"):
|
||||
contentType = detectedType
|
||||
case strings.HasPrefix(detectedType, "video/") && !strings.HasPrefix(contentType, "video/"):
|
||||
contentType = detectedType
|
||||
}
|
||||
}
|
||||
|
||||
if contentType == "" && ext != "" {
|
||||
contentType = normalizeWeComContentType(mime.TypeByExtension(ext))
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = normalizeWeComContentType(http.DetectContentType(data))
|
||||
}
|
||||
|
||||
if ext == "" {
|
||||
ext = detectedExt
|
||||
}
|
||||
if ext == "" && contentType != "" {
|
||||
if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 {
|
||||
ext = strings.ToLower(exts[0])
|
||||
}
|
||||
}
|
||||
|
||||
if filepath.Ext(filename) == "" && ext != "" {
|
||||
filename += ext
|
||||
}
|
||||
return filename, contentType
|
||||
}
|
||||
|
||||
func (c *WeComChannel) storeRemoteMedia(
|
||||
ctx context.Context,
|
||||
scope, msgID, resourceURL, aesKey, fallbackExt string,
|
||||
) (string, error) {
|
||||
store := c.GetMediaStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("no media store available")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
resp, err := c.mediaClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download media: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("download media returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, wecomOutboundMediaMaxBytes+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read media: %w", err)
|
||||
}
|
||||
if len(data) > wecomOutboundMediaMaxBytes {
|
||||
return "", fmt.Errorf("media too large")
|
||||
}
|
||||
|
||||
if aesKey != "" {
|
||||
key, keyErr := decodeMediaAESKey(aesKey)
|
||||
if keyErr != nil {
|
||||
return "", keyErr
|
||||
}
|
||||
data, err = decryptAESCBC(key, data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt media: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
filename, contentType := detectWeComMediaMetadata(
|
||||
data,
|
||||
msgID+fallbackExt,
|
||||
resp.Header.Get("Content-Type"),
|
||||
resourceURL,
|
||||
resp.Header.Get("Content-Disposition"),
|
||||
)
|
||||
ext := filepath.Ext(filename)
|
||||
if ext == "" {
|
||||
ext = inferMediaExt(contentType, fallbackExt)
|
||||
}
|
||||
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
||||
if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
|
||||
return "", fmt.Errorf("mkdir media dir: %w", mkdirErr)
|
||||
}
|
||||
tmpFile, err := os.CreateTemp(mediaDir, msgID+"-*"+ext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
if _, writeErr := tmpFile.Write(data); writeErr != nil {
|
||||
tmpFile.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("write temp file: %w", writeErr)
|
||||
}
|
||||
if closeErr := tmpFile.Close(); closeErr != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("close temp file: %w", closeErr)
|
||||
}
|
||||
|
||||
ref, err := store.Store(tmpPath, media.MediaMeta{
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
Source: "wecom",
|
||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||
}, scope)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return "", err
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func detectLocalWeComContentType(localPath, hint string) string {
|
||||
contentType := normalizeWeComContentType(hint)
|
||||
if !isGenericWeComContentType(contentType) {
|
||||
return contentType
|
||||
}
|
||||
|
||||
if kind, err := filetype.MatchFile(localPath); err == nil && kind != filetype.Unknown {
|
||||
return normalizeWeComContentType(kind.MIME.Value)
|
||||
}
|
||||
|
||||
if ext := strings.ToLower(filepath.Ext(localPath)); ext != "" {
|
||||
if byExt := normalizeWeComContentType(mime.TypeByExtension(ext)); byExt != "" {
|
||||
return byExt
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return contentType
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, err := file.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return contentType
|
||||
}
|
||||
if n == 0 {
|
||||
return contentType
|
||||
}
|
||||
return normalizeWeComContentType(http.DetectContentType(buf[:n]))
|
||||
}
|
||||
|
||||
func writeWeComTempFile(prefix, filename string, data []byte) (string, error) {
|
||||
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
||||
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("mkdir media dir: %w", err)
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
tmpFile, err := os.CreateTemp(mediaDir, prefix+"-*"+ext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
func (c *WeComChannel) downloadRemoteMediaToTemp(
|
||||
ctx context.Context,
|
||||
resourceURL, fallbackName string,
|
||||
) (string, string, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.mediaClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("download media: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return "", "", "", fmt.Errorf("download media returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, wecomOutboundMediaMaxBytes+1))
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("read media: %w", err)
|
||||
}
|
||||
if len(data) > wecomOutboundMediaMaxBytes {
|
||||
return "", "", "", fmt.Errorf("media too large")
|
||||
}
|
||||
|
||||
filename, contentType := detectWeComMediaMetadata(
|
||||
data,
|
||||
fallbackName,
|
||||
resp.Header.Get("Content-Type"),
|
||||
resourceURL,
|
||||
resp.Header.Get("Content-Disposition"),
|
||||
)
|
||||
tmpPath, err := writeWeComTempFile("wecom-outbound", filename, data)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return tmpPath, filename, contentType, nil
|
||||
}
|
||||
|
||||
func (c *WeComChannel) resolveOutboundPart(
|
||||
ctx context.Context,
|
||||
part bus.MediaPart,
|
||||
) (string, string, string, func(), error) {
|
||||
cleanup := func() {}
|
||||
filename := sanitizeWeComFilename(part.Filename)
|
||||
contentType := normalizeWeComContentType(part.ContentType)
|
||||
ref := strings.TrimSpace(part.Ref)
|
||||
|
||||
switch {
|
||||
case ref == "":
|
||||
return "", filename, contentType, cleanup, nil
|
||||
|
||||
case strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://"):
|
||||
localPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, ref, filename)
|
||||
if err != nil {
|
||||
return "", "", "", cleanup, err
|
||||
}
|
||||
return localPath, name, ct, func() { _ = os.Remove(localPath) }, nil
|
||||
|
||||
case strings.HasPrefix(ref, "media://"):
|
||||
store := c.GetMediaStore()
|
||||
if store == nil {
|
||||
return "", "", "", cleanup, fmt.Errorf("no media store available")
|
||||
}
|
||||
|
||||
localPath, meta, err := store.ResolveWithMeta(ref)
|
||||
if err != nil {
|
||||
return "", "", "", cleanup, err
|
||||
}
|
||||
if filename == "" {
|
||||
filename = sanitizeWeComFilename(meta.Filename)
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = normalizeWeComContentType(meta.ContentType)
|
||||
}
|
||||
if strings.HasPrefix(localPath, "http://") || strings.HasPrefix(localPath, "https://") {
|
||||
tmpPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, localPath, filename)
|
||||
if err != nil {
|
||||
return "", "", "", cleanup, err
|
||||
}
|
||||
return tmpPath, name, ct, func() { _ = os.Remove(tmpPath) }, nil
|
||||
}
|
||||
if _, err := os.Stat(localPath); err != nil {
|
||||
return "", "", "", cleanup, err
|
||||
}
|
||||
if filename == "" {
|
||||
filename = sanitizeWeComFilename(filepath.Base(localPath))
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = detectLocalWeComContentType(localPath, "")
|
||||
}
|
||||
return localPath, filename, contentType, cleanup, nil
|
||||
|
||||
case strings.HasPrefix(ref, "file://"):
|
||||
u, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return "", "", "", cleanup, err
|
||||
}
|
||||
localPath := u.Path
|
||||
if _, err := os.Stat(localPath); err != nil {
|
||||
return "", "", "", cleanup, err
|
||||
}
|
||||
if filename == "" {
|
||||
filename = sanitizeWeComFilename(filepath.Base(localPath))
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = detectLocalWeComContentType(localPath, "")
|
||||
}
|
||||
return localPath, filename, contentType, cleanup, nil
|
||||
|
||||
default:
|
||||
if _, err := os.Stat(ref); err != nil {
|
||||
return "", "", "", cleanup, err
|
||||
}
|
||||
if filename == "" {
|
||||
filename = sanitizeWeComFilename(filepath.Base(ref))
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = detectLocalWeComContentType(ref, "")
|
||||
}
|
||||
return ref, filename, contentType, cleanup, nil
|
||||
}
|
||||
}
|
||||
|
||||
func canWeComSendImage(contentType, ext string, size int64) bool {
|
||||
if size > wecomOutboundImageMaxBytes {
|
||||
return false
|
||||
}
|
||||
switch normalizeWeComContentType(contentType) {
|
||||
case "image/jpeg", "image/jpg", "image/png", "image/gif":
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(ext) {
|
||||
case ".jpg", ".jpeg", ".png", ".gif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func canWeComSendVoice(contentType, ext string, size int64) bool {
|
||||
if size > wecomOutboundVoiceMaxBytes {
|
||||
return false
|
||||
}
|
||||
contentType = normalizeWeComContentType(contentType)
|
||||
return strings.Contains(contentType, "amr") || strings.EqualFold(ext, ".amr")
|
||||
}
|
||||
|
||||
func canWeComSendVideo(contentType, ext string, size int64) bool {
|
||||
if size > wecomOutboundVideoMaxBytes {
|
||||
return false
|
||||
}
|
||||
return normalizeWeComContentType(contentType) == "video/mp4" || strings.EqualFold(ext, ".mp4")
|
||||
}
|
||||
|
||||
func outboundWeComMediaKind(partType, filename, contentType string, size int64) string {
|
||||
if size < wecomUploadMinBytes {
|
||||
return ""
|
||||
}
|
||||
|
||||
partType = strings.ToLower(strings.TrimSpace(partType))
|
||||
contentType = normalizeWeComContentType(contentType)
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
|
||||
if partType == "file" {
|
||||
if size <= wecomOutboundMediaMaxBytes {
|
||||
return "file"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if (partType == "image" || partType == "") && canWeComSendImage(contentType, ext, size) {
|
||||
return "image"
|
||||
}
|
||||
if (partType == "audio" || partType == "voice" || partType == "") && canWeComSendVoice(contentType, ext, size) {
|
||||
return "voice"
|
||||
}
|
||||
if (partType == "video" || partType == "") && canWeComSendVideo(contentType, ext, size) {
|
||||
return "video"
|
||||
}
|
||||
if size <= wecomOutboundMediaMaxBytes {
|
||||
return "file"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func trimWeComBytes(value string, limit int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if limit <= 0 || len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
size := 0
|
||||
var out strings.Builder
|
||||
for _, r := range value {
|
||||
width := len(string(r))
|
||||
if size+width > limit {
|
||||
break
|
||||
}
|
||||
size += width
|
||||
out.WriteRune(r)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func ensureWeComOutboundFilename(filename, localPath, contentType string) string {
|
||||
filename = sanitizeWeComFilename(filename)
|
||||
if filename == "" {
|
||||
filename = sanitizeWeComFilename(filepath.Base(localPath))
|
||||
}
|
||||
if filename == "" {
|
||||
filename = "media"
|
||||
}
|
||||
if filepath.Ext(filename) == "" {
|
||||
fallbackExt := inferMediaExt(contentType, strings.ToLower(filepath.Ext(localPath)))
|
||||
if fallbackExt != "" {
|
||||
filename += fallbackExt
|
||||
}
|
||||
}
|
||||
filename = trimWeComBytes(filename, 256)
|
||||
if filename == "" {
|
||||
return "media"
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
func buildWeComVideoContent(mediaID, filename, description string) *wecomVideoContent {
|
||||
title := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
title = trimWeComBytes(title, 64)
|
||||
if title == "" {
|
||||
title = "video"
|
||||
}
|
||||
description = trimWeComBytes(description, 512)
|
||||
return &wecomVideoContent{
|
||||
MediaID: mediaID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
func decodeWeComEnvelopeBody[T any](env wecomEnvelope) (T, error) {
|
||||
var out T
|
||||
if len(env.Body) == 0 {
|
||||
return out, fmt.Errorf("wecom response body is empty")
|
||||
}
|
||||
if err := json.Unmarshal(env.Body, &out); err != nil {
|
||||
return out, fmt.Errorf("decode wecom response body: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *WeComChannel) uploadOutboundMedia(
|
||||
ctx context.Context,
|
||||
localPath, filename, contentType string,
|
||||
part bus.MediaPart,
|
||||
) (*wecomOutboundMedia, error) {
|
||||
_ = ctx
|
||||
|
||||
contentType = detectLocalWeComContentType(localPath, contentType)
|
||||
filename = ensureWeComOutboundFilename(filename, localPath, contentType)
|
||||
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read media file: %w", err)
|
||||
}
|
||||
size := int64(len(data))
|
||||
kind := outboundWeComMediaKind(part.Type, filename, contentType, size)
|
||||
if kind == "" {
|
||||
return nil, fmt.Errorf("unsupported wecom media type or size for %q", filename)
|
||||
}
|
||||
|
||||
totalChunks := (len(data) + wecomUploadChunkMaxBytes - 1) / wecomUploadChunkMaxBytes
|
||||
if totalChunks <= 0 || totalChunks > wecomUploadMaxChunks {
|
||||
return nil, fmt.Errorf("wecom upload requires 1-%d chunks, got %d", wecomUploadMaxChunks, totalChunks)
|
||||
}
|
||||
|
||||
sum := md5.Sum(data)
|
||||
initEnv, err := c.sendCommandAck(wecomCommand{
|
||||
Cmd: wecomCmdUploadMediaInit,
|
||||
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||
Body: wecomUploadMediaInitBody{
|
||||
Type: kind,
|
||||
Filename: filename,
|
||||
TotalSize: size,
|
||||
TotalChunks: totalChunks,
|
||||
MD5: hex.EncodeToString(sum[:]),
|
||||
},
|
||||
}, wecomUploadTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initResp, err := decodeWeComEnvelopeBody[wecomUploadMediaInitResponse](initEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(initResp.UploadID) == "" {
|
||||
return nil, fmt.Errorf("wecom upload init returned empty upload_id")
|
||||
}
|
||||
|
||||
for idx, offset := 0, 0; offset < len(data); idx, offset = idx+1, offset+wecomUploadChunkMaxBytes {
|
||||
end := offset + wecomUploadChunkMaxBytes
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
sendErr := c.sendCommand(wecomCommand{
|
||||
Cmd: wecomCmdUploadMediaChunk,
|
||||
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||
Body: wecomUploadMediaChunkBody{
|
||||
UploadID: initResp.UploadID,
|
||||
ChunkIndex: idx,
|
||||
Base64Data: base64.StdEncoding.EncodeToString(data[offset:end]),
|
||||
},
|
||||
}, wecomUploadTimeout)
|
||||
if sendErr != nil {
|
||||
return nil, sendErr
|
||||
}
|
||||
}
|
||||
|
||||
finishEnv, err := c.sendCommandAck(wecomCommand{
|
||||
Cmd: wecomCmdUploadMediaEnd,
|
||||
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||
Body: wecomUploadMediaFinishBody{
|
||||
UploadID: initResp.UploadID,
|
||||
},
|
||||
}, wecomUploadTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finishResp, err := decodeWeComEnvelopeBody[wecomUploadMediaFinishResponse](finishEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(finishResp.MediaID) == "" {
|
||||
return nil, fmt.Errorf("wecom upload finish returned empty media_id")
|
||||
}
|
||||
|
||||
uploaded := &wecomOutboundMedia{
|
||||
MsgType: kind,
|
||||
MediaID: finishResp.MediaID,
|
||||
}
|
||||
if kind == "video" {
|
||||
video := buildWeComVideoContent(finishResp.MediaID, filename, part.Caption)
|
||||
uploaded.Title = video.Title
|
||||
uploaded.Description = video.Description
|
||||
}
|
||||
return uploaded, nil
|
||||
}
|
||||
|
||||
func fallbackWeComMediaText(part bus.MediaPart, kind, filename string) string {
|
||||
var lines []string
|
||||
if caption := strings.TrimSpace(part.Caption); caption != "" {
|
||||
lines = append(lines, caption)
|
||||
}
|
||||
|
||||
label := kind
|
||||
if label == "" {
|
||||
label = "media"
|
||||
}
|
||||
if filename != "" {
|
||||
lines = append(lines, fmt.Sprintf("[%s: %s]", label, filename))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("[%s attachment]", label))
|
||||
}
|
||||
|
||||
ref := strings.TrimSpace(part.Ref)
|
||||
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
|
||||
lines = append(lines, ref)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (c *WeComChannel) resolveMediaRoute(chatID string) (wecomTurn, uint32, bool) {
|
||||
if turn, ok := c.getTurn(chatID); ok {
|
||||
if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration {
|
||||
return turn, turn.ChatType, true
|
||||
}
|
||||
c.deleteTurn(chatID)
|
||||
}
|
||||
if route, ok := c.routes.Get(chatID); ok {
|
||||
return wecomTurn{ChatID: route.ChatID, ChatType: route.ChatType}, route.ChatType, false
|
||||
}
|
||||
return wecomTurn{ChatID: chatID}, 0, false
|
||||
}
|
||||
180
pkg/channels/wecom/media_test.go
Normal file
180
pkg/channels/wecom/media_test.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
basechannels "github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
)
|
||||
|
||||
func TestStoreRemoteMedia_DetectsJPEGContentTypeFromBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const jpegBase64 = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////" +
|
||||
"//////////////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////" +
|
||||
"//////////////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAVEQEBAAAAAAAAAAAAAAAAAAAABf/aAAwDAQACEAMQAAAB6A//xAAVEAEBAAAAAAAAAAAAAAAAAAAAEf/aAAgBAQABBQJf/8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAEP/aAAgBAQAGPwJf/8QAFBABAAAAAAAAAAAAAAAAAAAAEP/aAAgBAQABPyFf/9k="
|
||||
|
||||
jpegData := decodeTestBase64(t, jpegBase64)
|
||||
store := media.NewFileMediaStore()
|
||||
ch := &WeComChannel{
|
||||
BaseChannel: basechannels.NewBaseChannel("wecom", nil, nil, nil),
|
||||
mediaClient: &http.Client{
|
||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
Body: io.NopCloser(bytes.NewReader(jpegData)),
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
ref, err := ch.storeRemoteMedia(context.Background(), "test-scope", "msg-1", "https://wecom.example/media", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("storeRemoteMedia returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = store.ReleaseAll("test-scope")
|
||||
})
|
||||
|
||||
_, meta, err := store.ResolveWithMeta(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve media ref: %v", err)
|
||||
}
|
||||
if meta.ContentType != "image/jpeg" {
|
||||
t.Fatalf("expected image/jpeg content type, got %q", meta.ContentType)
|
||||
}
|
||||
if !strings.HasSuffix(meta.Filename, ".jpg") && !strings.HasSuffix(meta.Filename, ".jpeg") {
|
||||
t.Fatalf("expected jpeg filename, got %q", meta.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectWeComMediaMetadata_UsesFallbackExtensionWhenBodyUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filename, contentType := detectWeComMediaMetadata([]byte("not a real image"), "msg-2.pdf", "", "", "")
|
||||
if filename != "msg-2.pdf" {
|
||||
t.Fatalf("expected fallback filename to be preserved, got %q", filename)
|
||||
}
|
||||
if contentType != "application/pdf" {
|
||||
t.Fatalf("expected application/pdf from fallback extension, got %q", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRemoteMedia_PreservesSuffixFromURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
docxLikeData := []byte("PK\x03\x04fake office payload")
|
||||
store := media.NewFileMediaStore()
|
||||
ch := &WeComChannel{
|
||||
BaseChannel: basechannels.NewBaseChannel("wecom", nil, nil, nil),
|
||||
mediaClient: &http.Client{
|
||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
Body: io.NopCloser(bytes.NewReader(docxLikeData)),
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
ref, err := ch.storeRemoteMedia(
|
||||
context.Background(),
|
||||
"test-scope",
|
||||
"msg-docx",
|
||||
"https://wecom.example/media/report.docx?signature=1",
|
||||
"",
|
||||
".bin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("storeRemoteMedia returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = store.ReleaseAll("test-scope")
|
||||
})
|
||||
|
||||
localPath, meta, err := store.ResolveWithMeta(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve media ref: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(meta.Filename, ".docx") {
|
||||
t.Fatalf("expected docx filename, got %q", meta.Filename)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(localPath), ".docx") {
|
||||
t.Fatalf("expected docx temp path, got %q", localPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRemoteMedia_PreservesSuffixFromContentDisposition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pptxLikeData := []byte("PK\x03\x04fake office payload")
|
||||
store := media.NewFileMediaStore()
|
||||
ch := &WeComChannel{
|
||||
BaseChannel: basechannels.NewBaseChannel("wecom", nil, nil, nil),
|
||||
mediaClient: &http.Client{
|
||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Disposition": []string{`attachment; filename="slides.pptx"`},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewReader(pptxLikeData)),
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
ref, err := ch.storeRemoteMedia(
|
||||
context.Background(),
|
||||
"test-scope",
|
||||
"msg-pptx",
|
||||
"https://wecom.example/media/download",
|
||||
"",
|
||||
".bin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("storeRemoteMedia returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = store.ReleaseAll("test-scope")
|
||||
})
|
||||
|
||||
localPath, meta, err := store.ResolveWithMeta(ref)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve media ref: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(meta.Filename, ".pptx") {
|
||||
t.Fatalf("expected pptx filename, got %q", meta.Filename)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(localPath), ".pptx") {
|
||||
t.Fatalf("expected pptx temp path, got %q", localPath)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeTestBase64(t *testing.T, value string) []byte {
|
||||
t.Helper()
|
||||
|
||||
data, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(value)))
|
||||
if err != nil {
|
||||
t.Fatalf("decode base64 fixture: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
173
pkg/channels/wecom/protocol.go
Normal file
173
pkg/channels/wecom/protocol.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package wecom
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com"
|
||||
wecomCmdSubscribe = "aibot_subscribe"
|
||||
wecomCmdPing = "ping"
|
||||
wecomCmdMsgCallback = "aibot_msg_callback"
|
||||
wecomCmdEventCallback = "aibot_event_callback"
|
||||
wecomCmdRespondMsg = "aibot_respond_msg"
|
||||
wecomCmdSendMsg = "aibot_send_msg"
|
||||
wecomCmdUploadMediaInit = "aibot_upload_media_init"
|
||||
wecomCmdUploadMediaChunk = "aibot_upload_media_chunk"
|
||||
wecomCmdUploadMediaEnd = "aibot_upload_media_finish"
|
||||
)
|
||||
|
||||
type wecomEnvelope struct {
|
||||
Cmd string `json:"cmd,omitempty"`
|
||||
Headers wecomHeaders `json:"headers"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
}
|
||||
|
||||
type wecomHeaders struct {
|
||||
ReqID string `json:"req_id,omitempty"`
|
||||
}
|
||||
|
||||
type wecomCommand struct {
|
||||
Cmd string `json:"cmd"`
|
||||
Headers wecomHeaders `json:"headers"`
|
||||
Body any `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type wecomSendMsgBody struct {
|
||||
ChatID string `json:"chatid"`
|
||||
ChatType uint32 `json:"chat_type,omitempty"`
|
||||
MsgType string `json:"msgtype"`
|
||||
Markdown *wecomMarkdownContent `json:"markdown,omitempty"`
|
||||
File *wecomMediaRefContent `json:"file,omitempty"`
|
||||
Image *wecomMediaRefContent `json:"image,omitempty"`
|
||||
Voice *wecomMediaRefContent `json:"voice,omitempty"`
|
||||
Video *wecomVideoContent `json:"video,omitempty"`
|
||||
TemplateCard map[string]any `json:"template_card,omitempty"`
|
||||
}
|
||||
|
||||
type wecomRespondMsgBody struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Stream *wecomStreamContent `json:"stream,omitempty"`
|
||||
Markdown *wecomMarkdownContent `json:"markdown,omitempty"`
|
||||
File *wecomMediaRefContent `json:"file,omitempty"`
|
||||
Image *wecomMediaRefContent `json:"image,omitempty"`
|
||||
Voice *wecomMediaRefContent `json:"voice,omitempty"`
|
||||
Video *wecomVideoContent `json:"video,omitempty"`
|
||||
TemplateCard map[string]any `json:"template_card,omitempty"`
|
||||
}
|
||||
|
||||
type wecomStreamContent struct {
|
||||
ID string `json:"id"`
|
||||
Finish bool `json:"finish"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type wecomMarkdownContent struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type wecomMediaRefContent struct {
|
||||
MediaID string `json:"media_id"`
|
||||
}
|
||||
|
||||
type wecomVideoContent struct {
|
||||
MediaID string `json:"media_id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type wecomUploadMediaInitBody struct {
|
||||
Type string `json:"type"`
|
||||
Filename string `json:"filename"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
TotalChunks int `json:"total_chunks"`
|
||||
MD5 string `json:"md5,omitempty"`
|
||||
}
|
||||
|
||||
type wecomUploadMediaInitResponse struct {
|
||||
UploadID string `json:"upload_id"`
|
||||
}
|
||||
|
||||
type wecomUploadMediaChunkBody struct {
|
||||
UploadID string `json:"upload_id"`
|
||||
ChunkIndex int `json:"chunk_index"`
|
||||
Base64Data string `json:"base64_data"`
|
||||
}
|
||||
|
||||
type wecomUploadMediaFinishBody struct {
|
||||
UploadID string `json:"upload_id"`
|
||||
}
|
||||
|
||||
type wecomUploadMediaFinishResponse struct {
|
||||
Type string `json:"type"`
|
||||
MediaID string `json:"media_id"`
|
||||
CreatedAt json.RawMessage `json:"created_at"`
|
||||
}
|
||||
|
||||
type wecomIncomingMessage struct {
|
||||
MsgID string `json:"msgid"`
|
||||
AIBotID string `json:"aibotid"`
|
||||
ChatID string `json:"chatid,omitempty"`
|
||||
ChatType string `json:"chattype,omitempty"`
|
||||
From struct {
|
||||
UserID string `json:"userid"`
|
||||
} `json:"from"`
|
||||
MsgType string `json:"msgtype"`
|
||||
Text *struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text,omitempty"`
|
||||
Image *struct {
|
||||
URL string `json:"url"`
|
||||
AESKey string `json:"aeskey,omitempty"`
|
||||
} `json:"image,omitempty"`
|
||||
File *struct {
|
||||
URL string `json:"url"`
|
||||
AESKey string `json:"aeskey,omitempty"`
|
||||
} `json:"file,omitempty"`
|
||||
Video *struct {
|
||||
URL string `json:"url"`
|
||||
AESKey string `json:"aeskey,omitempty"`
|
||||
} `json:"video,omitempty"`
|
||||
Voice *struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"voice,omitempty"`
|
||||
Mixed *struct {
|
||||
MsgItem []struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Text *struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text,omitempty"`
|
||||
Image *struct {
|
||||
URL string `json:"url"`
|
||||
AESKey string `json:"aeskey,omitempty"`
|
||||
} `json:"image,omitempty"`
|
||||
File *struct {
|
||||
URL string `json:"url"`
|
||||
AESKey string `json:"aeskey,omitempty"`
|
||||
} `json:"file,omitempty"`
|
||||
} `json:"msg_item"`
|
||||
} `json:"mixed,omitempty"`
|
||||
Quote *struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Text *struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text,omitempty"`
|
||||
} `json:"quote,omitempty"`
|
||||
Event *struct {
|
||||
EventType string `json:"eventtype"`
|
||||
} `json:"event,omitempty"`
|
||||
}
|
||||
|
||||
func incomingChatID(msg wecomIncomingMessage) string {
|
||||
if msg.ChatID != "" {
|
||||
return msg.ChatID
|
||||
}
|
||||
return msg.From.UserID
|
||||
}
|
||||
|
||||
func incomingChatTypeCode(kind string) uint32 {
|
||||
if kind == "group" {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
113
pkg/channels/wecom/reqid_store.go
Normal file
113
pkg/channels/wecom/reqid_store.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type wecomRoute struct {
|
||||
ReqID string `json:"req_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType uint32 `json:"chat_type"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type reqIDStore struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
routes map[string]wecomRoute
|
||||
}
|
||||
|
||||
func newReqIDStore(path string) *reqIDStore {
|
||||
if path == "" {
|
||||
path = defaultReqIDStorePath()
|
||||
}
|
||||
s := &reqIDStore{
|
||||
path: path,
|
||||
routes: make(map[string]wecomRoute),
|
||||
}
|
||||
_ = s.load()
|
||||
return s
|
||||
}
|
||||
|
||||
func defaultReqIDStorePath() string {
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
return filepath.Join(home, ".picoclaw", "wecom", "reqid-store.json")
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "picoclaw-wecom-reqid-store.json")
|
||||
}
|
||||
|
||||
func (s *reqIDStore) Put(chatID, reqID string, chatType uint32, ttl time.Duration) error {
|
||||
if reqID == "" || chatID == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.deleteExpiredLocked(time.Now())
|
||||
s.routes[chatID] = wecomRoute{
|
||||
ReqID: reqID,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *reqIDStore) Get(chatID string) (wecomRoute, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.deleteExpiredLocked(time.Now())
|
||||
route, ok := s.routes[chatID]
|
||||
return route, ok
|
||||
}
|
||||
|
||||
func (s *reqIDStore) Delete(chatID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.routes, chatID)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *reqIDStore) load() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var routes map[string]wecomRoute
|
||||
if err := json.Unmarshal(data, &routes); err != nil {
|
||||
return err
|
||||
}
|
||||
s.routes = routes
|
||||
s.deleteExpiredLocked(time.Now())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *reqIDStore) deleteExpiredLocked(now time.Time) {
|
||||
for chatID, route := range s.routes {
|
||||
if !route.ExpiresAt.IsZero() && now.After(route.ExpiresAt) {
|
||||
delete(s.routes, chatID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *reqIDStore) saveLocked() error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s.routes, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, data, 0o600)
|
||||
}
|
||||
24
pkg/channels/wecom/reqid_store_test.go
Normal file
24
pkg/channels/wecom/reqid_store_test.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReqIDStorePersistsRoutes(t *testing.T) {
|
||||
storePath := filepath.Join(t.TempDir(), "reqids.json")
|
||||
store := newReqIDStore(storePath)
|
||||
if err := store.Put("chat-1", "req-1", 2, time.Hour); err != nil {
|
||||
t.Fatalf("Put() error = %v", err)
|
||||
}
|
||||
|
||||
reloaded := newReqIDStore(storePath)
|
||||
route, ok := reloaded.Get("chat-1")
|
||||
if !ok {
|
||||
t.Fatal("expected persisted route to be loaded")
|
||||
}
|
||||
if route.ChatID != "chat-1" || route.ReqID != "req-1" || route.ChatType != 2 {
|
||||
t.Fatalf("loaded route = %+v", route)
|
||||
}
|
||||
}
|
||||
970
pkg/channels/wecom/wecom.go
Normal file
970
pkg/channels/wecom/wecom.go
Normal file
|
|
@ -0,0 +1,970 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
wecomConnectTimeout = 15 * time.Second
|
||||
wecomCommandTimeout = 10 * time.Second
|
||||
wecomUploadTimeout = 30 * time.Second
|
||||
wecomHeartbeatInterval = 30 * time.Second
|
||||
wecomStreamMaxDuration = 5*time.Minute + 30*time.Second
|
||||
wecomStreamMinInterval = 500 * time.Millisecond
|
||||
wecomRouteTTL = 30 * time.Minute
|
||||
wecomMediaTimeout = 30 * time.Second
|
||||
wecomRecentMessageMax = 1000
|
||||
)
|
||||
|
||||
type WeComChannel struct {
|
||||
*channels.BaseChannel
|
||||
config config.WeComConfig
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
conn *websocket.Conn
|
||||
connMu sync.Mutex
|
||||
|
||||
pendingMu sync.Mutex
|
||||
pending map[string]chan wecomEnvelope
|
||||
|
||||
turnsMu sync.Mutex
|
||||
turns map[string][]wecomTurn
|
||||
|
||||
recent *recentMessageSet
|
||||
routes *reqIDStore
|
||||
mediaClient *http.Client
|
||||
commandSend func(wecomCommand, time.Duration) (wecomEnvelope, error)
|
||||
}
|
||||
|
||||
type wecomTurn struct {
|
||||
ReqID string
|
||||
ChatID string
|
||||
ChatType uint32
|
||||
StreamID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type wecomStreamer struct {
|
||||
channel *WeComChannel
|
||||
chatID string
|
||||
turn wecomTurn
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
lastSentAt time.Time
|
||||
content string
|
||||
}
|
||||
|
||||
type recentMessageSet struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]struct{}
|
||||
ring []string
|
||||
idx int
|
||||
}
|
||||
|
||||
func newRecentMessageSet(capacity int) *recentMessageSet {
|
||||
if capacity <= 0 {
|
||||
capacity = wecomRecentMessageMax
|
||||
}
|
||||
return &recentMessageSet{
|
||||
seen: make(map[string]struct{}, capacity),
|
||||
ring: make([]string, capacity),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *recentMessageSet) Mark(id string) bool {
|
||||
if id == "" {
|
||||
return true
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.seen[id]; ok {
|
||||
return false
|
||||
}
|
||||
if old := s.ring[s.idx]; old != "" {
|
||||
delete(s.seen, old)
|
||||
}
|
||||
s.ring[s.idx] = id
|
||||
s.idx = (s.idx + 1) % len(s.ring)
|
||||
s.seen[id] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func NewChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComChannel, error) {
|
||||
if cfg.BotID == "" || cfg.Secret() == "" {
|
||||
return nil, fmt.Errorf("wecom bot_id and secret are required")
|
||||
}
|
||||
if cfg.WebSocketURL == "" {
|
||||
cfg.WebSocketURL = wecomDefaultWebSocketURL
|
||||
}
|
||||
|
||||
base := channels.NewBaseChannel(
|
||||
"wecom",
|
||||
cfg,
|
||||
messageBus,
|
||||
cfg.AllowFrom,
|
||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||
)
|
||||
|
||||
ch := &WeComChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
pending: make(map[string]chan wecomEnvelope),
|
||||
turns: make(map[string][]wecomTurn),
|
||||
recent: newRecentMessageSet(wecomRecentMessageMax),
|
||||
routes: newReqIDStore(""),
|
||||
mediaClient: &http.Client{Timeout: wecomMediaTimeout},
|
||||
}
|
||||
ch.SetOwner(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (c *WeComChannel) Name() string { return "wecom" }
|
||||
|
||||
func (c *WeComChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("wecom", "Starting WeCom channel...")
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
c.SetRunning(true)
|
||||
go c.connectLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WeComChannel) Stop(_ context.Context) error {
|
||||
logger.InfoC("wecom", "Stopping WeCom channel...")
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
c.clearTurns()
|
||||
c.SetRunning(false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WeComChannel) BeginStream(_ context.Context, chatID string) (channels.Streamer, error) {
|
||||
if !c.IsRunning() {
|
||||
return nil, channels.ErrNotRunning
|
||||
}
|
||||
|
||||
turn, ok := c.getTurn(chatID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("wecom streaming unavailable: no active turn")
|
||||
}
|
||||
if time.Since(turn.CreatedAt) > wecomStreamMaxDuration {
|
||||
c.consumeTurn(chatID, turn)
|
||||
return nil, fmt.Errorf("wecom streaming unavailable: turn expired")
|
||||
}
|
||||
|
||||
return &wecomStreamer{
|
||||
channel: c,
|
||||
chatID: chatID,
|
||||
turn: turn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return channels.ErrNotRunning
|
||||
}
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if turn, ok := c.getTurn(msg.ChatID); ok {
|
||||
if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration {
|
||||
if err := c.sendStreamReply(turn, content); err == nil {
|
||||
c.consumeTurn(msg.ChatID, turn)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
c.consumeTurn(msg.ChatID, turn)
|
||||
}
|
||||
|
||||
if route, ok := c.routes.Get(msg.ChatID); ok {
|
||||
if err := c.sendActivePush(route.ChatID, route.ChatType, content); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.sendActivePush(msg.ChatID, 0, content); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return channels.ErrNotRunning
|
||||
}
|
||||
|
||||
route, chatType, hasTurn := c.resolveMediaRoute(msg.ChatID)
|
||||
chatID := route.ChatID
|
||||
if chatID == "" {
|
||||
chatID = msg.ChatID
|
||||
}
|
||||
|
||||
for _, part := range msg.Parts {
|
||||
if strings.TrimSpace(part.Ref) == "" {
|
||||
if caption := strings.TrimSpace(part.Caption); caption != "" {
|
||||
if err := c.sendActivePush(chatID, chatType, caption); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
func() {
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
uploaded, uploadErr := c.uploadOutboundMedia(ctx, localPath, filename, contentType, part)
|
||||
if uploadErr != nil {
|
||||
logger.WarnCF("wecom", "Falling back to placeholder after media upload failure", map[string]any{
|
||||
"chat_id": chatID,
|
||||
"ref": part.Ref,
|
||||
"filename": filename,
|
||||
"content_type": contentType,
|
||||
"error": uploadErr.Error(),
|
||||
})
|
||||
if hasTurn {
|
||||
if finishErr := c.sendStreamChunk(route, true, ""); finishErr != nil {
|
||||
err = finishErr
|
||||
return
|
||||
}
|
||||
c.deleteTurn(msg.ChatID)
|
||||
hasTurn = false
|
||||
}
|
||||
err = c.sendActivePush(chatID, chatType, fallbackWeComMediaText(part, "", filename))
|
||||
return
|
||||
}
|
||||
|
||||
if hasTurn {
|
||||
err = c.sendTurnMedia(route, uploaded)
|
||||
c.deleteTurn(msg.ChatID)
|
||||
hasTurn = false
|
||||
} else {
|
||||
err = c.sendActiveMedia(chatID, chatType, uploaded)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if caption := strings.TrimSpace(part.Caption); caption != "" {
|
||||
err = c.sendActivePush(chatID, chatType, caption)
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WeComChannel) connectLoop() {
|
||||
backoff := time.Second
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err := c.runConnection(); err != nil {
|
||||
logger.WarnCF("wecom", "WeCom connection lost", map[string]any{
|
||||
"error": err.Error(),
|
||||
"backoff": backoff.String(),
|
||||
})
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
}
|
||||
if backoff < time.Minute {
|
||||
backoff *= 2
|
||||
if backoff > time.Minute {
|
||||
backoff = time.Minute
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WeComChannel) runConnection() error {
|
||||
dialCtx, cancel := context.WithTimeout(c.ctx, wecomConnectTimeout)
|
||||
defer cancel()
|
||||
|
||||
conn, resp, err := websocket.DefaultDialer.DialContext(dialCtx, c.config.WebSocketURL, nil)
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", channels.ErrTemporary, err)
|
||||
}
|
||||
|
||||
c.connMu.Lock()
|
||||
c.conn = conn
|
||||
c.connMu.Unlock()
|
||||
defer func() {
|
||||
c.connMu.Lock()
|
||||
if c.conn == conn {
|
||||
c.conn = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
_ = conn.Close()
|
||||
c.clearTurns()
|
||||
}()
|
||||
|
||||
readErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
readErrCh <- c.readLoop(conn)
|
||||
}()
|
||||
|
||||
if writeErr := c.writeAndWait(conn, wecomCommand{
|
||||
Cmd: wecomCmdSubscribe,
|
||||
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||
Body: map[string]string{
|
||||
"bot_id": c.config.BotID,
|
||||
"secret": c.config.Secret(),
|
||||
},
|
||||
}, wecomCommandTimeout); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
|
||||
heartbeatDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(heartbeatDone)
|
||||
c.heartbeatLoop(conn)
|
||||
}()
|
||||
|
||||
err = <-readErrCh
|
||||
_ = conn.Close()
|
||||
<-heartbeatDone
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WeComChannel) heartbeatLoop(conn *websocket.Conn) {
|
||||
ticker := time.NewTicker(wecomHeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := c.writeAndWait(conn, wecomCommand{
|
||||
Cmd: wecomCmdPing,
|
||||
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||
}, wecomCommandTimeout); err != nil {
|
||||
logger.WarnCF("wecom", "Heartbeat failed", map[string]any{"error": err.Error()})
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WeComChannel) readLoop(conn *websocket.Conn) error {
|
||||
for {
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%w: %v", channels.ErrTemporary, err)
|
||||
}
|
||||
}
|
||||
|
||||
var env wecomEnvelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
logger.WarnCF("wecom", "Failed to parse WebSocket message", map[string]any{"error": err.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
if env.Cmd == "" && env.Headers.ReqID != "" {
|
||||
c.pendingMu.Lock()
|
||||
ch, ok := c.pending[env.Headers.ReqID]
|
||||
if ok {
|
||||
delete(c.pending, env.Headers.ReqID)
|
||||
}
|
||||
c.pendingMu.Unlock()
|
||||
if ok {
|
||||
ch <- env
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
go c.handleEnvelope(env)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WeComChannel) handleEnvelope(env wecomEnvelope) {
|
||||
switch env.Cmd {
|
||||
case wecomCmdMsgCallback:
|
||||
c.handleMessageCallback(env)
|
||||
case wecomCmdEventCallback:
|
||||
c.handleEventCallback(env)
|
||||
default:
|
||||
logger.DebugCF("wecom", "Ignoring unsupported WeCom command", map[string]any{"cmd": env.Cmd})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WeComChannel) handleEventCallback(env wecomEnvelope) {
|
||||
var msg wecomIncomingMessage
|
||||
if err := json.Unmarshal(env.Body, &msg); err != nil {
|
||||
logger.WarnCF("wecom", "Failed to parse WeCom event callback", map[string]any{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WeComChannel) handleMessageCallback(env wecomEnvelope) {
|
||||
var msg wecomIncomingMessage
|
||||
if err := json.Unmarshal(env.Body, &msg); err != nil {
|
||||
logger.WarnCF("wecom", "Failed to parse WeCom message callback", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !c.recent.Mark(msg.MsgID) {
|
||||
return
|
||||
}
|
||||
|
||||
reqID := env.Headers.ReqID
|
||||
if reqID == "" {
|
||||
logger.WarnC("wecom", "WeCom message callback missing req_id")
|
||||
return
|
||||
}
|
||||
if msg.Event != nil && msg.Event.EventType != "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.dispatchIncoming(reqID, msg); err != nil {
|
||||
logger.WarnCF("wecom", "Failed to dispatch WeCom message", map[string]any{
|
||||
"req_id": reqID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
_ = c.respondImmediate(reqID, "The WeCom message could not be processed.")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WeComChannel) dispatchIncoming(reqID string, msg wecomIncomingMessage) error {
|
||||
senderID := msg.From.UserID
|
||||
if senderID == "" {
|
||||
senderID = "unknown"
|
||||
}
|
||||
actualChatID := incomingChatID(msg)
|
||||
chatType := incomingChatTypeCode(msg.ChatType)
|
||||
peerKind := "direct"
|
||||
if msg.ChatType == "group" {
|
||||
peerKind = "group"
|
||||
}
|
||||
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "wecom",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("wecom", senderID),
|
||||
DisplayName: senderID,
|
||||
}
|
||||
|
||||
var (
|
||||
content string
|
||||
quoteText string
|
||||
mediaRefs []string
|
||||
err error
|
||||
)
|
||||
scope := channels.BuildMediaScope("wecom", actualChatID, msg.MsgID)
|
||||
switch msg.MsgType {
|
||||
case "text":
|
||||
if msg.Text != nil {
|
||||
content = strings.TrimSpace(msg.Text.Content)
|
||||
}
|
||||
case "voice":
|
||||
if msg.Voice != nil {
|
||||
content = strings.TrimSpace(msg.Voice.Content)
|
||||
}
|
||||
case "image":
|
||||
content = "[image]"
|
||||
mediaRefs, err = c.collectSingleMedia(c.ctx, scope, msg.MsgID, &mediaPayload{
|
||||
url: msg.Image.URL,
|
||||
aesKey: msg.Image.AESKey,
|
||||
}, "image", ".jpg")
|
||||
case "file":
|
||||
content = "[file]"
|
||||
mediaRefs, err = c.collectSingleMedia(c.ctx, scope, msg.MsgID, &mediaPayload{
|
||||
url: msg.File.URL,
|
||||
aesKey: msg.File.AESKey,
|
||||
}, "file", ".bin")
|
||||
case "video":
|
||||
content = "[video]"
|
||||
mediaRefs, err = c.collectSingleMedia(c.ctx, scope, msg.MsgID, &mediaPayload{
|
||||
url: msg.Video.URL,
|
||||
aesKey: msg.Video.AESKey,
|
||||
}, "video", ".mp4")
|
||||
case "mixed":
|
||||
content, mediaRefs, err = c.collectMixedMedia(c.ctx, scope, msg)
|
||||
default:
|
||||
return c.respondImmediate(reqID, "Unsupported WeCom message type: "+msg.MsgType)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Quote != nil && msg.Quote.Text != nil {
|
||||
quoteText = strings.TrimSpace(msg.Quote.Text.Content)
|
||||
if content == "" {
|
||||
content = quoteText
|
||||
}
|
||||
}
|
||||
if content == "" && len(mediaRefs) == 0 {
|
||||
return c.respondImmediate(reqID, "The WeCom message did not contain usable content.")
|
||||
}
|
||||
|
||||
turn := wecomTurn{
|
||||
ReqID: reqID,
|
||||
ChatID: actualChatID,
|
||||
ChatType: chatType,
|
||||
StreamID: randomID(10),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
c.queueTurn(actualChatID, turn)
|
||||
if err := c.routes.Put(actualChatID, reqID, chatType, wecomRouteTTL); err != nil {
|
||||
logger.WarnCF("wecom", "Failed to persist req_id route", map[string]any{
|
||||
"chat_id": actualChatID,
|
||||
"req_id": reqID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
opening := ""
|
||||
if c.config.SendThinkingMessage {
|
||||
opening = "Processing..."
|
||||
}
|
||||
if err := c.sendStreamChunk(turn, false, opening); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
peer := bus.Peer{Kind: peerKind, ID: actualChatID}
|
||||
metadata := map[string]string{
|
||||
"channel": "wecom",
|
||||
"req_id": reqID,
|
||||
"chat_id": actualChatID,
|
||||
"chat_type": msg.ChatType,
|
||||
"msg_id": msg.MsgID,
|
||||
"msg_type": msg.MsgType,
|
||||
}
|
||||
if quoteText != "" {
|
||||
metadata["quote_text"] = quoteText
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx, peer, msg.MsgID, senderID, actualChatID, content, mediaRefs, metadata, sender)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WeComChannel) collectSingleMedia(
|
||||
ctx context.Context,
|
||||
scope, msgID string,
|
||||
payload interface {
|
||||
GetURL() string
|
||||
GetAESKey() string
|
||||
},
|
||||
label, fallbackExt string,
|
||||
) ([]string, error) {
|
||||
if payload == nil || payload.GetURL() == "" {
|
||||
return nil, fmt.Errorf("%s payload is empty", label)
|
||||
}
|
||||
ref, err := c.storeRemoteMedia(ctx, scope, msgID, payload.GetURL(), payload.GetAESKey(), fallbackExt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{ref}, nil
|
||||
}
|
||||
|
||||
type mediaPayload struct {
|
||||
url string
|
||||
aesKey string
|
||||
}
|
||||
|
||||
func (p *mediaPayload) GetURL() string { return p.url }
|
||||
func (p *mediaPayload) GetAESKey() string { return p.aesKey }
|
||||
|
||||
func (c *WeComChannel) collectMixedMedia(
|
||||
ctx context.Context,
|
||||
scope string,
|
||||
msg wecomIncomingMessage,
|
||||
) (string, []string, error) {
|
||||
if msg.Mixed == nil {
|
||||
return "", nil, fmt.Errorf("mixed message is empty")
|
||||
}
|
||||
|
||||
var textParts []string
|
||||
var refs []string
|
||||
for idx, item := range msg.Mixed.MsgItem {
|
||||
switch item.MsgType {
|
||||
case "text":
|
||||
if item.Text != nil && strings.TrimSpace(item.Text.Content) != "" {
|
||||
textParts = append(textParts, strings.TrimSpace(item.Text.Content))
|
||||
}
|
||||
case "image":
|
||||
if item.Image != nil && item.Image.URL != "" {
|
||||
ref, err := c.storeRemoteMedia(
|
||||
ctx,
|
||||
scope,
|
||||
fmt.Sprintf("%s-%d", msg.MsgID, idx),
|
||||
item.Image.URL,
|
||||
item.Image.AESKey,
|
||||
".jpg",
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
case "file":
|
||||
if item.File != nil && item.File.URL != "" {
|
||||
ref, err := c.storeRemoteMedia(
|
||||
ctx,
|
||||
scope,
|
||||
fmt.Sprintf("%s-%d", msg.MsgID, idx),
|
||||
item.File.URL,
|
||||
item.File.AESKey,
|
||||
".bin",
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content := strings.Join(textParts, "\n")
|
||||
if content == "" && len(refs) > 0 {
|
||||
content = "[media]"
|
||||
}
|
||||
return content, refs, nil
|
||||
}
|
||||
|
||||
func (c *WeComChannel) respondImmediate(reqID, content string) error {
|
||||
turn := wecomTurn{
|
||||
ReqID: reqID,
|
||||
StreamID: randomID(10),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
return c.sendStreamChunk(turn, true, content)
|
||||
}
|
||||
|
||||
func (c *WeComChannel) sendStreamReply(turn wecomTurn, content string) error {
|
||||
return c.sendStreamChunk(turn, true, content)
|
||||
}
|
||||
|
||||
func (c *WeComChannel) sendStreamChunk(turn wecomTurn, finish bool, content string) error {
|
||||
return c.sendCommand(wecomCommand{
|
||||
Cmd: wecomCmdRespondMsg,
|
||||
Headers: wecomHeaders{ReqID: turn.ReqID},
|
||||
Body: wecomRespondMsgBody{
|
||||
MsgType: "stream",
|
||||
Stream: &wecomStreamContent{
|
||||
ID: turn.StreamID,
|
||||
Finish: finish,
|
||||
Content: content,
|
||||
},
|
||||
},
|
||||
}, wecomCommandTimeout)
|
||||
}
|
||||
|
||||
func (c *WeComChannel) sendTurnMedia(turn wecomTurn, uploaded *wecomOutboundMedia) error {
|
||||
if uploaded == nil {
|
||||
return fmt.Errorf("wecom outbound media is nil: %w", channels.ErrSendFailed)
|
||||
}
|
||||
if err := c.sendCommand(wecomCommand{
|
||||
Cmd: wecomCmdRespondMsg,
|
||||
Headers: wecomHeaders{ReqID: turn.ReqID},
|
||||
Body: uploaded.respondBody(),
|
||||
}, wecomCommandTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.sendStreamChunk(turn, true, "")
|
||||
}
|
||||
|
||||
func (c *WeComChannel) sendActivePush(chatID string, chatType uint32, content string) error {
|
||||
if strings.TrimSpace(chatID) == "" {
|
||||
return fmt.Errorf("empty chat ID: %w", channels.ErrSendFailed)
|
||||
}
|
||||
return c.sendCommand(wecomCommand{
|
||||
Cmd: wecomCmdSendMsg,
|
||||
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||
Body: wecomSendMsgBody{
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
MsgType: "markdown",
|
||||
Markdown: &wecomMarkdownContent{Content: content},
|
||||
},
|
||||
}, wecomCommandTimeout)
|
||||
}
|
||||
|
||||
func (c *WeComChannel) sendActiveMedia(chatID string, chatType uint32, uploaded *wecomOutboundMedia) error {
|
||||
if strings.TrimSpace(chatID) == "" {
|
||||
return fmt.Errorf("empty chat ID: %w", channels.ErrSendFailed)
|
||||
}
|
||||
if uploaded == nil {
|
||||
return fmt.Errorf("wecom outbound media is nil: %w", channels.ErrSendFailed)
|
||||
}
|
||||
return c.sendCommand(wecomCommand{
|
||||
Cmd: wecomCmdSendMsg,
|
||||
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||
Body: uploaded.sendBody(chatID, chatType),
|
||||
}, wecomCommandTimeout)
|
||||
}
|
||||
|
||||
func (c *WeComChannel) sendCommand(cmd wecomCommand, timeout time.Duration) error {
|
||||
_, err := c.sendCommandAck(cmd, timeout)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WeComChannel) sendCommandAck(cmd wecomCommand, timeout time.Duration) (wecomEnvelope, error) {
|
||||
if c.commandSend != nil {
|
||||
return c.commandSend(cmd, timeout)
|
||||
}
|
||||
return c.writeCurrentAck(cmd, timeout)
|
||||
}
|
||||
|
||||
func (c *WeComChannel) writeCurrentAck(cmd wecomCommand, timeout time.Duration) (wecomEnvelope, error) {
|
||||
c.connMu.Lock()
|
||||
conn := c.conn
|
||||
c.connMu.Unlock()
|
||||
if conn == nil {
|
||||
return wecomEnvelope{}, fmt.Errorf("wecom websocket not connected: %w", channels.ErrTemporary)
|
||||
}
|
||||
return c.writeAndWaitAck(conn, cmd, timeout)
|
||||
}
|
||||
|
||||
func (c *WeComChannel) writeAndWait(conn *websocket.Conn, cmd wecomCommand, timeout time.Duration) error {
|
||||
_, err := c.writeAndWaitAck(conn, cmd, timeout)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WeComChannel) writeAndWaitAck(
|
||||
conn *websocket.Conn,
|
||||
cmd wecomCommand,
|
||||
timeout time.Duration,
|
||||
) (wecomEnvelope, error) {
|
||||
if cmd.Headers.ReqID == "" {
|
||||
cmd.Headers.ReqID = randomID(10)
|
||||
}
|
||||
waitCh := make(chan wecomEnvelope, 1)
|
||||
c.pendingMu.Lock()
|
||||
c.pending[cmd.Headers.ReqID] = waitCh
|
||||
c.pendingMu.Unlock()
|
||||
defer func() {
|
||||
c.pendingMu.Lock()
|
||||
delete(c.pending, cmd.Headers.ReqID)
|
||||
c.pendingMu.Unlock()
|
||||
}()
|
||||
|
||||
data, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
return wecomEnvelope{}, fmt.Errorf("%w: %v", channels.ErrSendFailed, err)
|
||||
}
|
||||
c.connMu.Lock()
|
||||
err = conn.WriteMessage(websocket.TextMessage, data)
|
||||
c.connMu.Unlock()
|
||||
if err != nil {
|
||||
return wecomEnvelope{}, fmt.Errorf("%w: %v", channels.ErrTemporary, err)
|
||||
}
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case env := <-waitCh:
|
||||
if env.ErrCode != 0 {
|
||||
return wecomEnvelope{}, fmt.Errorf(
|
||||
"%w: wecom errcode=%d errmsg=%s",
|
||||
channels.ErrTemporary,
|
||||
env.ErrCode,
|
||||
env.ErrMsg,
|
||||
)
|
||||
}
|
||||
return env, nil
|
||||
case <-timer.C:
|
||||
return wecomEnvelope{}, fmt.Errorf("%w: timeout waiting for WeCom ack", channels.ErrTemporary)
|
||||
case <-c.ctx.Done():
|
||||
return wecomEnvelope{}, c.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WeComChannel) getTurn(chatID string) (wecomTurn, bool) {
|
||||
c.turnsMu.Lock()
|
||||
defer c.turnsMu.Unlock()
|
||||
queue := c.turns[chatID]
|
||||
if len(queue) == 0 {
|
||||
return wecomTurn{}, false
|
||||
}
|
||||
return queue[0], true
|
||||
}
|
||||
|
||||
func (c *WeComChannel) deleteTurn(chatID string) {
|
||||
c.turnsMu.Lock()
|
||||
defer c.turnsMu.Unlock()
|
||||
queue := c.turns[chatID]
|
||||
if len(queue) <= 1 {
|
||||
delete(c.turns, chatID)
|
||||
return
|
||||
}
|
||||
c.turns[chatID] = queue[1:]
|
||||
}
|
||||
|
||||
func (c *WeComChannel) queueTurn(chatID string, turn wecomTurn) {
|
||||
c.turnsMu.Lock()
|
||||
defer c.turnsMu.Unlock()
|
||||
c.turns[chatID] = append(c.turns[chatID], turn)
|
||||
}
|
||||
|
||||
func (c *WeComChannel) consumeTurn(chatID string, turn wecomTurn) bool {
|
||||
c.turnsMu.Lock()
|
||||
defer c.turnsMu.Unlock()
|
||||
|
||||
queue := c.turns[chatID]
|
||||
if len(queue) == 0 {
|
||||
return false
|
||||
}
|
||||
current := queue[0]
|
||||
if current.ReqID != turn.ReqID || current.StreamID != turn.StreamID {
|
||||
return false
|
||||
}
|
||||
if len(queue) == 1 {
|
||||
delete(c.turns, chatID)
|
||||
return true
|
||||
}
|
||||
c.turns[chatID] = queue[1:]
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *WeComChannel) clearTurns() {
|
||||
c.turnsMu.Lock()
|
||||
c.turns = make(map[string][]wecomTurn)
|
||||
c.turnsMu.Unlock()
|
||||
}
|
||||
|
||||
func randomID(n int) string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
if n <= 0 {
|
||||
n = 10
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
for i := range buf {
|
||||
v, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
buf[i] = alphabet[v.Int64()]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func (s *wecomStreamer) Update(ctx context.Context, content string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
if err := s.validateActiveTurn(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !s.lastSentAt.IsZero() {
|
||||
wait := time.Until(s.lastSentAt.Add(wecomStreamMinInterval))
|
||||
if wait > 0 {
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.channel.sendStreamChunk(s.turn, false, content); err != nil {
|
||||
return err
|
||||
}
|
||||
s.content = content
|
||||
s.lastSentAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wecomStreamer) Finalize(ctx context.Context, content string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
if err := s.validateActiveTurn(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.channel.sendStreamChunk(s.turn, true, content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.content = content
|
||||
s.closed = true
|
||||
s.channel.consumeTurn(s.chatID, s.turn)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wecomStreamer) Cancel(_ context.Context) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
if s.validateActiveTurn() == nil {
|
||||
_ = s.channel.sendStreamChunk(s.turn, true, s.content)
|
||||
s.channel.consumeTurn(s.chatID, s.turn)
|
||||
}
|
||||
s.closed = true
|
||||
}
|
||||
|
||||
func (s *wecomStreamer) validateActiveTurn() error {
|
||||
if time.Since(s.turn.CreatedAt) > wecomStreamMaxDuration {
|
||||
s.channel.consumeTurn(s.chatID, s.turn)
|
||||
return fmt.Errorf("wecom streaming unavailable: turn expired")
|
||||
}
|
||||
current, ok := s.channel.getTurn(s.chatID)
|
||||
if !ok || current.ReqID != s.turn.ReqID || current.StreamID != s.turn.StreamID {
|
||||
return fmt.Errorf("wecom streaming unavailable: turn no longer active")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
660
pkg/channels/wecom/wecom_test.go
Normal file
660
pkg/channels/wecom/wecom_test.go
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
)
|
||||
|
||||
func TestDispatchIncoming_UsesActualChatIDAndStoresReqIDRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := newTestWeComChannel(t, messageBus)
|
||||
|
||||
var commands []wecomCommand
|
||||
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||
commands = append(commands, cmd)
|
||||
return wecomTestAck(nil), nil
|
||||
}
|
||||
|
||||
msg := wecomIncomingMessage{
|
||||
MsgID: "msg-1",
|
||||
ChatID: "chat-1",
|
||||
ChatType: "direct",
|
||||
MsgType: "text",
|
||||
Text: &struct {
|
||||
Content string `json:"content"`
|
||||
}{Content: "hello"},
|
||||
}
|
||||
msg.From.UserID = "user-1"
|
||||
|
||||
if err := ch.dispatchIncoming("req-1", msg); err != nil {
|
||||
t.Fatalf("dispatchIncoming() error = %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
if inbound.ChatID != "chat-1" {
|
||||
t.Fatalf("inbound ChatID = %q, want chat-1", inbound.ChatID)
|
||||
}
|
||||
if inbound.MessageID != "msg-1" {
|
||||
t.Fatalf("inbound MessageID = %q, want msg-1", inbound.MessageID)
|
||||
}
|
||||
if inbound.Peer.ID != "chat-1" {
|
||||
t.Fatalf("inbound Peer.ID = %q, want chat-1", inbound.Peer.ID)
|
||||
}
|
||||
if inbound.Metadata["req_id"] != "req-1" {
|
||||
t.Fatalf("inbound req_id = %q, want req-1", inbound.Metadata["req_id"])
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected inbound message to be published")
|
||||
}
|
||||
|
||||
turn, ok := ch.getTurn("chat-1")
|
||||
if !ok {
|
||||
t.Fatal("expected queued turn for chat-1")
|
||||
}
|
||||
if turn.ReqID != "req-1" {
|
||||
t.Fatalf("turn.ReqID = %q, want req-1", turn.ReqID)
|
||||
}
|
||||
|
||||
route, ok := ch.routes.Get("chat-1")
|
||||
if !ok {
|
||||
t.Fatal("expected persisted route for chat-1")
|
||||
}
|
||||
if route.ReqID != "req-1" || route.ChatType != 1 {
|
||||
t.Fatalf("route = %+v", route)
|
||||
}
|
||||
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("expected 1 opening command, got %d", len(commands))
|
||||
}
|
||||
if commands[0].Cmd != wecomCmdRespondMsg {
|
||||
t.Fatalf("opening command = %q, want %q", commands[0].Cmd, wecomCmdRespondMsg)
|
||||
}
|
||||
if commands[0].Headers.ReqID != "req-1" {
|
||||
t.Fatalf("opening req_id = %q, want req-1", commands[0].Headers.ReqID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewChannel_DoesNotRegisterMessageSplitLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := newTestWeComChannel(t, bus.NewMessageBus())
|
||||
if got := ch.MaxMessageLength(); got != 0 {
|
||||
t.Fatalf("MaxMessageLength() = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeginStream_UpdateAndFinalize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := newTestWeComChannel(t, bus.NewMessageBus())
|
||||
ch.SetRunning(true)
|
||||
ch.queueTurn("chat-1", wecomTurn{
|
||||
ReqID: "req-1",
|
||||
ChatID: "chat-1",
|
||||
ChatType: 1,
|
||||
StreamID: "stream-1",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
var commands []wecomCommand
|
||||
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||
commands = append(commands, cmd)
|
||||
return wecomTestAck(nil), nil
|
||||
}
|
||||
|
||||
streamer, err := ch.BeginStream(context.Background(), "chat-1")
|
||||
if err != nil {
|
||||
t.Fatalf("BeginStream() error = %v", err)
|
||||
}
|
||||
if err := streamer.Update(context.Background(), "draft"); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if err := streamer.Finalize(context.Background(), "final"); err != nil {
|
||||
t.Fatalf("Finalize() error = %v", err)
|
||||
}
|
||||
|
||||
if len(commands) != 2 {
|
||||
t.Fatalf("expected 2 commands, got %d", len(commands))
|
||||
}
|
||||
for i, wantFinish := range []bool{false, true} {
|
||||
if commands[i].Cmd != wecomCmdRespondMsg {
|
||||
t.Fatalf("command[%d].Cmd = %q, want %q", i, commands[i].Cmd, wecomCmdRespondMsg)
|
||||
}
|
||||
body, ok := commands[i].Body.(wecomRespondMsgBody)
|
||||
if !ok {
|
||||
t.Fatalf("command[%d] body type = %T", i, commands[i].Body)
|
||||
}
|
||||
if body.Stream == nil {
|
||||
t.Fatalf("command[%d] missing stream body", i)
|
||||
}
|
||||
if body.Stream.ID != "stream-1" {
|
||||
t.Fatalf("command[%d] stream id = %q, want stream-1", i, body.Stream.ID)
|
||||
}
|
||||
if body.Stream.Finish != wantFinish {
|
||||
t.Fatalf("command[%d] finish = %v, want %v", i, body.Stream.Finish, wantFinish)
|
||||
}
|
||||
}
|
||||
if body := commands[0].Body.(wecomRespondMsgBody); body.Stream.Content != "draft" {
|
||||
t.Fatalf("update content = %q, want draft", body.Stream.Content)
|
||||
}
|
||||
if body := commands[1].Body.(wecomRespondMsgBody); body.Stream.Content != "final" {
|
||||
t.Fatalf("final content = %q, want final", body.Stream.Content)
|
||||
}
|
||||
if _, ok := ch.getTurn("chat-1"); ok {
|
||||
t.Fatal("expected turn to be consumed after Finalize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := newTestWeComChannel(t, bus.NewMessageBus())
|
||||
ch.SetRunning(true)
|
||||
ch.queueTurn("chat-1", wecomTurn{
|
||||
ReqID: "req-1",
|
||||
ChatID: "chat-1",
|
||||
ChatType: 1,
|
||||
StreamID: "stream-1",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
ch.queueTurn("chat-1", wecomTurn{
|
||||
ReqID: "req-2",
|
||||
ChatID: "chat-1",
|
||||
ChatType: 1,
|
||||
StreamID: "stream-2",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err := ch.routes.Put("chat-1", "req-2", 1, time.Hour); err != nil {
|
||||
t.Fatalf("Put() error = %v", err)
|
||||
}
|
||||
|
||||
var commands []wecomCommand
|
||||
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||
commands = append(commands, cmd)
|
||||
if len(commands) == 1 && cmd.Cmd == wecomCmdRespondMsg {
|
||||
return wecomEnvelope{}, errors.New("stream send failed")
|
||||
}
|
||||
return wecomTestAck(nil), nil
|
||||
}
|
||||
|
||||
if err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||
Channel: "wecom",
|
||||
ChatID: "chat-1",
|
||||
Content: "hello",
|
||||
}); err != nil {
|
||||
t.Fatalf("Send() error = %v", err)
|
||||
}
|
||||
|
||||
if len(commands) != 2 {
|
||||
t.Fatalf("expected 2 commands, got %d", len(commands))
|
||||
}
|
||||
if commands[0].Cmd != wecomCmdRespondMsg || commands[0].Headers.ReqID != "req-1" {
|
||||
t.Fatalf("first command = %+v", commands[0])
|
||||
}
|
||||
if commands[1].Cmd != wecomCmdSendMsg {
|
||||
t.Fatalf("second command = %q, want %q", commands[1].Cmd, wecomCmdSendMsg)
|
||||
}
|
||||
body, ok := commands[1].Body.(wecomSendMsgBody)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected send body type %T", commands[1].Body)
|
||||
}
|
||||
if body.ChatID != "chat-1" {
|
||||
t.Fatalf("send chatid = %q, want chat-1", body.ChatID)
|
||||
}
|
||||
if body.ChatType != 1 {
|
||||
t.Fatalf("send chat_type = %d, want 1", body.ChatType)
|
||||
}
|
||||
|
||||
nextTurn, ok := ch.getTurn("chat-1")
|
||||
if !ok {
|
||||
t.Fatal("expected second turn to remain queued")
|
||||
}
|
||||
if nextTurn.ReqID != "req-2" {
|
||||
t.Fatalf("next queued req_id = %q, want req-2", nextTurn.ReqID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSend_DoesNotSplitStreamReply(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := newTestWeComChannel(t, bus.NewMessageBus())
|
||||
ch.SetRunning(true)
|
||||
ch.queueTurn("chat-1", wecomTurn{
|
||||
ReqID: "req-1",
|
||||
ChatID: "chat-1",
|
||||
ChatType: 1,
|
||||
StreamID: "stream-1",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
var commands []wecomCommand
|
||||
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||
commands = append(commands, cmd)
|
||||
return wecomTestAck(nil), nil
|
||||
}
|
||||
|
||||
content := strings.Repeat("\u4e2d", 30000)
|
||||
if err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||
Channel: "wecom",
|
||||
ChatID: "chat-1",
|
||||
Content: content,
|
||||
}); err != nil {
|
||||
t.Fatalf("Send() error = %v", err)
|
||||
}
|
||||
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("expected 1 stream command, got %d", len(commands))
|
||||
}
|
||||
body, ok := commands[0].Body.(wecomRespondMsgBody)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected body type %T", commands[0].Body)
|
||||
}
|
||||
if body.Stream == nil || !body.Stream.Finish {
|
||||
t.Fatalf("stream body = %+v", body.Stream)
|
||||
}
|
||||
if body.Stream.Content != content {
|
||||
t.Fatalf("stream content length = %d, want %d", len(body.Stream.Content), len(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSend_DoesNotSplitActivePush(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := newTestWeComChannel(t, bus.NewMessageBus())
|
||||
ch.SetRunning(true)
|
||||
|
||||
var commands []wecomCommand
|
||||
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||
commands = append(commands, cmd)
|
||||
return wecomTestAck(nil), nil
|
||||
}
|
||||
|
||||
content := strings.Repeat("a", 30000)
|
||||
if err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||
Channel: "wecom",
|
||||
ChatID: "chat-1",
|
||||
Content: content,
|
||||
}); err != nil {
|
||||
t.Fatalf("Send() error = %v", err)
|
||||
}
|
||||
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("expected 1 send command, got %d", len(commands))
|
||||
}
|
||||
if commands[0].Cmd != wecomCmdSendMsg {
|
||||
t.Fatalf("command = %q, want %q", commands[0].Cmd, wecomCmdSendMsg)
|
||||
}
|
||||
body, ok := commands[0].Body.(wecomSendMsgBody)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected body type %T", commands[0].Body)
|
||||
}
|
||||
if body.Markdown == nil || body.Markdown.Content != content {
|
||||
t.Fatalf("markdown content length = %d, want %d", len(body.Markdown.Content), len(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_SendsActiveImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := newTestWeComChannel(t, bus.NewMessageBus())
|
||||
ch.SetRunning(true)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
imageData := wecomTestJPEGData(t)
|
||||
imagePath := filepath.Join(t.TempDir(), "photo.jpg")
|
||||
if err := os.WriteFile(imagePath, imageData, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
ref, err := store.Store(imagePath, media.MediaMeta{
|
||||
Filename: "photo.jpg",
|
||||
ContentType: "image/jpeg",
|
||||
Source: "test",
|
||||
CleanupPolicy: media.CleanupPolicyForgetOnly,
|
||||
}, "scope-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
var commands []wecomCommand
|
||||
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||
commands = append(commands, cmd)
|
||||
switch cmd.Cmd {
|
||||
case wecomCmdUploadMediaInit:
|
||||
return wecomTestAck(wecomUploadMediaInitResponse{UploadID: "upload-1"}), nil
|
||||
case wecomCmdUploadMediaEnd:
|
||||
return wecomTestAck(wecomUploadMediaFinishResponse{
|
||||
Type: "image",
|
||||
MediaID: "media-1",
|
||||
}), nil
|
||||
default:
|
||||
return wecomTestAck(nil), nil
|
||||
}
|
||||
}
|
||||
|
||||
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
Channel: "wecom",
|
||||
ChatID: "chat-1",
|
||||
Parts: []bus.MediaPart{{
|
||||
Ref: ref,
|
||||
Type: "image",
|
||||
Filename: "photo.jpg",
|
||||
ContentType: "image/jpeg",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
|
||||
if len(commands) != 4 {
|
||||
t.Fatalf("expected 4 commands, got %d", len(commands))
|
||||
}
|
||||
if commands[0].Cmd != wecomCmdUploadMediaInit {
|
||||
t.Fatalf("first command = %q, want %q", commands[0].Cmd, wecomCmdUploadMediaInit)
|
||||
}
|
||||
initBody, ok := commands[0].Body.(wecomUploadMediaInitBody)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected init body type %T", commands[0].Body)
|
||||
}
|
||||
if initBody.Type != "image" || initBody.Filename != "photo.jpg" || initBody.TotalChunks != 1 {
|
||||
t.Fatalf("init body = %+v", initBody)
|
||||
}
|
||||
if commands[1].Cmd != wecomCmdUploadMediaChunk {
|
||||
t.Fatalf("second command = %q, want %q", commands[1].Cmd, wecomCmdUploadMediaChunk)
|
||||
}
|
||||
chunkBody, ok := commands[1].Body.(wecomUploadMediaChunkBody)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected chunk body type %T", commands[1].Body)
|
||||
}
|
||||
if chunkBody.UploadID != "upload-1" || chunkBody.ChunkIndex != 0 || chunkBody.Base64Data == "" {
|
||||
t.Fatalf("chunk body = %+v", chunkBody)
|
||||
}
|
||||
if commands[2].Cmd != wecomCmdUploadMediaEnd {
|
||||
t.Fatalf("third command = %q, want %q", commands[2].Cmd, wecomCmdUploadMediaEnd)
|
||||
}
|
||||
if commands[3].Cmd != wecomCmdSendMsg {
|
||||
t.Fatalf("fourth command = %q, want %q", commands[3].Cmd, wecomCmdSendMsg)
|
||||
}
|
||||
|
||||
body, ok := commands[3].Body.(wecomSendMsgBody)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected send body type %T", commands[3].Body)
|
||||
}
|
||||
if body.MsgType != "image" || body.Image == nil {
|
||||
t.Fatalf("send body = %+v", body)
|
||||
}
|
||||
if body.ChatID != "chat-1" {
|
||||
t.Fatalf("send chatid = %q, want chat-1", body.ChatID)
|
||||
}
|
||||
if body.Image.MediaID != "media-1" {
|
||||
t.Fatalf("image media_id = %q, want media-1", body.Image.MediaID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_UsesTurnImageAndFinishesStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := newTestWeComChannel(t, bus.NewMessageBus())
|
||||
ch.SetRunning(true)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
imageData := wecomTestJPEGData(t)
|
||||
imagePath := filepath.Join(t.TempDir(), "reply.jpg")
|
||||
if err := os.WriteFile(imagePath, imageData, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
ref, err := store.Store(imagePath, media.MediaMeta{
|
||||
Filename: "reply.jpg",
|
||||
ContentType: "image/jpeg",
|
||||
Source: "test",
|
||||
CleanupPolicy: media.CleanupPolicyForgetOnly,
|
||||
}, "scope-2")
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
ch.queueTurn("chat-1", wecomTurn{
|
||||
ReqID: "req-1",
|
||||
ChatID: "chat-1",
|
||||
ChatType: 1,
|
||||
StreamID: "stream-1",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
putErr := ch.routes.Put("chat-1", "req-1", 1, time.Hour)
|
||||
if putErr != nil {
|
||||
t.Fatalf("Put() error = %v", putErr)
|
||||
}
|
||||
|
||||
var commands []wecomCommand
|
||||
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||
commands = append(commands, cmd)
|
||||
switch cmd.Cmd {
|
||||
case wecomCmdUploadMediaInit:
|
||||
return wecomTestAck(wecomUploadMediaInitResponse{UploadID: "upload-2"}), nil
|
||||
case wecomCmdUploadMediaEnd:
|
||||
return wecomTestAck(wecomUploadMediaFinishResponse{
|
||||
Type: "image",
|
||||
MediaID: "media-2",
|
||||
}), nil
|
||||
default:
|
||||
return wecomTestAck(nil), nil
|
||||
}
|
||||
}
|
||||
|
||||
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
Channel: "wecom",
|
||||
ChatID: "chat-1",
|
||||
Parts: []bus.MediaPart{{
|
||||
Ref: ref,
|
||||
Type: "image",
|
||||
Filename: "reply.jpg",
|
||||
ContentType: "image/jpeg",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
|
||||
if len(commands) != 5 {
|
||||
t.Fatalf("expected 5 commands, got %d", len(commands))
|
||||
}
|
||||
if commands[0].Cmd != wecomCmdUploadMediaInit {
|
||||
t.Fatalf("first command = %+v", commands[0])
|
||||
}
|
||||
if commands[1].Cmd != wecomCmdUploadMediaChunk {
|
||||
t.Fatalf("second command = %+v", commands[1])
|
||||
}
|
||||
if commands[2].Cmd != wecomCmdUploadMediaEnd {
|
||||
t.Fatalf("third command = %+v", commands[2])
|
||||
}
|
||||
if commands[3].Cmd != wecomCmdRespondMsg || commands[3].Headers.ReqID != "req-1" {
|
||||
t.Fatalf("fourth command = %+v", commands[3])
|
||||
}
|
||||
if commands[4].Cmd != wecomCmdRespondMsg || commands[4].Headers.ReqID != "req-1" {
|
||||
t.Fatalf("fifth command = %+v", commands[4])
|
||||
}
|
||||
|
||||
imageBody, ok := commands[3].Body.(wecomRespondMsgBody)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected image body type %T", commands[3].Body)
|
||||
}
|
||||
if imageBody.MsgType != "image" || imageBody.Image == nil {
|
||||
t.Fatalf("image body = %+v", imageBody)
|
||||
}
|
||||
if imageBody.Image.MediaID != "media-2" {
|
||||
t.Fatalf("image media_id = %q, want media-2", imageBody.Image.MediaID)
|
||||
}
|
||||
|
||||
streamBody, ok := commands[4].Body.(wecomRespondMsgBody)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected finish body type %T", commands[4].Body)
|
||||
}
|
||||
if streamBody.MsgType != "stream" || streamBody.Stream == nil || !streamBody.Stream.Finish {
|
||||
t.Fatalf("finish body = %+v", streamBody)
|
||||
}
|
||||
|
||||
if _, ok := ch.getTurn("chat-1"); ok {
|
||||
t.Fatal("expected turn to be removed after media send")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_SendsActiveFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := newTestWeComChannel(t, bus.NewMessageBus())
|
||||
ch.SetRunning(true)
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
ch.SetMediaStore(store)
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(filePath, []byte("%PDF-1.4"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
ref, err := store.Store(filePath, media.MediaMeta{
|
||||
Filename: "report.pdf",
|
||||
ContentType: "application/pdf",
|
||||
Source: "test",
|
||||
CleanupPolicy: media.CleanupPolicyForgetOnly,
|
||||
}, "scope-3")
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
var commands []wecomCommand
|
||||
ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) {
|
||||
commands = append(commands, cmd)
|
||||
switch cmd.Cmd {
|
||||
case wecomCmdUploadMediaInit:
|
||||
return wecomTestAck(wecomUploadMediaInitResponse{UploadID: "upload-3"}), nil
|
||||
case wecomCmdUploadMediaEnd:
|
||||
return wecomTestAck(wecomUploadMediaFinishResponse{
|
||||
Type: "file",
|
||||
MediaID: "media-3",
|
||||
}), nil
|
||||
default:
|
||||
return wecomTestAck(nil), nil
|
||||
}
|
||||
}
|
||||
|
||||
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
Channel: "wecom",
|
||||
ChatID: "chat-2",
|
||||
Parts: []bus.MediaPart{{
|
||||
Ref: ref,
|
||||
Type: "file",
|
||||
Filename: "report.pdf",
|
||||
ContentType: "application/pdf",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
|
||||
if len(commands) != 4 {
|
||||
t.Fatalf("expected 4 commands, got %d", len(commands))
|
||||
}
|
||||
if commands[0].Cmd != wecomCmdUploadMediaInit {
|
||||
t.Fatalf("first command = %q, want %q", commands[0].Cmd, wecomCmdUploadMediaInit)
|
||||
}
|
||||
initBody, ok := commands[0].Body.(wecomUploadMediaInitBody)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected init body type %T", commands[0].Body)
|
||||
}
|
||||
if initBody.Type != "file" || initBody.Filename != "report.pdf" {
|
||||
t.Fatalf("init body = %+v", initBody)
|
||||
}
|
||||
if commands[1].Cmd != wecomCmdUploadMediaChunk {
|
||||
t.Fatalf("second command = %q, want %q", commands[1].Cmd, wecomCmdUploadMediaChunk)
|
||||
}
|
||||
if commands[2].Cmd != wecomCmdUploadMediaEnd {
|
||||
t.Fatalf("third command = %q, want %q", commands[2].Cmd, wecomCmdUploadMediaEnd)
|
||||
}
|
||||
if commands[3].Cmd != wecomCmdSendMsg {
|
||||
t.Fatalf("fourth command = %q, want %q", commands[3].Cmd, wecomCmdSendMsg)
|
||||
}
|
||||
|
||||
body, ok := commands[3].Body.(wecomSendMsgBody)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected body type %T", commands[3].Body)
|
||||
}
|
||||
if body.MsgType != "file" || body.File == nil {
|
||||
t.Fatalf("body = %+v", body)
|
||||
}
|
||||
if body.File.MediaID != "media-3" {
|
||||
t.Fatalf("file media_id = %q, want media-3", body.File.MediaID)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestWeComChannel(t *testing.T, messageBus *bus.MessageBus) *WeComChannel {
|
||||
t.Helper()
|
||||
|
||||
cfg := config.WeComConfig{BotID: "bot-1"}
|
||||
cfg.SetSecret("secret-1")
|
||||
ch, err := NewChannel(cfg, messageBus)
|
||||
if err != nil {
|
||||
t.Fatalf("NewChannel() error = %v", err)
|
||||
}
|
||||
ch.ctx = context.Background()
|
||||
ch.routes = newReqIDStore(filepath.Join(t.TempDir(), "reqids.json"))
|
||||
return ch
|
||||
}
|
||||
|
||||
func wecomTestJPEGData(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
const jpegBase64 = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////" +
|
||||
"//////////////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////" +
|
||||
"//////////////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAVEQEBAAAAAAAAAAAAAAAAAAAABf/aAAwDAQACEAMQAAAB6A//xAAVEAEBAAAAAAAAAAAAAAAAAAAAEf/aAAgBAQABBQJf/8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAEP/aAAgBAQAGPwJf/8QAFBABAAAAAAAAAAAAAAAAAAAAEP/aAAgBAQABPyFf/9k="
|
||||
|
||||
return decodeTestBase64(t, jpegBase64)
|
||||
}
|
||||
|
||||
func TestDecodeWeComUploadFinish_AcceptsNumericCreatedAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp, err := decodeWeComEnvelopeBody[wecomUploadMediaFinishResponse](wecomEnvelope{
|
||||
Body: json.RawMessage(`{"type":"file","media_id":"media-1","created_at":1380000000}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("decodeWeComEnvelopeBody() error = %v", err)
|
||||
}
|
||||
if resp.Type != "file" || resp.MediaID != "media-1" {
|
||||
t.Fatalf("response = %+v", resp)
|
||||
}
|
||||
if string(resp.CreatedAt) != "1380000000" {
|
||||
t.Fatalf("created_at = %s, want 1380000000", string(resp.CreatedAt))
|
||||
}
|
||||
}
|
||||
|
||||
func wecomTestAck(body any) wecomEnvelope {
|
||||
var raw []byte
|
||||
if body != nil {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
raw = encoded
|
||||
}
|
||||
return wecomEnvelope{
|
||||
ErrCode: 0,
|
||||
ErrMsg: "ok",
|
||||
Body: raw,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,551 +0,0 @@
|
|||
# Security Configuration Refactoring
|
||||
|
||||
## Overview
|
||||
|
||||
This refactoring introduces a `.security.yml` file to store all sensitive data (API keys, tokens, secrets, passwords) separately from the main configuration. This improves security by:
|
||||
|
||||
1. **Separation of concerns**: Configuration settings and secrets are in separate files
|
||||
2. **Easier sharing**: The main config can be shared without exposing sensitive data
|
||||
3. **Better version control**: `.security.yml` can be added to `.gitignore`
|
||||
4. **Flexible deployment**: Different environments can use different security files
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
~/.picoclaw/
|
||||
├── config.json # Main configuration (safe to share)
|
||||
└── .security.yml # Security data (never share)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
In your `config.json`, use `ref:` references to point to values in `.security.yml`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "ref:channels.telegram.token"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Security Configuration
|
||||
|
||||
In your `.security.yml`, store the actual values:
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-your-actual-api-key-1"
|
||||
- "sk-your-actual-api-key-2" # Optional: Multiple keys for failover
|
||||
claude-sonnet-4.6:
|
||||
api_keys:
|
||||
- "sk-your-actual-anthropic-key" # Single key in array format
|
||||
|
||||
channels:
|
||||
telegram:
|
||||
token: "your-telegram-bot-token"
|
||||
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSAyour-brave-api-key-1"
|
||||
- "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover
|
||||
tavily:
|
||||
api_keys:
|
||||
- "tvly-your-tavily-api-key" # Single key in array format
|
||||
glm_search:
|
||||
api_key: "your-glm-search-api-key" # GLMSearch uses single key format
|
||||
```
|
||||
|
||||
## Reference Format
|
||||
|
||||
### Model API Keys
|
||||
|
||||
Format: `ref:model_list.<model_name>.api_key`
|
||||
|
||||
Example: `ref:model_list.gpt-5.4.api_key`
|
||||
|
||||
### Channel Tokens/Secrets
|
||||
|
||||
Format: `ref:channels.<channel_name>.<field>`
|
||||
|
||||
Examples:
|
||||
- `ref:channels.telegram.token`
|
||||
- `ref:channels.feishu.app_secret`
|
||||
- `ref:channels.feishu.encrypt_key`
|
||||
- `ref:channels.feishu.verification_token`
|
||||
- `ref:channels.discord.token`
|
||||
- `ref:channels.qq.app_secret`
|
||||
- `ref:channels.dingtalk.client_secret`
|
||||
- `ref:channels.slack.bot_token`
|
||||
- `ref:channels.slack.app_token`
|
||||
- `ref:channels.matrix.access_token`
|
||||
- `ref:channels.line.channel_secret`
|
||||
- `ref:channels.line.channel_access_token`
|
||||
- `ref:channels.onebot.access_token`
|
||||
- `ref:channels.wecom.token`
|
||||
- `ref:channels.wecom.encoding_aes_key`
|
||||
- `ref:channels.wecom_app.corp_secret`
|
||||
- `ref:channels.wecom_app.token`
|
||||
- `ref:channels.wecom_app.encoding_aes_key`
|
||||
- `ref:channels.wecom_aibot.token`
|
||||
- `ref:channels.wecom_aibot.encoding_aes_key`
|
||||
- `ref:channels.pico.token`
|
||||
- `ref:channels.irc.password`
|
||||
- `ref:channels.irc.nickserv_password`
|
||||
- `ref:channels.irc.sasl_password`
|
||||
|
||||
### Web Tool API Keys
|
||||
|
||||
Format: `ref:web.<provider>.<field>`
|
||||
|
||||
Examples:
|
||||
- `ref:web.brave.api_key`
|
||||
- `ref:web.tavily.api_key`
|
||||
- `ref:web.perplexity.api_key`
|
||||
- `ref:web.glm_search.api_key`
|
||||
|
||||
### Skills Registry Tokens
|
||||
|
||||
Format: `ref:skills.<registry>.<field>`
|
||||
|
||||
Examples:
|
||||
- `ref:skills.github.token`
|
||||
- `ref:skills.clawhub.auth_token`
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The refactoring maintains full backward compatibility:
|
||||
|
||||
1. **Direct values**: You can still use direct values in `config.json` (not recommended for production)
|
||||
2. **Mixed usage**: You can mix `ref:` references and direct values
|
||||
3. **Optional security file**: If `.security.yml` doesn't exist, all references will fail (but direct values still work)
|
||||
|
||||
### API Key Formats in .security.yml
|
||||
|
||||
**Models (gpt-5.4, claude-sonnet-4.6, etc.):**
|
||||
- Must use `api_keys` (array) format
|
||||
- Both single and multiple keys use array format
|
||||
|
||||
**Web Tools (Brave, Tavily, Perplexity):**
|
||||
- Must use `api_keys` (array) format
|
||||
- Both single and multiple keys use array format
|
||||
|
||||
**Web Tools (GLMSearch):**
|
||||
- Must use `api_key` (single string) format
|
||||
- Does NOT support array format
|
||||
|
||||
**Channels (Telegram, Discord, etc.):**
|
||||
- Use single field names (e.g., `token`, `app_secret`)
|
||||
- Each channel uses its specific field names
|
||||
|
||||
### Single Key (Models)
|
||||
|
||||
Use array format with one element:
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-your-key"
|
||||
```
|
||||
|
||||
In `config.json`:
|
||||
```json
|
||||
{
|
||||
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||
}
|
||||
```
|
||||
|
||||
### Single Key (GLMSearch)
|
||||
|
||||
Use single string format:
|
||||
```yaml
|
||||
web:
|
||||
glm_search:
|
||||
api_key: "your-glm-key"
|
||||
```
|
||||
|
||||
In `config.json`:
|
||||
```json
|
||||
{
|
||||
"api_key": "ref:web.glm_search.api_key"
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Step 1: Create .security.yml
|
||||
|
||||
Copy the example template:
|
||||
```bash
|
||||
cp security.example.yml ~/.picoclaw/.security.yml
|
||||
```
|
||||
|
||||
### Step 2: Fill in your actual values
|
||||
|
||||
Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual API keys and tokens.
|
||||
|
||||
### Step 3: Update config.json
|
||||
|
||||
Replace sensitive values in `~/.picoclaw/config.json` with `ref:` references:
|
||||
|
||||
**Before:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-actual-api-key-here"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Verify
|
||||
|
||||
Restart PicoClaw and verify it loads correctly:
|
||||
```bash
|
||||
picoclaw --version
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit `.security.yml`** to version control
|
||||
2. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml`
|
||||
3. **Use different keys** for different environments (dev, staging, production)
|
||||
4. **Rotate keys regularly** and update `.security.yml`
|
||||
5. **Backup securely**: Encrypt backups containing `.security.yml`
|
||||
|
||||
## API
|
||||
|
||||
### LoadSecurityConfig
|
||||
|
||||
```go
|
||||
func LoadSecurityConfig(securityPath string) (*SecurityConfig, error)
|
||||
```
|
||||
|
||||
Loads the security configuration from `.security.yml`. Returns an empty `SecurityConfig` if the file doesn't exist.
|
||||
|
||||
### SaveSecurityConfig
|
||||
|
||||
```go
|
||||
func SaveSecurityConfig(securityPath string, sec *SecurityConfig) error
|
||||
```
|
||||
|
||||
Saves the security configuration to `.security.yml` with `0o600` permissions.
|
||||
|
||||
### ResolveReference
|
||||
|
||||
```go
|
||||
func (sec *SecurityConfig) ResolveReference(ref string) (string, error)
|
||||
```
|
||||
|
||||
Resolves a reference string (e.g., `"ref:model_list.test.api_key"`) and returns the actual value.
|
||||
|
||||
### SecurityPath
|
||||
|
||||
```go
|
||||
func SecurityPath(configPath string) string
|
||||
```
|
||||
|
||||
Returns the path to `.security.yml` relative to the config file.
|
||||
|
||||
## Example: Complete Configuration
|
||||
|
||||
### config.json
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/picoclaw-workspace",
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"api_key": "ref:model_list.claude-sonnet-4.6.api_key"
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "ref:channels.telegram.token"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"api_key": "ref:web.brave.api_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .security.yml
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-proj-actual-openai-key-1"
|
||||
- "sk-proj-actual-openai-key-2"
|
||||
claude-sonnet-4.6:
|
||||
api_keys:
|
||||
- "sk-ant-actual-anthropic-key" # Single key in array format
|
||||
|
||||
channels:
|
||||
telegram:
|
||||
token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSAactualbravekey-1"
|
||||
- "BSAactualbravekey-2"
|
||||
tavily:
|
||||
api_keys:
|
||||
- "tvly-your-tavily-key" # Single key in array format
|
||||
glm_search:
|
||||
api_key: "your-glm-key" # GLMSearch uses single key format
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The refactoring includes comprehensive tests:
|
||||
|
||||
```bash
|
||||
go test ./pkg/config -run TestSecurityConfig
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: "model security entry not found"
|
||||
|
||||
- Ensure the model name in your reference matches exactly in `.security.yml`
|
||||
- Check that the `model_list` section exists in `.security.yml`
|
||||
- For models with indexed names (e.g., "gpt-5.4:0"), ensure the exact name is used or check the base name without index
|
||||
|
||||
### Error: "failed to load security config"
|
||||
|
||||
- Verify `.security.yml` exists in the same directory as `config.json`
|
||||
- Check the YAML syntax is valid (use a YAML validator)
|
||||
- Ensure file permissions allow reading
|
||||
|
||||
### Error: "unknown reference path"
|
||||
|
||||
- Verify the reference format is correct
|
||||
- Check the path structure matches the examples above
|
||||
- Ensure all required sections exist in `.security.yml`
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Multiple API Keys (Load Balancing & Failover)
|
||||
|
||||
Both models and web tools support multiple API keys for improved reliability:
|
||||
|
||||
**Benefits:**
|
||||
- **Load balancing**: Requests are distributed across multiple keys
|
||||
- **Failover**: Automatic switching to another key if one fails
|
||||
- **Rate limit management**: Distribute usage across multiple keys
|
||||
- **High availability**: Reduce downtime during API provider issues
|
||||
|
||||
#### Example: Model with Multiple Keys
|
||||
|
||||
**.security.yml:**
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-proj-key-1"
|
||||
- "sk-proj-key-2"
|
||||
- "sk-proj-key-3"
|
||||
```
|
||||
|
||||
**config.json:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Example: Web Tool with Multiple Keys
|
||||
|
||||
**.security.yml:**
|
||||
```yaml
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSA-key-1"
|
||||
- "BSA-key-2"
|
||||
tavily:
|
||||
api_keys:
|
||||
- "tvly-your-key" # Single key in array format
|
||||
glm_search:
|
||||
api_key: "your-glm-key" # GLMSearch uses single key format
|
||||
```
|
||||
|
||||
**config.json:**
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"api_key": "ref:web.brave.api_key"
|
||||
},
|
||||
"tavily": {
|
||||
"enabled": true,
|
||||
"api_key": "ref:web.tavily.api_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Supported Formats
|
||||
|
||||
**Models - Single key:**
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-your-key" # Array with one element
|
||||
```
|
||||
|
||||
**Models - Multiple keys:**
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-your-key-1"
|
||||
- "sk-your-key-2"
|
||||
- "sk-your-key-3"
|
||||
```
|
||||
|
||||
**Web Tools (Brave/Tavily/Perplexity) - Single key:**
|
||||
```yaml
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSA-your-key" # Array with one element
|
||||
```
|
||||
|
||||
**Web Tools (Brave/Tavily/Perplexity) - Multiple keys:**
|
||||
```yaml
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSA-key-1"
|
||||
- "BSA-key-2"
|
||||
```
|
||||
|
||||
**Web Tool (GLMSearch) - Single key only:**
|
||||
```yaml
|
||||
web:
|
||||
glm_search:
|
||||
api_key: "your-glm-key" # Single string (NOT array)
|
||||
```
|
||||
|
||||
All formats work identically in `config.json` - you always use the same reference format:
|
||||
```json
|
||||
{
|
||||
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||
}
|
||||
```
|
||||
|
||||
### Model Indexing for Load Balancing
|
||||
|
||||
When you have multiple models with the same base name but different API keys, you can use indexed names:
|
||||
|
||||
**.security.yml:**
|
||||
```yaml
|
||||
model_list:
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-proj-key-1"
|
||||
- "sk-proj-key-2"
|
||||
```
|
||||
|
||||
The system will automatically expand this into multiple model entries with fallback support.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can override any security value using environment variables:
|
||||
|
||||
**For models:**
|
||||
```bash
|
||||
export PICOCLAW_MODEL_LIST_GPT-5.4_API_KEY="sk-from-env"
|
||||
```
|
||||
|
||||
**For channels:**
|
||||
```bash
|
||||
export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env"
|
||||
```
|
||||
|
||||
**For web tools:**
|
||||
```bash
|
||||
export PICOCLAW_WEB_BRAVE_API_KEY="key-from-env"
|
||||
```
|
||||
|
||||
Environment variables follow this pattern: `PICOCLAW_<SECTION>_<KEY1>_<KEY2>_<FIELD>` with dots replaced by underscores and converted to uppercase.
|
||||
|
||||
### Multiple API Keys Not Working
|
||||
|
||||
- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch)
|
||||
- Check that the array format is correct in YAML (proper indentation)
|
||||
- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
|
||||
- GLMSearch MUST use `api_key` (single string format)
|
||||
- The reference in `config.json` is the same regardless of single or multiple keys
|
||||
|
||||
### Load Balancing/Failover Issues
|
||||
|
||||
- Verify all API keys in the `api_keys` array are valid
|
||||
- Check that all keys have the same rate limits and permissions
|
||||
- Monitor logs to see which keys are being used and failing
|
||||
|
|
@ -3,6 +3,7 @@ package config
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -106,6 +107,7 @@ func (c *Config) WithSecurity(sec *SecurityConfig) *Config {
|
|||
c.security = sec
|
||||
return c
|
||||
}
|
||||
sec = normalizeSecurityConfig(sec)
|
||||
err := applySecurityConfig(c, sec)
|
||||
if err != nil {
|
||||
return nil
|
||||
|
|
@ -114,6 +116,25 @@ func (c *Config) WithSecurity(sec *SecurityConfig) *Config {
|
|||
return c
|
||||
}
|
||||
|
||||
// FilterSensitiveData filters sensitive values from content before sending to LLM.
|
||||
// This prevents the LLM from seeing its own credentials.
|
||||
// Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig).
|
||||
// Short content (below FilterMinLength) is returned unchanged for performance.
|
||||
func (c *Config) FilterSensitiveData(content string) string {
|
||||
if c.security == nil || content == "" {
|
||||
return content
|
||||
}
|
||||
// Check if filtering is enabled (default: true)
|
||||
if !c.Tools.IsFilterSensitiveDataEnabled() {
|
||||
return content
|
||||
}
|
||||
// Fast path: skip filtering for short content
|
||||
if len(content) < c.Tools.GetFilterMinLength() {
|
||||
return content
|
||||
}
|
||||
return c.security.SensitiveDataReplacer().Replace(content)
|
||||
}
|
||||
|
||||
type HooksConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Defaults HookDefaultsConfig `json:"defaults,omitempty"`
|
||||
|
|
@ -299,12 +320,10 @@ type AgentDefaults struct {
|
|||
SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all"
|
||||
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
|
||||
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
|
||||
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
||||
DefaultWeComAIBotProcessingMessage = "⏳ Processing, please wait. The results will be sent shortly."
|
||||
)
|
||||
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
||||
|
||||
func (d *AgentDefaults) GetMaxMediaSize() int {
|
||||
if d.MaxMediaSize > 0 {
|
||||
|
|
@ -344,9 +363,7 @@ type ChannelsConfig struct {
|
|||
Matrix MatrixConfig `json:"matrix"`
|
||||
LINE LINEConfig `json:"line"`
|
||||
OneBot OneBotConfig `json:"onebot"`
|
||||
WeCom WeComConfig `json:"wecom"`
|
||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||
WeCom WeComConfig `json:"wecom" envPrefix:"PICOCLAW_CHANNELS_WECOM_"`
|
||||
Weixin WeixinConfig `json:"weixin"`
|
||||
Pico PicoConfig `json:"pico"`
|
||||
PicoClient PicoClientConfig `json:"pico_client"`
|
||||
|
|
@ -366,8 +383,20 @@ type TypingConfig struct {
|
|||
|
||||
// PlaceholderConfig controls placeholder message behavior (Phase 10).
|
||||
type PlaceholderConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Text FlexibleStringSlice `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// GetRandomText returns a random placeholder text, or default if none set.
|
||||
func (p *PlaceholderConfig) GetRandomText() string {
|
||||
if len(p.Text) == 0 {
|
||||
return "Thinking..."
|
||||
}
|
||||
if len(p.Text) == 1 {
|
||||
return p.Text[0]
|
||||
}
|
||||
idx := rand.Intn(len(p.Text))
|
||||
return p.Text[idx]
|
||||
}
|
||||
|
||||
type StreamingConfig struct {
|
||||
|
|
@ -571,18 +600,20 @@ func (c *SlackConfig) SetAppToken(token string) {
|
|||
}
|
||||
|
||||
type MatrixConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
|
||||
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
|
||||
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
|
||||
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
|
||||
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
|
||||
accessToken string
|
||||
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
|
||||
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
|
||||
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
|
||||
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
|
||||
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
|
||||
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
||||
secDirty bool
|
||||
CryptoDatabasePath string `json:"crypto_database_path,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_CRYPTO_DATABASE_PATH"`
|
||||
CryptoPassphrase string `json:"crypto_passphrase,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_CRYPTO_PASSPHRASE"`
|
||||
}
|
||||
|
||||
// AccessToken returns the Matrix access token
|
||||
|
|
@ -658,136 +689,28 @@ func (c *OneBotConfig) SetAccessToken(token string) {
|
|||
c.secDirty = true
|
||||
}
|
||||
|
||||
type WeComGroupConfig struct {
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from,omitempty"`
|
||||
}
|
||||
|
||||
type WeComConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
|
||||
token string
|
||||
encodingAESKey string
|
||||
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"`
|
||||
secDirty bool
|
||||
Enabled bool `json:"enabled" env:"ENABLED"`
|
||||
BotID string `json:"bot_id" env:"BOT_ID"`
|
||||
secret string
|
||||
WebSocketURL string `json:"websocket_url,omitempty" env:"WEBSOCKET_URL"`
|
||||
SendThinkingMessage bool `json:"send_thinking_message" env:"SEND_THINKING_MESSAGE"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"ALLOW_FROM"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"REASONING_CHANNEL_ID"`
|
||||
secDirty bool
|
||||
}
|
||||
|
||||
// Token returns the WeCom token
|
||||
func (c *WeComConfig) Token() string {
|
||||
return c.token
|
||||
}
|
||||
|
||||
// SetToken sets the WeCom token
|
||||
func (c *WeComConfig) SetToken(token string) {
|
||||
c.token = token
|
||||
c.secDirty = true
|
||||
}
|
||||
|
||||
// EncodingAESKey returns the WeCom encoding AES key
|
||||
func (c *WeComConfig) EncodingAESKey() string {
|
||||
return c.encodingAESKey
|
||||
}
|
||||
|
||||
// SetEncodingAESKey sets the WeCom encoding AES key
|
||||
func (c *WeComConfig) SetEncodingAESKey(key string) {
|
||||
c.encodingAESKey = key
|
||||
c.secDirty = true
|
||||
}
|
||||
|
||||
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
|
||||
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
|
||||
token string
|
||||
encodingAESKey string
|
||||
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"`
|
||||
secDirty bool
|
||||
}
|
||||
|
||||
// CorpSecret returns the corporate secret for WeCom app
|
||||
func (c *WeComAppConfig) CorpSecret() string {
|
||||
return c.corpSecret
|
||||
}
|
||||
|
||||
// SetCorpSecret sets the corporate secret for WeCom app
|
||||
func (c *WeComAppConfig) SetCorpSecret(secret string) {
|
||||
c.corpSecret = secret
|
||||
c.secDirty = true
|
||||
}
|
||||
|
||||
// Token returns the webhook token for WeCom app
|
||||
func (c *WeComAppConfig) Token() string {
|
||||
return c.token
|
||||
}
|
||||
|
||||
// SetToken sets the webhook token for WeCom app
|
||||
func (c *WeComAppConfig) SetToken(token string) {
|
||||
c.token = token
|
||||
c.secDirty = true
|
||||
}
|
||||
|
||||
// EncodingAESKey returns the encoding AES key for WeCom app
|
||||
func (c *WeComAppConfig) EncodingAESKey() string {
|
||||
return c.encodingAESKey
|
||||
}
|
||||
|
||||
// SetEncodingAESKey sets the encoding AES key for WeCom app
|
||||
func (c *WeComAppConfig) SetEncodingAESKey(key string) {
|
||||
c.encodingAESKey = key
|
||||
c.secDirty = true
|
||||
}
|
||||
|
||||
type WeComAIBotConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
|
||||
BotID string `json:"bot_id,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_BOT_ID"`
|
||||
secret string
|
||||
token string
|
||||
encodingAESKey string
|
||||
WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
|
||||
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
|
||||
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
|
||||
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
|
||||
ProcessingMessage string `json:"processing_message,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_PROCESSING_MESSAGE"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
|
||||
secDirty bool
|
||||
}
|
||||
|
||||
// Token returns the webhook token for WeCom AI bot
|
||||
func (c *WeComAIBotConfig) Token() string {
|
||||
return c.token
|
||||
}
|
||||
|
||||
// EncodingAESKey returns the encoding AES key for WeCom AI bot
|
||||
func (c *WeComAIBotConfig) EncodingAESKey() string {
|
||||
return c.encodingAESKey
|
||||
}
|
||||
|
||||
// SetToken sets the token for WeCom AI bot
|
||||
func (c *WeComAIBotConfig) SetToken(token string) {
|
||||
c.token = token
|
||||
c.secDirty = true
|
||||
}
|
||||
|
||||
// SetEncodingAESKey sets the encoding AES key for WeCom AI bot
|
||||
func (c *WeComAIBotConfig) SetEncodingAESKey(key string) {
|
||||
c.encodingAESKey = key
|
||||
c.secDirty = true
|
||||
}
|
||||
|
||||
func (c *WeComAIBotConfig) Secret() string {
|
||||
// Secret returns the WeCom bot secret.
|
||||
func (c *WeComConfig) Secret() string {
|
||||
return c.secret
|
||||
}
|
||||
|
||||
func (c *WeComAIBotConfig) SetSecret(secret string) {
|
||||
// SetSecret sets the WeCom bot secret.
|
||||
func (c *WeComConfig) SetSecret(secret string) {
|
||||
c.secret = secret
|
||||
c.secDirty = true
|
||||
}
|
||||
|
|
@ -795,6 +718,7 @@ func (c *WeComAIBotConfig) SetSecret(secret string) {
|
|||
type WeixinConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
|
||||
token string
|
||||
AccountID string `json:"account_id,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_ACCOUNT_ID"`
|
||||
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
|
||||
CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"`
|
||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
|
||||
|
|
@ -909,8 +833,9 @@ type DevicesConfig struct {
|
|||
}
|
||||
|
||||
type VoiceConfig struct {
|
||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"`
|
||||
EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
|
||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"`
|
||||
EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
|
||||
ElevenLabsAPIKey string `json:"elevenlabs_api_key,omitempty" env:"PICOCLAW_VOICE_ELEVENLABS_API_KEY"`
|
||||
}
|
||||
|
||||
// ModelConfig represents a model-centric provider configuration.
|
||||
|
|
@ -946,6 +871,10 @@ type ModelConfig struct {
|
|||
secModelName string
|
||||
apiKeys []string
|
||||
secDirty bool
|
||||
|
||||
// isVirtual marks this model as a virtual model generated from multi-key expansion.
|
||||
// Virtual models should not be persisted to config files.
|
||||
isVirtual bool
|
||||
}
|
||||
|
||||
// APIKey returns the first API key from apiKeys
|
||||
|
|
@ -956,6 +885,11 @@ func (c *ModelConfig) APIKey() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// IsVirtual returns true if this model was generated from multi-key expansion.
|
||||
func (c *ModelConfig) IsVirtual() bool {
|
||||
return c.isVirtual
|
||||
}
|
||||
|
||||
// Validate checks if the ModelConfig has all required fields.
|
||||
func (c *ModelConfig) Validate() error {
|
||||
if c.ModelName == "" {
|
||||
|
|
@ -1201,8 +1135,16 @@ type ReadFileToolConfig struct {
|
|||
}
|
||||
|
||||
type ToolsConfig struct {
|
||||
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
||||
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
||||
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
||||
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
||||
// FilterSensitiveData controls whether to filter sensitive values (API keys,
|
||||
// tokens, secrets) from tool results before sending to the LLM.
|
||||
// Default: true (enabled)
|
||||
FilterSensitiveData bool `json:"filter_sensitive_data" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"`
|
||||
// FilterMinLength is the minimum content length required for filtering.
|
||||
// Content shorter than this will be returned unchanged for performance.
|
||||
// Default: 8
|
||||
FilterMinLength int `json:"filter_min_length" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"`
|
||||
Web WebToolsConfig `json:"web"`
|
||||
Cron CronToolsConfig `json:"cron"`
|
||||
Exec ExecConfig `json:"exec"`
|
||||
|
|
@ -1226,6 +1168,19 @@ type ToolsConfig struct {
|
|||
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
|
||||
}
|
||||
|
||||
// IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled
|
||||
func (c *ToolsConfig) IsFilterSensitiveDataEnabled() bool {
|
||||
return c.FilterSensitiveData
|
||||
}
|
||||
|
||||
// GetFilterMinLength returns the minimum content length for filtering (default: 8)
|
||||
func (c *ToolsConfig) GetFilterMinLength() int {
|
||||
if c.FilterMinLength <= 0 {
|
||||
return 8
|
||||
}
|
||||
return c.FilterMinLength
|
||||
}
|
||||
|
||||
type SearchCacheConfig struct {
|
||||
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
|
||||
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
|
||||
|
|
@ -1309,11 +1264,14 @@ type MCPConfig struct {
|
|||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
logger.Debugf("loading config from %s", path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logger.WarnF("config file not found, using default config", map[string]any{"path": path})
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
logger.Errorf("failed to read config file: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -1325,6 +1283,7 @@ func LoadConfig(path string) (*Config, error) {
|
|||
return nil, fmt.Errorf("failed to detect config version: %w", e)
|
||||
}
|
||||
if len(data) <= 10 {
|
||||
logger.Warn(fmt.Sprintf("content is [%s]", string(data)))
|
||||
return DefaultConfig().WithSecurity(&SecurityConfig{}), nil
|
||||
}
|
||||
|
||||
|
|
@ -1340,36 +1299,63 @@ func LoadConfig(path string) (*Config, error) {
|
|||
}
|
||||
cfg, e = v.Migrate()
|
||||
if e != nil {
|
||||
logger.DebugF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
return nil, e
|
||||
}
|
||||
logger.DebugF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
defer func() {
|
||||
logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
err = makeBackup(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Load existing security config and merge with migrated one to prevent data loss
|
||||
existingSec, secErr := loadSecurityConfig(securityPath(path))
|
||||
if secErr != nil {
|
||||
logger.WarnF("failed to load existing security config during migration", map[string]any{"error": secErr})
|
||||
}
|
||||
if existingSec != nil && cfg.security != nil {
|
||||
cfg.security = mergeSecurityConfig(existingSec, cfg.security)
|
||||
// Re-apply the merged security config to update all channels and models
|
||||
if err = applySecurityConfig(cfg, cfg.security); err != nil {
|
||||
logger.WarnF("failed to re-apply merged security config during migration", map[string]any{"error": err})
|
||||
}
|
||||
}
|
||||
defer func(cfg *Config) {
|
||||
_ = SaveConfig(path, cfg)
|
||||
}()
|
||||
}(cfg)
|
||||
case CurrentVersion:
|
||||
// Current version
|
||||
cfg, err = loadConfig(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Legacy config (no version field)
|
||||
tmpCfg, e := loadConfigV0(data)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
|
||||
tmpCfgMigrated, e := tmpCfg.Migrate()
|
||||
if e != nil {
|
||||
logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
return nil, e
|
||||
}
|
||||
|
||||
// Load security configuration from .security.yml
|
||||
secPath := securityPath(path)
|
||||
sec, err := loadSecurityConfig(secPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load security config: %w", err)
|
||||
}
|
||||
|
||||
// Merge security configs: config.json takes precedence over .security.yml
|
||||
if err := applySecurityConfigWithPrecedence(cfg, tmpCfgMigrated, sec); err != nil {
|
||||
return nil, fmt.Errorf("failed to merge security config: %w", err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
|
||||
}
|
||||
|
||||
// Load security configuration
|
||||
securityPath := securityPath(path)
|
||||
sec, err := loadSecurityConfig(securityPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load security config: %w", err)
|
||||
}
|
||||
|
||||
// Apply security references from .security.yml BEFORE resolveAPIKeys
|
||||
// This resolves ref: references to actual values
|
||||
if err := applySecurityConfig(cfg, sec); err != nil {
|
||||
return nil, fmt.Errorf("failed to apply security config: %w", err)
|
||||
}
|
||||
|
||||
if passphrase := credential.PassphraseProvider(); passphrase != "" {
|
||||
for _, m := range cfg.ModelList {
|
||||
for _, k := range m.apiKeys {
|
||||
|
|
@ -1421,6 +1407,19 @@ func LoadConfig(path string) (*Config, error) {
|
|||
return cfg, nil
|
||||
}
|
||||
|
||||
func makeBackup(path string) error {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
// Create backup of the config file before migration
|
||||
bakPath := path + ".bak"
|
||||
if err := fileutil.CopyFile(path, bakPath, 0o600); err != nil {
|
||||
logger.ErrorF("failed to create config backup", map[string]any{"error": err})
|
||||
return fmt.Errorf("failed to create config backup: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyArray[T any](dst, src *[]T) {
|
||||
*dst = make([]T, len(*src))
|
||||
copy(*dst, *src)
|
||||
|
|
@ -1433,32 +1432,36 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
if sec.Web.Brave != nil && len(sec.Web.Brave.APIKeys) > 0 {
|
||||
copyArray(&cfg.Tools.Web.Brave.apiKeys, &sec.Web.Brave.APIKeys)
|
||||
if sec.Web != nil {
|
||||
if sec.Web.Brave != nil && len(sec.Web.Brave.APIKeys) > 0 {
|
||||
copyArray(&cfg.Tools.Web.Brave.apiKeys, &sec.Web.Brave.APIKeys)
|
||||
}
|
||||
|
||||
if sec.Web.Tavily != nil && len(sec.Web.Tavily.APIKeys) > 0 {
|
||||
copyArray(&cfg.Tools.Web.Tavily.apiKeys, &sec.Web.Tavily.APIKeys)
|
||||
}
|
||||
|
||||
if sec.Web.Perplexity != nil && len(sec.Web.Perplexity.APIKeys) > 0 {
|
||||
copyArray(&cfg.Tools.Web.Perplexity.apiKeys, &sec.Web.Perplexity.APIKeys)
|
||||
}
|
||||
|
||||
if sec.Web.GLMSearch != nil && sec.Web.GLMSearch.APIKey != "" {
|
||||
cfg.Tools.Web.GLMSearch.apiKey = sec.Web.GLMSearch.APIKey
|
||||
}
|
||||
|
||||
if sec.Web.BaiduSearch != nil && sec.Web.BaiduSearch.APIKey != "" {
|
||||
cfg.Tools.Web.BaiduSearch.apiKey = sec.Web.BaiduSearch.APIKey
|
||||
}
|
||||
}
|
||||
|
||||
if sec.Web.Tavily != nil && len(sec.Web.Tavily.APIKeys) > 0 {
|
||||
copyArray(&cfg.Tools.Web.Tavily.apiKeys, &sec.Web.Tavily.APIKeys)
|
||||
}
|
||||
if sec.Skills != nil {
|
||||
if sec.Skills.Github != nil && sec.Skills.Github.Token != "" {
|
||||
cfg.Tools.Skills.Github.token = sec.Skills.Github.Token
|
||||
}
|
||||
|
||||
if sec.Web.Perplexity != nil && len(sec.Web.Perplexity.APIKeys) > 0 {
|
||||
copyArray(&cfg.Tools.Web.Perplexity.apiKeys, &sec.Web.Perplexity.APIKeys)
|
||||
}
|
||||
|
||||
if sec.Web.GLMSearch != nil && sec.Web.GLMSearch.APIKey != "" {
|
||||
cfg.Tools.Web.GLMSearch.apiKey = sec.Web.GLMSearch.APIKey
|
||||
}
|
||||
|
||||
if sec.Web.BaiduSearch != nil && sec.Web.BaiduSearch.APIKey != "" {
|
||||
cfg.Tools.Web.BaiduSearch.apiKey = sec.Web.BaiduSearch.APIKey
|
||||
}
|
||||
|
||||
if sec.Skills.Github != nil && sec.Skills.Github.Token != "" {
|
||||
cfg.Tools.Skills.Github.token = sec.Skills.Github.Token
|
||||
}
|
||||
|
||||
if sec.Skills.ClawHub != nil && sec.Skills.ClawHub.AuthToken != "" {
|
||||
cfg.Tools.Skills.Registries.ClawHub.authToken = sec.Skills.ClawHub.AuthToken
|
||||
if sec.Skills.ClawHub != nil && sec.Skills.ClawHub.AuthToken != "" {
|
||||
cfg.Tools.Skills.Registries.ClawHub.authToken = sec.Skills.ClawHub.AuthToken
|
||||
}
|
||||
}
|
||||
|
||||
names := toNameIndex(cfg.ModelList)
|
||||
|
|
@ -1480,126 +1483,99 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Handle Telegram token
|
||||
if sec.Channels.Telegram != nil && sec.Channels.Telegram.Token != "" {
|
||||
cfg.Channels.Telegram.token = sec.Channels.Telegram.Token
|
||||
}
|
||||
if sec.Channels != nil {
|
||||
// Handle Telegram token
|
||||
if sec.Channels.Telegram != nil && sec.Channels.Telegram.Token != "" {
|
||||
cfg.Channels.Telegram.token = sec.Channels.Telegram.Token
|
||||
}
|
||||
|
||||
// Handle Feishu credentials
|
||||
if sec.Channels.Feishu != nil {
|
||||
if sec.Channels.Feishu.AppSecret != "" {
|
||||
cfg.Channels.Feishu.appSecret = sec.Channels.Feishu.AppSecret
|
||||
// Handle Feishu credentials
|
||||
if sec.Channels.Feishu != nil {
|
||||
if sec.Channels.Feishu.AppSecret != "" {
|
||||
cfg.Channels.Feishu.appSecret = sec.Channels.Feishu.AppSecret
|
||||
}
|
||||
if sec.Channels.Feishu.EncryptKey != "" {
|
||||
cfg.Channels.Feishu.encryptKey = sec.Channels.Feishu.EncryptKey
|
||||
}
|
||||
if sec.Channels.Feishu.VerificationToken != "" {
|
||||
cfg.Channels.Feishu.verificationToken = sec.Channels.Feishu.VerificationToken
|
||||
}
|
||||
}
|
||||
if sec.Channels.Feishu.EncryptKey != "" {
|
||||
cfg.Channels.Feishu.encryptKey = sec.Channels.Feishu.EncryptKey
|
||||
}
|
||||
if sec.Channels.Feishu.VerificationToken != "" {
|
||||
cfg.Channels.Feishu.verificationToken = sec.Channels.Feishu.VerificationToken
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Discord token
|
||||
if sec.Channels.Discord != nil && sec.Channels.Discord.Token != "" {
|
||||
cfg.Channels.Discord.token = sec.Channels.Discord.Token
|
||||
}
|
||||
// Handle Discord token
|
||||
if sec.Channels.Discord != nil && sec.Channels.Discord.Token != "" {
|
||||
cfg.Channels.Discord.token = sec.Channels.Discord.Token
|
||||
}
|
||||
|
||||
// Handle Weixin token
|
||||
if sec.Channels.Weixin != nil && sec.Channels.Weixin.Token != "" {
|
||||
cfg.Channels.Discord.token = sec.Channels.Discord.Token
|
||||
}
|
||||
// Handle Weixin token
|
||||
if sec.Channels.Weixin != nil && sec.Channels.Weixin.Token != "" {
|
||||
cfg.Channels.Weixin.token = sec.Channels.Weixin.Token
|
||||
}
|
||||
|
||||
// Handle DingTalk client secret
|
||||
if sec.Channels.DingTalk != nil && sec.Channels.DingTalk.ClientSecret != "" {
|
||||
cfg.Channels.DingTalk.clientSecret = sec.Channels.DingTalk.ClientSecret
|
||||
}
|
||||
// Handle DingTalk client secret
|
||||
if sec.Channels.DingTalk != nil && sec.Channels.DingTalk.ClientSecret != "" {
|
||||
cfg.Channels.DingTalk.clientSecret = sec.Channels.DingTalk.ClientSecret
|
||||
}
|
||||
|
||||
// Handle Slack tokens
|
||||
if sec.Channels.Slack != nil {
|
||||
if sec.Channels.Slack.BotToken != "" {
|
||||
cfg.Channels.Slack.botToken = sec.Channels.Slack.BotToken
|
||||
// Handle Slack tokens
|
||||
if sec.Channels.Slack != nil {
|
||||
if sec.Channels.Slack.BotToken != "" {
|
||||
cfg.Channels.Slack.botToken = sec.Channels.Slack.BotToken
|
||||
}
|
||||
if sec.Channels.Slack.AppToken != "" {
|
||||
cfg.Channels.Slack.appToken = sec.Channels.Slack.AppToken
|
||||
}
|
||||
}
|
||||
if sec.Channels.Slack.AppToken != "" {
|
||||
cfg.Channels.Slack.appToken = sec.Channels.Slack.AppToken
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Matrix access token
|
||||
if sec.Channels.Matrix != nil && sec.Channels.Matrix.AccessToken != "" {
|
||||
cfg.Channels.Matrix.accessToken = sec.Channels.Matrix.AccessToken
|
||||
}
|
||||
// Handle Matrix access token
|
||||
if sec.Channels.Matrix != nil && sec.Channels.Matrix.AccessToken != "" {
|
||||
cfg.Channels.Matrix.accessToken = sec.Channels.Matrix.AccessToken
|
||||
}
|
||||
|
||||
// Handle LINE credentials
|
||||
if sec.Channels.LINE != nil {
|
||||
if sec.Channels.LINE.ChannelSecret != "" {
|
||||
cfg.Channels.LINE.channelSecret = sec.Channels.LINE.ChannelSecret
|
||||
// Handle LINE credentials
|
||||
if sec.Channels.LINE != nil {
|
||||
if sec.Channels.LINE.ChannelSecret != "" {
|
||||
cfg.Channels.LINE.channelSecret = sec.Channels.LINE.ChannelSecret
|
||||
}
|
||||
if sec.Channels.LINE.ChannelAccessToken != "" {
|
||||
cfg.Channels.LINE.channelAccessToken = sec.Channels.LINE.ChannelAccessToken
|
||||
}
|
||||
}
|
||||
if sec.Channels.LINE.ChannelAccessToken != "" {
|
||||
cfg.Channels.LINE.channelAccessToken = sec.Channels.LINE.ChannelAccessToken
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OneBot access token
|
||||
if sec.Channels.OneBot != nil && sec.Channels.OneBot.AccessToken != "" {
|
||||
cfg.Channels.OneBot.accessToken = sec.Channels.OneBot.AccessToken
|
||||
}
|
||||
// Handle OneBot access token
|
||||
if sec.Channels.OneBot != nil && sec.Channels.OneBot.AccessToken != "" {
|
||||
cfg.Channels.OneBot.accessToken = sec.Channels.OneBot.AccessToken
|
||||
}
|
||||
|
||||
// Handle WeCom token and encoding key
|
||||
if sec.Channels.WeCom != nil {
|
||||
if sec.Channels.WeCom.Token != "" {
|
||||
cfg.Channels.WeCom.token = sec.Channels.WeCom.Token
|
||||
// Handle WeCom bot secret
|
||||
if sec.Channels.WeCom != nil {
|
||||
if sec.Channels.WeCom.Secret != "" {
|
||||
cfg.Channels.WeCom.secret = sec.Channels.WeCom.Secret
|
||||
}
|
||||
}
|
||||
if sec.Channels.WeCom.EncodingAESKey != "" {
|
||||
cfg.Channels.WeCom.encodingAESKey = sec.Channels.WeCom.EncodingAESKey
|
||||
}
|
||||
}
|
||||
|
||||
// Handle WeCom App credentials
|
||||
if sec.Channels.WeComApp != nil {
|
||||
if sec.Channels.WeComApp.CorpSecret != "" {
|
||||
cfg.Channels.WeComApp.corpSecret = sec.Channels.WeComApp.CorpSecret
|
||||
// Handle Pico channel token
|
||||
if sec.Channels.Pico != nil && sec.Channels.Pico.Token != "" {
|
||||
cfg.Channels.Pico.token = sec.Channels.Pico.Token
|
||||
}
|
||||
if sec.Channels.WeComApp.Token != "" {
|
||||
cfg.Channels.WeComApp.token = sec.Channels.WeComApp.Token
|
||||
}
|
||||
if sec.Channels.WeComApp.EncodingAESKey != "" {
|
||||
cfg.Channels.WeComApp.encodingAESKey = sec.Channels.WeComApp.EncodingAESKey
|
||||
}
|
||||
}
|
||||
|
||||
// Handle WeCom AI Bot credentials
|
||||
if sec.Channels.WeComAIBot != nil {
|
||||
if sec.Channels.WeComAIBot.Token != "" {
|
||||
cfg.Channels.WeComAIBot.token = sec.Channels.WeComAIBot.Token
|
||||
// Handle IRC passwords
|
||||
if sec.Channels.IRC != nil {
|
||||
if sec.Channels.IRC.Password != "" {
|
||||
cfg.Channels.IRC.password = sec.Channels.IRC.Password
|
||||
}
|
||||
if sec.Channels.IRC.NickServPassword != "" {
|
||||
cfg.Channels.IRC.nickServPassword = sec.Channels.IRC.NickServPassword
|
||||
}
|
||||
if sec.Channels.IRC.SASLPassword != "" {
|
||||
cfg.Channels.IRC.saslPassword = sec.Channels.IRC.SASLPassword
|
||||
}
|
||||
}
|
||||
if sec.Channels.WeComAIBot.EncodingAESKey != "" {
|
||||
cfg.Channels.WeComAIBot.encodingAESKey = sec.Channels.WeComAIBot.EncodingAESKey
|
||||
}
|
||||
if sec.Channels.WeComAIBot.Secret != "" {
|
||||
cfg.Channels.WeComAIBot.secret = sec.Channels.WeComAIBot.Secret
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Pico channel token
|
||||
if sec.Channels.Pico != nil && sec.Channels.Pico.Token != "" {
|
||||
cfg.Channels.Pico.token = sec.Channels.Pico.Token
|
||||
}
|
||||
|
||||
// Handle IRC passwords
|
||||
if sec.Channels.IRC != nil {
|
||||
if sec.Channels.IRC.Password != "" {
|
||||
cfg.Channels.IRC.password = sec.Channels.IRC.Password
|
||||
// Handle QQ app secret
|
||||
if sec.Channels.QQ != nil && sec.Channels.QQ.AppSecret != "" {
|
||||
cfg.Channels.QQ.appSecret = sec.Channels.QQ.AppSecret
|
||||
}
|
||||
if sec.Channels.IRC.NickServPassword != "" {
|
||||
cfg.Channels.IRC.nickServPassword = sec.Channels.IRC.NickServPassword
|
||||
}
|
||||
if sec.Channels.IRC.SASLPassword != "" {
|
||||
cfg.Channels.IRC.saslPassword = sec.Channels.IRC.SASLPassword
|
||||
}
|
||||
}
|
||||
|
||||
// Handle QQ app secret
|
||||
if sec.Channels.QQ != nil && sec.Channels.QQ.AppSecret != "" {
|
||||
cfg.Channels.QQ.appSecret = sec.Channels.QQ.AppSecret
|
||||
}
|
||||
|
||||
cfg.security = sec
|
||||
|
|
@ -1607,6 +1583,28 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// applySecurityConfigWithPrecedence merges security config from tmpCfg (migrated from configV0) and sec (SecurityConfig),
|
||||
// with tmpCfg taking precedence. It then applies the merged security config to cfg.
|
||||
func applySecurityConfigWithPrecedence(cfg *Config, tmpCfg *Config, sec *SecurityConfig) error {
|
||||
// Get security config from tmpCfg (already extracted during migration)
|
||||
var tmpSec *SecurityConfig
|
||||
if tmpCfg != nil {
|
||||
tmpSec = tmpCfg.security
|
||||
}
|
||||
|
||||
// If tmpCfg has no security config, just apply sec directly
|
||||
if tmpSec == nil {
|
||||
return applySecurityConfig(cfg, sec)
|
||||
}
|
||||
|
||||
// Merge sec and tmpSec, with tmpSec (from config.json) taking precedence
|
||||
// mergeSecurityConfig(existing, newer) - newer takes precedence
|
||||
mergedSec := mergeSecurityConfig(sec, tmpSec)
|
||||
|
||||
// Apply the merged security config to cfg
|
||||
return applySecurityConfig(cfg, mergedSec)
|
||||
}
|
||||
|
||||
func toNameIndex(list []*ModelConfig) []string {
|
||||
nameList := make([]string, 0, len(list))
|
||||
countMap := make(map[string]int)
|
||||
|
|
@ -1701,6 +1699,7 @@ func SaveConfig(path string, cfg *Config) error {
|
|||
logger.ErrorC("config", "security is nil")
|
||||
return fmt.Errorf("security is nil")
|
||||
}
|
||||
cfg.security = normalizeSecurityConfig(cfg.security)
|
||||
// Ensure version is always set when saving
|
||||
if cfg.Version == 0 {
|
||||
cfg.Version = CurrentVersion
|
||||
|
|
@ -1797,27 +1796,10 @@ func SaveConfig(path string, cfg *Config) error {
|
|||
}
|
||||
if cfg.Channels.WeCom.secDirty {
|
||||
cfg.security.Channels.WeCom = &WeComSecurity{
|
||||
Token: cfg.Channels.WeCom.Token(),
|
||||
EncodingAESKey: cfg.Channels.WeCom.EncodingAESKey(),
|
||||
Secret: cfg.Channels.WeCom.Secret(),
|
||||
}
|
||||
cfg.Channels.WeCom.secDirty = false
|
||||
}
|
||||
if cfg.Channels.WeComApp.secDirty {
|
||||
cfg.security.Channels.WeComApp = &WeComAppSecurity{
|
||||
CorpSecret: cfg.Channels.WeComApp.CorpSecret(),
|
||||
Token: cfg.Channels.WeComApp.Token(),
|
||||
EncodingAESKey: cfg.Channels.WeComApp.EncodingAESKey(),
|
||||
}
|
||||
cfg.Channels.WeComApp.secDirty = false
|
||||
}
|
||||
if cfg.Channels.WeComAIBot.secDirty {
|
||||
cfg.security.Channels.WeComAIBot = &WeComAIBotSecurity{
|
||||
Token: cfg.Channels.WeComAIBot.Token(),
|
||||
EncodingAESKey: cfg.Channels.WeComAIBot.EncodingAESKey(),
|
||||
Secret: cfg.Channels.WeComAIBot.Secret(),
|
||||
}
|
||||
cfg.Channels.WeComAIBot.secDirty = false
|
||||
}
|
||||
if cfg.Tools.Web.Brave.secDirty {
|
||||
cfg.security.Web.Brave = &BraveSecurity{
|
||||
APIKeys: cfg.Tools.Web.Brave.APIKeys(),
|
||||
|
|
@ -1875,10 +1857,24 @@ func SaveConfig(path string, cfg *Config) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Filter out virtual models before serializing to config file
|
||||
nonVirtualModels := make([]*ModelConfig, 0, len(cfg.ModelList))
|
||||
for _, m := range cfg.ModelList {
|
||||
if !m.isVirtual {
|
||||
nonVirtualModels = append(nonVirtualModels, m)
|
||||
}
|
||||
}
|
||||
// Temporarily replace ModelList with filtered version for serialization
|
||||
originalModelList := cfg.ModelList
|
||||
cfg.ModelList = nonVirtualModels
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
// Restore original ModelList after serialization
|
||||
cfg.ModelList = originalModelList
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Infof("saving config to %s", path)
|
||||
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||
}
|
||||
|
||||
|
|
@ -1942,6 +1938,17 @@ func (c *Config) ValidateModelList() error {
|
|||
|
||||
func (c *Config) SecurityCopyFrom(cfg *Config) {
|
||||
c.security = cfg.security
|
||||
if c.security != nil {
|
||||
if err := applySecurityConfig(c, c.security); err != nil {
|
||||
logger.Errorf("failed to apply security config in SecurityCopyFrom: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ApplySecurity re-applies the stored security config to populate private fields (tokens, API keys, etc.).
|
||||
// Call this after SecurityCopyFrom when you need private fields to be accessible for validation or use.
|
||||
func (c *Config) ApplySecurity() error {
|
||||
return applySecurityConfig(c, c.security)
|
||||
}
|
||||
|
||||
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
|
||||
|
|
@ -2081,6 +2088,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
|
|||
RequestTimeout: m.RequestTimeout,
|
||||
ThinkingLevel: m.ThinkingLevel,
|
||||
ExtraBody: m.ExtraBody,
|
||||
isVirtual: true,
|
||||
}
|
||||
expanded = append(expanded, additionalEntry)
|
||||
fallbackNames = append(fallbackNames, expandedName)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
|
||||
package config
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type agentDefaultsV0 struct {
|
||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||
|
|
@ -83,23 +85,21 @@ type toolsConfigV0 struct {
|
|||
}
|
||||
|
||||
type channelsConfigV0 struct {
|
||||
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
||||
Telegram telegramConfigV0 `json:"telegram"`
|
||||
Feishu feishuConfigV0 `json:"feishu"`
|
||||
Discord discordConfigV0 `json:"discord"`
|
||||
MaixCam maixcamConfigV0 `json:"maixcam"`
|
||||
Weixin weixinConfigV0 `json:"weixin"`
|
||||
QQ qqConfigV0 `json:"qq"`
|
||||
DingTalk dingtalkConfigV0 `json:"dingtalk"`
|
||||
Slack slackConfigV0 `json:"slack"`
|
||||
Matrix matrixConfigV0 `json:"matrix"`
|
||||
LINE lineConfigV0 `json:"line"`
|
||||
OneBot onebotConfigV0 `json:"onebot"`
|
||||
WeCom wecomConfigV0 `json:"wecom"`
|
||||
WeComApp wecomappConfigV0 `json:"wecom_app"`
|
||||
WeComAIBot wecomaibotConfigV0 `json:"wecom_aibot"`
|
||||
Pico picoConfigV0 `json:"pico"`
|
||||
IRC ircConfigV0 `json:"irc"`
|
||||
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
||||
Telegram telegramConfigV0 `json:"telegram"`
|
||||
Feishu feishuConfigV0 `json:"feishu"`
|
||||
Discord discordConfigV0 `json:"discord"`
|
||||
MaixCam maixcamConfigV0 `json:"maixcam"`
|
||||
Weixin weixinConfigV0 `json:"weixin"`
|
||||
QQ qqConfigV0 `json:"qq"`
|
||||
DingTalk dingtalkConfigV0 `json:"dingtalk"`
|
||||
Slack slackConfigV0 `json:"slack"`
|
||||
Matrix matrixConfigV0 `json:"matrix"`
|
||||
LINE lineConfigV0 `json:"line"`
|
||||
OneBot onebotConfigV0 `json:"onebot"`
|
||||
WeCom wecomConfigV0 `json:"wecom" envPrefix:"PICOCLAW_CHANNELS_WECOM_"`
|
||||
Pico picoConfigV0 `json:"pico"`
|
||||
IRC ircConfigV0 `json:"irc"`
|
||||
}
|
||||
|
||||
func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity) {
|
||||
|
|
@ -115,45 +115,39 @@ func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity)
|
|||
line, lineSecurity := v.LINE.ToLINEConfig()
|
||||
onebot, onebotSecurity := v.OneBot.ToOneBotConfig()
|
||||
wecom, wecomSecurity := v.WeCom.ToWeComConfig()
|
||||
wecomapp, wecomappSecurity := v.WeComApp.ToWeComAppConfig()
|
||||
wecomaibot, wecomaibotSecurity := v.WeComAIBot.ToWeComAIBotConfig()
|
||||
pico, picoSecurity := v.Pico.ToPicoConfig()
|
||||
irc, ircSecurity := v.IRC.ToIRCConfig()
|
||||
|
||||
return ChannelsConfig{
|
||||
WhatsApp: v.WhatsApp,
|
||||
Telegram: telegram,
|
||||
Feishu: feishu,
|
||||
Discord: discord,
|
||||
MaixCam: maixcam,
|
||||
QQ: qq,
|
||||
Weixin: weixin,
|
||||
DingTalk: dingtalk,
|
||||
Slack: slack,
|
||||
Matrix: matrix,
|
||||
LINE: line,
|
||||
OneBot: onebot,
|
||||
WeCom: wecom,
|
||||
WeComApp: wecomapp,
|
||||
WeComAIBot: wecomaibot,
|
||||
Pico: pico,
|
||||
IRC: irc,
|
||||
WhatsApp: v.WhatsApp,
|
||||
Telegram: telegram,
|
||||
Feishu: feishu,
|
||||
Discord: discord,
|
||||
MaixCam: maixcam,
|
||||
QQ: qq,
|
||||
Weixin: weixin,
|
||||
DingTalk: dingtalk,
|
||||
Slack: slack,
|
||||
Matrix: matrix,
|
||||
LINE: line,
|
||||
OneBot: onebot,
|
||||
WeCom: wecom,
|
||||
Pico: pico,
|
||||
IRC: irc,
|
||||
}, ChannelsSecurity{
|
||||
Telegram: &telegramSecurity,
|
||||
Feishu: &feishuSecurity,
|
||||
Discord: &discordSecurity,
|
||||
QQ: &qqSecurity,
|
||||
Weixin: &weixinSecurity,
|
||||
DingTalk: &dingtalkSecurity,
|
||||
Slack: &slackSecurity,
|
||||
Matrix: &matrixSecurity,
|
||||
LINE: &lineSecurity,
|
||||
OneBot: &onebotSecurity,
|
||||
WeCom: &wecomSecurity,
|
||||
WeComApp: &wecomappSecurity,
|
||||
WeComAIBot: &wecomaibotSecurity,
|
||||
Pico: &picoSecurity,
|
||||
IRC: &ircSecurity,
|
||||
Telegram: telegramSecurity,
|
||||
Feishu: feishuSecurity,
|
||||
Discord: discordSecurity,
|
||||
QQ: qqSecurity,
|
||||
Weixin: weixinSecurity,
|
||||
DingTalk: dingtalkSecurity,
|
||||
Slack: slackSecurity,
|
||||
Matrix: matrixSecurity,
|
||||
LINE: lineSecurity,
|
||||
OneBot: onebotSecurity,
|
||||
WeCom: wecomSecurity,
|
||||
Pico: picoSecurity,
|
||||
IRC: ircSecurity,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -169,19 +163,23 @@ type qqConfigV0 struct {
|
|||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *qqConfigV0) ToQQConfig() (QQConfig, QQSecurity) {
|
||||
return QQConfig{
|
||||
Enabled: v.Enabled,
|
||||
AppID: v.AppID,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
MaxMessageLength: v.MaxMessageLength,
|
||||
MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB,
|
||||
SendMarkdown: v.SendMarkdown,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, QQSecurity{
|
||||
func (v *qqConfigV0) ToQQConfig() (QQConfig, *QQSecurity) {
|
||||
var sec *QQSecurity
|
||||
if v.AppSecret != "" {
|
||||
sec = &QQSecurity{
|
||||
AppSecret: v.AppSecret,
|
||||
}
|
||||
}
|
||||
return QQConfig{
|
||||
Enabled: v.Enabled,
|
||||
AppID: v.AppID,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
MaxMessageLength: v.MaxMessageLength,
|
||||
MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB,
|
||||
SendMarkdown: v.SendMarkdown,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type telegramConfigV0 struct {
|
||||
|
|
@ -197,21 +195,25 @@ type telegramConfigV0 struct {
|
|||
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||
}
|
||||
|
||||
func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, TelegramSecurity) {
|
||||
return TelegramConfig{
|
||||
Enabled: v.Enabled,
|
||||
token: v.Token,
|
||||
BaseURL: v.BaseURL,
|
||||
Proxy: v.Proxy,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
UseMarkdownV2: v.UseMarkdownV2,
|
||||
}, TelegramSecurity{
|
||||
func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, *TelegramSecurity) {
|
||||
var sec *TelegramSecurity
|
||||
if v.Token != "" {
|
||||
sec = &TelegramSecurity{
|
||||
Token: v.Token,
|
||||
}
|
||||
}
|
||||
return TelegramConfig{
|
||||
Enabled: v.Enabled,
|
||||
token: v.Token,
|
||||
BaseURL: v.BaseURL,
|
||||
Proxy: v.Proxy,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
UseMarkdownV2: v.UseMarkdownV2,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type feishuConfigV0 struct {
|
||||
|
|
@ -228,20 +230,24 @@ type feishuConfigV0 struct {
|
|||
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
|
||||
}
|
||||
|
||||
func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, FeishuSecurity) {
|
||||
return FeishuConfig{
|
||||
Enabled: v.Enabled,
|
||||
AppID: v.AppID,
|
||||
appSecret: v.AppSecret,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, FeishuSecurity{
|
||||
func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, *FeishuSecurity) {
|
||||
var sec *FeishuSecurity
|
||||
if v.AppSecret != "" || v.EncryptKey != "" || v.VerificationToken != "" {
|
||||
sec = &FeishuSecurity{
|
||||
AppSecret: v.AppSecret,
|
||||
EncryptKey: v.EncryptKey,
|
||||
VerificationToken: v.VerificationToken,
|
||||
}
|
||||
}
|
||||
return FeishuConfig{
|
||||
Enabled: v.Enabled,
|
||||
AppID: v.AppID,
|
||||
appSecret: v.AppSecret,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type discordConfigV0 struct {
|
||||
|
|
@ -256,20 +262,24 @@ type discordConfigV0 struct {
|
|||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, DiscordSecurity) {
|
||||
return DiscordConfig{
|
||||
Enabled: v.Enabled,
|
||||
token: v.Token,
|
||||
Proxy: v.Proxy,
|
||||
AllowFrom: v.AllowFrom,
|
||||
MentionOnly: v.MentionOnly,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, DiscordSecurity{
|
||||
func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, *DiscordSecurity) {
|
||||
var sec *DiscordSecurity
|
||||
if v.Token != "" {
|
||||
sec = &DiscordSecurity{
|
||||
Token: v.Token,
|
||||
}
|
||||
}
|
||||
return DiscordConfig{
|
||||
Enabled: v.Enabled,
|
||||
token: v.Token,
|
||||
Proxy: v.Proxy,
|
||||
AllowFrom: v.AllowFrom,
|
||||
MentionOnly: v.MentionOnly,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type maixcamConfigV0 struct {
|
||||
|
|
@ -299,17 +309,21 @@ type dingtalkConfigV0 struct {
|
|||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, DingTalkSecurity) {
|
||||
return DingTalkConfig{
|
||||
Enabled: v.Enabled,
|
||||
ClientID: v.ClientID,
|
||||
clientSecret: v.ClientSecret,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, DingTalkSecurity{
|
||||
func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, *DingTalkSecurity) {
|
||||
var sec *DingTalkSecurity
|
||||
if v.ClientSecret != "" {
|
||||
sec = &DingTalkSecurity{
|
||||
ClientSecret: v.ClientSecret,
|
||||
}
|
||||
}
|
||||
return DingTalkConfig{
|
||||
Enabled: v.Enabled,
|
||||
ClientID: v.ClientID,
|
||||
clientSecret: v.ClientSecret,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type slackConfigV0 struct {
|
||||
|
|
@ -323,20 +337,24 @@ type slackConfigV0 struct {
|
|||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *slackConfigV0) ToSlackConfig() (SlackConfig, SlackSecurity) {
|
||||
return SlackConfig{
|
||||
Enabled: v.Enabled,
|
||||
botToken: v.BotToken,
|
||||
appToken: v.AppToken,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, SlackSecurity{
|
||||
func (v *slackConfigV0) ToSlackConfig() (SlackConfig, *SlackSecurity) {
|
||||
var sec *SlackSecurity
|
||||
if v.BotToken != "" || v.AppToken != "" {
|
||||
sec = &SlackSecurity{
|
||||
BotToken: v.BotToken,
|
||||
AppToken: v.AppToken,
|
||||
}
|
||||
}
|
||||
return SlackConfig{
|
||||
Enabled: v.Enabled,
|
||||
botToken: v.BotToken,
|
||||
appToken: v.AppToken,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type matrixConfigV0 struct {
|
||||
|
|
@ -353,22 +371,26 @@ type matrixConfigV0 struct {
|
|||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, MatrixSecurity) {
|
||||
return MatrixConfig{
|
||||
Enabled: v.Enabled,
|
||||
Homeserver: v.Homeserver,
|
||||
UserID: v.UserID,
|
||||
accessToken: v.AccessToken,
|
||||
DeviceID: v.DeviceID,
|
||||
JoinOnInvite: v.JoinOnInvite,
|
||||
MessageFormat: v.MessageFormat,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, MatrixSecurity{
|
||||
func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, *MatrixSecurity) {
|
||||
var sec *MatrixSecurity
|
||||
if v.AccessToken != "" {
|
||||
sec = &MatrixSecurity{
|
||||
AccessToken: v.AccessToken,
|
||||
}
|
||||
}
|
||||
return MatrixConfig{
|
||||
Enabled: v.Enabled,
|
||||
Homeserver: v.Homeserver,
|
||||
UserID: v.UserID,
|
||||
accessToken: v.AccessToken,
|
||||
DeviceID: v.DeviceID,
|
||||
JoinOnInvite: v.JoinOnInvite,
|
||||
MessageFormat: v.MessageFormat,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type lineConfigV0 struct {
|
||||
|
|
@ -385,23 +407,27 @@ type lineConfigV0 struct {
|
|||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *lineConfigV0) ToLINEConfig() (LINEConfig, LINESecurity) {
|
||||
return LINEConfig{
|
||||
Enabled: v.Enabled,
|
||||
channelSecret: v.ChannelSecret,
|
||||
channelAccessToken: v.ChannelAccessToken,
|
||||
WebhookHost: v.WebhookHost,
|
||||
WebhookPort: v.WebhookPort,
|
||||
WebhookPath: v.WebhookPath,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, LINESecurity{
|
||||
func (v *lineConfigV0) ToLINEConfig() (LINEConfig, *LINESecurity) {
|
||||
var sec *LINESecurity
|
||||
if v.ChannelSecret != "" || v.ChannelAccessToken != "" {
|
||||
sec = &LINESecurity{
|
||||
ChannelSecret: v.ChannelSecret,
|
||||
ChannelAccessToken: v.ChannelAccessToken,
|
||||
}
|
||||
}
|
||||
return LINEConfig{
|
||||
Enabled: v.Enabled,
|
||||
channelSecret: v.ChannelSecret,
|
||||
channelAccessToken: v.ChannelAccessToken,
|
||||
WebhookHost: v.WebhookHost,
|
||||
WebhookPort: v.WebhookPort,
|
||||
WebhookPath: v.WebhookPath,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type onebotConfigV0 struct {
|
||||
|
|
@ -417,54 +443,55 @@ type onebotConfigV0 struct {
|
|||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, OneBotSecurity) {
|
||||
return OneBotConfig{
|
||||
Enabled: v.Enabled,
|
||||
WSUrl: v.WSUrl,
|
||||
accessToken: v.AccessToken,
|
||||
ReconnectInterval: v.ReconnectInterval,
|
||||
GroupTriggerPrefix: v.GroupTriggerPrefix,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, OneBotSecurity{
|
||||
func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, *OneBotSecurity) {
|
||||
var sec *OneBotSecurity
|
||||
if v.AccessToken != "" {
|
||||
sec = &OneBotSecurity{
|
||||
AccessToken: v.AccessToken,
|
||||
}
|
||||
}
|
||||
return OneBotConfig{
|
||||
Enabled: v.Enabled,
|
||||
WSUrl: v.WSUrl,
|
||||
accessToken: v.AccessToken,
|
||||
ReconnectInterval: v.ReconnectInterval,
|
||||
GroupTriggerPrefix: v.GroupTriggerPrefix,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
Placeholder: v.Placeholder,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type wecomConfigV0 struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
|
||||
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
|
||||
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
|
||||
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
|
||||
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
|
||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
|
||||
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
|
||||
Enabled bool `json:"enabled" env:"ENABLED"`
|
||||
BotID string `json:"bot_id" env:"BOT_ID"`
|
||||
Secret string `json:"secret" env:"SECRET"`
|
||||
WebSocketURL string `json:"websocket_url,omitempty" env:"WEBSOCKET_URL"`
|
||||
SendThinkingMessage bool `json:"send_thinking_message" env:"SEND_THINKING_MESSAGE"`
|
||||
DMPolicy string `json:"dm_policy,omitempty" env:"DM_POLICY"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"ALLOW_FROM"`
|
||||
GroupPolicy string `json:"group_policy,omitempty" env:"GROUP_POLICY"`
|
||||
GroupAllowFrom FlexibleStringSlice `json:"group_allow_from,omitempty" env:"GROUP_ALLOW_FROM"`
|
||||
Groups map[string]WeComGroupConfig `json:"groups,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, WeComSecurity) {
|
||||
func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, *WeComSecurity) {
|
||||
var sec *WeComSecurity
|
||||
if v.Secret != "" {
|
||||
sec = &WeComSecurity{Secret: v.Secret}
|
||||
}
|
||||
return WeComConfig{
|
||||
Enabled: v.Enabled,
|
||||
token: v.Token,
|
||||
encodingAESKey: v.EncodingAESKey,
|
||||
WebhookURL: v.WebhookURL,
|
||||
WebhookHost: v.WebhookHost,
|
||||
WebhookPort: v.WebhookPort,
|
||||
WebhookPath: v.WebhookPath,
|
||||
AllowFrom: v.AllowFrom,
|
||||
ReplyTimeout: v.ReplyTimeout,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, WeComSecurity{
|
||||
Token: v.Token,
|
||||
EncodingAESKey: v.EncodingAESKey,
|
||||
}
|
||||
Enabled: v.Enabled,
|
||||
BotID: v.BotID,
|
||||
secret: v.Secret,
|
||||
WebSocketURL: v.WebSocketURL,
|
||||
SendThinkingMessage: v.SendThinkingMessage,
|
||||
AllowFrom: v.AllowFrom,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type weixinConfigV0 struct {
|
||||
|
|
@ -477,85 +504,22 @@ type weixinConfigV0 struct {
|
|||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, WeixinSecurity) {
|
||||
return WeixinConfig{
|
||||
Enabled: v.Enabled,
|
||||
token: v.Token,
|
||||
BaseURL: v.BaseURL,
|
||||
CDNBaseURL: v.CDNBaseURL,
|
||||
Proxy: v.Proxy,
|
||||
AllowFrom: v.AllowFrom,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, WeixinSecurity{
|
||||
func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, *WeixinSecurity) {
|
||||
var sec *WeixinSecurity
|
||||
if v.Token != "" {
|
||||
sec = &WeixinSecurity{
|
||||
Token: v.Token,
|
||||
}
|
||||
}
|
||||
|
||||
type wecomappConfigV0 struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
|
||||
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
|
||||
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
|
||||
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
|
||||
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
|
||||
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
|
||||
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
|
||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
|
||||
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *wecomappConfigV0) ToWeComAppConfig() (WeComAppConfig, WeComAppSecurity) {
|
||||
return WeComAppConfig{
|
||||
Enabled: v.Enabled,
|
||||
CorpID: v.CorpID,
|
||||
corpSecret: v.CorpSecret,
|
||||
AgentID: v.AgentID,
|
||||
token: v.Token,
|
||||
encodingAESKey: v.EncodingAESKey,
|
||||
WebhookHost: v.WebhookHost,
|
||||
WebhookPort: v.WebhookPort,
|
||||
WebhookPath: v.WebhookPath,
|
||||
AllowFrom: v.AllowFrom,
|
||||
ReplyTimeout: v.ReplyTimeout,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, WeComAppSecurity{
|
||||
CorpSecret: v.CorpSecret,
|
||||
Token: v.Token,
|
||||
EncodingAESKey: v.EncodingAESKey,
|
||||
}
|
||||
}
|
||||
|
||||
type wecomaibotConfigV0 struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
|
||||
Secret string `json:"secret" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"`
|
||||
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
|
||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
|
||||
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
|
||||
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"`
|
||||
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, WeComAIBotSecurity) {
|
||||
return WeComAIBotConfig{
|
||||
Enabled: v.Enabled,
|
||||
WebhookPath: v.WebhookPath,
|
||||
AllowFrom: v.AllowFrom,
|
||||
ReplyTimeout: v.ReplyTimeout,
|
||||
MaxSteps: v.MaxSteps,
|
||||
WelcomeMessage: v.WelcomeMessage,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, WeComAIBotSecurity{
|
||||
Token: v.Token,
|
||||
Secret: v.Secret,
|
||||
EncodingAESKey: v.EncodingAESKey,
|
||||
}
|
||||
}
|
||||
return WeixinConfig{
|
||||
Enabled: v.Enabled,
|
||||
token: v.Token,
|
||||
BaseURL: v.BaseURL,
|
||||
CDNBaseURL: v.CDNBaseURL,
|
||||
Proxy: v.Proxy,
|
||||
AllowFrom: v.AllowFrom,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type picoConfigV0 struct {
|
||||
|
|
@ -571,21 +535,25 @@ type picoConfigV0 struct {
|
|||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||
}
|
||||
|
||||
func (v *picoConfigV0) ToPicoConfig() (PicoConfig, PicoSecurity) {
|
||||
return PicoConfig{
|
||||
Enabled: v.Enabled,
|
||||
token: v.Token,
|
||||
AllowTokenQuery: v.AllowTokenQuery,
|
||||
AllowOrigins: v.AllowOrigins,
|
||||
PingInterval: v.PingInterval,
|
||||
ReadTimeout: v.ReadTimeout,
|
||||
WriteTimeout: v.WriteTimeout,
|
||||
MaxConnections: v.MaxConnections,
|
||||
AllowFrom: v.AllowFrom,
|
||||
Placeholder: v.Placeholder,
|
||||
}, PicoSecurity{
|
||||
func (v *picoConfigV0) ToPicoConfig() (PicoConfig, *PicoSecurity) {
|
||||
var sec *PicoSecurity
|
||||
if v.Token != "" {
|
||||
sec = &PicoSecurity{
|
||||
Token: v.Token,
|
||||
}
|
||||
}
|
||||
return PicoConfig{
|
||||
Enabled: v.Enabled,
|
||||
token: v.Token,
|
||||
AllowTokenQuery: v.AllowTokenQuery,
|
||||
AllowOrigins: v.AllowOrigins,
|
||||
PingInterval: v.PingInterval,
|
||||
ReadTimeout: v.ReadTimeout,
|
||||
WriteTimeout: v.WriteTimeout,
|
||||
MaxConnections: v.MaxConnections,
|
||||
AllowFrom: v.AllowFrom,
|
||||
Placeholder: v.Placeholder,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type ircConfigV0 struct {
|
||||
|
|
@ -607,29 +575,33 @@ type ircConfigV0 struct {
|
|||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (v *ircConfigV0) ToIRCConfig() (IRCConfig, IRCSecurity) {
|
||||
return IRCConfig{
|
||||
Enabled: v.Enabled,
|
||||
Server: v.Server,
|
||||
TLS: v.TLS,
|
||||
Nick: v.Nick,
|
||||
User: v.User,
|
||||
RealName: v.RealName,
|
||||
password: v.Password,
|
||||
nickServPassword: v.NickServPassword,
|
||||
SASLUser: v.SASLUser,
|
||||
saslPassword: v.SASLPassword,
|
||||
Channels: v.Channels,
|
||||
RequestCaps: v.RequestCaps,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, IRCSecurity{
|
||||
func (v *ircConfigV0) ToIRCConfig() (IRCConfig, *IRCSecurity) {
|
||||
var sec *IRCSecurity
|
||||
if v.Password != "" || v.NickServPassword != "" || v.SASLPassword != "" {
|
||||
sec = &IRCSecurity{
|
||||
Password: v.Password,
|
||||
NickServPassword: v.NickServPassword,
|
||||
SASLPassword: v.SASLPassword,
|
||||
}
|
||||
}
|
||||
return IRCConfig{
|
||||
Enabled: v.Enabled,
|
||||
Server: v.Server,
|
||||
TLS: v.TLS,
|
||||
Nick: v.Nick,
|
||||
User: v.User,
|
||||
RealName: v.RealName,
|
||||
password: v.Password,
|
||||
nickServPassword: v.NickServPassword,
|
||||
SASLUser: v.SASLUser,
|
||||
saslPassword: v.SASLPassword,
|
||||
Channels: v.Channels,
|
||||
RequestCaps: v.RequestCaps,
|
||||
AllowFrom: v.AllowFrom,
|
||||
GroupTrigger: v.GroupTrigger,
|
||||
Typing: v.Typing,
|
||||
ReasoningChannelID: v.ReasoningChannelID,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type providersConfigV0 struct {
|
||||
|
|
@ -783,7 +755,7 @@ func (c *configV0) Migrate() (*Config, error) {
|
|||
cfg.Tools.Web, secWeb = c.Tools.Web.ToWebToolsConfig()
|
||||
cfg.Tools.Cron = c.Tools.Cron
|
||||
cfg.Tools.Exec = c.Tools.Exec
|
||||
var secSkills SkillsSecurity
|
||||
var secSkills *SkillsSecurity
|
||||
cfg.Tools.Skills, secSkills = c.Tools.Skills.ToSkillsToolsConfig()
|
||||
cfg.Tools.MediaCleanup = c.Tools.MediaCleanup
|
||||
cfg.Tools.MCP = c.Tools.MCP
|
||||
|
|
@ -835,16 +807,18 @@ func (c *configV0) Migrate() (*Config, error) {
|
|||
for i, m := range c.ModelList {
|
||||
// Merge APIKey and APIKeys, deduplicating
|
||||
mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys)
|
||||
secModels[names[i]] = ModelSecurityEntry{
|
||||
APIKeys: mergedKeys,
|
||||
if len(mergedKeys) > 0 {
|
||||
secModels[names[i]] = ModelSecurityEntry{
|
||||
APIKeys: mergedKeys,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg.WithSecurity(&SecurityConfig{
|
||||
ModelList: secModels,
|
||||
Channels: secChannels,
|
||||
Web: secWeb,
|
||||
Channels: &secChannels,
|
||||
Web: &secWeb,
|
||||
Skills: secSkills,
|
||||
})
|
||||
cfg.Version = CurrentVersion
|
||||
|
|
@ -859,6 +833,7 @@ type webToolsConfigV0 struct {
|
|||
Perplexity perplexityConfigV0 ` json:"perplexity"`
|
||||
SearXNG SearXNGConfig ` json:"searxng"`
|
||||
GLMSearch glmSearchConfigV0 ` json:"glm_search"`
|
||||
BaiduSearch baiduSearchConfigV0 ` json:"baidu_search"`
|
||||
PreferNative bool ` json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
|
||||
Proxy string ` json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||
FetchLimitBytes int64 ` json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
|
||||
|
|
@ -873,13 +848,17 @@ type braveConfigV0 struct {
|
|||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
func (v *braveConfigV0) ToBraveConfig() (BraveConfig, BraveSecurity) {
|
||||
return BraveConfig{
|
||||
Enabled: v.Enabled,
|
||||
MaxResults: v.MaxResults,
|
||||
}, BraveSecurity{
|
||||
func (v *braveConfigV0) ToBraveConfig() (BraveConfig, *BraveSecurity) {
|
||||
var sec *BraveSecurity
|
||||
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
|
||||
sec = &BraveSecurity{
|
||||
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
|
||||
}
|
||||
}
|
||||
return BraveConfig{
|
||||
Enabled: v.Enabled,
|
||||
MaxResults: v.MaxResults,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type tavilyConfigV0 struct {
|
||||
|
|
@ -890,14 +869,18 @@ type tavilyConfigV0 struct {
|
|||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, TavilySecurity) {
|
||||
return TavilyConfig{
|
||||
Enabled: v.Enabled,
|
||||
BaseURL: v.BaseURL,
|
||||
MaxResults: v.MaxResults,
|
||||
}, TavilySecurity{
|
||||
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
|
||||
func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, *TavilySecurity) {
|
||||
var sec *TavilySecurity
|
||||
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
|
||||
sec = &TavilySecurity{
|
||||
APIKeys: k,
|
||||
}
|
||||
}
|
||||
return TavilyConfig{
|
||||
Enabled: v.Enabled,
|
||||
BaseURL: v.BaseURL,
|
||||
MaxResults: v.MaxResults,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type perplexityConfigV0 struct {
|
||||
|
|
@ -907,13 +890,17 @@ type perplexityConfigV0 struct {
|
|||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, PerplexitySecurity) {
|
||||
return PerplexityConfig{
|
||||
Enabled: v.Enabled,
|
||||
MaxResults: v.MaxResults,
|
||||
}, PerplexitySecurity{
|
||||
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
|
||||
func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, *PerplexitySecurity) {
|
||||
var sec *PerplexitySecurity
|
||||
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
|
||||
sec = &PerplexitySecurity{
|
||||
APIKeys: k,
|
||||
}
|
||||
}
|
||||
return PerplexityConfig{
|
||||
Enabled: v.Enabled,
|
||||
MaxResults: v.MaxResults,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type glmSearchConfigV0 struct {
|
||||
|
|
@ -923,15 +910,41 @@ type glmSearchConfigV0 struct {
|
|||
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
|
||||
}
|
||||
|
||||
func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, GLMSearchSecurity) {
|
||||
return GLMSearchConfig{
|
||||
Enabled: v.Enabled,
|
||||
apiKey: v.APIKey,
|
||||
BaseURL: v.BaseURL,
|
||||
SearchEngine: v.SearchEngine,
|
||||
}, GLMSearchSecurity{
|
||||
func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, *GLMSearchSecurity) {
|
||||
var sec *GLMSearchSecurity
|
||||
if v.APIKey != "" {
|
||||
sec = &GLMSearchSecurity{
|
||||
APIKey: v.APIKey,
|
||||
}
|
||||
}
|
||||
return GLMSearchConfig{
|
||||
Enabled: v.Enabled,
|
||||
apiKey: v.APIKey,
|
||||
BaseURL: v.BaseURL,
|
||||
SearchEngine: v.SearchEngine,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type baiduSearchConfigV0 struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BAIDU_ENABLED"`
|
||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"`
|
||||
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_BAIDU_BASE_URL"`
|
||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
func (v *baiduSearchConfigV0) ToBaiduSearchConfig() (BaiduSearchConfig, *BaiduSearchSecurity) {
|
||||
var sec *BaiduSearchSecurity
|
||||
if v.APIKey != "" {
|
||||
sec = &BaiduSearchSecurity{
|
||||
APIKey: v.APIKey,
|
||||
}
|
||||
}
|
||||
return BaiduSearchConfig{
|
||||
Enabled: v.Enabled,
|
||||
apiKey: v.APIKey,
|
||||
BaseURL: v.BaseURL,
|
||||
MaxResults: v.MaxResults,
|
||||
}, sec
|
||||
}
|
||||
|
||||
func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) {
|
||||
|
|
@ -939,6 +952,7 @@ func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity)
|
|||
tavily, tavilySecurity := v.Tavily.ToTavilyConfig()
|
||||
perplexity, perplexitySecurity := v.Perplexity.ToPerplexityConfig()
|
||||
glmSearch, glmSearchSecurity := v.GLMSearch.ToGLMSearchConfig()
|
||||
baiduSearch, baiduSearchSecurity := v.BaiduSearch.ToBaiduSearchConfig()
|
||||
|
||||
return WebToolsConfig{
|
||||
ToolConfig: v.ToolConfig,
|
||||
|
|
@ -948,16 +962,18 @@ func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity)
|
|||
Perplexity: perplexity,
|
||||
SearXNG: v.SearXNG,
|
||||
GLMSearch: glmSearch,
|
||||
BaiduSearch: baiduSearch,
|
||||
PreferNative: v.PreferNative,
|
||||
Proxy: v.Proxy,
|
||||
FetchLimitBytes: v.FetchLimitBytes,
|
||||
Format: v.Format,
|
||||
PrivateHostWhitelist: v.PrivateHostWhitelist,
|
||||
}, WebToolsSecurity{
|
||||
Brave: &braveSecurity,
|
||||
Tavily: &tavilySecurity,
|
||||
Perplexity: &perplexitySecurity,
|
||||
GLMSearch: &glmSearchSecurity,
|
||||
Brave: braveSecurity,
|
||||
Tavily: tavilySecurity,
|
||||
Perplexity: perplexitySecurity,
|
||||
GLMSearch: glmSearchSecurity,
|
||||
BaiduSearch: baiduSearchSecurity,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -981,16 +997,20 @@ type clawHubRegistryConfigV0 struct {
|
|||
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
|
||||
}
|
||||
|
||||
func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, ClawHubSecurity) {
|
||||
return ClawHubRegistryConfig{
|
||||
Enabled: v.Enabled,
|
||||
BaseURL: v.BaseURL,
|
||||
authToken: v.AuthToken,
|
||||
SearchPath: v.SearchPath,
|
||||
SkillsPath: v.SkillsPath,
|
||||
}, ClawHubSecurity{
|
||||
func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, *ClawHubSecurity) {
|
||||
var sec *ClawHubSecurity
|
||||
if v.AuthToken != "" {
|
||||
sec = &ClawHubSecurity{
|
||||
AuthToken: v.AuthToken,
|
||||
}
|
||||
}
|
||||
return ClawHubRegistryConfig{
|
||||
Enabled: v.Enabled,
|
||||
BaseURL: v.BaseURL,
|
||||
authToken: v.AuthToken,
|
||||
SearchPath: v.SearchPath,
|
||||
SkillsPath: v.SkillsPath,
|
||||
}, sec
|
||||
}
|
||||
|
||||
type skillsGithubConfigV0 struct {
|
||||
|
|
@ -998,13 +1018,17 @@ type skillsGithubConfigV0 struct {
|
|||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
|
||||
}
|
||||
|
||||
func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, GithubSecurity) {
|
||||
return SkillsGithubConfig{
|
||||
token: v.Token,
|
||||
Proxy: v.Proxy,
|
||||
}, GithubSecurity{
|
||||
func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, *GithubSecurity) {
|
||||
var sec *GithubSecurity
|
||||
if v.Token != "" {
|
||||
sec = &GithubSecurity{
|
||||
Token: v.Token,
|
||||
}
|
||||
}
|
||||
return SkillsGithubConfig{
|
||||
token: v.Token,
|
||||
Proxy: v.Proxy,
|
||||
}, sec
|
||||
}
|
||||
|
||||
func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesConfig, *ClawHubSecurity) {
|
||||
|
|
@ -1012,21 +1036,25 @@ func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesC
|
|||
|
||||
return SkillsRegistriesConfig{
|
||||
ClawHub: clawHub,
|
||||
}, &clawHubSecurity
|
||||
}, clawHubSecurity
|
||||
}
|
||||
|
||||
func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, SkillsSecurity) {
|
||||
func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, *SkillsSecurity) {
|
||||
registries, registriesSecurity := v.Registries.ToSkillsRegistriesConfig()
|
||||
github, githubSecurity := v.Github.ToSkillsGithubConfig()
|
||||
|
||||
return SkillsToolsConfig{
|
||||
ToolConfig: v.ToolConfig,
|
||||
Registries: registries,
|
||||
Github: github,
|
||||
MaxConcurrentSearches: v.MaxConcurrentSearches,
|
||||
SearchCache: v.SearchCache,
|
||||
}, SkillsSecurity{
|
||||
Github: &githubSecurity,
|
||||
var sec *SkillsSecurity
|
||||
if githubSecurity != nil || registriesSecurity != nil {
|
||||
sec = &SkillsSecurity{
|
||||
Github: githubSecurity,
|
||||
ClawHub: registriesSecurity,
|
||||
}
|
||||
}
|
||||
return SkillsToolsConfig{
|
||||
ToolConfig: v.ToolConfig,
|
||||
Registries: registries,
|
||||
Github: github,
|
||||
MaxConcurrentSearches: v.MaxConcurrentSearches,
|
||||
SearchCache: v.SearchCache,
|
||||
}, sec
|
||||
}
|
||||
|
|
|
|||
|
|
@ -360,6 +360,96 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSaveConfig_PreservesDisabledTelegramPlaceholder(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Channels.Telegram.Placeholder.Enabled = false
|
||||
|
||||
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), `"placeholder": {`) {
|
||||
t.Fatalf("saved config should include telegram placeholder config, got: %s", string(data))
|
||||
}
|
||||
if !strings.Contains(string(data), `"enabled": false`) {
|
||||
t.Fatalf("saved config should persist placeholder.enabled=false, got: %s", string(data))
|
||||
}
|
||||
|
||||
loaded, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
if loaded.Channels.Telegram.Placeholder.Enabled {
|
||||
t.Fatal("telegram placeholder should remain disabled after SaveConfig/LoadConfig round-trip")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveConfig_FiltersVirtualModels verifies that SaveConfig does not write
|
||||
// virtual models (generated by expandMultiKeyModels) to the config file.
|
||||
func TestSaveConfig_FiltersVirtualModels(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Manually add a virtual model to ModelList (simulating what expandMultiKeyModels does)
|
||||
primaryModel := &ModelConfig{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4o",
|
||||
apiKeys: []string{"key1"},
|
||||
}
|
||||
virtualModel := &ModelConfig{
|
||||
ModelName: "gpt-4__key_1",
|
||||
Model: "openai/gpt-4o",
|
||||
apiKeys: []string{"key2"},
|
||||
isVirtual: true,
|
||||
}
|
||||
cfg.ModelList = []*ModelConfig{primaryModel, virtualModel}
|
||||
|
||||
// SaveConfig should filter out virtual models
|
||||
if err := SaveConfig(path, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Reload and verify
|
||||
reloaded, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Should only have the primary model, not the virtual one
|
||||
if len(reloaded.ModelList) != 1 {
|
||||
t.Fatalf("expected 1 model after reload, got %d", len(reloaded.ModelList))
|
||||
}
|
||||
|
||||
if reloaded.ModelList[0].ModelName != "gpt-4" {
|
||||
t.Errorf("expected model_name 'gpt-4', got %q", reloaded.ModelList[0].ModelName)
|
||||
}
|
||||
|
||||
// Verify virtual model was not persisted
|
||||
for _, m := range reloaded.ModelList {
|
||||
if m.ModelName == "gpt-4__key_1" {
|
||||
t.Errorf("virtual model gpt-4__key_1 should not have been saved")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the saved file does not contain the virtual model name
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "gpt-4__key_1") {
|
||||
t.Errorf("saved config should not contain virtual model name 'gpt-4__key_1'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfig_Complete verifies all config fields are set
|
||||
func TestConfig_Complete(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
|
@ -397,6 +487,33 @@ func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Agents.Defaults.ToolFeedback.Enabled {
|
||||
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(
|
||||
configPath,
|
||||
[]byte(`{"version":1,"agents":{"defaults":{"workspace":"./workspace"}}}`),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
if cfg.Agents.Defaults.ToolFeedback.Enabled {
|
||||
t.Fatal("agents.defaults.tool_feedback.enabled should remain false when unset in config file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
|
|
@ -436,6 +553,40 @@ func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_FilterSensitiveDataEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Tools.FilterSensitiveData {
|
||||
t.Fatal("DefaultConfig().Tools.FilterSensitiveData should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_FilterMinLength(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Tools.FilterMinLength != 8 {
|
||||
t.Fatalf("DefaultConfig().Tools.FilterMinLength = %d, want 8", cfg.Tools.FilterMinLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsConfig_GetFilterMinLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
minLen int
|
||||
expected int
|
||||
}{
|
||||
{"zero returns default", 0, 8},
|
||||
{"negative returns default", -1, 8},
|
||||
{"positive returns value", 16, 16},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &ToolsConfig{FilterMinLength: tt.minLen}
|
||||
if got := cfg.GetFilterMinLength(); got != tt.expected {
|
||||
t.Errorf("GetFilterMinLength() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Tools.Cron.AllowCommand {
|
||||
|
|
@ -1252,3 +1403,182 @@ func TestDefaultConfig_MinimaxExtraBody(t *testing.T) {
|
|||
t.Fatalf("Minimax ExtraBody[reasoning_split] = %v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSensitiveData(t *testing.T) {
|
||||
// Test with nil security config
|
||||
cfg := &Config{}
|
||||
if got := cfg.FilterSensitiveData("hello sk-key123 world"); got != "hello sk-key123 world" {
|
||||
t.Errorf("nil security: got %q, want original", got)
|
||||
}
|
||||
|
||||
// Test with empty content
|
||||
cfg.security = &SecurityConfig{}
|
||||
if got := cfg.FilterSensitiveData(""); got != "" {
|
||||
t.Errorf("empty content: got %q, want empty", got)
|
||||
}
|
||||
|
||||
// Test short content (less than FilterMinLength=8, should skip filtering)
|
||||
cfg.security.ModelList = map[string]ModelSecurityEntry{
|
||||
"test": {APIKeys: []string{"sk-long-key-12345"}},
|
||||
}
|
||||
cfg.Tools.FilterSensitiveData = true
|
||||
cfg.Tools.FilterMinLength = 8
|
||||
|
||||
// Debug: check if sensitive values are collected
|
||||
values := cfg.security.collectSensitiveValues()
|
||||
t.Logf("collected %d sensitive values: %v", len(values), values)
|
||||
|
||||
if got := cfg.FilterSensitiveData("sk-key"); got != "sk-key" {
|
||||
t.Errorf("short content should not be filtered: got %q", got)
|
||||
}
|
||||
|
||||
// Test filtering works
|
||||
content := "Your API key is sk-long-key-12345 and token abc123"
|
||||
// abc123 is not in sensitive values, only sk-long-key-12345 should be filtered
|
||||
expected := "Your API key is [FILTERED] and token abc123"
|
||||
if got := cfg.FilterSensitiveData(content); got != expected {
|
||||
t.Errorf("filtering failed: got %q, want %q", got, expected)
|
||||
}
|
||||
|
||||
// Test disabled filtering
|
||||
cfg.Tools.FilterSensitiveData = false
|
||||
if got := cfg.FilterSensitiveData(content); got != content {
|
||||
t.Errorf("disabled filtering: got %q, want original %q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSensitiveData_MultipleKeys(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Tools: ToolsConfig{
|
||||
FilterSensitiveData: true,
|
||||
FilterMinLength: 8,
|
||||
},
|
||||
}
|
||||
cfg.security = &SecurityConfig{
|
||||
ModelList: map[string]ModelSecurityEntry{
|
||||
"model1": {APIKeys: []string{"key-one", "key-two"}},
|
||||
"model2": {APIKeys: []string{"key-three"}},
|
||||
},
|
||||
}
|
||||
|
||||
content := "key-one and key-two and key-three should be filtered"
|
||||
expected := "[FILTERED] and [FILTERED] and [FILTERED] should be filtered"
|
||||
if got := cfg.FilterSensitiveData(content); got != expected {
|
||||
t.Errorf("multiple keys: got %q, want %q", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Tools: ToolsConfig{
|
||||
FilterSensitiveData: true,
|
||||
FilterMinLength: 8,
|
||||
},
|
||||
}
|
||||
cfg.security = &SecurityConfig{
|
||||
// Model API keys
|
||||
ModelList: map[string]ModelSecurityEntry{
|
||||
"test-model": {APIKeys: []string{"sk-model-key-12345"}},
|
||||
},
|
||||
// Channel tokens
|
||||
Channels: &ChannelsSecurity{
|
||||
Telegram: &TelegramSecurity{Token: "telegram-bot-token-abcdef"},
|
||||
Discord: &DiscordSecurity{Token: "discord-bot-token-xyz789"},
|
||||
Slack: &SlackSecurity{BotToken: "xoxb-slack-bot-token", AppToken: "xapp-slack-app-token"},
|
||||
Matrix: &MatrixSecurity{AccessToken: "matrix-access-token-abc"},
|
||||
Feishu: &FeishuSecurity{AppSecret: "feishu-app-secret-123", EncryptKey: "feishu-encrypt-key"},
|
||||
DingTalk: &DingTalkSecurity{ClientSecret: "dingtalk-client-secret"},
|
||||
OneBot: &OneBotSecurity{AccessToken: "onebot-access-token"},
|
||||
WeCom: &WeComSecurity{Secret: "wecom-secret"},
|
||||
Pico: &PicoSecurity{Token: "pico-token-abc123"},
|
||||
IRC: &IRCSecurity{
|
||||
Password: "irc-password",
|
||||
NickServPassword: "nickserv-pass",
|
||||
SASLPassword: "sasl-pass",
|
||||
},
|
||||
},
|
||||
// Web tool API keys
|
||||
Web: &WebToolsSecurity{
|
||||
Brave: &BraveSecurity{APIKeys: []string{"brave-api-key"}},
|
||||
Tavily: &TavilySecurity{APIKeys: []string{"tavily-api-key"}},
|
||||
Perplexity: &PerplexitySecurity{APIKeys: []string{"perplexity-api-key"}},
|
||||
GLMSearch: &GLMSearchSecurity{APIKey: "glm-search-key"},
|
||||
BaiduSearch: &BaiduSearchSecurity{APIKey: "baidu-search-key"},
|
||||
},
|
||||
// Skills tokens
|
||||
Skills: &SkillsSecurity{
|
||||
Github: &GithubSecurity{Token: "github-token-xyz"},
|
||||
ClawHub: &ClawHubSecurity{AuthToken: "clawhub-auth-token"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "model_api_key",
|
||||
content: "Using model with key sk-model-key-12345",
|
||||
want: "Using model with key [FILTERED]",
|
||||
},
|
||||
{
|
||||
name: "telegram_token",
|
||||
content: "Telegram token: telegram-bot-token-abcdef",
|
||||
want: "Telegram token: [FILTERED]",
|
||||
},
|
||||
{
|
||||
name: "discord_token",
|
||||
content: "Discord token: discord-bot-token-xyz789",
|
||||
want: "Discord token: [FILTERED]",
|
||||
},
|
||||
{
|
||||
name: "slack_tokens",
|
||||
content: "Slack bot: xoxb-slack-bot-token, app: xapp-slack-app-token",
|
||||
want: "Slack bot: [FILTERED], app: [FILTERED]",
|
||||
},
|
||||
{
|
||||
name: "matrix_token",
|
||||
content: "Matrix access token: matrix-access-token-abc",
|
||||
want: "Matrix access token: [FILTERED]",
|
||||
},
|
||||
{
|
||||
name: "brave_api_key",
|
||||
content: "Brave key: brave-api-key",
|
||||
want: "Brave key: [FILTERED]",
|
||||
},
|
||||
{
|
||||
name: "tavily_api_key",
|
||||
content: "Tavily key: tavily-api-key",
|
||||
want: "Tavily key: [FILTERED]",
|
||||
},
|
||||
{
|
||||
name: "github_token",
|
||||
content: "GitHub token: github-token-xyz",
|
||||
want: "GitHub token: [FILTERED]",
|
||||
},
|
||||
{
|
||||
name: "irc_passwords",
|
||||
content: "IRC password: irc-password, nickserv: nickserv-pass",
|
||||
want: "IRC password: [FILTERED], nickserv: [FILTERED]",
|
||||
},
|
||||
{
|
||||
name: "mixed_content",
|
||||
content: "Model key sk-model-key-12345 and Telegram token telegram-bot-token-abcdef",
|
||||
want: "Model key [FILTERED] and Telegram token [FILTERED]",
|
||||
},
|
||||
{
|
||||
name: "short_key_not_filtered",
|
||||
content: "Key abc not filtered because length < 8",
|
||||
want: "Key abc not filtered because length < 8",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := cfg.FilterSensitiveData(tt.content); got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@ func DefaultConfig() *Config {
|
|||
SummarizeTokenPercent: 75,
|
||||
SteeringMode: "one-at-a-time",
|
||||
ToolFeedback: ToolFeedbackConfig{
|
||||
Enabled: true,
|
||||
Enabled: false,
|
||||
MaxArgsLength: 300,
|
||||
},
|
||||
SplitOnMarker: false,
|
||||
},
|
||||
},
|
||||
Bindings: []AgentBinding{},
|
||||
|
|
@ -62,7 +63,7 @@ func DefaultConfig() *Config {
|
|||
Typing: TypingConfig{Enabled: true},
|
||||
Placeholder: PlaceholderConfig{
|
||||
Enabled: true,
|
||||
Text: "Thinking... 💭",
|
||||
Text: FlexibleStringSlice{"Thinking... 💭"},
|
||||
},
|
||||
Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200},
|
||||
UseMarkdownV2: false,
|
||||
|
|
@ -111,8 +112,10 @@ func DefaultConfig() *Config {
|
|||
},
|
||||
Placeholder: PlaceholderConfig{
|
||||
Enabled: true,
|
||||
Text: "Thinking... 💭",
|
||||
Text: FlexibleStringSlice{"Thinking... 💭"},
|
||||
},
|
||||
CryptoDatabasePath: "",
|
||||
CryptoPassphrase: "",
|
||||
},
|
||||
LINE: LINEConfig{
|
||||
Enabled: false,
|
||||
|
|
@ -129,32 +132,11 @@ func DefaultConfig() *Config {
|
|||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
WeCom: WeComConfig{
|
||||
Enabled: false,
|
||||
WebhookURL: "",
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18793,
|
||||
WebhookPath: "/webhook/wecom",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
ReplyTimeout: 5,
|
||||
},
|
||||
WeComApp: WeComAppConfig{
|
||||
Enabled: false,
|
||||
CorpID: "",
|
||||
AgentID: 0,
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18792,
|
||||
WebhookPath: "/webhook/wecom-app",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
ReplyTimeout: 5,
|
||||
},
|
||||
WeComAIBot: WeComAIBotConfig{
|
||||
Enabled: false,
|
||||
WebhookPath: "/webhook/wecom-aibot",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
ReplyTimeout: 5,
|
||||
MaxSteps: 10,
|
||||
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
|
||||
ProcessingMessage: DefaultWeComAIBotProcessingMessage,
|
||||
Enabled: false,
|
||||
BotID: "",
|
||||
WebSocketURL: "wss://openws.work.weixin.qq.com",
|
||||
SendThinkingMessage: true,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Weixin: WeixinConfig{
|
||||
Enabled: false,
|
||||
|
|
@ -378,6 +360,8 @@ func DefaultConfig() *Config {
|
|||
LogLevel: "fatal",
|
||||
},
|
||||
Tools: ToolsConfig{
|
||||
FilterSensitiveData: true,
|
||||
FilterMinLength: 8,
|
||||
MediaCleanup: MediaCleanupConfig{
|
||||
ToolConfig: ToolConfig{
|
||||
Enabled: true,
|
||||
|
|
@ -537,8 +521,9 @@ func DefaultConfig() *Config {
|
|||
},
|
||||
security: &SecurityConfig{
|
||||
ModelList: map[string]ModelSecurityEntry{},
|
||||
Channels: ChannelsSecurity{},
|
||||
Web: WebToolsSecurity{},
|
||||
Channels: &ChannelsSecurity{},
|
||||
Web: &WebToolsSecurity{},
|
||||
Skills: &SkillsSecurity{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,20 +11,33 @@ Package config
|
|||
|
||||
# Example: Using Security Configuration
|
||||
|
||||
## 1. Create security.yml
|
||||
## Overview
|
||||
|
||||
File: ~/.picoclaw/security.yml
|
||||
The security configuration feature allows you to separate sensitive data (API keys,
|
||||
tokens, secrets, passwords) from your main configuration. The system automatically
|
||||
loads values from `.security.yml` and applies them to the corresponding fields in
|
||||
your config.
|
||||
|
||||
**Key Points:**
|
||||
- Values from `.security.yml` are automatically mapped to config fields
|
||||
- No `ref:` syntax is needed - just omit sensitive fields from config.json
|
||||
- If a field exists in both files, `.security.yml` value takes precedence
|
||||
- You can mix direct values in config.json with security values
|
||||
|
||||
## 1. Create .security.yml
|
||||
|
||||
File: ~/.picoclaw/.security.yml
|
||||
|
||||
```yaml
|
||||
# Model API Keys
|
||||
# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
|
||||
# Single key should be provided as an array with one element
|
||||
# All models MUST use 'api_keys' (plural) array format
|
||||
# Even a single key must be provided as an array with one element
|
||||
model_list:
|
||||
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-proj-your-actual-openai-key-1"
|
||||
- "sk-proj-your-actual-openai-key-2" # Failover key
|
||||
- "sk-proj-your-actual-openai-key-2" # Optional: Multiple keys for failover
|
||||
claude-sonnet-4.6:
|
||||
api_keys:
|
||||
- "sk-ant-your-actual-anthropic-key" # Single key in array format
|
||||
|
|
@ -38,80 +51,95 @@ channels:
|
|||
token: "your-discord-bot-token"
|
||||
|
||||
# Web Tool Keys
|
||||
# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
|
||||
# For GLMSearch, use 'api_key' (single string)
|
||||
# Brave, Tavily, Perplexity: Use 'api_keys' array
|
||||
# GLMSearch, BaiduSearch: Use 'api_key' single string
|
||||
web:
|
||||
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSAyour-brave-api-key-1"
|
||||
- "BSAyour-brave-api-key-2" # Failover key
|
||||
- "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover
|
||||
tavily:
|
||||
api_keys:
|
||||
- "tvly-your-tavily-api-key" # Single key in array format
|
||||
perplexity:
|
||||
api_keys:
|
||||
- "pplx-your-perplexity-api-key" # Single key in array format
|
||||
glm_search:
|
||||
api_key: "your-glm-search-api-key" # Single key (not array)
|
||||
baidu_search:
|
||||
api_key: "your-baidu-search-api-key" # Single key (not array)
|
||||
|
||||
```
|
||||
|
||||
## 2. Update config.json to use references
|
||||
## 2. Simplify config.json
|
||||
|
||||
File: ~/.picoclaw/config.json
|
||||
|
||||
Note: Sensitive fields are omitted because they're loaded from .security.yml
|
||||
|
||||
```json
|
||||
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/picoclaw-workspace",
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"api_key": "ref:model_list.claude-sonnet-4.6.api_key"
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "ref:channels.telegram.token"
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "ref:channels.discord.token"
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/picoclaw-workspace",
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
// api_key is automatically loaded from .security.yml
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_base": "https://api.anthropic.com/v1"
|
||||
// api_key is automatically loaded from .security.yml
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true
|
||||
// token is automatically loaded from .security.yml
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true
|
||||
// token is automatically loaded from .security.yml
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"api_key": "ref:web.brave.api_key"
|
||||
"enabled": true
|
||||
// api_key is automatically loaded from .security.yml
|
||||
},
|
||||
"tavily": {
|
||||
"enabled": true,
|
||||
"api_key": "ref:web.tavily.api_key"
|
||||
"enabled": true
|
||||
// api_key is automatically loaded from .security.yml
|
||||
},
|
||||
"glm_search": {
|
||||
"enabled": true
|
||||
// api_key is automatically loaded from .security.yml
|
||||
},
|
||||
"baidu_search": {
|
||||
"enabled": true
|
||||
// api_key is automatically loaded from .security.yml
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 3. Set proper permissions
|
||||
|
||||
```bash
|
||||
chmod 600 ~/.picoclaw/security.yml
|
||||
chmod 600 ~/.picoclaw/.security.yml
|
||||
```
|
||||
|
||||
## 4. Add to .gitignore
|
||||
|
|
@ -127,57 +155,131 @@ chmod 600 ~/.picoclaw/security.yml
|
|||
picoclaw --version
|
||||
```
|
||||
|
||||
# Available Reference Paths
|
||||
# Supported Fields in .security.yml
|
||||
|
||||
## Model API Keys
|
||||
- ref:model_list.<model_name>.api_key
|
||||
|
||||
All models MUST use the `api_keys` (plural) array format in .security.yml.
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
|
||||
<model_name>:
|
||||
api_keys:
|
||||
- "key-1"
|
||||
- "key-2" # Optional: Multiple keys for failover
|
||||
|
||||
```
|
||||
|
||||
Examples:
|
||||
- ref:model_list.gpt-5.4.api_key
|
||||
- ref:model_list.claude-sonnet-4.6.api_key
|
||||
```yaml
|
||||
model_list:
|
||||
|
||||
**Note:** In .security.yml, use `api_keys` (array) format for models.
|
||||
Both single and multiple keys should use the array format.
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-proj-key-1"
|
||||
- "sk-proj-key-2"
|
||||
claude-sonnet-4.6:
|
||||
api_keys:
|
||||
- "sk-ant-key"
|
||||
|
||||
```
|
||||
|
||||
**Important:**
|
||||
- Always use `api_keys` (plural) for models
|
||||
- Even a single key must be in an array format
|
||||
- The model_name in .security.yml must match the model_name in config.json
|
||||
|
||||
## Channel Tokens/Secrets
|
||||
- ref:channels.telegram.token
|
||||
- ref:channels.feishu.app_secret
|
||||
- ref:channels.feishu.encrypt_key
|
||||
- ref:channels.feishu.verification_token
|
||||
- ref:channels.discord.token
|
||||
- ref:channels.qq.app_secret
|
||||
- ref:channels.dingtalk.client_secret
|
||||
- ref:channels.slack.bot_token
|
||||
- ref:channels.slack.app_token
|
||||
- ref:channels.matrix.access_token
|
||||
- ref:channels.line.channel_secret
|
||||
- ref:channels.line.channel_access_token
|
||||
- ref:channels.onebot.access_token
|
||||
- ref:channels.wecom.token
|
||||
- ref:channels.wecom.encoding_aes_key
|
||||
- ref:channels.wecom_app.corp_secret
|
||||
- ref:channels.wecom_app.token
|
||||
- ref:channels.wecom_app.encoding_aes_key
|
||||
- ref:channels.wecom_aibot.token
|
||||
- ref:channels.wecom_aibot.encoding_aes_key
|
||||
- ref:channels.pico.token
|
||||
- ref:channels.irc.password
|
||||
- ref:channels.irc.nickserv_password
|
||||
- ref:channels.irc.sasl_password
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
|
||||
telegram:
|
||||
token: "value"
|
||||
feishu:
|
||||
app_secret: "value"
|
||||
encrypt_key: "value"
|
||||
verification_token: "value"
|
||||
discord:
|
||||
token: "value"
|
||||
weixin:
|
||||
token: "value"
|
||||
qq:
|
||||
app_secret: "value"
|
||||
dingtalk:
|
||||
client_secret: "value"
|
||||
slack:
|
||||
bot_token: "value"
|
||||
app_token: "value"
|
||||
matrix:
|
||||
access_token: "value"
|
||||
line:
|
||||
channel_secret: "value"
|
||||
channel_access_token: "value"
|
||||
onebot:
|
||||
access_token: "value"
|
||||
wecom:
|
||||
token: "value"
|
||||
encoding_aes_key: "value"
|
||||
wecom_app:
|
||||
corp_secret: "value"
|
||||
token: "value"
|
||||
encoding_aes_key: "value"
|
||||
wecom_aibot:
|
||||
secret: "value"
|
||||
token: "value"
|
||||
encoding_aes_key: "value"
|
||||
pico:
|
||||
token: "value"
|
||||
irc:
|
||||
password: "value"
|
||||
nickserv_password: "value"
|
||||
sasl_password: "value"
|
||||
|
||||
## Web Tool API Keys
|
||||
- ref:web.brave.api_key
|
||||
- ref:web.tavily.api_key
|
||||
- ref:web.perplexity.api_key
|
||||
- ref:web.glm_search.api_key
|
||||
|
||||
**Note:**
|
||||
- Brave, Tavily, Perplexity: Use `api_keys` (array) format in .security.yml
|
||||
- GLMSearch: Use `api_key` (single string) format in .security.yml
|
||||
**Brave, Tavily, Perplexity:**
|
||||
```yaml
|
||||
web:
|
||||
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSA-key-1"
|
||||
- "BSA-key-2"
|
||||
tavily:
|
||||
api_keys:
|
||||
- "tvly-key"
|
||||
perplexity:
|
||||
api_keys:
|
||||
- "pplx-key"
|
||||
|
||||
```
|
||||
Use `api_keys` (plural) array format.
|
||||
|
||||
**GLMSearch, BaiduSearch:**
|
||||
```yaml
|
||||
web:
|
||||
|
||||
glm_search:
|
||||
api_key: "your-glm-key"
|
||||
baidu_search:
|
||||
api_key: "your-baidu-key"
|
||||
|
||||
```
|
||||
Use `api_key` (singular) single string format.
|
||||
|
||||
## Skills Registry Tokens
|
||||
- ref:skills.github.token
|
||||
- ref:skills.clawhub.auth_token
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
|
||||
github:
|
||||
token: "value"
|
||||
clawhub:
|
||||
auth_token: "value"
|
||||
|
||||
```
|
||||
|
||||
# Backward Compatibility
|
||||
|
||||
|
|
@ -191,14 +293,14 @@ You can still use direct values in config.json if needed:
|
|||
"model_name": "local-model",
|
||||
"model": "ollama/llama3",
|
||||
"api_base": "http://localhost:11434/v1",
|
||||
"api_key": "ollama" // Direct value (no reference)
|
||||
"api_key": "ollama" // Direct value (works fine)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
You can also mix references and direct values:
|
||||
You can also mix security values and direct values:
|
||||
|
||||
```json
|
||||
|
||||
|
|
@ -206,10 +308,12 @@ You can also mix references and direct values:
|
|||
"model_list": [
|
||||
{
|
||||
"model_name": "cloud-model",
|
||||
"api_key": "ref:model_list.cloud-model.api_key" // From .security.yml
|
||||
// api_key loaded from .security.yml
|
||||
},
|
||||
{
|
||||
"model_name": "local-model",
|
||||
"model": "ollama/llama3",
|
||||
"api_base": "http://localhost:11434/v1",
|
||||
"api_key": "ollama" // Direct value
|
||||
}
|
||||
]
|
||||
|
|
@ -217,6 +321,11 @@ You can also mix references and direct values:
|
|||
|
||||
```
|
||||
|
||||
**Priority Order:**
|
||||
1. Environment variables (highest priority)
|
||||
2. .security.yml values
|
||||
3. config.json direct values (lowest priority)
|
||||
|
||||
# Migration from Old Config
|
||||
|
||||
## Step 1: Backup your config
|
||||
|
|
@ -224,7 +333,7 @@ You can also mix references and direct values:
|
|||
cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup
|
||||
```
|
||||
|
||||
## Step 2: Copy the example security file
|
||||
## Step 2: Create .security.yml
|
||||
```bash
|
||||
cp security.example.yml ~/.picoclaw/.security.yml
|
||||
```
|
||||
|
|
@ -232,10 +341,19 @@ cp security.example.yml ~/.picoclaw/.security.yml
|
|||
## Step 3: Fill in your API keys
|
||||
Edit ~/.picoclaw/.security.yml and replace placeholders with your actual keys.
|
||||
|
||||
## Step 4: Update config.json references
|
||||
Replace sensitive values in ~/.picoclaw/config.json with ref: references.
|
||||
## Step 4: Simplify config.json (Recommended)
|
||||
Remove sensitive fields from ~/.picoclaw/config.json:
|
||||
- `api_key` fields from model_list entries
|
||||
- `token` fields from channels
|
||||
- `api_key` fields from tools.web
|
||||
- `token`/`auth_token` fields from tools.skills
|
||||
|
||||
## Step 5: Test
|
||||
## Step 5: Set permissions
|
||||
```bash
|
||||
chmod 600 ~/.picoclaw/.security.yml
|
||||
```
|
||||
|
||||
## Step 6: Test
|
||||
```bash
|
||||
picoclaw --version
|
||||
```
|
||||
|
|
@ -249,9 +367,11 @@ rm ~/.picoclaw/config.json.backup
|
|||
|
||||
## Multiple API Keys (Load Balancing & Failover)
|
||||
|
||||
You can configure multiple API keys for both models and web tools to enable:
|
||||
You can configure multiple API keys for models and web tools to enable:
|
||||
- **Load balancing**: Requests are distributed across multiple keys
|
||||
- **Failover**: If a key fails, the system automatically switches to another key
|
||||
- **Rate limit management**: Distribute usage across multiple keys
|
||||
- **High availability**: Reduce downtime during API provider issues
|
||||
|
||||
### Example: Model with Multiple Keys
|
||||
|
||||
|
|
@ -275,7 +395,7 @@ model_list:
|
|||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "ref:model_list.gpt-5.4.api_key"
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -307,8 +427,13 @@ web:
|
|||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"api_key": "ref:web.brave.api_key"
|
||||
"enabled": true
|
||||
},
|
||||
"tavily": {
|
||||
"enabled": true
|
||||
},
|
||||
"glm_search": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -316,9 +441,9 @@ web:
|
|||
|
||||
```
|
||||
|
||||
### Single Key
|
||||
## Single Key Format
|
||||
|
||||
Use array format with one element:
|
||||
**Models, Brave, Tavily, Perplexity:**
|
||||
```yaml
|
||||
model_list:
|
||||
|
||||
|
|
@ -328,36 +453,32 @@ model_list:
|
|||
|
||||
```
|
||||
|
||||
### Multiple Keys (Load Balancing & Failover)
|
||||
|
||||
Use array format with multiple elements:
|
||||
**GLMSearch, BaiduSearch:**
|
||||
```yaml
|
||||
model_list:
|
||||
web:
|
||||
|
||||
gpt-5.4:
|
||||
api_keys:
|
||||
- "sk-proj-key-1"
|
||||
- "sk-proj-key-2"
|
||||
- "sk-proj-key-3"
|
||||
glm_search:
|
||||
api_key: "your-glm-key" # Single key (not array)
|
||||
|
||||
```
|
||||
|
||||
**Important:** All model keys in .security.yml must use the `api_keys` (plural) array format.
|
||||
The single `api_key` (singular) format is NOT supported for models.
|
||||
|
||||
### Model Index Matching
|
||||
## Model Name Matching
|
||||
|
||||
The system supports intelligent model name matching in .security.yml:
|
||||
|
||||
**Example 1: Exact Match**
|
||||
```yaml
|
||||
# config.json
|
||||
### Example 1: Exact Match
|
||||
|
||||
**config.json:**
|
||||
```json
|
||||
|
||||
{
|
||||
"model_name": "gpt-5.4:0"
|
||||
}
|
||||
|
||||
# .security.yml (exact match with index)
|
||||
```
|
||||
|
||||
**.security.yml (exact match with index):**
|
||||
```yaml
|
||||
model_list:
|
||||
|
||||
gpt-5.4:0:
|
||||
|
|
@ -365,26 +486,30 @@ model_list:
|
|||
|
||||
```
|
||||
|
||||
**Example 2: Base Name Match**
|
||||
```yaml
|
||||
# config.json
|
||||
### Example 2: Base Name Match
|
||||
|
||||
**config.json:**
|
||||
```json
|
||||
|
||||
{
|
||||
"model_name": "gpt-5.4:0"
|
||||
}
|
||||
|
||||
# .security.yml (base name without index)
|
||||
```
|
||||
|
||||
**.security.yml (base name without index):**
|
||||
```yaml
|
||||
model_list:
|
||||
|
||||
gpt-5.4:
|
||||
api_keys: ["key-1"]
|
||||
api_keys: ["key-1", "key-2"]
|
||||
|
||||
```
|
||||
|
||||
Both methods work. The base name match allows you to use simpler keys in .security.yml
|
||||
even when your config uses indexed model names for load balancing.
|
||||
|
||||
### Security File Permissions
|
||||
## Security File Permissions
|
||||
|
||||
The security file should have restricted permissions:
|
||||
|
||||
|
|
@ -397,26 +522,64 @@ This ensures only the owner can read and write the file.
|
|||
# Security Best Practices
|
||||
|
||||
1. Never commit .security.yml to version control
|
||||
2. Set file permissions: chmod 600 ~/.picoclaw/.security.yml
|
||||
3. Use different keys for different environments
|
||||
4. Rotate keys regularly and update .security.yml
|
||||
5. Encrypt backups containing .security.yml
|
||||
2. Add .security.yml to your .gitignore file
|
||||
3. Set file permissions: chmod 600 ~/.picoclaw/.security.yml
|
||||
4. Use different keys for different environments (dev, staging, production)
|
||||
5. Rotate keys regularly and update .security.yml
|
||||
6. Encrypt backups containing .security.yml
|
||||
7. Review access regularly
|
||||
|
||||
# Environment Variables
|
||||
|
||||
You can override any security value using environment variables:
|
||||
|
||||
```bash
|
||||
# Channels
|
||||
export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env"
|
||||
export PICOCLAW_CHANNELS_DISCORD_TOKEN="discord-token-from-env"
|
||||
|
||||
# Web Tools
|
||||
export PICOCLAW_TOOLS_WEB_BRAVE_API_KEY="brave-key-from-env"
|
||||
export PICOCLAW_TOOLS_WEB_BAIDU_API_KEY="baidu-key-from-env"
|
||||
|
||||
# Skills
|
||||
export PICOCLAW_TOOLS_SKILLS_GITHUB_TOKEN="github-token-from-env"
|
||||
```
|
||||
|
||||
Environment variables have the highest priority and will override both config.json
|
||||
and .security.yml values.
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## Error: "failed to load security config"
|
||||
- Ensure .security.yml exists in the same directory as config.json
|
||||
- Check YAML syntax is valid (use a YAML validator)
|
||||
- Verify file permissions allow reading
|
||||
|
||||
## Error: "model security entry not found"
|
||||
- Check that the model name in config.json matches exactly in .security.yml
|
||||
- Verify the model_list section exists in .security.yml
|
||||
- For indexed names (e.g., "gpt-5.4:0"), check both exact match and base name match
|
||||
- Ensure the YAML structure is correct (proper indentation)
|
||||
|
||||
## Error: "failed to load security config"
|
||||
- Ensure .security.yml exists in the same directory as config.json
|
||||
- Check YAML syntax is valid
|
||||
- Verify file permissions allow reading
|
||||
## Multiple API Keys Not Working
|
||||
- Ensure you're using `api_keys` (plural) in .security.yml for models and web tools (except GLMSearch/BaiduSearch)
|
||||
- Check that the array format is correct in YAML (proper indentation with dashes)
|
||||
- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
|
||||
- GLMSearch and BaiduSearch MUST use `api_key` (single string format)
|
||||
|
||||
## Error: "unknown reference path"
|
||||
- Verify the reference format is correct
|
||||
- Check the path structure matches the examples above
|
||||
- Ensure all required sections exist in .security.yml
|
||||
## Keys Not Being Applied
|
||||
- Check that .security.yml is in the same directory as config.json
|
||||
- Verify the file permissions allow reading (chmod 600 ~/.picoclaw/.security.yml)
|
||||
- Ensure the YAML structure matches the expected format
|
||||
- Check for typos in field names (case-sensitive)
|
||||
- Verify the model/channel names match exactly (case-sensitive)
|
||||
|
||||
## Load Balancing/Failover Issues
|
||||
- Verify all API keys in the api_keys array are valid
|
||||
- Check that all keys have the same rate limits and permissions
|
||||
- Monitor logs to see which keys are being used and failing
|
||||
- Ensure the api_keys array is properly formatted in YAML
|
||||
*/
|
||||
package config
|
||||
|
||||
|
|
|
|||
|
|
@ -566,3 +566,118 @@ func TestMigration_Integration_ModelNameField(t *testing.T) {
|
|||
t.Errorf("ModelFallbacks[0] = %q, want %q", cfg.Agents.Defaults.ModelFallbacks[0], "deepseek-chat")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigration_PreservesExistingSecurityConfig tests that when migrating from v0 to v1,
|
||||
// existing .security.yml values (e.g., loaded from environment variables) are preserved
|
||||
// and not overwritten by empty values from the legacy config.
|
||||
func TestMigration_PreservesExistingSecurityConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
securityPath := filepath.Join(tmpDir, ".security.yml")
|
||||
|
||||
// Create a legacy config (version 0) with model_list and channel config
|
||||
// The model_list doesn't have api_keys, they should come from existing .security.yml
|
||||
legacyConfig := `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "openai",
|
||||
"model": "openai/gpt-4"
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 18790
|
||||
},
|
||||
"tools": {
|
||||
"web": {"enabled": true}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
},
|
||||
"devices": {
|
||||
"enabled": false
|
||||
}
|
||||
}`
|
||||
|
||||
// Create an existing .security.yml with values that might come from env vars
|
||||
existingSecurity := `model_list:
|
||||
openai:0:
|
||||
api_keys:
|
||||
- sk-existing-key-from-env
|
||||
channels:
|
||||
telegram:
|
||||
token: existing-telegram-token-from-env
|
||||
discord:
|
||||
token: existing-discord-token-from-env
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- existing-brave-key
|
||||
`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
|
||||
t.Fatalf("Failed to write legacy config: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(securityPath, []byte(existingSecurity), 0o600); err != nil {
|
||||
t.Fatalf("Failed to write existing security config: %v", err)
|
||||
}
|
||||
|
||||
// Load the config - this should trigger migration
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify that the migrated config has the existing security values
|
||||
// Telegram token should be preserved
|
||||
if cfg.Channels.Telegram.Token() != "existing-telegram-token-from-env" {
|
||||
t.Errorf("Telegram token was overwritten: got %q, want %q",
|
||||
cfg.Channels.Telegram.Token(), "existing-telegram-token-from-env")
|
||||
}
|
||||
|
||||
// Discord token should be preserved (even though legacy config didn't have it)
|
||||
if cfg.Channels.Discord.Token() != "existing-discord-token-from-env" {
|
||||
t.Errorf("Discord token was overwritten: got %q, want %q",
|
||||
cfg.Channels.Discord.Token(), "existing-discord-token-from-env")
|
||||
}
|
||||
|
||||
// Model API key should be preserved
|
||||
if cfg.ModelList[0].APIKey() != "sk-existing-key-from-env" {
|
||||
t.Errorf("Model API key was overwritten: got %q, want %q",
|
||||
cfg.ModelList[0].APIKey(), "sk-existing-key-from-env")
|
||||
}
|
||||
|
||||
// Brave API key should be preserved
|
||||
if cfg.Tools.Web.Brave.APIKey() != "existing-brave-key" {
|
||||
t.Errorf("Brave API key was overwritten: got %q, want %q",
|
||||
cfg.Tools.Web.Brave.APIKey(), "existing-brave-key")
|
||||
}
|
||||
|
||||
// Reload the security config from disk to verify it wasn't corrupted
|
||||
reloadedSec, err := loadSecurityConfig(securityPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reload security config: %v", err)
|
||||
}
|
||||
|
||||
if reloadedSec.Channels.Telegram == nil ||
|
||||
reloadedSec.Channels.Telegram.Token != "existing-telegram-token-from-env" {
|
||||
t.Error("Telegram token not preserved in .security.yml file")
|
||||
}
|
||||
|
||||
if reloadedSec.Channels.Discord == nil || reloadedSec.Channels.Discord.Token != "existing-discord-token-from-env" {
|
||||
t.Error("Discord token not preserved in .security.yml file")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -232,6 +232,78 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestExpandMultiKeyModels_IsVirtualFlag(t *testing.T) {
|
||||
models := []*ModelConfig{
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4o",
|
||||
apiKeys: []string{"key1", "key2", "key3"},
|
||||
},
|
||||
}
|
||||
|
||||
result := expandMultiKeyModels(models)
|
||||
|
||||
// Should expand to 3 models
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 models, got %d", len(result))
|
||||
}
|
||||
|
||||
// Primary model should NOT be virtual
|
||||
primary := result[2]
|
||||
if primary.isVirtual {
|
||||
t.Errorf("primary model should not be virtual")
|
||||
}
|
||||
if primary.ModelName != "gpt-4" {
|
||||
t.Errorf("expected primary model_name 'gpt-4', got %q", primary.ModelName)
|
||||
}
|
||||
|
||||
// Virtual models should have isVirtual = true
|
||||
virtual1 := result[0]
|
||||
if !virtual1.isVirtual {
|
||||
t.Errorf("gpt-4__key_1 should be virtual")
|
||||
}
|
||||
if virtual1.ModelName != "gpt-4__key_1" {
|
||||
t.Errorf("expected virtual model_name 'gpt-4__key_1', got %q", virtual1.ModelName)
|
||||
}
|
||||
|
||||
virtual2 := result[1]
|
||||
if !virtual2.isVirtual {
|
||||
t.Errorf("gpt-4__key_2 should be virtual")
|
||||
}
|
||||
if virtual2.ModelName != "gpt-4__key_2" {
|
||||
t.Errorf("expected virtual model_name 'gpt-4__key_2', got %q", virtual2.ModelName)
|
||||
}
|
||||
|
||||
// IsVirtual() method should work
|
||||
if !virtual1.IsVirtual() {
|
||||
t.Errorf("IsVirtual() should return true for virtual model")
|
||||
}
|
||||
if primary.IsVirtual() {
|
||||
t.Errorf("IsVirtual() should return false for primary model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandMultiKeyModels_SingleKey_NotVirtual(t *testing.T) {
|
||||
models := []*ModelConfig{
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4o",
|
||||
apiKeys: []string{"single-key"},
|
||||
},
|
||||
}
|
||||
|
||||
result := expandMultiKeyModels(models)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 model, got %d", len(result))
|
||||
}
|
||||
|
||||
// Single key model should NOT be virtual
|
||||
if result[0].isVirtual {
|
||||
t.Errorf("single key model should not be virtual")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAPIKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
"github.com/tencent-connect/botgo/log"
|
||||
|
|
@ -22,19 +25,41 @@ const (
|
|||
SecurityConfigFile = ".security.yml"
|
||||
)
|
||||
|
||||
func normalizeSecurityConfig(sec *SecurityConfig) *SecurityConfig {
|
||||
if sec == nil {
|
||||
sec = &SecurityConfig{}
|
||||
}
|
||||
if sec.ModelList == nil {
|
||||
sec.ModelList = map[string]ModelSecurityEntry{}
|
||||
}
|
||||
if sec.Channels == nil {
|
||||
sec.Channels = &ChannelsSecurity{}
|
||||
}
|
||||
if sec.Web == nil {
|
||||
sec.Web = &WebToolsSecurity{}
|
||||
}
|
||||
if sec.Skills == nil {
|
||||
sec.Skills = &SkillsSecurity{}
|
||||
}
|
||||
return sec
|
||||
}
|
||||
|
||||
// SecurityConfig stores all sensitive data (API keys, tokens, secrets, passwords)
|
||||
// This data is loaded from security.yml and kept separate from the main config
|
||||
type SecurityConfig struct {
|
||||
// Model API keys. Map key is model_name, can include suffix like "abc:0", "abc:1"
|
||||
// for load balancing with same model_name. The suffix ":N" is used to distinguish
|
||||
// multiple configs that share the same base model_name.
|
||||
ModelList map[string]ModelSecurityEntry `yaml:"model_list,omitempty"`
|
||||
ModelList map[string]ModelSecurityEntry `yaml:"model_list"`
|
||||
|
||||
// Channel tokens/secrets
|
||||
Channels ChannelsSecurity `yaml:"channels,omitempty"`
|
||||
Channels *ChannelsSecurity `yaml:"channels,omitempty"`
|
||||
|
||||
Web WebToolsSecurity `yaml:"web,omitempty"`
|
||||
Skills SkillsSecurity `yaml:"skills,omitempty"`
|
||||
Web *WebToolsSecurity `yaml:"web,omitempty"`
|
||||
Skills *SkillsSecurity `yaml:"skills,omitempty"`
|
||||
|
||||
// cache for sensitive values and compiled regex (computed once)
|
||||
sensitiveCache *SensitiveDataCache
|
||||
}
|
||||
|
||||
// ModelSecurityEntry stores security data for a model
|
||||
|
|
@ -44,21 +69,19 @@ type ModelSecurityEntry struct {
|
|||
|
||||
// ChannelsSecurity stores channel-related security data
|
||||
type ChannelsSecurity struct {
|
||||
Telegram *TelegramSecurity `yaml:"telegram,omitempty"`
|
||||
Feishu *FeishuSecurity `yaml:"feishu,omitempty"`
|
||||
Discord *DiscordSecurity `yaml:"discord,omitempty"`
|
||||
Weixin *WeixinSecurity `yaml:"weixin,omitempty"`
|
||||
QQ *QQSecurity `yaml:"qq,omitempty"`
|
||||
DingTalk *DingTalkSecurity `yaml:"dingtalk,omitempty"`
|
||||
Slack *SlackSecurity `yaml:"slack,omitempty"`
|
||||
Matrix *MatrixSecurity `yaml:"matrix,omitempty"`
|
||||
LINE *LINESecurity `yaml:"line,omitempty"`
|
||||
OneBot *OneBotSecurity `yaml:"onebot,omitempty"`
|
||||
WeCom *WeComSecurity `yaml:"wecom,omitempty"`
|
||||
WeComApp *WeComAppSecurity `yaml:"wecom_app,omitempty"`
|
||||
WeComAIBot *WeComAIBotSecurity `yaml:"wecom_aibot,omitempty"`
|
||||
Pico *PicoSecurity `yaml:"pico,omitempty"`
|
||||
IRC *IRCSecurity `yaml:"irc,omitempty"`
|
||||
Telegram *TelegramSecurity `yaml:"telegram,omitempty"`
|
||||
Feishu *FeishuSecurity `yaml:"feishu,omitempty"`
|
||||
Discord *DiscordSecurity `yaml:"discord,omitempty"`
|
||||
Weixin *WeixinSecurity `yaml:"weixin,omitempty"`
|
||||
QQ *QQSecurity `yaml:"qq,omitempty"`
|
||||
DingTalk *DingTalkSecurity `yaml:"dingtalk,omitempty"`
|
||||
Slack *SlackSecurity `yaml:"slack,omitempty"`
|
||||
Matrix *MatrixSecurity `yaml:"matrix,omitempty"`
|
||||
LINE *LINESecurity `yaml:"line,omitempty"`
|
||||
OneBot *OneBotSecurity `yaml:"onebot,omitempty"`
|
||||
WeCom *WeComSecurity `yaml:"wecom,omitempty"`
|
||||
Pico *PicoSecurity `yaml:"pico,omitempty"`
|
||||
IRC *IRCSecurity `yaml:"irc,omitempty"`
|
||||
}
|
||||
|
||||
type TelegramSecurity struct {
|
||||
|
|
@ -106,20 +129,7 @@ type OneBotSecurity struct {
|
|||
}
|
||||
|
||||
type WeComSecurity struct {
|
||||
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
|
||||
EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
|
||||
}
|
||||
|
||||
type WeComAppSecurity struct {
|
||||
CorpSecret string `yaml:"corp_secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
|
||||
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
|
||||
EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
|
||||
}
|
||||
|
||||
type WeComAIBotSecurity struct {
|
||||
Secret string `yaml:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"`
|
||||
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
|
||||
EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
|
||||
Secret string `yaml:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_SECRET"`
|
||||
}
|
||||
|
||||
type PicoSecurity struct {
|
||||
|
|
@ -185,7 +195,7 @@ func loadSecurityConfig(securityPath string) (*SecurityConfig, error) {
|
|||
data, err := os.ReadFile(securityPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &SecurityConfig{}, nil
|
||||
return normalizeSecurityConfig(nil), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read security config: %w", err)
|
||||
}
|
||||
|
|
@ -204,7 +214,7 @@ func loadSecurityConfig(securityPath string) (*SecurityConfig, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return &sec, nil
|
||||
return normalizeSecurityConfig(&sec), nil
|
||||
}
|
||||
|
||||
// saveSecurityConfig saves the security configuration to security.yml
|
||||
|
|
@ -218,3 +228,219 @@ func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
|
|||
}
|
||||
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
|
||||
}
|
||||
|
||||
// mergeSecurityConfig merges two SecurityConfig instances, preferring non-empty values from 'newer'.
|
||||
// This is used during config migration to preserve existing security data while adding new entries.
|
||||
func mergeSecurityConfig(existing, newer *SecurityConfig) *SecurityConfig {
|
||||
if existing == nil {
|
||||
return normalizeSecurityConfig(newer)
|
||||
}
|
||||
if newer == nil {
|
||||
return normalizeSecurityConfig(existing)
|
||||
}
|
||||
|
||||
result := normalizeSecurityConfig(nil)
|
||||
|
||||
// Merge ModelList: prefer newer if it has keys, otherwise use existing
|
||||
for k, v := range existing.ModelList {
|
||||
result.ModelList[k] = v
|
||||
}
|
||||
for k, v := range newer.ModelList {
|
||||
if len(v.APIKeys) > 0 {
|
||||
result.ModelList[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Channels
|
||||
if existing.Channels != nil {
|
||||
result.Channels = existing.Channels
|
||||
}
|
||||
if newer.Channels != nil {
|
||||
if result.Channels == nil {
|
||||
result.Channels = &ChannelsSecurity{}
|
||||
}
|
||||
mergeChannelsSecurity(result.Channels, newer.Channels)
|
||||
}
|
||||
|
||||
// Merge Web
|
||||
if existing.Web != nil {
|
||||
result.Web = existing.Web
|
||||
}
|
||||
if newer.Web != nil {
|
||||
if result.Web == nil {
|
||||
result.Web = &WebToolsSecurity{}
|
||||
}
|
||||
mergeWebToolsSecurity(result.Web, newer.Web)
|
||||
}
|
||||
|
||||
// Merge Skills
|
||||
if existing.Skills != nil {
|
||||
result.Skills = existing.Skills
|
||||
}
|
||||
if newer.Skills != nil {
|
||||
if result.Skills == nil {
|
||||
result.Skills = &SkillsSecurity{}
|
||||
}
|
||||
mergeSkillsSecurity(result.Skills, newer.Skills)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func mergeChannelsSecurity(dst, src *ChannelsSecurity) {
|
||||
if src.Telegram != nil && src.Telegram.Token != "" {
|
||||
dst.Telegram = src.Telegram
|
||||
}
|
||||
if src.Feishu != nil &&
|
||||
(src.Feishu.AppSecret != "" || src.Feishu.EncryptKey != "" || src.Feishu.VerificationToken != "") {
|
||||
dst.Feishu = src.Feishu
|
||||
}
|
||||
if src.Discord != nil && src.Discord.Token != "" {
|
||||
dst.Discord = src.Discord
|
||||
}
|
||||
if src.Weixin != nil && src.Weixin.Token != "" {
|
||||
dst.Weixin = src.Weixin
|
||||
}
|
||||
if src.QQ != nil && src.QQ.AppSecret != "" {
|
||||
dst.QQ = src.QQ
|
||||
}
|
||||
if src.DingTalk != nil && src.DingTalk.ClientSecret != "" {
|
||||
dst.DingTalk = src.DingTalk
|
||||
}
|
||||
if src.Slack != nil && (src.Slack.BotToken != "" || src.Slack.AppToken != "") {
|
||||
dst.Slack = src.Slack
|
||||
}
|
||||
if src.Matrix != nil && src.Matrix.AccessToken != "" {
|
||||
dst.Matrix = src.Matrix
|
||||
}
|
||||
if src.LINE != nil && (src.LINE.ChannelSecret != "" || src.LINE.ChannelAccessToken != "") {
|
||||
dst.LINE = src.LINE
|
||||
}
|
||||
if src.OneBot != nil && src.OneBot.AccessToken != "" {
|
||||
dst.OneBot = src.OneBot
|
||||
}
|
||||
if src.WeCom != nil && src.WeCom.Secret != "" {
|
||||
dst.WeCom = src.WeCom
|
||||
}
|
||||
if src.Pico != nil && src.Pico.Token != "" {
|
||||
dst.Pico = src.Pico
|
||||
}
|
||||
if src.IRC != nil && (src.IRC.Password != "" || src.IRC.NickServPassword != "" || src.IRC.SASLPassword != "") {
|
||||
dst.IRC = src.IRC
|
||||
}
|
||||
}
|
||||
|
||||
func mergeWebToolsSecurity(dst, src *WebToolsSecurity) {
|
||||
if src.Brave != nil && len(src.Brave.APIKeys) > 0 {
|
||||
dst.Brave = src.Brave
|
||||
}
|
||||
if src.Tavily != nil && len(src.Tavily.APIKeys) > 0 {
|
||||
dst.Tavily = src.Tavily
|
||||
}
|
||||
if src.Perplexity != nil && len(src.Perplexity.APIKeys) > 0 {
|
||||
dst.Perplexity = src.Perplexity
|
||||
}
|
||||
if src.GLMSearch != nil && src.GLMSearch.APIKey != "" {
|
||||
dst.GLMSearch = src.GLMSearch
|
||||
}
|
||||
if src.BaiduSearch != nil && src.BaiduSearch.APIKey != "" {
|
||||
dst.BaiduSearch = src.BaiduSearch
|
||||
}
|
||||
}
|
||||
|
||||
func mergeSkillsSecurity(dst, src *SkillsSecurity) {
|
||||
if src.Github != nil && src.Github.Token != "" {
|
||||
dst.Github = src.Github
|
||||
}
|
||||
if src.ClawHub != nil && src.ClawHub.AuthToken != "" {
|
||||
dst.ClawHub = src.ClawHub
|
||||
}
|
||||
}
|
||||
|
||||
// SensitiveDataCache caches the compiled regex for filtering sensitive data.
|
||||
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
|
||||
// Computed once on first access via sync.Once.
|
||||
type SensitiveDataCache struct {
|
||||
replacer *strings.Replacer
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// SensitiveDataReplacer returns the strings.Replacer for filtering sensitive data.
|
||||
// It is computed once on first access via sync.Once.
|
||||
func (sec *SecurityConfig) SensitiveDataReplacer() *strings.Replacer {
|
||||
sec.initSensitiveCache()
|
||||
return sec.sensitiveCache.replacer
|
||||
}
|
||||
|
||||
// initSensitiveCache initializes the sensitive data cache if not already done.
|
||||
func (sec *SecurityConfig) initSensitiveCache() {
|
||||
if sec.sensitiveCache == nil {
|
||||
sec.sensitiveCache = &SensitiveDataCache{}
|
||||
}
|
||||
sec.sensitiveCache.once.Do(func() {
|
||||
values := sec.collectSensitiveValues()
|
||||
if len(values) == 0 {
|
||||
sec.sensitiveCache.replacer = strings.NewReplacer()
|
||||
return
|
||||
}
|
||||
|
||||
// Build old/new pairs for strings.Replacer
|
||||
var pairs []string
|
||||
for _, v := range values {
|
||||
if len(v) > 3 {
|
||||
pairs = append(pairs, v, "[FILTERED]")
|
||||
}
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
sec.sensitiveCache.replacer = strings.NewReplacer()
|
||||
return
|
||||
}
|
||||
sec.sensitiveCache.replacer = strings.NewReplacer(pairs...)
|
||||
})
|
||||
}
|
||||
|
||||
// collectSensitiveValues collects all sensitive strings from SecurityConfig using reflection.
|
||||
func (sec *SecurityConfig) collectSensitiveValues() []string {
|
||||
var values []string
|
||||
collectSensitive(reflect.ValueOf(sec), &values)
|
||||
return values
|
||||
}
|
||||
|
||||
// collectSensitive recursively traverses the value and collects all non-empty string fields.
|
||||
func collectSensitive(v reflect.Value, values *[]string) {
|
||||
// Dereference pointers/interfaces to get the underlying value
|
||||
for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
|
||||
if v.IsNil() {
|
||||
return
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
switch v.Kind() {
|
||||
case reflect.Struct:
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldType := v.Type().Field(i)
|
||||
if !fieldType.IsExported() {
|
||||
continue
|
||||
}
|
||||
collectSensitive(field, values)
|
||||
}
|
||||
case reflect.String:
|
||||
if v.String() != "" {
|
||||
*values = append(*values, v.String())
|
||||
}
|
||||
case reflect.Slice:
|
||||
if v.Type().Elem().Kind() == reflect.String {
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
if s := v.Index(i).String(); s != "" {
|
||||
*values = append(*values, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
case reflect.Map:
|
||||
for _, key := range v.MapKeys() {
|
||||
collectSensitive(v.MapIndex(key), values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,12 @@ import (
|
|||
|
||||
// Test JSON unmarshal of private fields
|
||||
func TestJSONUnmarshalPrivateFields(t *testing.T) {
|
||||
//nolint: govet
|
||||
type testStruct struct {
|
||||
PublicField string `json:"public"`
|
||||
privateField string `json:"private"`
|
||||
privateField string
|
||||
}
|
||||
|
||||
data := `{"public": "pub", "private": "priv"}`
|
||||
data := `{"public": "pub", "privateField": "priv"}`
|
||||
var s testStruct
|
||||
if err := json.Unmarshal([]byte(data), &s); err != nil {
|
||||
t.Fatalf("JSON unmarshal failed: %v", err)
|
||||
|
|
@ -35,9 +34,8 @@ func TestJSONUnmarshalPrivateFields(t *testing.T) {
|
|||
if s.PublicField != "pub" {
|
||||
t.Errorf("PublicField = %q, want 'pub'", s.PublicField)
|
||||
}
|
||||
// This should fail because privateField is unexported
|
||||
if s.privateField != "priv" {
|
||||
t.Logf("privateField = %q, want 'priv' - THIS IS EXPECTED TO FAIL", s.privateField)
|
||||
if s.privateField != "" {
|
||||
t.Errorf("privateField = %q, want empty because unexported fields are ignored", s.privateField)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +43,8 @@ func TestSecurityConfigIntegration(t *testing.T) {
|
|||
t.Run("Full workflow with security references", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create config.json with references
|
||||
// Create config.json with direct security values (not ref: references)
|
||||
// These values should take precedence over .security.yml
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
configContent := `{
|
||||
"version": 1,
|
||||
|
|
@ -54,25 +53,25 @@ func TestSecurityConfigIntegration(t *testing.T) {
|
|||
"model_name": "test-model",
|
||||
"model": "openai/test-model",
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"api_key": "ref:model_list.test-model.api_key"
|
||||
"api_key": "sk-from-config-json-direct"
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "ref:channels.telegram.token"
|
||||
"token": "token-from-config-json-direct"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"api_key": "ref:web.brave.api_key"
|
||||
"api_key": "BSA-from-config-json-direct"
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"github": {
|
||||
"token": "ref:skills.github.token"
|
||||
"token": "ghp-from-config-json-direct"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,46 +79,47 @@ func TestSecurityConfigIntegration(t *testing.T) {
|
|||
err := os.WriteFile(configPath, []byte(configContent), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create .security.yml with actual values
|
||||
// Create .security.yml with different values
|
||||
// These should be overridden by config.json values
|
||||
securityPath := filepath.Join(tmpDir, SecurityConfigFile)
|
||||
securityContent := `model_list:
|
||||
test-model:
|
||||
api_keys:
|
||||
- "sk-test-api-key-12345"
|
||||
- "sk-from-security-yml"
|
||||
|
||||
channels:
|
||||
telegram:
|
||||
token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
token: "token-from-security-yml"
|
||||
|
||||
web:
|
||||
brave:
|
||||
api_keys:
|
||||
- "BSAbrave-api-key-67890"
|
||||
- "BSA-from-security-yml"
|
||||
|
||||
skills:
|
||||
github:
|
||||
token: "ghp_github-token-abc123"`
|
||||
token: "ghp-from-security-yml"`
|
||||
err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load config and verify references are resolved
|
||||
// Load config and verify config.json values take precedence
|
||||
cfg, err := LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Verify model API key is resolved
|
||||
// Verify model API key from config.json takes precedence
|
||||
assert.Equal(t, 1, len(cfg.ModelList))
|
||||
assert.Equal(t, "test-model", cfg.ModelList[0].ModelName)
|
||||
assert.Equal(t, "sk-test-api-key-12345", cfg.ModelList[0].apiKeys[0])
|
||||
assert.Equal(t, "sk-from-config-json-direct", cfg.ModelList[0].apiKeys[0])
|
||||
|
||||
// Verify channel token is resolved
|
||||
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.token)
|
||||
// Verify channel token from config.json takes precedence
|
||||
assert.Equal(t, "token-from-config-json-direct", cfg.Channels.Telegram.token)
|
||||
|
||||
// Verify web tool API key is resolved
|
||||
assert.Equal(t, "BSAbrave-api-key-67890", cfg.Tools.Web.Brave.APIKey())
|
||||
// Verify web tool API key from config.json takes precedence
|
||||
assert.Equal(t, "BSA-from-config-json-direct", cfg.Tools.Web.Brave.APIKey())
|
||||
|
||||
// Verify skills token is resolved
|
||||
assert.Equal(t, "ghp_github-token-abc123", cfg.Tools.Skills.Github.token)
|
||||
// Verify skills token from config.json takes precedence
|
||||
assert.Equal(t, "ghp-from-config-json-direct", cfg.Tools.Skills.Github.token)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -242,15 +242,7 @@ func TestAllSecurityKeysAccessible(t *testing.T) {
|
|||
},
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook"
|
||||
},
|
||||
"wecom_app": {
|
||||
"enabled": true,
|
||||
"corp_id": "test_corp_id",
|
||||
"agent_id": 123456
|
||||
},
|
||||
"wecom_aibot": {
|
||||
"enabled": true
|
||||
"bot_id": "test_wecom_bot_id"
|
||||
},
|
||||
"pico": {
|
||||
"enabled": true
|
||||
|
|
@ -317,15 +309,7 @@ channels:
|
|||
onebot:
|
||||
access_token: "onebot_test_access_token"
|
||||
wecom:
|
||||
token: "wecom_test_webhook_token"
|
||||
encoding_aes_key: "wecom_test_aes_key"
|
||||
wecom_app:
|
||||
corp_secret: "wecom_app_test_corp_secret"
|
||||
token: "wecom_app_test_token"
|
||||
encoding_aes_key: "wecom_app_test_aes_key"
|
||||
wecom_aibot:
|
||||
token: "wecom_aibot_test_token"
|
||||
encoding_aes_key: "wecom_aibot_test_aes_key"
|
||||
secret: "wecom_test_secret"
|
||||
pico:
|
||||
token: "pico_test_token"
|
||||
irc:
|
||||
|
|
@ -411,24 +395,10 @@ skills:
|
|||
t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken())
|
||||
|
||||
// WeCom
|
||||
assert.Equal(t, "wecom_test_webhook_token", cfg.Channels.WeCom.Token())
|
||||
assert.Equal(t, "wecom_test_aes_key", cfg.Channels.WeCom.EncodingAESKey())
|
||||
t.Logf("WeCom Token(): %s", cfg.Channels.WeCom.Token())
|
||||
t.Logf("WeCom EncodingAESKey(): %s", cfg.Channels.WeCom.EncodingAESKey())
|
||||
|
||||
// WeCom App
|
||||
assert.Equal(t, "wecom_app_test_corp_secret", cfg.Channels.WeComApp.CorpSecret())
|
||||
assert.Equal(t, "wecom_app_test_token", cfg.Channels.WeComApp.Token())
|
||||
assert.Equal(t, "wecom_app_test_aes_key", cfg.Channels.WeComApp.EncodingAESKey())
|
||||
t.Logf("WeComApp CorpSecret(): %s", cfg.Channels.WeComApp.CorpSecret())
|
||||
t.Logf("WeComApp Token(): %s", cfg.Channels.WeComApp.Token())
|
||||
t.Logf("WeComApp EncodingAESKey(): %s", cfg.Channels.WeComApp.EncodingAESKey())
|
||||
|
||||
// WeCom AI Bot
|
||||
assert.Equal(t, "wecom_aibot_test_token", cfg.Channels.WeComAIBot.Token())
|
||||
assert.Equal(t, "wecom_aibot_test_aes_key", cfg.Channels.WeComAIBot.EncodingAESKey())
|
||||
t.Logf("WeComAIBot Token(): %s", cfg.Channels.WeComAIBot.Token())
|
||||
t.Logf("WeComAIBot EncodingAESKey(): %s", cfg.Channels.WeComAIBot.EncodingAESKey())
|
||||
assert.Equal(t, "test_wecom_bot_id", cfg.Channels.WeCom.BotID)
|
||||
assert.Equal(t, "wecom_test_secret", cfg.Channels.WeCom.Secret())
|
||||
t.Logf("WeCom BotID: %s", cfg.Channels.WeCom.BotID)
|
||||
t.Logf("WeCom Secret(): %s", cfg.Channels.WeCom.Secret())
|
||||
|
||||
// Pico
|
||||
assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token())
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue