Merge upstream/main (278 commits) into picoclaw

Major upstream changes:
- WeChat/WeCom channel refactored to new architecture
- exec tool: background execution + PTY support
- MCP server timeout config
- Context overflow detection improvements
- Multi-message sending via split marker
- SQLite dependency reorganization
- MIMO provider added
- Logger file output support
- FreeBSD/arm build fix

Conflict resolution:
- Preserved picoclaw-specific: orchestration, session DAG, plan mode,
  dedupe, streaming preview, status message tracking
- Adapted to upstream's StreamingProvider rename (CallbackStreamingProvider)
- Added missing config fields (OCRConfig, PlanModel, TaskReminderInterval,
  HeartbeatThreadID, SubagentThreadID, MCP Timeout)
- Fixed duplicate method declarations in loop.go and telegram_ext.go
- Restored markdownToTelegramMarkdownV2 wrapper after upstream AST refactor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-26 12:35:50 +09:00
commit 1ecd7d55c5
157 changed files with 13274 additions and 10107 deletions

View file

@ -29,10 +29,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
@ -71,4 +74,4 @@ jobs:
run: go generate ./...
- name: Run go test
run: go test ./...
run: go test -tags goolm,stdjson ./...

3
.gitignore vendored
View file

@ -25,6 +25,9 @@ build/
# Secrets & Config (keep templates, ignore actual secrets)
.env
config/config.json
.security.yml
onboard
# Test
coverage.txt

View file

@ -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

View file

@ -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,16 +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)
# mipsle skipped: modernc.org/sqlite (CGO-free) does not support mipsle via modernc.org/libc
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)
# netbsd skipped: modernc.org/sqlite has build issues on netbsd (mutex API mismatch)
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 $(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
@ -219,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
@ -234,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:

View file

@ -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**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 357 KiB

View 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
```

View file

@ -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")

View file

@ -17,6 +17,7 @@ func NewAuthCommand() *cobra.Command {
newStatusCommand(),
newModelsCommand(),
newWeixinCommand(),
newWeComCommand(),
)
return cmd

View file

@ -33,6 +33,7 @@ func TestNewAuthCommand(t *testing.T) {
"status",
"models",
"weixin",
"wecom",
}
subcommands := cmd.Commands()

View 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
}
}

View 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.")
}

View file

@ -14,8 +14,6 @@ import (
func NewGatewayCommand() *cobra.Command {
var debug bool
var noTruncate bool
var orchestration bool
var enableStats bool
var allowEmpty bool
cmd := &cobra.Command{
@ -36,17 +34,12 @@ func NewGatewayCommand() *cobra.Command {
return nil
},
RunE: func(_ *cobra.Command, _ []string) error {
return gateway.Run(
debug, internal.GetPicoclawHome(), internal.GetConfigPath(),
orchestration, enableStats, allowEmpty,
)
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
},
}
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
cmd.Flags().BoolVar(&orchestration, "orchestration", false, "Enable subagent orchestration")
cmd.Flags().BoolVar(&enableStats, "stats", false, "Enable stats collection")
cmd.Flags().BoolVarP(
&allowEmpty,
"allow-empty",

View file

@ -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
@ -123,6 +124,10 @@
"encrypt_key": "",
"verification_token": "",
"allow_from": [],
"placeholder": {
"enabled": true,
"text": ["Thinking...", "Processing...", "Typing..."]
},
"reasoning_channel_id": "",
"random_reaction_emoji": [],
"is_lark": false
@ -154,9 +159,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,
@ -176,39 +183,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": {
@ -241,13 +222,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
@ -258,79 +234,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,
@ -342,9 +245,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": {
@ -360,9 +261,7 @@
"perplexity": {
"enabled": false,
"api_key": "pplx-xxx",
"api_keys": [
"pplx-xxx"
],
"api_keys": ["pplx-xxx"],
"max_results": 5
},
"searxng": {
@ -377,6 +276,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": []
},
@ -405,19 +310,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"
}
@ -425,10 +323,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"
}
@ -445,10 +340,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"

View file

@ -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

View file

@ -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

View 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`.

View 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` 已填写。

View file

@ -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) |
@ -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>

View file

@ -454,6 +454,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 +579,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 +604,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 +617,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 +630,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 +643,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 +684,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 +697,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 +780,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 +798,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 +820,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 +845,7 @@ 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 |

View file

@ -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"
}
]

View file

@ -27,6 +27,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)
@ -63,6 +64,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 | - |

View 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

View file

@ -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) |
@ -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>

View file

@ -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 | - |

6
go.mod
View file

@ -13,6 +13,7 @@ require (
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
@ -32,6 +33,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
@ -40,6 +42,7 @@ 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 (
@ -68,6 +71,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
@ -78,13 +82,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 (

2
go.sum
View file

@ -70,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=

View file

@ -28,6 +28,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.
@ -54,6 +55,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
@ -215,6 +221,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")
}

View file

@ -16,6 +16,24 @@ import (
"github.com/sipeed/picoclaw/pkg/tools"
)
// SessionAccessor is the interface that AgentInstance.Sessions must satisfy.
// *session.LegacyAdapter implements this, as does the ephemeral store used in subturn.
type SessionAccessor interface {
AddMessage(sessionKey, role, content string)
AddFullMessage(sessionKey string, msg providers.Message)
GetHistory(key string) []providers.Message
GetSummary(key string) string
SetHistory(key string, history []providers.Message)
SetSummary(key, summary string)
TruncateHistory(key string, keepLast int)
Save(key string) error
Close() error
MarkDirty(key string)
Store() session.SessionStore
AdvanceStored(key string, count int)
CompactOldTurns(key string, keepLast int, summary string) error
}
// AgentInstance represents a fully configured agent with its own workspace,
// session manager, context builder, and tool registry.
type AgentInstance struct {
@ -35,7 +53,7 @@ type AgentInstance struct {
SummarizeMessageThreshold int
SummarizeTokenPercent int
Provider providers.LLMProvider
Sessions *session.LegacyAdapter
Sessions SessionAccessor
ContextBuilder *ContextBuilder
Tools *tools.ToolRegistry
Subagents *config.SubagentsConfig
@ -139,10 +157,12 @@ func NewAgentInstance(
sessionsManager := session.NewLegacyAdapter(store)
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
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 := ""

View file

@ -236,8 +236,9 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
t.Fatal("exec tool not registered")
}
execResult := execTool.Execute(context.Background(), map[string]any{
"action": "run",
"command": "cat " + filepath.Base(mediaPath),
"working_dir": mediaDir,
"cwd": mediaDir,
})
if execResult.IsError {
t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM)

View file

@ -96,6 +96,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)
TaskID string // Unique task ID for background task status tracking
@ -103,9 +104,16 @@ type processOptions struct {
SystemMessage bool // If true, this is a system message (subagent result) — skip placeholder and plan nudge
}
type continuationTarget struct {
SessionKey string
Channel string
ChatID string
}
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."
handledToolResponseSummary = "Requested output delivered via tool attachment."
sessionKeyAgentPrefix = "agent:"
metadataKeyAccountID = "account_id"
metadataKeyGuildID = "guild_id"
@ -414,7 +422,6 @@ func (al *AgentLoop) Run(ctx context.Context) error {
_ = agent.Sessions.Save(msg.SessionKey)
}
}
// Activate worktree for the session's plan execution
if agent := al.registry.GetDefaultAgent(); agent != nil {
taskName := agent.ContextBuilder.Memory().GetPlanTaskName()
@ -657,10 +664,135 @@ func (al *AgentLoop) resetMessageTool() {
}
}
// drainBusToSteering consumes inbound messages and redirects messages from the
// active scope into the steering queue. Messages from other scopes are requeued
// so they can be processed normally after the active turn. It drains all
// immediately available messages, blocking for the first one until ctx is done.
func (al *AgentLoop) drainBusToSteering(ctx context.Context, activeScope, activeAgentID string) {
blocking := true
for {
var msg bus.InboundMessage
if blocking {
// Block waiting for the first available message or ctx cancellation.
select {
case <-ctx.Done():
return
case m, ok := <-al.bus.InboundChan():
if !ok {
return
}
msg = m
}
} else {
// Non-blocking: drain any remaining queued messages, return when empty.
select {
case m, ok := <-al.bus.InboundChan():
if !ok {
return
}
msg = m
default:
return
}
}
blocking = false
msgScope, _, scopeOK := al.resolveSteeringTarget(msg)
if !scopeOK || msgScope != activeScope {
if err := al.requeueInboundMessage(msg); err != nil {
logger.WarnCF("agent", "Failed to requeue non-steering inbound message", map[string]any{
"error": err.Error(),
"channel": msg.Channel,
"sender_id": msg.SenderID,
})
}
continue
}
// Transcribe audio if needed before steering, so the agent sees text.
msg, _ = al.transcribeAudioInMessage(ctx, msg)
logger.InfoCF("agent", "Redirecting inbound message to steering queue",
map[string]any{
"channel": msg.Channel,
"sender_id": msg.SenderID,
"content_len": len(msg.Content),
"scope": activeScope,
})
if err := al.enqueueSteeringMessage(activeScope, activeAgentID, providers.Message{
Role: "user",
Content: msg.Content,
Media: append([]string(nil), msg.Media...),
}); err != nil {
logger.WarnCF("agent", "Failed to steer message, will be lost",
map[string]any{
"error": err.Error(),
"channel": msg.Channel,
})
}
}
}
func (al *AgentLoop) Stop() {
al.running.Store(false)
}
func (al *AgentLoop) publishResponseIfNeeded(ctx context.Context, channel, chatID, response string) {
if response == "" {
return
}
alreadySent := false
defaultAgent := al.GetRegistry().GetDefaultAgent()
if defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
alreadySent = mt.HasSentInRound()
}
}
}
if alreadySent {
logger.DebugCF(
"agent",
"Skipped outbound (message tool already sent)",
map[string]any{"channel": channel},
)
return
}
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: response,
})
logger.InfoCF("agent", "Published outbound response",
map[string]any{
"channel": channel,
"chat_id": chatID,
"content_len": len(response),
})
}
func (al *AgentLoop) buildContinuationTarget(msg bus.InboundMessage) (*continuationTarget, error) {
if msg.Channel == "system" {
return nil, nil
}
route, _, err := al.resolveMessageRoute(msg)
if err != nil {
return nil, err
}
return &continuationTarget{
SessionKey: resolveScopeKey(route, msg.SessionKey),
Channel: msg.Channel,
ChatID: msg.ChatID,
}, nil
}
// Close releases resources held by the loop (e.g. flushes write-behind stats
// and dirty session data). Should be called during graceful shutdown.
func (al *AgentLoop) Close() {
@ -745,6 +877,17 @@ func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string) turnEventScop
}
}
func (ts turnEventScope) meta(iteration int, source, tracePath string) EventMeta {
return EventMeta{
AgentID: ts.agentID,
TurnID: ts.turnID,
SessionKey: ts.sessionKey,
Iteration: iteration,
Source: source,
TracePath: tracePath,
}
}
func (al *AgentLoop) emitEvent(kind EventKind, meta EventMeta, payload any) {
evt := Event{
Kind: kind,
@ -761,6 +904,43 @@ func (al *AgentLoop) emitEvent(kind EventKind, meta EventMeta, payload any) {
al.eventBus.Emit(evt)
}
func cloneEventArguments(args map[string]any) map[string]any {
if len(args) == 0 {
return nil
}
cloned := make(map[string]any, len(args))
for k, v := range args {
cloned[k] = v
}
return cloned
}
func (al *AgentLoop) hookAbortError(ts *turnState, stage string, decision HookDecision) error {
reason := decision.Reason
if reason == "" {
reason = "hook requested turn abort"
}
err := fmt.Errorf("hook aborted turn during %s: %s", stage, reason)
al.emitEvent(
EventKindError,
ts.eventMeta("hooks", "turn.error"),
ErrorPayload{
Stage: "hook." + stage,
Message: err.Error(),
},
)
return err
}
func hookDeniedToolContent(prefix, reason string) string {
if reason == "" {
return prefix
}
return prefix + ": " + reason
}
func (al *AgentLoop) logEvent(evt Event) {
fields := map[string]any{
"event_kind": evt.Kind.String(),
@ -1033,13 +1213,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.
@ -1291,23 +1471,15 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha
return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: "heartbeat",
Channel: channel,
ChatID: heartbeatChatID,
UserMessage: content,
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
SuppressToolFeedback: true,
NoHistory: true, // Don't load session history for heartbeat
Background: true, // Enable live task notifications on Telegram
})
}
@ -1468,6 +1640,32 @@ func (al *AgentLoop) withTelegramThread(channel, chatID string, threadID int) st
return fmt.Sprintf("%s/%d", baseChatID, threadID)
}
func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) {
if msg.Channel == "system" {
return "", "", false
}
route, agent, err := al.resolveMessageRoute(msg)
if err != nil || agent == nil {
return "", "", false
}
return resolveScopeKey(route, msg.SessionKey), agent.ID, true
}
func (al *AgentLoop) requeueInboundMessage(msg bus.InboundMessage) error {
if al.bus == nil {
return nil
}
pubCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
return al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: msg.Content,
})
}
func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
if al.channelManager == nil {
return ""

View file

@ -14,6 +14,7 @@ import (
"github.com/sipeed/picoclaw/pkg/orch"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/stats"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
@ -192,7 +193,7 @@ func registerOrchestrationTools(
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
// Wire session recorder for DAG persistence.
recorder := newSessionRecorder(agent.Sessions)
recorder := newSessionRecorder(agent.Sessions.(*session.LegacyAdapter))
conductorKey := routing.BuildAgentMainSessionKey(agent.ID)
subagentManager.SetSessionRecorder(recorder, conductorKey)

View file

@ -97,6 +97,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 {

View file

@ -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
}
@ -454,6 +490,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-*")
@ -1513,7 +1760,6 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
"line": "rid-line",
"onebot": "rid-onebot",
"wecom": "rid-wecom",
"wecom_app": "rid-wecom-app",
} {
chManager.RegisterChannel(name, &fakeChannel{id: id})
}
@ -1533,7 +1779,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: ""},
}
@ -2139,3 +2384,325 @@ 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)
}
}
// --- Missing mock types for media tool tests ---
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 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 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 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})
}

View file

@ -279,6 +279,13 @@ func (al *AgentLoop) dequeueSteeringMessagesForScopeWithFallback(scope string) [
return al.steering.dequeueScopeWithFallback(scope)
}
func (al *AgentLoop) pendingSteeringCountForScope(scope string) int {
if al.steering == nil {
return 0
}
return al.steering.lenScope(scope)
}
func (al *AgentLoop) continueWithSteeringMessages(
ctx context.Context,
agent *AgentInstance,

View file

@ -10,6 +10,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/tools"
)
@ -346,6 +347,7 @@ func spawnSubTurn(
// We copy into a heap-allocated struct to avoid the go vet copylocks
// warning on the embedded instanceExt (which contains sync.RWMutex).
agent := copyAgentInstance(baseAgent)
agent.Sessions = ephemeralStore
// Clone the tool registry so child turn's tool registrations
// don't pollute the parent's registry.
if baseAgent.Tools != nil {
@ -619,16 +621,9 @@ func copyAgentInstance(src *AgentInstance) AgentInstance {
// ephemeralSessionStoreIface is satisfied by *ephemeralSessionStore.
// Declared so newEphemeralSession can return a typed interface.
// Must satisfy SessionAccessor so it can be assigned to AgentInstance.Sessions.
type ephemeralSessionStoreIface interface {
AddMessage(sessionKey, role, content string)
AddFullMessage(sessionKey string, msg providers.Message)
GetHistory(key string) []providers.Message
GetSummary(key string) string
SetSummary(key, summary string)
SetHistory(key string, history []providers.Message)
TruncateHistory(key string, keepLast int)
Save(key string) error
Close() error
SessionAccessor
}
func (e *ephemeralSessionStore) AddMessage(_, role, content string) {
@ -689,6 +684,10 @@ func (e *ephemeralSessionStore) TruncateHistory(_ string, keepLast int) {
func (e *ephemeralSessionStore) Save(_ string) error { return nil }
func (e *ephemeralSessionStore) Close() error { return nil }
func (e *ephemeralSessionStore) MarkDirty(_ string) {}
func (e *ephemeralSessionStore) Store() session.SessionStore { return nil }
func (e *ephemeralSessionStore) AdvanceStored(_ string, _ int) {}
func (e *ephemeralSessionStore) CompactOldTurns(_ string, _ int, _ string) error { return nil }
func (e *ephemeralSessionStore) truncateLocked() {
if len(e.history) > maxEphemeralHistorySize {

View file

@ -2,10 +2,12 @@ package agent
import (
"context"
"reflect"
"sync"
"sync/atomic"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/tools"
@ -39,6 +41,8 @@ type ActiveTurnInfo struct {
type turnResult struct {
finalContent string
status TurnEndStatus
followUps []bus.InboundMessage
}
type turnState struct {
@ -60,13 +64,21 @@ type turnState struct {
phase TurnPhase
iteration int
startedAt time.Time
finalContent string
followUps []bus.InboundMessage
gracefulInterrupt bool
gracefulInterruptHint string
gracefulTerminalUsed bool
hardAbort bool
providerCancel context.CancelFunc
turnCancel context.CancelFunc
restorePointHistory []providers.Message
restorePointSummary string
persistedMessages []providers.Message
// SubTurn support (from HEAD)
depth int // SubTurn depth (0 for root turn)
parentTurnID string // Parent turn ID (empty for root turn)
@ -203,6 +215,54 @@ func (ts *turnState) snapshot() ActiveTurnInfo {
}
}
func (ts *turnState) setPhase(phase TurnPhase) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.phase = phase
}
func (ts *turnState) setIteration(iteration int) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.iteration = iteration
}
func (ts *turnState) currentIteration() int {
ts.mu.RLock()
defer ts.mu.RUnlock()
return ts.iteration
}
func (ts *turnState) setFinalContent(content string) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.finalContent = content
}
func (ts *turnState) finalContentLen() int {
ts.mu.RLock()
defer ts.mu.RUnlock()
return len(ts.finalContent)
}
func (ts *turnState) setTurnCancel(cancel context.CancelFunc) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.turnCancel = cancel
}
func (ts *turnState) setProviderCancel(cancel context.CancelFunc) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.providerCancel = cancel
}
func (ts *turnState) clearProviderCancel(_ context.CancelFunc) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.providerCancel = nil
}
func (ts *turnState) requestGracefulInterrupt(hint string) bool {
ts.mu.Lock()
defer ts.mu.Unlock()
@ -214,6 +274,18 @@ func (ts *turnState) requestGracefulInterrupt(hint string) bool {
return true
}
func (ts *turnState) gracefulInterruptRequested() (bool, string) {
ts.mu.RLock()
defer ts.mu.RUnlock()
return ts.gracefulInterrupt && !ts.gracefulTerminalUsed, ts.gracefulInterruptHint
}
func (ts *turnState) markGracefulTerminalUsed() {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.gracefulTerminalUsed = true
}
func (ts *turnState) requestHardAbort() bool {
ts.mu.Lock()
if ts.hardAbort {
@ -234,6 +306,12 @@ func (ts *turnState) requestHardAbort() bool {
return true
}
func (ts *turnState) hardAbortRequested() bool {
ts.mu.RLock()
defer ts.mu.RUnlock()
return ts.hardAbort
}
func (ts *turnState) eventMeta(source, tracePath string) EventMeta {
snap := ts.snapshot()
return EventMeta{
@ -246,6 +324,67 @@ func (ts *turnState) eventMeta(source, tracePath string) EventMeta {
}
}
func (ts *turnState) captureRestorePoint(history []providers.Message, summary string) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.restorePointHistory = append([]providers.Message(nil), history...)
ts.restorePointSummary = summary
}
func (ts *turnState) recordPersistedMessage(msg providers.Message) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.persistedMessages = append(ts.persistedMessages, msg)
}
func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) {
history := agent.Sessions.GetHistory(ts.sessionKey)
summary := agent.Sessions.GetSummary(ts.sessionKey)
ts.mu.RLock()
persisted := append([]providers.Message(nil), ts.persistedMessages...)
ts.mu.RUnlock()
if matched := matchingTurnMessageTail(history, persisted); matched > 0 {
history = append([]providers.Message(nil), history[:len(history)-matched]...)
}
ts.captureRestorePoint(history, summary)
}
func (ts *turnState) restoreSession(agent *AgentInstance) error {
ts.mu.RLock()
history := append([]providers.Message(nil), ts.restorePointHistory...)
summary := ts.restorePointSummary
ts.mu.RUnlock()
agent.Sessions.SetHistory(ts.sessionKey, history)
agent.Sessions.SetSummary(ts.sessionKey, summary)
return agent.Sessions.Save(ts.sessionKey)
}
func matchingTurnMessageTail(history, persisted []providers.Message) int {
maxMatch := min(len(history), len(persisted))
for size := maxMatch; size > 0; size-- {
if reflect.DeepEqual(history[len(history)-size:], persisted[len(persisted)-size:]) {
return size
}
}
return 0
}
func (ts *turnState) interruptHintMessage() providers.Message {
_, hint := ts.gracefulInterruptRequested()
content := "Interrupt requested. Stop scheduling tools and provide a short final summary."
if hint != "" {
content += "\n\nInterrupt hint: " + hint
}
return providers.Message{
Role: "user",
Content: content,
}
}
// SubTurn-related methods
// Finish marks the turn as finished and closes the pendingResults channel

View file

@ -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.

View file

@ -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` 的 channelTelegram、Discord、Pico能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。
8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链reasoning/thinking路由到指定 channelWhatsApp、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路由到指定 channelWhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom。注意`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。

View file

@ -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 {

View file

@ -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 {

View file

@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"math"
"net/http"
"sync"
"time"
@ -76,18 +77,23 @@ type channelWorker struct {
}
type Manager struct {
managerExt // fork-specific fields (statusMsgIDs, taskMsgIDs, statusEditTimes)
channels map[string]Channel
workers map[string]*channelWorker
bus *bus.MessageBus
config *config.Config
mediaStore media.MediaStore
dispatchTask *asyncTask
mux *http.ServeMux
httpServer *http.Server
mu sync.RWMutex
placeholders sync.Map // "channel:chatID" → placeholderID (string)
typingStops sync.Map // "channel:chatID" → func()
reactionUndos sync.Map // "channel:chatID" → reactionEntry
streamActive sync.Map // "channel:chatID" → true (set when streamer.Finalize sent the message)
statusMsgIDs sync.Map // "channel:chatID" → statusMsgEntry (streaming preview)
taskMsgIDs sync.Map // "channel:chatID:taskID" → statusMsgEntry (background task status)
statusEditTimes sync.Map // key → time.Time — last EditMessage time for throttling
channelHashes map[string]string // channel name → config hash
}
type asyncTask struct {
@ -134,15 +140,23 @@ func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
}
}
// InvokeTypingStop invokes the registered typing stop function for the given channel and chatID.
// It is safe to call even when no typing indicator is active (no-op).
// Used by the agent loop to stop typing when processing completes (success, error, or panic),
// regardless of whether an outbound message is published.
func (m *Manager) InvokeTypingStop(channel, chatID string) {
key := channel + ":" + chatID
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
if entry, ok := v.(typingEntry); ok {
entry.stop()
}
}
}
// RecordReactionUndo registers a reaction undo function for later invocation.
// Implements PlaceholderRecorder.
func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
key := channel + ":" + chatID
if v, loaded := m.reactionUndos.Load(key); loaded {
if entry, ok := v.(reactionEntry); ok {
entry.undo() // idempotent
}
}
m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()})
}
@ -165,43 +179,7 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
}
}
// 3. Try editing a tracked status message (from streaming preview).
// For draft-based entries, update the draft with the final content so
// the draft bubble becomes the permanent response. If a placeholder
// also exists, delete it to avoid orphan "Thinking…" messages.
if v, loaded := m.statusMsgIDs.LoadAndDelete(key); loaded {
if entry, ok := v.(statusMsgEntry); ok {
if entry.draftID != 0 {
if drafter, ok := ch.(DraftSender); ok {
if err := drafter.SendDraft(ctx, msg.ChatID, entry.draftID, msg.Content); err == nil {
m.statusEditTimes.Delete(key)
// Draft displays the final content; delete orphan placeholder.
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if phEntry, ok := v.(placeholderEntry); ok && phEntry.id != "" {
if deleter, ok := ch.(MessageDeleter); ok {
_ = deleter.DeleteMessage(ctx, msg.ChatID, phEntry.id)
}
}
}
return true
}
// Clear orphan draft on failure to prevent stale streaming preview.
_ = drafter.SendDraft(ctx, msg.ChatID, entry.draftID, "")
}
m.statusEditTimes.Delete(key)
// Draft update failed → fall through to placeholder path.
} else if entry.messageID != "" {
if editor, ok := ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.messageID, msg.Content); err == nil {
m.placeholders.Delete(key)
return true // edited successfully, skip Send
}
}
}
}
}
// 3b. If a stream already finalized this message, delete the placeholder and skip send
// 3. If a stream already finalized this message, delete the placeholder and skip send
if _, loaded := m.streamActive.LoadAndDelete(key); loaded {
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
@ -231,6 +209,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),
@ -238,15 +250,19 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.Medi
bus: messageBus,
config: cfg,
mediaStore: store,
channelHashes: make(map[string]string),
}
// Register as streaming delegate so the agent loop can obtain streamers
messageBus.SetStreamDelegate(m)
if err := m.initChannels(); err != nil {
if err := m.initChannels(&cfg.Channels); err != nil {
return nil, err
}
// Store initial config hashes for all channels
m.channelHashes = toChannelHashes(cfg)
return m, nil
}
@ -337,15 +353,15 @@ func (m *Manager) initChannel(name, displayName string) {
}
}
func (m *Manager) initChannels() error {
func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
logger.InfoC("channels", "Initializing channel manager")
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token() != "" {
if channels.Telegram.Enabled && channels.Telegram.Token() != "" {
m.initChannel("telegram", "Telegram")
}
if m.config.Channels.WhatsApp.Enabled {
waCfg := m.config.Channels.WhatsApp
if channels.WhatsApp.Enabled {
waCfg := channels.WhatsApp
if waCfg.UseNative {
m.initChannel("whatsapp_native", "WhatsApp Native")
} else if waCfg.BridgeURL != "" {
@ -353,71 +369,62 @@ func (m *Manager) initChannels() error {
}
}
if m.config.Channels.Feishu.Enabled {
if channels.Feishu.Enabled {
m.initChannel("feishu", "Feishu")
}
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token() != "" {
if channels.Discord.Enabled && channels.Discord.Token() != "" {
m.initChannel("discord", "Discord")
}
if m.config.Channels.MaixCam.Enabled {
if channels.MaixCam.Enabled {
m.initChannel("maixcam", "MaixCam")
}
if m.config.Channels.QQ.Enabled {
if channels.QQ.Enabled {
m.initChannel("qq", "QQ")
}
if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" {
if channels.DingTalk.Enabled && channels.DingTalk.ClientID != "" {
m.initChannel("dingtalk", "DingTalk")
}
if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken() != "" {
if channels.Slack.Enabled && channels.Slack.BotToken() != "" {
m.initChannel("slack", "Slack")
}
if m.config.Channels.Matrix.Enabled &&
if channels.Matrix.Enabled &&
m.config.Channels.Matrix.Homeserver != "" &&
m.config.Channels.Matrix.UserID != "" &&
m.config.Channels.Matrix.AccessToken() != "" {
m.initChannel("matrix", "Matrix")
}
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken() != "" {
if channels.LINE.Enabled && channels.LINE.ChannelAccessToken() != "" {
m.initChannel("line", "LINE")
}
if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
if channels.OneBot.Enabled && channels.OneBot.WSUrl != "" {
m.initChannel("onebot", "OneBot")
}
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token() != "" {
if channels.WeCom.Enabled && channels.WeCom.BotID != "" && channels.WeCom.Secret() != "" {
m.initChannel("wecom", "WeCom")
}
if m.config.Channels.WeComAIBot.Enabled && (m.config.Channels.WeComAIBot.Token() != "" ||
(m.config.Channels.WeComAIBot.Secret() != "" && m.config.Channels.WeComAIBot.BotID != "")) {
m.initChannel("wecom_aibot", "WeCom AI Bot")
}
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
m.initChannel("wecom_app", "WeCom App")
}
if m.config.Channels.Weixin.Enabled && m.config.Channels.Weixin.Token() != "" {
if channels.Weixin.Enabled && channels.Weixin.Token() != "" {
m.initChannel("weixin", "Weixin")
}
if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token() != "" {
if channels.Pico.Enabled && channels.Pico.Token() != "" {
m.initChannel("pico", "Pico")
}
if m.config.Channels.PicoClient.Enabled && m.config.Channels.PicoClient.URL != "" {
if channels.PicoClient.Enabled && channels.PicoClient.URL != "" {
m.initChannel("pico_client", "Pico Client")
}
if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" {
if channels.IRC.Enabled && channels.IRC.Server != "" {
m.initChannel("irc", "IRC")
}
@ -428,31 +435,41 @@ func (m *Manager) initChannels() error {
return nil
}
// SetupHTTPServer registers channel webhook handlers and health checkers onto
// the health server's mux so everything is served by a single HTTP listener.
// SetupHTTPServer creates a shared HTTP server with the given listen address.
// It registers health endpoints from the health server and discovers channels
// that implement WebhookHandler and/or HealthChecker to register their handlers.
func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) {
if healthServer == nil {
return
m.mux = http.NewServeMux()
// Register health endpoints
if healthServer != nil {
healthServer.RegisterOnMux(m.mux)
}
mux := healthServer.Mux()
// Discover and register webhook handlers and health checkers
for name, ch := range m.channels {
if wh, ok := ch.(WebhookHandler); ok {
mux.Handle(wh.WebhookPath(), wh)
m.mux.Handle(wh.WebhookPath(), wh)
logger.InfoCF("channels", "Webhook handler registered", map[string]any{
"channel": name,
"path": wh.WebhookPath(),
})
}
if hc, ok := ch.(HealthChecker); ok {
mux.HandleFunc(hc.HealthPath(), hc.HealthHandler)
m.mux.HandleFunc(hc.HealthPath(), hc.HealthHandler)
logger.InfoCF("channels", "Health endpoint registered", map[string]any{
"channel": name,
"path": hc.HealthPath(),
})
}
}
m.httpServer = &http.Server{
Addr: addr,
Handler: m.mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
}
func (m *Manager) StartAll(ctx context.Context) error {
@ -493,6 +510,20 @@ func (m *Manager) StartAll(ctx context.Context) error {
// Start the TTL janitor that cleans up stale typing/placeholder entries
go m.runTTLJanitor(dispatchCtx)
// Start shared HTTP server if configured
if m.httpServer != nil {
go func() {
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
"addr": m.httpServer.Addr,
})
if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.FatalCF("channels", "Shared HTTP server error", map[string]any{
"error": err.Error(),
})
}
}()
}
logger.InfoC("channels", "All channels started")
return nil
}
@ -503,6 +534,18 @@ func (m *Manager) StopAll(ctx context.Context) error {
logger.InfoC("channels", "Stopping all channels")
// Shutdown shared HTTP server first
if m.httpServer != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := m.httpServer.Shutdown(shutdownCtx); err != nil {
logger.ErrorCF("channels", "Shared HTTP server shutdown error", map[string]any{
"error": err.Error(),
})
}
m.httpServer = nil
}
// Cancel dispatcher
if m.dispatchTask != nil {
m.dispatchTask.cancel()
@ -568,8 +611,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 {
@ -578,37 +623,48 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
if !ok {
return
}
// Route status/task messages to dedicated handlers
if msg.IsStatus {
m.handleStatusSend(ctx, name, w, msg)
continue
}
if msg.IsTaskStatus {
m.handleTaskStatusSend(ctx, name, w, msg)
continue
}
maxLen := 0
if mlp, ok := w.ch.(MessageLengthProvider); ok {
maxLen = mlp.MaxMessageLength()
}
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
chunks := SplitMessage(msg.Content, maxLen)
// 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)...)
}
}
}
// 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)
}
} else {
m.sendWithRetry(ctx, name, w, msg)
}
case <-ctx.Done():
return
}
}
}
// 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
@ -770,7 +826,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
}
@ -778,26 +834,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
@ -816,7 +883,7 @@ func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channe
case <-time.After(rateLimitDelay):
continue
case <-ctx.Done():
return
return ctx.Err()
}
}
@ -825,7 +892,7 @@ func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channe
select {
case <-time.After(backoff):
case <-ctx.Done():
return
return ctx.Err()
}
}
@ -836,6 +903,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
@ -882,18 +950,6 @@ func (m *Manager) runTTLJanitor(ctx context.Context) {
}
}
// InvokeTypingStop stops any active typing indicator for the given channel/chatID.
// Safe to call even if no typing indicator is active. Intended for use in defer
// after the LLM worker finishes processing a message.
func (m *Manager) InvokeTypingStop(channel, chatID string) {
key := channel + ":" + chatID
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
if entry, ok := v.(typingEntry); ok && entry.stop != nil {
entry.stop()
}
}
}
func (m *Manager) GetChannel(name string) (Channel, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
@ -926,6 +982,68 @@ func (m *Manager) GetEnabledChannels() []string {
return names
}
// Reload updates the config reference without restarting channels.
// This is used when channel config hasn't changed but other parts of the config have.
func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
m.mu.Lock()
defer m.mu.Unlock()
list := toChannelHashes(cfg)
added, removed := compareChannels(m.channelHashes, list)
for _, name := range removed {
// Stop all channels
channel := m.channels[name]
logger.InfoCF("channels", "Stopping channel", map[string]any{
"channel": name,
})
if err := channel.Stop(ctx); err != nil {
logger.ErrorCF("channels", "Error stopping channel", map[string]any{
"channel": name,
"error": err.Error(),
})
}
go func() {
m.UnregisterChannel(name)
}()
}
dispatchCtx, cancel := context.WithCancel(ctx)
m.dispatchTask = &asyncTask{cancel: cancel}
cc, err := toChannelConfig(cfg, added)
if err != nil {
logger.ErrorC("channels", fmt.Sprintf("toChannelConfig error: %v", err))
return err
}
err = m.initChannels(cc)
if err != nil {
logger.ErrorC("channels", fmt.Sprintf("initChannels error: %v", err))
return err
}
for _, name := range added {
channel := m.channels[name]
logger.InfoCF("channels", "Starting channel", map[string]any{
"channel": name,
})
if err := channel.Start(ctx); err != nil {
logger.ErrorCF("channels", "Failed to start channel", map[string]any{
"channel": name,
"error": err.Error(),
})
continue
}
// Lazily create worker only after channel starts successfully
w := newChannelWorker(name, channel)
m.workers[name] = w
go m.runWorker(dispatchCtx, name, w)
go m.runMediaWorker(dispatchCtx, name, w)
go func() {
m.RegisterChannel(name, channel)
}()
}
m.config = cfg
m.channelHashes = toChannelHashes(cfg)
return nil
}
func (m *Manager) RegisterChannel(name string, channel Channel) {
m.mu.Lock()
defer m.mu.Unlock()
@ -978,6 +1096,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]

View file

@ -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())

View file

@ -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
View 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
View 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])
}
}

View file

@ -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)
})
}

View file

@ -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,11 +648,28 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event
return
}
msgEvt := evt.Content.AsMessage()
if msgEvt == nil {
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.
if msgEvt.RelatesTo != nil && msgEvt.RelatesTo.GetReplaceID() != "" {
return
@ -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,

View file

@ -56,8 +56,9 @@ type PicoChannel struct {
*channels.BaseChannel
config config.PicoConfig
upgrader websocket.Upgrader
connections sync.Map // connID → *picoConn
connCount atomic.Int32
connections map[string]*picoConn // connID -> *picoConn
sessionConnections map[string]map[string]*picoConn // sessionID -> connID -> *picoConn
connsMu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
}
@ -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 {
for _, pc := range c.takeAllConnections() {
pc.close()
}
c.connections.Delete(key)
return true
})
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,12 +297,7 @@ 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
}
if pc.sessionID == sessionID {
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,
@ -223,8 +307,6 @@ func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
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)
if removed := c.removeConnection(pc.id); removed != nil {
logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{
"conn_id": pc.id,
"session_id": pc.sessionID,
"conn_id": removed.id,
"session_id": removed.sessionID,
})
}
}()
readTimeout := time.Duration(c.config.ReadTimeout) * time.Second

View 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
}

View file

@ -462,6 +462,17 @@ var mdV2SpecialChars = map[rune]bool{
// escapeMarkdownV2 escapes every MarkdownV2 special character in a plain-text
// segment. Already-escaped sequences (backslash + char) are forwarded verbatim.
// markdownToTelegramMarkdownV2 converts standard Markdown to Telegram MarkdownV2
// using the gomarkdown AST parser and the dual-mode telegramRenderer.
func markdownToTelegramMarkdownV2(text string) string {
if text == "" {
return ""
}
doc := parseMarkdownAST(text)
r := &telegramRenderer{mdv2: true}
return r.render(doc)
}
func escapeMarkdownV2(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)

View file

@ -42,23 +42,15 @@ var (
reInlineCode = regexp.MustCompile("`([^`]+)`")
)
// telegramHTTPTimeout is the HTTP client timeout for Telegram API requests.
// Long polling uses Timeout=30s on the API side; the HTTP client timeout
// must be longer to avoid canceling valid long-poll responses.
const telegramHTTPTimeout = 65 * time.Second
type TelegramChannel struct {
*channels.BaseChannel
bot *telego.Bot
bh *th.BotHandler
config *config.Config
chatIDs map[string]int64
dedupe *channels.MessageDeduplicator
ctx context.Context
cancel context.CancelFunc
lastActiveChatID string // composite chat ID of last received message
registerFunc func(context.Context, []commands.Definition) error
commandRegCancel context.CancelFunc
}
@ -67,46 +59,24 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
var opts []telego.BotOption
telegramCfg := cfg.Channels.Telegram
// Build the base transport (with optional proxy) and wrap it with
// resilientTransport for connection-failure detection and recovery logging.
var baseTransport http.RoundTripper
if telegramCfg.Proxy != "" {
proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
if parseErr != nil {
return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr)
}
baseTransport = &http.Transport{Proxy: http.ProxyURL(proxyURL)}
} else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
baseTransport = &http.Transport{Proxy: http.ProxyFromEnvironment}
} else {
baseTransport = http.DefaultTransport
}
ch := &TelegramChannel{
config: cfg,
chatIDs: make(map[string]int64),
dedupe: channels.NewMessageDeduplicator(1000),
}
transport := &resilientTransport{
base: baseTransport,
onFailure: func() {
logger.WarnC("telegram", "Polling connection lost, retrying...")
},
onRecover: func() {
logger.InfoC("telegram", "Polling connection recovered")
if chatID := ch.lastActiveChatID; chatID != "" {
go func() {
_ = ch.sendReconnectNotice(chatID)
}()
}
},
}
opts = append(opts, telego.WithHTTPClient(&http.Client{
Transport: transport,
Timeout: telegramHTTPTimeout,
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
},
}))
} else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
// Use environment proxy if configured
opts = append(opts, telego.WithHTTPClient(&http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
}))
}
if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
opts = append(opts, telego.WithAPIServer(baseURL))
@ -128,10 +98,12 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
)
ch.BaseChannel = base
ch.bot = bot
return ch, nil
return &TelegramChannel{
BaseChannel: base,
bot: bot,
config: cfg,
chatIDs: make(map[string]int64),
}, nil
}
func (c *TelegramChannel) Start(ctx context.Context) error {
@ -321,10 +293,6 @@ func (c *TelegramChannel) sendChunk(
}
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
// Don't retry on rate limit errors — they aren't parse failures.
if strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "Too Many Requests") {
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
logParseFailed(err, params.useMarkdownV2)
tgMsg.Text = params.mdFallback
@ -337,10 +305,17 @@ func (c *TelegramChannel) sendChunk(
return nil
}
// maxTypingDuration limits how long the typing indicator can run.
// Prevents endless typing when the LLM fails/hangs and preSend never invokes cancel.
// Matches channels.Manager's typingStopTTL (5 min) so behavior is consistent.
const maxTypingDuration = 5 * time.Minute
// StartTyping implements channels.TypingCapable.
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
// The returned stop function is idempotent and cancels the goroutine.
// The goroutine also exits automatically after maxTypingDuration if cancel is
// never called (e.g. when the LLM fails or times out without publishing).
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
cid, threadID, err := parseTelegramChatID(chatID)
if err != nil {
@ -354,12 +329,15 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
_ = c.bot.SendChatAction(ctx, action)
typingCtx, cancel := context.WithCancel(ctx)
// Cap lifetime so the goroutine cannot run indefinitely if cancel is never called
maxCtx, maxCancel := context.WithTimeout(typingCtx, maxTypingDuration)
go func() {
defer maxCancel()
ticker := time.NewTicker(4 * time.Second)
defer ticker.Stop()
for {
select {
case <-typingCtx.Done():
case <-maxCtx.Done():
return
case <-ticker.C:
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
@ -392,10 +370,6 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
}
_, err = c.bot.EditMessageText(ctx, editMsg)
if err != nil {
// Don't retry on rate limit errors — they aren't parse failures.
if strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "Too Many Requests") {
return err
}
logParseFailed(err, useMarkdownV2)
_, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content))
}
@ -403,6 +377,22 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
return err
}
// DeleteMessage implements channels.MessageDeleter.
func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
cid, _, err := parseTelegramChatID(chatID)
if err != nil {
return err
}
mid, err := strconv.Atoi(messageID)
if err != nil {
return err
}
return c.bot.DeleteMessage(ctx, &telego.DeleteMessageParams{
ChatID: tu.ID(cid),
MessageID: mid,
})
}
// SendPlaceholder implements channels.PlaceholderCapable.
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
// edited to the actual response via EditMessage (channels.MessageEditor).
@ -412,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 {
@ -570,21 +557,11 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return nil
}
// Deduplicate: Telegram may redeliver the same update after a timeout.
msgID := fmt.Sprintf("%d", message.MessageID)
if !c.dedupe.MarkMessageProcessed(msgID) {
logger.DebugCF("telegram", "Skipping duplicate message", map[string]any{
"message_id": msgID,
})
return nil
}
chatID := message.Chat.ID
c.chatIDs[platformID] = chatID
content := ""
mediaPaths := []string{}
hasMedia := message.Caption != "" || len(message.Photo) > 0 ||
message.Voice != nil || message.Audio != nil || message.Document != nil
chatIDStr := fmt.Sprintf("%d", chatID)
messageIDStr := fmt.Sprintf("%d", message.MessageID)
@ -605,68 +582,69 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return localPath // fallback: use raw path
}
// Fast path: text-only message (most common) — avoid Builder alloc
var content string
if !hasMedia {
if message.Text != "" {
content = message.Text
}
} else {
var cb strings.Builder
if message.Text != "" {
cb.WriteString(message.Text)
content += message.Text
}
if message.Caption != "" {
if cb.Len() > 0 {
cb.WriteByte('\n')
if content != "" {
content += "\n"
}
cb.WriteString(message.Caption)
content += message.Caption
}
if len(message.Photo) > 0 {
photo := message.Photo[len(message.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID)
if photoPath != "" {
mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg"))
if cb.Len() > 0 {
cb.WriteByte('\n')
if content != "" {
content += "\n"
}
cb.WriteString("[image: photo]")
content += "[image: photo]"
}
}
if message.Voice != nil {
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
if voicePath != "" {
mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
if cb.Len() > 0 {
cb.WriteByte('\n')
if content != "" {
content += "\n"
}
cb.WriteString("[voice]")
content += "[voice]"
}
}
if message.Audio != nil {
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
if audioPath != "" {
mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3"))
if cb.Len() > 0 {
cb.WriteByte('\n')
if content != "" {
content += "\n"
}
cb.WriteString("[audio]")
content += "[audio]"
}
}
if message.Document != nil {
docPath := c.downloadFile(ctx, message.Document.FileID, "")
if docPath != "" {
mediaPaths = append(mediaPaths, storeMedia(docPath, "document"))
if cb.Len() > 0 {
cb.WriteByte('\n')
if content != "" {
content += "\n"
}
cb.WriteString("[file]")
content += "[file]"
}
}
content = cb.String()
if content == "" && len(mediaPaths) == 0 {
return nil
}
if content == "" {
content = "[empty message]"
content = "[media only]"
}
// In group chats, apply unified group trigger filtering
@ -692,8 +670,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
}
c.lastActiveChatID = compositeChatID
logger.DebugCF("telegram", "Received message", map[string]any{
"sender_id": sender.CanonicalID,
"chat_id": compositeChatID,
@ -777,12 +753,11 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string)
}
func parseContent(text string, useMarkdownV2 bool) string {
if text == "" {
return ""
if useMarkdownV2 {
return markdownToTelegramMarkdownV2(text)
}
doc := parseMarkdownAST(text)
r := &telegramRenderer{mdv2: useMarkdownV2}
return r.render(doc)
return markdownToTelegramHTML(text)
}
// parseTelegramChatID splits "chatID/threadID" into its components.
@ -895,19 +870,6 @@ func isBotCommandEntityForThisBot(entityText, botUsername string) bool {
return strings.EqualFold(mentionUsername, botUsername)
}
// sendReconnectNotice sends a short notification to the given chat after
// the polling connection recovers from a failure.
func (c *TelegramChannel) sendReconnectNotice(compositeChatID string) error {
cid, threadID, err := parseTelegramChatID(compositeChatID)
if err != nil {
return err
}
msg := tu.Message(tu.ID(cid), "[system] Connection recovered — messages during the outage may have been delayed.")
msg.MessageThreadID = threadID
_, err = c.bot.SendMessage(c.ctx, msg)
return err
}
// stripBotMention removes the @bot mention from the content.
func (c *TelegramChannel) stripBotMention(content string) string {
botUsername := c.bot.Username()

View file

@ -15,7 +15,6 @@ func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
dedupe: channels.NewMessageDeduplicator(1000),
ctx: context.Background(),
}

View file

@ -71,29 +71,6 @@ func (c *TelegramChannel) SendDraft(ctx context.Context, chatID string, draftID
return nil
}
// DeleteMessage implements channels.MessageDeleter.
// It deletes a previously sent message by its platform message ID.
func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
cid, _, err := parseTelegramChatID(chatID)
if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
}
var mid int
if _, scanErr := fmt.Sscanf(messageID, "%d", &mid); scanErr != nil {
return fmt.Errorf("invalid message ID %s: %w", messageID, channels.ErrSendFailed)
}
return c.bot.DeleteMessage(ctx, &telego.DeleteMessageParams{
ChatID: telego.ChatID{ID: cid},
MessageID: mid,
})
}
// formatChatID formats a chat ID with optional thread ID as "chatID/threadID".
func formatChatID(chatID int64, threadID int) string {
if threadID != 0 {

View file

@ -51,7 +51,6 @@ func newGroupMentionOnlyChannel(t *testing.T, botUsername string) (*TelegramChan
),
bot: newTestTelegramBot(t, botUsername),
chatIDs: make(map[string]int64),
dedupe: channels.NewMessageDeduplicator(1000),
ctx: context.Background(),
}
return ch, messageBus

View file

@ -524,7 +524,6 @@ func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
dedupe: channels.NewMessageDeduplicator(1000),
ctx: context.Background(),
}
@ -566,7 +565,6 @@ func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) {
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
dedupe: channels.NewMessageDeduplicator(1000),
ctx: context.Background(),
}
@ -606,7 +604,6 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
dedupe: channels.NewMessageDeduplicator(1000),
ctx: context.Background(),
}
@ -644,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

View file

@ -1,558 +0,0 @@
package wecom
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
channels "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewWeComAIBotChannel(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())
}
})
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 TestWeComAIBotChannelStartStop(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()
// Test Start
if err := ch.Start(ctx); err != nil {
t.Fatalf("Failed to start channel: %v", err)
}
if !ch.IsRunning() {
t.Error("Expected channel to be running")
}
// Test Stop
if err := ch.Stop(ctx); err != nil {
t.Fatalf("Failed to stop channel: %v", err)
}
if ch.IsRunning() {
t.Error("Expected channel to be stopped")
}
}
func TestWeComAIBotChannelWebhookPath(t *testing.T) {
t.Run("default path", func(t *testing.T) {
cfg := config.WeComAIBotConfig{}
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) {
// Generate multiple IDs and check they are unique
ids := make(map[string]bool)
for i := 0; i < 100; i++ {
id := generateRandomID(10)
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)
plaintext := "Hello, World!"
receiveid := ""
// Encrypt — type-assert to the webhook-mode channel to access unexported helper
webhookCh, ok := ch.(*WeComAIBotChannel)
if !ok {
t.Fatal("Expected webhook-mode WeComAIBotChannel")
}
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")
}
// Verify signature using verifySignature function
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

View file

@ -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)
}
}
}
})
}

View file

@ -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 *channels.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: channels.NewMessageDeduplicator(1000),
}, 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

View file

@ -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 *channels.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: channels.NewMessageDeduplicator(1000),
}, 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)
}

View file

@ -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+"&timestamp="+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&timestamp=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&timestamp="+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+"&timestamp="+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+"&timestamp="+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&timestamp="+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+"&timestamp="+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+"&timestamp="+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")
}
}

View file

@ -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
}

View file

@ -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
View 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
}

View 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)
}

View 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
}

View 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)
}

View 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
View 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
}

View 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,
}
}

View file

@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"fmt"
"math/rand"
"os"
"path/filepath"
"strings"
@ -247,7 +248,7 @@ type AgentConfig struct {
}
type SubagentsConfig struct {
Enabled bool `json:"enabled,omitempty"` // Fork-only: gate orchestration
Enabled bool `json:"enabled,omitempty"`
AllowAgents []string `json:"allow_agents,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
}
@ -296,6 +297,11 @@ type SubTurnConfig struct {
ConcurrencyTimeoutSec int `json:"concurrency_timeout_sec" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_CONCURRENCY_TIMEOUT_SEC"`
}
type ToolFeedbackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
}
type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
@ -303,10 +309,13 @@ type AgentDefaults struct {
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
TaskReminderInterval int `json:"task_reminder_interval,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
OCR *OCRConfig `json:"ocr,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
@ -314,44 +323,14 @@ type AgentDefaults struct {
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
Routing *RoutingConfig `json:"routing,omitempty"`
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_"`
OCR *OCRConfig `json:"ocr,omitempty"`
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
}
// ToolFeedbackConfig controls whether tool execution details are sent to the
// chat channel as real-time feedback messages. When enabled, every tool call
// produces a short notification with the tool name and its parameters.
type ToolFeedbackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
}
// OCRConfig configures the external OCR command for PDF text extraction.
type OCRConfig struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
Timeout int `json:"timeout,omitempty"`
ReadingOrder string `json:"reading_order,omitempty"`
}
// GetOCRTimeout returns the configured timeout or default (600s = 10min).
func (c *OCRConfig) GetOCRTimeout() int {
if c != nil && c.Timeout > 0 {
return c.Timeout
}
return 600
}
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 {
@ -391,9 +370,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"`
@ -413,8 +390,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 {
@ -443,9 +432,8 @@ type TelegramConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
Streaming StreamingConfig `json:"streaming,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
SubagentThreadID int `json:"subagent_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_SUBAGENT_THREAD_ID"`
HeartbeatThreadID int `json:"heartbeat_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_HEARTBEAT_THREAD_ID"`
HeartbeatThreadID int `json:"heartbeat_thread_id" env:"PICOCLAW_CHANNELS_TELEGRAM_HEARTBEAT_THREAD_ID"`
SubagentThreadID int `json:"subagent_thread_id" env:"PICOCLAW_CHANNELS_TELEGRAM_SUBAGENT_THREAD_ID"`
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
secDirty bool
}
@ -633,6 +621,8 @@ type MatrixConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
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
@ -708,136 +698,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
}
// 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"`
Enabled bool `json:"enabled" env:"ENABLED"`
BotID string `json:"bot_id" env:"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"`
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 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
}
@ -992,13 +874,16 @@ type ModelConfig struct {
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
Stream *bool `json:"stream,omitempty"` // Use SSE streaming (default: protocol-dependent)
ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body
// from security
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
@ -1009,6 +894,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 == "" {
@ -1354,6 +1244,10 @@ func (c *ClawHubRegistryConfig) SetAuthToken(token string) {
type MCPServerConfig struct {
// Enabled indicates whether this MCP server is active
Enabled bool `json:"enabled"`
// Deferred controls whether this server's tools are registered as hidden (deferred/discovery mode).
// When nil, the global Discovery.Enabled setting applies.
// When explicitly set to true or false, it overrides the global setting for this server only.
Deferred *bool `json:"deferred,omitempty"`
// Command is the executable to run (e.g., "npx", "python", "/path/to/server")
Command string `json:"command"`
// Args are the arguments to pass to the command
@ -1368,10 +1262,8 @@ type MCPServerConfig struct {
URL string `json:"url,omitempty"`
// Headers are HTTP headers to send with requests (sse/http only)
Headers map[string]string `json:"headers,omitempty"`
// Timeout is the maximum duration in seconds for tool calls to this server (default: 60)
// Timeout is the per-server timeout in seconds (0 means default 60s)
Timeout int `json:"timeout,omitempty"`
// Deferred controls whether this server's tools are registered as hidden (deferred/discovery mode).
Deferred *bool `json:"deferred,omitempty"`
}
// MCPConfig defines configuration for all MCP servers
@ -1447,17 +1339,29 @@ func LoadConfig(path string) (*Config, error) {
if err != nil {
return nil, err
}
// Load security configuration
securityPath := securityPath(path)
sec, err := loadSecurityConfig(securityPath)
// 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)
}
// 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)
// 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)
@ -1654,39 +1558,10 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
cfg.Channels.OneBot.accessToken = sec.Channels.OneBot.AccessToken
}
// Handle WeCom token and encoding key
// Handle WeCom bot secret
if sec.Channels.WeCom != nil {
if sec.Channels.WeCom.Token != "" {
cfg.Channels.WeCom.token = sec.Channels.WeCom.Token
}
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
}
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
}
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
if sec.Channels.WeCom.Secret != "" {
cfg.Channels.WeCom.secret = sec.Channels.WeCom.Secret
}
}
@ -1719,6 +1594,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)
@ -1805,6 +1702,14 @@ func (c *Config) migrateChannelConfigs() {
}
func SaveConfig(path string, cfg *Config) error {
if cfg.security == nil {
logger.Errorf("config %#v", *cfg)
if len(cfg.ModelList) > 0 {
logger.Errorf("model[0] %#v", cfg.ModelList[0])
}
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 {
@ -1902,27 +1807,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(),
@ -1980,7 +1868,20 @@ 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
}
@ -2025,7 +1926,7 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
// findMatches finds all ModelConfig entries with the given model_name.
func (c *Config) findMatches(modelName string) []*ModelConfig {
matches := make([]*ModelConfig, 0, 4)
var matches []*ModelConfig
for i := range c.ModelList {
if c.ModelList[i].ModelName == modelName {
matches = append(matches, c.ModelList[i])
@ -2034,19 +1935,6 @@ func (c *Config) findMatches(modelName string) []*ModelConfig {
return matches
}
// FindModelConfigByRef finds a ModelConfig entry whose Model field matches
// "protocol/modelID" (case-insensitive). Used by the fallback chain to look up
// cross-provider candidates in model_list.
func (c *Config) FindModelConfigByRef(protocol, modelID string) *ModelConfig {
target := strings.ToLower(protocol + "/" + modelID)
for i := range c.ModelList {
if strings.ToLower(c.ModelList[i].Model) == target {
return c.ModelList[i]
}
}
return nil
}
// ValidateModelList validates all ModelConfig entries in the model_list.
// It checks that each model config is valid.
// Note: Multiple entries with the same model_name are allowed for load balancing.
@ -2211,6 +2099,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)

59
pkg/config/config_ext.go Normal file
View file

@ -0,0 +1,59 @@
// Fork-specific config extensions for picoclaw.
// Adds OCRConfig, FindModelConfigByRef, and other helpers
// that are not present in the upstream config package.
package config
import "strings"
// OCRConfig holds configuration for PDF OCR processing.
type OCRConfig struct {
Command string `json:"command" env:"PICOCLAW_OCR_COMMAND"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
ReadingOrder string `json:"reading_order,omitempty" env:"PICOCLAW_OCR_READING_ORDER"`
Timeout int `json:"timeout,omitempty" env:"PICOCLAW_OCR_TIMEOUT"` // seconds
}
// GetOCRTimeout returns the OCR timeout in seconds, defaulting to 300 (5 minutes).
func (c *OCRConfig) GetOCRTimeout() int {
if c.Timeout > 0 {
return c.Timeout
}
return 300
}
// FindModelConfigByRef searches model_list for a ModelConfig matching the
// given provider/model reference. The providerName is matched against the
// "protocol/" prefix of ModelConfig.Model, and modelName is matched against
// either ModelConfig.ModelName or the model portion after the slash.
// Returns nil if no match is found.
func (c *Config) FindModelConfigByRef(providerName, modelName string) *ModelConfig {
providerName = strings.ToLower(providerName)
modelName = strings.ToLower(modelName)
for i := range c.ModelList {
mc := c.ModelList[i]
// Match by model_name (user-facing alias)
if strings.ToLower(mc.ModelName) == modelName {
return mc
}
// Match by "provider/model" in the Model field
parts := strings.SplitN(mc.Model, "/", 2)
if len(parts) == 2 {
mcProvider := strings.ToLower(parts[0])
mcModel := strings.ToLower(parts[1])
if mcProvider == providerName && mcModel == modelName {
return mc
}
}
// Match by full "provider/model" as modelName
if strings.ToLower(mc.Model) == providerName+"/"+modelName {
return mc
}
}
return nil
}

View file

@ -97,9 +97,7 @@ type channelsConfigV0 struct {
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"`
WeCom wecomConfigV0 `json:"wecom" envPrefix:"PICOCLAW_CHANNELS_WECOM_"`
Pico picoConfigV0 `json:"pico"`
IRC ircConfigV0 `json:"irc"`
}
@ -117,8 +115,6 @@ 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()
@ -136,8 +132,6 @@ func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity)
LINE: line,
OneBot: onebot,
WeCom: wecom,
WeComApp: wecomapp,
WeComAIBot: wecomaibot,
Pico: pico,
IRC: irc,
}, ChannelsSecurity{
@ -152,8 +146,6 @@ func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity)
LINE: lineSecurity,
OneBot: onebotSecurity,
WeCom: wecomSecurity,
WeComApp: wecomappSecurity,
WeComAIBot: wecomaibotSecurity,
Pico: picoSecurity,
IRC: ircSecurity,
}
@ -473,38 +465,31 @@ func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, *OneBotSecurity) {
}
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) {
var sec *WeComSecurity
if v.Token != "" || v.EncodingAESKey != "" {
sec = &WeComSecurity{
Token: v.Token,
EncodingAESKey: v.EncodingAESKey,
}
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,
BotID: v.BotID,
secret: v.Secret,
WebSocketURL: v.WebSocketURL,
SendThinkingMessage: v.SendThinkingMessage,
AllowFrom: v.AllowFrom,
ReplyTimeout: v.ReplyTimeout,
GroupTrigger: v.GroupTrigger,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
@ -537,81 +522,6 @@ func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, *WeixinSecurity) {
}, sec
}
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) {
var sec *WeComAppSecurity
if v.CorpSecret != "" || v.Token != "" || v.EncodingAESKey != "" {
sec = &WeComAppSecurity{
CorpSecret: v.CorpSecret,
Token: v.Token,
EncodingAESKey: v.EncodingAESKey,
}
}
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,
}, sec
}
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) {
var sec *WeComAIBotSecurity
if v.Token != "" || v.Secret != "" || v.EncodingAESKey != "" {
sec = &WeComAIBotSecurity{
Token: v.Token,
Secret: v.Secret,
EncodingAESKey: v.EncodingAESKey,
}
}
return WeComAIBotConfig{
Enabled: v.Enabled,
WebhookPath: v.WebhookPath,
AllowFrom: v.AllowFrom,
ReplyTimeout: v.ReplyTimeout,
MaxSteps: v.MaxSteps,
WelcomeMessage: v.WelcomeMessage,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
type picoConfigV0 struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
@ -923,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"`
@ -1014,11 +925,34 @@ func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, *GLMSearchSecu
}, 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) {
brave, braveSecurity := v.Brave.ToBraveConfig()
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,
@ -1028,6 +962,7 @@ func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity)
Perplexity: perplexity,
SearXNG: v.SearXNG,
GLMSearch: glmSearch,
BaiduSearch: baiduSearch,
PreferNative: v.PreferNative,
Proxy: v.Proxy,
FetchLimitBytes: v.FetchLimitBytes,
@ -1038,6 +973,7 @@ func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity)
Tavily: tavilySecurity,
Perplexity: perplexitySecurity,
GLMSearch: glmSearchSecurity,
BaiduSearch: baiduSearchSecurity,
}
}

View file

@ -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()
@ -1372,8 +1462,7 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
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{Token: "wecom-token", EncodingAESKey: "wecom-aes-key"},
WeComApp: &WeComAppSecurity{CorpSecret: "wecom-app-secret", Token: "wecom-app-token"},
WeCom: &WeComSecurity{Secret: "wecom-secret"},
Pico: &PicoSecurity{Token: "pico-token-abc123"},
IRC: &IRCSecurity{
Password: "irc-password",

View file

@ -42,6 +42,7 @@ func DefaultConfig() *Config {
Enabled: true,
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,
@ -130,31 +133,10 @@ func DefaultConfig() *Config {
},
WeCom: WeComConfig{
Enabled: false,
WebhookURL: "",
WebhookHost: "0.0.0.0",
WebhookPort: 18793,
WebhookPath: "/webhook/wecom",
BotID: "",
WebSocketURL: "wss://openws.work.weixin.qq.com",
SendThinkingMessage: true,
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,
},
Weixin: WeixinConfig{
Enabled: false,

View file

@ -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,26 +51,33 @@ 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
{
@ -72,35 +92,43 @@ File: ~/.picoclaw/config.json
{
"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"
"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": "ref:model_list.claude-sonnet-4.6.api_key"
"api_base": "https://api.anthropic.com/v1"
// api_key is automatically loaded from .security.yml
}
],
"channels": {
"telegram": {
"enabled": true,
"token": "ref:channels.telegram.token"
"enabled": true
// token is automatically loaded from .security.yml
},
"discord": {
"enabled": true,
"token": "ref:channels.discord.token"
"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
}
}
}
@ -111,7 +139,7 @@ File: ~/.picoclaw/config.json
## 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

View 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

View file

@ -80,8 +80,6 @@ type ChannelsSecurity struct {
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"`
}
@ -131,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 {
@ -334,17 +319,9 @@ func mergeChannelsSecurity(dst, src *ChannelsSecurity) {
if src.OneBot != nil && src.OneBot.AccessToken != "" {
dst.OneBot = src.OneBot
}
if src.WeCom != nil && (src.WeCom.Token != "" || src.WeCom.EncodingAESKey != "") {
if src.WeCom != nil && src.WeCom.Secret != "" {
dst.WeCom = src.WeCom
}
if src.WeComApp != nil &&
(src.WeComApp.CorpSecret != "" || src.WeComApp.Token != "" || src.WeComApp.EncodingAESKey != "") {
dst.WeComApp = src.WeComApp
}
if src.WeComAIBot != nil &&
(src.WeComAIBot.Secret != "" || src.WeComAIBot.Token != "" || src.WeComAIBot.EncodingAESKey != "") {
dst.WeComAIBot = src.WeComAIBot
}
if src.Pico != nil && src.Pico.Token != "" {
dst.Pico = src.Pico
}

View file

@ -43,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,
@ -52,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"
}
}
}
@ -78,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)
})
}
@ -240,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
@ -315,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:
@ -409,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())

View file

@ -0,0 +1,24 @@
//go:build !mipsle && !netbsd && !(freebsd && arm)
package gateway
import (
// Matrix currently pulls in mautrix crypto and modernc sqlite transitively.
//
// We exclude it on:
// - linux/mipsle: mautrix crypto falls back to libolm when the `goolm` build
// tag is unavailable, and modernc.org/sqlite/modernc.org/libc also lacks a
// working build path for our mipsle + softfloat target.
// - netbsd/*: modernc.org/sqlite v1.46.1 fails to compile due to broken
// generated mutex code on NetBSD (for example sqlite_netbsd_amd64.go calls
// mu.enter/mu.leave, but the generated mutex type does not define them).
// - freebsd/arm: modernc.org/libc v1.67.6 fails to compile due to broken
// generated 32-bit FreeBSD code (size_t/uint64 and int32/int64 mismatches
// in libc_freebsd.go).
//
// This means Matrix is currently unavailable on those targets. The proper
// long-term fix is to split Matrix basic support from its E2EE/sqlite-backed
// crypto path, or to upgrade/replace the upstream sqlite dependency once the
// affected targets are supported.
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
)

View file

@ -3,15 +3,11 @@ package gateway
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@ -24,7 +20,6 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
_ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
@ -41,13 +36,8 @@ import (
"github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/miniapp"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/research"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/stats"
"github.com/sipeed/picoclaw/pkg/tailscale"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/voice"
)
@ -65,12 +55,12 @@ const (
type services struct {
CronService *cron.CronService
HeartbeatService *heartbeat.HeartbeatService
ResearchStore *research.ResearchStore
ResearchFocus *research.FocusTracker
MediaStore media.MediaStore
ChannelManager *channels.Manager
DeviceService *devices.Service
HealthServer *health.Server
manualReloadChan chan struct{}
reloading atomic.Bool
}
type startupBlockedProvider struct {
@ -92,7 +82,7 @@ func (p *startupBlockedProvider) GetDefaultModel() string {
}
// Run starts the gateway runtime using the configuration loaded from configPath.
func Run(debug bool, homePath, configPath string, orchestration bool, enableStats bool, allowEmptyStartup bool) error {
func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error {
panicPath := filepath.Join(homePath, logPath, panicFile)
panicFunc, err := logger.InitPanic(panicPath)
if err != nil {
@ -121,27 +111,19 @@ func Run(debug bool, homePath, configPath string, orchestration bool, enableStat
if err != nil {
return fmt.Errorf("error creating provider: %w", err)
}
if orchestration {
cfg.Agents.Defaults.Orchestration = true
}
if modelID != "" {
cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, enableStats)
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo()
toolsInfo := startupInfo["tools"].(map[string]any)
skillsInfo := startupInfo["skills"].(map[string]any)
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
if wsProvider, ok := toolsInfo["web_search_provider"].(string); ok {
fmt.Printf(" • Web search: %s\n", wsProvider)
} else {
fmt.Println(" • Web search: disabled")
}
fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"])
logger.InfoCF("agent", "Agent initialized",
@ -156,6 +138,25 @@ func Run(debug bool, homePath, configPath string, orchestration bool, enableStat
return err
}
// Setup manual reload channel for /reload endpoint
manualReloadChan := make(chan struct{}, 1)
runningServices.manualReloadChan = manualReloadChan
reloadTrigger := func() error {
if !runningServices.reloading.CompareAndSwap(false, true) {
return fmt.Errorf("reload already in progress")
}
select {
case manualReloadChan <- struct{}{}:
return nil
default:
// Should not happen, but reset flag if channel is full
runningServices.reloading.Store(false)
return fmt.Errorf("reload already queued")
}
}
runningServices.HealthServer.SetReloadFunc(reloadTrigger)
agentLoop.SetReloadFunc(reloadTrigger)
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop")
@ -182,12 +183,48 @@ func Run(debug bool, homePath, configPath string, orchestration bool, enableStat
shutdownGateway(runningServices, agentLoop, provider, true)
return nil
case newCfg := <-configReloadChan:
err := handleConfigReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
if !runningServices.reloading.CompareAndSwap(false, true) {
logger.Warn("Config reload skipped: another reload is in progress")
continue
}
err := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
if err != nil {
logger.Errorf("Config reload failed: %v", err)
}
case <-manualReloadChan:
logger.Info("Manual reload triggered via /reload endpoint")
newCfg, err := config.LoadConfig(configPath)
if err != nil {
logger.Errorf("Error loading config for manual reload: %v", err)
runningServices.reloading.Store(false)
continue
}
if err = newCfg.ValidateModelList(); err != nil {
logger.Errorf("Config validation failed: %v", err)
runningServices.reloading.Store(false)
continue
}
err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
if err != nil {
logger.Errorf("Manual reload failed: %v", err)
} else {
logger.Info("Manual reload completed successfully")
}
}
}
}
func executeReload(
ctx context.Context,
agentLoop *agent.AgentLoop,
newCfg *config.Config,
provider *providers.LLMProvider,
runningServices *services,
msgBus *bus.MessageBus,
allowEmptyStartup bool,
) error {
defer runningServices.reloading.Store(false)
return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup)
}
func createStartupProvider(
@ -237,18 +274,13 @@ func setupAndStartServices(
cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled,
)
runningServices.HeartbeatService.SetHeartbeatThreadID(cfg.Channels.Telegram.HeartbeatThreadID)
runningServices.HeartbeatService.SetBus(msgBus)
agentLoop.SetHeartbeatThreadUpdater(runningServices.HeartbeatService.SetHeartbeatThreadID)
runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(agentLoop))
if err = runningServices.HeartbeatService.Start(); err != nil {
return nil, fmt.Errorf("error starting heartbeat service: %w", err)
}
fmt.Println("✓ Heartbeat service started")
// Reset heartbeat suppression when a real user message arrives
agentLoop.OnUserMessage = runningServices.HeartbeatService.ResetSuppression
runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
@ -289,118 +321,11 @@ func setupAndStartServices(
return nil, fmt.Errorf("error starting channels: %w", err)
}
// Mini App setup: register routes and determine TLS mode
useTLS := false
var tlsCert, tlsKey string
var miniappNotifier *miniapp.StateNotifier
if cfg.Channels.Telegram.Enabled {
webAppURL := cfg.Channels.Telegram.WebAppURL
if webAppURL == "" {
// Auto-detect Tailscale hostname and build the WebAppURL
hostname, tsErr := tailscale.DetectHostname()
if tsErr != nil {
logger.InfoCF(
"miniapp",
"Tailscale not available, Mini App disabled",
map[string]any{"error": tsErr.Error()},
)
} else {
hostPort := net.JoinHostPort(hostname, strconv.Itoa(cfg.Gateway.Port))
webAppURL = "https://" + hostPort + "/miniapp"
cfg.Channels.Telegram.WebAppURL = webAppURL
}
}
// When the URL is HTTPS, fetch a TLS certificate from Tailscale so
// the server can actually serve over TLS. This covers both the
// auto-detected case above and a manually configured https:// URL.
if strings.HasPrefix(webAppURL, "https://") {
hostname, tsErr := tailscale.DetectHostname()
if tsErr != nil {
logger.ErrorCF("miniapp", "HTTPS URL configured but Tailscale not available",
map[string]any{"error": tsErr.Error()})
} else {
certDir := filepath.Join(cfg.WorkspacePath(), "state", "certs")
certFile, keyFile, certErr := tailscale.FetchCert(hostname, certDir)
if certErr != nil {
logger.ErrorCF("miniapp", "Failed to fetch TLS cert", map[string]any{"error": certErr.Error()})
} else {
tlsCert, tlsKey = certFile, keyFile
useTLS = true
}
}
}
if webAppURL != "" {
dataProvider := &agentLoopDataProvider{loop: agentLoop, workspace: cfg.WorkspacePath()}
sender := &telegramCommandSender{bus: msgBus}
miniappNotifier = miniapp.NewStateNotifier()
handler := miniapp.NewHandler(
dataProvider,
sender,
cfg.Channels.Telegram.Token(),
miniappNotifier,
cfg.Channels.Telegram.AllowFrom,
cfg.WorkspacePath(),
)
handler.SetCacheMutator(dataProvider)
agentLoop.OnStateChange = miniappNotifier.Notify
if b := agentLoop.GetOrchBroadcaster(); b != nil {
handler.SetOrchBroadcaster(b)
}
handler.RegisterRoutes(runningServices.HealthServer.Mux())
// Register dev preview tool for all agents
devPreviewTool := tools.NewDevPreviewTool(handler)
agentLoop.RegisterTool(devPreviewTool)
// Research store + tool registration
researchStore, rsErr := research.OpenResearchStore(
filepath.Join(cfg.WorkspacePath(), "research.db"),
cfg.WorkspacePath(),
)
if rsErr != nil {
logger.ErrorCF("research", "Failed to open research store", map[string]any{"error": rsErr.Error()})
} else {
focusTracker := research.NewFocusTracker()
agentLoop.RegisterTool(tools.NewResearchTool(researchStore, cfg.WorkspacePath(), focusTracker))
handler.SetResearchStore(researchStore)
handler.SetResearchFocus(focusTracker)
runningServices.ResearchStore = researchStore
runningServices.ResearchFocus = focusTracker
runningServices.HeartbeatService.SetResearchStore(researchStore)
fmt.Println("✓ Research store initialized")
}
fmt.Printf("✓ Mini App registered at %s\n", webAppURL)
}
}
// HealthServer is the single HTTP listener. Channel webhooks and health
// checkers were registered on its mux via SetupHTTPServer.
go func() {
var serverErr error
if useTLS {
serverErr = runningServices.HealthServer.StartTLS(tlsCert, tlsKey)
} else {
serverErr = runningServices.HealthServer.Start()
}
if serverErr != nil && serverErr != http.ErrServerClosed {
logger.ErrorCF("health", "Health server error", map[string]any{"error": serverErr.Error()})
}
}()
if useTLS {
fmt.Printf(
"✓ Health endpoints available at https://%s:%d/health and /ready (TLS)\n",
"✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n",
cfg.Gateway.Host,
cfg.Gateway.Port,
)
} else {
fmt.Printf(
"✓ Health endpoints available at http://%s:%d/health and /ready\n",
cfg.Gateway.Host, cfg.Gateway.Port,
)
}
stateManager := state.NewManager(cfg.WorkspacePath())
runningServices.DeviceService = devices.NewService(devices.Config{
@ -417,16 +342,14 @@ func setupAndStartServices(
return runningServices, nil
}
func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration) {
func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration, isReload bool) {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer shutdownCancel()
if runningServices.ChannelManager != nil {
// reload should not stop channel manager
if !isReload && runningServices.ChannelManager != nil {
runningServices.ChannelManager.StopAll(shutdownCtx)
}
if runningServices.HealthServer != nil {
runningServices.HealthServer.Stop(shutdownCtx)
}
if runningServices.DeviceService != nil {
runningServices.DeviceService.Stop()
}
@ -453,7 +376,7 @@ func shutdownGateway(
cp.Close()
}
stopAndCleanupServices(runningServices, gracefulShutdownTimeout)
stopAndCleanupServices(runningServices, gracefulShutdownTimeout, false)
agentLoop.Stop()
agentLoop.Close()
@ -477,7 +400,7 @@ func handleConfigReload(
logger.Infof(" New model is '%s', recreating provider...", newModel)
logger.Info(" Stopping all services...")
stopAndCleanupServices(runningServices, serviceShutdownTimeout)
stopAndCleanupServices(runningServices, serviceShutdownTimeout, true)
newProvider, newModelID, err := createStartupProvider(newCfg, allowEmptyStartup)
if err != nil {
@ -551,9 +474,6 @@ func restartServices(
cfg.Heartbeat.Enabled,
)
runningServices.HeartbeatService.SetBus(msgBus)
if runningServices.ResearchStore != nil {
runningServices.HeartbeatService.SetResearchStore(runningServices.ResearchStore)
}
runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(al))
if err = runningServices.HeartbeatService.Start(); err != nil {
return fmt.Errorf("error restarting heartbeat service: %w", err)
@ -584,24 +504,16 @@ func restartServices(
}
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
// Reuse existing HealthServer to preserve reloadFunc
if runningServices.HealthServer == nil {
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
}
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
return fmt.Errorf("error restarting channels: %w", err)
if err = runningServices.ChannelManager.Reload(context.Background(), cfg); err != nil {
return fmt.Errorf("error reload channels: %w", err)
}
// Start the HealthServer listener (single HTTP server for all routes)
go func() {
if sErr := runningServices.HealthServer.Start(); sErr != nil && sErr != http.ErrServerClosed {
logger.ErrorCF("health", "Health server error", map[string]any{"error": sErr.Error()})
}
}()
fmt.Printf(
" ✓ Channels restarted, health endpoints at http://%s:%d/health and ready\n",
cfg.Gateway.Host,
cfg.Gateway.Port,
)
fmt.Println(" ✓ Channels restarted.")
stateManager := state.NewManager(cfg.WorkspacePath())
runningServices.DeviceService = devices.NewService(devices.Config{
@ -757,320 +669,3 @@ func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, ch
return tools.SilentResult(response)
}
}
// agentLoopDataProvider adapts AgentLoop to the miniapp.DataProvider interface.
type agentLoopDataProvider struct {
loop *agent.AgentLoop
workspace string
gitReposCache []miniapp.GitRepoSummary
gitReposCacheAt time.Time
gitDetailCache map[string]gitDetailEntry
}
type gitDetailEntry struct {
info miniapp.GitInfo
at time.Time
}
const gitCacheTTL = 5 * time.Minute
func (p *agentLoopDataProvider) ListSkills() []skills.SkillInfo {
return p.loop.ListSkills()
}
func (p *agentLoopDataProvider) GetPlanInfo() miniapp.PlanInfo {
hasPlan, status, currentPhase, totalPhases, display, memory := p.loop.GetPlanInfo()
// Convert agent.PlanPhase -> miniapp.PlanPhase
agentPhases := p.loop.GetPlanPhases()
phases := make([]miniapp.PlanPhase, 0, len(agentPhases))
for _, ap := range agentPhases {
steps := make([]miniapp.PlanStep, 0, len(ap.Steps))
for _, as := range ap.Steps {
steps = append(steps, miniapp.PlanStep{
Index: as.Index,
Description: as.Description,
Done: as.Done,
})
}
phases = append(phases, miniapp.PlanPhase{
Number: ap.Number,
Title: ap.Title,
Steps: steps,
})
}
return miniapp.PlanInfo{
HasPlan: hasPlan,
Status: status,
CurrentPhase: currentPhase,
TotalPhases: totalPhases,
Display: display,
Phases: phases,
Memory: memory,
}
}
func (p *agentLoopDataProvider) GetSessionStats() *stats.Stats {
return p.loop.GetSessionStats()
}
func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
entries := p.loop.GetActiveSessions()
result := make([]miniapp.SessionInfo, len(entries))
for i, e := range entries {
result[i] = miniapp.SessionInfo{
SessionKey: e.SessionKey,
Channel: e.Channel,
ChatID: e.ChatID,
TouchDir: e.TouchDir,
ProjectPath: e.ProjectPath,
Purpose: e.Purpose,
Branch: e.Branch,
LastSeenAt: e.LastSeenAt.Format(time.RFC3339),
AgeSec: int(time.Since(e.LastSeenAt).Seconds()),
}
}
return result
}
func (p *agentLoopDataProvider) GetSessionGraph() *miniapp.SessionGraphData {
nodes := p.loop.GetSessionGraph()
if len(nodes) == 0 {
return &miniapp.SessionGraphData{
Nodes: []miniapp.SessionGraphNode{},
Edges: []miniapp.SessionGraphEdge{},
}
}
gNodes := make([]miniapp.SessionGraphNode, 0, len(nodes))
var edges []miniapp.SessionGraphEdge
for _, n := range nodes {
sk := gatewayShortKey(n.Key)
label := n.Label
if label == "" {
label = sk
}
gNodes = append(gNodes, miniapp.SessionGraphNode{
Key: n.Key,
ShortKey: sk,
Label: label,
Status: n.Status,
TurnCount: n.TurnCount,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
UpdatedAt: n.UpdatedAt.Format(time.RFC3339),
Summary: n.Summary,
ForkTurnID: n.ForkTurnID,
})
if n.ParentKey != "" {
edges = append(edges, miniapp.SessionGraphEdge{
From: n.ParentKey,
To: n.Key,
ForkTurnID: n.ForkTurnID,
})
}
}
if edges == nil {
edges = []miniapp.SessionGraphEdge{}
}
return &miniapp.SessionGraphData{Nodes: gNodes, Edges: edges}
}
// gatewayShortKey abbreviates long session keys for display.
func gatewayShortKey(key string) string {
parts := strings.Split(key, ":")
if len(parts) > 2 {
return strings.Join(parts[2:], ":")
}
return key
}
func (p *agentLoopDataProvider) GetContextInfo() miniapp.ContextInfo {
workDir, planWorkDir, workspace, bootstrap := p.loop.GetContextInfo()
files := make([]miniapp.BootstrapFileInfo, len(bootstrap))
for i, b := range bootstrap {
files[i] = miniapp.BootstrapFileInfo{Name: b.Name, Path: b.Path, Scope: b.Scope}
}
return miniapp.ContextInfo{
WorkDir: workDir,
PlanWorkDir: planWorkDir,
Workspace: workspace,
Bootstrap: files,
}
}
func (p *agentLoopDataProvider) GetSystemPrompt() string {
return p.loop.GetSystemPrompt()
}
func (p *agentLoopDataProvider) ListMediaCache(entryType string) []miniapp.MediaCacheEntry {
raw := p.loop.ListMediaCache(entryType)
if len(raw) == 0 {
return nil
}
entries := make([]miniapp.MediaCacheEntry, len(raw))
for i, e := range raw {
entries[i] = miniapp.MediaCacheEntry{
Hash: e.Hash,
Type: e.Type,
Result: e.Result,
FilePath: e.FilePath,
Pages: e.Pages,
CreatedAt: e.CreatedAt,
AccessedAt: e.AccessedAt,
}
}
return entries
}
func (p *agentLoopDataProvider) DeleteMediaCache(hash string) error {
return p.loop.DeleteMediaCache(hash)
}
func (p *agentLoopDataProvider) DeleteAllMediaCache() (int64, error) {
return p.loop.DeleteAllMediaCache()
}
func (p *agentLoopDataProvider) GetGitRepos() []miniapp.GitRepoSummary {
if time.Since(p.gitReposCacheAt) < gitCacheTTL {
return p.gitReposCache
}
if p.workspace == "" {
return nil
}
// Find workspace's own git root to exclude it
workspaceGitRoot := ""
if out, err := exec.Command("git", "-C", p.workspace, "rev-parse", "--show-toplevel").Output(); err == nil {
workspaceGitRoot = strings.TrimSpace(string(out))
}
// Scan for .git dirs up to 2 levels deep under workspace
seen := map[string]bool{}
var repos []miniapp.GitRepoSummary
for _, pattern := range []string{
filepath.Join(p.workspace, "*", ".git"),
filepath.Join(p.workspace, "*", "*", ".git"),
} {
matches, _ := filepath.Glob(pattern)
for _, m := range matches {
repoDir := filepath.Dir(m)
if repoDir == workspaceGitRoot || seen[repoDir] {
continue
}
seen[repoDir] = true
name := filepath.Base(repoDir)
branch := ""
out, err := exec.Command("git", "-C", repoDir, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err == nil {
branch = strings.TrimSpace(string(out))
}
repos = append(repos, miniapp.GitRepoSummary{Name: name, Branch: branch})
}
}
p.gitReposCache = repos
p.gitReposCacheAt = time.Now()
return repos
}
func (p *agentLoopDataProvider) GetGitRepoDetail(name string) miniapp.GitInfo {
// Path traversal prevention
if name == "" || filepath.Base(name) != name {
return miniapp.GitInfo{Name: name}
}
// Check detail cache
if p.gitDetailCache != nil {
if entry, ok := p.gitDetailCache[name]; ok && time.Since(entry.at) < gitCacheTTL {
return entry.info
}
}
if p.workspace == "" {
return miniapp.GitInfo{Name: name}
}
// Resolve repo path: try 1-level and 2-level deep
var repoDir string
for _, pattern := range []string{
filepath.Join(p.workspace, name, ".git"),
filepath.Join(p.workspace, "*", name, ".git"),
} {
matches, _ := filepath.Glob(pattern)
if len(matches) > 0 {
repoDir = filepath.Dir(matches[0])
break
}
}
if repoDir == "" {
return miniapp.GitInfo{Name: name}
}
info := collectGitRepoInfo(repoDir)
if p.gitDetailCache == nil {
p.gitDetailCache = make(map[string]gitDetailEntry)
}
p.gitDetailCache[name] = gitDetailEntry{info: info, at: time.Now()}
return info
}
func collectGitRepoInfo(gitRoot string) miniapp.GitInfo {
info := miniapp.GitInfo{Name: filepath.Base(gitRoot)}
// Current branch
out, err := exec.Command("git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err == nil {
info.Branch = strings.TrimSpace(string(out))
}
// Recent commits (20 entries)
out, err = exec.Command("git", "-C", gitRoot, "log", "--pretty=format:%h\x1f%s\x1f%an\x1f%cr", "-20").Output()
if err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
parts := strings.SplitN(line, "\x1f", 4)
if len(parts) == 4 {
info.Commits = append(info.Commits, miniapp.GitCommit{
Hash: parts[0], Subject: parts[1], Author: parts[2], Date: parts[3],
})
}
}
}
// Modified/untracked files
out, err = exec.Command("git", "-C", gitRoot, "status", "--porcelain").Output()
if err == nil && len(out) > 0 {
for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
if len(line) < 4 {
continue
}
info.Modified = append(info.Modified, miniapp.GitChange{
Status: strings.TrimSpace(line[:2]),
Path: line[3:],
})
}
}
return info
}
// telegramCommandSender injects Mini App commands into the message bus.
type telegramCommandSender struct {
bus *bus.MessageBus
}
func (s *telegramCommandSender) SendCommand(senderID, chatID, command string) {
s.bus.PublishInbound(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: senderID,
ChatID: chatID,
Content: command,
Metadata: map[string]string{
"source": "webapp",
},
})
}

View file

@ -7,6 +7,7 @@
package heartbeat
import (
"context"
"fmt"
"os"
"path/filepath"
@ -18,7 +19,6 @@ import (
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/research"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
)
@ -26,7 +26,6 @@ import (
const (
minIntervalMinutes = 5
defaultIntervalMinutes = 30
suppressionTTL = 24 * time.Hour
userTasksMarker = "Add your heartbeat tasks below this line:"
)
@ -41,13 +40,10 @@ type HeartbeatService struct {
bus *bus.MessageBus
state *state.Manager
handler HeartbeatHandler
researchStore *research.ResearchStore
interval time.Duration
enabled bool
mu sync.RWMutex
stopChan chan struct{}
lastNotifiedAt time.Time // when a non-silent result was last sent to user
heartbeatThreadID int
}
// NewHeartbeatService creates a new heartbeat service
@ -83,29 +79,6 @@ func (hs *HeartbeatService) SetHandler(handler HeartbeatHandler) {
hs.handler = handler
}
// SetHeartbeatThreadID configures Telegram thread routing for heartbeat messages.
func (hs *HeartbeatService) SetHeartbeatThreadID(threadID int) {
hs.mu.Lock()
defer hs.mu.Unlock()
hs.heartbeatThreadID = threadID
}
// SetResearchStore injects the research store for heartbeat-driven research.
func (hs *HeartbeatService) SetResearchStore(store *research.ResearchStore) {
hs.mu.Lock()
defer hs.mu.Unlock()
hs.researchStore = store
}
// ResetSuppression clears the notification suppression so the next
// non-silent heartbeat result will be delivered to the user again.
// Typically called when a user message arrives.
func (hs *HeartbeatService) ResetSuppression() {
hs.mu.Lock()
defer hs.mu.Unlock()
hs.lastNotifiedAt = time.Time{}
}
// Start begins the heartbeat service
func (hs *HeartbeatService) Start() error {
hs.mu.Lock()
@ -200,8 +173,12 @@ func (hs *HeartbeatService) executeHeartbeat() {
return
}
channel, chatID, reason := hs.resolveHeartbeatTarget()
hs.logInfof("Resolved channel: %s, chatID: %s (%s)", channel, chatID, reason)
// Get last channel info for context
lastChannel := hs.state.GetLastChannel()
channel, chatID := hs.parseLastChannel(lastChannel)
// Debug log for channel resolution
hs.logInfof("Resolved channel: %s, chatID: %s (from lastChannel: %s)", channel, chatID, lastChannel)
result := handler(prompt, channel, chatID)
@ -231,86 +208,16 @@ func (hs *HeartbeatService) executeHeartbeat() {
return
}
// Suppress duplicate notifications within the TTL window
hs.mu.RLock()
suppressed := !hs.lastNotifiedAt.IsZero() && time.Since(hs.lastNotifiedAt) < suppressionTTL
hs.mu.RUnlock()
if suppressed {
hs.logInfof("Heartbeat suppressed (already notified user recently)")
return
// Send result to user
if result.ForUser != "" {
hs.sendResponse(result.ForUser)
} else if result.ForLLM != "" {
hs.sendResponse(result.ForLLM)
}
// Skip sendResponse — the task completion message in runAgentLoop already
// includes the LLM response, so sending here would create a duplicate bubble.
hs.mu.Lock()
hs.lastNotifiedAt = time.Now()
hs.mu.Unlock()
hs.logInfof("Heartbeat completed: %s", result.ForLLM)
}
func (hs *HeartbeatService) resolveHeartbeatTarget() (channel, chatID, reason string) {
if explicit := hs.state.GetHeartbeatTarget(); explicit != "" {
if ch, cid := hs.parseTarget(explicit); ch != "" && cid != "" {
return ch, cid, fmt.Sprintf("explicit heartbeat target: %s", explicit)
}
hs.logErrorf("Invalid explicit heartbeat target: %s", explicit)
}
if threadID := hs.telegramHeartbeatThreadID(); threadID > 0 {
if ch, cid, src := hs.resolveTelegramThreadTarget(threadID); ch != "" && cid != "" {
return ch, cid, src
}
}
lastChannel := hs.state.GetLastChannel()
channel, chatID = hs.parseLastChannel(lastChannel)
return channel, chatID, fmt.Sprintf("fallback last channel: %s", lastChannel)
}
func (hs *HeartbeatService) resolveTelegramThreadTarget(threadID int) (channel, chatID, reason string) {
candidates := []struct {
value string
reason string
}{
{value: hs.state.GetLastHeartbeatTarget(), reason: "last heartbeat target"},
{value: hs.state.GetLastChannel(), reason: "last channel"},
}
for _, candidate := range candidates {
ch, cid := hs.parseTarget(candidate.value)
if ch != "telegram" || cid == "" {
continue
}
return ch,
withTelegramThread(cid, threadID),
fmt.Sprintf("telegram heartbeat_thread_id from %s", candidate.reason)
}
return "", "", ""
}
func (hs *HeartbeatService) telegramHeartbeatThreadID() int {
hs.mu.RLock()
defer hs.mu.RUnlock()
return hs.heartbeatThreadID
}
func withTelegramThread(chatID string, threadID int) string {
if threadID <= 0 || chatID == "" {
return chatID
}
baseChatID := chatID
if slash := strings.Index(baseChatID, "/"); slash >= 0 {
baseChatID = baseChatID[:slash]
}
if baseChatID == "" {
return chatID
}
return fmt.Sprintf("%s/%d", baseChatID, threadID)
}
// buildPrompt builds the heartbeat prompt from HEARTBEAT.md
func (hs *HeartbeatService) buildPrompt() string {
heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md")
@ -331,7 +238,6 @@ func (hs *HeartbeatService) buildPrompt() string {
}
now := time.Now().Format("2006-01-02 15:04:05")
researchCtx := hs.buildResearchContext()
return fmt.Sprintf(`# Heartbeat Check
Current time: %s
@ -341,91 +247,7 @@ Review the following tasks and execute any necessary actions using available ski
If there is nothing that requires attention, respond ONLY with: HEARTBEAT_OK
%s
%s`, now, content, researchCtx)
}
const (
maxResearchTasks = 3
maxFindingsPerTask = 10
maxSummaryLen = 200
)
// buildResearchContext generates a prompt section for incremental research progress.
// Only includes tasks that are "due" based on their interval setting.
func (hs *HeartbeatService) buildResearchContext() string {
hs.mu.RLock()
store := hs.researchStore
hs.mu.RUnlock()
if store == nil {
return ""
}
tasks, err := store.ListDueTasks(maxResearchTasks)
if err != nil {
hs.logErrorf("Failed to list due research tasks: %v", err)
return ""
}
if len(tasks) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n## Research Tasks (Incremental Progress)\n\n")
b.WriteString("You have research tasks due for progress. For each task below:\n")
b.WriteString("1. If pending, set status to 'active' first\n")
b.WriteString("2. Use web_search to find new information, then add_finding to record it\n")
fmt.Fprintf(&b, "3. **Web search budget: %d calls total this heartbeat** — use them wisely\n",
research.DefaultHeartbeatSearchQuota)
b.WriteString("4. Do NOT set status to 'completed' — research progresses incrementally across heartbeats\n\n")
for _, task := range tasks {
fmt.Fprintf(&b, "### %s [%s] (id: %s)\n", task.Title, task.Status, task.ID)
fmt.Fprintf(&b, "Interval: %s", task.Interval)
if !task.LastResearchedAt.IsZero() {
fmt.Fprintf(&b, " | Last researched: %s", task.LastResearchedAt.Format("2006-01-02 15:04"))
}
b.WriteString("\n")
if task.Description != "" {
fmt.Fprintf(&b, "Description: %s\n", task.Description)
}
docs, err := store.ListDocuments(task.ID)
if err != nil {
b.WriteString("(error loading findings)\n\n")
continue
}
if len(docs) == 0 {
b.WriteString("No findings yet — start researching this topic.\n\n")
continue
}
shown := docs
if len(shown) > maxFindingsPerTask {
shown = shown[:maxFindingsPerTask]
}
fmt.Fprintf(&b, "Existing findings (%d total):\n", len(docs))
for _, d := range shown {
summary := d.Summary
if len(summary) > maxSummaryLen {
summary = summary[:maxSummaryLen] + "..."
}
fmt.Fprintf(&b, "- [%d] %s", d.Seq, d.Title)
if summary != "" {
fmt.Fprintf(&b, " — %s", summary)
}
b.WriteString("\n")
}
if len(docs) > maxFindingsPerTask {
fmt.Fprintf(&b, " ... and %d more findings\n", len(docs)-maxFindingsPerTask)
}
b.WriteString("\nAdd NEW findings that build on and extend the above. Do not repeat existing findings.\n\n")
}
return b.String()
`, now, content)
}
// createDefaultHeartbeatTemplate creates the default HEARTBEAT.md file
@ -489,24 +311,59 @@ func heartbeatHasUserTasks(content string) bool {
return false
}
// sendResponse sends the heartbeat response to the last channel
func (hs *HeartbeatService) sendResponse(response string) {
hs.mu.RLock()
msgBus := hs.bus
hs.mu.RUnlock()
if msgBus == nil {
hs.logInfof("No message bus configured, heartbeat result not sent")
return
}
// Get last channel from state
lastChannel := hs.state.GetLastChannel()
if lastChannel == "" {
hs.logInfof("No last channel recorded, heartbeat result not sent")
return
}
platform, userID := hs.parseLastChannel(lastChannel)
// Skip internal channels that can't receive messages
if platform == "" || userID == "" {
return
}
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: platform,
ChatID: userID,
Content: response,
})
hs.logInfof("Heartbeat result sent to %s", platform)
}
// parseLastChannel parses the last channel string into platform and userID.
// Returns empty strings for invalid or internal channels.
func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, userID string) {
return hs.parseTarget(lastChannel)
}
func (hs *HeartbeatService) parseTarget(target string) (platform, userID string) {
if target == "" {
if lastChannel == "" {
return "", ""
}
parts := strings.SplitN(target, ":", 2)
// Parse channel format: "platform:user_id" (e.g., "telegram:123456")
parts := strings.SplitN(lastChannel, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
hs.logErrorf("Invalid heartbeat target format: %s", target)
hs.logErrorf("Invalid last channel format: %s", lastChannel)
return "", ""
}
platform, userID = parts[0], parts[1]
// Skip internal channels
if constants.IsInternalChannel(platform) {
hs.logInfof("Skipping internal channel: %s", platform)
return "", ""

View file

@ -8,8 +8,6 @@ import (
"github.com/sipeed/picoclaw/pkg/tools"
)
// TestExecuteHeartbeat_NoSendResponse verifies that heartbeat results
// do not trigger sendResponse (dedup: response is included in task status instead).
// TestExecuteHeartbeat_NoSendResponse verifies that heartbeat results
// do not trigger sendResponse (dedup: response is included in task status instead).
func TestExecuteHeartbeat_NoSendResponse(t *testing.T) {
@ -22,7 +20,9 @@ func TestExecuteHeartbeat_NoSendResponse(t *testing.T) {
hs := NewHeartbeatService(tmpDir, 30, true)
hs.stopChan = make(chan struct{})
var handlerCalled bool
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
handlerCalled = true
return &tools.ToolResult{
ForUser: "Task result for user",
ForLLM: "Task result for LLM",
@ -36,69 +36,7 @@ func TestExecuteHeartbeat_NoSendResponse(t *testing.T) {
hs.executeHeartbeat()
hs.mu.RLock()
notified := !hs.lastNotifiedAt.IsZero()
hs.mu.RUnlock()
if !notified {
t.Error("Expected lastNotifiedAt to be set after heartbeat completion")
}
}
func TestExecuteHeartbeat_TargetPriority_ExplicitTarget(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs.stopChan = make(chan struct{})
hs.SetHeartbeatThreadID(77)
if err := hs.state.SetHeartbeatTarget("slack:C12345/999"); err != nil {
t.Fatalf("SetHeartbeatTarget failed: %v", err)
}
if err := hs.state.SetLastHeartbeatTarget("telegram:-100500"); err != nil {
t.Fatalf("SetLastHeartbeatTarget failed: %v", err)
}
var gotChannel, gotChatID string
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
gotChannel, gotChatID = channel, chatID
return tools.SilentResult("ok")
})
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat()
if gotChannel != "slack" || gotChatID != "C12345/999" {
t.Fatalf("handler target = %s:%s, want slack:C12345/999", gotChannel, gotChatID)
}
}
func TestExecuteHeartbeat_TargetPriority_TelegramThread(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs.stopChan = make(chan struct{})
hs.SetHeartbeatThreadID(77)
if err := hs.state.SetLastHeartbeatTarget("telegram:-100500"); err != nil {
t.Fatalf("SetLastHeartbeatTarget failed: %v", err)
}
var gotChannel, gotChatID string
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
gotChannel, gotChatID = channel, chatID
return tools.SilentResult("ok")
})
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat()
if gotChannel != "telegram" || gotChatID != "-100500/77" {
t.Fatalf("handler target = %s:%s, want telegram:-100500/77", gotChannel, gotChatID)
if !handlerCalled {
t.Error("Expected handler to be called after heartbeat execution")
}
}

View file

@ -2,6 +2,7 @@ package logger
import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
@ -173,6 +174,12 @@ func SetConsoleLevel(level LogLevel) {
logger = logger.Level(level)
}
func DisableConsole() {
mu.Lock()
defer mu.Unlock()
logger = zerolog.New(io.Discard).With().Timestamp().Caller().Logger()
}
func GetLevel() LogLevel {
mu.RLock()
defer mu.RUnlock()
@ -243,6 +250,22 @@ func DisableFileLogging() {
fileLogger = zerolog.Logger{}
}
func ConfigureFromEnv() {
if logFile := os.Getenv("PICOCLAW_LOG_FILE"); logFile != "" {
if strings.HasPrefix(logFile, "~/") {
if home := os.Getenv("HOME"); home != "" {
logFile = filepath.Join(home, logFile[2:])
}
}
if err := EnableFileLogging(logFile); err != nil {
fmt.Fprintf(os.Stderr, "failed to enable file logging: %v\n", err)
} else {
DisableConsole()
}
}
}
func getCallerSkip() int {
for i := 2; i < 15; i++ {
pc, file, _, ok := runtime.Caller(i)

View file

@ -4,7 +4,11 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/rs/zerolog"
)
@ -254,3 +258,40 @@ func TestAppendFields_ErrorUsesErrorString(t *testing.T) {
t.Fatalf("error field = %#v, want %q", got["error"], "transcription request failed")
}
}
func TestDisableConsole(t *testing.T) {
DisableConsole()
Info("this should go to nowhere")
}
func TestConfigureFromEnv(t *testing.T) {
home := os.Getenv("HOME")
if home == "" {
t.Skip("HOME not set")
}
tmpFile := "/tmp/picoclaw_test_log_" + fmt.Sprintf("%d", time.Now().UnixNano())
defer os.Remove(tmpFile)
os.Setenv("PICOCLAW_LOG_FILE", tmpFile)
defer os.Unsetenv("PICOCLAW_LOG_FILE")
ConfigureFromEnv()
if logFile == nil {
t.Error("expected log file to be set")
}
Info("test message")
os.Setenv("PICOCLAW_LOG_FILE", "~/test_log")
ConfigureFromEnv()
expanded := filepath.Join(home, "test_log")
defer os.Remove(expanded)
}
func TestConfigureFromEnvNoEnv(t *testing.T) {
os.Unsetenv("PICOCLAW_LOG_FILE")
ConfigureFromEnv()
}

View file

@ -26,5 +26,4 @@ var supportedChannels = map[string]bool{
"line": true,
"onebot": true,
"wecom": true,
"wecom_app": true,
}

View file

@ -84,6 +84,15 @@ var (
substr("messages.1.content.1.tool_use.id"),
substr("invalid request format"),
}
contextOverflowPatterns = []errorPattern{
rxp(`context[_ ]?length[_ ]?exceeded`),
rxp(`context[_ ]?window[_ ]?exceeded`),
substr("maximum context length"),
substr("token limit"),
substr("too many tokens"),
substr("prompt is too long"),
substr("request too large"),
}
imageDimensionPatterns = []errorPattern{
rxp(`image dimensions exceed max`),
@ -201,6 +210,9 @@ func classifyByMessage(msg string) FailoverReason {
if matchesAny(msg, formatPatterns) {
return FailoverFormat
}
if matchesAny(msg, contextOverflowPatterns) {
return FailoverContextOverflow
}
return ""
}

View file

@ -221,6 +221,30 @@ func TestClassifyError_ImageDimensionError(t *testing.T) {
}
}
func TestClassifyError_ContextOverflowPatterns(t *testing.T) {
patterns := []string{
"context_length_exceeded",
"context_window_exceeded",
"maximum context length",
"token limit",
"too many tokens",
"prompt is too long",
"request too large",
}
for _, msg := range patterns {
err := errors.New(msg)
result := ClassifyError(err, "openai", "gpt-4")
if result == nil {
t.Errorf("pattern %q: expected non-nil", msg)
continue
}
if result.Reason != FailoverContextOverflow {
t.Errorf("pattern %q: reason = %q, want context_overflow", msg, result.Reason)
}
}
}
func TestClassifyError_ImageSizeError(t *testing.T) {
err := errors.New("image exceeds 20 mb limit")
result := ClassifyError(err, "openai", "gpt-4o")
@ -265,6 +289,7 @@ func TestFailoverError_IsRetriable(t *testing.T) {
{FailoverTimeout, true},
{FailoverOverloaded, true},
{FailoverFormat, false},
{FailoverContextOverflow, false},
{FailoverUnknown, true},
}

View file

@ -0,0 +1,42 @@
// Fork-specific provider factory extensions.
package providers
import (
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
)
// CreateProviderByName looks up a model in the config's model_list by provider
// name (case-insensitive) and creates an LLM provider from it.
// This is used as a legacy fallback when resolving providers for fallback models.
func CreateProviderByName(cfg *config.Config, providerName string) (LLMProvider, error) {
providerLower := strings.ToLower(providerName)
// Search model_list for a matching provider name (model_name or protocol prefix)
for _, mc := range cfg.ModelList {
// Match by model_name
if strings.ToLower(mc.ModelName) == providerLower {
p, _, err := CreateProviderFromConfig(mc)
if err != nil {
return nil, fmt.Errorf("failed to create provider %q: %w", providerName, err)
}
return p, nil
}
// Match by protocol prefix in Model field (e.g., "openai/gpt-4o" matches "openai")
if parts := strings.SplitN(mc.Model, "/", 2); len(parts) == 2 {
if strings.ToLower(parts[0]) == providerLower {
p, _, err := CreateProviderFromConfig(mc)
if err != nil {
return nil, fmt.Errorf("failed to create provider %q: %w", providerName, err)
}
return p, nil
}
}
}
return nil, fmt.Errorf("provider %q not found in model_list", providerName)
}

View file

@ -91,7 +91,14 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
return NewHTTPProviderFromConfig(cfg, apiBase), modelID, nil
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
cfg.APIKey(),
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
case "azure", "azure-openai":
// Azure OpenAI uses deployment-based URLs, api-key header auth,
@ -151,7 +158,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding":
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey() == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@ -160,7 +167,14 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
return NewHTTPProviderFromConfig(cfg, apiBase), modelID, nil
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
cfg.APIKey(),
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
case "minimax":
// Minimax requires reasoning_split: true in the request body
@ -171,13 +185,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
if cfg.ExtraBody == nil {
cfg.ExtraBody = make(map[string]any)
extraBody := cfg.ExtraBody
if extraBody == nil {
extraBody = make(map[string]any)
}
if _, ok := cfg.ExtraBody["reasoning_split"]; !ok {
cfg.ExtraBody["reasoning_split"] = true
if _, ok := extraBody["reasoning_split"]; !ok {
extraBody["reasoning_split"] = true
}
return NewHTTPProviderFromConfig(cfg, apiBase), modelID, nil
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
cfg.APIKey(),
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
extraBody,
), modelID, nil
case "anthropic":
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
@ -196,7 +218,14 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if cfg.APIKey() == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
}
return NewHTTPProviderFromConfig(cfg, apiBase), modelID, nil
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
cfg.APIKey(),
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
case "anthropic-messages":
// Anthropic Messages API with native format (HTTP-based, no SDK)
@ -265,22 +294,6 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
}
// CreateProviderByName creates a provider by looking up a model_name in the
// Config.ModelList. This replaces the legacy ProvidersConfig lookup.
func CreateProviderByName(cfg *config.Config, name string) (LLMProvider, error) {
name = strings.ToLower(name)
for _, mc := range cfg.ModelList {
if strings.ToLower(mc.ModelName) == name {
provider, _, err := CreateProviderFromConfig(mc)
if err != nil {
return nil, err
}
return provider, nil
}
}
return nil, fmt.Errorf("unknown provider %q (not found in model_list)", name)
}
// getDefaultAPIBase returns the default API base URL for a given protocol.
func getDefaultAPIBase(protocol string) string {
switch protocol {
@ -336,6 +349,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://api.longcat.chat/openai"
case "modelscope":
return "https://api-inference.modelscope.cn/v1"
case "mimo":
return "https://api.xiaomimimo.com/v1"
default:
return ""
}

View file

@ -123,6 +123,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"ollama", "ollama"},
{"longcat", "longcat"},
{"modelscope", "modelscope"},
{"mimo", "mimo"},
}
for _, tt := range tests {
@ -252,6 +253,35 @@ func TestGetDefaultAPIBase_Novita(t *testing.T) {
}
}
func TestCreateProviderFromConfig_Mimo(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-mimo",
Model: "mimo/mimo-v2-pro",
APIBase: "https://api.xiaomimimo.com/v1",
}
cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "mimo-v2-pro" {
t.Errorf("modelID = %q, want %q", modelID, "mimo-v2-pro")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestGetDefaultAPIBase_Mimo(t *testing.T) {
if got := getDefaultAPIBase("mimo"); got != "https://api.xiaomimimo.com/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "mimo", got, "https://api.xiaomimimo.com/v1")
}
}
func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-anthropic",

View file

@ -10,9 +10,7 @@ import (
"context"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers/openai_compat"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
type HTTPProvider struct {
@ -46,22 +44,6 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
}
}
// NewHTTPProviderFromConfig creates an HTTPProvider from a ModelConfig,
// honoring all optional fields including stream.
func NewHTTPProviderFromConfig(cfg *config.ModelConfig, apiBase string) *HTTPProvider {
opts := []openai_compat.Option{
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout) * time.Second),
openai_compat.WithExtraBody(cfg.ExtraBody),
}
if cfg.Stream != nil && *cfg.Stream {
opts = append(opts, openai_compat.WithStream(true))
}
return &HTTPProvider{
delegate: openai_compat.NewProvider(cfg.APIKey(), apiBase, cfg.Proxy, opts...),
}
}
func (p *HTTPProvider) Chat(
ctx context.Context,
messages []Message,
@ -72,20 +54,17 @@ func (p *HTTPProvider) Chat(
return p.delegate.Chat(ctx, messages, tools, model, options)
}
// CanStream returns true when SSE streaming is enabled.
func (p *HTTPProvider) CanStream() bool {
return p.delegate.CanStream()
}
// ChatStream opens an SSE connection and returns a channel of StreamEvent.
// ChatStream implements providers.CallbackStreamingProvider by delegating to the
// OpenAI-compatible streaming endpoint (SSE with stream: true).
func (p *HTTPProvider) ChatStream(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (<-chan protocoltypes.StreamEvent, error) {
return p.delegate.ChatStream(ctx, messages, tools, model, options)
onChunk func(accumulated string),
) (*LLMResponse, error) {
return p.delegate.ChatStream(ctx, messages, tools, model, options, onChunk)
}
func (p *HTTPProvider) GetDefaultModel() string {

View file

@ -11,9 +11,9 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/providers/common"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
@ -33,32 +33,23 @@ type (
type Provider struct {
apiKey string
apiBase string
endpointPath string // API path appended to apiBase (default: "/chat/completions")
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
stream bool // Use SSE streaming internally (accumulates into a single LLMResponse)
httpClient *http.Client
extraBody map[string]any // Additional fields to inject into request body
// Rate limiting: minimum interval between consecutive API requests.
// Shared across all goroutines using this provider instance.
mu sync.Mutex
lastRequestAt time.Time
minInterval time.Duration
endpointPath string // Override the default "/chat/completions" path
stream bool // When true, Chat uses streaming SSE parsing
}
// Option is a functional option for configuring a Provider.
type Option func(*Provider)
const defaultRequestTimeout = 120 * time.Second
const defaultRequestTimeout = common.DefaultRequestTimeout
// WithMaxTokensField sets the field name for max tokens (e.g., "max_completion_tokens").
func WithMaxTokensField(maxTokensField string) Option {
return func(p *Provider) {
p.maxTokensField = maxTokensField
}
}
// WithRequestTimeout overrides the HTTP client timeout.
func WithRequestTimeout(timeout time.Duration) Option {
return func(p *Provider) {
if timeout > 0 {
@ -67,60 +58,36 @@ func WithRequestTimeout(timeout time.Duration) Option {
}
}
// WithStream enables SSE streaming mode.
func WithStream(stream bool) Option {
return func(p *Provider) {
p.stream = stream
if stream && p.httpClient.Timeout == defaultRequestTimeout {
p.httpClient.Timeout = 5 * time.Minute
}
}
}
// WithMinInterval sets the minimum interval between consecutive API requests.
// This prevents rate limit errors when many subagents share the same provider.
func WithMinInterval(d time.Duration) Option {
return func(p *Provider) {
p.minInterval = d
}
}
// WithEndpointPath sets the API path appended to apiBase (default: "/chat/completions").
func WithEndpointPath(path string) Option {
return func(p *Provider) {
if path != "" {
p.endpointPath = path
}
}
}
func WithExtraBody(extraBody map[string]any) Option {
return func(p *Provider) {
p.extraBody = extraBody
}
}
// WithEndpointPath overrides the default "/chat/completions" path.
func WithEndpointPath(path string) Option {
return func(p *Provider) {
p.endpointPath = path
}
}
// WithStream makes Chat use streaming SSE parsing (stream: true in the request body).
func WithStream(enabled bool) Option {
return func(p *Provider) {
p.stream = enabled
}
}
// CanStream returns whether this provider was configured for streaming via WithStream.
func (p *Provider) CanStream() bool {
return p.stream
}
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
client := &http.Client{
Timeout: defaultRequestTimeout,
}
if proxy != "" {
parsed, err := url.Parse(proxy)
if err == nil {
client.Transport = &http.Transport{
Proxy: http.ProxyURL(parsed),
}
} else {
log.Printf("openai_compat: invalid proxy URL %q: %v", proxy, err)
}
}
p := &Provider{
apiKey: apiKey,
apiBase: strings.TrimRight(apiBase, "/"),
endpointPath: "/chat/completions",
httpClient: client,
httpClient: common.NewHTTPClient(proxy),
}
for _, opt := range opts {
@ -149,27 +116,24 @@ func NewProviderWithMaxTokensFieldAndTimeout(
)
}
// streamBufferSize is the channel buffer size for ChatStream events.
const streamBufferSize = 32
// buildHTTPRequest constructs a ready-to-send *http.Request for the chat API.
func (p *Provider) buildHTTPRequest(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
stream bool,
) (*http.Request, error) {
if p.apiBase == "" {
return nil, fmt.Errorf("API base not configured")
// chatURL returns the full URL for the chat endpoint, respecting any custom endpointPath.
func (p *Provider) chatURL() string {
path := p.endpointPath
if path == "" {
path = "/chat/completions"
}
return p.apiBase + path
}
// buildRequestBody constructs the common request body for Chat and ChatStream.
func (p *Provider) buildRequestBody(
messages []Message, tools []ToolDefinition, model string, options map[string]any,
) map[string]any {
model = normalizeModel(model, p.apiBase)
requestBody := map[string]any{
"model": model,
"messages": serializeMessages(messages),
"messages": common.SerializeMessages(messages),
}
// When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview.
@ -180,7 +144,7 @@ func (p *Provider) buildHTTPRequest(
requestBody["tool_choice"] = "auto"
}
if maxTokens, ok := asInt(options["max_tokens"]); ok {
if maxTokens, ok := common.AsInt(options["max_tokens"]); ok {
fieldName := p.maxTokensField
if fieldName == "" {
lowerModel := strings.ToLower(model)
@ -194,7 +158,7 @@ func (p *Provider) buildHTTPRequest(
requestBody[fieldName] = maxTokens
}
if temperature, ok := asFloat(options["temperature"]); ok {
if temperature, ok := common.AsFloat(options["temperature"]); ok {
lowerModel := strings.ToLower(model)
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
requestBody["temperature"] = 1.0
@ -203,14 +167,8 @@ func (p *Provider) buildHTTPRequest(
}
}
if stream {
requestBody["stream"] = true
}
// Prompt caching: pass a stable cache key so OpenAI can bucket requests
// with the same key and reuse prefix KV cache across calls.
// The key is typically the agent ID -- stable per agent, shared across requests.
// See: https://platform.openai.com/docs/guides/prompt-caching
// Prompt caching is only supported by OpenAI-native endpoints.
// Non-OpenAI providers reject unknown fields with 422 errors.
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
@ -225,41 +183,7 @@ func (p *Provider) buildHTTPRequest(
requestBody[k] = v
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+p.endpointPath, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
return req, nil
}
// waitForInterval enforces the minimum interval between consecutive API requests.
// It sleeps if needed, then records the current time as the last request time.
func (p *Provider) waitForInterval() {
if p.minInterval <= 0 {
return
}
p.mu.Lock()
if !p.lastRequestAt.IsZero() {
elapsed := time.Since(p.lastRequestAt)
if wait := p.minInterval - elapsed; wait > 0 {
p.mu.Unlock()
time.Sleep(wait)
p.mu.Lock()
}
}
p.lastRequestAt = time.Now()
p.mu.Unlock()
return requestBody
}
func (p *Provider) Chat(
@ -269,22 +193,31 @@ func (p *Provider) Chat(
model string,
options map[string]any,
) (*LLMResponse, error) {
// When streaming is enabled, delegate to ChatStream + AccumulateStream
// so that the SSE→channel path is always exercised.
// When stream mode is enabled via WithStream, delegate to ChatStream.
if p.stream {
ch, err := p.ChatStream(ctx, messages, tools, model, options)
if err != nil {
return nil, err
}
return AccumulateStream(ch)
return p.ChatStream(ctx, messages, tools, model, options, nil)
}
req, err := p.buildHTTPRequest(ctx, messages, tools, model, options, false)
if err != nil {
return nil, err
if p.apiBase == "" {
return nil, fmt.Errorf("API base not configured")
}
p.waitForInterval()
requestBody := p.buildRequestBody(messages, tools, model, options)
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", p.chatURL(), bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
resp, err := p.httpClient.Do(req)
if err != nil {
@ -292,471 +225,200 @@ func (p *Provider) Chat(
}
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
// Non-200: read a prefix to tell HTML error page apart from JSON error body.
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
if readErr != nil {
return nil, fmt.Errorf("failed to read response: %w", readErr)
}
if looksLikeHTML(body, contentType) {
return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase)
}
return nil, fmt.Errorf(
"API request failed:\n Status: %d\n Body: %s",
resp.StatusCode,
responsePreview(body, 128),
)
return nil, common.HandleErrorResponse(resp, p.apiBase)
}
// Peek without consuming so the full stream reaches the JSON decoder.
reader := bufio.NewReader(resp.Body)
prefix, err := reader.Peek(256) // io.EOF/ErrBufferFull are normal; only real errors abort
if err != nil && err != io.EOF && err != bufio.ErrBufferFull {
return nil, fmt.Errorf("failed to inspect response: %w", err)
}
if looksLikeHTML(prefix, contentType) {
return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase)
}
out, err := parseResponse(reader)
if err != nil {
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
}
return out, nil
return common.ReadAndParseResponse(resp, p.apiBase)
}
// CanStream returns true when this provider is configured for SSE streaming.
func (p *Provider) CanStream() bool {
return p.stream
}
// ChatStream opens an SSE connection and returns a channel of StreamEvent.
// The channel is closed when the stream ends or an error occurs.
// Canceling ctx will abort the HTTP request and close the channel.
// ChatStream implements streaming via OpenAI-compatible SSE (stream: true).
// onChunk receives the accumulated text so far on each text delta.
func (p *Provider) ChatStream(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (<-chan protocoltypes.StreamEvent, error) {
req, err := p.buildHTTPRequest(ctx, messages, tools, model, options, true)
if err != nil {
return nil, err
onChunk func(accumulated string),
) (*LLMResponse, error) {
if p.apiBase == "" {
return nil, fmt.Errorf("API base not configured")
}
p.waitForInterval()
requestBody := p.buildRequestBody(messages, tools, model, options)
requestBody["stream"] = true
resp, err := p.httpClient.Do(req) //nolint:bodyclose // closed in goroutine or error path below
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", p.chatURL(), bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
// Use a client without Timeout for streaming — the http.Client.Timeout covers
// the entire request lifecycle including body reads, which would kill long streams.
// Context cancellation still provides the safety net.
streamClient := &http.Client{Transport: p.httpClient.Transport}
resp, err := streamClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
return nil, common.HandleErrorResponse(resp, p.apiBase)
}
ch := make(chan protocoltypes.StreamEvent, streamBufferSize)
go func() {
defer resp.Body.Close()
defer close(ch)
readSSEIntoChannel(ctx, resp.Body, ch)
}()
return ch, nil
return parseStreamResponse(ctx, resp.Body, onChunk)
}
// readSSEIntoChannel reads SSE lines from r and sends StreamEvent values on ch.
// It returns when the stream ends, an error occurs, or ctx is canceled.
func readSSEIntoChannel(ctx context.Context, r io.Reader, ch chan<- protocoltypes.StreamEvent) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
// parseStreamResponse parses an OpenAI-compatible SSE stream.
func parseStreamResponse(
ctx context.Context,
reader io.Reader,
onChunk func(accumulated string),
) (*LLMResponse, error) {
var textContent strings.Builder
var finishReason string
var usage *UsageInfo
// Tool call assembly: OpenAI streams tool calls as incremental deltas
type toolAccum struct {
id string
name string
argsJSON strings.Builder
}
activeTools := map[int]*toolAccum{}
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) // 1MB initial, 10MB max
for scanner.Scan() {
// Check for context cancellation between lines.
select {
case <-ctx.Done():
return
default:
// Check for context cancellation between chunks
if err := ctx.Err(); err != nil {
return nil, err
}
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
return
break
}
var chunk streamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // skip malformed chunks
}
ev := protocoltypes.StreamEvent{}
if chunk.Usage != nil {
ev.Usage = chunk.Usage
}
if len(chunk.Choices) > 0 {
choice := chunk.Choices[0]
ev.ContentDelta = choice.Delta.Content
ev.ReasoningDelta = choice.Delta.ReasoningContent
if choice.FinishReason != "" {
ev.FinishReason = choice.FinishReason
}
for _, tc := range choice.Delta.ToolCalls {
delta := protocoltypes.StreamToolCallDelta{
Index: tc.Index,
ID: tc.ID,
}
if tc.Function != nil {
delta.Name = tc.Function.Name
delta.ArgumentsDelta = tc.Function.Arguments
}
ev.ToolCallDeltas = append(ev.ToolCallDeltas, delta)
}
}
select {
case ch <- ev:
case <-ctx.Done():
return
}
}
if err := scanner.Err(); err != nil {
select {
case ch <- protocoltypes.StreamEvent{Err: fmt.Errorf("reading stream: %w", err)}:
case <-ctx.Done():
}
}
}
// AccumulateStream drains a StreamEvent channel and returns a complete LLMResponse.
func AccumulateStream(ch <-chan protocoltypes.StreamEvent) (*LLMResponse, error) {
var content strings.Builder
var reasoning strings.Builder
var toolCalls []streamToolCallAcc
var finishReason string
var usage *UsageInfo
for ev := range ch {
if ev.Err != nil {
return nil, ev.Err
}
if ev.ContentDelta != "" {
content.WriteString(ev.ContentDelta)
}
if ev.ReasoningDelta != "" {
reasoning.WriteString(ev.ReasoningDelta)
}
if ev.FinishReason != "" {
finishReason = ev.FinishReason
}
if ev.Usage != nil {
usage = ev.Usage
}
for _, tc := range ev.ToolCallDeltas {
for len(toolCalls) <= tc.Index {
toolCalls = append(toolCalls, streamToolCallAcc{})
}
if tc.ID != "" {
toolCalls[tc.Index].ID = tc.ID
}
if tc.Name != "" {
toolCalls[tc.Index].Name = tc.Name
}
toolCalls[tc.Index].Arguments.WriteString(tc.ArgumentsDelta)
}
}
result := &LLMResponse{
Content: content.String(),
Reasoning: reasoning.String(),
FinishReason: finishReason,
Usage: usage,
}
for _, tc := range toolCalls {
arguments := make(map[string]any)
argStr := tc.Arguments.String()
if argStr != "" {
if err := json.Unmarshal([]byte(argStr), &arguments); err != nil {
log.Printf("openai_compat: failed to decode streamed tool call arguments for %q: %v", tc.Name, err)
arguments["raw"] = argStr
}
}
result.ToolCalls = append(result.ToolCalls, ToolCall{
ID: tc.ID,
Name: tc.Name,
Arguments: arguments,
Function: &FunctionCall{
Name: tc.Name,
Arguments: cloneOpenAIToolArgs(arguments),
},
})
}
return result, nil
}
func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {
respPreview := responsePreview(body, 128)
return fmt.Errorf(
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
apiBase,
contentType,
statusCode,
respPreview,
)
}
func looksLikeHTML(body []byte, contentType string) bool {
contentType = strings.ToLower(strings.TrimSpace(contentType))
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") {
return true
}
prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128))
return bytes.HasPrefix(prefix, []byte("<!doctype html")) ||
bytes.HasPrefix(prefix, []byte("<html")) ||
bytes.HasPrefix(prefix, []byte("<head")) ||
bytes.HasPrefix(prefix, []byte("<body"))
}
func leadingTrimmedPrefix(body []byte, maxLen int) []byte {
i := 0
for i < len(body) {
switch body[i] {
case ' ', '\t', '\n', '\r', '\f', '\v':
i++
default:
end := i + maxLen
if end > len(body) {
end = len(body)
}
return body[i:end]
}
}
return nil
}
func responsePreview(body []byte, maxLen int) string {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return "<empty>"
}
if len(trimmed) <= maxLen {
return string(trimmed)
}
return string(trimmed[:maxLen]) + "..."
}
func parseResponse(body io.Reader) (*LLMResponse, error) {
var apiResponse struct {
var chunk struct {
Choices []struct {
Message struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
Arguments string `json:"arguments"`
} `json:"function"`
ExtraContent *struct {
Google *struct {
ThoughtSignature string `json:"thought_signature"`
} `json:"google"`
} `json:"extra_content"`
} `json:"tool_calls"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage *UsageInfo `json:"usage"`
}
if err := json.NewDecoder(body).Decode(&apiResponse); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // skip malformed chunks
}
if len(apiResponse.Choices) == 0 {
return &LLMResponse{
Content: "",
FinishReason: "stop",
}, nil
if chunk.Usage != nil {
usage = chunk.Usage
}
choice := apiResponse.Choices[0]
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
for _, tc := range choice.Message.ToolCalls {
arguments := make(map[string]any)
name := ""
// Extract thought_signature from Gemini/Google-specific extra content
thoughtSignature := ""
if tc.ExtraContent != nil && tc.ExtraContent.Google != nil {
thoughtSignature = tc.ExtraContent.Google.ThoughtSignature
}
if tc.Function != nil {
name = tc.Function.Name
arguments = decodeToolCallArguments(tc.Function.Arguments, name)
}
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
toolCall := ToolCall{
ID: tc.ID,
Name: name,
Arguments: arguments,
ThoughtSignature: thoughtSignature,
Function: &FunctionCall{
Name: name,
Arguments: cloneOpenAIToolArgs(arguments),
ThoughtSignature: thoughtSignature,
},
}
if thoughtSignature != "" {
toolCall.ExtraContent = &ExtraContent{
Google: &GoogleExtra{
ThoughtSignature: thoughtSignature,
},
}
}
toolCalls = append(toolCalls, toolCall)
}
return &LLMResponse{
Content: choice.Message.Content,
ReasoningContent: choice.Message.ReasoningContent,
Reasoning: choice.Message.Reasoning,
ReasoningDetails: choice.Message.ReasoningDetails,
ToolCalls: toolCalls,
FinishReason: choice.FinishReason,
Usage: apiResponse.Usage,
}, nil
}
func decodeToolCallArguments(raw json.RawMessage, name string) map[string]any {
arguments := make(map[string]any)
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return arguments
}
var decoded any
if err := json.Unmarshal(raw, &decoded); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments payload for %q: %v", name, err)
arguments["raw"] = string(raw)
return arguments
}
switch v := decoded.(type) {
case string:
if strings.TrimSpace(v) == "" {
return arguments
}
if err := json.Unmarshal([]byte(v), &arguments); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
arguments["raw"] = v
}
return arguments
case map[string]any:
return v
default:
log.Printf("openai_compat: unsupported tool call arguments type for %q: %T", name, decoded)
arguments["raw"] = string(raw)
return arguments
}
}
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
// It mirrors protocoltypes.Message but omits SystemParts, which is an
// internal field that would be unknown to third-party endpoints.
type openaiMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// serializeMessages converts internal Message structs to the OpenAI wire format.
// - Strips SystemParts (unknown to third-party endpoints)
// - Converts messages with Media to multipart content format (text + image_url parts)
// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages
func serializeMessages(messages []Message) []any {
out := make([]any, 0, len(messages))
for _, m := range messages {
if len(m.Media) == 0 {
out = append(out, openaiMessage{
Role: m.Role,
Content: m.Content,
ReasoningContent: m.ReasoningContent,
ToolCalls: m.ToolCalls,
ToolCallID: m.ToolCallID,
})
if len(chunk.Choices) == 0 {
continue
}
// Multipart content format for messages with media
parts := make([]map[string]any, 0, 1+len(m.Media))
if m.Content != "" {
parts = append(parts, map[string]any{
"type": "text",
"text": m.Content,
})
}
for _, mediaURL := range m.Media {
if strings.HasPrefix(mediaURL, "data:image/") {
parts = append(parts, map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": mediaURL,
},
})
choice := chunk.Choices[0]
// Accumulate text content
if choice.Delta.Content != "" {
textContent.WriteString(choice.Delta.Content)
if onChunk != nil {
onChunk(textContent.String())
}
}
msg := map[string]any{
"role": m.Role,
"content": parts,
// Accumulate tool call deltas
for _, tc := range choice.Delta.ToolCalls {
acc, ok := activeTools[tc.Index]
if !ok {
acc = &toolAccum{}
activeTools[tc.Index] = acc
}
if m.ToolCallID != "" {
msg["tool_call_id"] = m.ToolCallID
if tc.ID != "" {
acc.id = tc.ID
}
if len(m.ToolCalls) > 0 {
msg["tool_calls"] = m.ToolCalls
if tc.Function != nil {
if tc.Function.Name != "" {
acc.name = tc.Function.Name
}
if tc.Function.Arguments != "" {
acc.argsJSON.WriteString(tc.Function.Arguments)
}
if m.ReasoningContent != "" {
msg["reasoning_content"] = m.ReasoningContent
}
out = append(out, msg)
}
return out
}
func cloneOpenAIToolArgs(src map[string]any) map[string]any {
if len(src) == 0 {
return map[string]any{}
if choice.FinishReason != nil {
finishReason = *choice.FinishReason
}
dst := make(map[string]any, len(src))
for k, v := range src {
dst[k] = v
}
return dst
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("streaming read error: %w", err)
}
// Assemble tool calls from accumulated deltas
var toolCalls []ToolCall
for i := 0; i < len(activeTools); i++ {
acc, ok := activeTools[i]
if !ok {
continue
}
args := make(map[string]any)
raw := acc.argsJSON.String()
if raw != "" {
if err := json.Unmarshal([]byte(raw), &args); err != nil {
log.Printf("openai_compat stream: failed to decode tool call arguments for %q: %v", acc.name, err)
args["raw"] = raw
}
}
toolCalls = append(toolCalls, ToolCall{
ID: acc.id,
Name: acc.name,
Arguments: args,
})
}
if finishReason == "" {
finishReason = "stop"
}
return &LLMResponse{
Content: textContent.String(),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: usage,
}, nil
}
func normalizeModel(model, apiBase string) string {
@ -771,44 +433,14 @@ func normalizeModel(model, apiBase string) string {
prefix := strings.ToLower(before)
switch prefix {
case "openai", "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek",
"google", "openrouter", "zhipu", "minimax", "mistral", "vivgrid", "novita":
case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
"openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita":
return after
default:
return model
}
}
func asInt(v any) (int, bool) {
switch val := v.(type) {
case int:
return val, true
case int64:
return int(val), true
case float64:
return int(val), true
case float32:
return int(val), true
default:
return 0, false
}
}
func asFloat(v any) (float64, bool) {
switch val := v.(type) {
case float64:
return val, true
case float32:
return float64(val), true
case int:
return float64(val), true
case int64:
return float64(val), true
default:
return 0, false
}
}
func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any {
result := make([]any, 0, len(tools)+1)
for _, t := range tools {
@ -836,42 +468,6 @@ func isNativeSearchHost(apiBase string) bool {
return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com")
}
// --- SSE streaming support ---
type streamChunk struct {
Choices []streamChoice `json:"choices"`
Usage *UsageInfo `json:"usage"`
}
type streamChoice struct {
Delta streamDelta `json:"delta"`
FinishReason string `json:"finish_reason"`
}
type streamDelta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []streamDeltaTC `json:"tool_calls"`
}
type streamDeltaTC struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function *streamDeltaFunction `json:"function"`
}
type streamDeltaFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type streamToolCallAcc struct {
ID string
Name string
Arguments strings.Builder
}
// supportsPromptCacheKey reports whether the given API base is known to
// support the prompt_cache_key request field. Currently only OpenAI's own
// API and Azure OpenAI support this. All other OpenAI-compatible providers

View file

@ -1,15 +1,11 @@
package openai_compat
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) {
@ -199,243 +195,6 @@ func TestProviderChat_CustomEndpointPath(t *testing.T) {
}
}
func TestReadSSEIntoChannel_TextAndToolCalls(t *testing.T) {
sseData := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{"content":" world"},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"greet","arguments":"{\"n"}}]},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ame\":\"Bob\"}"}}]},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":4,"total_tokens":7}}`,
``,
`data: [DONE]`,
``,
}, "\n")
ch := make(chan protocoltypes.StreamEvent, 32)
go func() {
defer close(ch)
readSSEIntoChannel(context.Background(), strings.NewReader(sseData), ch)
}()
var events []protocoltypes.StreamEvent
for ev := range ch {
events = append(events, ev)
}
if len(events) < 3 {
t.Fatalf("got %d events, want at least 3", len(events))
}
if events[0].ContentDelta != "Hello" {
t.Errorf("events[0].ContentDelta = %q, want %q", events[0].ContentDelta, "Hello")
}
if events[1].ContentDelta != " world" {
t.Errorf("events[1].ContentDelta = %q, want %q", events[1].ContentDelta, " world")
}
if len(events[2].ToolCallDeltas) != 1 || events[2].ToolCallDeltas[0].ID != "call_1" {
t.Errorf("events[2] should contain tool call with ID=call_1")
}
if events[2].ToolCallDeltas[0].Name != "greet" {
t.Errorf("events[2].ToolCallDeltas[0].Name = %q, want %q", events[2].ToolCallDeltas[0].Name, "greet")
}
lastEv := events[len(events)-1]
if lastEv.FinishReason != "stop" {
t.Errorf("last event FinishReason = %q, want %q", lastEv.FinishReason, "stop")
}
if lastEv.Usage == nil || lastEv.Usage.TotalTokens != 7 {
t.Errorf("last event Usage.TotalTokens = %v, want 7", lastEv.Usage)
}
}
func TestReadSSEIntoChannel_ContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
sseData := `data: {"choices":[{"delta":{"content":"first"},"finish_reason":""}]}` + "\n\n"
ch := make(chan protocoltypes.StreamEvent, 32)
go func() {
defer close(ch)
readSSEIntoChannel(ctx, strings.NewReader(sseData), ch)
}()
ev := <-ch
if ev.ContentDelta != "first" {
t.Fatalf("ContentDelta = %q, want %q", ev.ContentDelta, "first")
}
cancel()
_, ok := <-ch
if ok {
t.Fatal("expected channel to be closed after context cancel")
}
}
func TestAccumulateStream_FullResponse(t *testing.T) {
ch := make(chan protocoltypes.StreamEvent, 8)
go func() {
ch <- protocoltypes.StreamEvent{ContentDelta: "Hello"}
ch <- protocoltypes.StreamEvent{ContentDelta: " world"}
ch <- protocoltypes.StreamEvent{
ToolCallDeltas: []protocoltypes.StreamToolCallDelta{
{Index: 0, ID: "call_1", Name: "test_tool", ArgumentsDelta: `{"key"`},
},
}
ch <- protocoltypes.StreamEvent{
ToolCallDeltas: []protocoltypes.StreamToolCallDelta{
{Index: 0, ArgumentsDelta: `:"value"}`},
},
}
ch <- protocoltypes.StreamEvent{
FinishReason: "stop",
Usage: &UsageInfo{PromptTokens: 5, CompletionTokens: 3, TotalTokens: 8},
}
close(ch)
}()
resp, err := AccumulateStream(ch)
if err != nil {
t.Fatalf("AccumulateStream() error = %v", err)
}
if resp.Content != "Hello world" {
t.Errorf("Content = %q, want %q", resp.Content, "Hello world")
}
if resp.FinishReason != "stop" {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
}
if resp.Usage == nil || resp.Usage.TotalTokens != 8 {
t.Errorf("Usage.TotalTokens = %v, want 8", resp.Usage)
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls))
}
if resp.ToolCalls[0].Name != "test_tool" {
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "test_tool")
}
if resp.ToolCalls[0].Arguments["key"] != "value" {
t.Errorf("ToolCalls[0].Arguments[key] = %v, want %q", resp.ToolCalls[0].Arguments["key"], "value")
}
}
func TestAccumulateStream_Error(t *testing.T) {
ch := make(chan protocoltypes.StreamEvent, 4)
go func() {
ch <- protocoltypes.StreamEvent{ContentDelta: "partial"}
ch <- protocoltypes.StreamEvent{Err: fmt.Errorf("connection reset")}
close(ch)
}()
_, err := AccumulateStream(ch)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "connection reset") {
t.Fatalf("error = %q, want to contain %q", err.Error(), "connection reset")
}
}
func TestChatStream_EndToEnd(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
chunks := []string{
`data: {"choices":[{"delta":{"content":"stream"},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{"content":"ed"},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`,
`data: [DONE]`,
}
for _, c := range chunks {
fmt.Fprintln(w, c)
fmt.Fprintln(w)
if flusher != nil {
flusher.Flush()
}
}
}))
defer server.Close()
p := NewProvider("key", server.URL, "", WithStream(true))
ch, err := p.ChatStream(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "test", nil)
if err != nil {
t.Fatalf("ChatStream() error = %v", err)
}
resp, err := AccumulateStream(ch)
if err != nil {
t.Fatalf("AccumulateStream() error = %v", err)
}
if resp.Content != "streamed" {
t.Errorf("Content = %q, want %q", resp.Content, "streamed")
}
if resp.FinishReason != "stop" {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
}
if resp.Usage == nil || resp.Usage.TotalTokens != 3 {
t.Errorf("Usage.TotalTokens = %v, want 3", resp.Usage)
}
}
func TestChatStream_EarlyCancel(t *testing.T) {
serverDone := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(serverDone)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
for i := 0; i < 1000; i++ {
select {
case <-r.Context().Done():
return
default:
}
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"x\"},\"finish_reason\":\"\"}]}\n\n")
if flusher != nil {
flusher.Flush()
}
}
}))
defer server.Close()
p := NewProvider("key", server.URL, "", WithStream(true))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch, err := p.ChatStream(ctx, []Message{{Role: "user", Content: "hi"}}, nil, "test", nil)
if err != nil {
t.Fatalf("ChatStream() error = %v", err)
}
count := 0
for ev := range ch {
if ev.Err != nil {
break
}
count++
if count >= 5 {
cancel()
}
}
if count < 5 {
t.Errorf("expected at least 5 events before cancel, got %d", count)
}
<-serverDone
}
func TestCanStream(t *testing.T) {
p1 := NewProvider("key", "https://example.com", "")
if p1.CanStream() {

View file

@ -12,6 +12,7 @@ import (
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/providers/common"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
@ -748,34 +749,11 @@ func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) {
}
}
func TestSerializeMessages_PlainText(t *testing.T) {
messages := []protocoltypes.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi", ReasoningContent: "thinking..."},
}
result := serializeMessages(messages)
data, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
var msgs []map[string]any
json.Unmarshal(data, &msgs)
if msgs[0]["content"] != "hello" {
t.Fatalf("expected plain string content, got %v", msgs[0]["content"])
}
if msgs[1]["reasoning_content"] != "thinking..." {
t.Fatalf("reasoning_content not preserved, got %v", msgs[1]["reasoning_content"])
}
}
func TestSerializeMessages_WithMedia(t *testing.T) {
messages := []protocoltypes.Message{
{Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}},
}
result := serializeMessages(messages)
result := common.SerializeMessages(messages)
data, _ := json.Marshal(result)
var msgs []map[string]any
@ -808,7 +786,7 @@ func TestSerializeMessages_MediaWithToolCallID(t *testing.T) {
messages := []protocoltypes.Message{
{Role: "tool", Content: "image result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"},
}
result := serializeMessages(messages)
result := common.SerializeMessages(messages)
data, _ := json.Marshal(result)
var msgs []map[string]any
@ -1164,7 +1142,7 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) {
},
},
}
result := serializeMessages(messages)
result := common.SerializeMessages(messages)
data, _ := json.Marshal(result)
raw := string(data)

View file

@ -38,6 +38,21 @@ type StatefulProvider interface {
Close()
}
// CallbackStreamingProvider is an optional interface for providers that support
// token streaming via a callback function.
// onChunk receives the accumulated text so far (not individual deltas).
// The returned LLMResponse is the same complete response for compatibility with tool-call handling.
type CallbackStreamingProvider interface {
ChatStream(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
onChunk func(accumulated string),
) (*LLMResponse, error)
}
// ThinkingCapable is an optional interface for providers that support
// extended thinking (e.g. Anthropic). Used by the agent loop to warn
// when thinking_level is configured but the active provider cannot use it.
@ -63,6 +78,7 @@ const (
FailoverBilling FailoverReason = "billing"
FailoverTimeout FailoverReason = "timeout"
FailoverFormat FailoverReason = "format"
FailoverContextOverflow FailoverReason = "context_overflow"
FailoverOverloaded FailoverReason = "overloaded"
FailoverUnknown FailoverReason = "unknown"
)
@ -88,7 +104,7 @@ func (e *FailoverError) Unwrap() error {
// IsRetriable returns true if this error should trigger fallback to next candidate.
// Non-retriable: Format errors (bad request structure, image dimension/size).
func (e *FailoverError) IsRetriable() bool {
return e.Reason != FailoverFormat
return e.Reason != FailoverFormat && e.Reason != FailoverContextOverflow
}
// ModelConfig holds primary model and fallback list.

View file

@ -5,9 +5,13 @@ import (
"encoding/json"
"fmt"
"hash/fnv"
"os"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/media"
)
// MCPManager defines the interface for MCP manager operations
@ -25,6 +29,7 @@ type MCPTool struct {
manager MCPManager
serverName string
tool *mcp.Tool
mediaStore media.MediaStore
}
// NewMCPTool creates a new MCP tool wrapper
@ -36,6 +41,10 @@ func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool
}
}
func (t *MCPTool) SetMediaStore(store media.MediaStore) {
t.mediaStore = store
}
// sanitizeIdentifierComponent normalizes a string so it can be safely used
// as part of a tool/function identifier for downstream providers.
// It:
@ -218,13 +227,7 @@ func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult
WithError(fmt.Errorf("MCP tool error: %s", errMsg))
}
// Extract text content from result
output := extractContentText(result.Content)
return &ToolResult{
ForLLM: output,
IsError: false,
}
return t.normalizeResultContent(ctx, result.Content)
}
// extractContentText extracts text from MCP content array
@ -233,14 +236,269 @@ func extractContentText(content []mcp.Content) string {
for _, c := range content {
switch v := c.(type) {
case *mcp.TextContent:
parts = append(parts, v.Text)
parts = append(parts, sanitizeToolLLMContent(v.Text))
case *mcp.ImageContent:
// For images, just indicate that an image was returned
parts = append(parts, fmt.Sprintf("[Image: %s]", v.MIMEType))
parts = append(parts, fmt.Sprintf("[Image: %s]", normalizedMIMEType(v.MIMEType)))
case *mcp.AudioContent:
parts = append(parts, fmt.Sprintf("[Audio: %s]", normalizedMIMEType(v.MIMEType)))
case *mcp.ResourceLink:
parts = append(parts, summarizeResourceLink(v))
case *mcp.EmbeddedResource:
parts = append(parts, summarizeEmbeddedResource(v))
default:
// For other content types, use string representation
parts = append(parts, fmt.Sprintf("[Content: %T]", v))
}
}
return strings.Join(parts, "\n")
return sanitizeToolLLMContent(strings.Join(parts, "\n"))
}
func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Content) *ToolResult {
llmParts := make([]string, 0, len(content))
mediaRefs := make([]string, 0, len(content))
for _, c := range content {
switch v := c.(type) {
case *mcp.TextContent:
text := strings.TrimSpace(sanitizeToolLLMContent(v.Text))
if text != "" {
llmParts = append(llmParts, text)
}
case *mcp.ImageContent:
ref, note := t.storeBinaryContent(
ctx,
"image",
normalizedMIMEType(v.MIMEType),
v.Data,
v.Annotations,
)
if ref != "" {
mediaRefs = append(mediaRefs, ref)
}
if note != "" {
llmParts = append(llmParts, note)
}
case *mcp.AudioContent:
ref, note := t.storeBinaryContent(
ctx,
"audio",
normalizedMIMEType(v.MIMEType),
v.Data,
v.Annotations,
)
if ref != "" {
mediaRefs = append(mediaRefs, ref)
}
if note != "" {
llmParts = append(llmParts, note)
}
case *mcp.ResourceLink:
llmParts = append(llmParts, summarizeResourceLink(v))
case *mcp.EmbeddedResource:
ref, note := t.storeEmbeddedResource(ctx, v)
if ref != "" {
mediaRefs = append(mediaRefs, ref)
}
if note != "" {
llmParts = append(llmParts, note)
}
default:
llmParts = append(llmParts, fmt.Sprintf("[MCP returned unsupported content type %T]", v))
}
}
result := &ToolResult{
ForLLM: strings.Join(compactStrings(llmParts), "\n"),
Media: mediaRefs,
}
return result
}
func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string) {
if content == nil || content.Resource == nil {
return "", "[MCP returned an embedded resource without data.]"
}
resource := content.Resource
if len(resource.Blob) > 0 {
return t.storeBinaryContent(
ctx,
"resource",
normalizedMIMEType(resource.MIMEType),
resource.Blob,
content.Annotations,
)
}
if strings.TrimSpace(resource.Text) != "" {
return "", sanitizeToolLLMContent(resource.Text)
}
return "", summarizeEmbeddedResource(content)
}
func (t *MCPTool) storeBinaryContent(
ctx context.Context,
kind string,
mimeType string,
data []byte,
annotations *mcp.Annotations,
) (string, string) {
if len(data) == 0 {
return "", fmt.Sprintf("[MCP returned %s content (%s) but it was empty.]", kind, mimeType)
}
if !annotationsAllowUser(annotations) {
return "", fmt.Sprintf(
"[MCP returned %s content (%s) for non-user audience; omitted from model context.]",
kind,
mimeType,
)
}
if t.mediaStore == nil {
return "", fmt.Sprintf(
"[MCP returned %s content (%s); omitted from model context because media delivery is unavailable.]",
kind,
mimeType,
)
}
channel := ToolChannel(ctx)
chatID := ToolChatID(ctx)
if channel == "" || chatID == "" {
return "", fmt.Sprintf(
"[MCP returned %s content (%s); omitted from model context because no target chat was available.]",
kind,
mimeType,
)
}
dir := media.TempDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType)
}
ext := extensionForMIMEType(mimeType)
tmpFile, err := os.CreateTemp(dir, "mcp-*"+ext)
if err != nil {
return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType)
}
tmpPath := tmpFile.Name()
if _, err = tmpFile.Write(data); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType)
}
if err = tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath)
return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType)
}
scope := fmt.Sprintf(
"tool:mcp:%s:%s:%s:%d",
sanitizeIdentifierComponent(t.serverName),
channel,
chatID,
time.Now().UnixNano(),
)
filename := fmt.Sprintf(
"%s_%s%s",
sanitizeIdentifierComponent(t.serverName),
sanitizeIdentifierComponent(t.tool.Name),
ext,
)
ref, err := t.mediaStore.Store(tmpPath, media.MediaMeta{
Filename: filename,
ContentType: mimeType,
Source: fmt.Sprintf(
"tool:mcp:%s:%s",
sanitizeIdentifierComponent(t.serverName),
sanitizeIdentifierComponent(t.tool.Name),
),
}, scope)
if err != nil {
_ = os.Remove(tmpPath)
return "", fmt.Sprintf(
"[MCP returned %s content (%s) but it could not be registered as media.]",
kind,
mimeType,
)
}
return ref, fmt.Sprintf(
"[MCP returned %s content (%s); omitted from model context and stored as a local media artifact.]",
kind,
mimeType,
)
}
func summarizeResourceLink(content *mcp.ResourceLink) string {
if content == nil {
return "[MCP returned an empty resource link.]"
}
parts := []string{"[MCP returned resource link"}
if content.Name != "" {
parts = append(parts, fmt.Sprintf("name=%q", content.Name))
}
if content.URI != "" {
parts = append(parts, fmt.Sprintf("uri=%q", content.URI))
}
if content.MIMEType != "" {
parts = append(parts, fmt.Sprintf("mime=%q", content.MIMEType))
}
if content.Description != "" {
desc := strings.TrimSpace(content.Description)
if len(desc) > 200 {
desc = desc[:200] + "..."
}
parts = append(parts, fmt.Sprintf("description=%q", desc))
}
return strings.Join(parts, ", ") + "]"
}
func summarizeEmbeddedResource(content *mcp.EmbeddedResource) string {
if content == nil || content.Resource == nil {
return "[MCP returned an embedded resource.]"
}
resource := content.Resource
if resource.URI != "" {
return fmt.Sprintf(
"[MCP returned embedded resource %q (%s).]",
resource.URI,
normalizedMIMEType(resource.MIMEType),
)
}
return fmt.Sprintf("[MCP returned embedded resource (%s).]", normalizedMIMEType(resource.MIMEType))
}
func annotationsAllowUser(annotations *mcp.Annotations) bool {
if annotations == nil || len(annotations.Audience) == 0 {
return true
}
for _, audience := range annotations.Audience {
if strings.EqualFold(string(audience), "user") {
return true
}
}
return false
}
func normalizedMIMEType(mimeType string) string {
if strings.TrimSpace(mimeType) == "" {
return "application/octet-stream"
}
return mimeType
}
func compactStrings(parts []string) []string {
compact := make([]string, 0, len(parts))
for _, part := range parts {
if strings.TrimSpace(part) == "" {
continue
}
compact = append(compact, part)
}
return compact
}

Some files were not shown because too many files have changed in this diff Show more