From acac1972e60323607a57d74aaa9e1a767414f138 Mon Sep 17 00:00:00 2001
From: Luna Reed
Date: Wed, 18 Feb 2026 02:01:29 +0800
Subject: [PATCH 01/88] fix(exec): terminate process tree on timeout
---
pkg/tools/shell.go | 30 +++++++++++++-
pkg/tools/shell_process_unix.go | 32 +++++++++++++++
pkg/tools/shell_process_windows.go | 27 ++++++++++++
pkg/tools/shell_timeout_unix_test.go | 61 ++++++++++++++++++++++++++++
4 files changed, 148 insertions(+), 2 deletions(-)
create mode 100644 pkg/tools/shell_process_unix.go
create mode 100644 pkg/tools/shell_process_windows.go
create mode 100644 pkg/tools/shell_timeout_unix_test.go
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index 713850f97..11a1d59da 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -3,6 +3,7 @@ package tools
import (
"bytes"
"context"
+ "errors"
"fmt"
"os"
"os/exec"
@@ -109,18 +110,43 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To
cmd.Dir = cwd
}
+ prepareCommandForTermination(cmd)
+
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
- err := cmd.Run()
+ if err := cmd.Start(); err != nil {
+ return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
+ }
+
+ done := make(chan error, 1)
+ go func() {
+ done <- cmd.Wait()
+ }()
+
+ var err error
+ select {
+ case err = <-done:
+ case <-cmdCtx.Done():
+ _ = terminateProcessTree(cmd)
+ select {
+ case err = <-done:
+ case <-time.After(2 * time.Second):
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ err = <-done
+ }
+ }
+
output := stdout.String()
if stderr.Len() > 0 {
output += "\nSTDERR:\n" + stderr.String()
}
if err != nil {
- if cmdCtx.Err() == context.DeadlineExceeded {
+ if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
msg := fmt.Sprintf("Command timed out after %v", t.timeout)
return &ToolResult{
ForLLM: msg,
diff --git a/pkg/tools/shell_process_unix.go b/pkg/tools/shell_process_unix.go
new file mode 100644
index 000000000..7b29a81bf
--- /dev/null
+++ b/pkg/tools/shell_process_unix.go
@@ -0,0 +1,32 @@
+//go:build !windows
+
+package tools
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+func prepareCommandForTermination(cmd *exec.Cmd) {
+ if cmd == nil {
+ return
+ }
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+}
+
+func terminateProcessTree(cmd *exec.Cmd) error {
+ if cmd == nil || cmd.Process == nil {
+ return nil
+ }
+
+ pid := cmd.Process.Pid
+ if pid <= 0 {
+ return nil
+ }
+
+ // Kill the entire process group spawned by the shell command.
+ _ = syscall.Kill(-pid, syscall.SIGKILL)
+ // Fallback kill on the shell process itself.
+ _ = cmd.Process.Kill()
+ return nil
+}
diff --git a/pkg/tools/shell_process_windows.go b/pkg/tools/shell_process_windows.go
new file mode 100644
index 000000000..fe23b5c96
--- /dev/null
+++ b/pkg/tools/shell_process_windows.go
@@ -0,0 +1,27 @@
+//go:build windows
+
+package tools
+
+import (
+ "os/exec"
+ "strconv"
+)
+
+func prepareCommandForTermination(cmd *exec.Cmd) {
+ // no-op on Windows
+}
+
+func terminateProcessTree(cmd *exec.Cmd) error {
+ if cmd == nil || cmd.Process == nil {
+ return nil
+ }
+
+ pid := cmd.Process.Pid
+ if pid <= 0 {
+ return nil
+ }
+
+ _ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
+ _ = cmd.Process.Kill()
+ return nil
+}
diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go
new file mode 100644
index 000000000..4c6388b9b
--- /dev/null
+++ b/pkg/tools/shell_timeout_unix_test.go
@@ -0,0 +1,61 @@
+//go:build !windows
+
+package tools
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+)
+
+func processExists(pid int) bool {
+ if pid <= 0 {
+ return false
+ }
+ err := syscall.Kill(pid, 0)
+ return err == nil || err == syscall.EPERM
+}
+
+func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
+ tool := NewExecTool(t.TempDir(), false)
+ tool.SetTimeout(500 * time.Millisecond)
+
+ args := map[string]interface{}{
+ // Spawn a child process that would outlive the shell unless process-group kill is used.
+ "command": "sleep 60 & echo $! > child.pid; wait",
+ }
+
+ result := tool.Execute(context.Background(), args)
+ if !result.IsError {
+ t.Fatalf("expected timeout error, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "timed out") {
+ t.Fatalf("expected timeout message, got: %s", result.ForLLM)
+ }
+
+ childPIDPath := filepath.Join(tool.workingDir, "child.pid")
+ data, err := os.ReadFile(childPIDPath)
+ if err != nil {
+ t.Fatalf("failed to read child pid file: %v", err)
+ }
+
+ childPID, err := strconv.Atoi(strings.TrimSpace(string(data)))
+ if err != nil {
+ t.Fatalf("failed to parse child pid: %v", err)
+ }
+
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if !processExists(childPID) {
+ return
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+
+ t.Fatalf("child process %d is still running after timeout", childPID)
+}
From 9e120f90ea4dcda6a4323850b6e169514e71a3ca Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Wed, 18 Feb 2026 21:48:23 +0200
Subject: [PATCH 02/88] feat(fmt): Run formatters
---
.github/workflows/pr.yml | 20 --
.golangci.yaml | 9 +-
Makefile | 11 +-
cmd/picoclaw/main.go | 40 ++--
pkg/agent/context.go | 32 ++--
pkg/agent/instance.go | 2 +-
pkg/agent/loop.go | 112 ++++++++----
pkg/agent/loop_test.go | 46 +++--
pkg/agent/memory.go | 8 +-
pkg/agent/registry.go | 2 +-
pkg/agent/registry_test.go | 8 +-
pkg/auth/oauth.go | 17 +-
pkg/auth/oauth_test.go | 26 +--
pkg/auth/store.go | 4 +-
pkg/auth/store_test.go | 2 +-
pkg/channels/base.go | 4 +-
pkg/channels/dingtalk.go | 12 +-
pkg/channels/discord.go | 5 +-
pkg/channels/feishu_32.go | 4 +-
pkg/channels/feishu_64.go | 6 +-
pkg/channels/line.go | 36 ++--
pkg/channels/maixcam.go | 26 +--
pkg/channels/manager.go | 40 ++--
pkg/channels/onebot.go | 67 +++----
pkg/channels/qq.go | 10 +-
pkg/channels/slack.go | 22 +--
pkg/channels/telegram.go | 32 ++--
pkg/channels/telegram_commands.go | 3 +
pkg/channels/whatsapp.go | 8 +-
pkg/config/config.go | 112 ++++++------
pkg/config/config_test.go | 4 +-
pkg/cron/service.go | 16 +-
pkg/cron/service_test.go | 2 +-
pkg/devices/service.go | 8 +-
pkg/devices/sources/usb_linux.go | 2 +-
pkg/heartbeat/service.go | 6 +-
pkg/heartbeat/service_test.go | 8 +-
pkg/logger/logger.go | 38 ++--
pkg/logger/logger_test.go | 10 +-
pkg/migrate/config.go | 38 ++--
pkg/migrate/migrate.go | 14 +-
pkg/migrate/migrate_test.go | 172 +++++++++---------
pkg/providers/anthropic/provider.go | 38 ++--
pkg/providers/anthropic/provider_test.go | 55 ++++--
pkg/providers/claude_cli_provider.go | 8 +-
.../claude_cli_provider_integration_test.go | 2 -
pkg/providers/claude_cli_provider_test.go | 19 +-
pkg/providers/claude_provider.go | 8 +-
pkg/providers/claude_provider_test.go | 13 +-
pkg/providers/codex_cli_credentials.go | 4 +-
pkg/providers/codex_cli_credentials_test.go | 16 +-
pkg/providers/codex_cli_provider.go | 8 +-
.../codex_cli_provider_integration_test.go | 2 -
pkg/providers/codex_cli_provider_test.go | 12 +-
pkg/providers/codex_provider.go | 63 ++++---
pkg/providers/codex_provider_test.go | 111 +++++------
pkg/providers/fallback.go | 6 +-
pkg/providers/fallback_test.go | 8 +-
pkg/providers/github_copilot_provider.go | 16 +-
pkg/providers/http_provider.go | 4 +-
pkg/providers/openai_compat/provider.go | 32 ++--
pkg/providers/openai_compat/provider_test.go | 56 +++---
pkg/providers/protocoltypes/types.go | 16 +-
pkg/providers/tool_call_extract.go | 2 +-
pkg/providers/types.go | 24 ++-
pkg/session/manager.go | 4 +-
pkg/skills/installer.go | 4 +-
pkg/state/state.go | 4 +-
pkg/state/state_test.go | 2 +-
pkg/tools/base.go | 10 +-
pkg/tools/cron.go | 38 ++--
pkg/tools/edit.go | 34 ++--
pkg/tools/edit_test.go | 32 ++--
pkg/tools/filesystem.go | 36 ++--
pkg/tools/filesystem_test.go | 38 ++--
pkg/tools/i2c.go | 32 ++--
pkg/tools/i2c_linux.go | 20 +-
pkg/tools/i2c_other.go | 6 +-
pkg/tools/message.go | 14 +-
pkg/tools/message_test.go | 20 +-
pkg/tools/registry.go | 30 +--
pkg/tools/result_test.go | 2 +-
pkg/tools/shell.go | 12 +-
pkg/tools/shell_test.go | 26 +--
pkg/tools/spawn.go | 14 +-
pkg/tools/spi.go | 32 ++--
pkg/tools/spi_linux.go | 14 +-
pkg/tools/spi_other.go | 4 +-
pkg/tools/subagent.go | 32 +++-
pkg/tools/subagent_tool_test.go | 26 ++-
pkg/tools/toolloop.go | 7 +-
pkg/tools/types.go | 24 ++-
pkg/tools/web.go | 48 +++--
pkg/tools/web_test.go | 26 +--
pkg/utils/media.go | 17 +-
pkg/voice/transcriber.go | 40 ++--
96 files changed, 1239 insertions(+), 976 deletions(-)
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 55bf77e00..27782ced2 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -24,29 +24,10 @@ jobs:
with:
version: v2.10.1
- # TODO: Remove once linter is properly configured
- fmt-check:
- name: Formatting
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Go
- uses: actions/setup-go@v6
- with:
- go-version-file: go.mod
-
- - name: Check formatting
- run: |
- make fmt
- git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
-
# TODO: Remove once linter is properly configured
vet:
name: Vet
runs-on: ubuntu-latest
- needs: fmt-check
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -65,7 +46,6 @@ jobs:
test:
name: Tests
runs-on: ubuntu-latest
- needs: fmt-check
steps:
- name: Checkout
uses: actions/checkout@v6
diff --git a/.golangci.yaml b/.golangci.yaml
index 80e54ac1c..6dafb6b56 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -160,12 +160,11 @@ issues:
formatters:
enable:
+ - gci
+ - gofmt
+ - gofumpt
- goimports
- # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
- # - gci
- # - gofmt
- # - gofumpt
- # - golines
+ - golines
settings:
gci:
sections:
diff --git a/Makefile b/Makefile
index ff280e3e4..a5ad4a02d 100644
--- a/Makefile
+++ b/Makefile
@@ -17,6 +17,9 @@ LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X
GO?=go
GOFLAGS?=-v -tags stdjson
+# Golangci-lint
+GOLANGCI_LINT?=golangci-lint
+
# Installation
INSTALL_PREFIX?=$(HOME)/.local
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
@@ -126,13 +129,17 @@ clean:
vet:
@$(GO) vet ./...
-## fmt: Format Go code
+## test: Test Go code
test:
@$(GO) test ./...
## fmt: Format Go code
fmt:
- @$(GO) fmt ./...
+ @$(GOLANGCI_LINT) fmt
+
+## lint: Run linters
+lint:
+ @$(GOLANGCI_LINT) run
## deps: Download dependencies
deps:
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 128f8c421..5cd8039dd 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -22,6 +22,7 @@ import (
"time"
"github.com/chzyer/readline"
+
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -248,7 +249,7 @@ func onboard() {
func copyEmbeddedToTarget(targetDir string) error {
// Ensure target directory exists
- if err := os.MkdirAll(targetDir, 0755); err != nil {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("Failed to create target directory: %w", err)
}
@@ -278,12 +279,12 @@ func copyEmbeddedToTarget(targetDir string) error {
targetPath := filepath.Join(targetDir, new_path)
// Ensure target file's directory exists
- if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
}
// Write file
- if err := os.WriteFile(targetPath, data, 0644); err != nil {
+ if err := os.WriteFile(targetPath, data, 0o644); err != nil {
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
}
@@ -411,10 +412,10 @@ func agentCmd() {
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized",
- map[string]interface{}{
- "tools_count": startupInfo["tools"].(map[string]interface{})["count"],
- "skills_total": startupInfo["skills"].(map[string]interface{})["total"],
- "skills_available": startupInfo["skills"].(map[string]interface{})["available"],
+ map[string]any{
+ "tools_count": startupInfo["tools"].(map[string]any)["count"],
+ "skills_total": startupInfo["skills"].(map[string]any)["total"],
+ "skills_available": startupInfo["skills"].(map[string]any)["available"],
})
if message != "" {
@@ -441,7 +442,6 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
InterruptPrompt: "^C",
EOFPrompt: "exit",
})
-
if err != nil {
fmt.Printf("Error initializing readline: %v\n", err)
fmt.Println("Falling back to simple input mode...")
@@ -546,8 +546,8 @@ func gatewayCmd() {
// Print agent startup info
fmt.Println("\nš¦ Agent Status:")
startupInfo := agentLoop.GetStartupInfo()
- toolsInfo := startupInfo["tools"].(map[string]interface{})
- skillsInfo := startupInfo["skills"].(map[string]interface{})
+ toolsInfo := startupInfo["tools"].(map[string]any)
+ skillsInfo := startupInfo["skills"].(map[string]any)
fmt.Printf(" ⢠Tools: %d loaded\n", toolsInfo["count"])
fmt.Printf(" ⢠Skills: %d/%d available\n",
skillsInfo["available"],
@@ -555,7 +555,7 @@ func gatewayCmd() {
// Log to file as well
logger.InfoCF("agent", "Agent initialized",
- map[string]interface{}{
+ map[string]any{
"tools_count": toolsInfo["count"],
"skills_total": skillsInfo["total"],
"skills_available": skillsInfo["available"],
@@ -563,7 +563,14 @@ func gatewayCmd() {
// Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
- cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg)
+ cronService := setupCronTool(
+ agentLoop,
+ msgBus,
+ cfg.WorkspacePath(),
+ cfg.Agents.Defaults.RestrictToWorkspace,
+ execTimeout,
+ cfg,
+ )
heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
@@ -667,7 +674,7 @@ func gatewayCmd() {
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
go func() {
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()})
+ logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
}
}()
fmt.Printf("ā Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
@@ -988,7 +995,10 @@ func getConfigPath() string {
return filepath.Join(home, ".picoclaw", "config.json")
}
-func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *cron.CronService {
+func setupCronTool(
+ agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration,
+ config *config.Config,
+) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service
@@ -1315,7 +1325,7 @@ func skillsInstallBuiltinCmd(workspace string) {
continue
}
- if err := os.MkdirAll(workspacePath, 0755); err != nil {
+ if err := os.MkdirAll(workspacePath, 0o755); err != nil {
fmt.Printf("ā Failed to create directory for %s: %v\n", skillName, err)
continue
}
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index cf5ce2913..9abb3e5af 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -96,7 +96,9 @@ func (cb *ContextBuilder) buildToolsSection() string {
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
- sb.WriteString("**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n")
+ sb.WriteString(
+ "**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n",
+ )
sb.WriteString("You have access to the following tools:\n\n")
for _, s := range summaries {
sb.WriteString(s)
@@ -157,7 +159,9 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
return result
}
-func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message {
+func (cb *ContextBuilder) BuildMessages(
+ history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string,
+) []providers.Message {
messages := []providers.Message{}
systemPrompt := cb.BuildSystemPrompt()
@@ -169,7 +173,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
// Log system prompt summary for debugging (debug mode only)
logger.DebugCF("agent", "System prompt built",
- map[string]interface{}{
+ map[string]any{
"total_chars": len(systemPrompt),
"total_lines": strings.Count(systemPrompt, "\n") + 1,
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
@@ -181,7 +185,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
preview = preview[:500] + "... (truncated)"
}
logger.DebugCF("agent", "System prompt preview",
- map[string]interface{}{
+ map[string]any{
"preview": preview,
})
@@ -189,15 +193,15 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
}
- //This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM
+ // This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM
// --- INICIO DEL FIX ---
- //Diegox-17
+ // Diegox-17
for len(history) > 0 && (history[0].Role == "tool") {
logger.DebugCF("agent", "Removing orphaned tool message from history to prevent LLM error",
- map[string]interface{}{"role": history[0].Role})
+ map[string]any{"role": history[0].Role})
history = history[1:]
}
- //Diegox-17
+ // Diegox-17
// --- FIN DEL FIX ---
messages = append(messages, providers.Message{
@@ -215,7 +219,9 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
return messages
}
-func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message {
+func (cb *ContextBuilder) AddToolResult(
+ messages []providers.Message, toolCallID, toolName, result string,
+) []providers.Message {
messages = append(messages, providers.Message{
Role: "tool",
Content: result,
@@ -224,7 +230,9 @@ func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID
return messages
}
-func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, content string, toolCalls []map[string]interface{}) []providers.Message {
+func (cb *ContextBuilder) AddAssistantMessage(
+ messages []providers.Message, content string, toolCalls []map[string]any,
+) []providers.Message {
msg := providers.Message{
Role: "assistant",
Content: content,
@@ -254,13 +262,13 @@ func (cb *ContextBuilder) loadSkills() string {
}
// GetSkillsInfo returns information about loaded skills.
-func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
+func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills()
skillNames := make([]string, 0, len(allSkills))
for _, s := range allSkills {
skillNames = append(skillNames, s.Name)
}
- return map[string]interface{}{
+ return map[string]any{
"total": len(allSkills),
"available": len(allSkills),
"names": skillNames,
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
index 54a5396e7..4b380cbc5 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -39,7 +39,7 @@ func NewAgentInstance(
provider providers.LLMProvider,
) *AgentInstance {
workspace := resolveAgentWorkspace(agentCfg, defaults)
- os.MkdirAll(workspace, 0755)
+ os.MkdirAll(workspace, 0o755)
model := resolveAgentModel(agentCfg, defaults)
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index ed69712ff..9b0926e61 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -79,7 +79,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
}
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
-func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) {
+func registerSharedTools(
+ cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider,
+) {
for _, agentID := range registry.ListAgentIDs() {
agent, ok := registry.GetAgent(agentID)
if !ok {
@@ -215,7 +217,9 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
}
-func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) {
+func (al *AgentLoop) ProcessDirectWithChannel(
+ ctx context.Context, content, sessionKey, channel, chatID string,
+) (string, error) {
msg := bus.InboundMessage{
Channel: channel,
SenderID: "cron",
@@ -252,7 +256,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
logContent = utils.Truncate(msg.Content, 80)
}
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
- map[string]interface{}{
+ map[string]any{
"channel": msg.Channel,
"chat_id": msg.ChatID,
"sender_id": msg.SenderID,
@@ -291,7 +295,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}
logger.InfoCF("agent", "Routed message",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"session_key": sessionKey,
"matched_by": route.MatchedBy,
@@ -314,7 +318,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
}
logger.InfoCF("agent", "Processing system message",
- map[string]interface{}{
+ map[string]any{
"sender_id": msg.SenderID,
"chat_id": msg.ChatID,
})
@@ -339,7 +343,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
// Skip internal channels - only log, don't send to user
if constants.IsInternalChannel(originChannel) {
logger.InfoCF("agent", "Subagent completed (internal channel)",
- map[string]interface{}{
+ map[string]any{
"sender_id": msg.SenderID,
"content_len": len(content),
"channel": originChannel,
@@ -372,7 +376,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
if !constants.IsInternalChannel(opts.Channel) {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil {
- logger.WarnCF("agent", "Failed to record last channel", map[string]interface{}{"error": err.Error()})
+ logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()})
}
}
}
@@ -434,7 +438,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 9. Log response
responsePreview := utils.Truncate(finalContent, 120)
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"session_key": opts.SessionKey,
"iterations": iteration,
@@ -445,7 +449,9 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
}
// runLLMIteration executes the LLM call loop with tool handling.
-func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) {
+func (al *AgentLoop) runLLMIteration(
+ ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions,
+) (string, int, error) {
iteration := 0
var finalContent string
@@ -453,7 +459,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
iteration++
logger.DebugCF("agent", "LLM iteration",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"max": agent.MaxIterations,
@@ -464,7 +470,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
// Log LLM request details
logger.DebugCF("agent", "LLM request",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"model": agent.Model,
@@ -477,7 +483,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
// Log full messages (detailed)
logger.DebugCF("agent", "Full LLM request",
- map[string]interface{}{
+ map[string]any{
"iteration": iteration,
"messages_json": formatMessagesForLog(messages),
"tools_json": formatToolsForLog(providerToolDefs),
@@ -491,7 +497,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if len(agent.Candidates) > 1 && al.fallback != nil {
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
- return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
"max_tokens": 8192,
"temperature": 0.7,
})
@@ -503,11 +509,11 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
- map[string]interface{}{"agent_id": agent.ID, "iteration": iteration})
+ map[string]any{"agent_id": agent.ID, "iteration": iteration})
}
return fbResult.Response, nil
}
- return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
"max_tokens": 8192,
"temperature": 0.7,
})
@@ -528,7 +534,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
strings.Contains(errMsg, "length")
if isContextError && retry < maxRetries {
- logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{
+ logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
"error": err.Error(),
"retry": retry,
})
@@ -555,7 +561,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if err != nil {
logger.ErrorCF("agent", "LLM call failed",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"error": err.Error(),
@@ -567,7 +573,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if len(response.ToolCalls) == 0 {
finalContent = response.Content
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(finalContent),
@@ -581,7 +587,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
toolNames = append(toolNames, tc.Name)
}
logger.InfoCF("agent", "LLM requested tool calls",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"tools": toolNames,
"count": len(response.ToolCalls),
@@ -614,7 +620,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"tool": tc.Name,
"iteration": iteration,
@@ -629,14 +635,16 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
// The agent will handle user notification via processSystemMessage
if !result.Silent && result.ForUser != "" {
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
- map[string]interface{}{
+ map[string]any{
"tool": tc.Name,
"content_len": len(result.ForUser),
})
}
}
- toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
+ toolResult := agent.Tools.ExecuteWithContext(
+ ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback,
+ )
// Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
@@ -646,7 +654,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
Content: toolResult.ForUser,
})
logger.DebugCF("agent", "Sent tool result to user",
- map[string]interface{}{
+ map[string]any{
"tool": tc.Name,
"content_len": len(toolResult.ForUser),
})
@@ -752,7 +760,10 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
newHistory = append(newHistory, history[0]) // System prompt
// Add a note about compression
- compressionNote := fmt.Sprintf("[System: Emergency compression dropped %d oldest messages due to context limit]", droppedCount)
+ compressionNote := fmt.Sprintf(
+ "[System: Emergency compression dropped %d oldest messages due to context limit]",
+ droppedCount,
+ )
// If there was an existing summary, we might lose it if it was in the dropped part (which is just messages).
// The summary is stored separately in session.Summary, so it persists!
// We just need to ensure the user knows there's a gap.
@@ -770,7 +781,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
agent.Sessions.SetHistory(sessionKey, newHistory)
agent.Sessions.Save(sessionKey)
- logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{
+ logger.WarnCF("agent", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(newHistory),
@@ -778,8 +789,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
}
// GetStartupInfo returns information about loaded tools and skills for logging.
-func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
- info := make(map[string]interface{})
+func (al *AgentLoop) GetStartupInfo() map[string]any {
+ info := make(map[string]any)
agent := al.registry.GetDefaultAgent()
if agent == nil {
@@ -788,7 +799,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
// Tools info
toolsList := agent.Tools.List()
- info["tools"] = map[string]interface{}{
+ info["tools"] = map[string]any{
"count": len(toolsList),
"names": toolsList,
}
@@ -797,7 +808,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
// Agents info
- info["agents"] = map[string]interface{}{
+ info["agents"] = map[string]any{
"count": len(al.registry.ListAgentIDs()),
"ids": al.registry.ListAgentIDs(),
}
@@ -849,7 +860,10 @@ func formatToolsForLog(tools []providers.ToolDefinition) string {
result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
result += fmt.Sprintf(" Description: %s\n", tool.Function.Description)
if len(tool.Function.Parameters) > 0 {
- result += fmt.Sprintf(" Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200))
+ result += fmt.Sprintf(
+ " Parameters: %s\n",
+ utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200),
+ )
}
}
result += "]"
@@ -902,11 +916,21 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
- mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
- resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{
- "max_tokens": 1024,
- "temperature": 0.3,
- })
+ mergePrompt := fmt.Sprintf(
+ "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
+ s1,
+ s2,
+ )
+ resp, err := agent.Provider.Chat(
+ ctx,
+ []providers.Message{{Role: "user", Content: mergePrompt}},
+ nil,
+ agent.Model,
+ map[string]any{
+ "max_tokens": 1024,
+ "temperature": 0.3,
+ },
+ )
if err == nil {
finalSummary = resp.Content
} else {
@@ -928,7 +952,9 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
}
// summarizeBatch summarizes a batch of messages.
-func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
+func (al *AgentLoop) summarizeBatch(
+ ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string,
+) (string, error) {
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
if existingSummary != "" {
prompt += "Existing context: " + existingSummary + "\n"
@@ -938,10 +964,16 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, b
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
}
- response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{
- "max_tokens": 1024,
- "temperature": 0.3,
- })
+ response, err := agent.Provider.Chat(
+ ctx,
+ []providers.Message{{Role: "user", Content: prompt}},
+ nil,
+ agent.Model,
+ map[string]any{
+ "max_tokens": 1024,
+ "temperature": 0.3,
+ },
+ )
if err != nil {
return "", err
}
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index f2257973c..fc026bef4 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -17,7 +17,10 @@ import (
// mockProvider is a simple mock LLM provider for testing
type mockProvider struct{}
-func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
+func (m *mockProvider) Chat(
+ ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string,
+ opts map[string]any,
+) (*providers.LLMResponse, error) {
return &providers.LLMResponse{
Content: "Mock response",
ToolCalls: []providers.ToolCall{},
@@ -185,7 +188,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
// Verify tool is registered by checking it doesn't panic on GetStartupInfo
// (actual tool retrieval is tested in tools package tests)
info := al.GetStartupInfo()
- toolsInfo := info["tools"].(map[string]interface{})
+ toolsInfo := info["tools"].(map[string]any)
toolsList := toolsInfo["names"].([]string)
// Check that our custom tool name is in the list
@@ -260,7 +263,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
al.RegisterTool(testTool)
info := al.GetStartupInfo()
- toolsInfo := info["tools"].(map[string]interface{})
+ toolsInfo := info["tools"].(map[string]any)
toolsList := toolsInfo["names"].([]string)
// Check that our custom tool name is in the list
@@ -307,7 +310,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
t.Fatal("Expected 'tools' key in startup info")
}
- toolsMap, ok := toolsInfo.(map[string]interface{})
+ toolsMap, ok := toolsInfo.(map[string]any)
if !ok {
t.Fatal("Expected 'tools' to be a map")
}
@@ -363,7 +366,10 @@ type simpleMockProvider struct {
response string
}
-func (m *simpleMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
+func (m *simpleMockProvider) Chat(
+ ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string,
+ opts map[string]any,
+) (*providers.LLMResponse, error) {
return &providers.LLMResponse{
Content: m.response,
ToolCalls: []providers.ToolCall{},
@@ -385,14 +391,14 @@ func (m *mockCustomTool) Description() string {
return "Mock custom tool for testing"
}
-func (m *mockCustomTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (m *mockCustomTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{},
+ "properties": map[string]any{},
}
}
-func (m *mockCustomTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
+func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
return tools.SilentResult("Custom tool executed")
}
@@ -410,14 +416,14 @@ func (m *mockContextualTool) Description() string {
return "Mock contextual tool"
}
-func (m *mockContextualTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (m *mockContextualTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{},
+ "properties": map[string]any{},
}
}
-func (m *mockContextualTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
+func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
return tools.SilentResult("Contextual tool executed")
}
@@ -537,7 +543,10 @@ type failFirstMockProvider struct {
successResp string
}
-func (m *failFirstMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
+func (m *failFirstMockProvider) Chat(
+ ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string,
+ opts map[string]any,
+) (*providers.LLMResponse, error) {
m.currentCall++
if m.currentCall <= m.failures {
return nil, m.failError
@@ -602,8 +611,13 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
// Call ProcessDirectWithChannel
// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
- response, err := al.ProcessDirectWithChannel(context.Background(), "Trigger message", sessionKey, "test", "test-chat")
-
+ response, err := al.ProcessDirectWithChannel(
+ context.Background(),
+ "Trigger message",
+ sessionKey,
+ "test",
+ "test-chat",
+ )
if err != nil {
t.Fatalf("Expected success after retry, got error: %v", err)
}
diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go
index 3f6896f91..076e822fe 100644
--- a/pkg/agent/memory.go
+++ b/pkg/agent/memory.go
@@ -29,7 +29,7 @@ func NewMemoryStore(workspace string) *MemoryStore {
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
// Ensure memory directory exists
- os.MkdirAll(memoryDir, 0755)
+ os.MkdirAll(memoryDir, 0o755)
return &MemoryStore{
workspace: workspace,
@@ -57,7 +57,7 @@ func (ms *MemoryStore) ReadLongTerm() string {
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error {
- return os.WriteFile(ms.memoryFile, []byte(content), 0644)
+ return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
}
// ReadToday reads today's daily note.
@@ -77,7 +77,7 @@ func (ms *MemoryStore) AppendToday(content string) error {
// Ensure month directory exists
monthDir := filepath.Dir(todayFile)
- os.MkdirAll(monthDir, 0755)
+ os.MkdirAll(monthDir, 0o755)
var existingContent string
if data, err := os.ReadFile(todayFile); err == nil {
@@ -94,7 +94,7 @@ func (ms *MemoryStore) AppendToday(content string) error {
newContent = existingContent + "\n" + content
}
- return os.WriteFile(todayFile, []byte(newContent), 0644)
+ return os.WriteFile(todayFile, []byte(newContent), 0o644)
}
// GetRecentDailyNotes returns daily notes from the last N days.
diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go
index 4cf5a6fca..77b846832 100644
--- a/pkg/agent/registry.go
+++ b/pkg/agent/registry.go
@@ -42,7 +42,7 @@ func NewAgentRegistry(
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
registry.agents[id] = instance
logger.InfoCF("agent", "Registered agent",
- map[string]interface{}{
+ map[string]any{
"agent_id": id,
"name": ac.Name,
"workspace": instance.Workspace,
diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go
index f196d7fb7..518bb441f 100644
--- a/pkg/agent/registry_test.go
+++ b/pkg/agent/registry_test.go
@@ -10,7 +10,13 @@ import (
type mockRegistryProvider struct{}
-func (m *mockRegistryProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
+func (m *mockRegistryProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ options map[string]any,
+) (*providers.LLMResponse, error) {
return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
}
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index dcd91bebd..c01fc3b88 100644
--- a/pkg/auth/oauth.go
+++ b/pkg/auth/oauth.go
@@ -200,8 +200,11 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
deviceResp.Interval = 5
}
- fmt.Printf("\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
- cfg.Issuer, deviceResp.UserCode)
+ fmt.Printf(
+ "\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
+ cfg.Issuer,
+ deviceResp.UserCode,
+ )
deadline := time.After(15 * time.Minute)
ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second)
@@ -396,15 +399,15 @@ func extractAccountID(token string) string {
return accountID
}
- if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
+ if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]any); ok {
if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" {
return accountID
}
}
- if orgs, ok := claims["organizations"].([]interface{}); ok {
+ if orgs, ok := claims["organizations"].([]any); ok {
for _, org := range orgs {
- if orgMap, ok := org.(map[string]interface{}); ok {
+ if orgMap, ok := org.(map[string]any); ok {
if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
return accountID
}
@@ -415,7 +418,7 @@ func extractAccountID(token string) string {
return ""
}
-func parseJWTClaims(token string) (map[string]interface{}, error) {
+func parseJWTClaims(token string) (map[string]any, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return nil, fmt.Errorf("token is not a JWT")
@@ -434,7 +437,7 @@ func parseJWTClaims(token string) (map[string]interface{}, error) {
return nil, err
}
- var claims map[string]interface{}
+ var claims map[string]any
if err := json.Unmarshal(decoded, &claims); err != nil {
return nil, err
}
diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go
index 5deb17805..0cb589069 100644
--- a/pkg/auth/oauth_test.go
+++ b/pkg/auth/oauth_test.go
@@ -10,7 +10,7 @@ import (
"testing"
)
-func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string {
+func makeJWTForClaims(t *testing.T, claims map[string]any) string {
t.Helper()
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
@@ -89,7 +89,7 @@ func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) {
}
func TestParseTokenResponse(t *testing.T) {
- resp := map[string]interface{}{
+ resp := map[string]any{
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"expires_in": 3600,
@@ -120,8 +120,8 @@ func TestParseTokenResponse(t *testing.T) {
}
func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
- idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"})
- resp := map[string]interface{}{
+ idToken := makeJWTForClaims(t, map[string]any{"chatgpt_account_id": "acc-id-from-id-token"})
+ resp := map[string]any{
"access_token": "opaque-access-token",
"refresh_token": "test-refresh-token",
"expires_in": 3600,
@@ -139,9 +139,9 @@ func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
}
func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {
- token := makeJWTForClaims(t, map[string]interface{}{
- "organizations": []interface{}{
- map[string]interface{}{"id": "org_from_orgs"},
+ token := makeJWTForClaims(t, map[string]any{
+ "organizations": []any{
+ map[string]any{"id": "org_from_orgs"},
},
})
@@ -160,7 +160,7 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) {
func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
idToken := makeJWTWithAccountID("acc-from-id")
- resp := map[string]interface{}{
+ resp := map[string]any{
"access_token": "not-a-jwt",
"refresh_token": "test-refresh-token",
"expires_in": 3600,
@@ -180,7 +180,9 @@ func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
func makeJWTWithAccountID(accountID string) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
- payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`))
+ payload := base64.RawURLEncoding.EncodeToString(
+ []byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`),
+ )
return header + "." + payload + ".sig"
}
@@ -201,7 +203,7 @@ func TestExchangeCodeForTokens(t *testing.T) {
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_in": 3600,
@@ -240,7 +242,7 @@ func TestRefreshAccessToken(t *testing.T) {
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"access_token": "refreshed-access-token",
"refresh_token": "refreshed-refresh-token",
"expires_in": 3600,
@@ -290,7 +292,7 @@ func TestRefreshAccessTokenNoRefreshToken(t *testing.T) {
func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- resp := map[string]interface{}{
+ resp := map[string]any{
"access_token": "new-access-token-only",
"expires_in": 3600,
}
diff --git a/pkg/auth/store.go b/pkg/auth/store.go
index 20724929a..d32d4495a 100644
--- a/pkg/auth/store.go
+++ b/pkg/auth/store.go
@@ -62,7 +62,7 @@ func LoadStore() (*AuthStore, error) {
func SaveStore(store *AuthStore) error {
path := authFilePath()
dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0755); err != nil {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@@ -70,7 +70,7 @@ func SaveStore(store *AuthStore) error {
if err != nil {
return err
}
- return os.WriteFile(path, data, 0600)
+ return os.WriteFile(path, data, 0o600)
}
func GetCredential(provider string) (*AuthCredential, error) {
diff --git a/pkg/auth/store_test.go b/pkg/auth/store_test.go
index d96b460a1..f6793cfce 100644
--- a/pkg/auth/store_test.go
+++ b/pkg/auth/store_test.go
@@ -108,7 +108,7 @@ func TestStoreFilePermissions(t *testing.T) {
t.Fatalf("Stat() error: %v", err)
}
perm := info.Mode().Perm()
- if perm != 0600 {
+ if perm != 0o600 {
t.Errorf("file permissions = %o, want 0600", perm)
}
}
diff --git a/pkg/channels/base.go b/pkg/channels/base.go
index 4925099a3..cd6419ebb 100644
--- a/pkg/channels/base.go
+++ b/pkg/channels/base.go
@@ -17,14 +17,14 @@ type Channel interface {
}
type BaseChannel struct {
- config interface{}
+ config any
bus *bus.MessageBus
running bool
name string
allowList []string
}
-func NewBaseChannel(name string, config interface{}, bus *bus.MessageBus, allowList []string) *BaseChannel {
+func NewBaseChannel(name string, config any, bus *bus.MessageBus, allowList []string) *BaseChannel {
return &BaseChannel{
config: config,
bus: bus,
diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk.go
index 263785c0c..4e3a5d4f3 100644
--- a/pkg/channels/dingtalk.go
+++ b/pkg/channels/dingtalk.go
@@ -10,6 +10,7 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
+
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -108,7 +109,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID)
}
- logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{
+ logger.DebugCF("dingtalk", "Sending message", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
@@ -120,12 +121,14 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
// This is called by the Stream SDK when a new message arrives
// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error)
-func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
+func (c *DingTalkChannel) onChatBotMessageReceived(
+ ctx context.Context, data *chatbot.BotCallbackDataModel,
+) ([]byte, error) {
// Extract message content from Text field
content := data.Text.Content
if content == "" {
// Try to extract from Content interface{} if Text is empty
- if contentMap, ok := data.Content.(map[string]interface{}); ok {
+ if contentMap, ok := data.Content.(map[string]any); ok {
if textContent, ok := contentMap["content"].(string); ok {
content = textContent
}
@@ -155,7 +158,7 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch
"session_webhook": data.SessionWebhook,
}
- logger.DebugCF("dingtalk", "Received message", map[string]interface{}{
+ logger.DebugCF("dingtalk", "Received message", map[string]any{
"sender_nick": senderNick,
"sender_id": senderID,
"preview": utils.Truncate(content, 50),
@@ -184,7 +187,6 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
titleBytes,
contentBytes,
)
-
if err != nil {
return fmt.Errorf("failed to send reply: %w", err)
}
diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go
index f360c75ef..74ae44412 100644
--- a/pkg/channels/discord.go
+++ b/pkg/channels/discord.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/bwmarrin/discordgo"
+
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -106,7 +107,9 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return nil
}
- chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks
+ chunks := splitMessage(
+ msg.Content, 1500,
+ ) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks
for _, chunk := range chunks {
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
diff --git a/pkg/channels/feishu_32.go b/pkg/channels/feishu_32.go
index 4e60fbc11..5109b8195 100644
--- a/pkg/channels/feishu_32.go
+++ b/pkg/channels/feishu_32.go
@@ -17,7 +17,9 @@ type FeishuChannel struct {
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
- return nil, errors.New("feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config")
+ return nil, errors.New(
+ "feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
+ )
}
// Start is a stub method to satisfy the Channel interface
diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu_64.go
index 39dc40ac1..29d4001cb 100644
--- a/pkg/channels/feishu_64.go
+++ b/pkg/channels/feishu_64.go
@@ -65,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
go func() {
if err := wsClient.Start(runCtx); err != nil {
- logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{
+ logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{
"error": err.Error(),
})
}
@@ -121,7 +121,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
}
- logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{
+ logger.DebugCF("feishu", "Feishu message sent", map[string]any{
"chat_id": msg.ChatID,
})
@@ -165,7 +165,7 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
metadata["tenant_key"] = *sender.TenantKey
}
- logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{
+ logger.InfoCF("feishu", "Feishu message received", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"preview": utils.Truncate(content, 80),
diff --git a/pkg/channels/line.go b/pkg/channels/line.go
index ffb5533e8..f7ca98c92 100644
--- a/pkg/channels/line.go
+++ b/pkg/channels/line.go
@@ -75,11 +75,11 @@ func (c *LINEChannel) Start(ctx context.Context) error {
// Fetch bot profile to get bot's userId for mention detection
if err := c.fetchBotInfo(); err != nil {
- logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{
+ logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
"error": err.Error(),
})
} else {
- logger.InfoCF("line", "Bot info fetched", map[string]interface{}{
+ logger.InfoCF("line", "Bot info fetched", map[string]any{
"bot_user_id": c.botUserID,
"basic_id": c.botBasicID,
"display_name": c.botDisplayName,
@@ -100,12 +100,12 @@ func (c *LINEChannel) Start(ctx context.Context) error {
}
go func() {
- logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{
+ logger.InfoCF("line", "LINE webhook server listening", map[string]any{
"addr": addr,
"path": path,
})
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("line", "Webhook server error", map[string]interface{}{
+ logger.ErrorCF("line", "Webhook server error", map[string]any{
"error": err.Error(),
})
}
@@ -162,7 +162,7 @@ func (c *LINEChannel) Stop(ctx context.Context) error {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
- logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{
+ logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{
"error": err.Error(),
})
}
@@ -182,7 +182,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
- logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{
+ logger.ErrorCF("line", "Failed to read request body", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
@@ -200,7 +200,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
Events []lineEvent `json:"events"`
}
if err := json.Unmarshal(body, &payload); err != nil {
- logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
+ logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
@@ -266,7 +266,7 @@ type lineMentionee struct {
func (c *LINEChannel) processEvent(event lineEvent) {
if event.Type != "message" {
- logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{
+ logger.DebugCF("line", "Ignoring non-message event", map[string]any{
"type": event.Type,
})
return
@@ -278,7 +278,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
var msg lineMessage
if err := json.Unmarshal(event.Message, &msg); err != nil {
- logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
+ logger.ErrorCF("line", "Failed to parse message", map[string]any{
"error": err.Error(),
})
return
@@ -286,7 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
// In group chats, only respond when the bot is mentioned
if isGroup && !c.isBotMentioned(msg) {
- logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{
+ logger.DebugCF("line", "Ignoring group message without mention", map[string]any{
"chat_id": chatID,
})
return
@@ -312,7 +312,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
- logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{
+ logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
@@ -366,7 +366,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
"message_id": msg.ID,
}
- logger.DebugCF("line", "Received message", map[string]interface{}{
+ logger.DebugCF("line", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"message_type": msg.Type,
@@ -497,7 +497,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
tokenEntry := entry.(replyTokenEntry)
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
- logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{
+ logger.DebugCF("line", "Message sent via Reply API", map[string]any{
"chat_id": msg.ChatID,
"quoted": quoteToken != "",
})
@@ -525,7 +525,7 @@ func buildTextMessage(content, quoteToken string) map[string]string {
// sendReply sends a message using the LINE Reply API.
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
- payload := map[string]interface{}{
+ payload := map[string]any{
"replyToken": replyToken,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
@@ -535,7 +535,7 @@ func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteT
// sendPush sends a message using the LINE Push API.
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
- payload := map[string]interface{}{
+ payload := map[string]any{
"to": to,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
@@ -545,19 +545,19 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri
// sendLoading sends a loading animation indicator to the chat.
func (c *LINEChannel) sendLoading(chatID string) {
- payload := map[string]interface{}{
+ payload := map[string]any{
"chatId": chatID,
"loadingSeconds": 60,
}
if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
- logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{
+ logger.DebugCF("line", "Failed to send loading indicator", map[string]any{
"error": err.Error(),
})
}
}
// callAPI makes an authenticated POST request to the LINE API.
-func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error {
+func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go
index 01e570b25..6288a792c 100644
--- a/pkg/channels/maixcam.go
+++ b/pkg/channels/maixcam.go
@@ -21,10 +21,10 @@ type MaixCamChannel struct {
}
type MaixCamMessage struct {
- Type string `json:"type"`
- Tips string `json:"tips"`
- Timestamp float64 `json:"timestamp"`
- Data map[string]interface{} `json:"data"`
+ Type string `json:"type"`
+ Tips string `json:"tips"`
+ Timestamp float64 `json:"timestamp"`
+ Data map[string]any `json:"data"`
}
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
@@ -49,7 +49,7 @@ func (c *MaixCamChannel) Start(ctx context.Context) error {
c.listener = listener
c.setRunning(true)
- logger.InfoCF("maixcam", "MaixCam server listening", map[string]interface{}{
+ logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{
"host": c.config.Host,
"port": c.config.Port,
})
@@ -71,14 +71,14 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
conn, err := c.listener.Accept()
if err != nil {
if c.running {
- logger.ErrorCF("maixcam", "Failed to accept connection", map[string]interface{}{
+ logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{
"error": err.Error(),
})
}
return
}
- logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]interface{}{
+ logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]any{
"remote_addr": conn.RemoteAddr().String(),
})
@@ -112,7 +112,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
var msg MaixCamMessage
if err := decoder.Decode(&msg); err != nil {
if err.Error() != "EOF" {
- logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{
+ logger.ErrorCF("maixcam", "Failed to decode message", map[string]any{
"error": err.Error(),
})
}
@@ -133,14 +133,14 @@ func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) {
case "status":
c.handleStatusUpdate(msg)
default:
- logger.WarnCF("maixcam", "Unknown message type", map[string]interface{}{
+ logger.WarnCF("maixcam", "Unknown message type", map[string]any{
"type": msg.Type,
})
}
}
func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
- logger.InfoCF("maixcam", "", map[string]interface{}{
+ logger.InfoCF("maixcam", "", map[string]any{
"timestamp": msg.Timestamp,
"data": msg.Data,
})
@@ -176,7 +176,7 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
}
func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
- logger.InfoCF("maixcam", "Status update from MaixCam", map[string]interface{}{
+ logger.InfoCF("maixcam", "Status update from MaixCam", map[string]any{
"status": msg.Data,
})
}
@@ -214,7 +214,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return fmt.Errorf("no connected MaixCam devices")
}
- response := map[string]interface{}{
+ response := map[string]any{
"type": "command",
"timestamp": float64(0),
"message": msg.Content,
@@ -229,7 +229,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
var sendErr error
for conn := range c.clients {
if _, err := conn.Write(data); err != nil {
- logger.ErrorCF("maixcam", "Failed to send to client", map[string]interface{}{
+ logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{
"client": conn.RemoteAddr().String(),
"error": err.Error(),
})
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 7f6abc4cb..3ffaf5fb7 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -50,7 +50,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Telegram channel")
telegram, err := NewTelegramChannel(m.config, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -63,7 +63,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -76,7 +76,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Feishu channel")
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -89,7 +89,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Discord channel")
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -102,7 +102,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -115,7 +115,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize QQ channel")
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -128,7 +128,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -141,7 +141,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Slack channel")
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -154,7 +154,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize LINE channel")
line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -167,7 +167,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize OneBot channel")
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -176,7 +176,7 @@ func (m *Manager) initChannels() error {
}
}
- logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
+ logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})
@@ -200,11 +200,11 @@ func (m *Manager) StartAll(ctx context.Context) error {
go m.dispatchOutbound(dispatchCtx)
for name, channel := range m.channels {
- logger.InfoCF("channels", "Starting channel", map[string]interface{}{
+ 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]interface{}{
+ logger.ErrorCF("channels", "Failed to start channel", map[string]any{
"channel": name,
"error": err.Error(),
})
@@ -227,11 +227,11 @@ func (m *Manager) StopAll(ctx context.Context) error {
}
for name, channel := range m.channels {
- logger.InfoCF("channels", "Stopping channel", map[string]interface{}{
+ 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]interface{}{
+ logger.ErrorCF("channels", "Error stopping channel", map[string]any{
"channel": name,
"error": err.Error(),
})
@@ -266,14 +266,14 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
m.mu.RUnlock()
if !exists {
- logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{
+ logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{
"channel": msg.Channel,
})
continue
}
if err := channel.Send(ctx, msg); err != nil {
- logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Error sending message to channel", map[string]any{
"channel": msg.Channel,
"error": err.Error(),
})
@@ -289,13 +289,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) {
return channel, ok
}
-func (m *Manager) GetStatus() map[string]interface{} {
+func (m *Manager) GetStatus() map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
- status := make(map[string]interface{})
+ status := make(map[string]any)
for name, channel := range m.channels {
- status[name] = map[string]interface{}{
+ status[name] = map[string]any{
"enabled": true,
"running": channel.IsRunning(),
}
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
index 5d97fab9c..607aaed2a 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot.go
@@ -76,9 +76,9 @@ type oneBotEvent struct {
}
type oneBotAPIRequest struct {
- Action string `json:"action"`
- Params interface{} `json:"params"`
- Echo string `json:"echo,omitempty"`
+ Action string `json:"action"`
+ Params any `json:"params"`
+ Echo string `json:"echo,omitempty"`
}
type oneBotSendPrivateMsgParams struct {
@@ -109,14 +109,14 @@ func (c *OneBotChannel) Start(ctx context.Context) error {
return fmt.Errorf("OneBot ws_url not configured")
}
- logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{
+ logger.InfoCF("onebot", "Starting OneBot channel", map[string]any{
"ws_url": c.config.WSUrl,
})
c.ctx, c.cancel = context.WithCancel(ctx)
if err := c.connect(); err != nil {
- logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{
+ logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]any{
"error": err.Error(),
})
} else {
@@ -178,7 +178,7 @@ func (c *OneBotChannel) reconnectLoop() {
if conn == nil {
logger.InfoC("onebot", "Attempting to reconnect...")
if err := c.connect(); err != nil {
- logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{
+ logger.ErrorCF("onebot", "Reconnect failed", map[string]any{
"error": err.Error(),
})
} else {
@@ -246,7 +246,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
c.writeMu.Unlock()
if err != nil {
- logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{
+ logger.ErrorCF("onebot", "Failed to send message", map[string]any{
"error": err.Error(),
})
return err
@@ -255,7 +255,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return nil
}
-func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
+func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) {
chatID := msg.ChatID
if len(chatID) > 6 && chatID[:6] == "group:" {
@@ -308,7 +308,7 @@ func (c *OneBotChannel) listen() {
_, message, err := conn.ReadMessage()
if err != nil {
- logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
+ logger.ErrorCF("onebot", "WebSocket read error", map[string]any{
"error": err.Error(),
})
c.mu.Lock()
@@ -320,14 +320,14 @@ func (c *OneBotChannel) listen() {
return
}
- logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{
+ logger.DebugCF("onebot", "Raw WebSocket message received", map[string]any{
"length": len(message),
"payload": string(message),
})
var raw oneBotRawEvent
if err := json.Unmarshal(message, &raw); err != nil {
- logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{
+ logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]any{
"error": err.Error(),
"payload": string(message),
})
@@ -335,14 +335,14 @@ func (c *OneBotChannel) listen() {
}
if raw.Echo != "" || raw.Status.Online || raw.Status.Good {
- logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{
+ logger.DebugCF("onebot", "Received API response, skipping", map[string]any{
"echo": raw.Echo,
"status": raw.Status,
})
continue
}
- logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{
+ logger.DebugCF("onebot", "Parsed raw event", map[string]any{
"post_type": raw.PostType,
"message_type": raw.MessageType,
"sub_type": raw.SubType,
@@ -407,14 +407,14 @@ func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult
return parseMessageResult{Text: s, IsBotMentioned: mentioned}
}
- var segments []map[string]interface{}
+ var segments []map[string]any
if err := json.Unmarshal(raw, &segments); err == nil {
var text string
mentioned := false
selfIDStr := strconv.FormatInt(selfID, 10)
for _, seg := range segments {
segType, _ := seg["type"].(string)
- data, _ := seg["data"].(map[string]interface{})
+ data, _ := seg["data"].(map[string]any)
switch segType {
case "text":
if data != nil {
@@ -441,7 +441,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
case "message":
evt, err := c.normalizeMessageEvent(raw)
if err != nil {
- logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{
+ logger.WarnCF("onebot", "Failed to normalize message event", map[string]any{
"error": err.Error(),
})
return
@@ -450,20 +450,20 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
case "meta_event":
c.handleMetaEvent(raw)
case "notice":
- logger.DebugCF("onebot", "Notice event received", map[string]interface{}{
+ logger.DebugCF("onebot", "Notice event received", map[string]any{
"sub_type": raw.SubType,
})
case "request":
- logger.DebugCF("onebot", "Request event received", map[string]interface{}{
+ logger.DebugCF("onebot", "Request event received", map[string]any{
"sub_type": raw.SubType,
})
case "":
- logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{
+ logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]any{
"echo": raw.Echo,
"status": raw.Status,
})
default:
- logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
+ logger.DebugCF("onebot", "Unknown post_type", map[string]any{
"post_type": raw.PostType,
})
}
@@ -498,14 +498,14 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent
var sender oneBotSender
if len(raw.Sender) > 0 {
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
- logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{
+ logger.WarnCF("onebot", "Failed to parse sender", map[string]any{
"error": err.Error(),
"sender": string(raw.Sender),
})
}
}
- logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{
+ logger.DebugCF("onebot", "Normalized message event", map[string]any{
"message_type": raw.MessageType,
"user_id": userID,
"group_id": groupID,
@@ -534,13 +534,13 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent
func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
switch raw.MetaEventType {
case "lifecycle":
- logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{
+ logger.InfoCF("onebot", "Lifecycle event", map[string]any{
"sub_type": raw.SubType,
})
case "heartbeat":
logger.DebugC("onebot", "Heartbeat received")
default:
- logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{
+ logger.DebugCF("onebot", "Unknown meta_event_type", map[string]any{
"meta_event_type": raw.MetaEventType,
})
}
@@ -548,7 +548,7 @@ func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
if c.isDuplicate(evt.MessageID) {
- logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
+ logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{
"message_id": evt.MessageID,
})
return
@@ -556,7 +556,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
content := evt.Content
if content == "" {
- logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
+ logger.DebugCF("onebot", "Received empty message, ignoring", map[string]any{
"message_id": evt.MessageID,
})
return
@@ -572,7 +572,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
switch evt.MessageType {
case "private":
chatID = "private:" + senderID
- logger.InfoCF("onebot", "Received private message", map[string]interface{}{
+ logger.InfoCF("onebot", "Received private message", map[string]any{
"sender": senderID,
"message_id": evt.MessageID,
"length": len(content),
@@ -597,7 +597,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned)
if !triggered {
- logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
+ logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{
"sender": senderID,
"group": groupIDStr,
"is_mentioned": evt.IsBotMentioned,
@@ -607,7 +607,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
}
content = strippedContent
- logger.InfoCF("onebot", "Received group message", map[string]interface{}{
+ logger.InfoCF("onebot", "Received group message", map[string]any{
"sender": senderID,
"group": groupIDStr,
"message_id": evt.MessageID,
@@ -617,7 +617,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
})
default:
- logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
+ logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]any{
"type": evt.MessageType,
"message_id": evt.MessageID,
"user_id": evt.UserID,
@@ -629,7 +629,7 @@ func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
metadata["nickname"] = evt.Sender.Nickname
}
- logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{
+ logger.DebugCF("onebot", "Forwarding message to bus", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"content": truncate(content, 100),
@@ -668,7 +668,10 @@ func truncate(s string, n int) string {
return string(runes[:n]) + "..."
}
-func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) {
+func (c *OneBotChannel) checkGroupTrigger(
+ content string,
+ isBotMentioned bool,
+) (triggered bool, strippedContent string) {
if isBotMentioned {
return true, strings.TrimSpace(content)
}
diff --git a/pkg/channels/qq.go b/pkg/channels/qq.go
index 18b4ca0e0..055498797 100644
--- a/pkg/channels/qq.go
+++ b/pkg/channels/qq.go
@@ -77,7 +77,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to get websocket info: %w", err)
}
- logger.InfoCF("qq", "Got WebSocket info", map[string]interface{}{
+ logger.InfoCF("qq", "Got WebSocket info", map[string]any{
"shards": wsInfo.Shards,
})
@@ -87,7 +87,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
// åØ goroutine äøåÆåØ WebSocket čæę„ļ¼éæå
é»å”
go func() {
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
- logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{
+ logger.ErrorCF("qq", "WebSocket session error", map[string]any{
"error": err.Error(),
})
c.setRunning(false)
@@ -124,7 +124,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
// C2C ę¶ęÆåé
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
if err != nil {
- logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{
+ logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
"error": err.Error(),
})
return err
@@ -157,7 +157,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return nil
}
- logger.InfoCF("qq", "Received C2C message", map[string]interface{}{
+ logger.InfoCF("qq", "Received C2C message", map[string]any{
"sender": senderID,
"length": len(content),
})
@@ -197,7 +197,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return nil
}
- logger.InfoCF("qq", "Received group AT message", map[string]interface{}{
+ logger.InfoCF("qq", "Received group AT message", map[string]any{
"sender": senderID,
"group": data.GroupID,
"length": len(content),
diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go
index 0060972ed..f7359cd6d 100644
--- a/pkg/channels/slack.go
+++ b/pkg/channels/slack.go
@@ -75,7 +75,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
c.botUserID = authResp.UserID
c.teamID = authResp.TeamID
- logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{
+ logger.InfoCF("slack", "Slack bot connected", map[string]any{
"bot_user_id": c.botUserID,
"team": authResp.Team,
})
@@ -85,7 +85,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
go func() {
if err := c.socketClient.RunContext(c.ctx); err != nil {
if c.ctx.Err() == nil {
- logger.ErrorCF("slack", "Socket Mode connection error", map[string]interface{}{
+ logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{
"error": err.Error(),
})
}
@@ -140,7 +140,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
})
}
- logger.DebugCF("slack", "Message sent", map[string]interface{}{
+ logger.DebugCF("slack", "Message sent", map[string]any{
"channel_id": channelID,
"thread_ts": threadTS,
})
@@ -202,7 +202,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
// ę£ę„ē½ååļ¼éæå
为被ęē»ēēØę·äøč½½éä»¶
if !c.IsAllowed(ev.User) {
- logger.DebugCF("slack", "Message rejected by allowlist", map[string]interface{}{
+ logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
"user_id": ev.User,
})
return
@@ -238,7 +238,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
- logger.DebugCF("slack", "Failed to cleanup temp file", map[string]interface{}{
+ logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
@@ -261,7 +261,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
result, err := c.transcriber.Transcribe(ctx, localPath)
if err != nil {
- logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()})
+ logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()})
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
} else {
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
@@ -293,7 +293,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
"team_id": c.teamID,
}
- logger.DebugCF("slack", "Received message", map[string]interface{}{
+ logger.DebugCF("slack", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"preview": utils.Truncate(content, 50),
@@ -309,7 +309,7 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
}
if !c.IsAllowed(ev.User) {
- logger.DebugCF("slack", "Mention rejected by allowlist", map[string]interface{}{
+ logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
"user_id": ev.User,
})
return
@@ -375,7 +375,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
}
if !c.IsAllowed(cmd.UserID) {
- logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{
+ logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
"user_id": cmd.UserID,
})
return
@@ -400,7 +400,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
"team_id": c.teamID,
}
- logger.DebugCF("slack", "Slash command received", map[string]interface{}{
+ logger.DebugCF("slack", "Slash command received", map[string]any{
"sender_id": senderID,
"command": cmd.Command,
"text": utils.Truncate(content, 50),
@@ -415,7 +415,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string {
downloadURL = file.URLPrivate
}
if downloadURL == "" {
- logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID})
+ logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID})
return ""
}
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index 24b82b557..eb5bedaaf 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -11,10 +11,9 @@ import (
"sync"
"time"
- th "github.com/mymmrac/telego/telegohandler"
-
"github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegohandler"
+ th "github.com/mymmrac/telego/telegohandler"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -120,7 +119,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
}, th.AnyMessage())
c.setRunning(true)
- logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{
+ logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
"username": c.bot.Username(),
})
@@ -133,6 +132,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return nil
}
+
func (c *TelegramChannel) Stop(ctx context.Context) error {
logger.InfoC("telegram", "Stopping Telegram bot...")
c.setRunning(false)
@@ -175,7 +175,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
tgMsg.ParseMode = telego.ModeHTML
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
- logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{
+ logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
"error": err.Error(),
})
tgMsg.ParseMode = ""
@@ -203,7 +203,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
// ę£ę„ē½ååļ¼éæå
为被ęē»ēēØę·äøč½½éä»¶
if !c.IsAllowed(senderID) {
- logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
+ logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
"user_id": senderID,
})
return nil
@@ -220,7 +220,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
- logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]interface{}{
+ logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
@@ -265,14 +265,14 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
result, err := c.transcriber.Transcribe(ctx, voicePath)
if err != nil {
- logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{
+ logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{
"error": err.Error(),
"path": voicePath,
})
transcribedText = "[voice (transcription failed)]"
} else {
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
- logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{
+ logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{
"text": result.Text,
})
}
@@ -315,7 +315,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content = "[empty message]"
}
- logger.DebugCF("telegram", "Received message", map[string]interface{}{
+ logger.DebugCF("telegram", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": fmt.Sprintf("%d", chatID),
"preview": utils.Truncate(content, 50),
@@ -324,7 +324,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
// Thinking indicator
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
if err != nil {
- logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{
+ logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{
"error": err.Error(),
})
}
@@ -371,7 +371,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {
- logger.ErrorCF("telegram", "Failed to get photo file", map[string]interface{}{
+ logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{
"error": err.Error(),
})
return ""
@@ -386,7 +386,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
}
url := c.bot.FileDownloadURL(file.FilePath)
- logger.DebugCF("telegram", "File URL", map[string]interface{}{"url": url})
+ logger.DebugCF("telegram", "File URL", map[string]any{"url": url})
// Use FilePath as filename for better identification
filename := file.FilePath + ext
@@ -398,7 +398,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {
- logger.ErrorCF("telegram", "Failed to get file", map[string]interface{}{
+ logger.ErrorCF("telegram", "Failed to get file", map[string]any{
"error": err.Error(),
})
return ""
@@ -456,7 +456,11 @@ func markdownToTelegramHTML(text string) string {
for i, code := range codeBlocks.codes {
escaped := escapeHTML(code)
- text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("%s
", escaped))
+ text = strings.ReplaceAll(
+ text,
+ fmt.Sprintf("\x00CB%d\x00", i),
+ fmt.Sprintf("%s
", escaped),
+ )
}
return text
diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go
index df245e156..a084b641b 100644
--- a/pkg/channels/telegram_commands.go
+++ b/pkg/channels/telegram_commands.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/mymmrac/telego"
+
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -35,6 +36,7 @@ func commandArgs(text string) string {
}
return strings.TrimSpace(parts[1])
}
+
func (c *cmd) Help(ctx context.Context, message telego.Message) error {
msg := `/start - Start the bot
/help - Show this help message
@@ -96,6 +98,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
})
return err
}
+
func (c *cmd) List(ctx context.Context, message telego.Message) error {
args := commandArgs(message.Text)
if args == "" {
diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp.go
index c95e59578..6634f2722 100644
--- a/pkg/channels/whatsapp.go
+++ b/pkg/channels/whatsapp.go
@@ -86,7 +86,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("whatsapp connection not established")
}
- payload := map[string]interface{}{
+ payload := map[string]any{
"type": "message",
"to": msg.ChatID,
"content": msg.Content,
@@ -126,7 +126,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
continue
}
- var msg map[string]interface{}
+ var msg map[string]any
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("Failed to unmarshal WhatsApp message: %v", err)
continue
@@ -144,7 +144,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
}
}
-func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
+func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
senderID, ok := msg["from"].(string)
if !ok {
return
@@ -161,7 +161,7 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
}
var mediaPaths []string
- if mediaData, ok := msg["media"].([]interface{}); ok {
+ if mediaData, ok := msg["media"].([]any); ok {
mediaPaths = make([]string, 0, len(mediaData))
for _, m := range mediaData {
if path, ok := m.(string); ok {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 682996bd6..306fc1f34 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -23,7 +23,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
}
// Try []interface{} to handle mixed types
- var raw []interface{}
+ var raw []any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
@@ -139,16 +139,16 @@ type SessionConfig struct {
}
type AgentDefaults struct {
- Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
- RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
- Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
- Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
+ Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
+ RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
+ Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
+ Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
- ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
+ ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
- MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
- Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
- MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
+ MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
+ Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
+ MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
type ChannelsConfig struct {
@@ -165,87 +165,87 @@ type ChannelsConfig struct {
}
type WhatsAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
}
type TelegramConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
- Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
+ Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
}
type FeishuConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
- EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
+ AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
+ EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
}
type DiscordConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
}
type MaixCamConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
- Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
- Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
+ Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
+ Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
}
type QQConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
}
type DingTalkConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
- ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
+ ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
}
type SlackConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
- BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
- AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
+ BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
+ AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
}
type LINEConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
- ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
+ ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
}
type OneBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
- WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
- ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
+ WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
+ ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
}
type HeartbeatConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
}
type DevicesConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
}
@@ -266,11 +266,11 @@ type ProvidersConfig struct {
}
type ProviderConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
- APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
- AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
- ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
+ APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
+ APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
+ AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
}
type OpenAIProviderConfig struct {
@@ -284,19 +284,19 @@ type GatewayConfig struct {
}
type BraveConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
type DuckDuckGoConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
type PerplexityConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
@@ -483,11 +483,11 @@ func SaveConfig(path string, cfg *Config) error {
}
dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0755); err != nil {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
- return os.WriteFile(path, data, 0600)
+ return os.WriteFile(path, data, 0o600)
}
func (c *Config) WorkspacePath() string {
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 47916d155..8da0d214f 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -55,7 +55,7 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
if err != nil {
t.Fatalf("marshal: %v", err)
}
- var result map[string]interface{}
+ var result map[string]any
json.Unmarshal(data, &result)
if result["primary"] != "claude-opus" {
t.Errorf("primary = %v", result["primary"])
@@ -319,7 +319,7 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
}
perm := info.Mode().Perm()
- if perm != 0600 {
+ if perm != 0o600 {
t.Errorf("config file has permission %04o, want 0600", perm)
}
}
diff --git a/pkg/cron/service.go b/pkg/cron/service.go
index 9f62c743b..e699a44b5 100644
--- a/pkg/cron/service.go
+++ b/pkg/cron/service.go
@@ -331,7 +331,7 @@ func (cs *CronService) loadStore() error {
func (cs *CronService) saveStoreUnsafe() error {
dir := filepath.Dir(cs.storePath)
- if err := os.MkdirAll(dir, 0755); err != nil {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@@ -340,10 +340,16 @@ func (cs *CronService) saveStoreUnsafe() error {
return err
}
- return os.WriteFile(cs.storePath, data, 0600)
+ return os.WriteFile(cs.storePath, data, 0o600)
}
-func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) {
+func (cs *CronService) AddJob(
+ name string,
+ schedule CronSchedule,
+ message string,
+ deliver bool,
+ channel, to string,
+) (*CronJob, error) {
cs.mu.Lock()
defer cs.mu.Unlock()
@@ -465,7 +471,7 @@ func (cs *CronService) ListJobs(includeDisabled bool) []CronJob {
return enabled
}
-func (cs *CronService) Status() map[string]interface{} {
+func (cs *CronService) Status() map[string]any {
cs.mu.RLock()
defer cs.mu.RUnlock()
@@ -476,7 +482,7 @@ func (cs *CronService) Status() map[string]interface{} {
}
}
- return map[string]interface{}{
+ return map[string]any{
"enabled": cs.running,
"jobs": len(cs.store.Jobs),
"nextWakeAtMS": cs.getNextWakeMS(),
diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go
index 53d69f6a9..1a0dd1829 100644
--- a/pkg/cron/service_test.go
+++ b/pkg/cron/service_test.go
@@ -28,7 +28,7 @@ func TestSaveStore_FilePermissions(t *testing.T) {
}
perm := info.Mode().Perm()
- if perm != 0600 {
+ if perm != 0o600 {
t.Errorf("cron store has permission %04o, want 0600", perm)
}
}
diff --git a/pkg/devices/service.go b/pkg/devices/service.go
index 05a254729..1541d3c57 100644
--- a/pkg/devices/service.go
+++ b/pkg/devices/service.go
@@ -63,14 +63,14 @@ func (s *Service) Start(ctx context.Context) error {
for _, src := range s.sources {
eventCh, err := src.Start(s.ctx)
if err != nil {
- logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{
+ logger.ErrorCF("devices", "Failed to start source", map[string]any{
"kind": src.Kind(),
"error": err.Error(),
})
continue
}
go s.handleEvents(src.Kind(), eventCh)
- logger.InfoCF("devices", "Device source started", map[string]interface{}{
+ logger.InfoCF("devices", "Device source started", map[string]any{
"kind": src.Kind(),
})
}
@@ -115,7 +115,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
lastChannel := s.state.GetLastChannel()
if lastChannel == "" {
- logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{
+ logger.DebugCF("devices", "No last channel, skipping notification", map[string]any{
"event": ev.FormatMessage(),
})
return
@@ -133,7 +133,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
Content: msg,
})
- logger.InfoCF("devices", "Device notification sent", map[string]interface{}{
+ logger.InfoCF("devices", "Device notification sent", map[string]any{
"kind": ev.Kind,
"action": ev.Action,
"to": platform,
diff --git a/pkg/devices/sources/usb_linux.go b/pkg/devices/sources/usb_linux.go
index 1f6c068b3..be0193cfb 100644
--- a/pkg/devices/sources/usb_linux.go
+++ b/pkg/devices/sources/usb_linux.go
@@ -115,7 +115,7 @@ func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, err
}
if err := scanner.Err(); err != nil {
- logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()})
+ logger.ErrorCF("devices", "udevadm scan error", map[string]any{"error": err.Error()})
}
cmd.Wait()
}()
diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go
index dfdaef58b..75d6248b9 100644
--- a/pkg/heartbeat/service.go
+++ b/pkg/heartbeat/service.go
@@ -193,7 +193,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
if result.Async {
hs.logInfo("Async task started: %s", result.ForLLM)
logger.InfoCF("heartbeat", "Async heartbeat task started",
- map[string]interface{}{
+ map[string]any{
"message": result.ForLLM,
})
return
@@ -275,7 +275,7 @@ This file contains tasks for the heartbeat service to check periodically.
Add your heartbeat tasks below this line:
`
- if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0644); err != nil {
+ if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
hs.logError("Failed to create default HEARTBEAT.md: %v", err)
} else {
hs.logInfo("Created default HEARTBEAT.md template")
@@ -354,7 +354,7 @@ func (hs *HeartbeatService) logError(format string, args ...any) {
// log writes a message to the heartbeat log file
func (hs *HeartbeatService) log(level, format string, args ...any) {
logFile := filepath.Join(hs.workspace, "heartbeat.log")
- f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return
}
diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go
index a2b59e350..a4dfa7a72 100644
--- a/pkg/heartbeat/service_test.go
+++ b/pkg/heartbeat/service_test.go
@@ -37,7 +37,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
})
// Create HEARTBEAT.md
- os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
+ os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
// Execute heartbeat directly (internal method for testing)
hs.executeHeartbeat()
@@ -68,7 +68,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) {
})
// Create HEARTBEAT.md
- os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
+ os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat()
@@ -106,7 +106,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) {
})
// Create HEARTBEAT.md
- os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
+ os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat()
@@ -174,7 +174,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) {
})
// Create HEARTBEAT.md
- os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
+ os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
// Should not panic with nil result
hs.executeHeartbeat()
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index 22f66829f..54de66bf9 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -41,12 +41,12 @@ type Logger struct {
}
type LogEntry struct {
- Level string `json:"level"`
- Timestamp string `json:"timestamp"`
- Component string `json:"component,omitempty"`
- Message string `json:"message"`
- Fields map[string]interface{} `json:"fields,omitempty"`
- Caller string `json:"caller,omitempty"`
+ Level string `json:"level"`
+ Timestamp string `json:"timestamp"`
+ Component string `json:"component,omitempty"`
+ Message string `json:"message"`
+ Fields map[string]any `json:"fields,omitempty"`
+ Caller string `json:"caller,omitempty"`
}
func init() {
@@ -71,7 +71,7 @@ func EnableFileLogging(filePath string) error {
mu.Lock()
defer mu.Unlock()
- file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+ file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
@@ -96,7 +96,7 @@ func DisableFileLogging() {
}
}
-func logMessage(level LogLevel, component string, message string, fields map[string]interface{}) {
+func logMessage(level LogLevel, component string, message string, fields map[string]any) {
if level < currentLevel {
return
}
@@ -150,7 +150,7 @@ func formatComponent(component string) string {
return fmt.Sprintf(" %s:", component)
}
-func formatFields(fields map[string]interface{}) string {
+func formatFields(fields map[string]any) string {
var parts []string
for k, v := range fields {
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
@@ -166,11 +166,11 @@ func DebugC(component string, message string) {
logMessage(DEBUG, component, message, nil)
}
-func DebugF(message string, fields map[string]interface{}) {
+func DebugF(message string, fields map[string]any) {
logMessage(DEBUG, "", message, fields)
}
-func DebugCF(component string, message string, fields map[string]interface{}) {
+func DebugCF(component string, message string, fields map[string]any) {
logMessage(DEBUG, component, message, fields)
}
@@ -182,11 +182,11 @@ func InfoC(component string, message string) {
logMessage(INFO, component, message, nil)
}
-func InfoF(message string, fields map[string]interface{}) {
+func InfoF(message string, fields map[string]any) {
logMessage(INFO, "", message, fields)
}
-func InfoCF(component string, message string, fields map[string]interface{}) {
+func InfoCF(component string, message string, fields map[string]any) {
logMessage(INFO, component, message, fields)
}
@@ -198,11 +198,11 @@ func WarnC(component string, message string) {
logMessage(WARN, component, message, nil)
}
-func WarnF(message string, fields map[string]interface{}) {
+func WarnF(message string, fields map[string]any) {
logMessage(WARN, "", message, fields)
}
-func WarnCF(component string, message string, fields map[string]interface{}) {
+func WarnCF(component string, message string, fields map[string]any) {
logMessage(WARN, component, message, fields)
}
@@ -214,11 +214,11 @@ func ErrorC(component string, message string) {
logMessage(ERROR, component, message, nil)
}
-func ErrorF(message string, fields map[string]interface{}) {
+func ErrorF(message string, fields map[string]any) {
logMessage(ERROR, "", message, fields)
}
-func ErrorCF(component string, message string, fields map[string]interface{}) {
+func ErrorCF(component string, message string, fields map[string]any) {
logMessage(ERROR, component, message, fields)
}
@@ -230,10 +230,10 @@ func FatalC(component string, message string) {
logMessage(FATAL, component, message, nil)
}
-func FatalF(message string, fields map[string]interface{}) {
+func FatalF(message string, fields map[string]any) {
logMessage(FATAL, "", message, fields)
}
-func FatalCF(component string, message string, fields map[string]interface{}) {
+func FatalCF(component string, message string, fields map[string]any) {
logMessage(FATAL, component, message, fields)
}
diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go
index 9b9c96820..6e6f8dfa8 100644
--- a/pkg/logger/logger_test.go
+++ b/pkg/logger/logger_test.go
@@ -54,11 +54,11 @@ func TestLoggerWithComponent(t *testing.T) {
name string
component string
message string
- fields map[string]interface{}
+ fields map[string]any
}{
{"Simple message", "test", "Hello, world!", nil},
{"Message with component", "discord", "Discord message", nil},
- {"Message with fields", "telegram", "Telegram message", map[string]interface{}{
+ {"Message with fields", "telegram", "Telegram message", map[string]any{
"user_id": "12345",
"count": 42,
}},
@@ -128,12 +128,12 @@ func TestLoggerHelperFunctions(t *testing.T) {
Error("This should log")
InfoC("test", "Component message")
- InfoF("Fields message", map[string]interface{}{"key": "value"})
+ InfoF("Fields message", map[string]any{"key": "value"})
WarnC("test", "Warning with component")
- ErrorF("Error with fields", map[string]interface{}{"error": "test"})
+ ErrorF("Error with fields", map[string]any{"error": "test"})
SetLevel(DEBUG)
DebugC("test", "Debug with component")
- WarnF("Warning with fields", map[string]interface{}{"key": "value"})
+ WarnF("Warning with fields", map[string]any{"key": "value"})
}
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
index 57032e566..c7b1acb58 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -44,26 +44,26 @@ func findOpenClawConfig(openclawHome string) (string, error) {
return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome)
}
-func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) {
+func LoadOpenClawConfig(configPath string) (map[string]any, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("reading OpenClaw config: %w", err)
}
- var raw map[string]interface{}
+ var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
}
converted := convertKeysToSnake(raw)
- result, ok := converted.(map[string]interface{})
+ result, ok := converted.(map[string]any)
if !ok {
return nil, fmt.Errorf("unexpected config format")
}
return result, nil
}
-func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error) {
+func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
cfg := config.DefaultConfig()
var warnings []string
@@ -89,7 +89,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
if providers, ok := getMap(data, "providers"); ok {
for name, val := range providers {
- pMap, ok := val.(map[string]interface{})
+ pMap, ok := val.(map[string]any)
if !ok {
continue
}
@@ -128,7 +128,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
if channels, ok := getMap(data, "channels"); ok {
for name, val := range channels {
- cMap, ok := val.(map[string]interface{})
+ cMap, ok := val.(map[string]any)
if !ok {
continue
}
@@ -306,16 +306,16 @@ func camelToSnake(s string) string {
return result.String()
}
-func convertKeysToSnake(data interface{}) interface{} {
+func convertKeysToSnake(data any) any {
switch v := data.(type) {
- case map[string]interface{}:
- result := make(map[string]interface{}, len(v))
+ case map[string]any:
+ result := make(map[string]any, len(v))
for key, val := range v {
result[camelToSnake(key)] = convertKeysToSnake(val)
}
return result
- case []interface{}:
- result := make([]interface{}, len(v))
+ case []any:
+ result := make([]any, len(v))
for i, val := range v {
result[i] = convertKeysToSnake(val)
}
@@ -330,16 +330,16 @@ func rewriteWorkspacePath(path string) string {
return path
}
-func getMap(data map[string]interface{}, key string) (map[string]interface{}, bool) {
+func getMap(data map[string]any, key string) (map[string]any, bool) {
v, ok := data[key]
if !ok {
return nil, false
}
- m, ok := v.(map[string]interface{})
+ m, ok := v.(map[string]any)
return m, ok
}
-func getString(data map[string]interface{}, key string) (string, bool) {
+func getString(data map[string]any, key string) (string, bool) {
v, ok := data[key]
if !ok {
return "", false
@@ -348,7 +348,7 @@ func getString(data map[string]interface{}, key string) (string, bool) {
return s, ok
}
-func getFloat(data map[string]interface{}, key string) (float64, bool) {
+func getFloat(data map[string]any, key string) (float64, bool) {
v, ok := data[key]
if !ok {
return 0, false
@@ -357,7 +357,7 @@ func getFloat(data map[string]interface{}, key string) (float64, bool) {
return f, ok
}
-func getBool(data map[string]interface{}, key string) (bool, bool) {
+func getBool(data map[string]any, key string) (bool, bool) {
v, ok := data[key]
if !ok {
return false, false
@@ -366,19 +366,19 @@ func getBool(data map[string]interface{}, key string) (bool, bool) {
return b, ok
}
-func getBoolOrDefault(data map[string]interface{}, key string, defaultVal bool) bool {
+func getBoolOrDefault(data map[string]any, key string, defaultVal bool) bool {
if v, ok := getBool(data, key); ok {
return v
}
return defaultVal
}
-func getStringSlice(data map[string]interface{}, key string) []string {
+func getStringSlice(data map[string]any, key string) []string {
v, ok := data[key]
if !ok {
return []string{}
}
- arr, ok := v.([]interface{})
+ arr, ok := v.([]any)
if !ok {
return []string{}
}
diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go
index 921f821cb..ab2635890 100644
--- a/pkg/migrate/migrate.go
+++ b/pkg/migrate/migrate.go
@@ -161,7 +161,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
fmt.Printf(" ā Converted config: %s\n", action.Destination)
}
case ActionCreateDir:
- if err := os.MkdirAll(action.Destination, 0755); err != nil {
+ if err := os.MkdirAll(action.Destination, 0o755); err != nil {
result.Errors = append(result.Errors, err)
} else {
result.DirsCreated++
@@ -174,9 +174,13 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
continue
}
result.BackupsCreated++
- fmt.Printf(" ā Backed up %s -> %s.bak\n", filepath.Base(action.Destination), filepath.Base(action.Destination))
+ fmt.Printf(
+ " ā Backed up %s -> %s.bak\n",
+ filepath.Base(action.Destination),
+ filepath.Base(action.Destination),
+ )
- if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
result.Errors = append(result.Errors, err)
continue
}
@@ -188,7 +192,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
fmt.Printf(" ā Copied %s\n", relPath(action.Source, openclawHome))
}
case ActionCopy:
- if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
result.Errors = append(result.Errors, err)
continue
}
@@ -226,7 +230,7 @@ func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) e
incoming = MergeConfig(existing, incoming)
}
- if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil {
return err
}
return config.SaveConfig(dstConfigPath, incoming)
diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go
index e930d45f4..a7c4b5337 100644
--- a/pkg/migrate/migrate_test.go
+++ b/pkg/migrate/migrate_test.go
@@ -40,20 +40,20 @@ func TestCamelToSnake(t *testing.T) {
}
func TestConvertKeysToSnake(t *testing.T) {
- input := map[string]interface{}{
+ input := map[string]any{
"apiKey": "test-key",
"apiBase": "https://example.com",
- "nested": map[string]interface{}{
+ "nested": map[string]any{
"maxTokens": float64(8192),
- "allowFrom": []interface{}{"user1", "user2"},
- "deeperLevel": map[string]interface{}{
+ "allowFrom": []any{"user1", "user2"},
+ "deeperLevel": map[string]any{
"clientId": "abc",
},
},
}
result := convertKeysToSnake(input)
- m, ok := result.(map[string]interface{})
+ m, ok := result.(map[string]any)
if !ok {
t.Fatal("expected map[string]interface{}")
}
@@ -65,7 +65,7 @@ func TestConvertKeysToSnake(t *testing.T) {
t.Error("expected key 'api_base' after conversion")
}
- nested, ok := m["nested"].(map[string]interface{})
+ nested, ok := m["nested"].(map[string]any)
if !ok {
t.Fatal("expected nested map")
}
@@ -76,7 +76,7 @@ func TestConvertKeysToSnake(t *testing.T) {
t.Error("expected key 'allow_from' in nested map")
}
- deeper, ok := nested["deeper_level"].(map[string]interface{})
+ deeper, ok := nested["deeper_level"].(map[string]any)
if !ok {
t.Fatal("expected deeper_level map")
}
@@ -89,15 +89,15 @@ func TestLoadOpenClawConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json")
- openclawConfig := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ openclawConfig := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-ant-test123",
"apiBase": "https://api.anthropic.com",
},
},
- "agents": map[string]interface{}{
- "defaults": map[string]interface{}{
+ "agents": map[string]any{
+ "defaults": map[string]any{
"maxTokens": float64(4096),
"model": "claude-3-opus",
},
@@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(configPath, data, 0644); err != nil {
+ if err := os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatal(err)
}
@@ -117,11 +117,11 @@ func TestLoadOpenClawConfig(t *testing.T) {
t.Fatalf("LoadOpenClawConfig: %v", err)
}
- providers, ok := result["providers"].(map[string]interface{})
+ providers, ok := result["providers"].(map[string]any)
if !ok {
t.Fatal("expected providers map")
}
- anthropic, ok := providers["anthropic"].(map[string]interface{})
+ anthropic, ok := providers["anthropic"].(map[string]any)
if !ok {
t.Fatal("expected anthropic map")
}
@@ -129,11 +129,11 @@ func TestLoadOpenClawConfig(t *testing.T) {
t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"])
}
- agents, ok := result["agents"].(map[string]interface{})
+ agents, ok := result["agents"].(map[string]any)
if !ok {
t.Fatal("expected agents map")
}
- defaults, ok := agents["defaults"].(map[string]interface{})
+ defaults, ok := agents["defaults"].(map[string]any)
if !ok {
t.Fatal("expected defaults map")
}
@@ -144,16 +144,16 @@ func TestLoadOpenClawConfig(t *testing.T) {
func TestConvertConfig(t *testing.T) {
t.Run("providers mapping", func(t *testing.T) {
- data := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ data := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"api_key": "sk-ant-test",
"api_base": "https://api.anthropic.com",
},
- "openrouter": map[string]interface{}{
+ "openrouter": map[string]any{
"api_key": "sk-or-test",
},
- "groq": map[string]interface{}{
+ "groq": map[string]any{
"api_key": "gsk-test",
},
},
@@ -178,9 +178,9 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("unsupported provider warning", func(t *testing.T) {
- data := map[string]interface{}{
- "providers": map[string]interface{}{
- "deepseek": map[string]interface{}{
+ data := map[string]any{
+ "providers": map[string]any{
+ "deepseek": map[string]any{
"api_key": "sk-deep-test",
},
},
@@ -199,14 +199,14 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("channels mapping", func(t *testing.T) {
- data := map[string]interface{}{
- "channels": map[string]interface{}{
- "telegram": map[string]interface{}{
+ data := map[string]any{
+ "channels": map[string]any{
+ "telegram": map[string]any{
"enabled": true,
"token": "tg-token-123",
- "allow_from": []interface{}{"user1"},
+ "allow_from": []any{"user1"},
},
- "discord": map[string]interface{}{
+ "discord": map[string]any{
"enabled": true,
"token": "disc-token-456",
},
@@ -232,9 +232,9 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("unsupported channel warning", func(t *testing.T) {
- data := map[string]interface{}{
- "channels": map[string]interface{}{
- "email": map[string]interface{}{
+ data := map[string]any{
+ "channels": map[string]any{
+ "email": map[string]any{
"enabled": true,
},
},
@@ -253,9 +253,9 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("agent defaults", func(t *testing.T) {
- data := map[string]interface{}{
- "agents": map[string]interface{}{
- "defaults": map[string]interface{}{
+ data := map[string]any{
+ "agents": map[string]any{
+ "defaults": map[string]any{
"model": "claude-3-opus",
"max_tokens": float64(4096),
"temperature": 0.5,
@@ -284,7 +284,7 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("empty config", func(t *testing.T) {
- data := map[string]interface{}{}
+ data := map[string]any{}
cfg, warnings, err := ConvertConfig(data)
if err != nil {
@@ -386,9 +386,9 @@ func TestPlanWorkspaceMigration(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
- os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644)
- os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644)
+ os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
+ os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0o644)
+ os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -417,8 +417,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
- os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644)
+ os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
+ os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -440,8 +440,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
- os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644)
+ os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
+ os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, true)
if err != nil {
@@ -460,8 +460,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
dstDir := t.TempDir()
memDir := filepath.Join(srcDir, "memory")
- os.MkdirAll(memDir, 0755)
- os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644)
+ os.MkdirAll(memDir, 0o755)
+ os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -491,8 +491,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
dstDir := t.TempDir()
skillDir := filepath.Join(srcDir, "skills", "weather")
- os.MkdirAll(skillDir, 0755)
- os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644)
+ os.MkdirAll(skillDir, 0o755)
+ os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -515,7 +515,7 @@ func TestFindOpenClawConfig(t *testing.T) {
t.Run("finds openclaw.json", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json")
- os.WriteFile(configPath, []byte("{}"), 0644)
+ os.WriteFile(configPath, []byte("{}"), 0o644)
found, err := findOpenClawConfig(tmpDir)
if err != nil {
@@ -529,7 +529,7 @@ func TestFindOpenClawConfig(t *testing.T) {
t.Run("falls back to config.json", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
- os.WriteFile(configPath, []byte("{}"), 0644)
+ os.WriteFile(configPath, []byte("{}"), 0o644)
found, err := findOpenClawConfig(tmpDir)
if err != nil {
@@ -543,8 +543,8 @@ func TestFindOpenClawConfig(t *testing.T) {
t.Run("prefers openclaw.json over config.json", func(t *testing.T) {
tmpDir := t.TempDir()
openclawPath := filepath.Join(tmpDir, "openclaw.json")
- os.WriteFile(openclawPath, []byte("{}"), 0644)
- os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644)
+ os.WriteFile(openclawPath, []byte("{}"), 0o644)
+ os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0o644)
found, err := findOpenClawConfig(tmpDir)
if err != nil {
@@ -590,19 +590,19 @@ func TestRunDryRun(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
- os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
+ os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "test-key",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
DryRun: true,
@@ -631,33 +631,33 @@ func TestRunFullMigration(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644)
- os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
- os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0o644)
+ os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
+ os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0o644)
memDir := filepath.Join(wsDir, "memory")
- os.MkdirAll(memDir, 0755)
- os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644)
+ os.MkdirAll(memDir, 0o755)
+ os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-ant-migrate-test",
},
- "openrouter": map[string]interface{}{
+ "openrouter": map[string]any{
"apiKey": "sk-or-migrate-test",
},
},
- "channels": map[string]interface{}{
- "telegram": map[string]interface{}{
+ "channels": map[string]any{
+ "telegram": map[string]any{
"enabled": true,
"token": "tg-migrate-test",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
Force: true,
@@ -751,7 +751,7 @@ func TestRunMutuallyExclusiveFlags(t *testing.T) {
func TestBackupFile(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.md")
- os.WriteFile(filePath, []byte("original content"), 0644)
+ os.WriteFile(filePath, []byte("original content"), 0o644)
if err := backupFile(filePath); err != nil {
t.Fatalf("backupFile: %v", err)
@@ -772,7 +772,7 @@ func TestCopyFile(t *testing.T) {
srcPath := filepath.Join(tmpDir, "src.md")
dstPath := filepath.Join(tmpDir, "dst.md")
- os.WriteFile(srcPath, []byte("file content"), 0644)
+ os.WriteFile(srcPath, []byte("file content"), 0o644)
if err := copyFile(srcPath, dstPath); err != nil {
t.Fatalf("copyFile: %v", err)
@@ -792,18 +792,18 @@ func TestRunConfigOnly(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-config-only",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
Force: true,
@@ -832,18 +832,18 @@ func TestRunWorkspaceOnly(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-ws-only",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
Force: true,
diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go
index 8f46aa70c..28e04b506 100644
--- a/pkg/providers/anthropic/provider.go
+++ b/pkg/providers/anthropic/provider.go
@@ -9,16 +9,19 @@ import (
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
+
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
-type ToolCall = protocoltypes.ToolCall
-type FunctionCall = protocoltypes.FunctionCall
-type LLMResponse = protocoltypes.LLMResponse
-type UsageInfo = protocoltypes.UsageInfo
-type Message = protocoltypes.Message
-type ToolDefinition = protocoltypes.ToolDefinition
-type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+type (
+ ToolCall = protocoltypes.ToolCall
+ FunctionCall = protocoltypes.FunctionCall
+ LLMResponse = protocoltypes.LLMResponse
+ UsageInfo = protocoltypes.UsageInfo
+ Message = protocoltypes.Message
+ ToolDefinition = protocoltypes.ToolDefinition
+ ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+)
const defaultBaseURL = "https://api.anthropic.com"
@@ -61,7 +64,13 @@ func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (stri
return p
}
-func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *Provider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
var opts []option.RequestOption
if p.tokenSource != nil {
tok, err := p.tokenSource()
@@ -92,7 +101,12 @@ func (p *Provider) BaseURL() string {
return p.baseURL
}
-func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
+func buildParams(
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (anthropic.MessageNewParams, error) {
var system []anthropic.TextBlockParam
var anthropicMessages []anthropic.MessageParam
@@ -170,7 +184,7 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
if desc := t.Function.Description; desc != "" {
tool.Description = anthropic.String(desc)
}
- if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
+ if req, ok := t.Function.Parameters["required"].([]any); ok {
required := make([]string, 0, len(req))
for _, r := range req {
if s, ok := r.(string); ok {
@@ -195,10 +209,10 @@ func parseResponse(resp *anthropic.Message) *LLMResponse {
content += tb.Text
case "tool_use":
tu := block.AsToolUse()
- var args map[string]interface{}
+ var args map[string]any
if err := json.Unmarshal(tu.Input, &args); err != nil {
log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err)
- args = map[string]interface{}{"raw": string(tu.Input)}
+ args = map[string]any{"raw": string(tu.Input)}
}
toolCalls = append(toolCalls, ToolCall{
ID: tu.ID,
diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go
index 6a1dabafb..6cfb2948a 100644
--- a/pkg/providers/anthropic/provider_test.go
+++ b/pkg/providers/anthropic/provider_test.go
@@ -15,7 +15,7 @@ func TestBuildParams_BasicMessage(t *testing.T) {
messages := []Message{
{Role: "user", Content: "Hello"},
}
- params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{
+ params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]any{
"max_tokens": 1024,
})
if err != nil {
@@ -37,7 +37,7 @@ func TestBuildParams_SystemMessage(t *testing.T) {
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hi"},
}
- params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
+ params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]any{})
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
@@ -62,13 +62,13 @@ func TestBuildParams_ToolCallMessage(t *testing.T) {
{
ID: "call_1",
Name: "get_weather",
- Arguments: map[string]interface{}{"city": "SF"},
+ Arguments: map[string]any{"city": "SF"},
},
},
},
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
}
- params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
+ params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]any{})
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
@@ -84,17 +84,22 @@ func TestBuildParams_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "get_weather",
Description: "Get weather for a city",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "city": map[string]interface{}{"type": "string"},
+ "properties": map[string]any{
+ "city": map[string]any{"type": "string"},
},
- "required": []interface{}{"city"},
+ "required": []any{"city"},
},
},
},
}
- params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{})
+ params, err := buildParams(
+ []Message{{Role: "user", Content: "Hi"}},
+ tools,
+ "claude-sonnet-4-5-20250929",
+ map[string]any{},
+ )
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
@@ -154,19 +159,19 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
json.NewDecoder(r.Body).Decode(&reqBody)
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": reqBody["model"],
"stop_reason": "end_turn",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "text", "text": "Hello! How can I help you?"},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 15,
"output_tokens": 8,
},
@@ -178,7 +183,13 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
messages := []Message{{Role: "user", Content: "Hello"}}
- resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
+ resp, err := provider.Chat(
+ t.Context(),
+ messages,
+ nil,
+ "claude-sonnet-4-5-20250929",
+ map[string]any{"max_tokens": 1024},
+ )
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@@ -221,19 +232,19 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
json.NewDecoder(r.Body).Decode(&reqBody)
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": reqBody["model"],
"stop_reason": "end_turn",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "text", "text": "ok"},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 1,
"output_tokens": 1,
},
@@ -247,7 +258,13 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
return "refreshed-token", nil
}, server.URL)
- _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
+ _, err := p.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "claude-sonnet-4-5-20250929",
+ map[string]any{},
+ )
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go
index 58ba3647d..74ec33b98 100644
--- a/pkg/providers/claude_cli_provider.go
+++ b/pkg/providers/claude_cli_provider.go
@@ -24,7 +24,9 @@ func NewClaudeCliProvider(workspace string) *ClaudeCliProvider {
}
// Chat implements LLMProvider.Chat by executing the claude CLI.
-func (p *ClaudeCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *ClaudeCliProvider) Chat(
+ ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) (*LLMResponse, error) {
systemPrompt := p.buildSystemPrompt(messages, tools)
prompt := p.messagesToPrompt(messages)
@@ -111,7 +113,9 @@ func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
sb.WriteString("## Available Tools\n\n")
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
sb.WriteString("```json\n")
- sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
+ sb.WriteString(
+ `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
+ )
sb.WriteString("\n```\n\n")
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
sb.WriteString("### Tool Definitions:\n\n")
diff --git a/pkg/providers/claude_cli_provider_integration_test.go b/pkg/providers/claude_cli_provider_integration_test.go
index 9d1131ac4..f6e0d787a 100644
--- a/pkg/providers/claude_cli_provider_integration_test.go
+++ b/pkg/providers/claude_cli_provider_integration_test.go
@@ -28,7 +28,6 @@ func TestIntegration_RealClaudeCLI(t *testing.T) {
resp, err := p.Chat(ctx, []Message{
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() with real CLI error = %v", err)
}
@@ -75,7 +74,6 @@ func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) {
{Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
{Role: "user", Content: "What is 2+2?"},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go
index 063530deb..5bfe33247 100644
--- a/pkg/providers/claude_cli_provider_test.go
+++ b/pkg/providers/claude_cli_provider_test.go
@@ -30,12 +30,12 @@ func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
dir := t.TempDir()
if stdout != "" {
- if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil {
+ if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0o644); err != nil {
t.Fatal(err)
}
}
if stderr != "" {
- if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil {
+ if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0o644); err != nil {
t.Fatal(err)
}
}
@@ -51,7 +51,7 @@ func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
sb.WriteString(fmt.Sprintf("exit %d\n", exitCode))
script := filepath.Join(dir, "claude")
- if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil {
+ if err := os.WriteFile(script, []byte(sb.String()), 0o755); err != nil {
t.Fatal(err)
}
return script
@@ -67,7 +67,7 @@ func createSlowMockCLI(t *testing.T, sleepSeconds int) string {
dir := t.TempDir()
script := filepath.Join(dir, "claude")
content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds)
- if err := os.WriteFile(script, []byte(content), 0755); err != nil {
+ if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
return script
@@ -88,7 +88,7 @@ cat <<'EOFMOCK'
{"type":"result","result":"ok","session_id":"test"}
EOFMOCK
`, argsFile)
- if err := os.WriteFile(script, []byte(content), 0755); err != nil {
+ if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
return script
@@ -137,7 +137,6 @@ func TestChat_Success(t *testing.T) {
resp, err := p.Chat(context.Background(), []Message{
{Role: "user", Content: "Hello"},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
@@ -193,7 +192,6 @@ func TestChat_WithToolCallsInResponse(t *testing.T) {
resp, err := p.Chat(context.Background(), []Message{
{Role: "user", Content: "What's the weather?"},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
@@ -403,7 +401,6 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
resp, err := p.Chat(context.Background(), []Message{
{Role: "user", Content: "Hello"},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() with empty workspace error = %v", err)
}
@@ -611,10 +608,10 @@ func TestBuildSystemPrompt_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "get_weather",
Description: "Get weather for a location",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "location": map[string]interface{}{"type": "string"},
+ "properties": map[string]any{
+ "location": map[string]any{"type": "string"},
},
},
},
diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go
index 3ca54d5a3..60639ca18 100644
--- a/pkg/providers/claude_provider.go
+++ b/pkg/providers/claude_provider.go
@@ -29,7 +29,9 @@ func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string,
}
}
-func NewClaudeProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *ClaudeProvider {
+func NewClaudeProviderWithTokenSourceAndBaseURL(
+ token string, tokenSource func() (string, error), apiBase string,
+) *ClaudeProvider {
return &ClaudeProvider{
delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase),
}
@@ -39,7 +41,9 @@ func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *Claude
return &ClaudeProvider{delegate: delegate}
}
-func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *ClaudeProvider) Chat(
+ ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) (*LLMResponse, error) {
resp, err := p.delegate.Chat(ctx, messages, tools, model, options)
if err != nil {
return nil, err
diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/claude_provider_test.go
index 13bbde1fc..1f15e2792 100644
--- a/pkg/providers/claude_provider_test.go
+++ b/pkg/providers/claude_provider_test.go
@@ -8,6 +8,7 @@ import (
"github.com/anthropics/anthropic-sdk-go"
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
+
anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
)
@@ -22,19 +23,19 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
json.NewDecoder(r.Body).Decode(&reqBody)
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": reqBody["model"],
"stop_reason": "end_turn",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "text", "text": "Hello! How can I help you?"},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 15,
"output_tokens": 8,
},
@@ -48,7 +49,9 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
provider := newClaudeProviderWithDelegate(delegate)
messages := []Message{{Role: "user", Content: "Hello"}}
- resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
+ resp, err := provider.Chat(
+ t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]any{"max_tokens": 1024},
+ )
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/codex_cli_credentials.go
index 7ad39ce8e..46ba24b12 100644
--- a/pkg/providers/codex_cli_credentials.go
+++ b/pkg/providers/codex_cli_credentials.go
@@ -59,7 +59,9 @@ func CreateCodexCliTokenSource() func() (string, string, error) {
}
if time.Now().After(expiresAt) {
- return "", "", fmt.Errorf("codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login")
+ return "", "", fmt.Errorf(
+ "codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login",
+ )
}
return token, accountID, nil
diff --git a/pkg/providers/codex_cli_credentials_test.go b/pkg/providers/codex_cli_credentials_test.go
index 3267f2d16..43b21700a 100644
--- a/pkg/providers/codex_cli_credentials_test.go
+++ b/pkg/providers/codex_cli_credentials_test.go
@@ -18,7 +18,7 @@ func TestReadCodexCliCredentials_Valid(t *testing.T) {
"account_id": "org-test123"
}
}`
- if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
@@ -58,7 +58,7 @@ func TestReadCodexCliCredentials_EmptyToken(t *testing.T) {
authPath := filepath.Join(tmpDir, "auth.json")
authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}`
- if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
@@ -74,7 +74,7 @@ func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
authPath := filepath.Join(tmpDir, "auth.json")
- if err := os.WriteFile(authPath, []byte("not json"), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte("not json"), 0o600); err != nil {
t.Fatal(err)
}
@@ -91,7 +91,7 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
authPath := filepath.Join(tmpDir, "auth.json")
authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}`
- if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
@@ -112,12 +112,12 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) {
tmpDir := t.TempDir()
customDir := filepath.Join(tmpDir, "custom-codex")
- if err := os.MkdirAll(customDir, 0755); err != nil {
+ if err := os.MkdirAll(customDir, 0o755); err != nil {
t.Fatal(err)
}
authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}`
- if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
@@ -137,7 +137,7 @@ func TestCreateCodexCliTokenSource_Valid(t *testing.T) {
authPath := filepath.Join(tmpDir, "auth.json")
authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}`
- if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
@@ -161,7 +161,7 @@ func TestCreateCodexCliTokenSource_Expired(t *testing.T) {
authPath := filepath.Join(tmpDir, "auth.json")
authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}`
- if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
t.Fatal(err)
}
diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go
index 8886406b4..4c783ece5 100644
--- a/pkg/providers/codex_cli_provider.go
+++ b/pkg/providers/codex_cli_provider.go
@@ -25,7 +25,9 @@ func NewCodexCliProvider(workspace string) *CodexCliProvider {
}
// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode.
-func (p *CodexCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *CodexCliProvider) Chat(
+ ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) (*LLMResponse, error) {
if p.command == "" {
return nil, fmt.Errorf("codex command not configured")
}
@@ -133,7 +135,9 @@ func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
sb.WriteString("## Available Tools\n\n")
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
sb.WriteString("```json\n")
- sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
+ sb.WriteString(
+ `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
+ )
sb.WriteString("\n```\n\n")
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
sb.WriteString("### Tool Definitions:\n\n")
diff --git a/pkg/providers/codex_cli_provider_integration_test.go b/pkg/providers/codex_cli_provider_integration_test.go
index 0267c730f..17a8305ad 100644
--- a/pkg/providers/codex_cli_provider_integration_test.go
+++ b/pkg/providers/codex_cli_provider_integration_test.go
@@ -27,7 +27,6 @@ func TestIntegration_RealCodexCLI(t *testing.T) {
resp, err := p.Chat(ctx, []Message{
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() with real CLI error = %v", err)
}
@@ -64,7 +63,6 @@ func TestIntegration_RealCodexCLI_WithSystemPrompt(t *testing.T) {
{Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
{Role: "user", Content: "What is 2+2?"},
}, nil, "", nil)
-
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/codex_cli_provider_test.go
index 7e4e1bc15..414e0844d 100644
--- a/pkg/providers/codex_cli_provider_test.go
+++ b/pkg/providers/codex_cli_provider_test.go
@@ -292,10 +292,10 @@ func TestBuildPrompt_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "get_weather",
Description: "Get current weather",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "city": map[string]interface{}{"type": "string"},
+ "properties": map[string]any{
+ "city": map[string]any{"type": "string"},
},
},
},
@@ -409,7 +409,7 @@ func createMockCodexCLI(t *testing.T, events []string) string {
sb.WriteString(fmt.Sprintf("echo '%s'\n", event))
}
- if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil {
+ if err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil {
t.Fatal(err)
}
return scriptPath
@@ -480,7 +480,7 @@ echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `"
echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}'
echo '{"type":"turn.completed"}'`
- if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
+ if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
@@ -522,7 +522,7 @@ func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
scriptPath := filepath.Join(tmpDir, "codex")
script := "#!/bin/bash\nsleep 60"
- if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
+ if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go
index e3526cfb5..ecc983642 100644
--- a/pkg/providers/codex_provider.go
+++ b/pkg/providers/codex_provider.go
@@ -10,12 +10,15 @@ import (
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
"github.com/openai/openai-go/v3/responses"
+
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/logger"
)
-const codexDefaultModel = "gpt-5.2"
-const codexDefaultInstructions = "You are Codex, a coding assistant."
+const (
+ codexDefaultModel = "gpt-5.2"
+ codexDefaultInstructions = "You are Codex, a coding assistant."
+)
type CodexProvider struct {
client *openai.Client
@@ -44,22 +47,30 @@ func NewCodexProvider(token, accountID string) *CodexProvider {
}
}
-func NewCodexProviderWithTokenSource(token, accountID string, tokenSource func() (string, string, error)) *CodexProvider {
+func NewCodexProviderWithTokenSource(
+ token, accountID string, tokenSource func() (string, string, error),
+) *CodexProvider {
p := NewCodexProvider(token, accountID)
p.tokenSource = tokenSource
return p
}
-func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *CodexProvider) Chat(
+ ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) (*LLMResponse, error) {
var opts []option.RequestOption
accountID := p.accountID
resolvedModel, fallbackReason := resolveCodexModel(model)
if fallbackReason != "" {
- logger.WarnCF("provider.codex", "Requested model is not compatible with Codex backend, using fallback", map[string]interface{}{
- "requested_model": model,
- "resolved_model": resolvedModel,
- "reason": fallbackReason,
- })
+ logger.WarnCF(
+ "provider.codex",
+ "Requested model is not compatible with Codex backend, using fallback",
+ map[string]any{
+ "requested_model": model,
+ "resolved_model": resolvedModel,
+ "reason": fallbackReason,
+ },
+ )
}
if p.tokenSource != nil {
tok, accID, err := p.tokenSource()
@@ -74,10 +85,14 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To
if accountID != "" {
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
} else {
- logger.WarnCF("provider.codex", "No account id found for Codex request; backend may reject with 400", map[string]interface{}{
- "requested_model": model,
- "resolved_model": resolvedModel,
- })
+ logger.WarnCF(
+ "provider.codex",
+ "No account id found for Codex request; backend may reject with 400",
+ map[string]any{
+ "requested_model": model,
+ "resolved_model": resolvedModel,
+ },
+ )
}
params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch)
@@ -98,7 +113,7 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To
}
err := stream.Err()
if err != nil {
- fields := map[string]interface{}{
+ fields := map[string]any{
"requested_model": model,
"resolved_model": resolvedModel,
"messages_count": len(messages),
@@ -124,7 +139,7 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To
return nil, fmt.Errorf("codex API call: %w", err)
}
if resp == nil {
- fields := map[string]interface{}{
+ fields := map[string]any{
"requested_model": model,
"resolved_model": resolvedModel,
"messages_count": len(messages),
@@ -184,7 +199,9 @@ func resolveCodexModel(model string) (string, string) {
return codexDefaultModel, "unsupported model family"
}
-func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, enableWebSearch bool) responses.ResponseNewParams {
+func buildCodexParams(
+ messages []Message, tools []ToolDefinition, model string, options map[string]any, enableWebSearch bool,
+) responses.ResponseNewParams {
var inputItems responses.ResponseInputParam
var instructions string
@@ -197,7 +214,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
CallID: msg.ToolCallID,
- Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)},
+ Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{
+ OfString: openai.Opt(msg.Content),
+ },
},
})
} else {
@@ -221,7 +240,7 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
for _, tc := range msg.ToolCalls {
name, args, ok := resolveCodexToolCall(tc)
if !ok {
- logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]interface{}{
+ logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]any{
"call_id": tc.ID,
})
continue
@@ -246,7 +265,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
CallID: msg.ToolCallID,
- Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)},
+ Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{
+ OfString: openai.Opt(msg.Content),
+ },
},
})
}
@@ -341,9 +362,9 @@ func parseCodexResponse(resp *responses.Response) *LLMResponse {
}
}
case "function_call":
- var args map[string]interface{}
+ var args map[string]any
if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil {
- args = map[string]interface{}{"raw": item.Arguments}
+ args = map[string]any{"raw": item.Arguments}
}
toolCalls = append(toolCalls, ToolCall{
ID: item.CallID,
diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go
index 92e276165..4157e53e9 100644
--- a/pkg/providers/codex_provider_test.go
+++ b/pkg/providers/codex_provider_test.go
@@ -16,7 +16,7 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) {
messages := []Message{
{Role: "user", Content: "Hello"},
}
- params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{
"max_tokens": 2048,
"temperature": 0.7,
}, true)
@@ -39,7 +39,7 @@ func TestBuildCodexParams_SystemAsInstructions(t *testing.T) {
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hi"},
}
- params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, true)
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, true)
if !params.Instructions.Valid() {
t.Fatal("Instructions should be set")
}
@@ -54,12 +54,12 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) {
{
Role: "assistant",
ToolCalls: []ToolCall{
- {ID: "call_1", Name: "get_weather", Arguments: map[string]interface{}{"city": "SF"}},
+ {ID: "call_1", Name: "get_weather", Arguments: map[string]any{"city": "SF"}},
},
},
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
}
- params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false)
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, false)
if params.Input.OfInputItemList == nil {
t.Fatal("Input.OfInputItemList should not be nil")
}
@@ -87,7 +87,7 @@ func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) {
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
}
- params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false)
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]any{}, false)
if params.Input.OfInputItemList == nil {
t.Fatal("Input.OfInputItemList should not be nil")
}
@@ -114,16 +114,16 @@ func TestBuildCodexParams_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "get_weather",
Description: "Get weather",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "city": map[string]interface{}{"type": "string"},
+ "properties": map[string]any{
+ "city": map[string]any{"type": "string"},
},
},
},
},
}
- params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, false)
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]any{}, false)
if len(params.Tools) != 1 {
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
}
@@ -136,14 +136,14 @@ func TestBuildCodexParams_WithTools(t *testing.T) {
}
func TestBuildCodexParams_StoreIsFalse(t *testing.T) {
- params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, false)
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]any{}, false)
if !params.Store.Valid() || params.Store.Or(true) != false {
t.Error("Store should be explicitly set to false")
}
}
func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) {
- params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, true)
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]any{}, true)
if len(params.Tools) != 1 {
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
}
@@ -151,7 +151,11 @@ func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) {
t.Fatal("Tool should include built-in web_search")
}
if params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch {
- t.Errorf("Web search tool type = %q, want %q", params.Tools[0].OfWebSearch.Type, responses.WebSearchToolTypeWebSearch)
+ t.Errorf(
+ "Web search tool type = %q, want %q",
+ params.Tools[0].OfWebSearch.Type,
+ responses.WebSearchToolTypeWebSearch,
+ )
}
}
@@ -162,7 +166,7 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "web_search",
Description: "local web search",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
},
},
@@ -172,14 +176,14 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "read_file",
Description: "read file",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
},
},
},
}
- params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, true)
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]any{}, true)
if len(params.Tools) != 2 {
t.Fatalf("len(Tools) = %d, want 2", len(params.Tools))
}
@@ -296,7 +300,7 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
@@ -309,38 +313,38 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest)
return
}
- toolsAny, ok := reqBody["tools"].([]interface{})
+ toolsAny, ok := reqBody["tools"].([]any)
if !ok || len(toolsAny) != 1 {
http.Error(w, "missing default web search tool", http.StatusBadRequest)
return
}
- toolObj, ok := toolsAny[0].(map[string]interface{})
+ toolObj, ok := toolsAny[0].(map[string]any)
if !ok || toolObj["type"] != "web_search" {
http.Error(w, "expected web_search tool", http.StatusBadRequest)
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "resp_test",
"object": "response",
"status": "completed",
- "output": []map[string]interface{}{
+ "output": []map[string]any{
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "output_text", "text": "Hi from Codex!"},
},
},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 12,
"output_tokens": 6,
"total_tokens": 18,
- "input_tokens_details": map[string]interface{}{"cached_tokens": 0},
- "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
+ "input_tokens_details": map[string]any{"cached_tokens": 0},
+ "output_tokens_details": map[string]any{"reasoning_tokens": 0},
},
}
writeCompletedSSE(w, resp)
@@ -351,7 +355,7 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
messages := []Message{{Role: "user", Content: "Hello"}}
- resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"max_tokens": 1024})
+ resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{"max_tokens": 1024})
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@@ -373,7 +377,7 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
@@ -383,27 +387,27 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "resp_test",
"object": "response",
"status": "completed",
- "output": []map[string]interface{}{
+ "output": []map[string]any{
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "output_text", "text": "Hi from Codex!"},
},
},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 4,
"output_tokens": 3,
"total_tokens": 7,
- "input_tokens_details": map[string]interface{}{"cached_tokens": 0},
- "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
+ "input_tokens_details": map[string]any{"cached_tokens": 0},
+ "output_tokens_details": map[string]any{"reasoning_tokens": 0},
},
}
writeCompletedSSE(w, resp)
@@ -415,7 +419,7 @@ func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
messages := []Message{{Role: "user", Content: "Hello"}}
- resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{})
+ resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{})
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@@ -439,7 +443,7 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
@@ -465,27 +469,27 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "resp_test",
"object": "response",
"status": "completed",
- "output": []map[string]interface{}{
+ "output": []map[string]any{
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "output_text", "text": "Hi from Codex!"},
},
},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 8,
"output_tokens": 4,
"total_tokens": 12,
- "input_tokens_details": map[string]interface{}{"cached_tokens": 0},
- "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
+ "input_tokens_details": map[string]any{"cached_tokens": 0},
+ "output_tokens_details": map[string]any{"reasoning_tokens": 0},
},
}
writeCompletedSSE(w, resp)
@@ -499,7 +503,7 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T)
}
messages := []Message{{Role: "user", Content: "Hello"}}
- resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"temperature": 0.7})
+ resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{"temperature": 0.7})
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@@ -515,7 +519,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
@@ -533,27 +537,27 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "resp_test",
"object": "response",
"status": "completed",
- "output": []map[string]interface{}{
+ "output": []map[string]any{
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "output_text", "text": "Hi from Codex!"},
},
},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 8,
"output_tokens": 4,
"total_tokens": 12,
- "input_tokens_details": map[string]interface{}{"cached_tokens": 0},
- "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
+ "input_tokens_details": map[string]any{"cached_tokens": 0},
+ "output_tokens_details": map[string]any{"reasoning_tokens": 0},
},
}
writeCompletedSSE(w, resp)
@@ -588,7 +592,12 @@ func TestResolveCodexModel(t *testing.T) {
wantFallback bool
}{
{name: "empty", input: "", wantModel: codexDefaultModel, wantFallback: true},
- {name: "unsupported namespace", input: "anthropic/claude-3.5", wantModel: codexDefaultModel, wantFallback: true},
+ {
+ name: "unsupported namespace",
+ input: "anthropic/claude-3.5",
+ wantModel: codexDefaultModel,
+ wantFallback: true,
+ },
{name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true},
{name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false},
{name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false},
@@ -622,8 +631,8 @@ func createOpenAITestClient(baseURL, token, accountID string) *openai.Client {
return &c
}
-func writeCompletedSSE(w http.ResponseWriter, response map[string]interface{}) {
- event := map[string]interface{}{
+func writeCompletedSSE(w http.ResponseWriter, response map[string]any) {
+ event := map[string]any{
"type": "response.completed",
"sequence_number": 1,
"response": response,
diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go
index 9b07f9153..ecd451ec9 100644
--- a/pkg/providers/fallback.go
+++ b/pkg/providers/fallback.go
@@ -110,7 +110,11 @@ func (fc *FallbackChain) Execute(
Model: candidate.Model,
Skipped: true,
Reason: FailoverRateLimit,
- Error: fmt.Errorf("provider %s in cooldown (%s remaining)", candidate.Provider, remaining.Round(time.Second)),
+ Error: fmt.Errorf(
+ "provider %s in cooldown (%s remaining)",
+ candidate.Provider,
+ remaining.Round(time.Second),
+ ),
})
continue
}
diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go
index ea81e0d48..e872c672e 100644
--- a/pkg/providers/fallback_test.go
+++ b/pkg/providers/fallback_test.go
@@ -462,7 +462,13 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) {
func TestFallbackExhaustedError_Message(t *testing.T) {
e := &FallbackExhaustedError{
Attempts: []FallbackAttempt{
- {Provider: "openai", Model: "gpt-4", Error: errors.New("rate limited"), Reason: FailoverRateLimit, Duration: 500 * time.Millisecond},
+ {
+ Provider: "openai",
+ Model: "gpt-4",
+ Error: errors.New("rate limited"),
+ Reason: FailoverRateLimit,
+ Duration: 500 * time.Millisecond,
+ },
{Provider: "anthropic", Model: "claude", Skipped: true},
},
}
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index 5058819f5..6124881f7 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -2,10 +2,9 @@ package providers
import (
"context"
+ "encoding/json"
"fmt"
- json "encoding/json"
-
copilot "github.com/github/copilot-sdk/go"
)
@@ -17,7 +16,6 @@ type GitHubCopilotProvider struct {
}
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
-
var session *copilot.Session
if connectMode == "" {
connectMode = "grpc"
@@ -25,13 +23,15 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
switch connectMode {
case "stdio":
- //todo
+ // todo
case "grpc":
client := copilot.NewClient(&copilot.ClientOptions{
CLIUrl: uri,
})
if err := client.Start(context.Background()); err != nil {
- return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details")
+ return nil, fmt.Errorf(
+ "Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details",
+ )
}
defer client.Stop()
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
@@ -49,7 +49,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
}
// Chat sends a chat request to GitHub Copilot
-func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *GitHubCopilotProvider) Chat(
+ ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) (*LLMResponse, error) {
type tempMessage struct {
Role string `json:"role"`
Content string `json:"content"`
@@ -73,10 +75,8 @@ func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, to
FinishReason: "stop",
Content: content,
}, nil
-
}
func (p *GitHubCopilotProvider) GetDefaultModel() string {
-
return "gpt-4.1"
}
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 967d089d5..05c6eed6c 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -22,7 +22,9 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
}
}
-func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *HTTPProvider) Chat(
+ ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) (*LLMResponse, error) {
return p.delegate.Chat(ctx, messages, tools, model, options)
}
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 9b404dd77..3a7fe4f39 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -15,13 +15,15 @@ import (
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
-type ToolCall = protocoltypes.ToolCall
-type FunctionCall = protocoltypes.FunctionCall
-type LLMResponse = protocoltypes.LLMResponse
-type UsageInfo = protocoltypes.UsageInfo
-type Message = protocoltypes.Message
-type ToolDefinition = protocoltypes.ToolDefinition
-type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+type (
+ ToolCall = protocoltypes.ToolCall
+ FunctionCall = protocoltypes.FunctionCall
+ LLMResponse = protocoltypes.LLMResponse
+ UsageInfo = protocoltypes.UsageInfo
+ Message = protocoltypes.Message
+ ToolDefinition = protocoltypes.ToolDefinition
+ ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+)
type Provider struct {
apiKey string
@@ -52,14 +54,20 @@ func NewProvider(apiKey, apiBase, proxy string) *Provider {
}
}
-func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *Provider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
if p.apiBase == "" {
return nil, fmt.Errorf("API base not configured")
}
model = normalizeModel(model, p.apiBase)
- requestBody := map[string]interface{}{
+ requestBody := map[string]any{
"model": model,
"messages": messages,
}
@@ -154,7 +162,7 @@ func parseResponse(body []byte) (*LLMResponse, error) {
choice := apiResponse.Choices[0]
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
for _, tc := range choice.Message.ToolCalls {
- arguments := make(map[string]interface{})
+ arguments := make(map[string]any)
name := ""
if tc.Function != nil {
@@ -201,7 +209,7 @@ func normalizeModel(model, apiBase string) string {
}
}
-func asInt(v interface{}) (int, bool) {
+func asInt(v any) (int, bool) {
switch val := v.(type) {
case int:
return val, true
@@ -216,7 +224,7 @@ func asInt(v interface{}) (int, bool) {
}
}
-func asFloat(v interface{}) (float64, bool) {
+func asFloat(v any) (float64, bool) {
switch val := v.(type) {
case float64:
return val, true
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index 94779b39c..42f9d42ab 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -9,7 +9,7 @@ import (
)
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
- var requestBody map[string]interface{}
+ var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
@@ -20,10 +20,10 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
- resp := map[string]interface{}{
- "choices": []map[string]interface{}{
+ resp := map[string]any{
+ "choices": []map[string]any{
{
- "message": map[string]interface{}{"content": "ok"},
+ "message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
@@ -34,7 +34,13 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
defer server.Close()
p := NewProvider("key", server.URL, "")
- _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "glm-4.7", map[string]interface{}{"max_tokens": 1234})
+ _, err := p.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ "glm-4.7",
+ map[string]any{"max_tokens": 1234},
+ )
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
@@ -49,16 +55,16 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
func TestProviderChat_ParsesToolCalls(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- resp := map[string]interface{}{
- "choices": []map[string]interface{}{
+ resp := map[string]any{
+ "choices": []map[string]any{
{
- "message": map[string]interface{}{
+ "message": map[string]any{
"content": "",
- "tool_calls": []map[string]interface{}{
+ "tool_calls": []map[string]any{
{
"id": "call_1",
"type": "function",
- "function": map[string]interface{}{
+ "function": map[string]any{
"name": "get_weather",
"arguments": "{\"city\":\"SF\"}",
},
@@ -68,7 +74,7 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) {
"finish_reason": "tool_calls",
},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
@@ -109,17 +115,17 @@ func TestProviderChat_HTTPError(t *testing.T) {
}
func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {
- var requestBody map[string]interface{}
+ var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
- resp := map[string]interface{}{
- "choices": []map[string]interface{}{
+ resp := map[string]any{
+ "choices": []map[string]any{
{
- "message": map[string]interface{}{"content": "ok"},
+ "message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
@@ -135,7 +141,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin
[]Message{{Role: "user", Content: "hi"}},
nil,
"moonshot/kimi-k2.5",
- map[string]interface{}{"temperature": 0.3},
+ map[string]any{"temperature": 0.3},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -174,17 +180,17 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- var requestBody map[string]interface{}
+ var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
- resp := map[string]interface{}{
- "choices": []map[string]interface{}{
+ resp := map[string]any{
+ "choices": []map[string]any{
{
- "message": map[string]interface{}{"content": "ok"},
+ "message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
@@ -227,17 +233,17 @@ func TestProvider_ProxyConfigured(t *testing.T) {
}
func TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) {
- var requestBody map[string]interface{}
+ var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
- resp := map[string]interface{}{
- "choices": []map[string]interface{}{
+ resp := map[string]any{
+ "choices": []map[string]any{
{
- "message": map[string]interface{}{"content": "ok"},
+ "message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
@@ -253,7 +259,7 @@ func TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) {
[]Message{{Role: "user", Content: "hi"}},
nil,
"gpt-4o",
- map[string]interface{}{"max_tokens": float64(512), "temperature": 1},
+ map[string]any{"max_tokens": float64(512), "temperature": 1},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go
index 6b33ae734..b5b4a2d39 100644
--- a/pkg/providers/protocoltypes/types.go
+++ b/pkg/providers/protocoltypes/types.go
@@ -1,11 +1,11 @@
package protocoltypes
type ToolCall struct {
- ID string `json:"id"`
- Type string `json:"type,omitempty"`
- Function *FunctionCall `json:"function,omitempty"`
- Name string `json:"name,omitempty"`
- Arguments map[string]interface{} `json:"arguments,omitempty"`
+ ID string `json:"id"`
+ Type string `json:"type,omitempty"`
+ Function *FunctionCall `json:"function,omitempty"`
+ Name string `json:"name,omitempty"`
+ Arguments map[string]any `json:"arguments,omitempty"`
}
type FunctionCall struct {
@@ -39,7 +39,7 @@ type ToolDefinition struct {
}
type ToolFunctionDefinition struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Parameters map[string]interface{} `json:"parameters"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters map[string]any `json:"parameters"`
}
diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go
index 97a219283..7ddea0e99 100644
--- a/pkg/providers/tool_call_extract.go
+++ b/pkg/providers/tool_call_extract.go
@@ -38,7 +38,7 @@ func extractToolCallsFromText(text string) []ToolCall {
var result []ToolCall
for _, tc := range wrapper.ToolCalls {
- var args map[string]interface{}
+ var args map[string]any
json.Unmarshal([]byte(tc.Function.Arguments), &args)
result = append(result, ToolCall{
diff --git a/pkg/providers/types.go b/pkg/providers/types.go
index c4a9de58a..2fbddd686 100644
--- a/pkg/providers/types.go
+++ b/pkg/providers/types.go
@@ -7,16 +7,24 @@ import (
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
-type ToolCall = protocoltypes.ToolCall
-type FunctionCall = protocoltypes.FunctionCall
-type LLMResponse = protocoltypes.LLMResponse
-type UsageInfo = protocoltypes.UsageInfo
-type Message = protocoltypes.Message
-type ToolDefinition = protocoltypes.ToolDefinition
-type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+type (
+ ToolCall = protocoltypes.ToolCall
+ FunctionCall = protocoltypes.FunctionCall
+ LLMResponse = protocoltypes.LLMResponse
+ UsageInfo = protocoltypes.UsageInfo
+ Message = protocoltypes.Message
+ ToolDefinition = protocoltypes.ToolDefinition
+ ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+)
type LLMProvider interface {
- Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
+ Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ ) (*LLMResponse, error)
GetDefaultModel() string
}
diff --git a/pkg/session/manager.go b/pkg/session/manager.go
index 12bf33df0..08f0b0ad2 100644
--- a/pkg/session/manager.go
+++ b/pkg/session/manager.go
@@ -32,7 +32,7 @@ func NewSessionManager(storage string) *SessionManager {
}
if storage != "" {
- os.MkdirAll(storage, 0755)
+ os.MkdirAll(storage, 0o755)
sm.loadSessions()
}
@@ -214,7 +214,7 @@ func (sm *SessionManager) Save(key string) error {
_ = tmpFile.Close()
return err
}
- if err := tmpFile.Chmod(0644); err != nil {
+ if err := tmpFile.Chmod(0o644); err != nil {
_ = tmpFile.Close()
return err
}
diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go
index a3263c525..5742a8f03 100644
--- a/pkg/skills/installer.go
+++ b/pkg/skills/installer.go
@@ -66,12 +66,12 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
return fmt.Errorf("failed to read response: %w", err)
}
- if err := os.MkdirAll(skillDir, 0755); err != nil {
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
return fmt.Errorf("failed to create skill directory: %w", err)
}
skillPath := filepath.Join(skillDir, "SKILL.md")
- if err := os.WriteFile(skillPath, body, 0644); err != nil {
+ if err := os.WriteFile(skillPath, body, 0o644); err != nil {
return fmt.Errorf("failed to write skill file: %w", err)
}
diff --git a/pkg/state/state.go b/pkg/state/state.go
index 0bb9cd497..1a92f82ed 100644
--- a/pkg/state/state.go
+++ b/pkg/state/state.go
@@ -38,7 +38,7 @@ func NewManager(workspace string) *Manager {
oldStateFile := filepath.Join(workspace, "state.json")
// Create state directory if it doesn't exist
- os.MkdirAll(stateDir, 0755)
+ os.MkdirAll(stateDir, 0o755)
sm := &Manager{
workspace: workspace,
@@ -139,7 +139,7 @@ func (sm *Manager) saveAtomic() error {
}
// Write to temp file
- if err := os.WriteFile(tempFile, data, 0644); err != nil {
+ if err := os.WriteFile(tempFile, data, 0o644); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go
index ce3dd7215..f717a5bb4 100644
--- a/pkg/state/state_test.go
+++ b/pkg/state/state_test.go
@@ -98,7 +98,7 @@ func TestAtomicity_NoCorruptionOnInterrupt(t *testing.T) {
// Simulate a crash scenario by manually creating a corrupted temp file
tempFile := filepath.Join(tmpDir, "state", "state.json.tmp")
- err = os.WriteFile(tempFile, []byte("corrupted data"), 0644)
+ err = os.WriteFile(tempFile, []byte("corrupted data"), 0o644)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
diff --git a/pkg/tools/base.go b/pkg/tools/base.go
index b13174633..770d8cb04 100644
--- a/pkg/tools/base.go
+++ b/pkg/tools/base.go
@@ -6,8 +6,8 @@ import "context"
type Tool interface {
Name() string
Description() string
- Parameters() map[string]interface{}
- Execute(ctx context.Context, args map[string]interface{}) *ToolResult
+ Parameters() map[string]any
+ Execute(ctx context.Context, args map[string]any) *ToolResult
}
// ContextualTool is an optional interface that tools can implement
@@ -69,10 +69,10 @@ type AsyncTool interface {
SetCallback(cb AsyncCallback)
}
-func ToolToSchema(tool Tool) map[string]interface{} {
- return map[string]interface{}{
+func ToolToSchema(tool Tool) map[string]any {
+ return map[string]any{
"type": "function",
- "function": map[string]interface{}{
+ "function": map[string]any{
"name": tool.Name(),
"description": tool.Description(),
"parameters": tool.Parameters(),
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index e2764d8ac..562fffc84 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -30,7 +30,10 @@ type CronTool struct {
// NewCronTool creates a new CronTool
// execTimeout: 0 means no timeout, >0 sets the timeout duration
-func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *CronTool {
+func NewCronTool(
+ cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
+ execTimeout time.Duration, config *config.Config,
+) *CronTool {
execTool := NewExecToolWithConfig(workspace, restrict, config)
execTool.SetTimeout(execTimeout)
return &CronTool{
@@ -52,40 +55,40 @@ func (t *CronTool) Description() string {
}
// Parameters returns the tool parameters schema
-func (t *CronTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *CronTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "action": map[string]interface{}{
+ "properties": map[string]any{
+ "action": map[string]any{
"type": "string",
"enum": []string{"add", "list", "remove", "enable", "disable"},
"description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.",
},
- "message": map[string]interface{}{
+ "message": map[string]any{
"type": "string",
"description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.",
},
- "command": map[string]interface{}{
+ "command": map[string]any{
"type": "string",
"description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.",
},
- "at_seconds": map[string]interface{}{
+ "at_seconds": map[string]any{
"type": "integer",
"description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.",
},
- "every_seconds": map[string]interface{}{
+ "every_seconds": map[string]any{
"type": "integer",
"description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.",
},
- "cron_expr": map[string]interface{}{
+ "cron_expr": map[string]any{
"type": "string",
"description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.",
},
- "job_id": map[string]interface{}{
+ "job_id": map[string]any{
"type": "string",
"description": "Job ID (for remove/enable/disable)",
},
- "deliver": map[string]interface{}{
+ "deliver": map[string]any{
"type": "boolean",
"description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true",
},
@@ -103,7 +106,7 @@ func (t *CronTool) SetContext(channel, chatID string) {
}
// Execute runs the tool with the given arguments
-func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, ok := args["action"].(string)
if !ok {
return ErrorResult("action is required")
@@ -125,7 +128,7 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) *To
}
}
-func (t *CronTool) addJob(args map[string]interface{}) *ToolResult {
+func (t *CronTool) addJob(args map[string]any) *ToolResult {
t.mu.RLock()
channel := t.channel
chatID := t.chatID
@@ -233,7 +236,7 @@ func (t *CronTool) listJobs() *ToolResult {
return SilentResult(result)
}
-func (t *CronTool) removeJob(args map[string]interface{}) *ToolResult {
+func (t *CronTool) removeJob(args map[string]any) *ToolResult {
jobID, ok := args["job_id"].(string)
if !ok || jobID == "" {
return ErrorResult("job_id is required for remove")
@@ -245,7 +248,7 @@ func (t *CronTool) removeJob(args map[string]interface{}) *ToolResult {
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
}
-func (t *CronTool) enableJob(args map[string]interface{}, enable bool) *ToolResult {
+func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult {
jobID, ok := args["job_id"].(string)
if !ok || jobID == "" {
return ErrorResult("job_id is required for enable/disable")
@@ -279,7 +282,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
// Execute command if present
if job.Payload.Command != "" {
- args := map[string]interface{}{
+ args := map[string]any{
"command": job.Payload.Command,
}
@@ -320,7 +323,6 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
channel,
chatID,
)
-
if err != nil {
return fmt.Sprintf("Error: %v", err)
}
diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go
index 1e7c33b45..39d2642d4 100644
--- a/pkg/tools/edit.go
+++ b/pkg/tools/edit.go
@@ -30,19 +30,19 @@ func (t *EditFileTool) Description() string {
return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
}
-func (t *EditFileTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *EditFileTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
+ "properties": map[string]any{
+ "path": map[string]any{
"type": "string",
"description": "The file path to edit",
},
- "old_text": map[string]interface{}{
+ "old_text": map[string]any{
"type": "string",
"description": "The exact text to find and replace",
},
- "new_text": map[string]interface{}{
+ "new_text": map[string]any{
"type": "string",
"description": "The text to replace with",
},
@@ -51,7 +51,7 @@ func (t *EditFileTool) Parameters() map[string]interface{} {
}
}
-func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
@@ -89,12 +89,14 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{})
count := strings.Count(contentStr, oldText)
if count > 1 {
- return ErrorResult(fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count))
+ return ErrorResult(
+ fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count),
+ )
}
newContent := strings.Replace(contentStr, oldText, newText, 1)
- if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil {
+ if err := os.WriteFile(resolvedPath, []byte(newContent), 0o644); err != nil {
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
}
@@ -118,15 +120,15 @@ func (t *AppendFileTool) Description() string {
return "Append content to the end of a file"
}
-func (t *AppendFileTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *AppendFileTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
+ "properties": map[string]any{
+ "path": map[string]any{
"type": "string",
"description": "The file path to append to",
},
- "content": map[string]interface{}{
+ "content": map[string]any{
"type": "string",
"description": "The content to append",
},
@@ -135,7 +137,7 @@ func (t *AppendFileTool) Parameters() map[string]interface{} {
}
}
-func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
@@ -151,7 +153,7 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{
return ErrorResult(err.Error())
}
- f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to open file: %v", err))
}
diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go
index c4c02772d..6780dd9f6 100644
--- a/pkg/tools/edit_test.go
+++ b/pkg/tools/edit_test.go
@@ -12,11 +12,11 @@ import (
func TestEditTool_EditFile_Success(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0644)
+ os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0o644)
tool := NewEditFileTool(tmpDir, true)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"old_text": "World",
"new_text": "Universe",
@@ -60,7 +60,7 @@ func TestEditTool_EditFile_NotFound(t *testing.T) {
tool := NewEditFileTool(tmpDir, true)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"old_text": "old",
"new_text": "new",
@@ -83,11 +83,11 @@ func TestEditTool_EditFile_NotFound(t *testing.T) {
func TestEditTool_EditFile_OldTextNotFound(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("Hello World"), 0644)
+ os.WriteFile(testFile, []byte("Hello World"), 0o644)
tool := NewEditFileTool(tmpDir, true)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"old_text": "Goodbye",
"new_text": "Hello",
@@ -110,11 +110,11 @@ func TestEditTool_EditFile_OldTextNotFound(t *testing.T) {
func TestEditTool_EditFile_MultipleMatches(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("test test test"), 0644)
+ os.WriteFile(testFile, []byte("test test test"), 0o644)
tool := NewEditFileTool(tmpDir, true)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"old_text": "test",
"new_text": "done",
@@ -138,11 +138,11 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
tmpDir := t.TempDir()
otherDir := t.TempDir()
testFile := filepath.Join(otherDir, "test.txt")
- os.WriteFile(testFile, []byte("content"), 0644)
+ os.WriteFile(testFile, []byte("content"), 0o644)
tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"old_text": "content",
"new_text": "new",
@@ -165,7 +165,7 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
func TestEditTool_EditFile_MissingPath(t *testing.T) {
tool := NewEditFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"old_text": "old",
"new_text": "new",
}
@@ -182,7 +182,7 @@ func TestEditTool_EditFile_MissingPath(t *testing.T) {
func TestEditTool_EditFile_MissingOldText(t *testing.T) {
tool := NewEditFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/tmp/test.txt",
"new_text": "new",
}
@@ -199,7 +199,7 @@ func TestEditTool_EditFile_MissingOldText(t *testing.T) {
func TestEditTool_EditFile_MissingNewText(t *testing.T) {
tool := NewEditFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/tmp/test.txt",
"old_text": "old",
}
@@ -216,11 +216,11 @@ func TestEditTool_EditFile_MissingNewText(t *testing.T) {
func TestEditTool_AppendFile_Success(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("Initial content"), 0644)
+ os.WriteFile(testFile, []byte("Initial content"), 0o644)
tool := NewAppendFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"content": "\nAppended content",
}
@@ -260,7 +260,7 @@ func TestEditTool_AppendFile_Success(t *testing.T) {
func TestEditTool_AppendFile_MissingPath(t *testing.T) {
tool := NewAppendFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "test",
}
@@ -276,7 +276,7 @@ func TestEditTool_AppendFile_MissingPath(t *testing.T) {
func TestEditTool_AppendFile_MissingContent(t *testing.T) {
tool := NewAppendFileTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/tmp/test.txt",
}
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 09063ea0a..dd996bc0d 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -94,11 +94,11 @@ func (t *ReadFileTool) Description() string {
return "Read the contents of a file"
}
-func (t *ReadFileTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *ReadFileTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
+ "properties": map[string]any{
+ "path": map[string]any{
"type": "string",
"description": "Path to the file to read",
},
@@ -107,7 +107,7 @@ func (t *ReadFileTool) Parameters() map[string]interface{} {
}
}
-func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
@@ -143,15 +143,15 @@ func (t *WriteFileTool) Description() string {
return "Write content to a file"
}
-func (t *WriteFileTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *WriteFileTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
+ "properties": map[string]any{
+ "path": map[string]any{
"type": "string",
"description": "Path to the file to write",
},
- "content": map[string]interface{}{
+ "content": map[string]any{
"type": "string",
"description": "Content to write to the file",
},
@@ -160,7 +160,7 @@ func (t *WriteFileTool) Parameters() map[string]interface{} {
}
}
-func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
@@ -177,11 +177,11 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}
}
dir := filepath.Dir(resolvedPath)
- if err := os.MkdirAll(dir, 0755); err != nil {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
}
- if err := os.WriteFile(resolvedPath, []byte(content), 0644); err != nil {
+ if err := os.WriteFile(resolvedPath, []byte(content), 0o644); err != nil {
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
}
@@ -205,11 +205,11 @@ func (t *ListDirTool) Description() string {
return "List files and directories in a path"
}
-func (t *ListDirTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *ListDirTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
+ "properties": map[string]any{
+ "path": map[string]any{
"type": "string",
"description": "Path to list",
},
@@ -218,7 +218,7 @@ func (t *ListDirTool) Parameters() map[string]interface{} {
}
}
-func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
path = "."
diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go
index 958036419..5daa3dcea 100644
--- a/pkg/tools/filesystem_test.go
+++ b/pkg/tools/filesystem_test.go
@@ -12,11 +12,11 @@ import (
func TestFilesystemTool_ReadFile_Success(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("test content"), 0644)
+ os.WriteFile(testFile, []byte("test content"), 0o644)
tool := &ReadFileTool{}
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
}
@@ -43,7 +43,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
tool := &ReadFileTool{}
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/nonexistent_file_12345.txt",
}
@@ -64,7 +64,7 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
tool := &ReadFileTool{}
ctx := context.Background()
- args := map[string]interface{}{}
+ args := map[string]any{}
result := tool.Execute(ctx, args)
@@ -86,7 +86,7 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) {
tool := &WriteFileTool{}
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"content": "hello world",
}
@@ -125,7 +125,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
tool := &WriteFileTool{}
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": testFile,
"content": "test",
}
@@ -151,7 +151,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
tool := &WriteFileTool{}
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "test",
}
@@ -167,7 +167,7 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
tool := &WriteFileTool{}
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/tmp/test.txt",
}
@@ -179,7 +179,8 @@ func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
}
// Should mention required parameter
- if !strings.Contains(result.ForLLM, "content is required") && !strings.Contains(result.ForUser, "content is required") {
+ if !strings.Contains(result.ForLLM, "content is required") &&
+ !strings.Contains(result.ForUser, "content is required") {
t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM)
}
}
@@ -187,13 +188,13 @@ func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
// TestFilesystemTool_ListDir_Success verifies successful directory listing
func TestFilesystemTool_ListDir_Success(t *testing.T) {
tmpDir := t.TempDir()
- os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0644)
- os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0644)
- os.Mkdir(filepath.Join(tmpDir, "subdir"), 0755)
+ os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644)
+ os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644)
+ os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)
tool := &ListDirTool{}
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": tmpDir,
}
@@ -217,7 +218,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
tool := &ListDirTool{}
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"path": "/nonexistent_directory_12345",
}
@@ -238,7 +239,7 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
tool := &ListDirTool{}
ctx := context.Background()
- args := map[string]interface{}{}
+ args := map[string]any{}
result := tool.Execute(ctx, args)
@@ -250,15 +251,14 @@ func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
// Block paths that look inside workspace but point outside via symlink.
func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
-
root := t.TempDir()
workspace := filepath.Join(root, "workspace")
- if err := os.MkdirAll(workspace, 0755); err != nil {
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
secret := filepath.Join(root, "secret.txt")
- if err := os.WriteFile(secret, []byte("top secret"), 0644); err != nil {
+ if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
t.Fatalf("failed to write secret file: %v", err)
}
@@ -268,7 +268,7 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
}
tool := NewReadFileTool(workspace, true)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"path": link,
})
diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go
index abca5ec1e..0387a26d3 100644
--- a/pkg/tools/i2c.go
+++ b/pkg/tools/i2c.go
@@ -24,37 +24,37 @@ func (t *I2CTool) Description() string {
return "Interact with I2C bus devices for reading sensors and controlling peripherals. Actions: detect (list buses), scan (find devices on a bus), read (read bytes from device), write (send bytes to device). Linux only."
}
-func (t *I2CTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *I2CTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "action": map[string]interface{}{
+ "properties": map[string]any{
+ "action": map[string]any{
"type": "string",
"enum": []string{"detect", "scan", "read", "write"},
"description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)",
},
- "bus": map[string]interface{}{
+ "bus": map[string]any{
"type": "string",
"description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.",
},
- "address": map[string]interface{}{
+ "address": map[string]any{
"type": "integer",
"description": "7-bit I2C device address (0x03-0x77). Required for read/write.",
},
- "register": map[string]interface{}{
+ "register": map[string]any{
"type": "integer",
"description": "Register address to read from or write to. If set, sends register byte before read/write.",
},
- "data": map[string]interface{}{
+ "data": map[string]any{
"type": "array",
- "items": map[string]interface{}{"type": "integer"},
+ "items": map[string]any{"type": "integer"},
"description": "Bytes to write (0-255 each). Required for write action.",
},
- "length": map[string]interface{}{
+ "length": map[string]any{
"type": "integer",
"description": "Number of bytes to read (1-256). Default: 1. Used with read action.",
},
- "confirm": map[string]interface{}{
+ "confirm": map[string]any{
"type": "boolean",
"description": "Must be true for write operations. Safety guard to prevent accidental writes.",
},
@@ -63,7 +63,7 @@ func (t *I2CTool) Parameters() map[string]interface{} {
}
}
-func (t *I2CTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
if runtime.GOOS != "linux" {
return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.")
}
@@ -95,7 +95,9 @@ func (t *I2CTool) detect() *ToolResult {
}
if len(matches) == 0 {
- return SilentResult("No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)")
+ return SilentResult(
+ "No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)",
+ )
}
type busInfo struct {
@@ -122,7 +124,7 @@ func isValidBusID(id string) bool {
}
// parseI2CAddress extracts and validates an I2C address from args
-func parseI2CAddress(args map[string]interface{}) (int, *ToolResult) {
+func parseI2CAddress(args map[string]any) (int, *ToolResult) {
addrFloat, ok := args["address"].(float64)
if !ok {
return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)")
@@ -135,7 +137,7 @@ func parseI2CAddress(args map[string]interface{}) (int, *ToolResult) {
}
// parseI2CBus extracts and validates an I2C bus from args
-func parseI2CBus(args map[string]interface{}) (string, *ToolResult) {
+func parseI2CBus(args map[string]any) (string, *ToolResult) {
bus, ok := args["bus"].(string)
if !ok || bus == "" {
return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)")
diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go
index 294f7ecbc..2a0626340 100644
--- a/pkg/tools/i2c_linux.go
+++ b/pkg/tools/i2c_linux.go
@@ -74,7 +74,7 @@ func smbusProbe(fd int, addr int, hasQuick bool) bool {
// scan probes valid 7-bit addresses on a bus for connected devices.
// Uses the same hybrid probe strategy as i2cdetect's MODE_AUTO:
// SMBus Quick Write for most addresses, SMBus Read Byte for EEPROM ranges.
-func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) scan(args map[string]any) *ToolResult {
bus, errResult := parseI2CBus(args)
if errResult != nil {
return errResult
@@ -99,7 +99,9 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
hasReadByte := funcs&i2cFuncSmbusReadByte != 0
if !hasQuick && !hasReadByte {
- return ErrorResult(fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte ā cannot probe safely", devPath))
+ return ErrorResult(
+ fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte ā cannot probe safely", devPath),
+ )
}
type deviceEntry struct {
@@ -133,7 +135,7 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath))
}
- result, _ := json.MarshalIndent(map[string]interface{}{
+ result, _ := json.MarshalIndent(map[string]any{
"bus": devPath,
"devices": found,
"count": len(found),
@@ -142,7 +144,7 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
}
// readDevice reads bytes from an I2C device, optionally at a specific register
-func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
bus, errResult := parseI2CBus(args)
if errResult != nil {
return errResult
@@ -201,7 +203,7 @@ func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
intBytes[i] = int(buf[i])
}
- result, _ := json.MarshalIndent(map[string]interface{}{
+ result, _ := json.MarshalIndent(map[string]any{
"bus": devPath,
"address": fmt.Sprintf("0x%02x", addr),
"bytes": intBytes,
@@ -212,10 +214,12 @@ func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
}
// writeDevice writes bytes to an I2C device, optionally at a specific register
-func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
confirm, _ := args["confirm"].(bool)
if !confirm {
- return ErrorResult("write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.")
+ return ErrorResult(
+ "write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.",
+ )
}
bus, errResult := parseI2CBus(args)
@@ -228,7 +232,7 @@ func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult {
return errResult
}
- dataRaw, ok := args["data"].([]interface{})
+ dataRaw, ok := args["data"].([]any)
if !ok || len(dataRaw) == 0 {
return ErrorResult("data is required for write (array of byte values 0-255)")
}
diff --git a/pkg/tools/i2c_other.go b/pkg/tools/i2c_other.go
index d1d581348..7becf8339 100644
--- a/pkg/tools/i2c_other.go
+++ b/pkg/tools/i2c_other.go
@@ -3,16 +3,16 @@
package tools
// scan is a stub for non-Linux platforms.
-func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) scan(args map[string]any) *ToolResult {
return ErrorResult("I2C is only supported on Linux")
}
// readDevice is a stub for non-Linux platforms.
-func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
return ErrorResult("I2C is only supported on Linux")
}
// writeDevice is a stub for non-Linux platforms.
-func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult {
+func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
return ErrorResult("I2C is only supported on Linux")
}
diff --git a/pkg/tools/message.go b/pkg/tools/message.go
index abedb1316..15ef4ff73 100644
--- a/pkg/tools/message.go
+++ b/pkg/tools/message.go
@@ -26,19 +26,19 @@ func (t *MessageTool) Description() string {
return "Send a message to user on a chat channel. Use this when you want to communicate something."
}
-func (t *MessageTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *MessageTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "content": map[string]interface{}{
+ "properties": map[string]any{
+ "content": map[string]any{
"type": "string",
"description": "The message content to send",
},
- "channel": map[string]interface{}{
+ "channel": map[string]any{
"type": "string",
"description": "Optional: target channel (telegram, whatsapp, etc.)",
},
- "chat_id": map[string]interface{}{
+ "chat_id": map[string]any{
"type": "string",
"description": "Optional: target chat/user ID",
},
@@ -62,7 +62,7 @@ func (t *MessageTool) SetSendCallback(callback SendCallback) {
t.sendCallback = callback
}
-func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
content, ok := args["content"].(string)
if !ok {
return &ToolResult{ForLLM: "content is required", IsError: true}
diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go
index 4bedbe79b..717c1117b 100644
--- a/pkg/tools/message_test.go
+++ b/pkg/tools/message_test.go
@@ -19,7 +19,7 @@ func TestMessageTool_Execute_Success(t *testing.T) {
})
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "Hello, world!",
}
@@ -70,7 +70,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
})
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "Test message",
"channel": "custom-channel",
"chat_id": "custom-chat-id",
@@ -104,7 +104,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
})
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "Test message",
}
@@ -136,7 +136,7 @@ func TestMessageTool_Execute_MissingContent(t *testing.T) {
tool.SetContext("test-channel", "test-chat-id")
ctx := context.Background()
- args := map[string]interface{}{} // content missing
+ args := map[string]any{} // content missing
result := tool.Execute(ctx, args)
@@ -158,7 +158,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
})
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "Test message",
}
@@ -179,7 +179,7 @@ func TestMessageTool_Execute_NotConfigured(t *testing.T) {
// No SetSendCallback called
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"content": "Test message",
}
@@ -219,7 +219,7 @@ func TestMessageTool_Parameters(t *testing.T) {
t.Error("Expected type 'object'")
}
- props, ok := params["properties"].(map[string]interface{})
+ props, ok := params["properties"].(map[string]any)
if !ok {
t.Fatal("Expected properties to be a map")
}
@@ -231,7 +231,7 @@ func TestMessageTool_Parameters(t *testing.T) {
}
// Check content property
- contentProp, ok := props["content"].(map[string]interface{})
+ contentProp, ok := props["content"].(map[string]any)
if !ok {
t.Error("Expected 'content' property")
}
@@ -240,7 +240,7 @@ func TestMessageTool_Parameters(t *testing.T) {
}
// Check channel property (optional)
- channelProp, ok := props["channel"].(map[string]interface{})
+ channelProp, ok := props["channel"].(map[string]any)
if !ok {
t.Error("Expected 'channel' property")
}
@@ -249,7 +249,7 @@ func TestMessageTool_Parameters(t *testing.T) {
}
// Check chat_id property (optional)
- chatIDProp, ok := props["chat_id"].(map[string]interface{})
+ chatIDProp, ok := props["chat_id"].(map[string]any)
if !ok {
t.Error("Expected 'chat_id' property")
}
diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go
index c8cf92863..6ecb8ae7c 100644
--- a/pkg/tools/registry.go
+++ b/pkg/tools/registry.go
@@ -34,16 +34,22 @@ func (r *ToolRegistry) Get(name string) (Tool, bool) {
return tool, ok
}
-func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) *ToolResult {
+func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult {
return r.ExecuteWithContext(ctx, name, args, "", "", nil)
}
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
// If the tool implements AsyncTool and a non-nil callback is provided,
// the callback will be set on the tool before execution.
-func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args map[string]interface{}, channel, chatID string, asyncCallback AsyncCallback) *ToolResult {
+func (r *ToolRegistry) ExecuteWithContext(
+ ctx context.Context,
+ name string,
+ args map[string]any,
+ channel, chatID string,
+ asyncCallback AsyncCallback,
+) *ToolResult {
logger.InfoCF("tool", "Tool execution started",
- map[string]interface{}{
+ map[string]any{
"tool": name,
"args": args,
})
@@ -51,7 +57,7 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args
tool, ok := r.Get(name)
if !ok {
logger.ErrorCF("tool", "Tool not found",
- map[string]interface{}{
+ map[string]any{
"tool": name,
})
return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found"))
@@ -66,7 +72,7 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args
if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil {
asyncTool.SetCallback(asyncCallback)
logger.DebugCF("tool", "Async callback injected",
- map[string]interface{}{
+ map[string]any{
"tool": name,
})
}
@@ -78,20 +84,20 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args
// Log based on result type
if result.IsError {
logger.ErrorCF("tool", "Tool execution failed",
- map[string]interface{}{
+ map[string]any{
"tool": name,
"duration": duration.Milliseconds(),
"error": result.ForLLM,
})
} else if result.Async {
logger.InfoCF("tool", "Tool started (async)",
- map[string]interface{}{
+ map[string]any{
"tool": name,
"duration": duration.Milliseconds(),
})
} else {
logger.InfoCF("tool", "Tool execution completed",
- map[string]interface{}{
+ map[string]any{
"tool": name,
"duration_ms": duration.Milliseconds(),
"result_length": len(result.ForLLM),
@@ -101,11 +107,11 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args
return result
}
-func (r *ToolRegistry) GetDefinitions() []map[string]interface{} {
+func (r *ToolRegistry) GetDefinitions() []map[string]any {
r.mu.RLock()
defer r.mu.RUnlock()
- definitions := make([]map[string]interface{}, 0, len(r.tools))
+ definitions := make([]map[string]any, 0, len(r.tools))
for _, tool := range r.tools {
definitions = append(definitions, ToolToSchema(tool))
}
@@ -123,14 +129,14 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
schema := ToolToSchema(tool)
// Safely extract nested values with type checks
- fn, ok := schema["function"].(map[string]interface{})
+ fn, ok := schema["function"].(map[string]any)
if !ok {
continue
}
name, _ := fn["name"].(string)
desc, _ := fn["description"].(string)
- params, _ := fn["parameters"].(map[string]interface{})
+ params, _ := fn["parameters"].(map[string]any)
definitions = append(definitions, providers.ToolDefinition{
Type: "function",
diff --git a/pkg/tools/result_test.go b/pkg/tools/result_test.go
index bc798cd70..a234e33f3 100644
--- a/pkg/tools/result_test.go
+++ b/pkg/tools/result_test.go
@@ -192,7 +192,7 @@ func TestToolResultJSONStructure(t *testing.T) {
}
// Verify JSON structure
- var parsed map[string]interface{}
+ var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index d9430672f..9c58df355 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -118,15 +118,15 @@ func (t *ExecTool) Description() string {
return "Execute a shell command and return its output. Use with caution."
}
-func (t *ExecTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *ExecTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "command": map[string]interface{}{
+ "properties": map[string]any{
+ "command": map[string]any{
"type": "string",
"description": "The shell command to execute",
},
- "working_dir": map[string]interface{}{
+ "working_dir": map[string]any{
"type": "string",
"description": "Optional working directory for the command",
},
@@ -135,7 +135,7 @@ func (t *ExecTool) Parameters() map[string]interface{} {
}
}
-func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
command, ok := args["command"].(string)
if !ok {
return ErrorResult("command is required")
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index c06468a39..f85b5a008 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -14,7 +14,7 @@ func TestShellTool_Success(t *testing.T) {
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "echo 'hello world'",
}
@@ -41,7 +41,7 @@ func TestShellTool_Failure(t *testing.T) {
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "ls /nonexistent_directory_12345",
}
@@ -69,7 +69,7 @@ func TestShellTool_Timeout(t *testing.T) {
tool.SetTimeout(100 * time.Millisecond)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "sleep 10",
}
@@ -91,12 +91,12 @@ func TestShellTool_WorkingDir(t *testing.T) {
// Create temp directory
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
- os.WriteFile(testFile, []byte("test content"), 0644)
+ os.WriteFile(testFile, []byte("test content"), 0o644)
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "cat test.txt",
"working_dir": tmpDir,
}
@@ -117,7 +117,7 @@ func TestShellTool_DangerousCommand(t *testing.T) {
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "rm -rf /",
}
@@ -138,7 +138,7 @@ func TestShellTool_MissingCommand(t *testing.T) {
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{}
+ args := map[string]any{}
result := tool.Execute(ctx, args)
@@ -153,7 +153,7 @@ func TestShellTool_StderrCapture(t *testing.T) {
tool := NewExecTool("", false)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "sh -c 'echo stdout; echo stderr >&2'",
}
@@ -174,7 +174,7 @@ func TestShellTool_OutputTruncation(t *testing.T) {
ctx := context.Background()
// Generate long output (>10000 chars)
- args := map[string]interface{}{
+ args := map[string]any{
"command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000),
}
@@ -193,7 +193,7 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) {
tool.SetRestrictToWorkspace(true)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"command": "cat ../../etc/passwd",
}
@@ -205,6 +205,10 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) {
}
if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") {
- t.Errorf("Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
+ t.Errorf(
+ "Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s",
+ result.ForLLM,
+ result.ForUser,
+ )
}
}
diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go
index f01372467..73d385cb0 100644
--- a/pkg/tools/spawn.go
+++ b/pkg/tools/spawn.go
@@ -34,19 +34,19 @@ func (t *SpawnTool) Description() string {
return "Spawn a subagent to handle a task in the background. Use this for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done."
}
-func (t *SpawnTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *SpawnTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "task": map[string]interface{}{
+ "properties": map[string]any{
+ "task": map[string]any{
"type": "string",
"description": "The task for subagent to complete",
},
- "label": map[string]interface{}{
+ "label": map[string]any{
"type": "string",
"description": "Optional short label for the task (for display)",
},
- "agent_id": map[string]interface{}{
+ "agent_id": map[string]any{
"type": "string",
"description": "Optional target agent ID to delegate the task to",
},
@@ -64,7 +64,7 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
t.allowlistCheck = check
}
-func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string)
if !ok {
return ErrorResult("task is required")
diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go
index 4805d6a35..d6a88a5b0 100644
--- a/pkg/tools/spi.go
+++ b/pkg/tools/spi.go
@@ -24,41 +24,41 @@ func (t *SPITool) Description() string {
return "Interact with SPI bus devices for high-speed peripheral communication. Actions: list (find SPI devices), transfer (full-duplex send/receive), read (receive bytes). Linux only."
}
-func (t *SPITool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *SPITool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "action": map[string]interface{}{
+ "properties": map[string]any{
+ "action": map[string]any{
"type": "string",
"enum": []string{"list", "transfer", "read"},
"description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)",
},
- "device": map[string]interface{}{
+ "device": map[string]any{
"type": "string",
"description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.",
},
- "speed": map[string]interface{}{
+ "speed": map[string]any{
"type": "integer",
"description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).",
},
- "mode": map[string]interface{}{
+ "mode": map[string]any{
"type": "integer",
"description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.",
},
- "bits": map[string]interface{}{
+ "bits": map[string]any{
"type": "integer",
"description": "Bits per word. Default: 8.",
},
- "data": map[string]interface{}{
+ "data": map[string]any{
"type": "array",
- "items": map[string]interface{}{"type": "integer"},
+ "items": map[string]any{"type": "integer"},
"description": "Bytes to send (0-255 each). Required for transfer action.",
},
- "length": map[string]interface{}{
+ "length": map[string]any{
"type": "integer",
"description": "Number of bytes to read (1-4096). Required for read action.",
},
- "confirm": map[string]interface{}{
+ "confirm": map[string]any{
"type": "boolean",
"description": "Must be true for transfer operations. Safety guard to prevent accidental writes.",
},
@@ -67,7 +67,7 @@ func (t *SPITool) Parameters() map[string]interface{} {
}
}
-func (t *SPITool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult {
if runtime.GOOS != "linux" {
return ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.")
}
@@ -97,7 +97,9 @@ func (t *SPITool) list() *ToolResult {
}
if len(matches) == 0 {
- return SilentResult("No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded")
+ return SilentResult(
+ "No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded",
+ )
}
type devInfo struct {
@@ -118,7 +120,7 @@ func (t *SPITool) list() *ToolResult {
}
// parseSPIArgs extracts and validates common SPI parameters
-func parseSPIArgs(args map[string]interface{}) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
+func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
dev, ok := args["device"].(string)
if !ok || dev == "" {
return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)"
diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go
index 12b696007..9def73662 100644
--- a/pkg/tools/spi_linux.go
+++ b/pkg/tools/spi_linux.go
@@ -66,10 +66,12 @@ func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *T
}
// transfer performs a full-duplex SPI transfer
-func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
+func (t *SPITool) transfer(args map[string]any) *ToolResult {
confirm, _ := args["confirm"].(bool)
if !confirm {
- return ErrorResult("transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.")
+ return ErrorResult(
+ "transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.",
+ )
}
dev, speed, mode, bits, errMsg := parseSPIArgs(args)
@@ -77,7 +79,7 @@ func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
return ErrorResult(errMsg)
}
- dataRaw, ok := args["data"].([]interface{})
+ dataRaw, ok := args["data"].([]any)
if !ok || len(dataRaw) == 0 {
return ErrorResult("data is required for transfer (array of byte values 0-255)")
}
@@ -130,7 +132,7 @@ func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
intBytes[i] = int(b)
}
- result, _ := json.MarshalIndent(map[string]interface{}{
+ result, _ := json.MarshalIndent(map[string]any{
"device": devPath,
"sent": len(txBuf),
"received": intBytes,
@@ -140,7 +142,7 @@ func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
}
// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed)
-func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult {
+func (t *SPITool) readDevice(args map[string]any) *ToolResult {
dev, speed, mode, bits, errMsg := parseSPIArgs(args)
if errMsg != "" {
return ErrorResult(errMsg)
@@ -186,7 +188,7 @@ func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult {
intBytes[i] = int(b)
}
- result, _ := json.MarshalIndent(map[string]interface{}{
+ result, _ := json.MarshalIndent(map[string]any{
"device": devPath,
"bytes": intBytes,
"hex": hexBytes,
diff --git a/pkg/tools/spi_other.go b/pkg/tools/spi_other.go
index 6dfc86fd1..5d078ac3f 100644
--- a/pkg/tools/spi_other.go
+++ b/pkg/tools/spi_other.go
@@ -3,11 +3,11 @@
package tools
// transfer is a stub for non-Linux platforms.
-func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
+func (t *SPITool) transfer(args map[string]any) *ToolResult {
return ErrorResult("SPI is only supported on Linux")
}
// readDevice is a stub for non-Linux platforms.
-func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult {
+func (t *SPITool) readDevice(args map[string]any) *ToolResult {
return ErrorResult("SPI is only supported on Linux")
}
diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go
index 2fc7162d0..222137c89 100644
--- a/pkg/tools/subagent.go
+++ b/pkg/tools/subagent.go
@@ -34,7 +34,11 @@ type SubagentManager struct {
nextID int
}
-func NewSubagentManager(provider providers.LLMProvider, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager {
+func NewSubagentManager(
+ provider providers.LLMProvider,
+ defaultModel, workspace string,
+ bus *bus.MessageBus,
+) *SubagentManager {
return &SubagentManager{
tasks: make(map[string]*SubagentTask),
provider: provider,
@@ -62,7 +66,11 @@ func (sm *SubagentManager) RegisterTool(tool Tool) {
sm.tools.Register(tool)
}
-func (sm *SubagentManager) Spawn(ctx context.Context, task, label, agentID, originChannel, originChatID string, callback AsyncCallback) (string, error) {
+func (sm *SubagentManager) Spawn(
+ ctx context.Context,
+ task, label, agentID, originChannel, originChatID string,
+ callback AsyncCallback,
+) (string, error) {
sm.mu.Lock()
defer sm.mu.Unlock()
@@ -168,7 +176,12 @@ After completing the task, provide a clear summary of what was done.`
task.Status = "completed"
task.Result = loopResult.Content
result = &ToolResult{
- ForLLM: fmt.Sprintf("Subagent '%s' completed (iterations: %d): %s", task.Label, loopResult.Iterations, loopResult.Content),
+ ForLLM: fmt.Sprintf(
+ "Subagent '%s' completed (iterations: %d): %s",
+ task.Label,
+ loopResult.Iterations,
+ loopResult.Content,
+ ),
ForUser: loopResult.Content,
Silent: false,
IsError: false,
@@ -232,15 +245,15 @@ func (t *SubagentTool) Description() string {
return "Execute a subagent task synchronously and return the result. Use this for delegating specific tasks to an independent agent instance. Returns execution summary to user and full details to LLM."
}
-func (t *SubagentTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *SubagentTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "task": map[string]interface{}{
+ "properties": map[string]any{
+ "task": map[string]any{
"type": "string",
"description": "The task for subagent to complete",
},
- "label": map[string]interface{}{
+ "label": map[string]any{
"type": "string",
"description": "Optional short label for the task (for display)",
},
@@ -254,7 +267,7 @@ func (t *SubagentTool) SetContext(channel, chatID string) {
t.originChatID = chatID
}
-func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string)
if !ok {
return ErrorResult("task is required").WithError(fmt.Errorf("task parameter is required"))
@@ -295,7 +308,6 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
"temperature": 0.7,
},
}, messages, t.originChannel, t.originChatID)
-
if err != nil {
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
}
diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go
index 8a7d22f24..8e4dc3953 100644
--- a/pkg/tools/subagent_tool_test.go
+++ b/pkg/tools/subagent_tool_test.go
@@ -12,7 +12,13 @@ import (
// MockLLMProvider is a test implementation of LLMProvider
type MockLLMProvider struct{}
-func (m *MockLLMProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
+func (m *MockLLMProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ options map[string]any,
+) (*providers.LLMResponse, error) {
// Find the last user message to generate a response
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
@@ -79,13 +85,13 @@ func TestSubagentTool_Parameters(t *testing.T) {
}
// Check properties
- props, ok := params["properties"].(map[string]interface{})
+ props, ok := params["properties"].(map[string]any)
if !ok {
t.Fatal("Properties should be a map")
}
// Verify task parameter
- task, ok := props["task"].(map[string]interface{})
+ task, ok := props["task"].(map[string]any)
if !ok {
t.Fatal("Task parameter should exist")
}
@@ -94,7 +100,7 @@ func TestSubagentTool_Parameters(t *testing.T) {
}
// Verify label parameter
- label, ok := props["label"].(map[string]interface{})
+ label, ok := props["label"].(map[string]any)
if !ok {
t.Fatal("Label parameter should exist")
}
@@ -134,7 +140,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) {
tool.SetContext("telegram", "chat-123")
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "Write a haiku about coding",
"label": "haiku-task",
}
@@ -189,7 +195,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
tool := NewSubagentTool(manager)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "Test task without label",
}
@@ -212,7 +218,7 @@ func TestSubagentTool_Execute_MissingTask(t *testing.T) {
tool := NewSubagentTool(manager)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"label": "test",
}
@@ -239,7 +245,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
tool := NewSubagentTool(nil)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "test task",
}
@@ -268,7 +274,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
tool.SetContext(channel, chatID)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "Test context passing",
}
@@ -295,7 +301,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) {
// Create a task that will generate long response
longTask := strings.Repeat("This is a very long task description. ", 100)
- args := map[string]interface{}{
+ args := map[string]any{
"task": longTask,
"label": "long-test",
}
diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go
index 1302079b4..f0653e1f2 100644
--- a/pkg/tools/toolloop.go
+++ b/pkg/tools/toolloop.go
@@ -33,7 +33,12 @@ type ToolLoopResult struct {
// RunToolLoop executes the LLM + tool call iteration loop.
// This is the core agent logic that can be reused by both main agent and subagents.
-func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []providers.Message, channel, chatID string) (*ToolLoopResult, error) {
+func RunToolLoop(
+ ctx context.Context,
+ config ToolLoopConfig,
+ messages []providers.Message,
+ channel, chatID string,
+) (*ToolLoopResult, error) {
iteration := 0
var finalContent string
diff --git a/pkg/tools/types.go b/pkg/tools/types.go
index f8205b8bd..a6015cde3 100644
--- a/pkg/tools/types.go
+++ b/pkg/tools/types.go
@@ -10,11 +10,11 @@ type Message struct {
}
type ToolCall struct {
- ID string `json:"id"`
- Type string `json:"type"`
- Function *FunctionCall `json:"function,omitempty"`
- Name string `json:"name,omitempty"`
- Arguments map[string]interface{} `json:"arguments,omitempty"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function *FunctionCall `json:"function,omitempty"`
+ Name string `json:"name,omitempty"`
+ Arguments map[string]any `json:"arguments,omitempty"`
}
type FunctionCall struct {
@@ -36,7 +36,13 @@ type UsageInfo struct {
}
type LLMProvider interface {
- Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
+ Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ ) (*LLMResponse, error)
GetDefaultModel() string
}
@@ -46,7 +52,7 @@ type ToolDefinition struct {
}
type ToolFunctionDefinition struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Parameters map[string]interface{} `json:"parameters"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters map[string]any `json:"parameters"`
}
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index 6a6d40ecf..de8296816 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -183,11 +183,17 @@ type PerplexitySearchProvider struct {
func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
searchURL := "https://api.perplexity.ai/chat/completions"
- payload := map[string]interface{}{
+ payload := map[string]any{
"model": "sonar",
"messages": []map[string]string{
- {"role": "system", "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary."},
- {"role": "user", "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count)},
+ {
+ "role": "system",
+ "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary.",
+ },
+ {
+ "role": "user",
+ "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count),
+ },
},
"max_tokens": 1000,
}
@@ -295,15 +301,15 @@ func (t *WebSearchTool) Description() string {
return "Search the web for current information. Returns titles, URLs, and snippets from search results."
}
-func (t *WebSearchTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *WebSearchTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "query": map[string]interface{}{
+ "properties": map[string]any{
+ "query": map[string]any{
"type": "string",
"description": "Search query",
},
- "count": map[string]interface{}{
+ "count": map[string]any{
"type": "integer",
"description": "Number of results (1-10)",
"minimum": 1.0,
@@ -314,7 +320,7 @@ func (t *WebSearchTool) Parameters() map[string]interface{} {
}
}
-func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
query, ok := args["query"].(string)
if !ok {
return ErrorResult("query is required")
@@ -359,15 +365,15 @@ func (t *WebFetchTool) Description() string {
return "Fetch a URL and extract readable content (HTML to text). Use this to get weather info, news, articles, or any web content."
}
-func (t *WebFetchTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *WebFetchTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "url": map[string]interface{}{
+ "properties": map[string]any{
+ "url": map[string]any{
"type": "string",
"description": "URL to fetch",
},
- "maxChars": map[string]interface{}{
+ "maxChars": map[string]any{
"type": "integer",
"description": "Maximum characters to extract",
"minimum": 100.0,
@@ -377,7 +383,7 @@ func (t *WebFetchTool) Parameters() map[string]interface{} {
}
}
-func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
urlStr, ok := args["url"].(string)
if !ok {
return ErrorResult("url is required")
@@ -442,7 +448,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
var text, extractor string
if strings.Contains(contentType, "application/json") {
- var jsonData interface{}
+ var jsonData any
if err := json.Unmarshal(body, &jsonData); err == nil {
formatted, _ := json.MarshalIndent(jsonData, "", " ")
text = string(formatted)
@@ -465,7 +471,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
text = text[:maxChars]
}
- result := map[string]interface{}{
+ result := map[string]any{
"url": urlStr,
"status": resp.StatusCode,
"extractor": extractor,
@@ -477,7 +483,13 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
resultJSON, _ := json.MarshalIndent(result, "", " ")
return &ToolResult{
- ForLLM: fmt.Sprintf("Fetched %d bytes from %s (extractor: %s, truncated: %v)", len(text), urlStr, extractor, truncated),
+ ForLLM: fmt.Sprintf(
+ "Fetched %d bytes from %s (extractor: %s, truncated: %v)",
+ len(text),
+ urlStr,
+ extractor,
+ truncated,
+ ),
ForUser: string(resultJSON),
}
}
diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go
index a526ea34a..edb914f66 100644
--- a/pkg/tools/web_test.go
+++ b/pkg/tools/web_test.go
@@ -20,7 +20,7 @@ func TestWebTool_WebFetch_Success(t *testing.T) {
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": server.URL,
}
@@ -56,7 +56,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) {
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": server.URL,
}
@@ -77,7 +77,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) {
func TestWebTool_WebFetch_InvalidURL(t *testing.T) {
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": "not-a-valid-url",
}
@@ -98,7 +98,7 @@ func TestWebTool_WebFetch_InvalidURL(t *testing.T) {
func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) {
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": "ftp://example.com/file.txt",
}
@@ -119,7 +119,7 @@ func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) {
func TestWebTool_WebFetch_MissingURL(t *testing.T) {
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{}
+ args := map[string]any{}
result := tool.Execute(ctx, args)
@@ -147,7 +147,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
tool := NewWebFetchTool(1000) // Limit to 1000 chars
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": server.URL,
}
@@ -159,7 +159,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
}
// ForUser should contain truncated content (not the full 20000 chars)
- resultMap := make(map[string]interface{})
+ resultMap := make(map[string]any)
json.Unmarshal([]byte(result.ForUser), &resultMap)
if text, ok := resultMap["text"].(string); ok {
if len(text) > 1100 { // Allow some margin
@@ -191,7 +191,7 @@ func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5})
ctx := context.Background()
- args := map[string]interface{}{}
+ args := map[string]any{}
result := tool.Execute(ctx, args)
@@ -206,13 +206,17 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
- w.Write([]byte(`Title
Content
`))
+ w.Write(
+ []byte(
+ `Title
Content
`,
+ ),
+ )
}))
defer server.Close()
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": server.URL,
}
@@ -238,7 +242,7 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
tool := NewWebFetchTool(50000)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"url": "https://",
}
diff --git a/pkg/utils/media.go b/pkg/utils/media.go
index 2b184f2ec..a34889fb8 100644
--- a/pkg/utils/media.go
+++ b/pkg/utils/media.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/google/uuid"
+
"github.com/sipeed/picoclaw/pkg/logger"
)
@@ -65,8 +66,8 @@ func DownloadFile(url, filename string, opts DownloadOptions) string {
}
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
- if err := os.MkdirAll(mediaDir, 0700); err != nil {
- logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]interface{}{
+ if err := os.MkdirAll(mediaDir, 0o700); err != nil {
+ logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]any{
"error": err.Error(),
})
return ""
@@ -79,7 +80,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string {
// Create HTTP request
req, err := http.NewRequest("GET", url, nil)
if err != nil {
- logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]interface{}{
+ logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]any{
"error": err.Error(),
})
return ""
@@ -93,7 +94,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string {
client := &http.Client{Timeout: opts.Timeout}
resp, err := client.Do(req)
if err != nil {
- logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]interface{}{
+ logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]any{
"error": err.Error(),
"url": url,
})
@@ -102,7 +103,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- logger.ErrorCF(opts.LoggerPrefix, "File download returned non-200 status", map[string]interface{}{
+ logger.ErrorCF(opts.LoggerPrefix, "File download returned non-200 status", map[string]any{
"status": resp.StatusCode,
"url": url,
})
@@ -111,7 +112,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string {
out, err := os.Create(localPath)
if err != nil {
- logger.ErrorCF(opts.LoggerPrefix, "Failed to create local file", map[string]interface{}{
+ logger.ErrorCF(opts.LoggerPrefix, "Failed to create local file", map[string]any{
"error": err.Error(),
})
return ""
@@ -121,13 +122,13 @@ func DownloadFile(url, filename string, opts DownloadOptions) string {
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close()
os.Remove(localPath)
- logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]interface{}{
+ logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]any{
"error": err.Error(),
})
return ""
}
- logger.DebugCF(opts.LoggerPrefix, "File downloaded successfully", map[string]interface{}{
+ logger.DebugCF(opts.LoggerPrefix, "File downloaded successfully", map[string]any{
"path": localPath,
})
diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go
index 9af2ea6bb..ad8767d40 100644
--- a/pkg/voice/transcriber.go
+++ b/pkg/voice/transcriber.go
@@ -29,7 +29,7 @@ type TranscriptionResponse struct {
}
func NewGroqTranscriber(apiKey string) *GroqTranscriber {
- logger.DebugCF("voice", "Creating Groq transcriber", map[string]interface{}{"has_api_key": apiKey != ""})
+ logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""})
apiBase := "https://api.groq.com/openai/v1"
return &GroqTranscriber{
@@ -42,22 +42,22 @@ func NewGroqTranscriber(apiKey string) *GroqTranscriber {
}
func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
- logger.InfoCF("voice", "Starting transcription", map[string]interface{}{"audio_file": audioFilePath})
+ logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath})
audioFile, err := os.Open(audioFilePath)
if err != nil {
- logger.ErrorCF("voice", "Failed to open audio file", map[string]interface{}{"path": audioFilePath, "error": err})
+ logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err})
return nil, fmt.Errorf("failed to open audio file: %w", err)
}
defer audioFile.Close()
fileInfo, err := audioFile.Stat()
if err != nil {
- logger.ErrorCF("voice", "Failed to get file info", map[string]interface{}{"path": audioFilePath, "error": err})
+ logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err})
return nil, fmt.Errorf("failed to get file info: %w", err)
}
- logger.DebugCF("voice", "Audio file details", map[string]interface{}{
+ logger.DebugCF("voice", "Audio file details", map[string]any{
"size_bytes": fileInfo.Size(),
"file_name": filepath.Base(audioFilePath),
})
@@ -67,44 +67,44 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
if err != nil {
- logger.ErrorCF("voice", "Failed to create form file", map[string]interface{}{"error": err})
+ logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err})
return nil, fmt.Errorf("failed to create form file: %w", err)
}
copied, err := io.Copy(part, audioFile)
if err != nil {
- logger.ErrorCF("voice", "Failed to copy file content", map[string]interface{}{"error": err})
+ logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err})
return nil, fmt.Errorf("failed to copy file content: %w", err)
}
- logger.DebugCF("voice", "File copied to request", map[string]interface{}{"bytes_copied": copied})
+ logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied})
if err := writer.WriteField("model", "whisper-large-v3"); err != nil {
- logger.ErrorCF("voice", "Failed to write model field", map[string]interface{}{"error": err})
+ logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err})
return nil, fmt.Errorf("failed to write model field: %w", err)
}
if err := writer.WriteField("response_format", "json"); err != nil {
- logger.ErrorCF("voice", "Failed to write response_format field", map[string]interface{}{"error": err})
+ logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err})
return nil, fmt.Errorf("failed to write response_format field: %w", err)
}
if err := writer.Close(); err != nil {
- logger.ErrorCF("voice", "Failed to close multipart writer", map[string]interface{}{"error": err})
+ logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err})
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
}
url := t.apiBase + "/audio/transcriptions"
req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
if err != nil {
- logger.ErrorCF("voice", "Failed to create request", map[string]interface{}{"error": err})
+ logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err})
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+t.apiKey)
- logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]interface{}{
+ logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{
"url": url,
"request_size_bytes": requestBody.Len(),
"file_size_bytes": fileInfo.Size(),
@@ -112,37 +112,37 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
resp, err := t.httpClient.Do(req)
if err != nil {
- logger.ErrorCF("voice", "Failed to send request", map[string]interface{}{"error": err})
+ logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err})
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- logger.ErrorCF("voice", "Failed to read response", map[string]interface{}{"error": err})
+ logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err})
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
- logger.ErrorCF("voice", "API error", map[string]interface{}{
+ logger.ErrorCF("voice", "API error", map[string]any{
"status_code": resp.StatusCode,
"response": string(body),
})
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
- logger.DebugCF("voice", "Received response from Groq API", map[string]interface{}{
+ logger.DebugCF("voice", "Received response from Groq API", map[string]any{
"status_code": resp.StatusCode,
"response_size_bytes": len(body),
})
var result TranscriptionResponse
if err := json.Unmarshal(body, &result); err != nil {
- logger.ErrorCF("voice", "Failed to unmarshal response", map[string]interface{}{"error": err})
+ logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err})
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
- logger.InfoCF("voice", "Transcription completed successfully", map[string]interface{}{
+ logger.InfoCF("voice", "Transcription completed successfully", map[string]any{
"text_length": len(result.Text),
"language": result.Language,
"duration_seconds": result.Duration,
@@ -154,6 +154,6 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
func (t *GroqTranscriber) IsAvailable() bool {
available := t.apiKey != ""
- logger.DebugCF("voice", "Checking transcriber availability", map[string]interface{}{"available": available})
+ logger.DebugCF("voice", "Checking transcriber availability", map[string]any{"available": available})
return available
}
From d07ac54eef87fab754c25e3fded292ce6c957db3 Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Wed, 18 Feb 2026 21:55:55 +0200
Subject: [PATCH 03/88] feat(fmt): Fix fmt
---
pkg/skills/loader.go | 2 +-
pkg/skills/loader_test.go | 34 ++++++++++++++++++++++++++--------
2 files changed, 27 insertions(+), 9 deletions(-)
diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go
index bb0abbdcc..eb0d5f322 100644
--- a/pkg/skills/loader.go
+++ b/pkg/skills/loader.go
@@ -254,7 +254,7 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
content, err := os.ReadFile(skillPath)
if err != nil {
logger.WarnCF("skills", "Failed to read skill metadata",
- map[string]interface{}{
+ map[string]any{
"skill_path": skillPath,
"error": err.Error(),
})
diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go
index 539d24646..aca901d33 100644
--- a/pkg/skills/loader_test.go
+++ b/pkg/skills/loader_test.go
@@ -80,11 +80,11 @@ func TestExtractFrontmatter(t *testing.T) {
sl := &SkillsLoader{}
testcases := []struct {
- name string
- content string
- expectedName string
- expectedDesc string
- lineEndingType string
+ name string
+ content string
+ expectedName string
+ expectedDesc string
+ lineEndingType string
}{
{
name: "unix-line-endings",
@@ -117,8 +117,20 @@ func TestExtractFrontmatter(t *testing.T) {
// Parse YAML to get name and description (parseSimpleYAML now handles all line ending types)
yamlMeta := sl.parseSimpleYAML(frontmatter)
- assert.Equal(t, tc.expectedName, yamlMeta["name"], "Name should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType)
- assert.Equal(t, tc.expectedDesc, yamlMeta["description"], "Description should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType)
+ assert.Equal(
+ t,
+ tc.expectedName,
+ yamlMeta["name"],
+ "Name should be correctly parsed from frontmatter with %s line endings",
+ tc.lineEndingType,
+ )
+ assert.Equal(
+ t,
+ tc.expectedDesc,
+ yamlMeta["description"],
+ "Description should be correctly parsed from frontmatter with %s line endings",
+ tc.lineEndingType,
+ )
})
}
}
@@ -173,7 +185,13 @@ func TestStripFrontmatter(t *testing.T) {
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
result := sl.stripFrontmatter(tc.content)
- assert.Equal(t, tc.expectedContent, result, "Frontmatter should be stripped correctly for %s", tc.lineEndingType)
+ assert.Equal(
+ t,
+ tc.expectedContent,
+ result,
+ "Frontmatter should be stripped correctly for %s",
+ tc.lineEndingType,
+ )
})
}
}
From 676bd6d222509591614db840c2d36e94b2fdc3a2 Mon Sep 17 00:00:00 2001
From: PixelTux
Date: Thu, 19 Feb 2026 15:52:46 +0100
Subject: [PATCH 04/88] extra_hosts mapping to have enables container-to-host
connectivity
---
docker-compose.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/docker-compose.yml b/docker-compose.yml
index 32e8ee339..c268b01cd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,9 @@ services:
container_name: picoclaw-agent
profiles:
- agent
+ # Uncomment to access host network; leave commented unless needed.
+ #extra_hosts:
+ # - "host.docker.internal:host-gateway"
volumes:
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
@@ -29,6 +32,9 @@ services:
restart: unless-stopped
profiles:
- gateway
+ # Uncomment to access host network; leave commented unless needed.
+ #extra_hosts:
+ # - "host.docker.internal:host-gateway"
volumes:
# Configuration file
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
From a89683190386c9da604fc22cf20ec594aa32214f Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Thu, 19 Feb 2026 22:05:15 +0200
Subject: [PATCH 05/88] feat(fmt): Fix formatting
---
pkg/agent/loop.go | 127 ++++++++++++++++--------
pkg/agent/loop_test.go | 46 ++++++---
pkg/agent/mock_provider_test.go | 8 +-
pkg/channels/discord.go | 5 +-
pkg/channels/onebot.go | 91 +++++++++--------
pkg/config/config.go | 112 ++++++++++-----------
pkg/providers/openai_compat/provider.go | 3 +-
pkg/tools/subagent_tool_test.go | 30 +++---
pkg/tools/web_test.go | 3 +-
9 files changed, 254 insertions(+), 171 deletions(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 0f1b26c5c..6772959b6 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -79,7 +79,12 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
}
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
-func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) {
+func registerSharedTools(
+ cfg *config.Config,
+ msgBus *bus.MessageBus,
+ registry *AgentRegistry,
+ provider providers.LLMProvider,
+) {
for _, agentID := range registry.ListAgentIDs() {
agent, ok := registry.GetAgent(agentID)
if !ok {
@@ -216,7 +221,10 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
}
-func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) {
+func (al *AgentLoop) ProcessDirectWithChannel(
+ ctx context.Context,
+ content, sessionKey, channel, chatID string,
+) (string, error) {
msg := bus.InboundMessage{
Channel: channel,
SenderID: "cron",
@@ -253,7 +261,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
logContent = utils.Truncate(msg.Content, 80)
}
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
- map[string]interface{}{
+ map[string]any{
"channel": msg.Channel,
"chat_id": msg.ChatID,
"sender_id": msg.SenderID,
@@ -292,7 +300,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}
logger.InfoCF("agent", "Routed message",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"session_key": sessionKey,
"matched_by": route.MatchedBy,
@@ -315,7 +323,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
}
logger.InfoCF("agent", "Processing system message",
- map[string]interface{}{
+ map[string]any{
"sender_id": msg.SenderID,
"chat_id": msg.ChatID,
})
@@ -340,7 +348,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
// Skip internal channels - only log, don't send to user
if constants.IsInternalChannel(originChannel) {
logger.InfoCF("agent", "Subagent completed (internal channel)",
- map[string]interface{}{
+ map[string]any{
"sender_id": msg.SenderID,
"content_len": len(content),
"channel": originChannel,
@@ -373,7 +381,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
if !constants.IsInternalChannel(opts.Channel) {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil {
- logger.WarnCF("agent", "Failed to record last channel", map[string]interface{}{"error": err.Error()})
+ logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()})
}
}
}
@@ -435,7 +443,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 9. Log response
responsePreview := utils.Truncate(finalContent, 120)
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"session_key": opts.SessionKey,
"iterations": iteration,
@@ -446,7 +454,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
}
// runLLMIteration executes the LLM call loop with tool handling.
-func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) {
+func (al *AgentLoop) runLLMIteration(
+ ctx context.Context,
+ agent *AgentInstance,
+ messages []providers.Message,
+ opts processOptions,
+) (string, int, error) {
iteration := 0
var finalContent string
@@ -454,7 +467,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
iteration++
logger.DebugCF("agent", "LLM iteration",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"max": agent.MaxIterations,
@@ -465,7 +478,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
// Log LLM request details
logger.DebugCF("agent", "LLM request",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"model": agent.Model,
@@ -478,7 +491,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
// Log full messages (detailed)
logger.DebugCF("agent", "Full LLM request",
- map[string]interface{}{
+ map[string]any{
"iteration": iteration,
"messages_json": formatMessagesForLog(messages),
"tools_json": formatToolsForLog(providerToolDefs),
@@ -492,7 +505,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if len(agent.Candidates) > 1 && al.fallback != nil {
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
- return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
"max_tokens": agent.MaxTokens,
"temperature": agent.Temperature,
})
@@ -504,11 +517,11 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
- map[string]interface{}{"agent_id": agent.ID, "iteration": iteration})
+ map[string]any{"agent_id": agent.ID, "iteration": iteration})
}
return fbResult.Response, nil
}
- return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
"max_tokens": agent.MaxTokens,
"temperature": agent.Temperature,
})
@@ -529,7 +542,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
strings.Contains(errMsg, "length")
if isContextError && retry < maxRetries {
- logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{
+ logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
"error": err.Error(),
"retry": retry,
})
@@ -556,7 +569,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if err != nil {
logger.ErrorCF("agent", "LLM call failed",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"error": err.Error(),
@@ -568,7 +581,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if len(response.ToolCalls) == 0 {
finalContent = response.Content
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(finalContent),
@@ -582,7 +595,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
toolNames = append(toolNames, tc.Name)
}
logger.InfoCF("agent", "LLM requested tool calls",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"tools": toolNames,
"count": len(response.ToolCalls),
@@ -616,7 +629,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"tool": tc.Name,
"iteration": iteration,
@@ -631,14 +644,21 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
// The agent will handle user notification via processSystemMessage
if !result.Silent && result.ForUser != "" {
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
- map[string]interface{}{
+ map[string]any{
"tool": tc.Name,
"content_len": len(result.ForUser),
})
}
}
- toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
+ toolResult := agent.Tools.ExecuteWithContext(
+ ctx,
+ tc.Name,
+ tc.Arguments,
+ opts.Channel,
+ opts.ChatID,
+ asyncCallback,
+ )
// Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
@@ -648,7 +668,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
Content: toolResult.ForUser,
})
logger.DebugCF("agent", "Sent tool result to user",
- map[string]interface{}{
+ map[string]any{
"tool": tc.Name,
"content_len": len(toolResult.ForUser),
})
@@ -754,7 +774,10 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
newHistory = append(newHistory, history[0]) // System prompt
// Add a note about compression
- compressionNote := fmt.Sprintf("[System: Emergency compression dropped %d oldest messages due to context limit]", droppedCount)
+ compressionNote := fmt.Sprintf(
+ "[System: Emergency compression dropped %d oldest messages due to context limit]",
+ droppedCount,
+ )
// If there was an existing summary, we might lose it if it was in the dropped part (which is just messages).
// The summary is stored separately in session.Summary, so it persists!
// We just need to ensure the user knows there's a gap.
@@ -772,7 +795,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
agent.Sessions.SetHistory(sessionKey, newHistory)
agent.Sessions.Save(sessionKey)
- logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{
+ logger.WarnCF("agent", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(newHistory),
@@ -780,8 +803,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
}
// GetStartupInfo returns information about loaded tools and skills for logging.
-func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
- info := make(map[string]interface{})
+func (al *AgentLoop) GetStartupInfo() map[string]any {
+ info := make(map[string]any)
agent := al.registry.GetDefaultAgent()
if agent == nil {
@@ -790,7 +813,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
// Tools info
toolsList := agent.Tools.List()
- info["tools"] = map[string]interface{}{
+ info["tools"] = map[string]any{
"count": len(toolsList),
"names": toolsList,
}
@@ -799,7 +822,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
// Agents info
- info["agents"] = map[string]interface{}{
+ info["agents"] = map[string]any{
"count": len(al.registry.ListAgentIDs()),
"ids": al.registry.ListAgentIDs(),
}
@@ -851,7 +874,10 @@ func formatToolsForLog(tools []providers.ToolDefinition) string {
result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
result += fmt.Sprintf(" Description: %s\n", tool.Function.Description)
if len(tool.Function.Parameters) > 0 {
- result += fmt.Sprintf(" Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200))
+ result += fmt.Sprintf(
+ " Parameters: %s\n",
+ utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200),
+ )
}
}
result += "]"
@@ -904,11 +930,21 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
- mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
- resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{
- "max_tokens": 1024,
- "temperature": 0.3,
- })
+ mergePrompt := fmt.Sprintf(
+ "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
+ s1,
+ s2,
+ )
+ resp, err := agent.Provider.Chat(
+ ctx,
+ []providers.Message{{Role: "user", Content: mergePrompt}},
+ nil,
+ agent.Model,
+ map[string]any{
+ "max_tokens": 1024,
+ "temperature": 0.3,
+ },
+ )
if err == nil {
finalSummary = resp.Content
} else {
@@ -930,7 +966,12 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
}
// summarizeBatch summarizes a batch of messages.
-func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
+func (al *AgentLoop) summarizeBatch(
+ ctx context.Context,
+ agent *AgentInstance,
+ batch []providers.Message,
+ existingSummary string,
+) (string, error) {
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
if existingSummary != "" {
prompt += "Existing context: " + existingSummary + "\n"
@@ -940,10 +981,16 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, b
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
}
- response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{
- "max_tokens": 1024,
- "temperature": 0.3,
- })
+ response, err := agent.Provider.Chat(
+ ctx,
+ []providers.Message{{Role: "user", Content: prompt}},
+ nil,
+ agent.Model,
+ map[string]any{
+ "max_tokens": 1024,
+ "temperature": 0.3,
+ },
+ )
if err != nil {
return "", err
}
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 360685eca..4414398b1 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -171,7 +171,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
// Verify tool is registered by checking it doesn't panic on GetStartupInfo
// (actual tool retrieval is tested in tools package tests)
info := al.GetStartupInfo()
- toolsInfo := info["tools"].(map[string]interface{})
+ toolsInfo := info["tools"].(map[string]any)
toolsList := toolsInfo["names"].([]string)
// Check that our custom tool name is in the list
@@ -246,7 +246,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
al.RegisterTool(testTool)
info := al.GetStartupInfo()
- toolsInfo := info["tools"].(map[string]interface{})
+ toolsInfo := info["tools"].(map[string]any)
toolsList := toolsInfo["names"].([]string)
// Check that our custom tool name is in the list
@@ -293,7 +293,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
t.Fatal("Expected 'tools' key in startup info")
}
- toolsMap, ok := toolsInfo.(map[string]interface{})
+ toolsMap, ok := toolsInfo.(map[string]any)
if !ok {
t.Fatal("Expected 'tools' to be a map")
}
@@ -349,7 +349,13 @@ type simpleMockProvider struct {
response string
}
-func (m *simpleMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
+func (m *simpleMockProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ opts map[string]any,
+) (*providers.LLMResponse, error) {
return &providers.LLMResponse{
Content: m.response,
ToolCalls: []providers.ToolCall{},
@@ -371,14 +377,14 @@ func (m *mockCustomTool) Description() string {
return "Mock custom tool for testing"
}
-func (m *mockCustomTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (m *mockCustomTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{},
+ "properties": map[string]any{},
}
}
-func (m *mockCustomTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
+func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
return tools.SilentResult("Custom tool executed")
}
@@ -396,14 +402,14 @@ func (m *mockContextualTool) Description() string {
return "Mock contextual tool"
}
-func (m *mockContextualTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (m *mockContextualTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{},
+ "properties": map[string]any{},
}
}
-func (m *mockContextualTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
+func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
return tools.SilentResult("Contextual tool executed")
}
@@ -523,7 +529,13 @@ type failFirstMockProvider struct {
successResp string
}
-func (m *failFirstMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
+func (m *failFirstMockProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ opts map[string]any,
+) (*providers.LLMResponse, error) {
m.currentCall++
if m.currentCall <= m.failures {
return nil, m.failError
@@ -588,7 +600,13 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
// Call ProcessDirectWithChannel
// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
- response, err := al.ProcessDirectWithChannel(context.Background(), "Trigger message", sessionKey, "test", "test-chat")
+ response, err := al.ProcessDirectWithChannel(
+ context.Background(),
+ "Trigger message",
+ sessionKey,
+ "test",
+ "test-chat",
+ )
if err != nil {
t.Fatalf("Expected success after retry, got error: %v", err)
}
diff --git a/pkg/agent/mock_provider_test.go b/pkg/agent/mock_provider_test.go
index ccbecbafe..4962810dc 100644
--- a/pkg/agent/mock_provider_test.go
+++ b/pkg/agent/mock_provider_test.go
@@ -8,7 +8,13 @@ import (
type mockProvider struct{}
-func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
+func (m *mockProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ opts map[string]any,
+) (*providers.LLMResponse, error) {
return &providers.LLMResponse{
Content: "Mock response",
ToolCalls: []providers.ToolCall{},
diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go
index 9ddec662c..b26f2e684 100644
--- a/pkg/channels/discord.go
+++ b/pkg/channels/discord.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/bwmarrin/discordgo"
+
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -296,7 +297,7 @@ func (c *DiscordChannel) startTyping(chatID string) {
go func() {
if err := c.session.ChannelTyping(chatID); err != nil {
- logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err})
+ logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
}
ticker := time.NewTicker(8 * time.Second)
defer ticker.Stop()
@@ -311,7 +312,7 @@ func (c *DiscordChannel) startTyping(chatID string) {
return
case <-ticker.C:
if err := c.session.ChannelTyping(chatID); err != nil {
- logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err})
+ logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
}
}
}
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
index 53e82b44d..b221365e3 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot.go
@@ -87,14 +87,14 @@ type oneBotSender struct {
}
type oneBotAPIRequest struct {
- Action string `json:"action"`
- Params interface{} `json:"params"`
- Echo string `json:"echo,omitempty"`
+ Action string `json:"action"`
+ Params any `json:"params"`
+ Echo string `json:"echo,omitempty"`
}
type oneBotMessageSegment struct {
- Type string `json:"type"`
- Data map[string]interface{} `json:"data"`
+ Type string `json:"type"`
+ Data map[string]any `json:"data"`
}
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
@@ -117,13 +117,13 @@ func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
go func() {
- _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]interface{}{
+ _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{
"message_id": messageID,
"emoji_id": emojiID,
"set": set,
}, 5*time.Second)
if err != nil {
- logger.DebugCF("onebot", "Failed to set emoji like", map[string]interface{}{
+ logger.DebugCF("onebot", "Failed to set emoji like", map[string]any{
"message_id": messageID,
"error": err.Error(),
})
@@ -136,14 +136,14 @@ func (c *OneBotChannel) Start(ctx context.Context) error {
return fmt.Errorf("OneBot ws_url not configured")
}
- logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{
+ logger.InfoCF("onebot", "Starting OneBot channel", map[string]any{
"ws_url": c.config.WSUrl,
})
c.ctx, c.cancel = context.WithCancel(ctx)
if err := c.connect(); err != nil {
- logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{
+ logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]any{
"error": err.Error(),
})
} else {
@@ -208,7 +208,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) {
err := conn.WriteMessage(websocket.PingMessage, nil)
c.writeMu.Unlock()
if err != nil {
- logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]interface{}{
+ logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]any{
"error": err.Error(),
})
return
@@ -220,7 +220,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) {
func (c *OneBotChannel) fetchSelfID() {
resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second)
if err != nil {
- logger.WarnCF("onebot", "Failed to get_login_info", map[string]interface{}{
+ logger.WarnCF("onebot", "Failed to get_login_info", map[string]any{
"error": err.Error(),
})
return
@@ -250,7 +250,7 @@ func (c *OneBotChannel) fetchSelfID() {
}
if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 {
atomic.StoreInt64(&c.selfID, uid)
- logger.InfoCF("onebot", "Bot self ID retrieved", map[string]interface{}{
+ logger.InfoCF("onebot", "Bot self ID retrieved", map[string]any{
"self_id": uid,
"nickname": info.Nickname,
})
@@ -258,12 +258,12 @@ func (c *OneBotChannel) fetchSelfID() {
}
}
- logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]interface{}{
+ logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]any{
"response": string(resp),
})
}
-func (c *OneBotChannel) sendAPIRequest(action string, params interface{}, timeout time.Duration) (json.RawMessage, error) {
+func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.Duration) (json.RawMessage, error) {
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
@@ -332,7 +332,7 @@ func (c *OneBotChannel) reconnectLoop() {
if conn == nil {
logger.InfoC("onebot", "Attempting to reconnect...")
if err := c.connect(); err != nil {
- logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{
+ logger.ErrorCF("onebot", "Reconnect failed", map[string]any{
"error": err.Error(),
})
} else {
@@ -405,7 +405,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
c.writeMu.Unlock()
if err != nil {
- logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{
+ logger.ErrorCF("onebot", "Failed to send message", map[string]any{
"error": err.Error(),
})
return err
@@ -427,20 +427,20 @@ func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMes
if msgID, ok := lastMsgID.(string); ok && msgID != "" {
segments = append(segments, oneBotMessageSegment{
Type: "reply",
- Data: map[string]interface{}{"id": msgID},
+ Data: map[string]any{"id": msgID},
})
}
}
segments = append(segments, oneBotMessageSegment{
Type: "text",
- Data: map[string]interface{}{"text": content},
+ Data: map[string]any{"text": content},
})
return segments
}
-func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
+func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) {
chatID := msg.ChatID
segments := c.buildMessageSegments(chatID, msg.Content)
@@ -458,7 +458,7 @@ func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, inter
if err != nil {
return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID)
}
- return action, map[string]interface{}{idKey: id, "message": segments}, nil
+ return action, map[string]any{idKey: id, "message": segments}, nil
}
func (c *OneBotChannel) listen() {
@@ -478,7 +478,7 @@ func (c *OneBotChannel) listen() {
default:
_, message, err := conn.ReadMessage()
if err != nil {
- logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
+ logger.ErrorCF("onebot", "WebSocket read error", map[string]any{
"error": err.Error(),
})
c.mu.Lock()
@@ -494,14 +494,14 @@ func (c *OneBotChannel) listen() {
var raw oneBotRawEvent
if err := json.Unmarshal(message, &raw); err != nil {
- logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{
+ logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]any{
"error": err.Error(),
"payload": string(message),
})
continue
}
- logger.DebugCF("onebot", "WebSocket event", map[string]interface{}{
+ logger.DebugCF("onebot", "WebSocket event", map[string]any{
"length": len(message),
"post_type": raw.PostType,
"sub_type": raw.SubType,
@@ -518,7 +518,7 @@ func (c *OneBotChannel) listen() {
default:
}
} else {
- logger.DebugCF("onebot", "Received API response (no waiter)", map[string]interface{}{
+ logger.DebugCF("onebot", "Received API response (no waiter)", map[string]any{
"echo": raw.Echo,
"status": string(raw.Status),
})
@@ -527,7 +527,7 @@ func (c *OneBotChannel) listen() {
}
if isAPIResponse(raw.Status) {
- logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]interface{}{
+ logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]any{
"status": string(raw.Status),
})
continue
@@ -594,7 +594,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
return parseMessageResult{Text: s, IsBotMentioned: mentioned}
}
- var segments []map[string]interface{}
+ var segments []map[string]any
if err := json.Unmarshal(raw, &segments); err != nil {
return parseMessageResult{}
}
@@ -608,7 +608,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
for _, seg := range segments {
segType, _ := seg["type"].(string)
- data, _ := seg["data"].(map[string]interface{})
+ data, _ := seg["data"].(map[string]any)
switch segType {
case "text":
@@ -662,7 +662,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
result, err := c.transcriber.Transcribe(tctx, localPath)
tcancel()
if err != nil {
- logger.WarnCF("onebot", "Voice transcription failed", map[string]interface{}{
+ logger.WarnCF("onebot", "Voice transcription failed", map[string]any{
"error": err.Error(),
})
textParts = append(textParts, "[voice (transcription failed)]")
@@ -713,7 +713,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
case "message":
if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
if !c.IsAllowed(strconv.FormatInt(userID, 10)) {
- logger.DebugCF("onebot", "Message rejected by allowlist", map[string]interface{}{
+ logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{
"user_id": userID,
})
return
@@ -722,7 +722,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
c.handleMessage(raw)
case "message_sent":
- logger.DebugCF("onebot", "Bot sent message event", map[string]interface{}{
+ logger.DebugCF("onebot", "Bot sent message event", map[string]any{
"message_type": raw.MessageType,
"message_id": parseJSONString(raw.MessageID),
})
@@ -734,18 +734,18 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
c.handleNoticeEvent(raw)
case "request":
- logger.DebugCF("onebot", "Request event received", map[string]interface{}{
+ logger.DebugCF("onebot", "Request event received", map[string]any{
"sub_type": raw.SubType,
})
case "":
- logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{
+ logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]any{
"echo": raw.Echo,
"status": raw.Status,
})
default:
- logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
+ logger.DebugCF("onebot", "Unknown post_type", map[string]any{
"post_type": raw.PostType,
})
}
@@ -753,14 +753,14 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
if raw.MetaEventType == "lifecycle" {
- logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{"sub_type": raw.SubType})
+ logger.InfoCF("onebot", "Lifecycle event", map[string]any{"sub_type": raw.SubType})
} else if raw.MetaEventType != "heartbeat" {
logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil)
}
}
func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) {
- fields := map[string]interface{}{
+ fields := map[string]any{
"notice_type": raw.NoticeType,
"sub_type": raw.SubType,
"group_id": parseJSONString(raw.GroupID),
@@ -780,7 +780,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
// Parse fields from raw event
userID, err := parseJSONInt64(raw.UserID)
if err != nil {
- logger.WarnCF("onebot", "Failed to parse user_id", map[string]interface{}{
+ logger.WarnCF("onebot", "Failed to parse user_id", map[string]any{
"error": err.Error(),
"raw": string(raw.UserID),
})
@@ -817,7 +817,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
var sender oneBotSender
if len(raw.Sender) > 0 {
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
- logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{
+ logger.WarnCF("onebot", "Failed to parse sender", map[string]any{
"error": err.Error(),
"sender": string(raw.Sender),
})
@@ -829,7 +829,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
defer func() {
for _, f := range parsed.LocalFiles {
if err := os.Remove(f); err != nil {
- logger.DebugCF("onebot", "Failed to remove temp file", map[string]interface{}{
+ logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{
"path": f,
"error": err.Error(),
})
@@ -839,14 +839,14 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
}
if c.isDuplicate(messageID) {
- logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
+ logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{
"message_id": messageID,
})
return
}
if content == "" {
- logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
+ logger.DebugCF("onebot", "Received empty message, ignoring", map[string]any{
"message_id": messageID,
})
return
@@ -885,7 +885,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned)
if !triggered {
- logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
+ logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{
"sender": senderID,
"group": groupIDStr,
"is_mentioned": isBotMentioned,
@@ -896,7 +896,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
content = strippedContent
default:
- logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
+ logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]any{
"type": raw.MessageType,
"message_id": messageID,
"user_id": userID,
@@ -904,7 +904,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
return
}
- logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]interface{}{
+ logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]any{
"sender": senderID,
"chat_id": chatID,
"message_id": messageID,
@@ -957,7 +957,10 @@ func truncate(s string, n int) string {
return string(runes[:n]) + "..."
}
-func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) {
+func (c *OneBotChannel) checkGroupTrigger(
+ content string,
+ isBotMentioned bool,
+) (triggered bool, strippedContent string) {
if isBotMentioned {
return true, strings.TrimSpace(content)
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 3bdb6f030..220eae88a 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -23,7 +23,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
}
// Try []interface{} to handle mixed types
- var raw []interface{}
+ var raw []any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
@@ -139,16 +139,16 @@ type SessionConfig struct {
}
type AgentDefaults struct {
- Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
- RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
- Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
- Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
+ Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
+ RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
+ Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
+ Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
- ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
+ ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
- MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
- Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
- MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
+ MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
+ Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
+ MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
type ChannelsConfig struct {
@@ -165,87 +165,87 @@ type ChannelsConfig struct {
}
type WhatsAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
}
type TelegramConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
- Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
+ Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
}
type FeishuConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
- EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
+ AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
+ EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
}
type DiscordConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
}
type MaixCamConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
- Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
- Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
+ Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
+ Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
}
type QQConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
}
type DingTalkConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
- ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
+ ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
}
type SlackConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
- BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
- AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
+ BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
+ AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
}
type LINEConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
- ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
+ ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
}
type OneBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
- WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
- ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
+ WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
+ ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
}
type HeartbeatConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
}
type DevicesConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
}
@@ -266,11 +266,11 @@ type ProvidersConfig struct {
}
type ProviderConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
- APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
- AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
- ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
+ APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
+ APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
+ AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
}
type OpenAIProviderConfig struct {
@@ -284,19 +284,19 @@ type GatewayConfig struct {
}
type BraveConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
type DuckDuckGoConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
type PerplexityConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
@@ -482,11 +482,11 @@ func SaveConfig(path string, cfg *Config) error {
}
dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0755); err != nil {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
- return os.WriteFile(path, data, 0600)
+ return os.WriteFile(path, data, 0o600)
}
func (c *Config) WorkspacePath() string {
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 9cfec44fe..a09825c1c 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -79,7 +79,8 @@ func (p *Provider) Chat(
if maxTokens, ok := asInt(options["max_tokens"]); ok {
lowerModel := strings.ToLower(model)
- if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") {
+ if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") ||
+ strings.Contains(lowerModel, "gpt-5") {
requestBody["max_completion_tokens"] = maxTokens
} else {
requestBody["max_tokens"] = maxTokens
diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go
index f960a7fda..59bfdffae 100644
--- a/pkg/tools/subagent_tool_test.go
+++ b/pkg/tools/subagent_tool_test.go
@@ -11,10 +11,16 @@ import (
// MockLLMProvider is a test implementation of LLMProvider
type MockLLMProvider struct {
- lastOptions map[string]interface{}
+ lastOptions map[string]any
}
-func (m *MockLLMProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
+func (m *MockLLMProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ options map[string]any,
+) (*providers.LLMResponse, error) {
m.lastOptions = options
// Find the last user message to generate a response
for i := len(messages) - 1; i >= 0; i-- {
@@ -47,7 +53,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
tool.SetContext("cli", "direct")
ctx := context.Background()
- args := map[string]interface{}{"task": "Do something"}
+ args := map[string]any{"task": "Do something"}
result := tool.Execute(ctx, args)
if result == nil || result.IsError {
@@ -108,13 +114,13 @@ func TestSubagentTool_Parameters(t *testing.T) {
}
// Check properties
- props, ok := params["properties"].(map[string]interface{})
+ props, ok := params["properties"].(map[string]any)
if !ok {
t.Fatal("Properties should be a map")
}
// Verify task parameter
- task, ok := props["task"].(map[string]interface{})
+ task, ok := props["task"].(map[string]any)
if !ok {
t.Fatal("Task parameter should exist")
}
@@ -123,7 +129,7 @@ func TestSubagentTool_Parameters(t *testing.T) {
}
// Verify label parameter
- label, ok := props["label"].(map[string]interface{})
+ label, ok := props["label"].(map[string]any)
if !ok {
t.Fatal("Label parameter should exist")
}
@@ -163,7 +169,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) {
tool.SetContext("telegram", "chat-123")
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "Write a haiku about coding",
"label": "haiku-task",
}
@@ -218,7 +224,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
tool := NewSubagentTool(manager)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "Test task without label",
}
@@ -241,7 +247,7 @@ func TestSubagentTool_Execute_MissingTask(t *testing.T) {
tool := NewSubagentTool(manager)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"label": "test",
}
@@ -268,7 +274,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
tool := NewSubagentTool(nil)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "test task",
}
@@ -297,7 +303,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
tool.SetContext(channel, chatID)
ctx := context.Background()
- args := map[string]interface{}{
+ args := map[string]any{
"task": "Test context passing",
}
@@ -324,7 +330,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) {
// Create a task that will generate long response
longTask := strings.Repeat("This is a very long task description. ", 100)
- args := map[string]interface{}{
+ args := map[string]any{
"task": longTask,
"label": "long-test",
}
diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go
index 222a38972..d999d8958 100644
--- a/pkg/tools/web_test.go
+++ b/pkg/tools/web_test.go
@@ -255,7 +255,8 @@ func TestWebFetchTool_extractText(t *testing.T) {
if len(lines) < 2 {
t.Errorf("Expected multiple lines, got %d: %q", len(lines), got)
}
- if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || !strings.Contains(got, "Paragraph 2") {
+ if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") ||
+ !strings.Contains(got, "Paragraph 2") {
t.Errorf("Missing expected text: %q", got)
}
},
From 59772cdbf25904a40ae24dddf55a7767e3f4be4d Mon Sep 17 00:00:00 2001
From: swordkee
Date: Fri, 20 Feb 2026 15:33:24 +0800
Subject: [PATCH 06/88] feat: add wecom and wecomApp channel support
---
config/config.example.json | 24 +
pkg/channels/manager.go | 26 +
pkg/channels/wecom.go | 529 ++++++++++++++++
pkg/channels/wecom_app.go | 707 +++++++++++++++++++++
pkg/channels/wecom_app_test.go | 1089 ++++++++++++++++++++++++++++++++
pkg/channels/wecom_test.go | 689 ++++++++++++++++++++
pkg/config/config.go | 28 +
pkg/config/defaults.go | 24 +
8 files changed, 3116 insertions(+)
create mode 100644 pkg/channels/wecom.go
create mode 100644 pkg/channels/wecom_app.go
create mode 100644 pkg/channels/wecom_app_test.go
create mode 100644 pkg/channels/wecom_test.go
diff --git a/config/config.example.json b/config/config.example.json
index e14d4fa63..f0c82c2bc 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -106,6 +106,30 @@
"reconnect_interval": 5,
"group_trigger_prefix": [],
"allow_from": []
+ },
+ "wecom": {
+ "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_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": [],
+ "reply_timeout": 5
+ },
+ "wecom_app": {
+ "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_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
}
},
"providers": {
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 7f6abc4cb..b80d1c8fb 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -176,6 +176,32 @@ func (m *Manager) initChannels() error {
}
}
+ if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
+ logger.DebugC("channels", "Attempting to initialize WeCom channel")
+ wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
+ if err != nil {
+ logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ m.channels["wecom"] = wecom
+ logger.InfoC("channels", "WeCom channel enabled successfully")
+ }
+ }
+
+ if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
+ logger.DebugC("channels", "Attempting to initialize WeCom App channel")
+ wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
+ if err != nil {
+ logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ m.channels["wecom_app"] = wecomApp
+ logger.InfoC("channels", "WeCom App channel enabled successfully")
+ }
+ }
+
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
"enabled_channels": len(m.channels),
})
diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go
new file mode 100644
index 000000000..5d4e14697
--- /dev/null
+++ b/pkg/channels/wecom.go
@@ -0,0 +1,529 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// WeCom Bot (ä¼äøå¾®äæ”ęŗč½ęŗåØäŗŗ) channel implementation
+// Uses webhook callback mode for receiving messages and webhook API for sending replies
+
+package channels
+
+import (
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/sha1"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "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 {
+ *BaseChannel
+ config config.WeComConfig
+ server *http.Server
+ ctx context.Context
+ cancel context.CancelFunc
+ processedMsgs map[string]bool // Message deduplication: msg_id -> processed
+ msgMu sync.RWMutex
+}
+
+// WeComBotXMLMessage represents the XML message structure from WeCom Bot
+type WeComBotXMLMessage 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"`
+ PicUrl string `xml:"PicUrl"`
+ MediaId string `xml:"MediaId"`
+ Format string `xml:"Format"`
+ Recognition string `xml:"Recognition"` // Voice recognition result
+}
+
+// WeComBotReplyMessage represents the reply message structure
+type WeComBotReplyMessage 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"`
+}
+
+// WeComBotWebhookReply represents the webhook API reply
+type WeComBotWebhookReply struct {
+ MsgType string `json:"msgtype"`
+ Text struct {
+ Content string `json:"content"`
+ } `json:"text,omitempty"`
+ Markdown struct {
+ Content string `json:"content"`
+ } `json:"markdown,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 := NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom)
+
+ return &WeComBotChannel{
+ BaseChannel: base,
+ config: cfg,
+ processedMsgs: make(map[string]bool),
+ }, nil
+}
+
+// Name returns the channel name
+func (c *WeComBotChannel) Name() string {
+ return "wecom"
+}
+
+// Start initializes the WeCom Bot channel with HTTP webhook server
+func (c *WeComBotChannel) Start(ctx context.Context) error {
+ logger.InfoC("wecom", "Starting WeCom Bot channel...")
+
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ // Setup HTTP server for webhook
+ mux := http.NewServeMux()
+ webhookPath := c.config.WebhookPath
+ if webhookPath == "" {
+ webhookPath = "/webhook/wecom"
+ }
+ mux.HandleFunc(webhookPath, c.handleWebhook)
+
+ // Health check endpoint
+ mux.HandleFunc("/health/wecom", c.handleHealth)
+
+ addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
+ c.server = &http.Server{
+ Addr: addr,
+ Handler: mux,
+ }
+
+ c.setRunning(true)
+ logger.InfoCF("wecom", "WeCom Bot channel started", map[string]interface{}{
+ "address": addr,
+ "path": webhookPath,
+ })
+
+ // Start server in goroutine
+ go func() {
+ if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ logger.ErrorCF("wecom", "HTTP server error", map[string]interface{}{
+ "error": err.Error(),
+ })
+ }
+ }()
+
+ 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()
+ }
+
+ if c.server != nil {
+ shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ c.server.Shutdown(shutdownCtx)
+ }
+
+ 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 fmt.Errorf("wecom channel not running")
+ }
+
+ logger.DebugCF("wecom", "Sending message via webhook", map[string]interface{}{
+ "chat_id": msg.ChatID,
+ "preview": utils.Truncate(msg.Content, 100),
+ })
+
+ return c.sendWebhookReply(ctx, msg.ChatID, msg.Content)
+}
+
+// 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 !c.verifySignature(msgSignature, timestamp, nonce, echostr) {
+ logger.WarnC("wecom", "Signature verification failed")
+ http.Error(w, "Invalid signature", http.StatusForbidden)
+ return
+ }
+
+ // Decrypt echostr
+ decryptedEchoStr, err := c.decryptMessage(echostr)
+ if err != nil {
+ logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{
+ "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]interface{}{
+ "error": err.Error(),
+ })
+ http.Error(w, "Invalid XML", http.StatusBadRequest)
+ return
+ }
+
+ // Verify signature
+ if !c.verifySignature(msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
+ logger.WarnC("wecom", "Message signature verification failed")
+ http.Error(w, "Invalid signature", http.StatusForbidden)
+ return
+ }
+
+ // Decrypt message
+ decryptedMsg, err := c.decryptMessage(encryptedMsg.Encrypt)
+ if err != nil {
+ logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{
+ "error": err.Error(),
+ })
+ http.Error(w, "Decryption failed", http.StatusInternalServerError)
+ return
+ }
+
+ // Parse decrypted XML message
+ var msg WeComBotXMLMessage
+ if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
+ logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]interface{}{
+ "error": err.Error(),
+ })
+ http.Error(w, "Invalid message format", http.StatusBadRequest)
+ return
+ }
+
+ // Process the message asynchronously with context
+ go c.processMessage(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 WeComBotXMLMessage) {
+ // Skip non-text messages for now (can be extended)
+ if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" {
+ logger.DebugCF("wecom", "Skipping non-supported message type", map[string]interface{}{
+ "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)
+ c.msgMu.Lock()
+ if c.processedMsgs[msgID] {
+ c.msgMu.Unlock()
+ logger.DebugCF("wecom", "Skipping duplicate message", map[string]interface{}{
+ "msg_id": msgID,
+ })
+ return
+ }
+ c.processedMsgs[msgID] = true
+ c.msgMu.Unlock()
+
+ // Clean up old messages periodically (keep last 1000)
+ if len(c.processedMsgs) > 1000 {
+ c.msgMu.Lock()
+ c.processedMsgs = make(map[string]bool)
+ c.msgMu.Unlock()
+ }
+
+ senderID := msg.FromUserName
+ chatID := senderID // WeCom Bot uses user ID as chat ID
+
+ // Use voice recognition result if available
+ content := msg.Content
+ if msg.MsgType == "voice" && msg.Recognition != "" {
+ content = msg.Recognition
+ }
+
+ // Build metadata
+ // WeCom Bot only supports direct messages (private chat)
+ metadata := map[string]string{
+ "msg_type": msg.MsgType,
+ "msg_id": fmt.Sprintf("%d", msg.MsgId),
+ "platform": "wecom",
+ "media_id": msg.MediaId,
+ "create_time": fmt.Sprintf("%d", msg.CreateTime),
+ "peer_kind": "direct",
+ "peer_id": senderID,
+ }
+
+ logger.DebugCF("wecom", "Received message", map[string]interface{}{
+ "sender_id": senderID,
+ "msg_type": msg.MsgType,
+ "preview": utils.Truncate(content, 50),
+ })
+
+ // Handle the message through the base channel
+ c.HandleMessage(senderID, chatID, content, nil, metadata)
+}
+
+// verifySignature verifies the message signature
+func (c *WeComBotChannel) verifySignature(msgSignature, timestamp, nonce, msgEncrypt string) bool {
+ if c.config.Token == "" {
+ return true // Skip verification if token is not set
+ }
+
+ // Sort parameters
+ params := []string{c.config.Token, timestamp, nonce, msgEncrypt}
+ sort.Strings(params)
+
+ // Concatenate
+ str := strings.Join(params, "")
+
+ // SHA1 hash
+ hash := sha1.Sum([]byte(str))
+ expectedSignature := fmt.Sprintf("%x", hash)
+
+ return expectedSignature == msgSignature
+}
+
+// decryptMessage decrypts the encrypted message using AES
+func (c *WeComBotChannel) decryptMessage(encryptedMsg string) (string, error) {
+ if c.config.EncodingAESKey == "" {
+ // No encryption, return as is (base64 decode)
+ decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
+ if err != nil {
+ return "", err
+ }
+ return string(decoded), nil
+ }
+
+ // Decode AES key (base64)
+ aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=")
+ if err != nil {
+ return "", fmt.Errorf("failed to decode AES key: %w", err)
+ }
+
+ // Decode encrypted message
+ cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
+ if err != nil {
+ return "", fmt.Errorf("failed to decode message: %w", err)
+ }
+
+ // AES decrypt
+ block, err := aes.NewCipher(aesKey)
+ if err != nil {
+ return "", fmt.Errorf("failed to create cipher: %w", err)
+ }
+
+ if len(cipherText) < aes.BlockSize {
+ return "", fmt.Errorf("ciphertext too short")
+ }
+
+ mode := cipher.NewCBCDecrypter(block, aesKey[:aes.BlockSize])
+ plainText := make([]byte, len(cipherText))
+ mode.CryptBlocks(plainText, cipherText)
+
+ // Remove PKCS7 padding
+ plainText, err = pkcs7UnpadWeCom(plainText)
+ if err != nil {
+ return "", fmt.Errorf("failed to unpad: %w", err)
+ }
+
+ // Parse message structure
+ // Format: random(16) + msg_len(4) + msg + corp_id
+ if len(plainText) < 20 {
+ return "", fmt.Errorf("decrypted message too short")
+ }
+
+ msgLen := binary.BigEndian.Uint32(plainText[16:20])
+ if int(msgLen) > len(plainText)-20 {
+ return "", fmt.Errorf("invalid message length")
+ }
+
+ msg := plainText[20 : 20+msgLen]
+ // corpID := plainText[20+msgLen:] // Could be used for verification
+
+ return string(msg), nil
+}
+
+// pkcs7UnpadWeCom removes PKCS7 padding with validation
+func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
+ if len(data) == 0 {
+ return data, nil
+ }
+ padding := int(data[len(data)-1])
+ if padding == 0 || padding > aes.BlockSize {
+ return nil, fmt.Errorf("invalid padding size: %d", padding)
+ }
+ if padding > len(data) {
+ return nil, fmt.Errorf("padding size larger than data")
+ }
+ // Verify all padding bytes
+ for i := 0; i < padding; i++ {
+ if data[len(data)-1-i] != byte(padding) {
+ return nil, fmt.Errorf("invalid padding byte at position %d", i)
+ }
+ }
+ return data[:len(data)-padding], nil
+}
+
+// sendWebhookReply sends a reply using the webhook URL
+func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error {
+ reply := WeComBotWebhookReply{
+ 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")
+
+ client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to send webhook reply: %w", err)
+ }
+ defer resp.Body.Close()
+
+ 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]interface{}{
+ "status": "ok",
+ "running": c.IsRunning(),
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(status)
+}
diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go
new file mode 100644
index 000000000..c1d0ebaad
--- /dev/null
+++ b/pkg/channels/wecom_app.go
@@ -0,0 +1,707 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// WeCom App (ä¼äøå¾®äæ”čŖå»ŗåŗēØ) channel implementation
+// Supports receiving messages via webhook callback and sending messages proactively
+
+package channels
+
+import (
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/sha1"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "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 {
+ *BaseChannel
+ config config.WeComAppConfig
+ server *http.Server
+ accessToken string
+ tokenExpiry time.Time
+ tokenMu sync.RWMutex
+ ctx context.Context
+ cancel context.CancelFunc
+ processedMsgs map[string]bool // Message deduplication: msg_id -> processed
+ msgMu sync.RWMutex
+}
+
+// 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 := NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom)
+
+ return &WeComAppChannel{
+ BaseChannel: base,
+ config: cfg,
+ processedMsgs: make(map[string]bool),
+ }, nil
+}
+
+// Name returns the channel name
+func (c *WeComAppChannel) Name() string {
+ return "wecom_app"
+}
+
+// Start initializes the WeCom App channel with HTTP webhook server
+func (c *WeComAppChannel) Start(ctx context.Context) error {
+ logger.InfoC("wecom_app", "Starting WeCom App channel...")
+
+ 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]interface{}{
+ "error": err.Error(),
+ })
+ }
+
+ // Start token refresh goroutine
+ go c.tokenRefreshLoop()
+
+ // Setup HTTP server for webhook
+ mux := http.NewServeMux()
+ webhookPath := c.config.WebhookPath
+ if webhookPath == "" {
+ webhookPath = "/webhook/wecom-app"
+ }
+ mux.HandleFunc(webhookPath, c.handleWebhook)
+
+ // Health check endpoint
+ mux.HandleFunc("/health/wecom-app", c.handleHealth)
+
+ addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
+ c.server = &http.Server{
+ Addr: addr,
+ Handler: mux,
+ }
+
+ c.setRunning(true)
+ logger.InfoCF("wecom_app", "WeCom App channel started", map[string]interface{}{
+ "address": addr,
+ "path": webhookPath,
+ })
+
+ // Start server in goroutine
+ go func() {
+ if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ logger.ErrorCF("wecom_app", "HTTP server error", map[string]interface{}{
+ "error": err.Error(),
+ })
+ }
+ }()
+
+ 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()
+ }
+
+ if c.server != nil {
+ shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ c.server.Shutdown(shutdownCtx)
+ }
+
+ 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 fmt.Errorf("wecom_app channel not running")
+ }
+
+ accessToken := c.getAccessToken()
+ if accessToken == "" {
+ return fmt.Errorf("no valid access token available")
+ }
+
+ logger.DebugCF("wecom_app", "Sending message", map[string]interface{}{
+ "chat_id": msg.ChatID,
+ "preview": utils.Truncate(msg.Content, 100),
+ })
+
+ return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)
+}
+
+// handleWebhook handles incoming webhook requests from WeCom
+func (c *WeComAppChannel) 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 *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")
+
+ if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" {
+ http.Error(w, "Missing parameters", http.StatusBadRequest)
+ return
+ }
+
+ // Verify signature
+ if !c.verifySignature(msgSignature, timestamp, nonce, echostr) {
+ logger.WarnC("wecom_app", "Signature verification failed")
+ http.Error(w, "Invalid signature", http.StatusForbidden)
+ return
+ }
+
+ // Decrypt echostr
+ decryptedEchoStr, err := c.decryptMessage(echostr)
+ if err != nil {
+ logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{
+ "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 *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]interface{}{
+ "error": err.Error(),
+ })
+ http.Error(w, "Invalid XML", http.StatusBadRequest)
+ return
+ }
+
+ // Verify signature
+ if !c.verifySignature(msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
+ logger.WarnC("wecom_app", "Message signature verification failed")
+ http.Error(w, "Invalid signature", http.StatusForbidden)
+ return
+ }
+
+ // Decrypt message
+ decryptedMsg, err := c.decryptMessage(encryptedMsg.Encrypt)
+ if err != nil {
+ logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{
+ "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]interface{}{
+ "error": err.Error(),
+ })
+ http.Error(w, "Invalid message format", http.StatusBadRequest)
+ return
+ }
+
+ // Process the message with context
+ go c.processMessage(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]interface{}{
+ "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)
+ c.msgMu.Lock()
+ if c.processedMsgs[msgID] {
+ c.msgMu.Unlock()
+ logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]interface{}{
+ "msg_id": msgID,
+ })
+ return
+ }
+ c.processedMsgs[msgID] = true
+ c.msgMu.Unlock()
+
+ // Clean up old messages periodically (keep last 1000)
+ if len(c.processedMsgs) > 1000 {
+ c.msgMu.Lock()
+ c.processedMsgs = make(map[string]bool)
+ c.msgMu.Unlock()
+ }
+
+ 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)
+ 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),
+ "peer_kind": "direct",
+ "peer_id": senderID,
+ }
+
+ content := msg.Content
+
+ logger.DebugCF("wecom_app", "Received message", map[string]interface{}{
+ "sender_id": senderID,
+ "msg_type": msg.MsgType,
+ "preview": utils.Truncate(content, 50),
+ })
+
+ // Handle the message through the base channel
+ c.HandleMessage(senderID, chatID, content, nil, metadata)
+}
+
+// verifySignature verifies the message signature
+func (c *WeComAppChannel) verifySignature(msgSignature, timestamp, nonce, msgEncrypt string) bool {
+ if c.config.Token == "" {
+ return true // Skip verification if token is not set
+ }
+
+ // Sort parameters
+ params := []string{c.config.Token, timestamp, nonce, msgEncrypt}
+ sort.Strings(params)
+
+ // Concatenate
+ str := strings.Join(params, "")
+
+ // SHA1 hash
+ hash := sha1.Sum([]byte(str))
+ expectedSignature := fmt.Sprintf("%x", hash)
+
+ return expectedSignature == msgSignature
+}
+
+// decryptMessage decrypts the encrypted message using AES
+func (c *WeComAppChannel) decryptMessage(encryptedMsg string) (string, error) {
+ if c.config.EncodingAESKey == "" {
+ // No encryption, return as is (base64 decode)
+ decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
+ if err != nil {
+ return "", err
+ }
+ return string(decoded), nil
+ }
+
+ // Decode AES key (base64)
+ aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=")
+ if err != nil {
+ return "", fmt.Errorf("failed to decode AES key: %w", err)
+ }
+
+ // Decode encrypted message
+ cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
+ if err != nil {
+ return "", fmt.Errorf("failed to decode message: %w", err)
+ }
+
+ // AES decrypt
+ block, err := aes.NewCipher(aesKey)
+ if err != nil {
+ return "", fmt.Errorf("failed to create cipher: %w", err)
+ }
+
+ if len(cipherText) < aes.BlockSize {
+ return "", fmt.Errorf("ciphertext too short")
+ }
+
+ mode := cipher.NewCBCDecrypter(block, aesKey[:aes.BlockSize])
+ plainText := make([]byte, len(cipherText))
+ mode.CryptBlocks(plainText, cipherText)
+
+ // Remove PKCS7 padding
+ plainText, err = pkcs7Unpad(plainText)
+ if err != nil {
+ return "", fmt.Errorf("failed to unpad: %w", err)
+ }
+
+ // Parse message structure
+ // Format: random(16) + msg_len(4) + msg + corp_id
+ if len(plainText) < 20 {
+ return "", fmt.Errorf("decrypted message too short")
+ }
+
+ msgLen := binary.BigEndian.Uint32(plainText[16:20])
+ if int(msgLen) > len(plainText)-20 {
+ return "", fmt.Errorf("invalid message length")
+ }
+
+ msg := plainText[20 : 20+msgLen]
+ // corpID := plainText[20+msgLen:] // Can be used for verification
+
+ return string(msg), nil
+}
+
+// pkcs7Unpad removes PKCS7 padding with validation
+func pkcs7Unpad(data []byte) ([]byte, error) {
+ if len(data) == 0 {
+ return data, nil
+ }
+ padding := int(data[len(data)-1])
+ if padding == 0 || padding > aes.BlockSize {
+ return nil, fmt.Errorf("invalid padding size: %d", padding)
+ }
+ if padding > len(data) {
+ return nil, fmt.Errorf("padding size larger than data")
+ }
+ // Verify all padding bytes
+ for i := 0; i < padding; i++ {
+ if data[len(data)-1-i] != byte(padding) {
+ return nil, fmt.Errorf("invalid padding byte at position %d", i)
+ }
+ }
+ return data[:len(data)-padding], nil
+}
+
+// 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]interface{}{
+ "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 {
+ apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
+
+ msg := WeComTextMessage{
+ ToUser: userID,
+ MsgType: "text",
+ AgentID: c.config.AgentID,
+ }
+ msg.Text.Content = content
+
+ jsonData, err := json.Marshal(msg)
+ if err != nil {
+ return fmt.Errorf("failed to marshal message: %w", err)
+ }
+
+ // Use configurable timeout (default 5 seconds)
+ timeout := c.config.ReplyTimeout
+ if timeout <= 0 {
+ timeout = 5
+ }
+
+ reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to send message: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read response: %w", err)
+ }
+
+ var sendResp WeComSendMessageResponse
+ if err := json.Unmarshal(body, &sendResp); err != nil {
+ return fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ if sendResp.ErrCode != 0 {
+ return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
+ }
+
+ return nil
+}
+
+// sendMarkdownMessage sends a markdown message to a user
+func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, userID, content string) error {
+ apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
+
+ msg := WeComMarkdownMessage{
+ ToUser: userID,
+ MsgType: "markdown",
+ AgentID: c.config.AgentID,
+ }
+ msg.Markdown.Content = content
+
+ jsonData, err := json.Marshal(msg)
+ if err != nil {
+ return fmt.Errorf("failed to marshal message: %w", err)
+ }
+
+ // Use configurable timeout (default 5 seconds)
+ timeout := c.config.ReplyTimeout
+ if timeout <= 0 {
+ timeout = 5
+ }
+
+ reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to send message: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read response: %w", err)
+ }
+
+ var sendResp WeComSendMessageResponse
+ if err := json.Unmarshal(body, &sendResp); err != nil {
+ return fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ if sendResp.ErrCode != 0 {
+ return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
+ }
+
+ return nil
+}
+
+// handleHealth handles health check requests
+func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
+ status := map[string]interface{}{
+ "status": "ok",
+ "running": c.IsRunning(),
+ "has_token": c.getAccessToken() != "",
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(status)
+}
diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom_app_test.go
new file mode 100644
index 000000000..4283c07e6
--- /dev/null
+++ b/pkg/channels/wecom_app_test.go
@@ -0,0 +1,1089 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// WeCom App (ä¼äøå¾®äæ”čŖå»ŗåŗēØ) channel tests
+
+package channels
+
+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"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// generateTestAESKeyApp generates a valid test AES key for WeCom App
+func generateTestAESKeyApp() 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 + 1)
+ }
+ // Return base64 encoded key without padding
+ return base64.StdEncoding.EncodeToString(key)[:43]
+}
+
+// encryptTestMessageApp encrypts a message for testing WeCom App
+func encryptTestMessageApp(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 + corp_id
+ random := make([]byte, 0, 16)
+ for i := 0; i < 16; i++ {
+ random = append(random, byte(i+1))
+ }
+
+ msgBytes := []byte(message)
+ corpID := []byte("test_corp_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, corpID...)
+
+ // 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
+}
+
+// generateSignatureApp generates a signature for testing WeCom App
+func generateSignatureApp(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 TestNewWeComAppChannel(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+
+ t.Run("missing corp_id", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ }
+ _, err := NewWeComAppChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing corp_id, got nil")
+ }
+ })
+
+ t.Run("missing corp_secret", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "",
+ AgentID: 1000002,
+ }
+ _, err := NewWeComAppChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing corp_secret, got nil")
+ }
+ })
+
+ t.Run("missing agent_id", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 0,
+ }
+ _, err := NewWeComAppChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing agent_id, got nil")
+ }
+ })
+
+ t.Run("valid config", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ AllowFrom: []string{"user1", "user2"},
+ }
+ ch, err := NewWeComAppChannel(cfg, msgBus)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ch.Name() != "wecom_app" {
+ t.Errorf("Name() = %q, want %q", ch.Name(), "wecom_app")
+ }
+ if ch.IsRunning() {
+ t.Error("new channel should not be running")
+ }
+ })
+}
+
+func TestWeComAppChannelIsAllowed(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+
+ t.Run("empty allowlist allows all", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ AllowFrom: []string{},
+ }
+ ch, _ := NewWeComAppChannel(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.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ AllowFrom: []string{"allowed_user"},
+ }
+ ch, _ := NewWeComAppChannel(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 TestWeComAppVerifySignature(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ Token: "test_token",
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ t.Run("valid signature", func(t *testing.T) {
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ msgEncrypt := "test_message"
+ expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt)
+
+ if !ch.verifySignature(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 ch.verifySignature("invalid_sig", timestamp, nonce, msgEncrypt) {
+ t.Error("invalid signature should fail verification")
+ }
+ })
+
+ t.Run("empty token skips verification", func(t *testing.T) {
+ cfgEmpty := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ Token: "",
+ }
+ chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
+
+ if !chEmpty.verifySignature("any_sig", "any_ts", "any_nonce", "any_msg") {
+ t.Error("empty token should skip verification and return true")
+ }
+ })
+}
+
+func TestWeComAppDecryptMessage(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+
+ t.Run("decrypt without AES key", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ EncodingAESKey: "",
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ // Without AES key, message should be base64 decoded only
+ plainText := "hello world"
+ encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
+
+ result, err := ch.decryptMessage(encoded)
+ 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 := generateTestAESKeyApp()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ EncodingAESKey: aesKey,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ originalMsg := "Hello"
+ encrypted, err := encryptTestMessageApp(originalMsg, aesKey)
+ if err != nil {
+ t.Fatalf("failed to encrypt test message: %v", err)
+ }
+
+ result, err := ch.decryptMessage(encrypted)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result != originalMsg {
+ t.Errorf("decryptMessage() = %q, want %q", result, originalMsg)
+ }
+ })
+
+ t.Run("invalid base64", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ EncodingAESKey: "",
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ _, err := ch.decryptMessage("invalid_base64!!!")
+ if err == nil {
+ t.Error("expected error for invalid base64, got nil")
+ }
+ })
+
+ t.Run("invalid AES key", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ EncodingAESKey: "invalid_key",
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ _, err := ch.decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")))
+ if err == nil {
+ t.Error("expected error for invalid AES key, got nil")
+ }
+ })
+
+ t.Run("ciphertext too short", func(t *testing.T) {
+ aesKey := generateTestAESKeyApp()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ EncodingAESKey: aesKey,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ // Encrypt a very short message that results in ciphertext less than block size
+ shortData := make([]byte, 8)
+ _, err := ch.decryptMessage(base64.StdEncoding.EncodeToString(shortData))
+ if err == nil {
+ t.Error("expected error for short ciphertext, got nil")
+ }
+ })
+}
+
+func TestWeComAppPKCS7Unpad(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 TestWeComAppHandleVerification(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ aesKey := generateTestAESKeyApp()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ Token: "test_token",
+ EncodingAESKey: aesKey,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ t.Run("valid verification request", func(t *testing.T) {
+ echostr := "test_echostr_123"
+ encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey)
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr)
+
+ req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ w := httptest.NewRecorder()
+
+ ch.handleVerification(context.Background(), w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
+ }
+ if w.Body.String() != echostr {
+ t.Errorf("response body = %q, want %q", w.Body.String(), echostr)
+ }
+ })
+
+ t.Run("missing parameters", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=sig×tamp=ts", nil)
+ w := httptest.NewRecorder()
+
+ ch.handleVerification(context.Background(), w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
+ }
+ })
+
+ t.Run("invalid signature", func(t *testing.T) {
+ echostr := "test_echostr"
+ encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey)
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+
+ req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ w := httptest.NewRecorder()
+
+ ch.handleVerification(context.Background(), w, req)
+
+ if w.Code != http.StatusForbidden {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
+ }
+ })
+}
+
+func TestWeComAppHandleMessageCallback(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ aesKey := generateTestAESKeyApp()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ Token: "test_token",
+ EncodingAESKey: aesKey,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ t.Run("valid message callback", func(t *testing.T) {
+ // Create XML message
+ xmlMsg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "text",
+ Content: "Hello World",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+ xmlData, _ := xml.Marshal(xmlMsg)
+
+ // Encrypt message
+ encrypted, _ := encryptTestMessageApp(string(xmlData), aesKey)
+
+ // Create encrypted XML wrapper
+ encryptedWrapper := struct {
+ XMLName xml.Name `xml:"xml"`
+ Encrypt string `xml:"Encrypt"`
+ }{
+ Encrypt: encrypted,
+ }
+ wrapperData, _ := xml.Marshal(encryptedWrapper)
+
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ signature := generateSignatureApp("test_token", timestamp, nonce, encrypted)
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ w := httptest.NewRecorder()
+
+ ch.handleMessageCallback(context.Background(), w, req)
+
+ 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-app?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 := generateSignatureApp("test_token", timestamp, nonce, "")
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"))
+ w := httptest.NewRecorder()
+
+ ch.handleMessageCallback(context.Background(), w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
+ }
+ })
+
+ t.Run("invalid signature", func(t *testing.T) {
+ encryptedWrapper := struct {
+ XMLName xml.Name `xml:"xml"`
+ Encrypt string `xml:"Encrypt"`
+ }{
+ Encrypt: "encrypted_data",
+ }
+ wrapperData, _ := xml.Marshal(encryptedWrapper)
+
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ w := httptest.NewRecorder()
+
+ ch.handleMessageCallback(context.Background(), w, req)
+
+ if w.Code != http.StatusForbidden {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
+ }
+ })
+}
+
+func TestWeComAppProcessMessage(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ t.Run("process text message", func(t *testing.T) {
+ msg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "text",
+ Content: "Hello World",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+
+ t.Run("process image message", func(t *testing.T) {
+ msg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "image",
+ PicUrl: "https://example.com/image.jpg",
+ MediaId: "media_123",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+
+ t.Run("process voice message", func(t *testing.T) {
+ msg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "voice",
+ MediaId: "media_123",
+ Format: "amr",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+
+ t.Run("skip unsupported message type", func(t *testing.T) {
+ msg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "video",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+
+ t.Run("process event message", func(t *testing.T) {
+ msg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "event",
+ Event: "subscribe",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+}
+
+func TestWeComAppHandleWebhook(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ Token: "test_token",
+ }
+ ch, _ := NewWeComAppChannel(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 := generateSignatureApp("test_token", timestamp, nonce, encoded)
+
+ req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil)
+ w := httptest.NewRecorder()
+
+ ch.handleWebhook(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
+ }
+ })
+
+ t.Run("POST request calls message callback", func(t *testing.T) {
+ encryptedWrapper := struct {
+ XMLName xml.Name `xml:"xml"`
+ Encrypt string `xml:"Encrypt"`
+ }{
+ Encrypt: base64.StdEncoding.EncodeToString([]byte("test")),
+ }
+ wrapperData, _ := xml.Marshal(encryptedWrapper)
+
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ w := httptest.NewRecorder()
+
+ ch.handleWebhook(w, req)
+
+ // Should not be method not allowed
+ if w.Code == http.StatusMethodNotAllowed {
+ t.Error("POST request should not return Method Not Allowed")
+ }
+ })
+
+ t.Run("unsupported method", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPut, "/webhook/wecom-app", 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 TestWeComAppHandleHealth(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ req := httptest.NewRequest(http.MethodGet, "/health/wecom-app", 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") || !strings.Contains(body, "has_token") {
+ t.Errorf("response body should contain status, running, and has_token fields, got: %s", body)
+ }
+}
+
+func TestWeComAppAccessToken(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ t.Run("get empty access token initially", func(t *testing.T) {
+ token := ch.getAccessToken()
+ if token != "" {
+ t.Errorf("getAccessToken() = %q, want empty string", token)
+ }
+ })
+
+ t.Run("set and get access token", func(t *testing.T) {
+ ch.tokenMu.Lock()
+ ch.accessToken = "test_token_123"
+ ch.tokenExpiry = time.Now().Add(1 * time.Hour)
+ ch.tokenMu.Unlock()
+
+ token := ch.getAccessToken()
+ if token != "test_token_123" {
+ t.Errorf("getAccessToken() = %q, want %q", token, "test_token_123")
+ }
+ })
+
+ t.Run("expired token returns empty", func(t *testing.T) {
+ ch.tokenMu.Lock()
+ ch.accessToken = "expired_token"
+ ch.tokenExpiry = time.Now().Add(-1 * time.Hour)
+ ch.tokenMu.Unlock()
+
+ token := ch.getAccessToken()
+ if token != "" {
+ t.Errorf("getAccessToken() = %q, want empty string for expired token", token)
+ }
+ })
+}
+
+func TestWeComAppMessageStructures(t *testing.T) {
+ t.Run("WeComTextMessage structure", func(t *testing.T) {
+ msg := WeComTextMessage{
+ ToUser: "user123",
+ MsgType: "text",
+ AgentID: 1000002,
+ }
+ msg.Text.Content = "Hello World"
+
+ if msg.ToUser != "user123" {
+ t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123")
+ }
+ if msg.MsgType != "text" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
+ }
+ if msg.AgentID != 1000002 {
+ t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002)
+ }
+ if msg.Text.Content != "Hello World" {
+ t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
+ }
+
+ // Test JSON marshaling
+ jsonData, err := json.Marshal(msg)
+ if err != nil {
+ t.Fatalf("failed to marshal JSON: %v", err)
+ }
+
+ var unmarshaled WeComTextMessage
+ err = json.Unmarshal(jsonData, &unmarshaled)
+ if err != nil {
+ t.Fatalf("failed to unmarshal JSON: %v", err)
+ }
+
+ if unmarshaled.ToUser != msg.ToUser {
+ t.Errorf("JSON round-trip failed for ToUser")
+ }
+ })
+
+ t.Run("WeComMarkdownMessage structure", func(t *testing.T) {
+ msg := WeComMarkdownMessage{
+ ToUser: "user123",
+ MsgType: "markdown",
+ AgentID: 1000002,
+ }
+ msg.Markdown.Content = "# Hello\nWorld"
+
+ if msg.Markdown.Content != "# Hello\nWorld" {
+ t.Errorf("Markdown.Content = %q, want %q", msg.Markdown.Content, "# Hello\nWorld")
+ }
+
+ // Test JSON marshaling
+ jsonData, err := json.Marshal(msg)
+ if err != nil {
+ t.Fatalf("failed to marshal JSON: %v", err)
+ }
+
+ if !bytes.Contains(jsonData, []byte("markdown")) {
+ t.Error("JSON should contain 'markdown' field")
+ }
+ })
+
+ t.Run("WeComImageMessage structure", func(t *testing.T) {
+ msg := WeComImageMessage{
+ ToUser: "user123",
+ MsgType: "image",
+ AgentID: 1000002,
+ }
+ msg.Image.MediaID = "media_123456"
+
+ if msg.Image.MediaID != "media_123456" {
+ t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456")
+ }
+ })
+
+ t.Run("WeComAccessTokenResponse structure", func(t *testing.T) {
+ jsonData := `{
+ "errcode": 0,
+ "errmsg": "ok",
+ "access_token": "test_access_token",
+ "expires_in": 7200
+ }`
+
+ var resp WeComAccessTokenResponse
+ err := json.Unmarshal([]byte(jsonData), &resp)
+ if err != nil {
+ t.Fatalf("failed to unmarshal JSON: %v", err)
+ }
+
+ if resp.ErrCode != 0 {
+ t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0)
+ }
+ if resp.ErrMsg != "ok" {
+ t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok")
+ }
+ if resp.AccessToken != "test_access_token" {
+ t.Errorf("AccessToken = %q, want %q", resp.AccessToken, "test_access_token")
+ }
+ if resp.ExpiresIn != 7200 {
+ t.Errorf("ExpiresIn = %d, want %d", resp.ExpiresIn, 7200)
+ }
+ })
+
+ t.Run("WeComSendMessageResponse structure", func(t *testing.T) {
+ jsonData := `{
+ "errcode": 0,
+ "errmsg": "ok",
+ "invaliduser": "",
+ "invalidparty": "",
+ "invalidtag": ""
+ }`
+
+ var resp WeComSendMessageResponse
+ err := json.Unmarshal([]byte(jsonData), &resp)
+ if err != nil {
+ t.Fatalf("failed to unmarshal JSON: %v", err)
+ }
+
+ if resp.ErrCode != 0 {
+ t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0)
+ }
+ if resp.ErrMsg != "ok" {
+ t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok")
+ }
+ })
+}
+
+func TestWeComAppXMLMessageStructure(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+
+ 1234567890123456
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.ToUserName != "corp_id" {
+ t.Errorf("ToUserName = %q, want %q", msg.ToUserName, "corp_id")
+ }
+ if msg.FromUserName != "user123" {
+ t.Errorf("FromUserName = %q, want %q", msg.FromUserName, "user123")
+ }
+ if msg.CreateTime != 1234567890 {
+ t.Errorf("CreateTime = %d, want %d", msg.CreateTime, 1234567890)
+ }
+ if msg.MsgType != "text" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
+ }
+ if msg.Content != "Hello World" {
+ t.Errorf("Content = %q, want %q", msg.Content, "Hello World")
+ }
+ if msg.MsgId != 1234567890123456 {
+ t.Errorf("MsgId = %d, want %d", msg.MsgId, 1234567890123456)
+ }
+ if msg.AgentID != 1000002 {
+ t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002)
+ }
+}
+
+func TestWeComAppXMLMessageImage(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+
+
+ 1234567890123456
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.MsgType != "image" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "image")
+ }
+ if msg.PicUrl != "https://example.com/image.jpg" {
+ t.Errorf("PicUrl = %q, want %q", msg.PicUrl, "https://example.com/image.jpg")
+ }
+ if msg.MediaId != "media_123" {
+ t.Errorf("MediaId = %q, want %q", msg.MediaId, "media_123")
+ }
+}
+
+func TestWeComAppXMLMessageVoice(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+
+
+ 1234567890123456
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.MsgType != "voice" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "voice")
+ }
+ if msg.Format != "amr" {
+ t.Errorf("Format = %q, want %q", msg.Format, "amr")
+ }
+}
+
+func TestWeComAppXMLMessageLocation(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+ 39.9042
+ 116.4074
+ 16
+
+ 1234567890123456
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.MsgType != "location" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "location")
+ }
+ if msg.LocationX != 39.9042 {
+ t.Errorf("LocationX = %f, want %f", msg.LocationX, 39.9042)
+ }
+ if msg.LocationY != 116.4074 {
+ t.Errorf("LocationY = %f, want %f", msg.LocationY, 116.4074)
+ }
+ if msg.Scale != 16 {
+ t.Errorf("Scale = %d, want %d", msg.Scale, 16)
+ }
+ if msg.Label != "Beijing" {
+ t.Errorf("Label = %q, want %q", msg.Label, "Beijing")
+ }
+}
+
+func TestWeComAppXMLMessageLink(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+
+
+
+ 1234567890123456
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.MsgType != "link" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "link")
+ }
+ if msg.Title != "Link Title" {
+ t.Errorf("Title = %q, want %q", msg.Title, "Link Title")
+ }
+ if msg.Description != "Link Description" {
+ t.Errorf("Description = %q, want %q", msg.Description, "Link Description")
+ }
+ if msg.Url != "https://example.com" {
+ t.Errorf("Url = %q, want %q", msg.Url, "https://example.com")
+ }
+}
+
+func TestWeComAppXMLMessageEvent(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+
+
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.MsgType != "event" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "event")
+ }
+ if msg.Event != "subscribe" {
+ t.Errorf("Event = %q, want %q", msg.Event, "subscribe")
+ }
+ if msg.EventKey != "event_key_123" {
+ t.Errorf("EventKey = %q, want %q", msg.EventKey, "event_key_123")
+ }
+}
diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom_test.go
new file mode 100644
index 000000000..a2015a8d3
--- /dev/null
+++ b/pkg/channels/wecom_test.go
@@ -0,0 +1,689 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// WeCom Bot (ä¼äøå¾®äæ”ęŗč½ęŗåØäŗŗ) channel tests
+
+package channels
+
+import (
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/sha1"
+ "encoding/base64"
+ "encoding/binary"
+ "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
+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 + corp_id
+ random := make([]byte, 0, 16)
+ for i := 0; i < 16; i++ {
+ random = append(random, byte(i))
+ }
+
+ msgBytes := []byte(message)
+ corpID := []byte("test_corp_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, corpID...)
+
+ // 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{
+ Token: "",
+ 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{
+ Token: "test_token",
+ 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{
+ Token: "test_token",
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ 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{
+ Token: "test_token",
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ 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{
+ Token: "test_token",
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ 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{
+ Token: "test_token",
+ 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 !ch.verifySignature(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 ch.verifySignature("invalid_sig", timestamp, nonce, msgEncrypt) {
+ t.Error("invalid signature should fail verification")
+ }
+ })
+
+ t.Run("empty token skips verification", func(t *testing.T) {
+ // Create a channel manually with empty token to test the behavior
+ cfgEmpty := config.WeComConfig{
+ Token: "",
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ }
+ base := NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom)
+ chEmpty := &WeComBotChannel{
+ BaseChannel: base,
+ config: cfgEmpty,
+ }
+
+ if !chEmpty.verifySignature("any_sig", "any_ts", "any_nonce", "any_msg") {
+ t.Error("empty token should skip verification and return true")
+ }
+ })
+}
+
+func TestWeComBotDecryptMessage(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+
+ t.Run("decrypt without AES key", func(t *testing.T) {
+ cfg := config.WeComConfig{
+ Token: "test_token",
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ EncodingAESKey: "",
+ }
+ ch, _ := NewWeComBotChannel(cfg, msgBus)
+
+ // Without AES key, message should be base64 decoded only
+ plainText := "hello world"
+ encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
+
+ result, err := ch.decryptMessage(encoded)
+ 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{
+ Token: "test_token",
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ EncodingAESKey: aesKey,
+ }
+ ch, _ := NewWeComBotChannel(cfg, msgBus)
+
+ originalMsg := "Hello"
+ encrypted, err := encryptTestMessage(originalMsg, aesKey)
+ if err != nil {
+ t.Fatalf("failed to encrypt test message: %v", err)
+ }
+
+ result, err := ch.decryptMessage(encrypted)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result != originalMsg {
+ t.Errorf("decryptMessage() = %q, want %q", result, originalMsg)
+ }
+ })
+
+ t.Run("invalid base64", func(t *testing.T) {
+ cfg := config.WeComConfig{
+ Token: "test_token",
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ EncodingAESKey: "",
+ }
+ ch, _ := NewWeComBotChannel(cfg, msgBus)
+
+ _, err := ch.decryptMessage("invalid_base64!!!")
+ if err == nil {
+ t.Error("expected error for invalid base64, got nil")
+ }
+ })
+
+ t.Run("invalid AES key", func(t *testing.T) {
+ cfg := config.WeComConfig{
+ Token: "test_token",
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ EncodingAESKey: "invalid_key",
+ }
+ ch, _ := NewWeComBotChannel(cfg, msgBus)
+
+ _, err := ch.decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")))
+ 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 := pkcs7UnpadWeCom(tt.input)
+ if tt.expected == nil {
+ // This case should return an error
+ if err == nil {
+ t.Errorf("pkcs7UnpadWeCom() expected error for invalid padding, got result: %v", result)
+ }
+ return
+ }
+ if err != nil {
+ t.Errorf("pkcs7UnpadWeCom() unexpected error: %v", err)
+ return
+ }
+ if !bytes.Equal(result, tt.expected) {
+ t.Errorf("pkcs7UnpadWeCom() = %v, want %v", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestWeComBotHandleVerification(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ aesKey := generateTestAESKey()
+ cfg := config.WeComConfig{
+ Token: "test_token",
+ EncodingAESKey: aesKey,
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ }
+ ch, _ := NewWeComBotChannel(cfg, msgBus)
+
+ t.Run("valid verification request", func(t *testing.T) {
+ echostr := "test_echostr_123"
+ encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr)
+
+ req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ w := httptest.NewRecorder()
+
+ ch.handleVerification(context.Background(), w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
+ }
+ if w.Body.String() != echostr {
+ t.Errorf("response body = %q, want %q", w.Body.String(), echostr)
+ }
+ })
+
+ t.Run("missing parameters", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=sig×tamp=ts", nil)
+ w := httptest.NewRecorder()
+
+ ch.handleVerification(context.Background(), w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
+ }
+ })
+
+ t.Run("invalid signature", func(t *testing.T) {
+ echostr := "test_echostr"
+ encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+
+ req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ w := httptest.NewRecorder()
+
+ ch.handleVerification(context.Background(), w, req)
+
+ if w.Code != http.StatusForbidden {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
+ }
+ })
+}
+
+func TestWeComBotHandleMessageCallback(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ aesKey := generateTestAESKey()
+ cfg := config.WeComConfig{
+ Token: "test_token",
+ EncodingAESKey: aesKey,
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ }
+ ch, _ := NewWeComBotChannel(cfg, msgBus)
+
+ t.Run("valid message callback", func(t *testing.T) {
+ // Create XML message
+ xmlMsg := WeComBotXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "text",
+ Content: "Hello World",
+ MsgId: 123456,
+ }
+ xmlData, _ := xml.Marshal(xmlMsg)
+
+ // Encrypt message
+ encrypted, _ := encryptTestMessage(string(xmlData), aesKey)
+
+ // Create encrypted XML wrapper
+ encryptedWrapper := struct {
+ XMLName xml.Name `xml:"xml"`
+ Encrypt string `xml:"Encrypt"`
+ }{
+ Encrypt: encrypted,
+ }
+ wrapperData, _ := xml.Marshal(encryptedWrapper)
+
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ signature := generateSignature("test_token", timestamp, nonce, encrypted)
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ w := httptest.NewRecorder()
+
+ ch.handleMessageCallback(context.Background(), w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
+ }
+ if w.Body.String() != "success" {
+ t.Errorf("response body = %q, want %q", w.Body.String(), "success")
+ }
+ })
+
+ t.Run("missing parameters", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=sig", nil)
+ w := httptest.NewRecorder()
+
+ ch.handleMessageCallback(context.Background(), w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
+ }
+ })
+
+ t.Run("invalid XML", func(t *testing.T) {
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ signature := generateSignature("test_token", timestamp, nonce, "")
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"))
+ w := httptest.NewRecorder()
+
+ ch.handleMessageCallback(context.Background(), w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
+ }
+ })
+
+ t.Run("invalid signature", func(t *testing.T) {
+ encryptedWrapper := struct {
+ XMLName xml.Name `xml:"xml"`
+ Encrypt string `xml:"Encrypt"`
+ }{
+ Encrypt: "encrypted_data",
+ }
+ wrapperData, _ := xml.Marshal(encryptedWrapper)
+
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ w := httptest.NewRecorder()
+
+ ch.handleMessageCallback(context.Background(), w, req)
+
+ if w.Code != http.StatusForbidden {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
+ }
+ })
+}
+
+func TestWeComBotProcessMessage(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComConfig{
+ Token: "test_token",
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ }
+ ch, _ := NewWeComBotChannel(cfg, msgBus)
+
+ t.Run("process text message", func(t *testing.T) {
+ msg := WeComBotXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "text",
+ Content: "Hello World",
+ MsgId: 123456,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+
+ t.Run("process voice message with recognition", func(t *testing.T) {
+ msg := WeComBotXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "voice",
+ Recognition: "Voice message text",
+ MsgId: 123456,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+
+ t.Run("skip unsupported message type", func(t *testing.T) {
+ msg := WeComBotXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "video",
+ MsgId: 123456,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+}
+
+func TestWeComBotHandleWebhook(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComConfig{
+ Token: "test_token",
+ WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
+ }
+ ch, _ := NewWeComBotChannel(cfg, msgBus)
+
+ t.Run("GET request calls verification", func(t *testing.T) {
+ echostr := "test_echostr"
+ encoded := base64.StdEncoding.EncodeToString([]byte(echostr))
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ signature := generateSignature("test_token", timestamp, nonce, encoded)
+
+ req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil)
+ w := httptest.NewRecorder()
+
+ ch.handleWebhook(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
+ }
+ })
+
+ t.Run("POST request calls message callback", func(t *testing.T) {
+ encryptedWrapper := struct {
+ XMLName xml.Name `xml:"xml"`
+ Encrypt string `xml:"Encrypt"`
+ }{
+ Encrypt: base64.StdEncoding.EncodeToString([]byte("test")),
+ }
+ wrapperData, _ := xml.Marshal(encryptedWrapper)
+
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ w := httptest.NewRecorder()
+
+ ch.handleWebhook(w, req)
+
+ // Should not be method not allowed
+ if w.Code == http.StatusMethodNotAllowed {
+ t.Error("POST request should not return Method Not Allowed")
+ }
+ })
+
+ t.Run("unsupported method", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPut, "/webhook/wecom", nil)
+ w := httptest.NewRecorder()
+
+ ch.handleWebhook(w, req)
+
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed)
+ }
+ })
+}
+
+func TestWeComBotHandleHealth(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComConfig{
+ Token: "test_token",
+ 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 TestWeComBotWebhookReplyMessage(t *testing.T) {
+ msg := WeComBotWebhookReply{
+ 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 TestWeComBotXMLMessageStructure(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+
+ 1234567890123456
+`
+
+ var msg WeComBotXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.ToUserName != "corp_id" {
+ t.Errorf("ToUserName = %q, want %q", msg.ToUserName, "corp_id")
+ }
+ if msg.FromUserName != "user123" {
+ t.Errorf("FromUserName = %q, want %q", msg.FromUserName, "user123")
+ }
+ if msg.CreateTime != 1234567890 {
+ t.Errorf("CreateTime = %d, want %d", msg.CreateTime, 1234567890)
+ }
+ if msg.MsgType != "text" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
+ }
+ if msg.Content != "Hello World" {
+ t.Errorf("Content = %q, want %q", msg.Content, "Hello World")
+ }
+ if msg.MsgId != 1234567890123456 {
+ t.Errorf("MsgId = %d, want %d", msg.MsgId, 1234567890123456)
+ }
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 0d41796a4..95753bf15 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -190,6 +190,8 @@ type ChannelsConfig struct {
Slack SlackConfig `json:"slack"`
LINE LINEConfig `json:"line"`
OneBot OneBotConfig `json:"onebot"`
+ WeCom WeComConfig `json:"wecom"`
+ WeComApp WeComAppConfig `json:"wecom_app"`
}
type WhatsAppConfig struct {
@@ -267,6 +269,32 @@ type OneBotConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
}
+type WeComConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
+ EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
+ WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
+}
+
+type WeComAppConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
+ CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
+ CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
+ AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
+ EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
+}
+
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 70ba67adf..ee46034a5 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -88,6 +88,30 @@ func DefaultConfig() *Config {
GroupTriggerPrefix: []string{},
AllowFrom: FlexibleStringSlice{},
},
+ WeCom: WeComConfig{
+ Enabled: false,
+ Token: "",
+ EncodingAESKey: "",
+ WebhookURL: "",
+ WebhookHost: "0.0.0.0",
+ WebhookPort: 18793,
+ WebhookPath: "/webhook/wecom",
+ AllowFrom: FlexibleStringSlice{},
+ ReplyTimeout: 5,
+ },
+ WeComApp: WeComAppConfig{
+ Enabled: false,
+ CorpID: "",
+ CorpSecret: "",
+ AgentID: 0,
+ Token: "",
+ EncodingAESKey: "",
+ WebhookHost: "0.0.0.0",
+ WebhookPort: 18792,
+ WebhookPath: "/webhook/wecom-app",
+ AllowFrom: FlexibleStringSlice{},
+ ReplyTimeout: 5,
+ },
},
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{WebSearch: true},
From 14ccfb39d94cd2ede66af4d2b21b1765116153fc Mon Sep 17 00:00:00 2001
From: swordkee
Date: Fri, 20 Feb 2026 18:28:10 +0800
Subject: [PATCH 07/88] feat: add wecom and wecomApp test
---
pkg/channels/wecom.go | 265 +++++++++++++--------------------
pkg/channels/wecom_app.go | 115 +-------------
pkg/channels/wecom_app_test.go | 20 +--
pkg/channels/wecom_common.go | 117 +++++++++++++++
pkg/channels/wecom_test.go | 210 +++++++++++++++++---------
5 files changed, 371 insertions(+), 356 deletions(-)
create mode 100644 pkg/channels/wecom_common.go
diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go
index 5d4e14697..33afef17a 100644
--- a/pkg/channels/wecom.go
+++ b/pkg/channels/wecom.go
@@ -7,17 +7,11 @@ package channels
import (
"bytes"
"context"
- "crypto/aes"
- "crypto/cipher"
- "crypto/sha1"
- "encoding/base64"
- "encoding/binary"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
- "sort"
"strings"
"sync"
"time"
@@ -40,40 +34,54 @@ type WeComBotChannel struct {
msgMu sync.RWMutex
}
-// WeComBotXMLMessage represents the XML message structure from WeCom Bot
-type WeComBotXMLMessage 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"`
- PicUrl string `xml:"PicUrl"`
- MediaId string `xml:"MediaId"`
- Format string `xml:"Format"`
- Recognition string `xml:"Recognition"` // Voice recognition result
+// 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 {
- 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"`
-}
-
-// WeComBotWebhookReply represents the webhook API reply
-type WeComBotWebhookReply struct {
MsgType string `json:"msgtype"`
Text struct {
Content string `json:"content"`
} `json:"text,omitempty"`
- Markdown struct {
- Content string `json:"content"`
- } `json:"markdown,omitempty"`
}
// NewWeComBotChannel creates a new WeCom Bot channel instance
@@ -205,14 +213,14 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
}
// Verify signature
- if !c.verifySignature(msgSignature, timestamp, nonce, echostr) {
+ if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
logger.WarnC("wecom", "Signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
}
// Decrypt echostr
- decryptedEchoStr, err := c.decryptMessage(echostr)
+ decryptedEchoStr, err := WeComDecryptMessage(echostr, c.config.EncodingAESKey)
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{
"error": err.Error(),
@@ -265,14 +273,14 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
// Verify signature
- if !c.verifySignature(msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
+ if !WeComVerifySignature(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
- decryptedMsg, err := c.decryptMessage(encryptedMsg.Encrypt)
+ decryptedMsg, err := WeComDecryptMessage(encryptedMsg.Encrypt, c.config.EncodingAESKey)
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{
"error": err.Error(),
@@ -281,9 +289,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
return
}
- // Parse decrypted XML message
- var msg WeComBotXMLMessage
- if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
+ // 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]interface{}{
"error": err.Error(),
})
@@ -300,9 +308,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
// processMessage processes the received message
-func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotXMLMessage) {
- // Skip non-text messages for now (can be extended)
- if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" {
+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]interface{}{
"msg_type": msg.MsgType,
})
@@ -310,8 +318,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotXMLMes
}
// Message deduplication: Use msg_id to prevent duplicate processing
- // As per WeCom documentation, use msg_id for deduplication
- msgID := fmt.Sprintf("%d", msg.MsgId)
+ msgID := msg.MsgID
c.msgMu.Lock()
if c.processedMsgs[msgID] {
c.msgMu.Unlock()
@@ -330,141 +337,73 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotXMLMes
c.msgMu.Unlock()
}
- senderID := msg.FromUserName
- chatID := senderID // WeCom Bot uses user ID as chat ID
+ senderID := msg.From.UserID
- // Use voice recognition result if available
- content := msg.Content
- if msg.MsgType == "voice" && msg.Recognition != "" {
- content = msg.Recognition
+ // 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
- // WeCom Bot only supports direct messages (private chat)
metadata := map[string]string{
- "msg_type": msg.MsgType,
- "msg_id": fmt.Sprintf("%d", msg.MsgId),
- "platform": "wecom",
- "media_id": msg.MediaId,
- "create_time": fmt.Sprintf("%d", msg.CreateTime),
- "peer_kind": "direct",
- "peer_id": senderID,
+ "msg_type": msg.MsgType,
+ "msg_id": msg.MsgID,
+ "platform": "wecom",
+ "peer_kind": peerKind,
+ "peer_id": peerID,
+ "response_url": msg.ResponseURL,
+ }
+ if isGroupChat {
+ metadata["chat_id"] = msg.ChatID
+ metadata["sender_id"] = senderID
}
logger.DebugCF("wecom", "Received message", map[string]interface{}{
- "sender_id": senderID,
- "msg_type": msg.MsgType,
- "preview": utils.Truncate(content, 50),
+ "sender_id": senderID,
+ "msg_type": msg.MsgType,
+ "peer_kind": peerKind,
+ "is_group_chat": isGroupChat,
+ "preview": utils.Truncate(content, 50),
})
// Handle the message through the base channel
c.HandleMessage(senderID, chatID, content, nil, metadata)
}
-// verifySignature verifies the message signature
-func (c *WeComBotChannel) verifySignature(msgSignature, timestamp, nonce, msgEncrypt string) bool {
- if c.config.Token == "" {
- return true // Skip verification if token is not set
- }
-
- // Sort parameters
- params := []string{c.config.Token, timestamp, nonce, msgEncrypt}
- sort.Strings(params)
-
- // Concatenate
- str := strings.Join(params, "")
-
- // SHA1 hash
- hash := sha1.Sum([]byte(str))
- expectedSignature := fmt.Sprintf("%x", hash)
-
- return expectedSignature == msgSignature
-}
-
-// decryptMessage decrypts the encrypted message using AES
-func (c *WeComBotChannel) decryptMessage(encryptedMsg string) (string, error) {
- if c.config.EncodingAESKey == "" {
- // No encryption, return as is (base64 decode)
- decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
- if err != nil {
- return "", err
- }
- return string(decoded), nil
- }
-
- // Decode AES key (base64)
- aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=")
- if err != nil {
- return "", fmt.Errorf("failed to decode AES key: %w", err)
- }
-
- // Decode encrypted message
- cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
- if err != nil {
- return "", fmt.Errorf("failed to decode message: %w", err)
- }
-
- // AES decrypt
- block, err := aes.NewCipher(aesKey)
- if err != nil {
- return "", fmt.Errorf("failed to create cipher: %w", err)
- }
-
- if len(cipherText) < aes.BlockSize {
- return "", fmt.Errorf("ciphertext too short")
- }
-
- mode := cipher.NewCBCDecrypter(block, aesKey[:aes.BlockSize])
- plainText := make([]byte, len(cipherText))
- mode.CryptBlocks(plainText, cipherText)
-
- // Remove PKCS7 padding
- plainText, err = pkcs7UnpadWeCom(plainText)
- if err != nil {
- return "", fmt.Errorf("failed to unpad: %w", err)
- }
-
- // Parse message structure
- // Format: random(16) + msg_len(4) + msg + corp_id
- if len(plainText) < 20 {
- return "", fmt.Errorf("decrypted message too short")
- }
-
- msgLen := binary.BigEndian.Uint32(plainText[16:20])
- if int(msgLen) > len(plainText)-20 {
- return "", fmt.Errorf("invalid message length")
- }
-
- msg := plainText[20 : 20+msgLen]
- // corpID := plainText[20+msgLen:] // Could be used for verification
-
- return string(msg), nil
-}
-
-// pkcs7UnpadWeCom removes PKCS7 padding with validation
-func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
- if len(data) == 0 {
- return data, nil
- }
- padding := int(data[len(data)-1])
- if padding == 0 || padding > aes.BlockSize {
- return nil, fmt.Errorf("invalid padding size: %d", padding)
- }
- if padding > len(data) {
- return nil, fmt.Errorf("padding size larger than data")
- }
- // Verify all padding bytes
- for i := 0; i < padding; i++ {
- if data[len(data)-1-i] != byte(padding) {
- return nil, fmt.Errorf("invalid padding byte at position %d", i)
- }
- }
- return data[:len(data)-padding], nil
-}
-
// sendWebhookReply sends a reply using the webhook URL
func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error {
- reply := WeComBotWebhookReply{
+ reply := WeComBotReplyMessage{
MsgType: "text",
}
reply.Text.Content = content
diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go
index c1d0ebaad..783d381f2 100644
--- a/pkg/channels/wecom_app.go
+++ b/pkg/channels/wecom_app.go
@@ -7,18 +7,12 @@ package channels
import (
"bytes"
"context"
- "crypto/aes"
- "crypto/cipher"
- "crypto/sha1"
- "encoding/base64"
- "encoding/binary"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
- "sort"
"strings"
"sync"
"time"
@@ -265,14 +259,14 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
}
// Verify signature
- if !c.verifySignature(msgSignature, timestamp, nonce, echostr) {
+ if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
logger.WarnC("wecom_app", "Signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
}
// Decrypt echostr
- decryptedEchoStr, err := c.decryptMessage(echostr)
+ decryptedEchoStr, err := WeComDecryptMessage(echostr, c.config.EncodingAESKey)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{
"error": err.Error(),
@@ -325,14 +319,14 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
// Verify signature
- if !c.verifySignature(msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
+ if !WeComVerifySignature(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
- decryptedMsg, err := c.decryptMessage(encryptedMsg.Encrypt)
+ decryptedMsg, err := WeComDecryptMessage(encryptedMsg.Encrypt, c.config.EncodingAESKey)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{
"error": err.Error(),
@@ -418,107 +412,6 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
c.HandleMessage(senderID, chatID, content, nil, metadata)
}
-// verifySignature verifies the message signature
-func (c *WeComAppChannel) verifySignature(msgSignature, timestamp, nonce, msgEncrypt string) bool {
- if c.config.Token == "" {
- return true // Skip verification if token is not set
- }
-
- // Sort parameters
- params := []string{c.config.Token, timestamp, nonce, msgEncrypt}
- sort.Strings(params)
-
- // Concatenate
- str := strings.Join(params, "")
-
- // SHA1 hash
- hash := sha1.Sum([]byte(str))
- expectedSignature := fmt.Sprintf("%x", hash)
-
- return expectedSignature == msgSignature
-}
-
-// decryptMessage decrypts the encrypted message using AES
-func (c *WeComAppChannel) decryptMessage(encryptedMsg string) (string, error) {
- if c.config.EncodingAESKey == "" {
- // No encryption, return as is (base64 decode)
- decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
- if err != nil {
- return "", err
- }
- return string(decoded), nil
- }
-
- // Decode AES key (base64)
- aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=")
- if err != nil {
- return "", fmt.Errorf("failed to decode AES key: %w", err)
- }
-
- // Decode encrypted message
- cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
- if err != nil {
- return "", fmt.Errorf("failed to decode message: %w", err)
- }
-
- // AES decrypt
- block, err := aes.NewCipher(aesKey)
- if err != nil {
- return "", fmt.Errorf("failed to create cipher: %w", err)
- }
-
- if len(cipherText) < aes.BlockSize {
- return "", fmt.Errorf("ciphertext too short")
- }
-
- mode := cipher.NewCBCDecrypter(block, aesKey[:aes.BlockSize])
- plainText := make([]byte, len(cipherText))
- mode.CryptBlocks(plainText, cipherText)
-
- // Remove PKCS7 padding
- plainText, err = pkcs7Unpad(plainText)
- if err != nil {
- return "", fmt.Errorf("failed to unpad: %w", err)
- }
-
- // Parse message structure
- // Format: random(16) + msg_len(4) + msg + corp_id
- if len(plainText) < 20 {
- return "", fmt.Errorf("decrypted message too short")
- }
-
- msgLen := binary.BigEndian.Uint32(plainText[16:20])
- if int(msgLen) > len(plainText)-20 {
- return "", fmt.Errorf("invalid message length")
- }
-
- msg := plainText[20 : 20+msgLen]
- // corpID := plainText[20+msgLen:] // Can be used for verification
-
- return string(msg), nil
-}
-
-// pkcs7Unpad removes PKCS7 padding with validation
-func pkcs7Unpad(data []byte) ([]byte, error) {
- if len(data) == 0 {
- return data, nil
- }
- padding := int(data[len(data)-1])
- if padding == 0 || padding > aes.BlockSize {
- return nil, fmt.Errorf("invalid padding size: %d", padding)
- }
- if padding > len(data) {
- return nil, fmt.Errorf("padding size larger than data")
- }
- // Verify all padding bytes
- for i := 0; i < padding; i++ {
- if data[len(data)-1-i] != byte(padding) {
- return nil, fmt.Errorf("invalid padding byte at position %d", i)
- }
- }
- return data[:len(data)-padding], nil
-}
-
// tokenRefreshLoop periodically refreshes the access token
func (c *WeComAppChannel) tokenRefreshLoop() {
ticker := time.NewTicker(5 * time.Minute)
diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom_app_test.go
index 4283c07e6..bc40806bb 100644
--- a/pkg/channels/wecom_app_test.go
+++ b/pkg/channels/wecom_app_test.go
@@ -197,7 +197,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
msgEncrypt := "test_message"
expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt)
- if !ch.verifySignature(expectedSig, timestamp, nonce, msgEncrypt) {
+ if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
t.Error("valid signature should pass verification")
}
})
@@ -207,7 +207,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
nonce := "test_nonce"
msgEncrypt := "test_message"
- if ch.verifySignature("invalid_sig", timestamp, nonce, msgEncrypt) {
+ if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification")
}
})
@@ -221,7 +221,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
}
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
- if !chEmpty.verifySignature("any_sig", "any_ts", "any_nonce", "any_msg") {
+ if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true")
}
})
@@ -243,7 +243,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
plainText := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
- result, err := ch.decryptMessage(encoded)
+ result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -268,12 +268,12 @@ func TestWeComAppDecryptMessage(t *testing.T) {
t.Fatalf("failed to encrypt test message: %v", err)
}
- result, err := ch.decryptMessage(encrypted)
+ result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != originalMsg {
- t.Errorf("decryptMessage() = %q, want %q", result, originalMsg)
+ t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg)
}
})
@@ -286,7 +286,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComAppChannel(cfg, msgBus)
- _, err := ch.decryptMessage("invalid_base64!!!")
+ _, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid base64, got nil")
}
@@ -301,7 +301,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComAppChannel(cfg, msgBus)
- _, err := ch.decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")))
+ _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid AES key, got nil")
}
@@ -319,7 +319,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
// Encrypt a very short message that results in ciphertext less than block size
shortData := make([]byte, 8)
- _, err := ch.decryptMessage(base64.StdEncoding.EncodeToString(shortData))
+ _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for short ciphertext, got nil")
}
@@ -361,7 +361,7 @@ func TestWeComAppPKCS7Unpad(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result, err := pkcs7Unpad(tt.input)
+ result, err := pkcs7UnpadWeCom(tt.input)
if tt.expected == nil {
// This case should return an error
if err == nil {
diff --git a/pkg/channels/wecom_common.go b/pkg/channels/wecom_common.go
new file mode 100644
index 000000000..16a25fad6
--- /dev/null
+++ b/pkg/channels/wecom_common.go
@@ -0,0 +1,117 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// WeCom common utilities for both WeCom Bot and WeCom App
+
+package channels
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/sha1"
+ "encoding/base64"
+ "encoding/binary"
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// WeComVerifySignature verifies the message signature for WeCom
+// This is a common function used by both WeCom Bot and WeCom App
+func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
+ if token == "" {
+ return true // Skip verification if token is not set
+ }
+
+ // Sort parameters
+ params := []string{token, timestamp, nonce, msgEncrypt}
+ sort.Strings(params)
+
+ // Concatenate
+ str := strings.Join(params, "")
+
+ // SHA1 hash
+ hash := sha1.Sum([]byte(str))
+ expectedSignature := fmt.Sprintf("%x", hash)
+
+ return expectedSignature == msgSignature
+}
+
+// WeComDecryptMessage decrypts the encrypted message using AES
+// This is a common function used by both WeCom Bot and WeCom App
+func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
+ if encodingAESKey == "" {
+ // No encryption, return as is (base64 decode)
+ decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
+ if err != nil {
+ return "", err
+ }
+ return string(decoded), nil
+ }
+
+ // Decode AES key (base64)
+ aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
+ if err != nil {
+ return "", fmt.Errorf("failed to decode AES key: %w", err)
+ }
+
+ // Decode encrypted message
+ cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
+ if err != nil {
+ return "", fmt.Errorf("failed to decode message: %w", err)
+ }
+
+ // AES decrypt
+ block, err := aes.NewCipher(aesKey)
+ if err != nil {
+ return "", fmt.Errorf("failed to create cipher: %w", err)
+ }
+
+ if len(cipherText) < aes.BlockSize {
+ return "", fmt.Errorf("ciphertext too short")
+ }
+
+ mode := cipher.NewCBCDecrypter(block, aesKey[:aes.BlockSize])
+ plainText := make([]byte, len(cipherText))
+ mode.CryptBlocks(plainText, cipherText)
+
+ // Remove PKCS7 padding
+ plainText, err = pkcs7UnpadWeCom(plainText)
+ if err != nil {
+ return "", fmt.Errorf("failed to unpad: %w", err)
+ }
+
+ // Parse message structure
+ // Format: random(16) + msg_len(4) + msg + corp_id
+ if len(plainText) < 20 {
+ return "", fmt.Errorf("decrypted message too short")
+ }
+
+ msgLen := binary.BigEndian.Uint32(plainText[16:20])
+ if int(msgLen) > len(plainText)-20 {
+ return "", fmt.Errorf("invalid message length")
+ }
+
+ msg := plainText[20 : 20+msgLen]
+
+ return string(msg), nil
+}
+
+// pkcs7UnpadWeCom removes PKCS7 padding with validation
+func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
+ if len(data) == 0 {
+ return data, nil
+ }
+ padding := int(data[len(data)-1])
+ if padding == 0 || padding > aes.BlockSize {
+ return nil, fmt.Errorf("invalid padding size: %d", padding)
+ }
+ if padding > len(data) {
+ return nil, fmt.Errorf("padding size larger than data")
+ }
+ // Verify all padding bytes
+ for i := 0; i < padding; i++ {
+ if data[len(data)-1-i] != byte(padding) {
+ return nil, fmt.Errorf("invalid padding byte at position %d", i)
+ }
+ }
+ return data[:len(data)-padding], nil
+}
diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom_test.go
index a2015a8d3..c3f889c64 100644
--- a/pkg/channels/wecom_test.go
+++ b/pkg/channels/wecom_test.go
@@ -11,6 +11,7 @@ import (
"crypto/sha1"
"encoding/base64"
"encoding/binary"
+ "encoding/json"
"encoding/xml"
"fmt"
"net/http"
@@ -34,7 +35,7 @@ func generateTestAESKey() string {
return base64.StdEncoding.EncodeToString(key)[:43]
}
-// encryptTestMessage encrypts a message for testing
+// 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 + "=")
@@ -42,14 +43,14 @@ func encryptTestMessage(message, aesKey string) (string, error) {
return "", err
}
- // Prepare message: random(16) + msg_len(4) + msg + corp_id
+ // Prepare message: random(16) + msg_len(4) + msg + receiveid
random := make([]byte, 0, 16)
for i := 0; i < 16; i++ {
random = append(random, byte(i))
}
msgBytes := []byte(message)
- corpID := []byte("test_corp_id")
+ receiveID := []byte("test_aibot_id")
msgLen := uint32(len(msgBytes))
lenBytes := make([]byte, 4)
@@ -57,7 +58,7 @@ func encryptTestMessage(message, aesKey string) (string, error) {
plainText := append(random, lenBytes...)
plainText = append(plainText, msgBytes...)
- plainText = append(plainText, corpID...)
+ plainText = append(plainText, receiveID...)
// PKCS7 padding
blockSize := aes.BlockSize
@@ -176,7 +177,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
msgEncrypt := "test_message"
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
- if !ch.verifySignature(expectedSig, timestamp, nonce, msgEncrypt) {
+ if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
t.Error("valid signature should pass verification")
}
})
@@ -186,7 +187,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
nonce := "test_nonce"
msgEncrypt := "test_message"
- if ch.verifySignature("invalid_sig", timestamp, nonce, msgEncrypt) {
+ if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification")
}
})
@@ -203,7 +204,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
config: cfgEmpty,
}
- if !chEmpty.verifySignature("any_sig", "any_ts", "any_nonce", "any_msg") {
+ if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true")
}
})
@@ -224,7 +225,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
plainText := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
- result, err := ch.decryptMessage(encoded)
+ result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -248,12 +249,12 @@ func TestWeComBotDecryptMessage(t *testing.T) {
t.Fatalf("failed to encrypt test message: %v", err)
}
- result, err := ch.decryptMessage(encrypted)
+ result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != originalMsg {
- t.Errorf("decryptMessage() = %q, want %q", result, originalMsg)
+ t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg)
}
})
@@ -265,7 +266,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
- _, err := ch.decryptMessage("invalid_base64!!!")
+ _, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid base64, got nil")
}
@@ -279,7 +280,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
- _, err := ch.decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")))
+ _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid AES key, got nil")
}
@@ -408,20 +409,62 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
- t.Run("valid message callback", func(t *testing.T) {
- // Create XML message
- xmlMsg := WeComBotXMLMessage{
- ToUserName: "corp_id",
- FromUserName: "user123",
- CreateTime: 1234567890,
- MsgType: "text",
- Content: "Hello World",
- MsgId: 123456,
- }
- xmlData, _ := xml.Marshal(xmlMsg)
+ t.Run("valid direct message callback", func(t *testing.T) {
+ // Create JSON message for direct chat (single)
+ jsonMsg := `{
+ "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"}
+ }`
// Encrypt message
- encrypted, _ := encryptTestMessage(string(xmlData), aesKey)
+ encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
+
+ // Create encrypted XML wrapper
+ encryptedWrapper := struct {
+ XMLName xml.Name `xml:"xml"`
+ Encrypt string `xml:"Encrypt"`
+ }{
+ Encrypt: encrypted,
+ }
+ wrapperData, _ := xml.Marshal(encryptedWrapper)
+
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ signature := generateSignature("test_token", timestamp, nonce, encrypted)
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ w := httptest.NewRecorder()
+
+ ch.handleMessageCallback(context.Background(), w, req)
+
+ 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) {
+ // Create JSON message for group chat
+ jsonMsg := `{
+ "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"}
+ }`
+
+ // Encrypt message
+ encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
// Create encrypted XML wrapper
encryptedWrapper := struct {
@@ -506,42 +549,61 @@ func TestWeComBotProcessMessage(t *testing.T) {
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
- t.Run("process text message", func(t *testing.T) {
- msg := WeComBotXMLMessage{
- ToUserName: "corp_id",
- FromUserName: "user123",
- CreateTime: 1234567890,
- MsgType: "text",
- Content: "Hello World",
- MsgId: 123456,
+ 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 voice message with recognition", func(t *testing.T) {
- msg := WeComBotXMLMessage{
- ToUserName: "corp_id",
- FromUserName: "user123",
- CreateTime: 1234567890,
- MsgType: "voice",
- Recognition: "Voice message text",
- MsgId: 123456,
+ 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 := WeComBotXMLMessage{
- ToUserName: "corp_id",
- FromUserName: "user123",
- CreateTime: 1234567890,
- MsgType: "video",
- MsgId: 123456,
+ 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)
@@ -637,8 +699,8 @@ func TestWeComBotHandleHealth(t *testing.T) {
}
}
-func TestWeComBotWebhookReplyMessage(t *testing.T) {
- msg := WeComBotWebhookReply{
+func TestWeComBotReplyMessage(t *testing.T) {
+ msg := WeComBotReplyMessage{
MsgType: "text",
}
msg.Text.Content = "Hello World"
@@ -651,39 +713,43 @@ func TestWeComBotWebhookReplyMessage(t *testing.T) {
}
}
-func TestWeComBotXMLMessageStructure(t *testing.T) {
- xmlData := `
-
-
-
- 1234567890
-
-
- 1234567890123456
-`
+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 WeComBotXMLMessage
- err := xml.Unmarshal([]byte(xmlData), &msg)
+ var msg WeComBotMessage
+ err := json.Unmarshal([]byte(jsonData), &msg)
if err != nil {
- t.Fatalf("failed to unmarshal XML: %v", err)
+ t.Fatalf("failed to unmarshal JSON: %v", err)
}
- if msg.ToUserName != "corp_id" {
- t.Errorf("ToUserName = %q, want %q", msg.ToUserName, "corp_id")
+ if msg.MsgID != "test_msg_id_123" {
+ t.Errorf("MsgID = %q, want %q", msg.MsgID, "test_msg_id_123")
}
- if msg.FromUserName != "user123" {
- t.Errorf("FromUserName = %q, want %q", msg.FromUserName, "user123")
+ if msg.AIBotID != "test_aibot_id" {
+ t.Errorf("AIBotID = %q, want %q", msg.AIBotID, "test_aibot_id")
}
- if msg.CreateTime != 1234567890 {
- t.Errorf("CreateTime = %d, want %d", msg.CreateTime, 1234567890)
+ 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.Content != "Hello World" {
- t.Errorf("Content = %q, want %q", msg.Content, "Hello World")
- }
- if msg.MsgId != 1234567890123456 {
- t.Errorf("MsgId = %d, want %d", msg.MsgId, 1234567890123456)
+ if msg.Text.Content != "Hello World" {
+ t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
}
}
From d692cc0cc62cfa3a56ad692c31a5020f7e5c3392 Mon Sep 17 00:00:00 2001
From: Harsh Bansal <122075346+harshbansal7@users.noreply.github.com>
Date: Fri, 20 Feb 2026 16:25:04 +0530
Subject: [PATCH 08/88] Feature: Implement Skill Discovery - With Clawhub
Integration and Caching (#332)
* Add Find Skills and Install Skills
* Improvements
* fix file name
* Update pkg/skills/clawhub_registry.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* fix
* Comments addressed
* Resolve comments
* fix tests
* fixes
* Comments resolved
* Update pkg/skills/search_cache_repro_test.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* minor fix
* fix test
* fixes
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
cmd/picoclaw/cmd_skills.go | 101 ++++++++-
cmd/picoclaw/main.go | 2 +-
config/config.example.json | 11 +
pkg/agent/loop.go | 10 +
pkg/config/config.go | 34 ++-
pkg/config/defaults.go | 13 ++
pkg/skills/clawhub_registry.go | 311 ++++++++++++++++++++++++++++
pkg/skills/clawhub_registry_test.go | 256 +++++++++++++++++++++++
pkg/skills/registry.go | 223 ++++++++++++++++++++
pkg/skills/registry_test.go | 179 ++++++++++++++++
pkg/skills/search_cache.go | 229 ++++++++++++++++++++
pkg/skills/search_cache_test.go | 200 ++++++++++++++++++
pkg/tools/skills_install.go | 199 ++++++++++++++++++
pkg/tools/skills_install_test.go | 103 +++++++++
pkg/tools/skills_search.go | 119 +++++++++++
pkg/tools/skills_search_test.go | 82 ++++++++
pkg/utils/download.go | 93 +++++++++
pkg/utils/skills.go | 19 ++
pkg/utils/string.go | 9 +
pkg/utils/zip.go | 120 +++++++++++
20 files changed, 2303 insertions(+), 10 deletions(-)
create mode 100644 pkg/skills/clawhub_registry.go
create mode 100644 pkg/skills/clawhub_registry_test.go
create mode 100644 pkg/skills/registry.go
create mode 100644 pkg/skills/registry_test.go
create mode 100644 pkg/skills/search_cache.go
create mode 100644 pkg/skills/search_cache_test.go
create mode 100644 pkg/tools/skills_install.go
create mode 100644 pkg/tools/skills_install_test.go
create mode 100644 pkg/tools/skills_search.go
create mode 100644 pkg/tools/skills_search_test.go
create mode 100644 pkg/utils/download.go
create mode 100644 pkg/utils/skills.go
create mode 100644 pkg/utils/zip.go
diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go
index 9ea38dcf6..32b7c62b8 100644
--- a/cmd/picoclaw/cmd_skills.go
+++ b/cmd/picoclaw/cmd_skills.go
@@ -11,15 +11,17 @@ import (
"strings"
"time"
+ "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
func skillsHelp() {
fmt.Println("\nSkills commands:")
fmt.Println(" list List installed skills")
fmt.Println(" install Install skill from GitHub")
- fmt.Println(" install-builtin Install all builtin skills to workspace")
- fmt.Println(" list-builtin List available builtin skills")
+ fmt.Println(" install-builtin Install all builtin skills to workspace")
+ fmt.Println(" list-builtin List available builtin skills")
fmt.Println(" remove Remove installed skill")
fmt.Println(" search Search available skills")
fmt.Println(" show Show skill details")
@@ -30,6 +32,7 @@ func skillsHelp() {
fmt.Println(" picoclaw skills install-builtin")
fmt.Println(" picoclaw skills list-builtin")
fmt.Println(" picoclaw skills remove weather")
+ fmt.Println(" picoclaw skills install --registry clawhub github")
}
func skillsListCmd(loader *skills.SkillsLoader) {
@@ -50,13 +53,27 @@ func skillsListCmd(loader *skills.SkillsLoader) {
}
}
-func skillsInstallCmd(installer *skills.SkillInstaller) {
+func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) {
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills install ")
- fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather")
+ fmt.Println(" picoclaw skills install --registry ")
return
}
+ // Check for --registry flag.
+ if os.Args[3] == "--registry" {
+ if len(os.Args) < 6 {
+ fmt.Println("Usage: picoclaw skills install --registry ")
+ fmt.Println("Example: picoclaw skills install --registry clawhub github")
+ return
+ }
+ registryName := os.Args[4]
+ slug := os.Args[5]
+ skillsInstallFromRegistry(cfg, registryName, slug)
+ return
+ }
+
+ // Default: install from GitHub (backward compatible).
repo := os.Args[3]
fmt.Printf("Installing skill from %s...\n", repo)
@@ -64,11 +81,83 @@ func skillsInstallCmd(installer *skills.SkillInstaller) {
defer cancel()
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
- fmt.Printf("ā Failed to install skill: %v\n", err)
+ fmt.Printf("\u2717 Failed to install skill: %v\n", err)
os.Exit(1)
}
- fmt.Printf("ā Skill '%s' installed successfully!\n", filepath.Base(repo))
+ fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
+}
+
+// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
+func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
+ err := utils.ValidateSkillIdentifier(registryName)
+ if err != nil {
+ fmt.Printf("\u2717 Invalid registry name: %v\n", err)
+ os.Exit(1)
+ }
+
+ err = utils.ValidateSkillIdentifier(slug)
+ if err != nil {
+ fmt.Printf("\u2717 Invalid slug: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
+
+ registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
+ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
+ ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
+ })
+
+ registry := registryMgr.GetRegistry(registryName)
+ if registry == nil {
+ fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName)
+ os.Exit(1)
+ }
+
+ workspace := cfg.WorkspacePath()
+ targetDir := filepath.Join(workspace, "skills", slug)
+
+ if _, err := os.Stat(targetDir); err == nil {
+ fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
+ os.Exit(1)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0755); err != nil {
+ fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
+ os.Exit(1)
+ }
+
+ result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
+ if err != nil {
+ rmErr := os.RemoveAll(targetDir)
+ if rmErr != nil {
+ fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
+ }
+ fmt.Printf("\u2717 Failed to install skill: %v\n", err)
+ os.Exit(1)
+ }
+
+ if result.IsMalwareBlocked {
+ rmErr := os.RemoveAll(targetDir)
+ if rmErr != nil {
+ fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
+ }
+ fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
+ os.Exit(1)
+ }
+
+ if result.IsSuspicious {
+ fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
+ }
+
+ fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
+ if result.Summary != "" {
+ fmt.Printf(" %s\n", result.Summary)
+ }
}
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index ce9389417..1e4b393f8 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -141,7 +141,7 @@ func main() {
case "list":
skillsListCmd(skillsLoader)
case "install":
- skillsInstallCmd(installer)
+ skillsInstallCmd(installer, cfg)
case "remove", "uninstall":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills remove ")
diff --git a/config/config.example.json b/config/config.example.json
index abc928e92..fa87fbec7 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -194,6 +194,17 @@
"exec": {
"enable_deny_patterns": false,
"custom_deny_patterns": []
+ },
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "enabled": true,
+ "base_url": "https://clawhub.ai",
+ "search_path": "/api/v1/search",
+ "skills_path": "/api/v1/skills",
+ "download_path": "/api/v1/download"
+ }
+ }
}
},
"heartbeat": {
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index e7b48d47a..f8eef395a 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -23,6 +23,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
+ "github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
@@ -117,6 +118,15 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A
})
agent.Tools.Register(messageTool)
+ // Skill discovery and installation tools
+ registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
+ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
+ ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
+ })
+ searchCache := skills.NewSearchCache(cfg.Tools.Skills.SearchCache.MaxSize, time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second)
+ agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
+ agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
+
// Spawn tool with allowlist checker
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 0d41796a4..9d5e5d42e 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -416,9 +416,37 @@ type ExecConfig struct {
}
type ToolsConfig struct {
- Web WebToolsConfig `json:"web"`
- Cron CronToolsConfig `json:"cron"`
- Exec ExecConfig `json:"exec"`
+ Web WebToolsConfig `json:"web"`
+ Cron CronToolsConfig `json:"cron"`
+ Exec ExecConfig `json:"exec"`
+ Skills SkillsToolsConfig `json:"skills"`
+}
+
+type SkillsToolsConfig struct {
+ Registries SkillsRegistriesConfig `json:"registries"`
+ MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"`
+ SearchCache SearchCacheConfig `json:"search_cache"`
+}
+
+type SearchCacheConfig struct {
+ MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
+ TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
+}
+
+type SkillsRegistriesConfig struct {
+ ClawHub ClawHubRegistryConfig `json:"clawhub"`
+}
+
+type ClawHubRegistryConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
+ AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
+ SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
+ SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
+ DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
+ Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
+ MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
+ MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
}
func LoadConfig(path string) (*Config, error) {
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 54d6d68c3..07974b8eb 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -265,6 +265,19 @@ func DefaultConfig() *Config {
Exec: ExecConfig{
EnableDenyPatterns: true,
},
+ Skills: SkillsToolsConfig{
+ Registries: SkillsRegistriesConfig{
+ ClawHub: ClawHubRegistryConfig{
+ Enabled: true,
+ BaseURL: "https://clawhub.ai",
+ },
+ },
+ MaxConcurrentSearches: 2,
+ SearchCache: SearchCacheConfig{
+ MaxSize: 50,
+ TTLSeconds: 300,
+ },
+ },
},
Heartbeat: HeartbeatConfig{
Enabled: true,
diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go
new file mode 100644
index 000000000..e2a940afd
--- /dev/null
+++ b/pkg/skills/clawhub_registry.go
@@ -0,0 +1,311 @@
+package skills
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+const (
+ defaultClawHubTimeout = 30 * time.Second
+ defaultMaxZipSize = 50 * 1024 * 1024 // 50 MB
+ defaultMaxResponseSize = 2 * 1024 * 1024 // 2 MB
+)
+
+// ClawHubRegistry implements SkillRegistry for the ClawHub platform.
+type ClawHubRegistry struct {
+ baseURL string
+ authToken string // Optional - for elevated rate limits
+ searchPath string // Search API
+ skillsPath string // For retrieving skill metadata
+ downloadPath string // For fetching ZIP files for download
+ maxZipSize int
+ maxResponseSize int
+ client *http.Client
+}
+
+// NewClawHubRegistry creates a new ClawHub registry client from config.
+func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry {
+ baseURL := cfg.BaseURL
+ if baseURL == "" {
+ baseURL = "https://clawhub.ai"
+ }
+ searchPath := cfg.SearchPath
+ if searchPath == "" {
+ searchPath = "/api/v1/search"
+ }
+ skillsPath := cfg.SkillsPath
+ if skillsPath == "" {
+ skillsPath = "/api/v1/skills"
+ }
+ downloadPath := cfg.DownloadPath
+ if downloadPath == "" {
+ downloadPath = "/api/v1/download"
+ }
+
+ timeout := defaultClawHubTimeout
+ if cfg.Timeout > 0 {
+ timeout = time.Duration(cfg.Timeout) * time.Second
+ }
+
+ maxZip := defaultMaxZipSize
+ if cfg.MaxZipSize > 0 {
+ maxZip = cfg.MaxZipSize
+ }
+
+ maxResp := defaultMaxResponseSize
+ if cfg.MaxResponseSize > 0 {
+ maxResp = cfg.MaxResponseSize
+ }
+
+ return &ClawHubRegistry{
+ baseURL: baseURL,
+ authToken: cfg.AuthToken,
+ searchPath: searchPath,
+ skillsPath: skillsPath,
+ downloadPath: downloadPath,
+ maxZipSize: maxZip,
+ maxResponseSize: maxResp,
+ client: &http.Client{
+ Timeout: timeout,
+ Transport: &http.Transport{
+ MaxIdleConns: 5,
+ IdleConnTimeout: 30 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ },
+ },
+ }
+}
+
+func (c *ClawHubRegistry) Name() string {
+ return "clawhub"
+}
+
+// --- Search ---
+
+type clawhubSearchResponse struct {
+ Results []clawhubSearchResult `json:"results"`
+}
+
+type clawhubSearchResult struct {
+ Score float64 `json:"score"`
+ Slug *string `json:"slug"`
+ DisplayName *string `json:"displayName"`
+ Summary *string `json:"summary"`
+ Version *string `json:"version"`
+}
+
+func (c *ClawHubRegistry) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) {
+ u, err := url.Parse(c.baseURL + c.searchPath)
+ if err != nil {
+ return nil, fmt.Errorf("invalid base URL: %w", err)
+ }
+
+ q := u.Query()
+ q.Set("q", query)
+ if limit > 0 {
+ q.Set("limit", fmt.Sprintf("%d", limit))
+ }
+ u.RawQuery = q.Encode()
+
+ body, err := c.doGet(ctx, u.String())
+ if err != nil {
+ return nil, fmt.Errorf("search request failed: %w", err)
+ }
+
+ var resp clawhubSearchResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return nil, fmt.Errorf("failed to parse search response: %w", err)
+ }
+
+ results := make([]SearchResult, 0, len(resp.Results))
+ for _, r := range resp.Results {
+ slug := utils.DerefStr(r.Slug, "")
+ if slug == "" {
+ continue
+ }
+
+ summary := utils.DerefStr(r.Summary, "")
+ if summary == "" {
+ continue
+ }
+
+ displayName := utils.DerefStr(r.DisplayName, "")
+ if displayName == "" {
+ displayName = slug
+ }
+
+ results = append(results, SearchResult{
+ Score: r.Score,
+ Slug: slug,
+ DisplayName: displayName,
+ Summary: summary,
+ Version: utils.DerefStr(r.Version, ""),
+ RegistryName: c.Name(),
+ })
+ }
+
+ return results, nil
+}
+
+// --- GetSkillMeta ---
+
+type clawhubSkillResponse struct {
+ Slug string `json:"slug"`
+ DisplayName string `json:"displayName"`
+ Summary string `json:"summary"`
+ LatestVersion *clawhubVersionInfo `json:"latestVersion"`
+ Moderation *clawhubModerationInfo `json:"moderation"`
+}
+
+type clawhubVersionInfo struct {
+ Version string `json:"version"`
+}
+
+type clawhubModerationInfo struct {
+ IsMalwareBlocked bool `json:"isMalwareBlocked"`
+ IsSuspicious bool `json:"isSuspicious"`
+}
+
+func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error) {
+ if err := utils.ValidateSkillIdentifier(slug); err != nil {
+ return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error())
+ }
+
+ u := c.baseURL + c.skillsPath + "/" + url.PathEscape(slug)
+
+ body, err := c.doGet(ctx, u)
+ if err != nil {
+ return nil, fmt.Errorf("skill metadata request failed: %w", err)
+ }
+
+ var resp clawhubSkillResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return nil, fmt.Errorf("failed to parse skill metadata: %w", err)
+ }
+
+ meta := &SkillMeta{
+ Slug: resp.Slug,
+ DisplayName: resp.DisplayName,
+ Summary: resp.Summary,
+ RegistryName: c.Name(),
+ }
+
+ if resp.LatestVersion != nil {
+ meta.LatestVersion = resp.LatestVersion.Version
+ }
+ if resp.Moderation != nil {
+ meta.IsMalwareBlocked = resp.Moderation.IsMalwareBlocked
+ meta.IsSuspicious = resp.Moderation.IsSuspicious
+ }
+
+ return meta, nil
+}
+
+// --- DownloadAndInstall ---
+
+// DownloadAndInstall fetches metadata (with fallback), resolves version,
+// downloads the skill ZIP, and extracts it to targetDir.
+// Returns an InstallResult for the caller to use for moderation decisions.
+func (c *ClawHubRegistry) DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error) {
+ if err := utils.ValidateSkillIdentifier(slug); err != nil {
+ return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error())
+ }
+
+ // Step 1: Fetch metadata (with fallback).
+ result := &InstallResult{}
+ meta, err := c.GetSkillMeta(ctx, slug)
+ if err != nil {
+ // Fallback: proceed without metadata.
+ meta = nil
+ }
+
+ if meta != nil {
+ result.IsMalwareBlocked = meta.IsMalwareBlocked
+ result.IsSuspicious = meta.IsSuspicious
+ result.Summary = meta.Summary
+ }
+
+ // Step 2: Resolve version.
+ installVersion := version
+ if installVersion == "" && meta != nil {
+ installVersion = meta.LatestVersion
+ }
+ if installVersion == "" {
+ installVersion = "latest"
+ }
+ result.Version = installVersion
+
+ // Step 3: Download ZIP to temp file (streams in ~32KB chunks).
+ u, err := url.Parse(c.baseURL + c.downloadPath)
+ if err != nil {
+ return nil, fmt.Errorf("invalid base URL: %w", err)
+ }
+
+ q := u.Query()
+ q.Set("slug", slug)
+ if installVersion != "latest" {
+ q.Set("version", installVersion)
+ }
+ u.RawQuery = q.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ if c.authToken != "" {
+ req.Header.Set("Authorization", "Bearer "+c.authToken)
+ }
+
+ tmpPath, err := utils.DownloadToFile(ctx, c.client, req, int64(c.maxZipSize))
+ if err != nil {
+ return nil, fmt.Errorf("download failed: %w", err)
+ }
+ defer os.Remove(tmpPath)
+
+ // Step 4: Extract from file on disk.
+ if err := utils.ExtractZipFile(tmpPath, targetDir); err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// --- HTTP helper ---
+
+func (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "application/json")
+ if c.authToken != "" {
+ req.Header.Set("Authorization", "Bearer "+c.authToken)
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ // Limit response body read to prevent memory issues.
+ body, err := io.ReadAll(io.LimitReader(resp.Body, int64(c.maxResponseSize)))
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
+ }
+
+ return body, nil
+}
diff --git a/pkg/skills/clawhub_registry_test.go b/pkg/skills/clawhub_registry_test.go
new file mode 100644
index 000000000..d12e19504
--- /dev/null
+++ b/pkg/skills/clawhub_registry_test.go
@@ -0,0 +1,256 @@
+package skills
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestRegistry(serverURL, authToken string) *ClawHubRegistry {
+ return NewClawHubRegistry(ClawHubConfig{
+ Enabled: true,
+ BaseURL: serverURL,
+ AuthToken: authToken,
+ })
+}
+
+func TestClawHubRegistrySearch(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/v1/search", r.URL.Path)
+ assert.Equal(t, "github", r.URL.Query().Get("q"))
+
+ slug := "github"
+ name := "GitHub Integration"
+ summary := "Interact with GitHub repos"
+ version := "1.0.0"
+
+ json.NewEncoder(w).Encode(clawhubSearchResponse{
+ Results: []clawhubSearchResult{
+ {Score: 0.95, Slug: &slug, DisplayName: &name, Summary: &summary, Version: &version},
+ },
+ })
+ }))
+ defer srv.Close()
+
+ reg := newTestRegistry(srv.URL, "")
+ results, err := reg.Search(context.Background(), "github", 5)
+
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ assert.Equal(t, "github", results[0].Slug)
+ assert.Equal(t, "GitHub Integration", results[0].DisplayName)
+ assert.InDelta(t, 0.95, results[0].Score, 0.001)
+ assert.Equal(t, "clawhub", results[0].RegistryName)
+}
+
+func TestClawHubRegistryGetSkillMeta(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/v1/skills/github", r.URL.Path)
+
+ json.NewEncoder(w).Encode(clawhubSkillResponse{
+ Slug: "github",
+ DisplayName: "GitHub Integration",
+ Summary: "Full GitHub API integration",
+ LatestVersion: &clawhubVersionInfo{
+ Version: "2.1.0",
+ },
+ Moderation: &clawhubModerationInfo{
+ IsMalwareBlocked: false,
+ IsSuspicious: true,
+ },
+ })
+ }))
+ defer srv.Close()
+
+ reg := newTestRegistry(srv.URL, "")
+ meta, err := reg.GetSkillMeta(context.Background(), "github")
+
+ require.NoError(t, err)
+ assert.Equal(t, "github", meta.Slug)
+ assert.Equal(t, "2.1.0", meta.LatestVersion)
+ assert.False(t, meta.IsMalwareBlocked)
+ assert.True(t, meta.IsSuspicious)
+}
+
+func TestClawHubRegistryGetSkillMetaUnsafeSlug(t *testing.T) {
+ reg := newTestRegistry("https://example.com", "")
+ _, err := reg.GetSkillMeta(context.Background(), "../etc/passwd")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid slug")
+}
+
+func TestClawHubRegistryDownloadAndInstall(t *testing.T) {
+ // Create a valid ZIP in memory.
+ zipBuf := createTestZip(t, map[string]string{
+ "SKILL.md": "---\nname: test-skill\ndescription: A test\n---\nHello skill",
+ "README.md": "# Test Skill\n",
+ })
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/skills/test-skill":
+ // Metadata endpoint.
+ json.NewEncoder(w).Encode(clawhubSkillResponse{
+ Slug: "test-skill",
+ DisplayName: "Test Skill",
+ Summary: "A test skill",
+ LatestVersion: &clawhubVersionInfo{Version: "1.0.0"},
+ })
+ case "/api/v1/download":
+ assert.Equal(t, "test-skill", r.URL.Query().Get("slug"))
+ w.Header().Set("Content-Type", "application/zip")
+ w.Write(zipBuf)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ defer srv.Close()
+
+ tmpDir := t.TempDir()
+ targetDir := filepath.Join(tmpDir, "test-skill")
+
+ reg := newTestRegistry(srv.URL, "")
+ result, err := reg.DownloadAndInstall(context.Background(), "test-skill", "1.0.0", targetDir)
+
+ require.NoError(t, err)
+ assert.Equal(t, "1.0.0", result.Version)
+ assert.False(t, result.IsMalwareBlocked)
+
+ // Verify extracted files.
+ skillContent, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md"))
+ require.NoError(t, err)
+ assert.Contains(t, string(skillContent), "Hello skill")
+
+ readmeContent, err := os.ReadFile(filepath.Join(targetDir, "README.md"))
+ require.NoError(t, err)
+ assert.Contains(t, string(readmeContent), "# Test Skill")
+}
+
+func TestClawHubRegistryAuthToken(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ authHeader := r.Header.Get("Authorization")
+ assert.Equal(t, "Bearer test-token-123", authHeader)
+ json.NewEncoder(w).Encode(clawhubSearchResponse{Results: nil})
+ }))
+ defer srv.Close()
+
+ reg := newTestRegistry(srv.URL, "test-token-123")
+ _, _ = reg.Search(context.Background(), "test", 5)
+}
+
+func TestExtractZipPathTraversal(t *testing.T) {
+ // Create a ZIP with a path traversal entry.
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+
+ // Malicious entry trying to escape directory.
+ w, err := zw.Create("../../etc/passwd")
+ require.NoError(t, err)
+ w.Write([]byte("malicious"))
+
+ zw.Close()
+
+ // Write to temp file for extractZipFile.
+ tmpZip := filepath.Join(t.TempDir(), "bad.zip")
+ require.NoError(t, os.WriteFile(tmpZip, buf.Bytes(), 0644))
+
+ tmpDir := t.TempDir()
+ err = utils.ExtractZipFile(tmpZip, tmpDir)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "unsafe path")
+}
+
+func TestExtractZipWithSubdirectories(t *testing.T) {
+ zipBuf := createTestZip(t, map[string]string{
+ "SKILL.md": "root file",
+ "scripts/helper.sh": "#!/bin/bash\necho hello",
+ "examples/demo.yaml": "key: value",
+ })
+
+ // Write to temp file for extractZipFile.
+ tmpZip := filepath.Join(t.TempDir(), "test.zip")
+ require.NoError(t, os.WriteFile(tmpZip, zipBuf, 0644))
+
+ tmpDir := t.TempDir()
+ targetDir := filepath.Join(tmpDir, "my-skill")
+
+ err := utils.ExtractZipFile(tmpZip, targetDir)
+ require.NoError(t, err)
+
+ // Verify nested file.
+ data, err := os.ReadFile(filepath.Join(targetDir, "scripts", "helper.sh"))
+ require.NoError(t, err)
+ assert.Contains(t, string(data), "#!/bin/bash")
+}
+
+func TestClawHubRegistrySearchHTTPError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Server Error"))
+ }))
+ defer srv.Close()
+
+ reg := newTestRegistry(srv.URL, "")
+ _, err := reg.Search(context.Background(), "test", 5)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "500")
+}
+
+func TestClawHubRegistrySearchNullableFields(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ validSlug := "valid-slug"
+ validSummary := "valid summary"
+
+ // Return results with various null/empty fields
+ json.NewEncoder(w).Encode(clawhubSearchResponse{
+ Results: []clawhubSearchResult{
+ // Case 1: Null Slug -> Skip
+ {Score: 0.1, Slug: nil, DisplayName: nil, Summary: nil, Version: nil},
+ // Case 2: Valid Slug, Null Summary -> Skip
+ {Score: 0.2, Slug: &validSlug, DisplayName: nil, Summary: nil, Version: nil},
+ // Case 3: Valid Slug, Valid Summary, Null Name -> Keep, Name=Slug
+ {Score: 0.8, Slug: &validSlug, DisplayName: nil, Summary: &validSummary, Version: nil},
+ },
+ })
+ }))
+ defer srv.Close()
+
+ reg := newTestRegistry(srv.URL, "")
+ results, err := reg.Search(context.Background(), "test", 5)
+
+ require.NoError(t, err)
+ require.Len(t, results, 1, "should only return 1 valid result")
+
+ r := results[0]
+ assert.Equal(t, "valid-slug", r.Slug)
+ assert.Equal(t, "valid-slug", r.DisplayName, "should fallback name to slug")
+ assert.Equal(t, "valid summary", r.Summary)
+}
+
+// --- helpers ---
+
+func createTestZip(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+
+ for name, content := range files {
+ w, err := zw.Create(name)
+ require.NoError(t, err)
+ _, err = w.Write([]byte(content))
+ require.NoError(t, err)
+ }
+
+ require.NoError(t, zw.Close())
+ return buf.Bytes()
+}
diff --git a/pkg/skills/registry.go b/pkg/skills/registry.go
new file mode 100644
index 000000000..45ae72253
--- /dev/null
+++ b/pkg/skills/registry.go
@@ -0,0 +1,223 @@
+package skills
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+)
+
+const (
+ defaultMaxConcurrentSearches = 2
+)
+
+// SearchResult represents a single result from a skill registry search.
+type SearchResult struct {
+ Score float64 `json:"score"`
+ Slug string `json:"slug"`
+ DisplayName string `json:"display_name"`
+ Summary string `json:"summary"`
+ Version string `json:"version"`
+ RegistryName string `json:"registry_name"`
+}
+
+// SkillMeta holds metadata about a skill from a registry.
+type SkillMeta struct {
+ Slug string `json:"slug"`
+ DisplayName string `json:"display_name"`
+ Summary string `json:"summary"`
+ LatestVersion string `json:"latest_version"`
+ IsMalwareBlocked bool `json:"is_malware_blocked"`
+ IsSuspicious bool `json:"is_suspicious"`
+ RegistryName string `json:"registry_name"`
+}
+
+// InstallResult is returned by DownloadAndInstall to carry metadata
+// back to the caller for moderation and user messaging.
+type InstallResult struct {
+ Version string
+ IsMalwareBlocked bool
+ IsSuspicious bool
+ Summary string
+}
+
+// SkillRegistry is the interface that all skill registries must implement.
+// Each registry represents a different source of skills (e.g., clawhub.ai)
+type SkillRegistry interface {
+ // Name returns the unique name of this registry (e.g., "clawhub").
+ Name() string
+ // Search searches the registry for skills matching the query.
+ Search(ctx context.Context, query string, limit int) ([]SearchResult, error)
+ // GetSkillMeta retrieves metadata for a specific skill by slug.
+ GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error)
+ // DownloadAndInstall fetches metadata, resolves the version, downloads and
+ // installs the skill to targetDir. Returns an InstallResult with metadata
+ // for the caller to use for moderation and user messaging.
+ DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error)
+}
+
+// RegistryConfig holds configuration for all skill registries.
+// This is the input to NewRegistryManagerFromConfig.
+type RegistryConfig struct {
+ ClawHub ClawHubConfig
+ MaxConcurrentSearches int
+}
+
+// ClawHubConfig configures the ClawHub registry.
+type ClawHubConfig struct {
+ Enabled bool
+ BaseURL string
+ AuthToken string
+ SearchPath string // e.g. "/api/v1/search"
+ SkillsPath string // e.g. "/api/v1/skills"
+ DownloadPath string // e.g. "/api/v1/download"
+ Timeout int // seconds, 0 = default (30s)
+ MaxZipSize int // bytes, 0 = default (50MB)
+ MaxResponseSize int // bytes, 0 = default (2MB)
+}
+
+// RegistryManager coordinates multiple skill registries.
+// It fans out search requests and routes installs to the correct registry.
+type RegistryManager struct {
+ registries []SkillRegistry
+ maxConcurrent int
+ mu sync.RWMutex
+}
+
+// NewRegistryManager creates an empty RegistryManager.
+func NewRegistryManager() *RegistryManager {
+ return &RegistryManager{
+ registries: make([]SkillRegistry, 0),
+ maxConcurrent: defaultMaxConcurrentSearches,
+ }
+}
+
+// NewRegistryManagerFromConfig builds a RegistryManager from config,
+// instantiating only the enabled registries.
+func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager {
+ rm := NewRegistryManager()
+ if cfg.MaxConcurrentSearches > 0 {
+ rm.maxConcurrent = cfg.MaxConcurrentSearches
+ }
+ if cfg.ClawHub.Enabled {
+ rm.AddRegistry(NewClawHubRegistry(cfg.ClawHub))
+ }
+ return rm
+}
+
+// AddRegistry adds a registry to the manager.
+func (rm *RegistryManager) AddRegistry(r SkillRegistry) {
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
+ rm.registries = append(rm.registries, r)
+}
+
+// GetRegistry returns a registry by name, or nil if not found.
+func (rm *RegistryManager) GetRegistry(name string) SkillRegistry {
+ rm.mu.RLock()
+ defer rm.mu.RUnlock()
+ for _, r := range rm.registries {
+ if r.Name() == name {
+ return r
+ }
+ }
+ return nil
+}
+
+// SearchAll fans out the query to all registries concurrently
+// and merges results sorted by score descending.
+func (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit int) ([]SearchResult, error) {
+ rm.mu.RLock()
+ regs := make([]SkillRegistry, len(rm.registries))
+ copy(regs, rm.registries)
+ rm.mu.RUnlock()
+
+ if len(regs) == 0 {
+ return nil, fmt.Errorf("no registries configured")
+ }
+
+ type regResult struct {
+ results []SearchResult
+ err error
+ }
+
+ // Semaphore: limit concurrency.
+ sem := make(chan struct{}, rm.maxConcurrent)
+ resultsCh := make(chan regResult, len(regs))
+
+ var wg sync.WaitGroup
+ for _, reg := range regs {
+ wg.Add(1)
+ go func(r SkillRegistry) {
+ defer wg.Done()
+
+ // Acquire semaphore slot.
+ select {
+ case sem <- struct{}{}:
+ defer func() { <-sem }()
+ case <-ctx.Done():
+ resultsCh <- regResult{err: ctx.Err()}
+ return
+ }
+
+ searchCtx, cancel := context.WithTimeout(ctx, 1*time.Minute)
+ defer cancel()
+
+ results, err := r.Search(searchCtx, query, limit)
+ if err != nil {
+ slog.Warn("registry search failed", "registry", r.Name(), "error", err)
+ resultsCh <- regResult{err: err}
+ return
+ }
+ resultsCh <- regResult{results: results}
+ }(reg)
+ }
+
+ // Close results channel after all goroutines complete.
+ go func() {
+ wg.Wait()
+ close(resultsCh)
+ }()
+
+ var merged []SearchResult
+ var lastErr error
+
+ var anyRegistrySucceeded bool
+ for rr := range resultsCh {
+ if rr.err != nil {
+ lastErr = rr.err
+ continue
+ }
+ anyRegistrySucceeded = true
+ merged = append(merged, rr.results...)
+ }
+
+ // If all registries failed, return the last error.
+ if !anyRegistrySucceeded && lastErr != nil {
+ return nil, fmt.Errorf("all registries failed: %w", lastErr)
+ }
+
+ // Sort by score descending.
+ sortByScoreDesc(merged)
+
+ // Clamp to limit.
+ if limit > 0 && len(merged) > limit {
+ merged = merged[:limit]
+ }
+
+ return merged, nil
+}
+
+// sortByScoreDesc sorts SearchResults by Score in descending order (insertion sort ā small slices).
+func sortByScoreDesc(results []SearchResult) {
+ for i := 1; i < len(results); i++ {
+ key := results[i]
+ j := i - 1
+ for j >= 0 && results[j].Score < key.Score {
+ results[j+1] = results[j]
+ j--
+ }
+ results[j+1] = key
+ }
+}
diff --git a/pkg/skills/registry_test.go b/pkg/skills/registry_test.go
new file mode 100644
index 000000000..daecd5a59
--- /dev/null
+++ b/pkg/skills/registry_test.go
@@ -0,0 +1,179 @@
+package skills
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
+ "github.com/stretchr/testify/assert"
+)
+
+// mockRegistry is a test double implementing SkillRegistry.
+type mockRegistry struct {
+ name string
+ searchResults []SearchResult
+ searchErr error
+ meta *SkillMeta
+ metaErr error
+ installResult *InstallResult
+ installErr error
+}
+
+func (m *mockRegistry) Name() string { return m.name }
+
+func (m *mockRegistry) Search(_ context.Context, _ string, _ int) ([]SearchResult, error) {
+ return m.searchResults, m.searchErr
+}
+
+func (m *mockRegistry) GetSkillMeta(_ context.Context, _ string) (*SkillMeta, error) {
+ return m.meta, m.metaErr
+}
+
+func (m *mockRegistry) DownloadAndInstall(_ context.Context, _, _, _ string) (*InstallResult, error) {
+ return m.installResult, m.installErr
+}
+
+func TestRegistryManagerSearchAllSingle(t *testing.T) {
+ mgr := NewRegistryManager()
+ mgr.AddRegistry(&mockRegistry{
+ name: "test",
+ searchResults: []SearchResult{
+ {Slug: "skill-a", Score: 0.9, RegistryName: "test"},
+ {Slug: "skill-b", Score: 0.5, RegistryName: "test"},
+ },
+ })
+
+ results, err := mgr.SearchAll(context.Background(), "test query", 10)
+ assert.NoError(t, err)
+ assert.Len(t, results, 2)
+ assert.Equal(t, "skill-a", results[0].Slug)
+}
+
+func TestRegistryManagerSearchAllMultiple(t *testing.T) {
+ mgr := NewRegistryManager()
+ mgr.AddRegistry(&mockRegistry{
+ name: "alpha",
+ searchResults: []SearchResult{
+ {Slug: "skill-a", Score: 0.8, RegistryName: "alpha"},
+ },
+ })
+ mgr.AddRegistry(&mockRegistry{
+ name: "beta",
+ searchResults: []SearchResult{
+ {Slug: "skill-b", Score: 0.95, RegistryName: "beta"},
+ },
+ })
+
+ results, err := mgr.SearchAll(context.Background(), "test query", 10)
+ assert.NoError(t, err)
+ assert.Len(t, results, 2)
+ // Should be sorted by score descending
+ assert.Equal(t, "skill-b", results[0].Slug)
+ assert.Equal(t, "skill-a", results[1].Slug)
+}
+
+func TestRegistryManagerSearchAllOneFailsGracefully(t *testing.T) {
+ mgr := NewRegistryManager()
+ mgr.AddRegistry(&mockRegistry{
+ name: "failing",
+ searchErr: fmt.Errorf("network error"),
+ })
+ mgr.AddRegistry(&mockRegistry{
+ name: "working",
+ searchResults: []SearchResult{
+ {Slug: "skill-a", Score: 0.8, RegistryName: "working"},
+ },
+ })
+
+ results, err := mgr.SearchAll(context.Background(), "test query", 10)
+ assert.NoError(t, err)
+ assert.Len(t, results, 1)
+ assert.Equal(t, "skill-a", results[0].Slug)
+}
+
+func TestRegistryManagerSearchAllAllFail(t *testing.T) {
+ mgr := NewRegistryManager()
+ mgr.AddRegistry(&mockRegistry{
+ name: "fail-1",
+ searchErr: fmt.Errorf("error 1"),
+ })
+
+ _, err := mgr.SearchAll(context.Background(), "test query", 10)
+ assert.Error(t, err)
+}
+
+func TestRegistryManagerSearchAllNoRegistries(t *testing.T) {
+ mgr := NewRegistryManager()
+ _, err := mgr.SearchAll(context.Background(), "test query", 10)
+ assert.Error(t, err)
+}
+
+func TestRegistryManagerGetRegistry(t *testing.T) {
+ mgr := NewRegistryManager()
+ mock := &mockRegistry{name: "clawhub"}
+ mgr.AddRegistry(mock)
+
+ got := mgr.GetRegistry("clawhub")
+ assert.NotNil(t, got)
+ assert.Equal(t, "clawhub", got.Name())
+
+ got = mgr.GetRegistry("nonexistent")
+ assert.Nil(t, got)
+}
+
+func TestRegistryManagerSearchAllRespectLimit(t *testing.T) {
+ mgr := NewRegistryManager()
+ results := make([]SearchResult, 20)
+ for i := range results {
+ results[i] = SearchResult{Slug: fmt.Sprintf("skill-%d", i), Score: float64(20 - i)}
+ }
+ mgr.AddRegistry(&mockRegistry{
+ name: "test",
+ searchResults: results,
+ })
+
+ got, err := mgr.SearchAll(context.Background(), "test", 5)
+ assert.NoError(t, err)
+ assert.Len(t, got, 5)
+ // Top scores first
+ assert.Equal(t, "skill-0", got[0].Slug)
+}
+
+func TestRegistryManagerSearchAllTimeout(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
+ defer cancel()
+
+ time.Sleep(5 * time.Millisecond) // Let context expire.
+
+ mgr := NewRegistryManager()
+ mgr.AddRegistry(&mockRegistry{
+ name: "slow",
+ searchErr: fmt.Errorf("context deadline exceeded"),
+ })
+
+ _, err := mgr.SearchAll(ctx, "test", 5)
+ assert.Error(t, err)
+}
+
+func TestSortByScoreDesc(t *testing.T) {
+ results := []SearchResult{
+ {Slug: "c", Score: 0.3},
+ {Slug: "a", Score: 0.9},
+ {Slug: "b", Score: 0.5},
+ }
+ sortByScoreDesc(results)
+ assert.Equal(t, "a", results[0].Slug)
+ assert.Equal(t, "b", results[1].Slug)
+ assert.Equal(t, "c", results[2].Slug)
+}
+
+func TestIsSafeSlug(t *testing.T) {
+ assert.NoError(t, utils.ValidateSkillIdentifier("github"))
+ assert.NoError(t, utils.ValidateSkillIdentifier("docker-compose"))
+ assert.Error(t, utils.ValidateSkillIdentifier(""))
+ assert.Error(t, utils.ValidateSkillIdentifier("../etc/passwd"))
+ assert.Error(t, utils.ValidateSkillIdentifier("path/traversal"))
+ assert.Error(t, utils.ValidateSkillIdentifier("path\\traversal"))
+}
diff --git a/pkg/skills/search_cache.go b/pkg/skills/search_cache.go
new file mode 100644
index 000000000..5d7d2797e
--- /dev/null
+++ b/pkg/skills/search_cache.go
@@ -0,0 +1,229 @@
+package skills
+
+import (
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// SearchCache provides lightweight caching for search results.
+// It uses trigram-based similarity to match similar queries to cached results,
+// avoiding redundant API calls. Thread-safe for concurrent access.
+type SearchCache struct {
+ mu sync.RWMutex
+ entries map[string]*cacheEntry
+ order []string // LRU order: oldest first.
+ maxEntries int
+ ttl time.Duration
+}
+
+type cacheEntry struct {
+ query string
+ trigrams []uint32
+ results []SearchResult
+ createdAt time.Time
+}
+
+// similarityThreshold is the minimum trigram Jaccard similarity for a cache hit.
+const similarityThreshold = 0.7
+
+// NewSearchCache creates a new search cache.
+// maxEntries is the maximum number of cached queries (excess evicts LRU).
+// ttl is how long each entry lives before expiration.
+func NewSearchCache(maxEntries int, ttl time.Duration) *SearchCache {
+ if maxEntries <= 0 {
+ maxEntries = 50
+ }
+ if ttl <= 0 {
+ ttl = 5 * time.Minute
+ }
+ return &SearchCache{
+ entries: make(map[string]*cacheEntry),
+ order: make([]string, 0),
+ maxEntries: maxEntries,
+ ttl: ttl,
+ }
+}
+
+// Get looks up results for a query. Returns cached results and true if found
+// (either exact or similar match above threshold). Returns nil, false on miss.
+func (sc *SearchCache) Get(query string) ([]SearchResult, bool) {
+ normalized := normalizeQuery(query)
+ if normalized == "" {
+ return nil, false
+ }
+
+ sc.mu.Lock()
+ defer sc.mu.Unlock()
+
+ // Exact match first.
+ if entry, ok := sc.entries[normalized]; ok {
+ if time.Since(entry.createdAt) < sc.ttl {
+ sc.moveToEndLocked(normalized)
+ return copyResults(entry.results), true
+ }
+ }
+
+ // Similarity match.
+ queryTrigrams := buildTrigrams(normalized)
+ var bestEntry *cacheEntry
+ var bestSim float64
+
+ for _, entry := range sc.entries {
+ if time.Since(entry.createdAt) >= sc.ttl {
+ continue // Skip expired.
+ }
+ sim := jaccardSimilarity(queryTrigrams, entry.trigrams)
+ if sim > bestSim {
+ bestSim = sim
+ bestEntry = entry
+ }
+ }
+
+ if bestSim >= similarityThreshold && bestEntry != nil {
+ sc.moveToEndLocked(bestEntry.query)
+ return copyResults(bestEntry.results), true
+ }
+
+ return nil, false
+}
+
+// Put stores results for a query. Evicts the oldest entry if at capacity.
+func (sc *SearchCache) Put(query string, results []SearchResult) {
+ normalized := normalizeQuery(query)
+ if normalized == "" {
+ return
+ }
+
+ sc.mu.Lock()
+ defer sc.mu.Unlock()
+
+ // Evict expired entries first.
+ sc.evictExpiredLocked()
+
+ // If already exists, update.
+ if _, ok := sc.entries[normalized]; ok {
+ sc.entries[normalized] = &cacheEntry{
+ query: normalized,
+ trigrams: buildTrigrams(normalized),
+ results: copyResults(results),
+ createdAt: time.Now(),
+ }
+ // Move to end of LRU order.
+ sc.moveToEndLocked(normalized)
+ return
+ }
+
+ // Evict LRU if at capacity.
+ for len(sc.entries) >= sc.maxEntries && len(sc.order) > 0 {
+ oldest := sc.order[0]
+ sc.order = sc.order[1:]
+ delete(sc.entries, oldest)
+ }
+
+ // Insert new entry.
+ sc.entries[normalized] = &cacheEntry{
+ query: normalized,
+ trigrams: buildTrigrams(normalized),
+ results: copyResults(results),
+ createdAt: time.Now(),
+ }
+ sc.order = append(sc.order, normalized)
+}
+
+// Len returns the number of entries (for testing).
+func (sc *SearchCache) Len() int {
+ sc.mu.RLock()
+ defer sc.mu.RUnlock()
+ return len(sc.entries)
+}
+
+// --- internal ---
+
+func (sc *SearchCache) evictExpiredLocked() {
+ now := time.Now()
+ newOrder := make([]string, 0, len(sc.order))
+ for _, key := range sc.order {
+ entry, ok := sc.entries[key]
+ if !ok || now.Sub(entry.createdAt) >= sc.ttl {
+ delete(sc.entries, key)
+ continue
+ }
+ newOrder = append(newOrder, key)
+ }
+ sc.order = newOrder
+}
+
+func (sc *SearchCache) moveToEndLocked(key string) {
+ for i, k := range sc.order {
+ if k == key {
+ sc.order = append(sc.order[:i], sc.order[i+1:]...)
+ break
+ }
+ }
+ sc.order = append(sc.order, key)
+}
+
+func normalizeQuery(q string) string {
+ return strings.ToLower(strings.TrimSpace(q))
+}
+
+// buildTrigrams generates hash of trigrams from a string.
+// Example: "hello" ā {"hel", "ell", "llo"}
+// "hel" -> 0x0068656c -> 4 bytes; compared to 16 bytes of a string
+func buildTrigrams(s string) []uint32 {
+ if len(s) < 3 {
+ return nil
+ }
+
+ trigrams := make([]uint32, 0, len(s)-2)
+ for i := 0; i <= len(s)-3; i++ {
+ trigrams = append(trigrams, uint32(s[i])<<16|uint32(s[i+1])<<8|uint32(s[i+2]))
+ }
+
+ // Sort and Deduplication
+ sort.Slice(trigrams, func(i, j int) bool { return trigrams[i] < trigrams[j] })
+ n := 1
+ for i := 1; i < len(trigrams); i++ {
+ if trigrams[i] != trigrams[i-1] {
+ trigrams[n] = trigrams[i]
+ n++
+ }
+ }
+
+ return trigrams[:n]
+}
+
+// jaccardSimilarity computes |A ā© B| / |A āŖ B|.
+func jaccardSimilarity(a, b []uint32) float64 {
+ if len(a) == 0 && len(b) == 0 {
+ return 1
+ }
+ i, j := 0, 0
+ intersection := 0
+
+ for i < len(a) && j < len(b) {
+ if a[i] == b[j] {
+ intersection++
+ i++
+ j++
+ } else if a[i] < b[j] {
+ i++
+ } else {
+ j++
+ }
+ }
+
+ union := len(a) + len(b) - intersection
+ return float64(intersection) / float64(union)
+}
+
+func copyResults(results []SearchResult) []SearchResult {
+ if results == nil {
+ return nil
+ }
+ cp := make([]SearchResult, len(results))
+ copy(cp, results)
+ return cp
+}
diff --git a/pkg/skills/search_cache_test.go b/pkg/skills/search_cache_test.go
new file mode 100644
index 000000000..816bdfb93
--- /dev/null
+++ b/pkg/skills/search_cache_test.go
@@ -0,0 +1,200 @@
+package skills
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSearchCacheExactHit(t *testing.T) {
+ cache := NewSearchCache(10, 5*time.Minute)
+
+ results := []SearchResult{
+ {Slug: "github", Score: 0.9, RegistryName: "clawhub"},
+ {Slug: "docker", Score: 0.7, RegistryName: "clawhub"},
+ }
+ cache.Put("github integration", results)
+
+ got, hit := cache.Get("github integration")
+ assert.True(t, hit)
+ assert.Len(t, got, 2)
+ assert.Equal(t, "github", got[0].Slug)
+}
+
+func TestSearchCacheExactHitCaseInsensitive(t *testing.T) {
+ cache := NewSearchCache(10, 5*time.Minute)
+
+ results := []SearchResult{{Slug: "github", Score: 0.9}}
+ cache.Put("GitHub Integration", results)
+
+ got, hit := cache.Get("github integration")
+ assert.True(t, hit)
+ assert.Len(t, got, 1)
+}
+
+func TestSearchCacheSimilarHit(t *testing.T) {
+ cache := NewSearchCache(10, 5*time.Minute)
+
+ results := []SearchResult{{Slug: "github", Score: 0.9}}
+ cache.Put("github integration tool", results)
+
+ // "github integration" is very similar to "github integration tool"
+ got, hit := cache.Get("github integration")
+ assert.True(t, hit)
+ assert.Len(t, got, 1)
+}
+
+func TestSearchCacheDissimilarMiss(t *testing.T) {
+ cache := NewSearchCache(10, 5*time.Minute)
+
+ results := []SearchResult{{Slug: "github", Score: 0.9}}
+ cache.Put("github integration", results)
+
+ // Completely unrelated query
+ _, hit := cache.Get("database management")
+ assert.False(t, hit)
+}
+
+func TestSearchCacheTTLExpiration(t *testing.T) {
+ cache := NewSearchCache(10, 50*time.Millisecond)
+
+ results := []SearchResult{{Slug: "github", Score: 0.9}}
+ cache.Put("github integration", results)
+
+ // Immediately should hit
+ _, hit := cache.Get("github integration")
+ assert.True(t, hit)
+
+ // Wait for expiration
+ time.Sleep(100 * time.Millisecond)
+
+ _, hit = cache.Get("github integration")
+ assert.False(t, hit)
+}
+
+func TestSearchCacheLRUEviction(t *testing.T) {
+ cache := NewSearchCache(3, 5*time.Minute)
+
+ cache.Put("query-1", []SearchResult{{Slug: "a"}})
+ cache.Put("query-2", []SearchResult{{Slug: "b"}})
+ cache.Put("query-3", []SearchResult{{Slug: "c"}})
+
+ assert.Equal(t, 3, cache.Len())
+
+ // Adding a 4th should evict query-1 (oldest)
+ cache.Put("query-4", []SearchResult{{Slug: "d"}})
+ assert.Equal(t, 3, cache.Len())
+
+ _, hit := cache.Get("query-1")
+ assert.False(t, hit, "oldest entry should be evicted")
+
+ got, hit := cache.Get("query-4")
+ assert.True(t, hit)
+ assert.Equal(t, "d", got[0].Slug)
+}
+
+func TestSearchCacheEmptyQuery(t *testing.T) {
+ cache := NewSearchCache(10, 5*time.Minute)
+
+ _, hit := cache.Get("")
+ assert.False(t, hit)
+
+ _, hit = cache.Get(" ")
+ assert.False(t, hit)
+}
+
+func TestSearchCacheResultsCopied(t *testing.T) {
+ cache := NewSearchCache(10, 5*time.Minute)
+
+ original := []SearchResult{{Slug: "github", Score: 0.9}}
+ cache.Put("test", original)
+
+ // Mutate original after putting
+ original[0].Slug = "mutated"
+
+ got, hit := cache.Get("test")
+ assert.True(t, hit)
+ assert.Equal(t, "github", got[0].Slug, "cache should hold a copy, not a reference")
+}
+
+func TestBuildTrigrams(t *testing.T) {
+ trigrams := buildTrigrams("hello")
+ assert.Contains(t, trigrams, uint32('h')<<16|uint32('e')<<8|uint32('l'))
+ assert.Contains(t, trigrams, uint32('e')<<16|uint32('l')<<8|uint32('l'))
+ assert.Contains(t, trigrams, uint32('l')<<16|uint32('l')<<8|uint32('o'))
+ assert.Len(t, trigrams, 3)
+}
+
+func TestJaccardSimilarity(t *testing.T) {
+ a := buildTrigrams("github integration")
+ b := buildTrigrams("github integration tool")
+
+ sim := jaccardSimilarity(a, b)
+ assert.Greater(t, sim, 0.5, "similar strings should have high sim")
+
+ c := buildTrigrams("completely different query about databases")
+ sim2 := jaccardSimilarity(a, c)
+ assert.Less(t, sim2, 0.3, "dissimilar strings should have low sim")
+}
+
+func TestJaccardSimilarityEdgeCases(t *testing.T) {
+ empty := buildTrigrams("")
+ nonempty := buildTrigrams("hello")
+
+ assert.Equal(t, 1.0, jaccardSimilarity(empty, empty))
+ assert.Equal(t, 0.0, jaccardSimilarity(empty, nonempty))
+ assert.Equal(t, 0.0, jaccardSimilarity(nonempty, empty))
+}
+
+func TestSearchCacheConcurrency(t *testing.T) {
+ cache := NewSearchCache(50, 5*time.Minute)
+ done := make(chan struct{})
+
+ // Concurrent writes
+ go func() {
+ for i := 0; i < 100; i++ {
+ cache.Put("query-write-"+string(rune('a'+i%26)), []SearchResult{{Slug: "x"}})
+ }
+ done <- struct{}{}
+ }()
+
+ // Concurrent reads
+ go func() {
+ for i := 0; i < 100; i++ {
+ cache.Get("query-write-a")
+ }
+ done <- struct{}{}
+ }()
+
+ <-done
+}
+
+func TestSearchCacheLRUUpdateOnGet(t *testing.T) {
+ // Capacity 3
+ cache := NewSearchCache(3, time.Hour)
+
+ // Fill cache: query-A, query-B, query-C
+ // Use longer strings to ensure trigrams are generated and avoid false positive similarity
+ cache.Put("query-A", []SearchResult{{Slug: "A"}})
+ cache.Put("query-B", []SearchResult{{Slug: "B"}})
+ cache.Put("query-C", []SearchResult{{Slug: "C"}})
+
+ // Access query-A (should make it most recently used)
+ if _, found := cache.Get("query-A"); !found {
+ t.Fatal("query-A should be in cache")
+ }
+
+ // Add query-D. Should evict query-B (LRU) instead of query-A (which was refreshed)
+ cache.Put("query-D", []SearchResult{{Slug: "D"}})
+
+ // Check if query-A is still there
+ if _, found := cache.Get("query-A"); !found {
+ t.Fatalf("query-A was evicted! valid LRU should have kept query-A and evicted query-B.")
+ }
+
+ // Check if query-B is evicted
+ if _, found := cache.Get("query-B"); found {
+ t.Fatal("query-B should have been evicted")
+ }
+}
diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go
new file mode 100644
index 000000000..6b05918ce
--- /dev/null
+++ b/pkg/tools/skills_install.go
@@ -0,0 +1,199 @@
+package tools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/skills"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+// InstallSkillTool allows the LLM agent to install skills from registries.
+// It shares the same RegistryManager that FindSkillsTool uses,
+// so all registries configured in config are available for installation.
+type InstallSkillTool struct {
+ registryMgr *skills.RegistryManager
+ workspace string
+ mu sync.Mutex
+}
+
+// NewInstallSkillTool creates a new InstallSkillTool.
+// registryMgr is the shared registry manager (same instance as FindSkillsTool).
+// workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/.
+func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool {
+ return &InstallSkillTool{
+ registryMgr: registryMgr,
+ workspace: workspace,
+ mu: sync.Mutex{},
+ }
+}
+
+func (t *InstallSkillTool) Name() string {
+ return "install_skill"
+}
+
+func (t *InstallSkillTool) Description() string {
+ return "Install a skill from a registry by slug. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills."
+}
+
+func (t *InstallSkillTool) Parameters() map[string]interface{} {
+ return map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "slug": map[string]interface{}{
+ "type": "string",
+ "description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')",
+ },
+ "version": map[string]interface{}{
+ "type": "string",
+ "description": "Specific version to install (optional, defaults to latest)",
+ },
+ "registry": map[string]interface{}{
+ "type": "string",
+ "description": "Registry to install from (required, e.g., 'clawhub')",
+ },
+ "force": map[string]interface{}{
+ "type": "boolean",
+ "description": "Force reinstall if skill already exists (default false)",
+ },
+ },
+ "required": []string{"slug", "registry"},
+ }
+}
+
+func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+ // Install lock to prevent concurrent directory operations.
+ // Ideally this should be done at a `slug` level, currently, its at a `workspace` level.
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ // Validate slug
+ slug, _ := args["slug"].(string)
+ if err := utils.ValidateSkillIdentifier(slug); err != nil {
+ return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error()))
+ }
+
+ // Validate registry
+ registryName, _ := args["registry"].(string)
+ if err := utils.ValidateSkillIdentifier(registryName); err != nil {
+ return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error()))
+ }
+
+ version, _ := args["version"].(string)
+ force, _ := args["force"].(bool)
+
+ // Check if already installed.
+ skillsDir := filepath.Join(t.workspace, "skills")
+ targetDir := filepath.Join(skillsDir, slug)
+
+ if !force {
+ if _, err := os.Stat(targetDir); err == nil {
+ return ErrorResult(fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir))
+ }
+ } else {
+ // Force: remove existing if present.
+ os.RemoveAll(targetDir)
+ }
+
+ // Resolve which registry to use.
+ registry := t.registryMgr.GetRegistry(registryName)
+ if registry == nil {
+ return ErrorResult(fmt.Sprintf("registry %q not found", registryName))
+ }
+
+ // Ensure skills directory exists.
+ if err := os.MkdirAll(skillsDir, 0755); err != nil {
+ return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err))
+ }
+
+ // Download and install (handles metadata, version resolution, extraction).
+ result, err := registry.DownloadAndInstall(ctx, slug, version, targetDir)
+ if err != nil {
+ // Clean up partial install.
+ rmErr := os.RemoveAll(targetDir)
+ if rmErr != nil {
+ logger.ErrorCF("tool", "Failed to remove partial install",
+ map[string]interface{}{
+ "tool": "install_skill",
+ "target_dir": targetDir,
+ "error": rmErr.Error(),
+ })
+ }
+ return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err))
+ }
+
+ // Moderation: block malware.
+ if result.IsMalwareBlocked {
+ rmErr := os.RemoveAll(targetDir)
+ if rmErr != nil {
+ logger.ErrorCF("tool", "Failed to remove partial install",
+ map[string]interface{}{
+ "tool": "install_skill",
+ "target_dir": targetDir,
+ "error": rmErr.Error(),
+ })
+ }
+ return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug))
+ }
+
+ // Write origin metadata.
+ if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil {
+ logger.ErrorCF("tool", "Failed to write origin metadata",
+ map[string]interface{}{
+ "tool": "install_skill",
+ "error": err.Error(),
+ "target": targetDir,
+ "registry": registry.Name(),
+ "slug": slug,
+ "version": result.Version,
+ })
+ _ = err
+ }
+
+ // Build result with moderation warning if suspicious.
+ var output string
+ if result.IsSuspicious {
+ output = fmt.Sprintf("ā ļø Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug)
+ }
+ output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n",
+ slug, result.Version, registry.Name(), targetDir)
+
+ if result.Summary != "" {
+ output += fmt.Sprintf("Description: %s\n", result.Summary)
+ }
+ output += "\nThe skill is now available and can be loaded in the current session."
+
+ return SilentResult(output)
+}
+
+// originMeta tracks which registry a skill was installed from.
+type originMeta struct {
+ Version int `json:"version"`
+ Registry string `json:"registry"`
+ Slug string `json:"slug"`
+ InstalledVersion string `json:"installed_version"`
+ InstalledAt int64 `json:"installed_at"`
+}
+
+func writeOriginMeta(targetDir, registryName, slug, version string) error {
+ meta := originMeta{
+ Version: 1,
+ Registry: registryName,
+ Slug: slug,
+ InstalledVersion: version,
+ InstalledAt: time.Now().UnixMilli(),
+ }
+
+ data, err := json.MarshalIndent(meta, "", " ")
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0644)
+}
diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/skills_install_test.go
new file mode 100644
index 000000000..e6941a950
--- /dev/null
+++ b/pkg/tools/skills_install_test.go
@@ -0,0 +1,103 @@
+package tools
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestInstallSkillToolName(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ assert.Equal(t, "install_skill", tool.Name())
+}
+
+func TestInstallSkillToolMissingSlug(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ result := tool.Execute(context.Background(), map[string]interface{}{})
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
+}
+
+func TestInstallSkillToolEmptySlug(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "slug": " ",
+ })
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
+}
+
+func TestInstallSkillToolUnsafeSlug(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+
+ cases := []string{
+ "../etc/passwd",
+ "path/traversal",
+ "path\\traversal",
+ }
+
+ for _, slug := range cases {
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "slug": slug,
+ })
+ assert.True(t, result.IsError, "slug %q should be rejected", slug)
+ assert.Contains(t, result.ForLLM, "invalid slug")
+ }
+}
+
+func TestInstallSkillToolAlreadyExists(t *testing.T) {
+ workspace := t.TempDir()
+ skillDir := filepath.Join(workspace, "skills", "existing-skill")
+ require.NoError(t, os.MkdirAll(skillDir, 0755))
+
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "slug": "existing-skill",
+ "registry": "clawhub",
+ })
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "already installed")
+}
+
+func TestInstallSkillToolRegistryNotFound(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "slug": "some-skill",
+ "registry": "nonexistent",
+ })
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "registry")
+ assert.Contains(t, result.ForLLM, "not found")
+}
+
+func TestInstallSkillToolParameters(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ params := tool.Parameters()
+
+ props, ok := params["properties"].(map[string]interface{})
+ assert.True(t, ok)
+ assert.Contains(t, props, "slug")
+ assert.Contains(t, props, "version")
+ assert.Contains(t, props, "registry")
+ assert.Contains(t, props, "force")
+
+ required, ok := params["required"].([]string)
+ assert.True(t, ok)
+ assert.Contains(t, required, "slug")
+ assert.Contains(t, required, "registry")
+}
+
+func TestInstallSkillToolMissingRegistry(t *testing.T) {
+ tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "slug": "some-skill",
+ })
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "invalid registry")
+}
diff --git a/pkg/tools/skills_search.go b/pkg/tools/skills_search.go
new file mode 100644
index 000000000..b12949ec2
--- /dev/null
+++ b/pkg/tools/skills_search.go
@@ -0,0 +1,119 @@
+package tools
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+// FindSkillsTool allows the LLM agent to search for installable skills from registries.
+type FindSkillsTool struct {
+ registryMgr *skills.RegistryManager
+ cache *skills.SearchCache
+}
+
+// NewFindSkillsTool creates a new FindSkillsTool.
+// registryMgr is the shared registry manager (built from config in createToolRegistry).
+// cache is the search cache for deduplicating similar queries.
+func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool {
+ return &FindSkillsTool{
+ registryMgr: registryMgr,
+ cache: cache,
+ }
+}
+
+func (t *FindSkillsTool) Name() string {
+ return "find_skills"
+}
+
+func (t *FindSkillsTool) Description() string {
+ return "Search for installable skills from skill registries. Returns skill slugs, descriptions, versions, and relevance scores. Use this to discover skills before installing them with install_skill."
+}
+
+func (t *FindSkillsTool) Parameters() map[string]interface{} {
+ return map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "query": map[string]interface{}{
+ "type": "string",
+ "description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')",
+ },
+ "limit": map[string]interface{}{
+ "type": "integer",
+ "description": "Maximum number of results to return (1-20, default 5)",
+ "minimum": 1.0,
+ "maximum": 20.0,
+ },
+ },
+ "required": []string{"query"},
+ }
+}
+
+func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+ query, ok := args["query"].(string)
+ query = strings.ToLower(strings.TrimSpace(query))
+ if !ok || query == "" {
+ return ErrorResult("query is required and must be a non-empty string")
+ }
+
+ limit := 5
+ if l, ok := args["limit"].(float64); ok {
+ li := int(l)
+ if li >= 1 && li <= 20 {
+ limit = li
+ }
+ }
+
+ // Check cache first.
+ if t.cache != nil {
+ if cached, hit := t.cache.Get(query); hit {
+ return SilentResult(formatSearchResults(query, cached, true))
+ }
+ }
+
+ // Search all registries.
+ results, err := t.registryMgr.SearchAll(ctx, query, limit)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("skill search failed: %v", err))
+ }
+
+ // Cache the results.
+ if t.cache != nil && len(results) > 0 {
+ t.cache.Put(query, results)
+ }
+
+ return SilentResult(formatSearchResults(query, results, false))
+}
+
+func formatSearchResults(query string, results []skills.SearchResult, cached bool) string {
+ if len(results) == 0 {
+ return fmt.Sprintf("No skills found for query: %q", query)
+ }
+
+ var sb strings.Builder
+ source := ""
+ if cached {
+ source = " (cached)"
+ }
+ sb.WriteString(fmt.Sprintf("Found %d skills for %q%s:\n\n", len(results), query, source))
+
+ for i, r := range results {
+ sb.WriteString(fmt.Sprintf("%d. **%s**", i+1, r.Slug))
+ if r.Version != "" {
+ sb.WriteString(fmt.Sprintf(" v%s", r.Version))
+ }
+ sb.WriteString(fmt.Sprintf(" (score: %.3f, registry: %s)\n", r.Score, r.RegistryName))
+ if r.DisplayName != "" && r.DisplayName != r.Slug {
+ sb.WriteString(fmt.Sprintf(" Name: %s\n", r.DisplayName))
+ }
+ if r.Summary != "" {
+ sb.WriteString(fmt.Sprintf(" %s\n", r.Summary))
+ }
+ sb.WriteString("\n")
+ }
+
+ sb.WriteString("Use install_skill with the slug to install a skill.")
+ return sb.String()
+}
diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/skills_search_test.go
new file mode 100644
index 000000000..7e07b2775
--- /dev/null
+++ b/pkg/tools/skills_search_test.go
@@ -0,0 +1,82 @@
+package tools
+
+import (
+ "context"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFindSkillsToolName(t *testing.T) {
+ tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
+ assert.Equal(t, "find_skills", tool.Name())
+}
+
+func TestFindSkillsToolMissingQuery(t *testing.T) {
+ tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
+ result := tool.Execute(context.Background(), map[string]interface{}{})
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "query is required")
+}
+
+func TestFindSkillsToolEmptyQuery(t *testing.T) {
+ tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "query": " ",
+ })
+ assert.True(t, result.IsError)
+}
+
+func TestFindSkillsToolCacheHit(t *testing.T) {
+ cache := skills.NewSearchCache(10, 5*60*1000*1000*1000) // 5 min
+ cache.Put("github", []skills.SearchResult{
+ {Slug: "github", Score: 0.9, RegistryName: "clawhub"},
+ })
+
+ tool := NewFindSkillsTool(skills.NewRegistryManager(), cache)
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "query": "github",
+ })
+
+ assert.False(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "github")
+ assert.Contains(t, result.ForLLM, "cached")
+}
+
+func TestFindSkillsToolParameters(t *testing.T) {
+ tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
+ params := tool.Parameters()
+
+ props, ok := params["properties"].(map[string]interface{})
+ assert.True(t, ok)
+ assert.Contains(t, props, "query")
+ assert.Contains(t, props, "limit")
+
+ required, ok := params["required"].([]string)
+ assert.True(t, ok)
+ assert.Contains(t, required, "query")
+}
+
+func TestFindSkillsToolDescription(t *testing.T) {
+ tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
+ assert.NotEmpty(t, tool.Description())
+ assert.Contains(t, tool.Description(), "skill")
+}
+
+func TestFormatSearchResultsEmpty(t *testing.T) {
+ result := formatSearchResults("test query", nil, false)
+ assert.Contains(t, result, "No skills found")
+}
+
+func TestFormatSearchResultsWithData(t *testing.T) {
+ results := []skills.SearchResult{
+ {Slug: "github", Score: 0.95, DisplayName: "GitHub", Summary: "GitHub API integration", Version: "1.0.0", RegistryName: "clawhub"},
+ }
+ output := formatSearchResults("github", results, false)
+ assert.Contains(t, output, "github")
+ assert.Contains(t, output, "v1.0.0")
+ assert.Contains(t, output, "0.950")
+ assert.Contains(t, output, "clawhub")
+ assert.Contains(t, output, "install_skill")
+}
diff --git a/pkg/utils/download.go b/pkg/utils/download.go
new file mode 100644
index 000000000..9fa7fbfa7
--- /dev/null
+++ b/pkg/utils/download.go
@@ -0,0 +1,93 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// DownloadToFile streams an HTTP response body to a temporary file in small
+// chunks (~32KB), keeping peak memory usage constant regardless of file size.
+//
+// Parameters:
+// - ctx: context for cancellation/timeout
+// - client: HTTP client to use (caller controls timeouts, transport, etc.)
+// - req: fully prepared *http.Request (method, URL, headers, etc.)
+// - maxBytes: maximum bytes to download; 0 means no limit
+//
+// Returns the path to the temporary file. The caller is responsible for
+// removing it when done (defer os.Remove(path)).
+//
+// On any error the temp file is cleaned up automatically.
+func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request, maxBytes int64) (string, error) {
+ // Attach context.
+ req = req.WithContext(ctx)
+
+ logger.DebugCF("download", "Starting download", map[string]interface{}{
+ "url": req.URL.String(),
+ "max_bytes": maxBytes,
+ })
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ // Read a small amount for the error message.
+ errBody := make([]byte, 512)
+ n, _ := io.ReadFull(resp.Body, errBody)
+ return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(errBody[:n]))
+ }
+
+ // Create temp file.
+ tmpFile, err := os.CreateTemp("", "picoclaw-dl-*")
+ if err != nil {
+ return "", fmt.Errorf("failed to create temp file: %w", err)
+ }
+ tmpPath := tmpFile.Name()
+
+ logger.DebugCF("download", "Streaming to temp file", map[string]interface{}{
+ "path": tmpPath,
+ })
+
+ // Cleanup helper ā removes the temp file on any error.
+ cleanup := func() {
+ _ = tmpFile.Close()
+ _ = os.Remove(tmpPath)
+ }
+
+ // Optionally limit the download size.
+ var src io.Reader = resp.Body
+ if maxBytes > 0 {
+ src = io.LimitReader(resp.Body, maxBytes+1) // +1 to detect overflow
+ }
+
+ written, err := io.Copy(tmpFile, src)
+ if err != nil {
+ cleanup()
+ return "", fmt.Errorf("download write failed: %w", err)
+ }
+
+ if maxBytes > 0 && written > maxBytes {
+ cleanup()
+ return "", fmt.Errorf("download too large: %d bytes (max %d)", written, maxBytes)
+ }
+
+ if err := tmpFile.Close(); err != nil {
+ _ = os.Remove(tmpPath)
+ return "", fmt.Errorf("failed to close temp file: %w", err)
+ }
+
+ logger.DebugCF("download", "Download complete", map[string]interface{}{
+ "path": tmpPath,
+ "bytes_written": written,
+ })
+
+ return tmpPath, nil
+}
diff --git a/pkg/utils/skills.go b/pkg/utils/skills.go
new file mode 100644
index 000000000..1d2cfac7f
--- /dev/null
+++ b/pkg/utils/skills.go
@@ -0,0 +1,19 @@
+package utils
+
+import (
+ "fmt"
+ "strings"
+)
+
+// ValidateSkillIdentifier validates that the given skill identifier (slug or registry name) is non-empty
+// and does not contain path separators ("/", "\\") or ".." for security.
+func ValidateSkillIdentifier(identifier string) error {
+ trimmed := strings.TrimSpace(identifier)
+ if trimmed == "" {
+ return fmt.Errorf("identifier is required and must be a non-empty string")
+ }
+ if strings.ContainsAny(trimmed, "/\\") || strings.Contains(trimmed, "..") {
+ return fmt.Errorf("identifier must not contain path separators or '..' to prevent directory traversal")
+ }
+ return nil
+}
diff --git a/pkg/utils/string.go b/pkg/utils/string.go
index 0d9837cb9..7a6aa37cc 100644
--- a/pkg/utils/string.go
+++ b/pkg/utils/string.go
@@ -14,3 +14,12 @@ func Truncate(s string, maxLen int) string {
}
return string(runes[:maxLen-3]) + "..."
}
+
+// DerefStr dereferences a pointer to a string and
+// returns the value or a fallback if the pointer is nil.
+func DerefStr(s *string, fallback string) string {
+ if s == nil {
+ return fallback
+ }
+ return *s
+}
diff --git a/pkg/utils/zip.go b/pkg/utils/zip.go
new file mode 100644
index 000000000..cad91e420
--- /dev/null
+++ b/pkg/utils/zip.go
@@ -0,0 +1,120 @@
+package utils
+
+import (
+ "archive/zip"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// ExtractZipFile extracts a ZIP archive from disk to targetDir.
+// It reads entries one at a time from disk, keeping memory usage minimal.
+//
+// Security: rejects path traversal attempts and symlinks.
+func ExtractZipFile(zipPath string, targetDir string) error {
+ reader, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return fmt.Errorf("invalid ZIP: %w", err)
+ }
+ defer reader.Close()
+
+ logger.DebugCF("zip", "Extracting ZIP", map[string]interface{}{
+ "zip_path": zipPath,
+ "target_dir": targetDir,
+ "entries": len(reader.File),
+ })
+
+ if err := os.MkdirAll(targetDir, 0755); err != nil {
+ return fmt.Errorf("failed to create target dir: %w", err)
+ }
+
+ for _, f := range reader.File {
+ // Path traversal protection.
+ cleanName := filepath.Clean(f.Name)
+ if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
+ return fmt.Errorf("zip entry has unsafe path: %q", f.Name)
+ }
+
+ destPath := filepath.Join(targetDir, cleanName)
+
+ // Double-check the resolved path is within target directory (defense-in-depth).
+ targetDirClean := filepath.Clean(targetDir)
+ if !strings.HasPrefix(filepath.Clean(destPath), targetDirClean+string(filepath.Separator)) && filepath.Clean(destPath) != targetDirClean {
+ return fmt.Errorf("zip entry escapes target dir: %q", f.Name)
+ }
+
+ mode := f.FileInfo().Mode()
+
+ // Reject any symlink.
+ if mode&os.ModeSymlink != 0 {
+ return fmt.Errorf("zip contains symlink %q; symlinks are not allowed", f.Name)
+ }
+
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(destPath, 0755); err != nil {
+ return err
+ }
+ continue
+ }
+
+ // Ensure parent directory exists.
+ if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
+ return err
+ }
+
+ if err := extractSingleFile(f, destPath); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// extractSingleFile extracts one zip.File entry to destPath, with a size check.
+func extractSingleFile(f *zip.File, destPath string) error {
+ const maxFileSize = 5 * 1024 * 1024 // 5MB, adjust as appropriate
+
+ // Check the uncompressed size from the header, if available.
+ if f.UncompressedSize64 > maxFileSize {
+ return fmt.Errorf("zip entry %q is too large (%d bytes)", f.Name, f.UncompressedSize64)
+ }
+
+ rc, err := f.Open()
+ if err != nil {
+ return fmt.Errorf("failed to open zip entry %q: %w", f.Name, err)
+ }
+ defer rc.Close()
+
+ outFile, err := os.Create(destPath)
+ if err != nil {
+ return fmt.Errorf("failed to create file %q: %w", destPath, err)
+ }
+ // We don't return the close error via return, since it's not a named error return.
+ // Instead, we log to stderr and remove the partially written file as defensive cleanup.
+ defer func() {
+ if cerr := outFile.Close(); cerr != nil {
+ _ = os.Remove(destPath)
+ logger.ErrorCF("zip", "Failed to close file", map[string]interface{}{
+ "dest_path": destPath,
+ "error": cerr.Error(),
+ })
+ }
+ }()
+
+ // Streamed size check: prevent overruns and malicious/corrupt headers.
+ written, err := io.CopyN(outFile, rc, maxFileSize+1)
+ if err != nil && err != io.EOF {
+ _ = os.Remove(destPath)
+ return fmt.Errorf("failed to extract %q: %w", f.Name, err)
+ }
+ if written > maxFileSize {
+ _ = os.Remove(destPath)
+ return fmt.Errorf("zip entry %q exceeds max size (%d bytes)", f.Name, written)
+ }
+
+ return nil
+}
From bca92433ba209e1866c86eaa342f708f29ba882e Mon Sep 17 00:00:00 2001
From: Yasuhiro Matsumoto
Date: Fri, 20 Feb 2026 16:06:33 +0900
Subject: [PATCH 09/88] Use strings.Builder instead of += concatenation in
loops
---
pkg/agent/context.go | 6 ++---
pkg/agent/loop.go | 54 +++++++++++++++++++++------------------
pkg/agent/memory.go | 61 +++++++++++++++++++-------------------------
3 files changed, 58 insertions(+), 63 deletions(-)
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index 27e3ef9dc..78f5f1ffa 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -146,15 +146,15 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
"IDENTITY.md",
}
- var result string
+ var sb strings.Builder
for _, filename := range bootstrapFiles {
filePath := filepath.Join(cb.workspace, filename)
if data, err := os.ReadFile(filePath); err == nil {
- result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data))
+ fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data)
}
}
- return result
+ return sb.String()
}
func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message {
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index e7b48d47a..bec44325e 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -818,49 +818,49 @@ func formatMessagesForLog(messages []providers.Message) string {
return "[]"
}
- var result string
- result += "[\n"
+ var sb strings.Builder
+ sb.WriteString("[\n")
for i, msg := range messages {
- result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role)
+ fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role)
if len(msg.ToolCalls) > 0 {
- result += " ToolCalls:\n"
+ sb.WriteString(" ToolCalls:\n")
for _, tc := range msg.ToolCalls {
- result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
+ fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
if tc.Function != nil {
- result += fmt.Sprintf(" Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200))
+ fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200))
}
}
}
if msg.Content != "" {
content := utils.Truncate(msg.Content, 200)
- result += fmt.Sprintf(" Content: %s\n", content)
+ fmt.Fprintf(&sb, " Content: %s\n", content)
}
if msg.ToolCallID != "" {
- result += fmt.Sprintf(" ToolCallID: %s\n", msg.ToolCallID)
+ fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID)
}
- result += "\n"
+ sb.WriteString("\n")
}
- result += "]"
- return result
+ sb.WriteString("]")
+ return sb.String()
}
// formatToolsForLog formats tool definitions for logging
-func formatToolsForLog(tools []providers.ToolDefinition) string {
- if len(tools) == 0 {
+func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
+ if len(toolDefs) == 0 {
return "[]"
}
- var result string
- result += "[\n"
- for i, tool := range tools {
- result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
- result += fmt.Sprintf(" Description: %s\n", tool.Function.Description)
+ var sb strings.Builder
+ sb.WriteString("[\n")
+ for i, tool := range toolDefs {
+ fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
+ fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
if len(tool.Function.Parameters) > 0 {
- result += fmt.Sprintf(" Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200))
+ fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200))
}
}
- result += "]"
- return result
+ sb.WriteString("]")
+ return sb.String()
}
// summarizeSession summarizes the conversation history for a session.
@@ -936,14 +936,18 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
// summarizeBatch summarizes a batch of messages.
func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
- prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
+ var sb strings.Builder
+ sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
if existingSummary != "" {
- prompt += "Existing context: " + existingSummary + "\n"
+ sb.WriteString("Existing context: ")
+ sb.WriteString(existingSummary)
+ sb.WriteString("\n")
}
- prompt += "\nCONVERSATION:\n"
+ sb.WriteString("\nCONVERSATION:\n")
for _, m := range batch {
- prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
+ fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
}
+ prompt := sb.String()
response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{
"max_tokens": 1024,
diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go
index 3f6896f91..6e5d0ba40 100644
--- a/pkg/agent/memory.go
+++ b/pkg/agent/memory.go
@@ -10,6 +10,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"time"
)
@@ -100,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
// GetRecentDailyNotes returns daily notes from the last N days.
// Contents are joined with "---" separator.
func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
- var notes []string
+ var sb strings.Builder
+ first := true
for i := 0; i < days; i++ {
date := time.Now().AddDate(0, 0, -i)
@@ -109,53 +111,42 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md")
if data, err := os.ReadFile(filePath); err == nil {
- notes = append(notes, string(data))
+ if !first {
+ sb.WriteString("\n\n---\n\n")
+ }
+ sb.Write(data)
+ first = false
}
}
- if len(notes) == 0 {
- return ""
- }
-
- // Join with separator
- var result string
- for i, note := range notes {
- if i > 0 {
- result += "\n\n---\n\n"
- }
- result += note
- }
- return result
+ return sb.String()
}
// GetMemoryContext returns formatted memory context for the agent prompt.
// Includes long-term memory and recent daily notes.
func (ms *MemoryStore) GetMemoryContext() string {
- var parts []string
-
- // Long-term memory
longTerm := ms.ReadLongTerm()
- if longTerm != "" {
- parts = append(parts, "## Long-term Memory\n\n"+longTerm)
- }
-
- // Recent daily notes (last 3 days)
recentNotes := ms.GetRecentDailyNotes(3)
- if recentNotes != "" {
- parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
- }
- if len(parts) == 0 {
+ if longTerm == "" && recentNotes == "" {
return ""
}
- // Join parts with separator
- var result string
- for i, part := range parts {
- if i > 0 {
- result += "\n\n---\n\n"
- }
- result += part
+ var sb strings.Builder
+ sb.WriteString("# Memory\n\n")
+
+ if longTerm != "" {
+ sb.WriteString("## Long-term Memory\n\n")
+ sb.WriteString(longTerm)
}
- return fmt.Sprintf("# Memory\n\n%s", result)
+
+ if recentNotes != "" {
+ if longTerm != "" {
+ sb.WriteString("\n\n---\n\n")
+ }
+ sb.WriteString("## Recent Daily Notes\n\n")
+ sb.WriteString(recentNotes)
+ }
+
+ return sb.String()
}
From 2fb2a733d425ac6b023fd0adcc18a2ee3abc1618 Mon Sep 17 00:00:00 2001
From: Vernon Stinebaker
Date: Fri, 20 Feb 2026 19:18:37 +0800
Subject: [PATCH 10/88] feat(discord): add mention_only option for @-mention
responses (#518)
* feat(discord): add mention_only option for @-mention responses
Add MentionOnly config option to Discord channel. When enabled, the bot
only responds when explicitly @-mentioned, useful for shared servers.
- Add MentionOnly bool field to DiscordConfig
- Store botUserID on startup for mention checking
- Check m.Mentions before processing messages when MentionOnly is true
- Update config example and README documentation
* fix(discord): resolve race condition and strip mention from content
- Get botUserID before opening session to avoid race condition
- Add stripBotMention to remove @mention from message content
- Handles both <@USER_ID> and <@!USER_ID> mention formats
* fix(discord): skip mention_only check for DMs
DMs should always be responded to regardless of mention_only setting.
Added check to skip the mention_only logic when GuildID is empty.
* Update README.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Hua Audio <161028864+Huaaudio@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
README.md | 7 ++++-
config/config.example.json | 3 ++-
pkg/channels/discord.go | 55 +++++++++++++++++++++++++++++++-------
pkg/config/config.go | 7 ++---
pkg/config/defaults.go | 7 ++---
5 files changed, 62 insertions(+), 17 deletions(-)
diff --git a/README.md b/README.md
index 468350409..a9065d2a4 100644
--- a/README.md
+++ b/README.md
@@ -334,7 +334,8 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"],
+ "mention_only": false
}
}
}
@@ -347,6 +348,10 @@ picoclaw gateway
* Bot Permissions: `Send Messages`, `Read Message History`
* Open the generated invite URL and add the bot to your server
+**Optional: Mention-only mode**
+
+Set `"mention_only": true` to make the bot respond only when @-mentioned. Useful for shared servers where you want the bot to respond only when explicitly called.
+
**6. Run**
```bash
diff --git a/config/config.example.json b/config/config.example.json
index fa87fbec7..07b75d785 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -57,7 +57,8 @@
"discord": {
"enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN",
- "allow_from": []
+ "allow_from": [],
+ "mention_only": false
},
"qq": {
"enabled": false,
diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go
index 9ddec662c..342ddb478 100644
--- a/pkg/channels/discord.go
+++ b/pkg/channels/discord.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
+ "strings"
"sync"
"time"
@@ -28,6 +29,7 @@ type DiscordChannel struct {
ctx context.Context
typingMu sync.Mutex
typingStop map[string]chan struct{} // chatID ā stop signal
+ botUserID string // stored for mention checking
}
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
@@ -63,6 +65,14 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
logger.InfoC("discord", "Starting Discord bot")
c.ctx = ctx
+
+ // Get bot user ID before opening session to avoid race condition
+ botUser, err := c.session.User("@me")
+ if err != nil {
+ return fmt.Errorf("failed to get bot user: %w", err)
+ }
+ c.botUserID = botUser.ID
+
c.session.AddHandler(c.handleMessage)
if err := c.session.Open(); err != nil {
@@ -71,10 +81,6 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
c.setRunning(true)
- botUser, err := c.session.User("@me")
- if err != nil {
- return fmt.Errorf("failed to get bot user: %w", err)
- }
logger.InfoCF("discord", "Discord bot connected", map[string]any{
"username": botUser.Username,
"user_id": botUser.ID,
@@ -131,7 +137,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
}
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
- // 使ēØä¼ å
„ē ctx čæč”č¶
ę¶ę§å¶
+ // Use the passed ctx for timeout control
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
@@ -152,7 +158,7 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin
}
}
-// appendContent å®å
Øå°čæ½å å
容å°ē°ęęę¬
+// appendContent safely appends content to existing text
func appendContent(content, suffix string) string {
if content == "" {
return suffix
@@ -169,7 +175,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return
}
- // ę£ę„ē½ååļ¼éæå
为被ęē»ēēØę·äøč½½éä»¶å转å½
+ // Check allowlist first to avoid downloading attachments and transcribing for rejected users
if !c.IsAllowed(m.Author.ID) {
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
"user_id": m.Author.ID,
@@ -177,6 +183,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return
}
+ // If configured to only respond to mentions, check if bot is mentioned
+ // Skip this check for DMs (GuildID is empty) - DMs should always be responded to
+ if c.config.MentionOnly && m.GuildID != "" {
+ isMentioned := false
+ for _, mention := range m.Mentions {
+ if mention.ID == c.botUserID {
+ isMentioned = true
+ break
+ }
+ }
+ if !isMentioned {
+ logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{
+ "user_id": m.Author.ID,
+ })
+ return
+ }
+ }
+
senderID := m.Author.ID
senderName := m.Author.Username
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
@@ -184,10 +208,11 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
}
content := m.Content
+ content = c.stripBotMention(content)
mediaPaths := make([]string, 0, len(m.Attachments))
localFiles := make([]string, 0, len(m.Attachments))
- // ē”®äæäø“ę¶ęä»¶åØå½ę°čæåę¶č¢«ęø
ē
+ // Ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
@@ -211,7 +236,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
if c.transcriber != nil && c.transcriber.IsAvailable() {
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
result, err := c.transcriber.Transcribe(ctx, localPath)
- cancel() // ē«å³éę¾contextčµęŗļ¼éæå
åØforå¾ŖēÆäøę³ę¼
+ cancel() // Release context resources immediately to avoid leaks in for loop
if err != nil {
logger.ErrorCF("discord", "Voice transcription failed", map[string]any{
@@ -333,3 +358,15 @@ func (c *DiscordChannel) downloadAttachment(url, filename string) string {
LoggerPrefix: "discord",
})
}
+
+// stripBotMention removes the bot mention from the message content.
+// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
+func (c *DiscordChannel) stripBotMention(text string) string {
+ if c.botUserID == "" {
+ return text
+ }
+ // Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID>
+ text = strings.ReplaceAll(text, fmt.Sprintf("<@%s>", c.botUserID), "")
+ text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "")
+ return strings.TrimSpace(text)
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 9d5e5d42e..e44212605 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -215,9 +215,10 @@ type FeishuConfig struct {
}
type DiscordConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
+ MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
}
type MaixCamConfig struct {
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 07974b8eb..d66de9081 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -43,9 +43,10 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{},
},
Discord: DiscordConfig{
- Enabled: false,
- Token: "",
- AllowFrom: FlexibleStringSlice{},
+ Enabled: false,
+ Token: "",
+ AllowFrom: FlexibleStringSlice{},
+ MentionOnly: false,
},
MaixCam: MaixCamConfig{
Enabled: false,
From ca481035a4ab5731f5f0d7c867db119f151f5b34 Mon Sep 17 00:00:00 2001
From: swordkee
Date: Fri, 20 Feb 2026 19:39:12 +0800
Subject: [PATCH 11/88] feat: add wecom and wecomApp test
---
pkg/channels/wecom.go | 8 +++-
pkg/channels/wecom_app.go | 51 +++++++++++++++++++++---
pkg/channels/wecom_common.go | 77 +++++++++++++++++++++++++++++++++---
3 files changed, 122 insertions(+), 14 deletions(-)
diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go
index 33afef17a..404949e07 100644
--- a/pkg/channels/wecom.go
+++ b/pkg/channels/wecom.go
@@ -220,7 +220,9 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
}
// Decrypt echostr
- decryptedEchoStr, err := WeComDecryptMessage(echostr, c.config.EncodingAESKey)
+ // For AIBOT (ęŗč½ęŗåØäŗŗ), receiveid should be empty string ""
+ // Reference: https://developer.work.weixin.qq.com/document/path/101033
+ decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{
"error": err.Error(),
@@ -280,7 +282,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
// Decrypt message
- decryptedMsg, err := WeComDecryptMessage(encryptedMsg.Encrypt, c.config.EncodingAESKey)
+ // For AIBOT (ęŗč½ęŗåØäŗŗ), receiveid should be empty string ""
+ // Reference: https://developer.work.weixin.qq.com/document/path/101033
+ decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{
"error": err.Error(),
diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go
index 783d381f2..63a1dd815 100644
--- a/pkg/channels/wecom_app.go
+++ b/pkg/channels/wecom_app.go
@@ -230,6 +230,14 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
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]interface{}{
+ "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)
@@ -242,6 +250,9 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request)
return
}
+ logger.WarnCF("wecom_app", "Method not allowed", map[string]interface{}{
+ "method": r.Method,
+ })
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
@@ -253,28 +264,55 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
nonce := query.Get("nonce")
echostr := query.Get("echostr")
+ logger.DebugCF("wecom_app", "Handling verification request", map[string]interface{}{
+ "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 !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
- logger.WarnC("wecom_app", "Signature verification failed")
+ logger.WarnCF("wecom_app", "Signature verification failed", map[string]interface{}{
+ "token": c.config.Token,
+ "msg_signature": msgSignature,
+ "timestamp": timestamp,
+ "nonce": nonce,
+ })
http.Error(w, "Invalid signature", http.StatusForbidden)
return
}
- // Decrypt echostr
- decryptedEchoStr, err := WeComDecryptMessage(echostr, c.config.EncodingAESKey)
+ 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]interface{}{
+ "encoding_aes_key": c.config.EncodingAESKey,
+ "corp_id": c.config.CorpID,
+ })
+ decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{
- "error": err.Error(),
+ "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]interface{}{
+ "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)
@@ -325,8 +363,9 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
return
}
- // Decrypt message
- decryptedMsg, err := WeComDecryptMessage(encryptedMsg.Encrypt, c.config.EncodingAESKey)
+ // Decrypt message with CorpID verification
+ // For WeCom App (čŖå»ŗåŗēØ), receiveid should be corp_id
+ decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{
"error": err.Error(),
diff --git a/pkg/channels/wecom_common.go b/pkg/channels/wecom_common.go
index 16a25fad6..3e3908622 100644
--- a/pkg/channels/wecom_common.go
+++ b/pkg/channels/wecom_common.go
@@ -12,6 +12,8 @@ import (
"fmt"
"sort"
"strings"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
)
// WeComVerifySignature verifies the message signature for WeCom
@@ -37,7 +39,20 @@ func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt stri
// WeComDecryptMessage decrypts the encrypted message using AES
// This is a common function used by both WeCom Bot and WeCom App
+// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
+ return WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
+}
+
+// WeComDecryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
+// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
+func WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
+ logger.DebugCF("wecom_common", "Starting decryption", map[string]interface{}{
+ "encodingAESKey_len": len(encodingAESKey),
+ "receiveid": receiveid,
+ "encryptedMsg_len": len(encryptedMsg),
+ })
+
if encodingAESKey == "" {
// No encryption, return as is (base64 decode)
decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
@@ -50,14 +65,27 @@ func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
// Decode AES key (base64)
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
+ logger.ErrorCF("wecom_common", "Failed to decode AES key", map[string]interface{}{
+ "error": err.Error(),
+ "key": encodingAESKey,
+ })
return "", fmt.Errorf("failed to decode AES key: %w", err)
}
+ logger.DebugCF("wecom_common", "AES key decoded", map[string]interface{}{
+ "key_len": len(aesKey),
+ })
// Decode encrypted message
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
if err != nil {
+ logger.ErrorCF("wecom_common", "Failed to decode message", map[string]interface{}{
+ "error": err.Error(),
+ })
return "", fmt.Errorf("failed to decode message: %w", err)
}
+ logger.DebugCF("wecom_common", "Message decoded", map[string]interface{}{
+ "cipher_len": len(cipherText),
+ })
// AES decrypt
block, err := aes.NewCipher(aesKey)
@@ -66,42 +94,79 @@ func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
}
if len(cipherText) < aes.BlockSize {
- return "", fmt.Errorf("ciphertext too short")
+ return "", fmt.Errorf("ciphertext too short: %d < %d", len(cipherText), aes.BlockSize)
}
- mode := cipher.NewCBCDecrypter(block, aesKey[:aes.BlockSize])
+ // IV is the first 16 bytes of AESKey
+ iv := aesKey[:aes.BlockSize]
+ mode := cipher.NewCBCDecrypter(block, iv)
plainText := make([]byte, len(cipherText))
mode.CryptBlocks(plainText, cipherText)
// Remove PKCS7 padding
- plainText, err = pkcs7UnpadWeCom(plainText)
+ unpaddedText, err := pkcs7UnpadWeCom(plainText)
if err != nil {
+ lastByte := -1
+ if len(plainText) > 0 {
+ lastByte = int(plainText[len(plainText)-1])
+ }
+ logger.ErrorCF("wecom_common", "PKCS7 unpad failed", map[string]interface{}{
+ "error": err.Error(),
+ "plain_len": len(plainText),
+ "last_byte": lastByte,
+ })
return "", fmt.Errorf("failed to unpad: %w", err)
}
+ plainText = unpaddedText
// Parse message structure
- // Format: random(16) + msg_len(4) + msg + corp_id
+ // Format: random(16) + msg_len(4) + msg + receiveid
if len(plainText) < 20 {
return "", fmt.Errorf("decrypted message too short")
}
msgLen := binary.BigEndian.Uint32(plainText[16:20])
+ logger.DebugCF("wecom_common", "Message structure parsed", map[string]interface{}{
+ "msg_len": msgLen,
+ "plain_len": len(plainText),
+ "total_expected": 20 + int(msgLen),
+ })
+
if int(msgLen) > len(plainText)-20 {
- return "", fmt.Errorf("invalid message length")
+ return "", fmt.Errorf("invalid message length: %d > %d", msgLen, len(plainText)-20)
}
msg := plainText[20 : 20+msgLen]
+ // Verify receiveid if provided
+ if receiveid != "" && len(plainText) > 20+int(msgLen) {
+ actualReceiveID := string(plainText[20+msgLen:])
+ logger.DebugCF("wecom_common", "ReceiveID verification", map[string]interface{}{
+ "expected": receiveid,
+ "actual": actualReceiveID,
+ })
+ if actualReceiveID != receiveid {
+ return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
+ }
+ }
+
+ logger.DebugCF("wecom_common", "Decryption successful", map[string]interface{}{
+ "msg_len": len(msg),
+ })
return string(msg), nil
}
// pkcs7UnpadWeCom removes PKCS7 padding with validation
+// WeCom uses block size of 32 (not standard AES block size of 16)
+const wecomBlockSize = 32
+
func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
if len(data) == 0 {
return data, nil
}
padding := int(data[len(data)-1])
- if padding == 0 || padding > aes.BlockSize {
+ // WeCom uses 32-byte block size for PKCS7 padding
+ if padding == 0 || padding > wecomBlockSize {
return nil, fmt.Errorf("invalid padding size: %d", padding)
}
if padding > len(data) {
From df49f6698a145f619fb687125792953401800e61 Mon Sep 17 00:00:00 2001
From: Yasuhiro Matsumoto
Date: Fri, 20 Feb 2026 20:48:43 +0900
Subject: [PATCH 12/88] Fix
---
pkg/agent/memory.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go
index 6e5d0ba40..70be2fb61 100644
--- a/pkg/agent/memory.go
+++ b/pkg/agent/memory.go
@@ -133,7 +133,6 @@ func (ms *MemoryStore) GetMemoryContext() string {
}
var sb strings.Builder
- sb.WriteString("# Memory\n\n")
if longTerm != "" {
sb.WriteString("## Long-term Memory\n\n")
From 0f70f783bd88c5db875c1ebebe5bde902a96ab27 Mon Sep 17 00:00:00 2001
From: swordkee
Date: Fri, 20 Feb 2026 20:01:22 +0800
Subject: [PATCH 13/88] feat: add wecom and wecomApp test
---
README.fr.md | 84 ++++++++++++++-
README.ja.md | 84 ++++++++++++++-
README.md | 84 ++++++++++++++-
README.pt-br.md | 84 ++++++++++++++-
README.vi.md | 84 ++++++++++++++-
README.zh.md | 87 ++++++++++++++-
config/config.example.json | 2 +
docs/wecom-app-configuration.md | 117 ++++++++++++++++++++
pkg/channels/wecom.go | 132 +++++++++++++++++++++++
pkg/channels/wecom_common.go | 182 --------------------------------
10 files changed, 751 insertions(+), 189 deletions(-)
create mode 100644 docs/wecom-app-configuration.md
delete mode 100644 pkg/channels/wecom_common.go
diff --git a/README.fr.md b/README.fr.md
index 21913f6ba..d49edc5ee 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -262,7 +262,7 @@ Et voilĆ ! Vous avez un assistant IA fonctionnel en 2 minutes.
## š¬ Applications de Chat
-Discutez avec votre PicoClaw via Telegram, Discord, DingTalk ou LINE
+Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
| Canal | Configuration |
| ------------ | -------------------------------------- |
@@ -271,6 +271,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk ou LINE
| **QQ** | Facile (AppID + AppSecret) |
| **DingTalk** | Moyen (identifiants de l'application) |
| **LINE** | Moyen (identifiants + URL de webhook) |
+| **WeCom** | Moyen (CorpID + configuration webhook) |
Telegram (RecommandƩ)
@@ -470,6 +471,87 @@ picoclaw gateway
+
+WeCom (WeChat Work)
+
+PicoClaw prend en charge deux types d'intƩgration WeCom :
+
+**Option 1 : WeCom Bot (Robot Intelligent)** - Configuration plus facile, prend en charge les discussions de groupe
+**Option 2 : WeCom App (Application PersonnalisƩe)** - Plus de fonctionnalitƩs, messagerie proactive
+
+Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour des instructions dƩtaillƩes.
+
+**Configuration Rapide - WeCom Bot :**
+
+**1. CrƩer un bot**
+
+* AccĆ©dez Ć la Console d'Administration WeCom ā Discussion de Groupe ā Ajouter un Bot de Groupe
+* Copiez l'URL du webhook (format : `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
+
+**2. Configurer**
+
+```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_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**Configuration Rapide - WeCom App :**
+
+**1. CrƩer une application**
+
+* AccĆ©dez Ć la Console d'Administration WeCom ā Gestion des Applications ā CrĆ©er une Application
+* Copiez l'**AgentId** et le **Secret**
+* Accédez à la page "Mon Entreprise", copiez le **CorpID**
+
+**2. Configurer la rƩception des messages**
+
+* Dans les dĆ©tails de l'application, cliquez sur "Recevoir les Messages" ā "Configurer l'API"
+* DƩfinissez l'URL sur `http://your-server:18792/webhook/wecom-app`
+* GƩnƩrez le **Token** et l'**EncodingAESKey**
+
+**3. Configurer**
+
+```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_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**4. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+> **Note** : WeCom App nƩcessite l'ouverture du port 18792 pour les callbacks webhook. Utilisez un proxy inverse pour HTTPS en production.
+
+
+
##
Rejoignez le RƩseau Social d'Agents
Connectez PicoClaw au RƩseau Social d'Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intƩgrƩe.
diff --git a/README.ja.md b/README.ja.md
index c0e40883d..793a51101 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -226,7 +226,7 @@ picoclaw agent -m "What is 2+2?"
## š¬ ćć£ććć¢ććŖ
-TelegramćDiscordćQQćDingTalkćLINE ć§ PicoClaw ćØä¼č©±ć§ćć¾ć
+TelegramćDiscordćQQćDingTalkćLINEćWeCom ć§ PicoClaw ćØä¼č©±ć§ćć¾ć
| ćć£ćć« | ć»ććć¢ćć |
|---------|------------|
@@ -235,6 +235,7 @@ TelegramćDiscordćQQćDingTalkćLINE ć§ PicoClaw ćØä¼č©±ć§ćć¾ć
| **QQ** | ē°”åļ¼AppID + AppSecretļ¼ |
| **DingTalk** | ę®éļ¼ć¢ććŖčŖčؼę
å ±ļ¼ |
| **LINE** | ę®éļ¼čŖčؼę
å ± + Webhook URLļ¼ |
+| **WeCom** | ę®éļ¼CorpID + WebhookčØå®ļ¼ |
Telegramļ¼ęØå„Øļ¼
@@ -430,6 +431,87 @@ picoclaw gateway
+
+WeCom (ä¼ę„微俔)
+
+PicoClaw ćÆ2種é”ć® WeCom ēµ±åććµćć¼ććć¦ćć¾ćļ¼
+
+**ćŖćć·ć§ć³1: WeCom Bot (ęŗč½ćććć)** - ē°”åćŖčØå®ćć°ć«ć¼ććć£ćć対åæ
+**ćŖćć·ć§ć³2: WeCom App (čŖä½ć¢ććŖ)** - ććå¤ę©č½ćć¢ćÆćć£ćć”ćć»ć¼ćøć³ć°åƾåæ
+
+詳瓰ćŖčØå®ęé 㯠[WeCom App Configuration Guide](docs/wecom-app-configuration.md) ćåē
§ćć¦ćć ććć
+
+**ćÆć¤ććÆć»ććć¢ćć - WeCom Bot:**
+
+**1. ććććä½ę**
+
+* WeCom ē®”ēć³ć³ć½ć¼ć« ā ć°ć«ć¼ććć£ćć ā ć°ć«ć¼ćććććčæ½å
+* Webhook URL ćć³ćć¼ļ¼å½¢å¼: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`ļ¼
+
+**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_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**ćÆć¤ććÆć»ććć¢ćć - WeCom App:**
+
+**1. ć¢ććŖćä½ę**
+
+* WeCom ē®”ēć³ć³ć½ć¼ć« ā ć¢ććŖē®”ē ā ć¢ććŖćä½ę
+* **AgentId** 㨠**Secret** ćć³ćć¼
+* "ćć¤ä¼ē¤¾" ćć¼ćøć§ **CorpID** ćć³ćć¼
+
+**2. ć”ćć»ć¼ćøåäæ”ćčØå®**
+
+* ć¢ććŖč©³ē“°ć§ "ć”ćć»ć¼ćøćåäæ”" ā "APIćčØå®" ććÆćŖććÆ
+* URL ć `http://your-server:18792/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_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**4. čµ·å**
+
+```bash
+picoclaw gateway
+```
+
+> **注ę**: WeCom App 㯠Webhook ć³ć¼ć«ćććÆēØć«ćć¼ć 18792 ćéę¾ććåæ
č¦ćććć¾ććę¬ēŖē°å¢ć§ćÆ HTTPS ēØć®ćŖćć¼ć¹ćććć·ć使ēØćć¦ćć ććć
+
+
+
## āļø čØå®
čØå®ćć”ć¤ć«: `~/.picoclaw/config.json`
diff --git a/README.md b/README.md
index 468350409..321e8f60c 100644
--- a/README.md
+++ b/README.md
@@ -264,7 +264,7 @@ That's it! You have a working AI assistant in 2 minutes.
## š¬ Chat Apps
-Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
+Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
| Channel | Setup |
| ------------ | ---------------------------------- |
@@ -273,6 +273,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
+| **WeCom** | Medium (CorpID + webhook setup) |
Telegram (Recommended)
@@ -472,6 +473,87 @@ picoclaw gateway
+
+WeCom (ä¼äøå¾®äæ”)
+
+PicoClaw supports two types of WeCom integration:
+
+**Option 1: WeCom Bot (ęŗč½ęŗåØäŗŗ)** - Easier setup, supports group chats
+**Option 2: WeCom App (čŖå»ŗåŗēØ)** - More features, proactive messaging
+
+See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions.
+
+**Quick Setup - WeCom Bot:**
+
+**1. Create a bot**
+
+* 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`)
+
+**2. Configure**
+
+```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_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**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:18792/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_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**4. Run**
+
+```bash
+picoclaw gateway
+```
+
+> **Note**: WeCom App requires opening port 18792 for webhook callbacks. Use a reverse proxy for HTTPS.
+
+
+
##
Join the Agent Social Network
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
diff --git a/README.pt-br.md b/README.pt-br.md
index 44f27813c..a1788d119 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -263,7 +263,7 @@ Pronto! VocĆŖ tem um assistente de IA funcionando em 2 minutos.
## š¬ Integração com Apps de Chat
-Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE.
+Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
| Canal | NĆvel de Configuração |
| --- | --- |
@@ -272,6 +272,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE.
| **QQ** | FƔcil (AppID + AppSecret) |
| **DingTalk** | MƩdio (credenciais do app) |
| **LINE** | MƩdio (credenciais + webhook URL) |
+| **WeCom** | Médio (CorpID + configuração webhook) |
Telegram (Recomendado)
@@ -471,6 +472,87 @@ picoclaw gateway
+
+WeCom (WeChat Work)
+
+O PicoClaw suporta dois tipos de integração WeCom:
+
+**Opção 1: WeCom Bot (RobÓ Inteligente)** - Configuração mais fÔcil, suporta chats em grupo
+**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas
+
+Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para instruções detalhadas.
+
+**Configuração RÔpida - WeCom Bot:**
+
+**1. Criar um bot**
+
+* Acesse o Console de Administração WeCom ā Chat em Grupo ā Adicionar Bot de Grupo
+* Copie a URL do webhook (formato: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
+
+**2. Configurar**
+
+```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_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**Configuração RÔpida - WeCom App:**
+
+**1. Criar um aplicativo**
+
+* Acesse o Console de Administração WeCom ā Gerenciamento de Aplicativos ā Criar Aplicativo
+* Copie o **AgentId** e o **Secret**
+* Acesse a pƔgina "Minha Empresa", copie o **CorpID**
+
+**2. Configurar recebimento de mensagens**
+
+* Nos detalhes do aplicativo, clique em "Receber Mensagens" ā "Configurar API"
+* Defina a URL como `http://your-server:18792/webhook/wecom-app`
+* Gere o **Token** e o **EncodingAESKey**
+
+**3. Configurar**
+
+```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_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**4. Executar**
+
+```bash
+picoclaw gateway
+```
+
+> **Nota**: O WeCom App requer a abertura da porta 18792 para callbacks de webhook. Use um proxy reverso para HTTPS em produção.
+
+
+
##
Junte-se a Rede Social de Agentes
Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma Ćŗnica mensagem via CLI ou qualquer App de Chat integrado.
diff --git a/README.vi.md b/README.vi.md
index 08fa3dccd..5548f88a4 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -243,7 +243,7 @@ Vįŗy lĆ xong! Bįŗ”n ÄĆ£ có mį»t trợ lý AI hoįŗ”t Äį»ng chį» trong 2 p
## š¬ TĆch hợp ứng dỄng Chat
-Trò chuyį»n vį»i PicoClaw qua Telegram, Discord, DingTalk hoįŗ·c LINE.
+Trò chuyį»n vį»i PicoClaw qua Telegram, Discord, DingTalk, LINE hoįŗ·c WeCom.
| KĆŖnh | Mức Äį» thiįŗæt lįŗp |
| --- | --- |
@@ -252,6 +252,7 @@ Trò chuyį»n vį»i PicoClaw qua Telegram, Discord, DingTalk hoįŗ·c LINE.
| **QQ** | Dį»
(AppID + AppSecret) |
| **DingTalk** | Trung bƬnh (app credentials) |
| **LINE** | Trung bƬnh (credentials + webhook URL) |
+| **WeCom** | Trung bình (CorpID + cẄu hình webhook) |
Telegram (Khuyên dùng)
@@ -451,6 +452,87 @@ picoclaw gateway
+
+WeCom (WeChat Work)
+
+PicoClaw hį» trợ hai loįŗ”i tĆch hợp WeCom:
+
+**Tùy chį»n 1: WeCom Bot (Robot ThĆ“ng minh)** - Thiįŗæt lįŗp dį»
dà ng hƔn, hỠtrợ chat nhóm
+**Tùy chį»n 2: WeCom App (Ứng dỄng Tį»± xĆ¢y dį»±ng)** - Nhiį»u tĆnh nÄng hĘ”n, nhįŗÆn tin chį»§ Äį»ng
+
+Xem [Hʰį»ng dįŗ«n Cįŗ„u hƬnh WeCom App](docs/wecom-app-configuration.md) Äį» biįŗæt hʰį»ng dįŗ«n chi tiįŗæt.
+
+**Thiįŗæt lįŗp Nhanh - WeCom Bot:**
+
+**1. Tįŗ”o bot**
+
+* Truy cįŗp Bįŗ£ng Äiį»u khiį»n Quįŗ£n trį» WeCom ā Chat Nhóm ā ThĆŖm Bot Nhóm
+* Sao chĆ©p URL webhook (Äį»nh dįŗ”ng: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
+
+**2. CẄu hình**
+
+```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_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**Thiįŗæt lįŗp Nhanh - WeCom App:**
+
+**1. TẔo ứng dỄng**
+
+* Truy cįŗp Bįŗ£ng Äiį»u khiį»n Quįŗ£n trį» WeCom ā Quįŗ£n lý Ứng dỄng ā Tįŗ”o Ứng dỄng
+* Sao chép **AgentId** và **Secret**
+* Truy cįŗp trang "CĆ“ng ty cį»§a tĆ“i", sao chĆ©p **CorpID**
+
+**2. Cįŗ„u hƬnh nhįŗn tin nhįŗÆn**
+
+* Trong chi tiįŗæt ứng dỄng, nhįŗ„p vĆ o "Nhįŗn Tin nhįŗÆn" ā "Thiįŗæt lįŗp API"
+* Äįŗ·t URL thĆ nh `http://your-server:18792/webhook/wecom-app`
+* Tįŗ”o **Token** vĆ **EncodingAESKey**
+
+**3. CẄu hình**
+
+```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_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**4. Chįŗ”y**
+
+```bash
+picoclaw gateway
+```
+
+> **Lʰu ý**: WeCom App yĆŖu cįŗ§u mį» cį»ng 18792 cho callback webhook. Sį» dỄng proxy ngược cho HTTPS trong mĆ“i trʰį»ng sįŗ£n xuįŗ„t.
+
+
+
##
Tham gia Mįŗ”ng xĆ£ hį»i Agent
Kįŗæt nį»i PicoClaw vį»i Mįŗ”ng xĆ£ hį»i Agent chį» bįŗ±ng cĆ”ch gį»i mį»t tin nhįŗÆn qua CLI hoįŗ·c bįŗ„t kỳ ứng dỄng Chat nĆ o ÄĆ£ tĆch hợp.
diff --git a/README.zh.md b/README.zh.md
index 4827e66ea..d470db033 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -273,14 +273,15 @@ picoclaw agent -m "2+2 ēäŗå ļ¼"
## š¬ č天åŗēØéę (Chat Apps)
-éčæ Telegram, Discord ęééäøęØē PicoClaw 对čÆć
+éčæ Telegram, Discord, ééęä¼äøå¾®äæ”äøęØē PicoClaw 对čÆć
| ęø é | 设置é¾åŗ¦ |
| --- | --- |
| **Telegram** | ē®å (ä»
é token) |
| **Discord** | ē®å (bot token + intents) |
| **QQ** | ē®å (AppID + AppSecret) |
-| **éé (DingTalk)** | äøē (app credentials) |
+| **éé (DingTalk)** | äøē (åŗēØåčÆ) |
+| **ä¼äøå¾®äæ” (WeCom)** | äøē (ä¼äøID + Webhooké
ē½®) |
Telegram (ęØč)
@@ -438,6 +439,88 @@ picoclaw gateway
+
+ä¼äøå¾®äæ” (WeCom)
+
+PicoClaw ęÆęäø¤ē§ä¼äøå¾®äæ”éęę¹å¼ļ¼
+
+**é锹1: ęŗč½ęŗåØäŗŗ (WeCom Bot)** - 设置ę“ē®åļ¼ęÆę群č
+**é锹2: čŖå»ŗåŗēØ (WeCom App)** - åč½ę“äø°åÆļ¼ęÆęäø»åØęØéę¶ęÆ
+
+čÆ¦č§ [ä¼äøå¾®äæ”čŖå»ŗåŗēØé
ē½®ęå](docs/wecom-app-configuration.md)ć
+
+**åæ«é设置 - ęŗč½ęŗåØäŗŗļ¼**
+
+**1. å建ęŗåØäŗŗ**
+
+* åå¾ä¼äøå¾®äæ”ē®”ēåå° ā 群č ā ę·»å 群ęŗåØäŗŗ
+* å¤å¶ Webhook URL (ę ¼å¼: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
+
+**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_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**åæ«é设置 - čŖå»ŗåŗēØļ¼**
+
+**1. å建åŗēØ**
+
+* åå¾ä¼äøå¾®äæ”ē®”ēåå° ā åŗēØē®”ē ā å建åŗēØ
+* å¤å¶ **AgentId** å **Secret**
+* åå¾"ęēä¼äø"锵é¢ļ¼å¤å¶ **CorpID**
+
+**2. é
ē½®ę„ę¶ę¶ęÆ**
+
+* åØåŗēØčƦę
锵ļ¼ē¹å»"ę„ę¶ę¶ęÆ" ā "设置API"
+* 设置 URL 为 `http://your-server:18792/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_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**4. čæč”**
+
+```bash
+picoclaw gateway
+
+```
+
+> **注ę**: čŖå»ŗåŗēØéč¦å¼ę¾ 18792 端å£ēØäŗę„ę¶ Webhook åč°ćēäŗ§ēÆå¢å»ŗč®®ä½æēØåå代ēé
ē½® HTTPSć
+
+
+
##
å å
„ Agent 社交ē½ē»
åŖééčæ CLI ęä»»ä½éęēč天åŗēØåéäøę”ę¶ęÆļ¼å³åÆå° PicoClaw čæę„å° Agent 社交ē½ē»ć
diff --git a/config/config.example.json b/config/config.example.json
index f0c82c2bc..67819688c 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -108,6 +108,7 @@
"allow_from": []
},
"wecom": {
+ "_comment": "WeCom Bot (ęŗč½ęŗåØäŗŗ) - Easier setup, supports group chats",
"enabled": false,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
@@ -119,6 +120,7 @@
"reply_timeout": 5
},
"wecom_app": {
+ "_comment": "WeCom App (čŖå»ŗåŗēØ) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
"enabled": false,
"corp_id": "YOUR_CORP_ID",
"corp_secret": "YOUR_CORP_SECRET",
diff --git a/docs/wecom-app-configuration.md b/docs/wecom-app-configuration.md
new file mode 100644
index 000000000..3b17d37a7
--- /dev/null
+++ b/docs/wecom-app-configuration.md
@@ -0,0 +1,117 @@
+# ä¼äøå¾®äæ”čŖå»ŗåŗēØ (WeCom App) é
ē½®ęå
+
+ę¬ę攣ä»ē»å¦ä½åØ PicoClaw äøé
ē½®ä¼äøå¾®äæ”čŖå»ŗåŗēØ (wecom-app) ééć
+
+## åč½ē¹ę§
+
+| åč½ | ęÆęē¶ę |
+|------|---------|
+| 被åØę„ę¶ę¶ęÆ | ā
|
+| äø»åØåéę¶ęÆ | ā
|
+| ē§č | ā
|
+| 群č | ā |
+
+## é
ē½®ę„éŖ¤
+
+### 1. ä¼äøå¾®äæ”åå°é
ē½®
+
+1. ē»å½ [ä¼äøå¾®äæ”ē®”ēåå°](https://work.weixin.qq.com/wework_admin)
+2. čæå
„"åŗēØē®”ē" ā éę©čŖå»ŗåŗēØ
+3. č®°å½ä»„äøäæ”ęÆļ¼
+ - **AgentId**: åŗēØčƦę
锵ę¾ē¤ŗ
+ - **Secret**: ē¹å»"ę„ē"č·å
+4. čæå
„"ęēä¼äø"锵é¢ļ¼č®°å½ **ä¼äøID** (CorpID)
+
+### 2. ę„ę¶ę¶ęÆé
ē½®
+
+1. åØåŗēØčƦę
锵ļ¼ē¹å»"ę„ę¶ę¶ęÆ"ē"设置APIę„ę¶"
+2. 唫å仄äøäæ”ęÆļ¼
+ - **URL**: `http://your-server:18792/webhook/wecom-app`
+ - **Token**: éęŗēęęčŖå®ä¹ļ¼ēØäŗē¾åéŖčÆļ¼
+ - **EncodingAESKey**: ē¹å»"éęŗēę"ēę43å符ēåÆé„
+3. ē¹å»"äæå"ę¶ļ¼ä¼äøå¾®äæ”ä¼åééŖčÆčÆ·ę±
+
+### 3. PicoClaw é
ē½®
+
+åØ `config.json` äøę·»å 仄äøé
ē½®ļ¼
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx", // ä¼äøID
+ "corp_secret": "xxxxxxxxxxxxxxxxxxxxxxxx", // åŗēØSecret
+ "agent_id": 1000002, // åŗēØAgentId
+ "token": "your_token", // ę„ę¶ę¶ęÆé
ē½®ēToken
+ "encoding_aes_key": "your_encoding_aes_key", // ę„ę¶ę¶ęÆé
ē½®ēEncodingAESKey
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+## åøøč§é®é¢
+
+### 1. åč°URLéŖčÆå¤±č“„
+
+**ēē¶**: ä¼äøå¾®äæ”äæåAPIę„ę¶ę¶ęÆę¶ę示éŖčÆå¤±č“„
+
+**ę£ę„锹**:
+- 甮认ęå”åØé²ē«å¢å·²å¼ę¾ 18792 端å£
+- 甮认 `corp_id`ć`token`ć`encoding_aes_key` é
ē½®ę£ē”®
+- ę„ē PicoClaw ę„åæęÆå¦ę请ę±å°č¾¾
+
+### 2. äøęę¶ęÆč§£åÆå¤±č“„
+
+**ēē¶**: åéäøęę¶ęÆę¶åŗē° `invalid padding size` é误
+
+**åå **: ä¼äøå¾®äæ”使ēØéę åē PKCS7 唫å
ļ¼32åčå大å°ļ¼
+
+**č§£å³**: ē”®äæä½æēØęę°ēę¬ē PicoClawļ¼å·²äæ®å¤ę¤é®é¢ć
+
+### 3. 端å£å²ēŖ
+
+**ēē¶**: åÆåØę¶ę示端å£å·²č¢«å ēØ
+
+**č§£å³**: äæ®ę¹ `webhook_port` äøŗå
¶ä»ē«Æå£ļ¼å¦ 18794
+
+## ęęÆē»č
+
+### å åÆē®ę³
+
+- **ē®ę³**: AES-256-CBC
+- **åÆé„**: EncodingAESKey Base64č§£ē åē32åč
+- **IV**: AESKeyēå16åč
+- **唫å
**: PKCS7ļ¼å大å°äøŗ32åčļ¼éę å16åčļ¼
+- **ę¶ęÆę ¼å¼**: XML
+
+### ę¶ęÆē»ę
+
+č§£åÆåēę¶ęÆę ¼å¼ļ¼
+```
+random(16B) + msg_len(4B) + msg + receiveid
+```
+
+å
¶äø `receiveid` 对äŗčŖå»ŗåŗēØęÆ `corp_id`ć
+
+## č°čÆ
+
+åÆēØč°čÆęØ”å¼ę„ē详ē»ę„åæļ¼
+
+```bash
+picoclaw gateway --debug
+```
+
+å
³é®ę„åæę čÆļ¼
+- `wecom_app`: WeCom App ééēøå
³ę„åæ
+- `wecom_common`: å åÆč§£åÆēøå
³ę„åæ
+
+## åčę攣
+
+- [ä¼äøå¾®äæ”å®ę¹ę攣 - ę„ę¶ę¶ęÆ](https://developer.work.weixin.qq.com/document/path/96211)
+- [ä¼äøå¾®äæ”å®ę¹å č§£åÆåŗ](https://github.com/sbzhu/weworkapi_golang)
diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go
index 404949e07..064568243 100644
--- a/pkg/channels/wecom.go
+++ b/pkg/channels/wecom.go
@@ -7,11 +7,17 @@ package channels
import (
"bytes"
"context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/sha1"
+ "encoding/base64"
+ "encoding/binary"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
+ "sort"
"strings"
"sync"
"time"
@@ -470,3 +476,129 @@ func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
+
+// WeCom common utilities for both WeCom Bot and WeCom App
+// The following functions were moved from wecom_common.go
+
+// WeComVerifySignature verifies the message signature for WeCom
+// This is a common function used by both WeCom Bot and WeCom App
+func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
+ if token == "" {
+ return true // Skip verification if token is not set
+ }
+
+ // Sort parameters
+ params := []string{token, timestamp, nonce, msgEncrypt}
+ sort.Strings(params)
+
+ // Concatenate
+ str := strings.Join(params, "")
+
+ // SHA1 hash
+ hash := sha1.Sum([]byte(str))
+ expectedSignature := fmt.Sprintf("%x", hash)
+
+ return expectedSignature == msgSignature
+}
+
+// WeComDecryptMessage decrypts the encrypted message using AES
+// This is a common function used by both WeCom Bot and WeCom App
+// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
+func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
+ return WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
+}
+
+// WeComDecryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
+// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
+func WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
+ if encodingAESKey == "" {
+ // No encryption, return as is (base64 decode)
+ decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
+ if err != nil {
+ return "", err
+ }
+ return string(decoded), nil
+ }
+
+ // Decode AES key (base64)
+ aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
+ if err != nil {
+ return "", fmt.Errorf("failed to decode AES key: %w", err)
+ }
+
+ // Decode encrypted message
+ cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
+ if err != nil {
+ return "", fmt.Errorf("failed to decode message: %w", err)
+ }
+
+ // AES decrypt
+ block, err := aes.NewCipher(aesKey)
+ if err != nil {
+ return "", fmt.Errorf("failed to create cipher: %w", err)
+ }
+
+ if len(cipherText) < aes.BlockSize {
+ return "", fmt.Errorf("ciphertext too short")
+ }
+
+ // IV is the first 16 bytes of AESKey
+ iv := aesKey[:aes.BlockSize]
+ mode := cipher.NewCBCDecrypter(block, iv)
+ plainText := make([]byte, len(cipherText))
+ mode.CryptBlocks(plainText, cipherText)
+
+ // Remove PKCS7 padding
+ plainText, err = pkcs7UnpadWeCom(plainText)
+ if err != nil {
+ return "", fmt.Errorf("failed to unpad: %w", err)
+ }
+
+ // Parse message structure
+ // Format: random(16) + msg_len(4) + msg + receiveid
+ if len(plainText) < 20 {
+ return "", fmt.Errorf("decrypted message too short")
+ }
+
+ msgLen := binary.BigEndian.Uint32(plainText[16:20])
+ if int(msgLen) > len(plainText)-20 {
+ return "", fmt.Errorf("invalid message length")
+ }
+
+ msg := plainText[20 : 20+msgLen]
+
+ // Verify receiveid if provided
+ if receiveid != "" && len(plainText) > 20+int(msgLen) {
+ actualReceiveID := string(plainText[20+msgLen:])
+ if actualReceiveID != receiveid {
+ return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
+ }
+ }
+
+ return string(msg), nil
+}
+
+// pkcs7UnpadWeCom removes PKCS7 padding with validation
+// WeCom uses block size of 32 (not standard AES block size of 16)
+const wecomBlockSize = 32
+
+func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
+ if len(data) == 0 {
+ return data, nil
+ }
+ padding := int(data[len(data)-1])
+ // WeCom uses 32-byte block size for PKCS7 padding
+ if padding == 0 || padding > wecomBlockSize {
+ return nil, fmt.Errorf("invalid padding size: %d", padding)
+ }
+ if padding > len(data) {
+ return nil, fmt.Errorf("padding size larger than data")
+ }
+ // Verify all padding bytes
+ for i := 0; i < padding; i++ {
+ if data[len(data)-1-i] != byte(padding) {
+ return nil, fmt.Errorf("invalid padding byte at position %d", i)
+ }
+ }
+ return data[:len(data)-padding], nil
+}
diff --git a/pkg/channels/wecom_common.go b/pkg/channels/wecom_common.go
deleted file mode 100644
index 3e3908622..000000000
--- a/pkg/channels/wecom_common.go
+++ /dev/null
@@ -1,182 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// WeCom common utilities for both WeCom Bot and WeCom App
-
-package channels
-
-import (
- "crypto/aes"
- "crypto/cipher"
- "crypto/sha1"
- "encoding/base64"
- "encoding/binary"
- "fmt"
- "sort"
- "strings"
-
- "github.com/sipeed/picoclaw/pkg/logger"
-)
-
-// WeComVerifySignature verifies the message signature for WeCom
-// This is a common function used by both WeCom Bot and WeCom App
-func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
- if token == "" {
- return true // Skip verification if token is not set
- }
-
- // Sort parameters
- params := []string{token, timestamp, nonce, msgEncrypt}
- sort.Strings(params)
-
- // Concatenate
- str := strings.Join(params, "")
-
- // SHA1 hash
- hash := sha1.Sum([]byte(str))
- expectedSignature := fmt.Sprintf("%x", hash)
-
- return expectedSignature == msgSignature
-}
-
-// WeComDecryptMessage decrypts the encrypted message using AES
-// This is a common function used by both WeCom Bot and WeCom App
-// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
-func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
- return WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
-}
-
-// WeComDecryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
-// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
-func WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
- logger.DebugCF("wecom_common", "Starting decryption", map[string]interface{}{
- "encodingAESKey_len": len(encodingAESKey),
- "receiveid": receiveid,
- "encryptedMsg_len": len(encryptedMsg),
- })
-
- if encodingAESKey == "" {
- // No encryption, return as is (base64 decode)
- decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
- if err != nil {
- return "", err
- }
- return string(decoded), nil
- }
-
- // Decode AES key (base64)
- aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
- if err != nil {
- logger.ErrorCF("wecom_common", "Failed to decode AES key", map[string]interface{}{
- "error": err.Error(),
- "key": encodingAESKey,
- })
- return "", fmt.Errorf("failed to decode AES key: %w", err)
- }
- logger.DebugCF("wecom_common", "AES key decoded", map[string]interface{}{
- "key_len": len(aesKey),
- })
-
- // Decode encrypted message
- cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
- if err != nil {
- logger.ErrorCF("wecom_common", "Failed to decode message", map[string]interface{}{
- "error": err.Error(),
- })
- return "", fmt.Errorf("failed to decode message: %w", err)
- }
- logger.DebugCF("wecom_common", "Message decoded", map[string]interface{}{
- "cipher_len": len(cipherText),
- })
-
- // AES decrypt
- block, err := aes.NewCipher(aesKey)
- if err != nil {
- return "", fmt.Errorf("failed to create cipher: %w", err)
- }
-
- if len(cipherText) < aes.BlockSize {
- return "", fmt.Errorf("ciphertext too short: %d < %d", len(cipherText), aes.BlockSize)
- }
-
- // IV is the first 16 bytes of AESKey
- iv := aesKey[:aes.BlockSize]
- mode := cipher.NewCBCDecrypter(block, iv)
- plainText := make([]byte, len(cipherText))
- mode.CryptBlocks(plainText, cipherText)
-
- // Remove PKCS7 padding
- unpaddedText, err := pkcs7UnpadWeCom(plainText)
- if err != nil {
- lastByte := -1
- if len(plainText) > 0 {
- lastByte = int(plainText[len(plainText)-1])
- }
- logger.ErrorCF("wecom_common", "PKCS7 unpad failed", map[string]interface{}{
- "error": err.Error(),
- "plain_len": len(plainText),
- "last_byte": lastByte,
- })
- return "", fmt.Errorf("failed to unpad: %w", err)
- }
- plainText = unpaddedText
-
- // Parse message structure
- // Format: random(16) + msg_len(4) + msg + receiveid
- if len(plainText) < 20 {
- return "", fmt.Errorf("decrypted message too short")
- }
-
- msgLen := binary.BigEndian.Uint32(plainText[16:20])
- logger.DebugCF("wecom_common", "Message structure parsed", map[string]interface{}{
- "msg_len": msgLen,
- "plain_len": len(plainText),
- "total_expected": 20 + int(msgLen),
- })
-
- if int(msgLen) > len(plainText)-20 {
- return "", fmt.Errorf("invalid message length: %d > %d", msgLen, len(plainText)-20)
- }
-
- msg := plainText[20 : 20+msgLen]
-
- // Verify receiveid if provided
- if receiveid != "" && len(plainText) > 20+int(msgLen) {
- actualReceiveID := string(plainText[20+msgLen:])
- logger.DebugCF("wecom_common", "ReceiveID verification", map[string]interface{}{
- "expected": receiveid,
- "actual": actualReceiveID,
- })
- if actualReceiveID != receiveid {
- return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
- }
- }
-
- logger.DebugCF("wecom_common", "Decryption successful", map[string]interface{}{
- "msg_len": len(msg),
- })
- return string(msg), nil
-}
-
-// pkcs7UnpadWeCom removes PKCS7 padding with validation
-// WeCom uses block size of 32 (not standard AES block size of 16)
-const wecomBlockSize = 32
-
-func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
- if len(data) == 0 {
- return data, nil
- }
- padding := int(data[len(data)-1])
- // WeCom uses 32-byte block size for PKCS7 padding
- if padding == 0 || padding > wecomBlockSize {
- return nil, fmt.Errorf("invalid padding size: %d", padding)
- }
- if padding > len(data) {
- return nil, fmt.Errorf("padding size larger than data")
- }
- // Verify all padding bytes
- for i := 0; i < padding; i++ {
- if data[len(data)-1-i] != byte(padding) {
- return nil, fmt.Errorf("invalid padding byte at position %d", i)
- }
- }
- return data[:len(data)-padding], nil
-}
From 838a69085bafadd90175d8b1a52aa3547084222b Mon Sep 17 00:00:00 2001
From: esubaalew
Date: Fri, 20 Feb 2026 18:23:22 +0300
Subject: [PATCH 14/88] fix: correct docs misalignment across translations and
guides
- Fix DingTalk section referencing "QQ numbers" instead of DingTalk user IDs
- Fix Anthropic example showing OAuth when code uses paste-token auth
- Replace OpenClaw references in ANTIGRAVITY_AUTH.md with actual PicoClaw paths and Go patterns
- Fix auth file path from auth-profiles.json to auth.json in ANTIGRAVITY_USAGE.md
- Remove non-existent approval tool from tools_configuration.md, add skills tool docs
- Update Quick Start configs in fr/pt-br/vi/ja translations to use model_list format
- Fix allowFrom camelCase to allow_from in fr/pt-br translations
- Fix camelCase config keys in ja translation
- Update zh/ja web search config from old flat format to brave/duckduckgo
- Fix broken ClawdChat link and trailing commas in zh translation
- Add missing qwen/cerebras providers to fr/pt-br/vi translation tables
- Add missing protocol prefixes to migration guide
- Fix typos in community roadmap
---
README.fr.md | 31 +-
README.ja.md | 61 ++-
README.md | 8 +-
README.pt-br.md | 29 +-
README.vi.md | 40 +-
README.zh.md | 33 +-
docs/ANTIGRAVITY_AUTH.md | 435 ++++++----------------
docs/ANTIGRAVITY_USAGE.md | 10 +-
docs/design/provider-refactoring-tests.md | 9 +-
docs/migration/model-list-migration.md | 8 +
docs/picoclaw_community_roadmap_260216.md | 4 +-
docs/tools_configuration.md | 53 ++-
12 files changed, 281 insertions(+), 440 deletions(-)
diff --git a/README.fr.md b/README.fr.md
index d49edc5ee..7199f7098 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -212,19 +212,24 @@ picoclaw onboard
```json
{
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ],
"agents": {
"defaults": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
+ "model": "gpt4"
}
},
- "providers": {
- "openrouter": {
- "api_key": "xxx",
- "api_base": "https://openrouter.ai/api/v1"
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "VOTRE_TOKEN_BOT",
+ "allow_from": ["VOTRE_USER_ID"]
}
},
"tools": {
@@ -290,7 +295,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
"telegram": {
"enabled": true,
"token": "VOTRE_TOKEN_BOT",
- "allowFrom": ["VOTRE_USER_ID"]
+ "allow_from": ["VOTRE_USER_ID"]
}
}
}
@@ -333,7 +338,7 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "VOTRE_TOKEN_BOT",
- "allowFrom": ["VOTRE_USER_ID"]
+ "allow_from": ["VOTRE_USER_ID"]
}
}
}
@@ -765,6 +770,8 @@ Le sous-agent a accĆØs aux outils (message, web_search, etc.) et peut communique
| `anthropic` (Ć tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` (Ć tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `deepseek` (Ć tester) | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (Alibaba Qwen) | [dashscope.aliyuncs.com](https://dashscope.aliyuncs.com/compatible-mode/v1) |
+| `cerebras` | LLM (Cerebras) | [cerebras.ai](https://api.cerebras.ai/v1) |
| `groq` | LLM + **Transcription vocale** (Whisper) | [console.groq.com](https://console.groq.com) |
@@ -1087,7 +1094,7 @@ Ajoutez la clƩ dans `~/.picoclaw/config.json` si vous utilisez Brave :
"tools": {
"web": {
"brave": {
- "enabled": true,
+ "enabled": false,
"api_key": "VOTRE_CLE_API_BRAVE",
"max_results": 5
},
diff --git a/README.ja.md b/README.ja.md
index 793a51101..bb0bdfb28 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -174,35 +174,25 @@ picoclaw onboard
```json
{
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ],
"agents": {
"defaults": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
+ "model": "gpt4"
}
},
- "providers": {
- "openrouter": {
- "api_key": "xxx",
- "api_base": "https://openrouter.ai/api/v1"
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_TELEGRAM_BOT_TOKEN",
+ "allow_from": []
}
- },
- "tools": {
- "web": {
- "search": {
- "api_key": "YOUR_BRAVE_API_KEY",
- "max_results": 5
- }
- },
- "cron": {
- "exec_timeout_minutes": 5
- }
- },
- "heartbeat": {
- "enabled": true,
- "interval": 30
}
}
```
@@ -214,7 +204,7 @@ picoclaw onboard
> **注ę**: å®å
ØćŖčØå®ćć³ćć¬ć¼ć㯠`config.example.json` ćåē
§ćć¦ćć ććć
-**3. ćć£ćć**
+**4. ćć£ćć**
```bash
picoclaw agent -m "What is 2+2?"
@@ -764,10 +754,10 @@ HEARTBEAT_OK åæē ć¦ć¼ć¶ć¼ćē“ę„ēµęćåćåć
},
"providers": {
"openrouter": {
- "apiKey": "sk-or-v1-xxx"
+ "api_key": "sk-or-v1-xxx"
},
"groq": {
- "apiKey": "gsk_xxx"
+ "api_key": "gsk_xxx"
}
},
"channels": {
@@ -786,17 +776,17 @@ HEARTBEAT_OK åæē ć¦ć¼ć¶ć¼ćē“ę„ēµęćåćåć
},
"feishu": {
"enabled": false,
- "appId": "cli_xxx",
- "appSecret": "xxx",
- "encryptKey": "",
- "verificationToken": "",
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
"allow_from": []
}
},
"tools": {
"web": {
"search": {
- "apiKey": "BSA..."
+ "api_key": "BSA..."
}
},
"cron": {
@@ -1001,9 +991,14 @@ Web ę¤ē“¢ćęå¹ć«ććć«ćÆļ¼
{
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": true,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
}
}
diff --git a/README.md b/README.md
index a82a9ad32..d7d8be80b 100644
--- a/README.md
+++ b/README.md
@@ -418,7 +418,7 @@ picoclaw gateway
}
```
-> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
+> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.
**3. Run**
@@ -867,15 +867,15 @@ This design also enables **multi-agent support** with flexible provider selectio
}
```
-**Anthropic (with OAuth)**
+**Anthropic (with API key)**
```json
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
- "auth_method": "oauth"
+ "api_key": "sk-ant-your-key"
}
```
-> Run `picoclaw auth login --provider anthropic` to set up OAuth credentials.
+> Run `picoclaw auth login --provider anthropic` to paste your API token.
**Ollama (local)**
```json
diff --git a/README.pt-br.md b/README.pt-br.md
index a1788d119..ec8fe8e1c 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -213,19 +213,17 @@ picoclaw onboard
```json
{
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ],
"agents": {
"defaults": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
- }
- },
- "providers": {
- "openrouter": {
- "api_key": "xxx",
- "api_base": "https://openrouter.ai/api/v1"
+ "model": "gpt4"
}
},
"tools": {
@@ -291,7 +289,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"]
}
}
}
@@ -334,7 +332,7 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"]
}
}
}
@@ -766,6 +764,8 @@ O subagente tem acesso Ć s ferramentas (message, web_search, etc.) e pode se com
| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | Alibaba Qwen | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `cerebras` | Cerebras | [cerebras.ai](https://cerebras.ai) |
| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
@@ -1088,7 +1088,7 @@ Adicione a key em `~/.picoclaw/config.json` se usar o Brave:
"tools": {
"web": {
"brave": {
- "enabled": true,
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
@@ -1119,3 +1119,4 @@ Isso acontece quando outra instância do bot estÔ em execução. Certifique-se
| **Zhipu** | 200K tokens/mês | Melhor para usuÔrios chineses |
| **Brave Search** | 2000 consultas/mĆŖs | Funcionalidade de busca web |
| **Groq** | Plano gratuito disponĆvel | InferĆŖncia ultra-rĆ”pida (Llama, Mixtral) |
+| **Cerebras** | Plano gratuito disponĆvel | InferĆŖncia ultra-rĆ”pida (Llama 3.3 70B) |
diff --git a/README.vi.md b/README.vi.md
index 5548f88a4..161842933 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -193,32 +193,24 @@ picoclaw onboard
```json
{
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ],
"agents": {
"defaults": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
+ "model": "gpt4"
}
},
- "providers": {
- "openrouter": {
- "api_key": "xxx",
- "api_base": "https://openrouter.ai/api/v1"
- }
- },
- "tools": {
- "web": {
- "brave": {
- "enabled": false,
- "api_key": "YOUR_BRAVE_API_KEY",
- "max_results": 5
- },
- "duckduckgo": {
- "enabled": true,
- "max_results": 5
- }
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_TELEGRAM_BOT_TOKEN",
+ "allow_from": []
}
}
}
@@ -747,6 +739,8 @@ Subagent có quyį»n truy cįŗp cĆ”c cĆ“ng cỄ (message, web_search, v.v.) vĆ
| `openai` (Äang thį» nghiį»m) | LLM (GPT trį»±c tiįŗæp) | [platform.openai.com](https://platform.openai.com) |
| `deepseek` (Äang thį» nghiį»m) | LLM (DeepSeek trį»±c tiįŗæp) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **Chuyį»n giį»ng nói** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `qwen` | LLM (Qwen trį»±c tiįŗæp) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `cerebras` | LLM (Cerebras trį»±c tiįŗæp) | [cerebras.ai](https://cerebras.ai) |
CẄu hình Zhipu
@@ -1065,7 +1059,7 @@ Thêm key và o `~/.picoclaw/config.json` nếu dùng Brave:
"tools": {
"web": {
"brave": {
- "enabled": true,
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
diff --git a/README.zh.md b/README.zh.md
index d470db033..0989770ca 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -238,9 +238,14 @@ picoclaw onboard
],
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
},
"cron": {
@@ -345,7 +350,8 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"],
+ "mention_only": false
}
}
}
@@ -525,7 +531,7 @@ picoclaw gateway
åŖééčæ CLI ęä»»ä½éęēč天åŗēØåéäøę”ę¶ęÆļ¼å³åÆå° PicoClaw čæę„å° Agent 社交ē½ē»ć
-**é
读 `https://clawdchat.ai/skill.md` å¹¶ęē
§čÆ“ęå å
„ [ClawdChat.ai**](https://clawdchat.ai)
+**é
读 `https://clawdchat.ai/skill.md` å¹¶ęē
§čÆ“ęå å
„ [ClawdChat.ai](https://clawdchat.ai)
## āļø é
置详解
@@ -857,8 +863,8 @@ Agent 读å HEARTBEAT.md
"zhipu": {
"api_key": "Your API Key",
"api_base": "https://open.bigmodel.cn/api/paas/v4"
- },
- },
+ }
+ }
}
```
@@ -921,8 +927,14 @@ picoclaw agent -m "ä½ å„½"
},
"tools": {
"web": {
- "search": {
- "api_key": "BSA..."
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
},
"cron": {
@@ -989,9 +1001,14 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
{
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
}
}
diff --git a/docs/ANTIGRAVITY_AUTH.md b/docs/ANTIGRAVITY_AUTH.md
index 5d68de427..89261d899 100644
--- a/docs/ANTIGRAVITY_AUTH.md
+++ b/docs/ANTIGRAVITY_AUTH.md
@@ -378,7 +378,7 @@ const antigravityPlugin = {
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
configSchema: emptyPluginConfigSchema(),
- register(api: OpenClawPluginApi) {
+ register(api: PicoClawPluginApi) {
api.registerProvider({
id: "google-antigravity",
label: "Google Antigravity",
@@ -405,7 +405,7 @@ const antigravityPlugin = {
```typescript
type ProviderAuthContext = {
- config: OpenClawConfig;
+ config: PicoClawConfig;
agentDir?: string;
workspaceDir?: string;
prompter: WizardPrompter; // UI prompts/notifications
@@ -426,7 +426,7 @@ type ProviderAuthResult = {
profileId: string;
credential: AuthProfileCredential;
}>;
- configPatch?: Partial;
+ configPatch?: Partial;
defaultModel?: string;
notes?: string[];
};
@@ -438,10 +438,9 @@ type ProviderAuthResult = {
### 1. Required Environment/Dependencies
-- Node.js ā„ 22
-- OpenClaw plugin-sdk
-- crypto module (built-in)
-- http module (built-in)
+- Go ā„ 1.21
+- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`)
+- `crypto` and `net/http` standard library packages
### 2. Required Headers for API Calls
@@ -572,36 +571,40 @@ Each SSE message (`data: {...}`) is wrapped in a `response` field:
## Configuration
-### openclaw.json Configuration
+### config.json Configuration
-```json5
+```json
{
- agents: {
- defaults: {
- model: {
- primary: "google-antigravity/claude-opus-4-6-thinking",
- },
- },
- },
+ "model_list": [
+ {
+ "model_name": "gemini-flash",
+ "model": "antigravity/gemini-3-flash",
+ "auth_method": "oauth"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gemini-flash"
+ }
+ }
}
```
### Auth Profile Storage
-Auth profiles are stored in `~/.openclaw/agent/auth-profiles.json`:
+Auth profiles are stored in `~/.picoclaw/auth.json`:
```json
{
- "version": 1,
- "profiles": {
- "google-antigravity:user@example.com": {
- "type": "oauth",
+ "credentials": {
+ "google-antigravity": {
+ "access_token": "ya29...",
+ "refresh_token": "1//...",
+ "expires_at": "2026-01-01T00:00:00Z",
"provider": "google-antigravity",
- "access": "ya29...",
- "refresh": "1//...",
- "expires": 1704067200000,
+ "auth_method": "oauth",
"email": "user@example.com",
- "projectId": "my-project-id"
+ "project_id": "my-project-id"
}
}
}
@@ -611,277 +614,85 @@ Auth profiles are stored in `~/.openclaw/agent/auth-profiles.json`:
## Creating a New Provider in PicoClaw
+PicoClaw providers are implemented as Go packages under `pkg/providers/`. To add a new provider:
+
### Step-by-Step Implementation
-#### 1. Create Plugin Structure
+#### 1. Create Provider File
+
+Create a new Go file in `pkg/providers/`:
```
-extensions/
-āāā your-provider-auth/
- āāā openclaw.plugin.json
- āāā package.json
- āāā README.md
- āāā index.ts
+pkg/providers/
+āāā your_provider.go
```
-#### 2. Define Plugin Manifest
+#### 2. Implement the Provider Interface
-**openclaw.plugin.json:**
-```json
-{
- "id": "your-provider-auth",
- "providers": ["your-provider"],
- "configSchema": {
- "type": "object",
- "additionalProperties": false,
- "properties": {}
- }
+Your provider must implement the `Provider` interface defined in `pkg/providers/types.go`:
+
+```go
+package providers
+
+type YourProvider struct {
+ apiKey string
+ apiBase string
}
-```
-**package.json:**
-```json
-{
- "name": "@openclaw/your-provider-auth",
- "version": "1.0.0",
- "private": true,
- "description": "Your Provider OAuth plugin",
- "type": "module"
-}
-```
-
-#### 3. Implement OAuth Flow
-
-```typescript
-import {
- buildOauthProviderAuthResult,
- emptyPluginConfigSchema,
- type OpenClawPluginApi,
- type ProviderAuthContext,
-} from "openclaw/plugin-sdk";
-
-const YOUR_CLIENT_ID = "your-client-id";
-const YOUR_CLIENT_SECRET = "your-client-secret";
-const AUTH_URL = "https://provider.com/oauth/authorize";
-const TOKEN_URL = "https://provider.com/oauth/token";
-const REDIRECT_URI = "http://localhost:PORT/oauth-callback";
-
-async function loginYourProvider(params: {
- isRemote: boolean;
- openUrl: (url: string) => Promise;
- prompt: (message: string) => Promise;
- note: (message: string, title?: string) => Promise;
- log: (message: string) => void;
- progress: { update: (msg: string) => void; stop: (msg?: string) => void };
-}) {
- // 1. Generate PKCE
- const { verifier, challenge } = generatePkce();
- const state = randomBytes(16).toString("hex");
-
- // 2. Build auth URL
- const authUrl = buildAuthUrl({ challenge, state });
-
- // 3. Start callback server (if not remote)
- const callbackServer = !params.isRemote
- ? await startCallbackServer({ timeoutMs: 5 * 60 * 1000 })
- : null;
-
- // 4. Open browser or show URL
- if (callbackServer) {
- await params.openUrl(authUrl);
- const callback = await callbackServer.waitForCallback();
- code = callback.searchParams.get("code");
- } else {
- await params.note(`Auth URL: ${authUrl}`, "OAuth");
- const input = await params.prompt("Paste redirect URL:");
- const parsed = parseCallbackInput(input);
- code = parsed.code;
- }
-
- // 5. Exchange code for tokens
- const tokens = await exchangeCode({ code, verifier });
-
- // 6. Fetch additional user data
- const email = await fetchUserEmail(tokens.access);
-
- return { ...tokens, email };
-}
-```
-
-#### 4. Register Provider
-
-```typescript
-const yourProviderPlugin = {
- id: "your-provider-auth",
- name: "Your Provider Auth",
- description: "OAuth for Your Provider",
- configSchema: emptyPluginConfigSchema(),
-
- register(api: OpenClawPluginApi) {
- api.registerProvider({
- id: "your-provider",
- label: "Your Provider",
- docsPath: "/providers/models",
- aliases: ["yp"],
-
- auth: [
- {
- id: "oauth",
- label: "OAuth Login",
- hint: "Browser-based authentication",
- kind: "oauth",
-
- run: async (ctx: ProviderAuthContext) => {
- const spin = ctx.prompter.progress("Starting OAuth...");
-
- try {
- const result = await loginYourProvider({
- isRemote: ctx.isRemote,
- openUrl: ctx.openUrl,
- prompt: async (msg) => String(await ctx.prompter.text({ message: msg })),
- note: ctx.prompter.note,
- log: (msg) => ctx.runtime.log(msg),
- progress: spin,
- });
-
- return buildOauthProviderAuthResult({
- providerId: "your-provider",
- defaultModel: "your-provider/model-name",
- access: result.access,
- refresh: result.refresh,
- expires: result.expires,
- email: result.email,
- notes: ["Provider-specific notes"],
- });
- } catch (err) {
- spin.stop("OAuth failed");
- throw err;
- }
- },
- },
- ],
- });
- },
-};
-
-export default yourProviderPlugin;
-```
-
-#### 5. Implement Usage Tracking (Optional)
-
-```typescript
-// src/infra/provider-usage.fetch.your-provider.ts
-export async function fetchYourProviderUsage(
- token: string,
- timeoutMs: number,
- fetchFn: typeof fetch
-): Promise {
- // Fetch usage data from provider API
- const response = await fetchFn("https://api.provider.com/usage", {
- headers: { Authorization: `Bearer ${token}` },
- });
-
- const data = await response.json();
-
- return {
- provider: "your-provider",
- displayName: "Your Provider",
- windows: [
- { label: "Credits", usedPercent: data.usedPercent },
- ],
- plan: data.planName,
- };
-}
-```
-
-#### 6. Register Usage Fetcher
-
-```typescript
-// src/infra/provider-usage.load.ts
-case "your-provider":
- return await fetchYourProviderUsage(auth.token, timeoutMs, fetchFn);
-```
-
-#### 7. Add Provider to Type Definitions
-
-```typescript
-// src/infra/provider-usage.types.ts
-export type SupportedProvider =
- | "anthropic"
- | "github-copilot"
- | "google-gemini-cli"
- | "google-antigravity"
- | "your-provider" // Add here
- | "minimax"
- | "openai-codex";
-```
-
-#### 8. Add Auth Choice Handler
-
-```typescript
-// src/commands/auth-choice.apply.your-provider.ts
-import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
-
-export async function applyAuthChoiceYourProvider(
- params: ApplyAuthChoiceParams
-): Promise {
- return await applyAuthChoicePluginProvider(params, {
- authChoice: "your-provider",
- pluginId: "your-provider-auth",
- providerId: "your-provider",
- methodId: "oauth",
- label: "Your Provider",
- });
-}
-```
-
-#### 9. Export from Main Index
-
-```typescript
-// src/commands/auth-choice.apply.ts
-import { applyAuthChoiceYourProvider } from "./auth-choice.apply.your-provider.js";
-
-// In the switch statement:
-case "your-provider":
- return await applyAuthChoiceYourProvider(params);
-```
-
-### Helper Utilities
-
-#### PKCE Generation
-```typescript
-function generatePkce(): { verifier: string; challenge: string } {
- const verifier = randomBytes(32).toString("hex");
- const challenge = createHash("sha256").update(verifier).digest("base64url");
- return { verifier, challenge };
-}
-```
-
-#### Callback Server
-```typescript
-async function startCallbackServer(params: { timeoutMs: number }) {
- const port = 51121; // Your port
-
- const server = createServer((request, response) => {
- const url = new URL(request.url!, `http://localhost:${port}`);
-
- if (url.pathname === "/oauth-callback") {
- response.writeHead(200, { "Content-Type": "text/html" });
- response.end("Authentication complete
");
- resolveCallback(url);
- server.close();
+func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider {
+ if apiBase == "" {
+ apiBase = "https://api.your-provider.com/v1"
}
- });
-
- await new Promise((resolve, reject) => {
- server.listen(port, "127.0.0.1", resolve);
- server.once("error", reject);
- });
-
- return {
- waitForCallback: () => callbackPromise,
- close: () => new Promise((resolve) => server.close(resolve)),
- };
+ return &YourProvider{apiKey: apiKey, apiBase: apiBase}
+}
+
+func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error {
+ // Implement chat completion with streaming
+}
+```
+
+#### 3. Register in the Factory
+
+Add your provider to the protocol switch in `pkg/providers/factory.go`:
+
+```go
+case "your-provider":
+ return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
+```
+
+#### 4. Add Default Config (Optional)
+
+Add a default entry in `pkg/config/defaults.go`:
+
+```go
+{
+ ModelName: "your-model",
+ Model: "your-provider/model-name",
+ APIKey: "",
+},
+```
+
+#### 5. Add Auth Support (Optional)
+
+If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`:
+
+```go
+case "your-provider":
+ authLoginYourProvider()
+```
+
+#### 6. Configure via `config.json`
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "your-model",
+ "model": "your-provider/model-name",
+ "api_key": "your-api-key",
+ "api_base": "https://api.your-provider.com/v1"
+ }
+ ]
}
```
@@ -892,33 +703,27 @@ async function startCallbackServer(params: { timeoutMs: number }) {
### CLI Commands
```bash
-# Enable the plugin
-openclaw plugins enable your-provider-auth
+# Authenticate with a provider
+picoclaw auth login --provider your-provider
-# Restart gateway
-openclaw gateway restart
+# List models (for Antigravity)
+picoclaw auth models
-# Authenticate
-openclaw models auth login --provider your-provider --set-default
+# Start the gateway
+picoclaw gateway
-# List models
-openclaw models list
-
-# Set model
-openclaw models set your-provider/model-name
-
-# Check usage
-openclaw models usage
+# Run an agent with a specific model
+picoclaw agent -m "Hello" --model your-model
```
### Environment Variables for Testing
```bash
-# Test specific providers only
-export OPENCLAW_LIVE_PROVIDERS="your-provider,google-antigravity"
+# Override default model
+export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
-# Test with specific models
-export OPENCLAW_LIVE_GATEWAY_MODELS="your-provider/model-name"
+# Override provider settings
+export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
```
---
@@ -926,16 +731,16 @@ export OPENCLAW_LIVE_GATEWAY_MODELS="your-provider/model-name"
## References
- **Source Files:**
- - `extensions/google-antigravity-auth/index.ts` - Full OAuth implementation
- - `src/infra/provider-usage.fetch.antigravity.ts` - Usage fetching
- - `src/agents/pi-embedded-runner/google.ts` - Model sanitization
- - `src/agents/model-forward-compat.ts` - Forward compatibility
- - `src/plugin-sdk/provider-auth-result.ts` - Auth result builder
- - `src/plugins/types.ts` - Plugin type definitions
+ - `pkg/providers/antigravity_provider.go` - Antigravity provider implementation
+ - `pkg/auth/oauth.go` - OAuth flow implementation
+ - `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`)
+ - `pkg/providers/factory.go` - Provider factory and protocol routing
+ - `pkg/providers/types.go` - Provider interface definitions
+ - `cmd/picoclaw/cmd_auth.go` - Auth CLI commands
- **Documentation:**
- - `docs/concepts/model-providers.md` - Provider overview
- - `docs/concepts/usage-tracking.md` - Usage tracking
+ - `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide
+ - `docs/migration/model-list-migration.md` - Migration guide
---
@@ -987,7 +792,7 @@ Some models might show up in the available models list but return an empty respo
## Troubleshooting
### "Token expired"
-- Refresh OAuth tokens: `openclaw models auth login --provider google-antigravity`
+- Refresh OAuth tokens: `picoclaw auth login --provider antigravity`
### "Gemini for Google Cloud is not enabled"
- Enable the API in your Google Cloud Console
@@ -998,5 +803,5 @@ Some models might show up in the available models list but return an empty respo
### Models not appearing in list
- Verify OAuth authentication completed successfully
-- Check auth profile storage: `~/.openclaw/agent/auth-profiles.json`
-- Ensure the plugin is enabled: `openclaw plugins list`
+- Check auth profile storage: `~/.picoclaw/auth.json`
+- Re-run `picoclaw auth login --provider antigravity`
diff --git a/docs/ANTIGRAVITY_USAGE.md b/docs/ANTIGRAVITY_USAGE.md
index 8bf1fdfdb..e8194b6bc 100644
--- a/docs/ANTIGRAVITY_USAGE.md
+++ b/docs/ANTIGRAVITY_USAGE.md
@@ -47,14 +47,12 @@ picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
If you are deploying via Coolify or Docker, follow these steps to test:
-1. **Branch**: Use the `feat/antigravity-provider` branch.
-2. **Environment Variables**:
- * `PICOCLAW_AGENTS_DEFAULTS_PROVIDER=antigravity`
- * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-3-flash`
-3. **Authentication persistence**:
+1. **Environment Variables**:
+ * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash`
+2. **Authentication persistence**:
If you've logged in locally, you can copy your credentials to the server:
```bash
- scp ~/.picoclaw/auth-profiles.json user@your-server:~/.picoclaw/
+ scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/
```
*Alternatively*, run the `auth login` command once on the server if you have terminal access.
diff --git a/docs/design/provider-refactoring-tests.md b/docs/design/provider-refactoring-tests.md
index fc6429278..060be9ba8 100644
--- a/docs/design/provider-refactoring-tests.md
+++ b/docs/design/provider-refactoring-tests.md
@@ -1,7 +1,5 @@
# Provider Architecture Refactoring - Test Suite Summary
-> PRD: `tasks/prd-provider-refactoring.md`
-
This document summarizes the complete test suite designed for the Provider architecture refactoring.
## Test File Structure
@@ -12,10 +10,8 @@ pkg/
ā āāā model_config_test.go # US-001, US-002: ModelConfig struct and GetModelConfig tests
ā āāā migration_test.go # US-003: Backward compatibility and migration tests
āāā providers/
-ā āāā registry_test.go # US-006: Load balancing tests
-ā āāā integration_test.go # E2E integration tests
-ā āāā factory/
-ā āāā factory_test.go # US-004, US-005: Provider factory tests
+ā āāā factory_test.go # US-004, US-005: Provider factory tests
+ā āāā factory_provider_test.go # Factory provider integration tests
```
---
@@ -122,7 +118,6 @@ go test ./pkg/... -race
# Run specific package tests
go test ./pkg/config -v
go test ./pkg/providers -v
-go test ./pkg/providers/factory -v
# Run E2E tests
go test ./pkg/providers -run TestE2E -v
diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md
index 0682bae1a..589dfc043 100644
--- a/docs/migration/model-list-migration.md
+++ b/docs/migration/model-list-migration.md
@@ -85,6 +85,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
+| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` |
| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` |
| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` |
@@ -93,6 +94,13 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` |
| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` |
| `qwen/` | Alibaba Qwen | `qwen/qwen-max` |
+| `zhipu/` | Zhipu AI | `zhipu/glm-4` |
+| `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` |
+| `ollama/` | Ollama (local) | `ollama/llama3` |
+| `vllm/` | vLLM (local) | `vllm/my-model` |
+| `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` |
+| `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` |
+| `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` |
**Note**: If no prefix is specified, `openai/` is used as the default.
diff --git a/docs/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md
index cfcc30f17..95de768c6 100644
--- a/docs/picoclaw_community_roadmap_260216.md
+++ b/docs/picoclaw_community_roadmap_260216.md
@@ -71,14 +71,14 @@ Interested in a specific feature? You can "claim" these tasks and start building
* Support for OneBot, additional platforms
* attachments (images, audio, video, files).
* **Skills:**
- * Implementing `find_skill` to discover tools via [openclaw/skills](https://github.com/openclaw/skills) and other platforms.
+ * Implementing `find_skill` to discover tools via [ClawhHub](https://clawhub.ai) and other platforms.
* **Operations:** * MCP Support.
* Android operations (e.g., botdrop).
* Browser automation via CDP or ActionBook.
* **Multi-Agent Ecosystem:**
- * **Basic Model-Agnet** S
+ * **Basic Model-Agent**
* **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
* **Swarm Mode.**
* **AIEOS Integration.**
diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md
index 8777ddbd6..8aba1aa91 100644
--- a/docs/tools_configuration.md
+++ b/docs/tools_configuration.md
@@ -9,8 +9,8 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
"tools": {
"web": { ... },
"exec": { ... },
- "approval": { ... },
- "cron": { ... }
+ "cron": { ... },
+ "skills": { ... }
}
}
```
@@ -83,25 +83,12 @@ By default, PicoClaw blocks the following dangerous commands:
"custom_deny_patterns": [
"\\brm\\s+-r\\b",
"\\bkillall\\s+python"
- ],
+ ]
}
}
}
```
-## Approval Tool
-
-The approval tool controls permissions for dangerous operations.
-
-| Config | Type | Default | Description |
-|--------|------|---------|-------------|
-| `enabled` | bool | true | Enable approval functionality |
-| `write_file` | bool | true | Require approval for file writes |
-| `edit_file` | bool | true | Require approval for file edits |
-| `append_file` | bool | true | Require approval for file appends |
-| `exec` | bool | true | Require approval for command execution |
-| `timeout_minutes` | int | 5 | Approval timeout in minutes |
-
## Cron Tool
The cron tool is used for scheduling periodic tasks.
@@ -110,6 +97,40 @@ The cron tool is used for scheduling periodic tasks.
|--------|------|---------|-------------|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
+## Skills Tool
+
+The skills tool configures skill discovery and installation via registries like ClawHub.
+
+### Registries
+
+| Config | Type | Default | Description |
+|--------|------|---------|-------------|
+| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
+| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
+| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
+| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
+| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
+
+### Configuration Example
+
+```json
+{
+ "tools": {
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "enabled": true,
+ "base_url": "https://clawhub.ai",
+ "search_path": "/api/v1/search",
+ "skills_path": "/api/v1/skills",
+ "download_path": "/api/v1/download"
+ }
+ }
+ }
+ }
+}
+```
+
## Environment Variables
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS__`:
From 0675ce7c38ba4139bfce94c47e19275dbaed4017 Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Fri, 20 Feb 2026 20:03:11 +0200
Subject: [PATCH 15/88] feat(fmt): Fix formatting
---
cmd/picoclaw/cmd_agent.go | 10 +-
cmd/picoclaw/cmd_gateway.go | 26 +++-
cmd/picoclaw/cmd_onboard.go | 6 +-
cmd/picoclaw/cmd_skills.go | 4 +-
pkg/agent/context.go | 43 ++++--
pkg/agent/loop.go | 127 +++++++++++------
pkg/auth/oauth.go | 13 +-
pkg/channels/dingtalk.go | 13 +-
pkg/channels/feishu_64.go | 6 +-
pkg/channels/line.go | 36 ++---
pkg/channels/manager.go | 44 +++---
pkg/channels/wecom.go | 25 ++--
pkg/channels/wecom_app.go | 38 ++---
pkg/channels/wecom_app_test.go | 42 +++++-
pkg/channels/wecom_test.go | 48 +++++--
pkg/config/config.go | 168 +++++++++++-----------
pkg/config/migration_test.go | 16 ++-
pkg/migrate/migrate_test.go | 172 +++++++++++------------
pkg/providers/anthropic/provider_test.go | 44 +++---
pkg/providers/antigravity_provider.go | 118 ++++++++++------
pkg/providers/claude_provider_test.go | 11 +-
pkg/providers/http_provider.go | 8 +-
pkg/providers/openai_compat/provider.go | 39 +++--
pkg/providers/protocoltypes/types.go | 20 +--
pkg/providers/toolcall_utils.go | 4 +-
pkg/providers/types.go | 28 ++--
pkg/skills/clawhub_registry.go | 5 +-
pkg/skills/clawhub_registry_test.go | 7 +-
pkg/skills/registry_test.go | 3 +-
pkg/tools/shell_timeout_unix_test.go | 2 +-
pkg/tools/skills_install.go | 30 ++--
pkg/tools/skills_install_test.go | 19 +--
pkg/tools/skills_search.go | 12 +-
pkg/tools/skills_search_test.go | 20 ++-
pkg/utils/download.go | 6 +-
pkg/utils/zip.go | 13 +-
36 files changed, 731 insertions(+), 495 deletions(-)
diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go
index cee9f68ec..6d6ff935f 100644
--- a/cmd/picoclaw/cmd_agent.go
+++ b/cmd/picoclaw/cmd_agent.go
@@ -13,6 +13,7 @@ import (
"strings"
"github.com/chzyer/readline"
+
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -74,10 +75,10 @@ func agentCmd() {
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized",
- map[string]interface{}{
- "tools_count": startupInfo["tools"].(map[string]interface{})["count"],
- "skills_total": startupInfo["skills"].(map[string]interface{})["total"],
- "skills_available": startupInfo["skills"].(map[string]interface{})["available"],
+ map[string]any{
+ "tools_count": startupInfo["tools"].(map[string]any)["count"],
+ "skills_total": startupInfo["skills"].(map[string]any)["total"],
+ "skills_available": startupInfo["skills"].(map[string]any)["available"],
})
if message != "" {
@@ -104,7 +105,6 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
InterruptPrompt: "^C",
EOFPrompt: "exit",
})
-
if err != nil {
fmt.Printf("Error initializing readline: %v\n", err)
fmt.Println("Falling back to simple input mode...")
diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go
index 1f1bf5491..00ec0f96d 100644
--- a/cmd/picoclaw/cmd_gateway.go
+++ b/cmd/picoclaw/cmd_gateway.go
@@ -60,8 +60,8 @@ func gatewayCmd() {
// Print agent startup info
fmt.Println("\nš¦ Agent Status:")
startupInfo := agentLoop.GetStartupInfo()
- toolsInfo := startupInfo["tools"].(map[string]interface{})
- skillsInfo := startupInfo["skills"].(map[string]interface{})
+ toolsInfo := startupInfo["tools"].(map[string]any)
+ skillsInfo := startupInfo["skills"].(map[string]any)
fmt.Printf(" ⢠Tools: %d loaded\n", toolsInfo["count"])
fmt.Printf(" ⢠Skills: %d/%d available\n",
skillsInfo["available"],
@@ -69,7 +69,7 @@ func gatewayCmd() {
// Log to file as well
logger.InfoCF("agent", "Agent initialized",
- map[string]interface{}{
+ map[string]any{
"tools_count": toolsInfo["count"],
"skills_total": skillsInfo["total"],
"skills_available": skillsInfo["available"],
@@ -77,7 +77,14 @@ func gatewayCmd() {
// Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
- cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg)
+ cronService := setupCronTool(
+ agentLoop,
+ msgBus,
+ cfg.WorkspacePath(),
+ cfg.Agents.Defaults.RestrictToWorkspace,
+ execTimeout,
+ cfg,
+ )
heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
@@ -181,7 +188,7 @@ func gatewayCmd() {
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
go func() {
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()})
+ logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
}
}()
fmt.Printf("ā Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
@@ -203,7 +210,14 @@ func gatewayCmd() {
fmt.Println("ā Gateway stopped")
}
-func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cfg *config.Config) *cron.CronService {
+func setupCronTool(
+ agentLoop *agent.AgentLoop,
+ msgBus *bus.MessageBus,
+ workspace string,
+ restrict bool,
+ execTimeout time.Duration,
+ cfg *config.Config,
+) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service
diff --git a/cmd/picoclaw/cmd_onboard.go b/cmd/picoclaw/cmd_onboard.go
index 6e61e3267..1a9ebad61 100644
--- a/cmd/picoclaw/cmd_onboard.go
+++ b/cmd/picoclaw/cmd_onboard.go
@@ -55,7 +55,7 @@ func onboard() {
func copyEmbeddedToTarget(targetDir string) error {
// Ensure target directory exists
- if err := os.MkdirAll(targetDir, 0755); err != nil {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("Failed to create target directory: %w", err)
}
@@ -85,12 +85,12 @@ func copyEmbeddedToTarget(targetDir string) error {
targetPath := filepath.Join(targetDir, new_path)
// Ensure target file's directory exists
- if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
}
// Write file
- if err := os.WriteFile(targetPath, data, 0644); err != nil {
+ if err := os.WriteFile(targetPath, data, 0o644); err != nil {
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
}
diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go
index 32b7c62b8..2dd46756a 100644
--- a/cmd/picoclaw/cmd_skills.go
+++ b/cmd/picoclaw/cmd_skills.go
@@ -126,7 +126,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
- if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
os.Exit(1)
}
@@ -193,7 +193,7 @@ func skillsInstallBuiltinCmd(workspace string) {
continue
}
- if err := os.MkdirAll(workspacePath, 0755); err != nil {
+ if err := os.MkdirAll(workspacePath, 0o755); err != nil {
fmt.Printf("ā Failed to create directory for %s: %v\n", skillName, err)
continue
}
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index 78f5f1ffa..e989ffaaf 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -96,7 +96,9 @@ func (cb *ContextBuilder) buildToolsSection() string {
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
- sb.WriteString("**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n")
+ sb.WriteString(
+ "**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n",
+ )
sb.WriteString("You have access to the following tools:\n\n")
for _, s := range summaries {
sb.WriteString(s)
@@ -157,7 +159,13 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
return sb.String()
}
-func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message {
+func (cb *ContextBuilder) BuildMessages(
+ history []providers.Message,
+ summary string,
+ currentMessage string,
+ media []string,
+ channel, chatID string,
+) []providers.Message {
messages := []providers.Message{}
systemPrompt := cb.BuildSystemPrompt()
@@ -169,7 +177,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
// Log system prompt summary for debugging (debug mode only)
logger.DebugCF("agent", "System prompt built",
- map[string]interface{}{
+ map[string]any{
"total_chars": len(systemPrompt),
"total_lines": strings.Count(systemPrompt, "\n") + 1,
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
@@ -181,7 +189,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
preview = preview[:500] + "... (truncated)"
}
logger.DebugCF("agent", "System prompt preview",
- map[string]interface{}{
+ map[string]any{
"preview": preview,
})
@@ -218,12 +226,12 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
switch msg.Role {
case "tool":
if len(sanitized) == 0 {
- logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]interface{}{})
+ logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
continue
}
last := sanitized[len(sanitized)-1]
if last.Role != "assistant" || len(last.ToolCalls) == 0 {
- logger.DebugCF("agent", "Dropping orphaned tool message", map[string]interface{}{})
+ logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
continue
}
sanitized = append(sanitized, msg)
@@ -231,12 +239,16 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
case "assistant":
if len(msg.ToolCalls) > 0 {
if len(sanitized) == 0 {
- logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]interface{}{})
+ logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
continue
}
prev := sanitized[len(sanitized)-1]
if prev.Role != "user" && prev.Role != "tool" {
- logger.DebugCF("agent", "Dropping assistant tool-call turn with invalid predecessor", map[string]interface{}{"prev_role": prev.Role})
+ logger.DebugCF(
+ "agent",
+ "Dropping assistant tool-call turn with invalid predecessor",
+ map[string]any{"prev_role": prev.Role},
+ )
continue
}
}
@@ -250,7 +262,10 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
return sanitized
}
-func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message {
+func (cb *ContextBuilder) AddToolResult(
+ messages []providers.Message,
+ toolCallID, toolName, result string,
+) []providers.Message {
messages = append(messages, providers.Message{
Role: "tool",
Content: result,
@@ -259,7 +274,11 @@ func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID
return messages
}
-func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, content string, toolCalls []map[string]interface{}) []providers.Message {
+func (cb *ContextBuilder) AddAssistantMessage(
+ messages []providers.Message,
+ content string,
+ toolCalls []map[string]any,
+) []providers.Message {
msg := providers.Message{
Role: "assistant",
Content: content,
@@ -289,13 +308,13 @@ func (cb *ContextBuilder) loadSkills() string {
}
// GetSkillsInfo returns information about loaded skills.
-func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
+func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills()
skillNames := make([]string, 0, len(allSkills))
for _, s := range allSkills {
skillNames = append(skillNames, s.Name)
}
- return map[string]interface{}{
+ return map[string]any{
"total": len(allSkills),
"available": len(allSkills),
"names": skillNames,
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index add183aaf..b36f4a0c4 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -80,7 +80,12 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
}
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
-func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) {
+func registerSharedTools(
+ cfg *config.Config,
+ msgBus *bus.MessageBus,
+ registry *AgentRegistry,
+ provider providers.LLMProvider,
+) {
for _, agentID := range registry.ListAgentIDs() {
agent, ok := registry.GetAgent(agentID)
if !ok {
@@ -123,7 +128,10 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
})
- searchCache := skills.NewSearchCache(cfg.Tools.Skills.SearchCache.MaxSize, time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second)
+ searchCache := skills.NewSearchCache(
+ cfg.Tools.Skills.SearchCache.MaxSize,
+ time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
+ )
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
@@ -226,7 +234,10 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
}
-func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) {
+func (al *AgentLoop) ProcessDirectWithChannel(
+ ctx context.Context,
+ content, sessionKey, channel, chatID string,
+) (string, error) {
msg := bus.InboundMessage{
Channel: channel,
SenderID: "cron",
@@ -263,7 +274,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
logContent = utils.Truncate(msg.Content, 80)
}
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
- map[string]interface{}{
+ map[string]any{
"channel": msg.Channel,
"chat_id": msg.ChatID,
"sender_id": msg.SenderID,
@@ -302,7 +313,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}
logger.InfoCF("agent", "Routed message",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"session_key": sessionKey,
"matched_by": route.MatchedBy,
@@ -325,7 +336,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
}
logger.InfoCF("agent", "Processing system message",
- map[string]interface{}{
+ map[string]any{
"sender_id": msg.SenderID,
"chat_id": msg.ChatID,
})
@@ -350,7 +361,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
// Skip internal channels - only log, don't send to user
if constants.IsInternalChannel(originChannel) {
logger.InfoCF("agent", "Subagent completed (internal channel)",
- map[string]interface{}{
+ map[string]any{
"sender_id": msg.SenderID,
"content_len": len(content),
"channel": originChannel,
@@ -383,7 +394,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
if !constants.IsInternalChannel(opts.Channel) {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil {
- logger.WarnCF("agent", "Failed to record last channel", map[string]interface{}{"error": err.Error()})
+ logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()})
}
}
}
@@ -445,7 +456,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 9. Log response
responsePreview := utils.Truncate(finalContent, 120)
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"session_key": opts.SessionKey,
"iterations": iteration,
@@ -456,7 +467,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
}
// runLLMIteration executes the LLM call loop with tool handling.
-func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) {
+func (al *AgentLoop) runLLMIteration(
+ ctx context.Context,
+ agent *AgentInstance,
+ messages []providers.Message,
+ opts processOptions,
+) (string, int, error) {
iteration := 0
var finalContent string
@@ -464,7 +480,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
iteration++
logger.DebugCF("agent", "LLM iteration",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"max": agent.MaxIterations,
@@ -475,7 +491,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
// Log LLM request details
logger.DebugCF("agent", "LLM request",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"model": agent.Model,
@@ -488,7 +504,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
// Log full messages (detailed)
logger.DebugCF("agent", "Full LLM request",
- map[string]interface{}{
+ map[string]any{
"iteration": iteration,
"messages_json": formatMessagesForLog(messages),
"tools_json": formatToolsForLog(providerToolDefs),
@@ -502,7 +518,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if len(agent.Candidates) > 1 && al.fallback != nil {
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
- return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
"max_tokens": agent.MaxTokens,
"temperature": agent.Temperature,
})
@@ -514,11 +530,11 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
- map[string]interface{}{"agent_id": agent.ID, "iteration": iteration})
+ map[string]any{"agent_id": agent.ID, "iteration": iteration})
}
return fbResult.Response, nil
}
- return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
"max_tokens": agent.MaxTokens,
"temperature": agent.Temperature,
})
@@ -539,7 +555,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
strings.Contains(errMsg, "length")
if isContextError && retry < maxRetries {
- logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{
+ logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
"error": err.Error(),
"retry": retry,
})
@@ -566,7 +582,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if err != nil {
logger.ErrorCF("agent", "LLM call failed",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"error": err.Error(),
@@ -578,7 +594,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if len(response.ToolCalls) == 0 {
finalContent = response.Content
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(finalContent),
@@ -597,7 +613,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
toolNames = append(toolNames, tc.Name)
}
logger.InfoCF("agent", "LLM requested tool calls",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"tools": toolNames,
"count": len(normalizedToolCalls),
@@ -641,7 +657,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"tool": tc.Name,
"iteration": iteration,
@@ -656,14 +672,21 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
// The agent will handle user notification via processSystemMessage
if !result.Silent && result.ForUser != "" {
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
- map[string]interface{}{
+ map[string]any{
"tool": tc.Name,
"content_len": len(result.ForUser),
})
}
}
- toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
+ toolResult := agent.Tools.ExecuteWithContext(
+ ctx,
+ tc.Name,
+ tc.Arguments,
+ opts.Channel,
+ opts.ChatID,
+ asyncCallback,
+ )
// Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
@@ -673,7 +696,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
Content: toolResult.ForUser,
})
logger.DebugCF("agent", "Sent tool result to user",
- map[string]interface{}{
+ map[string]any{
"tool": tc.Name,
"content_len": len(toolResult.ForUser),
})
@@ -775,7 +798,10 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
// Append compression note to the original system prompt instead of adding a new system message
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
- compressionNote := fmt.Sprintf("\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]", droppedCount)
+ compressionNote := fmt.Sprintf(
+ "\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]",
+ droppedCount,
+ )
enhancedSystemPrompt := history[0]
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
newHistory = append(newHistory, enhancedSystemPrompt)
@@ -787,7 +813,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
agent.Sessions.SetHistory(sessionKey, newHistory)
agent.Sessions.Save(sessionKey)
- logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{
+ logger.WarnCF("agent", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(newHistory),
@@ -795,8 +821,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
}
// GetStartupInfo returns information about loaded tools and skills for logging.
-func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
- info := make(map[string]interface{})
+func (al *AgentLoop) GetStartupInfo() map[string]any {
+ info := make(map[string]any)
agent := al.registry.GetDefaultAgent()
if agent == nil {
@@ -805,7 +831,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
// Tools info
toolsList := agent.Tools.List()
- info["tools"] = map[string]interface{}{
+ info["tools"] = map[string]any{
"count": len(toolsList),
"names": toolsList,
}
@@ -814,7 +840,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
// Agents info
- info["agents"] = map[string]interface{}{
+ info["agents"] = map[string]any{
"count": len(al.registry.ListAgentIDs()),
"ids": al.registry.ListAgentIDs(),
}
@@ -919,11 +945,21 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
- mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
- resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{
- "max_tokens": 1024,
- "temperature": 0.3,
- })
+ mergePrompt := fmt.Sprintf(
+ "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
+ s1,
+ s2,
+ )
+ resp, err := agent.Provider.Chat(
+ ctx,
+ []providers.Message{{Role: "user", Content: mergePrompt}},
+ nil,
+ agent.Model,
+ map[string]any{
+ "max_tokens": 1024,
+ "temperature": 0.3,
+ },
+ )
if err == nil {
finalSummary = resp.Content
} else {
@@ -945,7 +981,12 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
}
// summarizeBatch summarizes a batch of messages.
-func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
+func (al *AgentLoop) summarizeBatch(
+ ctx context.Context,
+ agent *AgentInstance,
+ batch []providers.Message,
+ existingSummary string,
+) (string, error) {
var sb strings.Builder
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
if existingSummary != "" {
@@ -959,10 +1000,16 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, b
}
prompt := sb.String()
- response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{
- "max_tokens": 1024,
- "temperature": 0.3,
- })
+ response, err := agent.Provider.Chat(
+ ctx,
+ []providers.Message{{Role: "user", Content: prompt}},
+ nil,
+ agent.Model,
+ map[string]any{
+ "max_tokens": 1024,
+ "temperature": 0.3,
+ },
+ )
if err != nil {
return "", err
}
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index 496a4674c..cf8c1c9c4 100644
--- a/pkg/auth/oauth.go
+++ b/pkg/auth/oauth.go
@@ -44,7 +44,9 @@ func OpenAIOAuthConfig() OAuthProviderConfig {
// Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access.
func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
// These are the same client credentials used by the OpenCode antigravity plugin.
- clientID := decodeBase64("MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==")
+ clientID := decodeBase64(
+ "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
+ )
clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=")
return OAuthProviderConfig{
Issuer: "https://accounts.google.com/o/oauth2/v2",
@@ -129,8 +131,13 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
}
- fmt.Printf("Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", cfg.Port)
- fmt.Println("please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.")
+ fmt.Printf(
+ "Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n",
+ cfg.Port,
+ )
+ fmt.Println(
+ "please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.",
+ )
fmt.Println("Waiting for authentication (browser or manual paste)...")
// Start manual input in a goroutine
diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk.go
index 79cc85219..662fba3b7 100644
--- a/pkg/channels/dingtalk.go
+++ b/pkg/channels/dingtalk.go
@@ -10,6 +10,7 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
+
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -108,7 +109,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID)
}
- logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{
+ logger.DebugCF("dingtalk", "Sending message", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
@@ -120,12 +121,15 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
// This is called by the Stream SDK when a new message arrives
// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error)
-func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
+func (c *DingTalkChannel) onChatBotMessageReceived(
+ ctx context.Context,
+ data *chatbot.BotCallbackDataModel,
+) ([]byte, error) {
// Extract message content from Text field
content := data.Text.Content
if content == "" {
// Try to extract from Content interface{} if Text is empty
- if contentMap, ok := data.Content.(map[string]interface{}); ok {
+ if contentMap, ok := data.Content.(map[string]any); ok {
if textContent, ok := contentMap["content"].(string); ok {
content = textContent
}
@@ -163,7 +167,7 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch
metadata["peer_id"] = data.ConversationId
}
- logger.DebugCF("dingtalk", "Received message", map[string]interface{}{
+ logger.DebugCF("dingtalk", "Received message", map[string]any{
"sender_nick": senderNick,
"sender_id": senderID,
"preview": utils.Truncate(content, 50),
@@ -192,7 +196,6 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
titleBytes,
contentBytes,
)
-
if err != nil {
return fmt.Errorf("failed to send reply: %w", err)
}
diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu_64.go
index 9e15fa3a7..42e74980f 100644
--- a/pkg/channels/feishu_64.go
+++ b/pkg/channels/feishu_64.go
@@ -65,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
go func() {
if err := wsClient.Start(runCtx); err != nil {
- logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{
+ logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{
"error": err.Error(),
})
}
@@ -121,7 +121,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
}
- logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{
+ logger.DebugCF("feishu", "Feishu message sent", map[string]any{
"chat_id": msg.ChatID,
})
@@ -174,7 +174,7 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
metadata["peer_id"] = chatID
}
- logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{
+ logger.InfoCF("feishu", "Feishu message received", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"preview": utils.Truncate(content, 80),
diff --git a/pkg/channels/line.go b/pkg/channels/line.go
index 9f7d2bde0..44134996f 100644
--- a/pkg/channels/line.go
+++ b/pkg/channels/line.go
@@ -75,11 +75,11 @@ func (c *LINEChannel) Start(ctx context.Context) error {
// Fetch bot profile to get bot's userId for mention detection
if err := c.fetchBotInfo(); err != nil {
- logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{
+ logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
"error": err.Error(),
})
} else {
- logger.InfoCF("line", "Bot info fetched", map[string]interface{}{
+ logger.InfoCF("line", "Bot info fetched", map[string]any{
"bot_user_id": c.botUserID,
"basic_id": c.botBasicID,
"display_name": c.botDisplayName,
@@ -100,12 +100,12 @@ func (c *LINEChannel) Start(ctx context.Context) error {
}
go func() {
- logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{
+ logger.InfoCF("line", "LINE webhook server listening", map[string]any{
"addr": addr,
"path": path,
})
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("line", "Webhook server error", map[string]interface{}{
+ logger.ErrorCF("line", "Webhook server error", map[string]any{
"error": err.Error(),
})
}
@@ -162,7 +162,7 @@ func (c *LINEChannel) Stop(ctx context.Context) error {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
- logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{
+ logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{
"error": err.Error(),
})
}
@@ -182,7 +182,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
- logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{
+ logger.ErrorCF("line", "Failed to read request body", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
@@ -200,7 +200,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
Events []lineEvent `json:"events"`
}
if err := json.Unmarshal(body, &payload); err != nil {
- logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
+ logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
@@ -266,7 +266,7 @@ type lineMentionee struct {
func (c *LINEChannel) processEvent(event lineEvent) {
if event.Type != "message" {
- logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{
+ logger.DebugCF("line", "Ignoring non-message event", map[string]any{
"type": event.Type,
})
return
@@ -278,7 +278,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
var msg lineMessage
if err := json.Unmarshal(event.Message, &msg); err != nil {
- logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
+ logger.ErrorCF("line", "Failed to parse message", map[string]any{
"error": err.Error(),
})
return
@@ -286,7 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
// In group chats, only respond when the bot is mentioned
if isGroup && !c.isBotMentioned(msg) {
- logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{
+ logger.DebugCF("line", "Ignoring group message without mention", map[string]any{
"chat_id": chatID,
})
return
@@ -312,7 +312,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
- logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{
+ logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
@@ -374,7 +374,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
metadata["peer_id"] = senderID
}
- logger.DebugCF("line", "Received message", map[string]interface{}{
+ logger.DebugCF("line", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"message_type": msg.Type,
@@ -505,7 +505,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
tokenEntry := entry.(replyTokenEntry)
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
- logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{
+ logger.DebugCF("line", "Message sent via Reply API", map[string]any{
"chat_id": msg.ChatID,
"quoted": quoteToken != "",
})
@@ -533,7 +533,7 @@ func buildTextMessage(content, quoteToken string) map[string]string {
// sendReply sends a message using the LINE Reply API.
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
- payload := map[string]interface{}{
+ payload := map[string]any{
"replyToken": replyToken,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
@@ -543,7 +543,7 @@ func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteT
// sendPush sends a message using the LINE Push API.
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
- payload := map[string]interface{}{
+ payload := map[string]any{
"to": to,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
@@ -553,19 +553,19 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri
// sendLoading sends a loading animation indicator to the chat.
func (c *LINEChannel) sendLoading(chatID string) {
- payload := map[string]interface{}{
+ payload := map[string]any{
"chatId": chatID,
"loadingSeconds": 60,
}
if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
- logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{
+ logger.DebugCF("line", "Failed to send loading indicator", map[string]any{
"error": err.Error(),
})
}
}
// callAPI makes an authenticated POST request to the LINE API.
-func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error {
+func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index b80d1c8fb..75edaf49e 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -50,7 +50,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Telegram channel")
telegram, err := NewTelegramChannel(m.config, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -63,7 +63,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -76,7 +76,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Feishu channel")
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -89,7 +89,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Discord channel")
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -102,7 +102,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -115,7 +115,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize QQ channel")
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -128,7 +128,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -141,7 +141,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Slack channel")
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -154,7 +154,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize LINE channel")
line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -167,7 +167,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize OneBot channel")
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -180,7 +180,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize WeCom channel")
wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -193,7 +193,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize WeCom App channel")
wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -202,7 +202,7 @@ func (m *Manager) initChannels() error {
}
}
- logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
+ logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})
@@ -226,11 +226,11 @@ func (m *Manager) StartAll(ctx context.Context) error {
go m.dispatchOutbound(dispatchCtx)
for name, channel := range m.channels {
- logger.InfoCF("channels", "Starting channel", map[string]interface{}{
+ 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]interface{}{
+ logger.ErrorCF("channels", "Failed to start channel", map[string]any{
"channel": name,
"error": err.Error(),
})
@@ -253,11 +253,11 @@ func (m *Manager) StopAll(ctx context.Context) error {
}
for name, channel := range m.channels {
- logger.InfoCF("channels", "Stopping channel", map[string]interface{}{
+ 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]interface{}{
+ logger.ErrorCF("channels", "Error stopping channel", map[string]any{
"channel": name,
"error": err.Error(),
})
@@ -292,14 +292,14 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
m.mu.RUnlock()
if !exists {
- logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{
+ logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{
"channel": msg.Channel,
})
continue
}
if err := channel.Send(ctx, msg); err != nil {
- logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Error sending message to channel", map[string]any{
"channel": msg.Channel,
"error": err.Error(),
})
@@ -315,13 +315,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) {
return channel, ok
}
-func (m *Manager) GetStatus() map[string]interface{} {
+func (m *Manager) GetStatus() map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
- status := make(map[string]interface{})
+ status := make(map[string]any)
for name, channel := range m.channels {
- status[name] = map[string]interface{}{
+ status[name] = map[string]any{
"enabled": true,
"running": channel.IsRunning(),
}
diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go
index 064568243..07bd8488c 100644
--- a/pkg/channels/wecom.go
+++ b/pkg/channels/wecom.go
@@ -134,7 +134,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error {
}
c.setRunning(true)
- logger.InfoCF("wecom", "WeCom Bot channel started", map[string]interface{}{
+ logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{
"address": addr,
"path": webhookPath,
})
@@ -142,7 +142,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error {
// Start server in goroutine
go func() {
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("wecom", "HTTP server error", map[string]interface{}{
+ logger.ErrorCF("wecom", "HTTP server error", map[string]any{
"error": err.Error(),
})
}
@@ -178,7 +178,7 @@ func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("wecom channel not running")
}
- logger.DebugCF("wecom", "Sending message via webhook", map[string]interface{}{
+ logger.DebugCF("wecom", "Sending message via webhook", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
@@ -230,7 +230,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
// Reference: https://developer.work.weixin.qq.com/document/path/101033
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
if err != nil {
- logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{
+ logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
"error": err.Error(),
})
http.Error(w, "Decryption failed", http.StatusInternalServerError)
@@ -273,7 +273,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
- logger.ErrorCF("wecom", "Failed to parse XML", map[string]interface{}{
+ logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid XML", http.StatusBadRequest)
@@ -292,7 +292,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
// Reference: https://developer.work.weixin.qq.com/document/path/101033
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
if err != nil {
- logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{
+ logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Decryption failed", http.StatusInternalServerError)
@@ -302,7 +302,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
// 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]interface{}{
+ logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid message format", http.StatusBadRequest)
@@ -320,8 +320,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
// 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]interface{}{
+ 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
@@ -332,7 +333,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
c.msgMu.Lock()
if c.processedMsgs[msgID] {
c.msgMu.Unlock()
- logger.DebugCF("wecom", "Skipping duplicate message", map[string]interface{}{
+ logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
"msg_id": msgID,
})
return
@@ -399,7 +400,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
metadata["sender_id"] = senderID
}
- logger.DebugCF("wecom", "Received message", map[string]interface{}{
+ logger.DebugCF("wecom", "Received message", map[string]any{
"sender_id": senderID,
"msg_type": msg.MsgType,
"peer_kind": peerKind,
@@ -468,7 +469,7 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
// handleHealth handles health check requests
func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
- status := map[string]interface{}{
+ status := map[string]any{
"status": "ok",
"running": c.IsRunning(),
}
diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go
index 63a1dd815..878504106 100644
--- a/pkg/channels/wecom_app.go
+++ b/pkg/channels/wecom_app.go
@@ -145,7 +145,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
// Get initial access token
if err := c.refreshAccessToken(); err != nil {
- logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]interface{}{
+ logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{
"error": err.Error(),
})
}
@@ -171,7 +171,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
}
c.setRunning(true)
- logger.InfoCF("wecom_app", "WeCom App channel started", map[string]interface{}{
+ logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{
"address": addr,
"path": webhookPath,
})
@@ -179,7 +179,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
// Start server in goroutine
go func() {
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("wecom_app", "HTTP server error", map[string]interface{}{
+ logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{
"error": err.Error(),
})
}
@@ -218,7 +218,7 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("no valid access token available")
}
- logger.DebugCF("wecom_app", "Sending message", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Sending message", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
@@ -231,7 +231,7 @@ 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]interface{}{
+ logger.DebugCF("wecom_app", "Received webhook request", map[string]any{
"method": r.Method,
"url": r.URL.String(),
"path": r.URL.Path,
@@ -250,7 +250,7 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request)
return
}
- logger.WarnCF("wecom_app", "Method not allowed", map[string]interface{}{
+ logger.WarnCF("wecom_app", "Method not allowed", map[string]any{
"method": r.Method,
})
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -264,7 +264,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
nonce := query.Get("nonce")
echostr := query.Get("echostr")
- logger.DebugCF("wecom_app", "Handling verification request", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Handling verification request", map[string]any{
"msg_signature": msgSignature,
"timestamp": timestamp,
"nonce": nonce,
@@ -280,7 +280,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
// Verify signature
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
- logger.WarnCF("wecom_app", "Signature verification failed", map[string]interface{}{
+ logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
"token": c.config.Token,
"msg_signature": msgSignature,
"timestamp": timestamp,
@@ -294,13 +294,13 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
// Decrypt echostr with CorpID verification
// For WeCom App (čŖå»ŗåŗēØ), receiveid should be corp_id
- logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{
"encoding_aes_key": c.config.EncodingAESKey,
"corp_id": c.config.CorpID,
})
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
- logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{
+ 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,
@@ -309,7 +309,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
return
}
- logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{
"decrypted": decryptedEchoStr,
})
@@ -349,7 +349,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
- logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]interface{}{
+ logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid XML", http.StatusBadRequest)
@@ -367,7 +367,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
// For WeCom App (čŖå»ŗåŗēØ), receiveid should be corp_id
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
- logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{
+ logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Decryption failed", http.StatusInternalServerError)
@@ -377,7 +377,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
// 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]interface{}{
+ logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid message format", http.StatusBadRequest)
@@ -396,7 +396,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
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]interface{}{
+ logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{
"msg_type": msg.MsgType,
})
return
@@ -408,7 +408,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
c.msgMu.Lock()
if c.processedMsgs[msgID] {
c.msgMu.Unlock()
- logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
"msg_id": msgID,
})
return
@@ -441,7 +441,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
content := msg.Content
- logger.DebugCF("wecom_app", "Received message", map[string]interface{}{
+ logger.DebugCF("wecom_app", "Received message", map[string]any{
"sender_id": senderID,
"msg_type": msg.MsgType,
"preview": utils.Truncate(content, 50),
@@ -462,7 +462,7 @@ func (c *WeComAppChannel) tokenRefreshLoop() {
return
case <-ticker.C:
if err := c.refreshAccessToken(); err != nil {
- logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]interface{}{
+ logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{
"error": err.Error(),
})
}
@@ -628,7 +628,7 @@ func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken,
// handleHealth handles health check requests
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
- status := map[string]interface{}{
+ status := map[string]any{
"status": "ok",
"running": c.IsRunning(),
"has_token": c.getAccessToken() != "",
diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom_app_test.go
index bc40806bb..6778520f3 100644
--- a/pkg/channels/wecom_app_test.go
+++ b/pkg/channels/wecom_app_test.go
@@ -399,7 +399,11 @@ func TestWeComAppHandleVerification(t *testing.T) {
nonce := "test_nonce"
signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr)
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleVerification(context.Background(), w, req)
@@ -429,7 +433,11 @@ func TestWeComAppHandleVerification(t *testing.T) {
timestamp := "1234567890"
nonce := "test_nonce"
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleVerification(context.Background(), w, req)
@@ -481,7 +489,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
nonce := "test_nonce"
signature := generateSignatureApp("test_token", timestamp, nonce, encrypted)
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -510,7 +522,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
nonce := "test_nonce"
signature := generateSignatureApp("test_token", timestamp, nonce, "")
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ strings.NewReader("invalid xml"),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -532,7 +548,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
timestamp := "1234567890"
nonce := "test_nonce"
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -646,7 +666,11 @@ func TestWeComAppHandleWebhook(t *testing.T) {
nonce := "test_nonce"
signature := generateSignatureApp("test_token", timestamp, nonce, encoded)
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleWebhook(w, req)
@@ -669,7 +693,11 @@ func TestWeComAppHandleWebhook(t *testing.T) {
nonce := "test_nonce"
signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleWebhook(w, req)
diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom_test.go
index c3f889c64..53cde2693 100644
--- a/pkg/channels/wecom_test.go
+++ b/pkg/channels/wecom_test.go
@@ -358,7 +358,11 @@ func TestWeComBotHandleVerification(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr)
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleVerification(context.Background(), w, req)
@@ -388,7 +392,11 @@ func TestWeComBotHandleVerification(t *testing.T) {
timestamp := "1234567890"
nonce := "test_nonce"
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleVerification(context.Background(), w, req)
@@ -437,7 +445,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encrypted)
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -479,7 +491,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encrypted)
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -508,7 +524,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, "")
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ strings.NewReader("invalid xml"),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -530,7 +550,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
timestamp := "1234567890"
nonce := "test_nonce"
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
@@ -625,7 +649,11 @@ func TestWeComBotHandleWebhook(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encoded)
- req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded,
+ nil,
+ )
w := httptest.NewRecorder()
ch.handleWebhook(w, req)
@@ -648,7 +676,11 @@ func TestWeComBotHandleWebhook(t *testing.T) {
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
- req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
+ bytes.NewReader(wrapperData),
+ )
w := httptest.NewRecorder()
ch.handleWebhook(w, req)
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 005631e4a..20556011a 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -26,7 +26,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
}
// Try []interface{} to handle mixed types
- var raw []interface{}
+ var raw []any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
@@ -167,16 +167,16 @@ type SessionConfig struct {
}
type AgentDefaults struct {
- Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
- RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
- Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
- Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
+ Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
+ RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
+ Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
+ Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
- ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
+ ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
- MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
- Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
- MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
+ MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
+ Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
+ MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
type ChannelsConfig struct {
@@ -195,114 +195,114 @@ type ChannelsConfig struct {
}
type WhatsAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
}
type TelegramConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
- Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
+ Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
}
type FeishuConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
- EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
+ AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
+ EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
}
type DiscordConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
}
type MaixCamConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
- Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
- Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
+ Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
+ Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
}
type QQConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
}
type DingTalkConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
- ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
+ ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
}
type SlackConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
- BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
- AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
+ BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
+ AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
}
type LINEConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
- ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
+ ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
}
type OneBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
- WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
- ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
+ WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
+ ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
}
type WeComConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
+ 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"`
+ 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"`
}
type WeComAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
- CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
- CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
- AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
+ 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"`
+ 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"`
}
type HeartbeatConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
}
type DevicesConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
}
@@ -359,11 +359,11 @@ func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
}
type ProviderConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
- APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
- AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
- ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
+ APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
+ APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
+ AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
}
type OpenAIProviderConfig struct {
@@ -413,19 +413,19 @@ type GatewayConfig struct {
}
type BraveConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
type DuckDuckGoConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
type PerplexityConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
@@ -458,7 +458,7 @@ type SkillsToolsConfig struct {
}
type SearchCacheConfig struct {
- MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
+ MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
}
@@ -467,14 +467,14 @@ type SkillsRegistriesConfig struct {
}
type ClawHubRegistryConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
- BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
- AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
- SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
- SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
- DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
- Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
- MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
+ AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
+ SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
+ SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
+ DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
+ Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
+ MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
}
@@ -517,11 +517,11 @@ func SaveConfig(path string, cfg *Config) error {
}
dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0755); err != nil {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
- return os.WriteFile(path, data, 0600)
+ return os.WriteFile(path, data, 0o600)
}
func (c *Config) WorkspacePath() string {
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index b9a333f9e..1e8139e68 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -361,7 +361,10 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: tt.providerAlias,
- Model: strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1]),
+ Model: strings.TrimPrefix(
+ tt.expectedModel,
+ tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
+ ),
},
},
Providers: ProvidersConfig{},
@@ -382,7 +385,10 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
}
// Need to fix the model name in config
- cfg.Agents.Defaults.Model = strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1])
+ cfg.Agents.Defaults.Model = strings.TrimPrefix(
+ tt.expectedModel,
+ tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
+ )
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
@@ -515,7 +521,11 @@ func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {
func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6")
if result != "openrouter/claude-sonnet-4.6" {
- t.Errorf("buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q", result, "openrouter/claude-sonnet-4.6")
+ t.Errorf(
+ "buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q",
+ result,
+ "openrouter/claude-sonnet-4.6",
+ )
}
}
diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go
index 759fc9024..ccc00f72c 100644
--- a/pkg/migrate/migrate_test.go
+++ b/pkg/migrate/migrate_test.go
@@ -40,20 +40,20 @@ func TestCamelToSnake(t *testing.T) {
}
func TestConvertKeysToSnake(t *testing.T) {
- input := map[string]interface{}{
+ input := map[string]any{
"apiKey": "test-key",
"apiBase": "https://example.com",
- "nested": map[string]interface{}{
+ "nested": map[string]any{
"maxTokens": float64(8192),
- "allowFrom": []interface{}{"user1", "user2"},
- "deeperLevel": map[string]interface{}{
+ "allowFrom": []any{"user1", "user2"},
+ "deeperLevel": map[string]any{
"clientId": "abc",
},
},
}
result := convertKeysToSnake(input)
- m, ok := result.(map[string]interface{})
+ m, ok := result.(map[string]any)
if !ok {
t.Fatal("expected map[string]interface{}")
}
@@ -65,7 +65,7 @@ func TestConvertKeysToSnake(t *testing.T) {
t.Error("expected key 'api_base' after conversion")
}
- nested, ok := m["nested"].(map[string]interface{})
+ nested, ok := m["nested"].(map[string]any)
if !ok {
t.Fatal("expected nested map")
}
@@ -76,7 +76,7 @@ func TestConvertKeysToSnake(t *testing.T) {
t.Error("expected key 'allow_from' in nested map")
}
- deeper, ok := nested["deeper_level"].(map[string]interface{})
+ deeper, ok := nested["deeper_level"].(map[string]any)
if !ok {
t.Fatal("expected deeper_level map")
}
@@ -89,15 +89,15 @@ func TestLoadOpenClawConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json")
- openclawConfig := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ openclawConfig := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-ant-test123",
"apiBase": "https://api.anthropic.com",
},
},
- "agents": map[string]interface{}{
- "defaults": map[string]interface{}{
+ "agents": map[string]any{
+ "defaults": map[string]any{
"maxTokens": float64(4096),
"model": "claude-3-opus",
},
@@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(configPath, data, 0644); err != nil {
+ if err := os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatal(err)
}
@@ -117,11 +117,11 @@ func TestLoadOpenClawConfig(t *testing.T) {
t.Fatalf("LoadOpenClawConfig: %v", err)
}
- providers, ok := result["providers"].(map[string]interface{})
+ providers, ok := result["providers"].(map[string]any)
if !ok {
t.Fatal("expected providers map")
}
- anthropic, ok := providers["anthropic"].(map[string]interface{})
+ anthropic, ok := providers["anthropic"].(map[string]any)
if !ok {
t.Fatal("expected anthropic map")
}
@@ -129,11 +129,11 @@ func TestLoadOpenClawConfig(t *testing.T) {
t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"])
}
- agents, ok := result["agents"].(map[string]interface{})
+ agents, ok := result["agents"].(map[string]any)
if !ok {
t.Fatal("expected agents map")
}
- defaults, ok := agents["defaults"].(map[string]interface{})
+ defaults, ok := agents["defaults"].(map[string]any)
if !ok {
t.Fatal("expected defaults map")
}
@@ -144,16 +144,16 @@ func TestLoadOpenClawConfig(t *testing.T) {
func TestConvertConfig(t *testing.T) {
t.Run("providers mapping", func(t *testing.T) {
- data := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ data := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"api_key": "sk-ant-test",
"api_base": "https://api.anthropic.com",
},
- "openrouter": map[string]interface{}{
+ "openrouter": map[string]any{
"api_key": "sk-or-test",
},
- "groq": map[string]interface{}{
+ "groq": map[string]any{
"api_key": "gsk-test",
},
},
@@ -178,9 +178,9 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("unsupported provider warning", func(t *testing.T) {
- data := map[string]interface{}{
- "providers": map[string]interface{}{
- "unknown_provider": map[string]interface{}{
+ data := map[string]any{
+ "providers": map[string]any{
+ "unknown_provider": map[string]any{
"api_key": "sk-test",
},
},
@@ -199,14 +199,14 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("channels mapping", func(t *testing.T) {
- data := map[string]interface{}{
- "channels": map[string]interface{}{
- "telegram": map[string]interface{}{
+ data := map[string]any{
+ "channels": map[string]any{
+ "telegram": map[string]any{
"enabled": true,
"token": "tg-token-123",
- "allow_from": []interface{}{"user1"},
+ "allow_from": []any{"user1"},
},
- "discord": map[string]interface{}{
+ "discord": map[string]any{
"enabled": true,
"token": "disc-token-456",
},
@@ -232,9 +232,9 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("unsupported channel warning", func(t *testing.T) {
- data := map[string]interface{}{
- "channels": map[string]interface{}{
- "email": map[string]interface{}{
+ data := map[string]any{
+ "channels": map[string]any{
+ "email": map[string]any{
"enabled": true,
},
},
@@ -253,9 +253,9 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("agent defaults", func(t *testing.T) {
- data := map[string]interface{}{
- "agents": map[string]interface{}{
- "defaults": map[string]interface{}{
+ data := map[string]any{
+ "agents": map[string]any{
+ "defaults": map[string]any{
"model": "claude-3-opus",
"max_tokens": float64(4096),
"temperature": 0.5,
@@ -287,7 +287,7 @@ func TestConvertConfig(t *testing.T) {
})
t.Run("empty config", func(t *testing.T) {
- data := map[string]interface{}{}
+ data := map[string]any{}
cfg, warnings, err := ConvertConfig(data)
if err != nil {
@@ -389,9 +389,9 @@ func TestPlanWorkspaceMigration(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
- os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644)
- os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644)
+ os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
+ os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0o644)
+ os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -420,8 +420,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
- os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644)
+ os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
+ os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -443,8 +443,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
- os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644)
+ os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
+ os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, true)
if err != nil {
@@ -463,8 +463,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
dstDir := t.TempDir()
memDir := filepath.Join(srcDir, "memory")
- os.MkdirAll(memDir, 0755)
- os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644)
+ os.MkdirAll(memDir, 0o755)
+ os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -494,8 +494,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
dstDir := t.TempDir()
skillDir := filepath.Join(srcDir, "skills", "weather")
- os.MkdirAll(skillDir, 0755)
- os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644)
+ os.MkdirAll(skillDir, 0o755)
+ os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0o644)
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
if err != nil {
@@ -518,7 +518,7 @@ func TestFindOpenClawConfig(t *testing.T) {
t.Run("finds openclaw.json", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json")
- os.WriteFile(configPath, []byte("{}"), 0644)
+ os.WriteFile(configPath, []byte("{}"), 0o644)
found, err := findOpenClawConfig(tmpDir)
if err != nil {
@@ -532,7 +532,7 @@ func TestFindOpenClawConfig(t *testing.T) {
t.Run("falls back to config.json", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
- os.WriteFile(configPath, []byte("{}"), 0644)
+ os.WriteFile(configPath, []byte("{}"), 0o644)
found, err := findOpenClawConfig(tmpDir)
if err != nil {
@@ -546,8 +546,8 @@ func TestFindOpenClawConfig(t *testing.T) {
t.Run("prefers openclaw.json over config.json", func(t *testing.T) {
tmpDir := t.TempDir()
openclawPath := filepath.Join(tmpDir, "openclaw.json")
- os.WriteFile(openclawPath, []byte("{}"), 0644)
- os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644)
+ os.WriteFile(openclawPath, []byte("{}"), 0o644)
+ os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0o644)
found, err := findOpenClawConfig(tmpDir)
if err != nil {
@@ -593,19 +593,19 @@ func TestRunDryRun(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
- os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
+ os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "test-key",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
DryRun: true,
@@ -634,33 +634,33 @@ func TestRunFullMigration(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644)
- os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
- os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0o644)
+ os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
+ os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0o644)
memDir := filepath.Join(wsDir, "memory")
- os.MkdirAll(memDir, 0755)
- os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644)
+ os.MkdirAll(memDir, 0o755)
+ os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-ant-migrate-test",
},
- "openrouter": map[string]interface{}{
+ "openrouter": map[string]any{
"apiKey": "sk-or-migrate-test",
},
},
- "channels": map[string]interface{}{
- "telegram": map[string]interface{}{
+ "channels": map[string]any{
+ "telegram": map[string]any{
"enabled": true,
"token": "tg-migrate-test",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
Force: true,
@@ -754,7 +754,7 @@ func TestRunMutuallyExclusiveFlags(t *testing.T) {
func TestBackupFile(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.md")
- os.WriteFile(filePath, []byte("original content"), 0644)
+ os.WriteFile(filePath, []byte("original content"), 0o644)
if err := backupFile(filePath); err != nil {
t.Fatalf("backupFile: %v", err)
@@ -775,7 +775,7 @@ func TestCopyFile(t *testing.T) {
srcPath := filepath.Join(tmpDir, "src.md")
dstPath := filepath.Join(tmpDir, "dst.md")
- os.WriteFile(srcPath, []byte("file content"), 0644)
+ os.WriteFile(srcPath, []byte("file content"), 0o644)
if err := copyFile(srcPath, dstPath); err != nil {
t.Fatalf("copyFile: %v", err)
@@ -795,18 +795,18 @@ func TestRunConfigOnly(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-config-only",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
Force: true,
@@ -835,18 +835,18 @@ func TestRunWorkspaceOnly(t *testing.T) {
picoClawHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
+ os.MkdirAll(wsDir, 0o755)
+ os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
- configData := map[string]interface{}{
- "providers": map[string]interface{}{
- "anthropic": map[string]interface{}{
+ configData := map[string]any{
+ "providers": map[string]any{
+ "anthropic": map[string]any{
"apiKey": "sk-ws-only",
},
},
}
data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
+ os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
opts := Options{
Force: true,
diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go
index 08ac9c829..3d21c1d0b 100644
--- a/pkg/providers/anthropic/provider_test.go
+++ b/pkg/providers/anthropic/provider_test.go
@@ -15,7 +15,7 @@ func TestBuildParams_BasicMessage(t *testing.T) {
messages := []Message{
{Role: "user", Content: "Hello"},
}
- params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{
+ params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{
"max_tokens": 1024,
})
if err != nil {
@@ -37,7 +37,7 @@ func TestBuildParams_SystemMessage(t *testing.T) {
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hi"},
}
- params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{})
+ params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
@@ -62,13 +62,13 @@ func TestBuildParams_ToolCallMessage(t *testing.T) {
{
ID: "call_1",
Name: "get_weather",
- Arguments: map[string]interface{}{"city": "SF"},
+ Arguments: map[string]any{"city": "SF"},
},
},
},
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
}
- params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{})
+ params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
@@ -84,17 +84,17 @@ func TestBuildParams_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{
Name: "get_weather",
Description: "Get weather for a city",
- Parameters: map[string]interface{}{
+ Parameters: map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "city": map[string]interface{}{"type": "string"},
+ "properties": map[string]any{
+ "city": map[string]any{"type": "string"},
},
- "required": []interface{}{"city"},
+ "required": []any{"city"},
},
},
},
}
- params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]interface{}{})
+ params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]any{})
if err != nil {
t.Fatalf("buildParams() error: %v", err)
}
@@ -154,19 +154,19 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
json.NewDecoder(r.Body).Decode(&reqBody)
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": reqBody["model"],
"stop_reason": "end_turn",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "text", "text": "Hello! How can I help you?"},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 15,
"output_tokens": 8,
},
@@ -178,7 +178,7 @@ func TestProvider_ChatRoundTrip(t *testing.T) {
provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
messages := []Message{{Role: "user", Content: "Hello"}}
- resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024})
+ resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024})
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@@ -221,19 +221,19 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
json.NewDecoder(r.Body).Decode(&reqBody)
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": reqBody["model"],
"stop_reason": "end_turn",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "text", "text": "ok"},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 1,
"output_tokens": 1,
},
@@ -247,7 +247,13 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
return "refreshed-token", nil
}, server.URL)
- _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4.6", map[string]interface{}{})
+ _, err := p.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "claude-sonnet-4.6",
+ map[string]any{},
+ )
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go
index 6c6bf7830..cff67c88c 100644
--- a/pkg/providers/antigravity_provider.go
+++ b/pkg/providers/antigravity_provider.go
@@ -45,7 +45,13 @@ func NewAntigravityProvider() *AntigravityProvider {
// Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API.
// The v1internal endpoint wraps the standard Gemini request in an envelope with
// project, model, request, requestType, userAgent, and requestId fields.
-func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *AntigravityProvider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
accessToken, projectID, err := p.tokenSource()
if err != nil {
return nil, fmt.Errorf("antigravity auth: %w", err)
@@ -58,7 +64,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
model = strings.TrimPrefix(model, "google-antigravity/")
model = strings.TrimPrefix(model, "antigravity/")
- logger.DebugCF("provider.antigravity", "Starting chat", map[string]interface{}{
+ logger.DebugCF("provider.antigravity", "Starting chat", map[string]any{
"model": model,
"project": projectID,
"requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
@@ -68,7 +74,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
innerRequest := p.buildRequest(messages, tools, model, options)
// Wrap in v1internal envelope (matches pi-ai SDK format)
- envelope := map[string]interface{}{
+ envelope := map[string]any{
"project": projectID,
"model": model,
"request": innerRequest,
@@ -115,7 +121,7 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
}
if resp.StatusCode != http.StatusOK {
- logger.ErrorCF("provider.antigravity", "API call failed", map[string]interface{}{
+ logger.ErrorCF("provider.antigravity", "API call failed", map[string]any{
"status_code": resp.StatusCode,
"response": string(respBody),
"model": model,
@@ -133,7 +139,9 @@ func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tool
// Check for empty response (some models might return valid success but empty text)
if llmResp.Content == "" && len(llmResp.ToolCalls) == 0 {
- return nil, fmt.Errorf("antigravity: model returned an empty response (this model might be invalid or restricted)")
+ return nil, fmt.Errorf(
+ "antigravity: model returned an empty response (this model might be invalid or restricted)",
+ )
}
return llmResp, nil
@@ -167,13 +175,13 @@ type antigravityPart struct {
}
type antigravityFunctionCall struct {
- Name string `json:"name"`
- Args map[string]interface{} `json:"args"`
+ Name string `json:"name"`
+ Args map[string]any `json:"args"`
}
type antigravityFunctionResponse struct {
- Name string `json:"name"`
- Response map[string]interface{} `json:"response"`
+ Name string `json:"name"`
+ Response map[string]any `json:"response"`
}
type antigravityTool struct {
@@ -181,9 +189,9 @@ type antigravityTool struct {
}
type antigravityFuncDecl struct {
- Name string `json:"name"`
- Description string `json:"description,omitempty"`
- Parameters interface{} `json:"parameters,omitempty"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Parameters any `json:"parameters,omitempty"`
}
type antigravitySystemPrompt struct {
@@ -195,7 +203,12 @@ type antigravityGenConfig struct {
Temperature float64 `json:"temperature,omitempty"`
}
-func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) antigravityRequest {
+func (p *AntigravityProvider) buildRequest(
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) antigravityRequest {
req := antigravityRequest{}
toolCallNames := make(map[string]string)
@@ -215,7 +228,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
Parts: []antigravityPart{{
FunctionResponse: &antigravityFunctionResponse{
Name: toolName,
- Response: map[string]interface{}{
+ Response: map[string]any{
"result": msg.Content,
},
},
@@ -237,9 +250,13 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
for _, tc := range msg.ToolCalls {
toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
if toolName == "" {
- logger.WarnCF("provider.antigravity", "Skipping tool call with empty name in history", map[string]interface{}{
- "tool_call_id": tc.ID,
- })
+ logger.WarnCF(
+ "provider.antigravity",
+ "Skipping tool call with empty name in history",
+ map[string]any{
+ "tool_call_id": tc.ID,
+ },
+ )
continue
}
if tc.ID != "" {
@@ -264,7 +281,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
Parts: []antigravityPart{{
FunctionResponse: &antigravityFunctionResponse{
Name: toolName,
- Response: map[string]interface{}{
+ Response: map[string]any{
"result": msg.Content,
},
},
@@ -311,7 +328,7 @@ func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefin
return req
}
-func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, string) {
+func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) {
name := tc.Name
args := tc.Arguments
thoughtSignature := ""
@@ -324,11 +341,11 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, strin
}
if args == nil {
- args = map[string]interface{}{}
+ args = map[string]any{}
}
if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" {
- var parsed map[string]interface{}
+ var parsed map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil {
args = parsed
}
@@ -483,9 +500,12 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
Name: part.FunctionCall.Name,
Arguments: part.FunctionCall.Args,
Function: &FunctionCall{
- Name: part.FunctionCall.Name,
- Arguments: string(argumentsJSON),
- ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
+ Name: part.FunctionCall.Name,
+ Arguments: string(argumentsJSON),
+ ThoughtSignature: extractPartThoughtSignature(
+ part.ThoughtSignature,
+ part.ThoughtSignatureSnake,
+ ),
},
})
}
@@ -556,24 +576,24 @@ var geminiUnsupportedKeywords = map[string]bool{
"maxProperties": true,
}
-func sanitizeSchemaForGemini(schema map[string]interface{}) map[string]interface{} {
+func sanitizeSchemaForGemini(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
- result := make(map[string]interface{})
+ result := make(map[string]any)
for k, v := range schema {
if geminiUnsupportedKeywords[k] {
continue
}
// Recursively sanitize nested objects
switch val := v.(type) {
- case map[string]interface{}:
+ case map[string]any:
result[k] = sanitizeSchemaForGemini(val)
- case []interface{}:
- sanitized := make([]interface{}, len(val))
+ case []any:
+ sanitized := make([]any, len(val))
for i, item := range val {
- if m, ok := item.(map[string]interface{}); ok {
+ if m, ok := item.(map[string]any); ok {
sanitized[i] = sanitizeSchemaForGemini(m)
} else {
sanitized[i] = item
@@ -604,7 +624,9 @@ func createAntigravityTokenSource() func() (string, string, error) {
return "", "", fmt.Errorf("loading auth credentials: %w", err)
}
if cred == nil {
- return "", "", fmt.Errorf("no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity")
+ return "", "", fmt.Errorf(
+ "no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity",
+ )
}
// Refresh if needed
@@ -625,7 +647,9 @@ func createAntigravityTokenSource() func() (string, string, error) {
}
if cred.IsExpired() {
- return "", "", fmt.Errorf("antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity")
+ return "", "", fmt.Errorf(
+ "antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity",
+ )
}
projectID := cred.ProjectID
@@ -633,7 +657,7 @@ func createAntigravityTokenSource() func() (string, string, error) {
// Try to fetch project ID from API
fetchedID, err := FetchAntigravityProjectID(cred.AccessToken)
if err != nil {
- logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]interface{}{
+ logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]any{
"error": err.Error(),
})
projectID = "rising-fact-p41fc" // Default fallback (same as OpenCode)
@@ -650,8 +674,8 @@ func createAntigravityTokenSource() func() (string, string, error) {
// FetchAntigravityProjectID retrieves the Google Cloud project ID from the loadCodeAssist endpoint.
func FetchAntigravityProjectID(accessToken string) (string, error) {
- reqBody, _ := json.Marshal(map[string]interface{}{
- "metadata": map[string]interface{}{
+ reqBody, _ := json.Marshal(map[string]any{
+ "metadata": map[string]any{
"ideType": "IDE_UNSPECIFIED",
"platform": "PLATFORM_UNSPECIFIED",
"pluginType": "GEMINI",
@@ -695,7 +719,7 @@ func FetchAntigravityProjectID(accessToken string) (string, error) {
// FetchAntigravityModels fetches available models from the Cloud Code Assist API.
func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) {
- reqBody, _ := json.Marshal(map[string]interface{}{
+ reqBody, _ := json.Marshal(map[string]any{
"project": projectID,
})
@@ -717,16 +741,20 @@ func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelIn
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("fetchAvailableModels failed (HTTP %d): %s", resp.StatusCode, truncateString(string(body), 200))
+ return nil, fmt.Errorf(
+ "fetchAvailableModels failed (HTTP %d): %s",
+ resp.StatusCode,
+ truncateString(string(body), 200),
+ )
}
var result struct {
Models map[string]struct {
DisplayName string `json:"displayName"`
QuotaInfo struct {
- RemainingFraction interface{} `json:"remainingFraction"`
- ResetTime string `json:"resetTime"`
- IsExhausted bool `json:"isExhausted"`
+ RemainingFraction any `json:"remainingFraction"`
+ ResetTime string `json:"resetTime"`
+ IsExhausted bool `json:"isExhausted"`
} `json:"quotaInfo"`
} `json:"models"`
}
@@ -797,10 +825,10 @@ func randomString(n int) string {
func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte) error {
var errResp struct {
Error struct {
- Code int `json:"code"`
- Message string `json:"message"`
- Status string `json:"status"`
- Details []map[string]interface{} `json:"details"`
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Status string `json:"status"`
+ Details []map[string]any `json:"details"`
} `json:"error"`
}
@@ -813,7 +841,7 @@ func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte)
// Try to extract quota reset info
for _, detail := range errResp.Error.Details {
if typeVal, ok := detail["@type"].(string); ok && strings.HasSuffix(typeVal, "ErrorInfo") {
- if metadata, ok := detail["metadata"].(map[string]interface{}); ok {
+ if metadata, ok := detail["metadata"].(map[string]any); ok {
if delay, ok := metadata["quotaResetDelay"].(string); ok {
return fmt.Errorf("antigravity rate limit exceeded: %s (reset in %s)", msg, delay)
}
diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/claude_provider_test.go
index b1bcd8b40..98e07bb80 100644
--- a/pkg/providers/claude_provider_test.go
+++ b/pkg/providers/claude_provider_test.go
@@ -8,6 +8,7 @@ import (
"github.com/anthropics/anthropic-sdk-go"
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
+
anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
)
@@ -22,19 +23,19 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
return
}
- var reqBody map[string]interface{}
+ var reqBody map[string]any
json.NewDecoder(r.Body).Decode(&reqBody)
- resp := map[string]interface{}{
+ resp := map[string]any{
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": reqBody["model"],
"stop_reason": "end_turn",
- "content": []map[string]interface{}{
+ "content": []map[string]any{
{"type": "text", "text": "Hello! How can I help you?"},
},
- "usage": map[string]interface{}{
+ "usage": map[string]any{
"input_tokens": 15,
"output_tokens": 8,
},
@@ -48,7 +49,7 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
provider := newClaudeProviderWithDelegate(delegate)
messages := []Message{{Role: "user", Content: "Hello"}}
- resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024})
+ resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024})
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index eeaa9690a..d0c4344f3 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -28,7 +28,13 @@ func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField st
}
}
-func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *HTTPProvider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
return p.delegate.Chat(ctx, messages, tools, model, options)
}
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 6bc43a470..b8528953a 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -15,15 +15,17 @@ import (
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
-type ToolCall = protocoltypes.ToolCall
-type FunctionCall = protocoltypes.FunctionCall
-type LLMResponse = protocoltypes.LLMResponse
-type UsageInfo = protocoltypes.UsageInfo
-type Message = protocoltypes.Message
-type ToolDefinition = protocoltypes.ToolDefinition
-type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
-type ExtraContent = protocoltypes.ExtraContent
-type GoogleExtra = protocoltypes.GoogleExtra
+type (
+ ToolCall = protocoltypes.ToolCall
+ FunctionCall = protocoltypes.FunctionCall
+ LLMResponse = protocoltypes.LLMResponse
+ UsageInfo = protocoltypes.UsageInfo
+ Message = protocoltypes.Message
+ ToolDefinition = protocoltypes.ToolDefinition
+ ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+ ExtraContent = protocoltypes.ExtraContent
+ GoogleExtra = protocoltypes.GoogleExtra
+)
type Provider struct {
apiKey string
@@ -60,14 +62,20 @@ func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string
}
}
-func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *Provider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
if p.apiBase == "" {
return nil, fmt.Errorf("API base not configured")
}
model = normalizeModel(model, p.apiBase)
- requestBody := map[string]interface{}{
+ requestBody := map[string]any{
"model": model,
"messages": messages,
}
@@ -83,7 +91,8 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef
if fieldName == "" {
// Fallback: detect from model name for backward compatibility
lowerModel := strings.ToLower(model)
- if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") {
+ if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") ||
+ strings.Contains(lowerModel, "gpt-5") {
fieldName = "max_completion_tokens"
} else {
fieldName = "max_tokens"
@@ -173,7 +182,7 @@ func parseResponse(body []byte) (*LLMResponse, error) {
choice := apiResponse.Choices[0]
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
for _, tc := range choice.Message.ToolCalls {
- arguments := make(map[string]interface{})
+ arguments := make(map[string]any)
name := ""
// Extract thought_signature from Gemini/Google-specific extra content
@@ -238,7 +247,7 @@ func normalizeModel(model, apiBase string) string {
}
}
-func asInt(v interface{}) (int, bool) {
+func asInt(v any) (int, bool) {
switch val := v.(type) {
case int:
return val, true
@@ -253,7 +262,7 @@ func asInt(v interface{}) (int, bool) {
}
}
-func asFloat(v interface{}) (float64, bool) {
+func asFloat(v any) (float64, bool) {
switch val := v.(type) {
case float64:
return val, true
diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go
index b7e7062b9..3a089ca47 100644
--- a/pkg/providers/protocoltypes/types.go
+++ b/pkg/providers/protocoltypes/types.go
@@ -1,13 +1,13 @@
package protocoltypes
type ToolCall struct {
- ID string `json:"id"`
- Type string `json:"type,omitempty"`
- Function *FunctionCall `json:"function,omitempty"`
- Name string `json:"name,omitempty"`
- Arguments map[string]interface{} `json:"arguments,omitempty"`
- ThoughtSignature string `json:"-"` // Internal use only
- ExtraContent *ExtraContent `json:"extra_content,omitempty"`
+ ID string `json:"id"`
+ Type string `json:"type,omitempty"`
+ Function *FunctionCall `json:"function,omitempty"`
+ Name string `json:"name,omitempty"`
+ Arguments map[string]any `json:"arguments,omitempty"`
+ ThoughtSignature string `json:"-"` // Internal use only
+ ExtraContent *ExtraContent `json:"extra_content,omitempty"`
}
type ExtraContent struct {
@@ -50,7 +50,7 @@ type ToolDefinition struct {
}
type ToolFunctionDefinition struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Parameters map[string]interface{} `json:"parameters"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters map[string]any `json:"parameters"`
}
diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/toolcall_utils.go
index c7c35ef42..49218b1b1 100644
--- a/pkg/providers/toolcall_utils.go
+++ b/pkg/providers/toolcall_utils.go
@@ -20,12 +20,12 @@ func NormalizeToolCall(tc ToolCall) ToolCall {
// Ensure Arguments is not nil
if normalized.Arguments == nil {
- normalized.Arguments = map[string]interface{}{}
+ normalized.Arguments = map[string]any{}
}
// Parse Arguments from Function.Arguments if not already set
if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" {
- var parsed map[string]interface{}
+ var parsed map[string]any
if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil {
normalized.Arguments = parsed
}
diff --git a/pkg/providers/types.go b/pkg/providers/types.go
index e783e6348..f711e7803 100644
--- a/pkg/providers/types.go
+++ b/pkg/providers/types.go
@@ -7,18 +7,26 @@ import (
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
-type ToolCall = protocoltypes.ToolCall
-type FunctionCall = protocoltypes.FunctionCall
-type LLMResponse = protocoltypes.LLMResponse
-type UsageInfo = protocoltypes.UsageInfo
-type Message = protocoltypes.Message
-type ToolDefinition = protocoltypes.ToolDefinition
-type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
-type ExtraContent = protocoltypes.ExtraContent
-type GoogleExtra = protocoltypes.GoogleExtra
+type (
+ ToolCall = protocoltypes.ToolCall
+ FunctionCall = protocoltypes.FunctionCall
+ LLMResponse = protocoltypes.LLMResponse
+ UsageInfo = protocoltypes.UsageInfo
+ Message = protocoltypes.Message
+ ToolDefinition = protocoltypes.ToolDefinition
+ ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+ ExtraContent = protocoltypes.ExtraContent
+ GoogleExtra = protocoltypes.GoogleExtra
+)
type LLMProvider interface {
- Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
+ Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ ) (*LLMResponse, error)
GetDefaultModel() string
}
diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go
index e2a940afd..f78197bbe 100644
--- a/pkg/skills/clawhub_registry.go
+++ b/pkg/skills/clawhub_registry.go
@@ -214,7 +214,10 @@ func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*Skill
// DownloadAndInstall fetches metadata (with fallback), resolves version,
// downloads the skill ZIP, and extracts it to targetDir.
// Returns an InstallResult for the caller to use for moderation decisions.
-func (c *ClawHubRegistry) DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error) {
+func (c *ClawHubRegistry) DownloadAndInstall(
+ ctx context.Context,
+ slug, version, targetDir string,
+) (*InstallResult, error) {
if err := utils.ValidateSkillIdentifier(slug); err != nil {
return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error())
}
diff --git a/pkg/skills/clawhub_registry_test.go b/pkg/skills/clawhub_registry_test.go
index d12e19504..65ee638da 100644
--- a/pkg/skills/clawhub_registry_test.go
+++ b/pkg/skills/clawhub_registry_test.go
@@ -11,9 +11,10 @@ import (
"path/filepath"
"testing"
- "github.com/sipeed/picoclaw/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
)
func newTestRegistry(serverURL, authToken string) *ClawHubRegistry {
@@ -162,7 +163,7 @@ func TestExtractZipPathTraversal(t *testing.T) {
// Write to temp file for extractZipFile.
tmpZip := filepath.Join(t.TempDir(), "bad.zip")
- require.NoError(t, os.WriteFile(tmpZip, buf.Bytes(), 0644))
+ require.NoError(t, os.WriteFile(tmpZip, buf.Bytes(), 0o644))
tmpDir := t.TempDir()
err = utils.ExtractZipFile(tmpZip, tmpDir)
@@ -179,7 +180,7 @@ func TestExtractZipWithSubdirectories(t *testing.T) {
// Write to temp file for extractZipFile.
tmpZip := filepath.Join(t.TempDir(), "test.zip")
- require.NoError(t, os.WriteFile(tmpZip, zipBuf, 0644))
+ require.NoError(t, os.WriteFile(tmpZip, zipBuf, 0o644))
tmpDir := t.TempDir()
targetDir := filepath.Join(tmpDir, "my-skill")
diff --git a/pkg/skills/registry_test.go b/pkg/skills/registry_test.go
index daecd5a59..a4694bd43 100644
--- a/pkg/skills/registry_test.go
+++ b/pkg/skills/registry_test.go
@@ -6,8 +6,9 @@ import (
"testing"
"time"
- "github.com/sipeed/picoclaw/pkg/utils"
"github.com/stretchr/testify/assert"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
)
// mockRegistry is a test double implementing SkillRegistry.
diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go
index 4c6388b9b..04ef8e441 100644
--- a/pkg/tools/shell_timeout_unix_test.go
+++ b/pkg/tools/shell_timeout_unix_test.go
@@ -25,7 +25,7 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
tool := NewExecTool(t.TempDir(), false)
tool.SetTimeout(500 * time.Millisecond)
- args := map[string]interface{}{
+ args := map[string]any{
// Spawn a child process that would outlive the shell unless process-group kill is used.
"command": "sleep 60 & echo $! > child.pid; wait",
}
diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go
index 6b05918ce..55c0b678d 100644
--- a/pkg/tools/skills_install.go
+++ b/pkg/tools/skills_install.go
@@ -42,23 +42,23 @@ func (t *InstallSkillTool) Description() string {
return "Install a skill from a registry by slug. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills."
}
-func (t *InstallSkillTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *InstallSkillTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "slug": map[string]interface{}{
+ "properties": map[string]any{
+ "slug": map[string]any{
"type": "string",
"description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')",
},
- "version": map[string]interface{}{
+ "version": map[string]any{
"type": "string",
"description": "Specific version to install (optional, defaults to latest)",
},
- "registry": map[string]interface{}{
+ "registry": map[string]any{
"type": "string",
"description": "Registry to install from (required, e.g., 'clawhub')",
},
- "force": map[string]interface{}{
+ "force": map[string]any{
"type": "boolean",
"description": "Force reinstall if skill already exists (default false)",
},
@@ -67,7 +67,7 @@ func (t *InstallSkillTool) Parameters() map[string]interface{} {
}
}
-func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
// Install lock to prevent concurrent directory operations.
// Ideally this should be done at a `slug` level, currently, its at a `workspace` level.
t.mu.Lock()
@@ -94,7 +94,9 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
if !force {
if _, err := os.Stat(targetDir); err == nil {
- return ErrorResult(fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir))
+ return ErrorResult(
+ fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir),
+ )
}
} else {
// Force: remove existing if present.
@@ -108,7 +110,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
}
// Ensure skills directory exists.
- if err := os.MkdirAll(skillsDir, 0755); err != nil {
+ if err := os.MkdirAll(skillsDir, 0o755); err != nil {
return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err))
}
@@ -119,7 +121,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
logger.ErrorCF("tool", "Failed to remove partial install",
- map[string]interface{}{
+ map[string]any{
"tool": "install_skill",
"target_dir": targetDir,
"error": rmErr.Error(),
@@ -133,7 +135,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
logger.ErrorCF("tool", "Failed to remove partial install",
- map[string]interface{}{
+ map[string]any{
"tool": "install_skill",
"target_dir": targetDir,
"error": rmErr.Error(),
@@ -145,7 +147,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interfac
// Write origin metadata.
if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil {
logger.ErrorCF("tool", "Failed to write origin metadata",
- map[string]interface{}{
+ map[string]any{
"tool": "install_skill",
"error": err.Error(),
"target": targetDir,
@@ -195,5 +197,5 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
return err
}
- return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0644)
+ return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644)
}
diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/skills_install_test.go
index e6941a950..676fcecc0 100644
--- a/pkg/tools/skills_install_test.go
+++ b/pkg/tools/skills_install_test.go
@@ -6,9 +6,10 @@ import (
"path/filepath"
"testing"
- "github.com/sipeed/picoclaw/pkg/skills"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
)
func TestInstallSkillToolName(t *testing.T) {
@@ -18,14 +19,14 @@ func TestInstallSkillToolName(t *testing.T) {
func TestInstallSkillToolMissingSlug(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]interface{}{})
+ result := tool.Execute(context.Background(), map[string]any{})
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
}
func TestInstallSkillToolEmptySlug(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"slug": " ",
})
assert.True(t, result.IsError)
@@ -42,7 +43,7 @@ func TestInstallSkillToolUnsafeSlug(t *testing.T) {
}
for _, slug := range cases {
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"slug": slug,
})
assert.True(t, result.IsError, "slug %q should be rejected", slug)
@@ -53,10 +54,10 @@ func TestInstallSkillToolUnsafeSlug(t *testing.T) {
func TestInstallSkillToolAlreadyExists(t *testing.T) {
workspace := t.TempDir()
skillDir := filepath.Join(workspace, "skills", "existing-skill")
- require.NoError(t, os.MkdirAll(skillDir, 0755))
+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"slug": "existing-skill",
"registry": "clawhub",
})
@@ -67,7 +68,7 @@ func TestInstallSkillToolAlreadyExists(t *testing.T) {
func TestInstallSkillToolRegistryNotFound(t *testing.T) {
workspace := t.TempDir()
tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"slug": "some-skill",
"registry": "nonexistent",
})
@@ -80,7 +81,7 @@ func TestInstallSkillToolParameters(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
params := tool.Parameters()
- props, ok := params["properties"].(map[string]interface{})
+ props, ok := params["properties"].(map[string]any)
assert.True(t, ok)
assert.Contains(t, props, "slug")
assert.Contains(t, props, "version")
@@ -95,7 +96,7 @@ func TestInstallSkillToolParameters(t *testing.T) {
func TestInstallSkillToolMissingRegistry(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"slug": "some-skill",
})
assert.True(t, result.IsError)
diff --git a/pkg/tools/skills_search.go b/pkg/tools/skills_search.go
index b12949ec2..2b6cffd38 100644
--- a/pkg/tools/skills_search.go
+++ b/pkg/tools/skills_search.go
@@ -32,15 +32,15 @@ func (t *FindSkillsTool) Description() string {
return "Search for installable skills from skill registries. Returns skill slugs, descriptions, versions, and relevance scores. Use this to discover skills before installing them with install_skill."
}
-func (t *FindSkillsTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (t *FindSkillsTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{
- "query": map[string]interface{}{
+ "properties": map[string]any{
+ "query": map[string]any{
"type": "string",
"description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')",
},
- "limit": map[string]interface{}{
+ "limit": map[string]any{
"type": "integer",
"description": "Maximum number of results to return (1-20, default 5)",
"minimum": 1.0,
@@ -51,7 +51,7 @@ func (t *FindSkillsTool) Parameters() map[string]interface{} {
}
}
-func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
query, ok := args["query"].(string)
query = strings.ToLower(strings.TrimSpace(query))
if !ok || query == "" {
diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/skills_search_test.go
index 7e07b2775..0e5387cf5 100644
--- a/pkg/tools/skills_search_test.go
+++ b/pkg/tools/skills_search_test.go
@@ -4,8 +4,9 @@ import (
"context"
"testing"
- "github.com/sipeed/picoclaw/pkg/skills"
"github.com/stretchr/testify/assert"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
)
func TestFindSkillsToolName(t *testing.T) {
@@ -15,14 +16,14 @@ func TestFindSkillsToolName(t *testing.T) {
func TestFindSkillsToolMissingQuery(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
- result := tool.Execute(context.Background(), map[string]interface{}{})
+ result := tool.Execute(context.Background(), map[string]any{})
assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "query is required")
}
func TestFindSkillsToolEmptyQuery(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"query": " ",
})
assert.True(t, result.IsError)
@@ -35,7 +36,7 @@ func TestFindSkillsToolCacheHit(t *testing.T) {
})
tool := NewFindSkillsTool(skills.NewRegistryManager(), cache)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"query": "github",
})
@@ -48,7 +49,7 @@ func TestFindSkillsToolParameters(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
params := tool.Parameters()
- props, ok := params["properties"].(map[string]interface{})
+ props, ok := params["properties"].(map[string]any)
assert.True(t, ok)
assert.Contains(t, props, "query")
assert.Contains(t, props, "limit")
@@ -71,7 +72,14 @@ func TestFormatSearchResultsEmpty(t *testing.T) {
func TestFormatSearchResultsWithData(t *testing.T) {
results := []skills.SearchResult{
- {Slug: "github", Score: 0.95, DisplayName: "GitHub", Summary: "GitHub API integration", Version: "1.0.0", RegistryName: "clawhub"},
+ {
+ Slug: "github",
+ Score: 0.95,
+ DisplayName: "GitHub",
+ Summary: "GitHub API integration",
+ Version: "1.0.0",
+ RegistryName: "clawhub",
+ },
}
output := formatSearchResults("github", results, false)
assert.Contains(t, output, "github")
diff --git a/pkg/utils/download.go b/pkg/utils/download.go
index 9fa7fbfa7..5d9a13a30 100644
--- a/pkg/utils/download.go
+++ b/pkg/utils/download.go
@@ -27,7 +27,7 @@ func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request,
// Attach context.
req = req.WithContext(ctx)
- logger.DebugCF("download", "Starting download", map[string]interface{}{
+ logger.DebugCF("download", "Starting download", map[string]any{
"url": req.URL.String(),
"max_bytes": maxBytes,
})
@@ -52,7 +52,7 @@ func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request,
}
tmpPath := tmpFile.Name()
- logger.DebugCF("download", "Streaming to temp file", map[string]interface{}{
+ logger.DebugCF("download", "Streaming to temp file", map[string]any{
"path": tmpPath,
})
@@ -84,7 +84,7 @@ func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request,
return "", fmt.Errorf("failed to close temp file: %w", err)
}
- logger.DebugCF("download", "Download complete", map[string]interface{}{
+ logger.DebugCF("download", "Download complete", map[string]any{
"path": tmpPath,
"bytes_written": written,
})
diff --git a/pkg/utils/zip.go b/pkg/utils/zip.go
index cad91e420..919ce5a20 100644
--- a/pkg/utils/zip.go
+++ b/pkg/utils/zip.go
@@ -22,13 +22,13 @@ func ExtractZipFile(zipPath string, targetDir string) error {
}
defer reader.Close()
- logger.DebugCF("zip", "Extracting ZIP", map[string]interface{}{
+ logger.DebugCF("zip", "Extracting ZIP", map[string]any{
"zip_path": zipPath,
"target_dir": targetDir,
"entries": len(reader.File),
})
- if err := os.MkdirAll(targetDir, 0755); err != nil {
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("failed to create target dir: %w", err)
}
@@ -43,7 +43,8 @@ func ExtractZipFile(zipPath string, targetDir string) error {
// Double-check the resolved path is within target directory (defense-in-depth).
targetDirClean := filepath.Clean(targetDir)
- if !strings.HasPrefix(filepath.Clean(destPath), targetDirClean+string(filepath.Separator)) && filepath.Clean(destPath) != targetDirClean {
+ if !strings.HasPrefix(filepath.Clean(destPath), targetDirClean+string(filepath.Separator)) &&
+ filepath.Clean(destPath) != targetDirClean {
return fmt.Errorf("zip entry escapes target dir: %q", f.Name)
}
@@ -55,14 +56,14 @@ func ExtractZipFile(zipPath string, targetDir string) error {
}
if f.FileInfo().IsDir() {
- if err := os.MkdirAll(destPath, 0755); err != nil {
+ if err := os.MkdirAll(destPath, 0o755); err != nil {
return err
}
continue
}
// Ensure parent directory exists.
- if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return err
}
@@ -98,7 +99,7 @@ func extractSingleFile(f *zip.File, destPath string) error {
defer func() {
if cerr := outFile.Close(); cerr != nil {
_ = os.Remove(destPath)
- logger.ErrorCF("zip", "Failed to close file", map[string]interface{}{
+ logger.ErrorCF("zip", "Failed to close file", map[string]any{
"dest_path": destPath,
"error": cerr.Error(),
})
From 5ca239b5c502df157ed54774aa46a6c9e3fed1ff Mon Sep 17 00:00:00 2001
From: harshbansal7
Date: Sat, 21 Feb 2026 01:02:35 +0530
Subject: [PATCH 16/88] fix
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index d7d8be80b..7bc7b1089 100644
--- a/README.md
+++ b/README.md
@@ -243,7 +243,7 @@ picoclaw onboard
}
```
-> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#-model-configuration) for details.
+> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
**3. Get API Keys**
From 123cffa85a1d69c5489a46e4a8f49630f130df03 Mon Sep 17 00:00:00 2001
From: harshbansal7
Date: Sat, 21 Feb 2026 01:04:48 +0530
Subject: [PATCH 17/88] fix 2
---
README.zh.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.zh.md b/README.zh.md
index 0989770ca..ab896b6c0 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -256,7 +256,7 @@ picoclaw onboard
```
-> **ę°åč½**: `model_list` é
ē½®ę ¼å¼ęÆęé¶ä»£ē ę·»å providerć详č§[樔åé
ē½®](#-樔åé
ē½®-model_list)ē« čć
+> **ę°åč½**: `model_list` é
ē½®ę ¼å¼ęÆęé¶ä»£ē ę·»å providerć详č§[樔åé
ē½®](#樔åé
ē½®-model_list)ē« čć
**3. č·å API Key**
From c2ace2561cfae81839e977f90da7e29017d7b8e7 Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Fri, 20 Feb 2026 22:09:36 +0200
Subject: [PATCH 18/88] feat(ci): Remove fmt from build step
---
.github/workflows/build.yml | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 499613625..9b89b69ae 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,7 +2,7 @@ name: build
on:
push:
- branches: ["main"]
+ branches: [ "main" ]
jobs:
build:
@@ -16,10 +16,5 @@ jobs:
with:
go-version-file: go.mod
- - name: fmt
- run: |
- make fmt
- git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
-
- name: Build
run: make build-all
From 02b4d9fbe2dea85fb032ceba7d4c64f1499ba95a Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Fri, 20 Feb 2026 22:35:16 +0200
Subject: [PATCH 19/88] feat(linter): Fix govet linter
---
.github/workflows/pr.yml | 19 -------------------
.golangci.yaml | 1 -
cmd/picoclaw/cmd_auth.go | 6 +++---
cmd/picoclaw/cmd_gateway.go | 3 ++-
cmd/picoclaw/cmd_skills.go | 4 ++--
pkg/channels/telegram.go | 4 ++--
pkg/channels/wecom.go | 2 +-
pkg/channels/wecom_app.go | 2 +-
pkg/channels/wecom_app_test.go | 13 -------------
pkg/channels/wecom_test.go | 4 +---
pkg/migrate/migrate.go | 2 +-
pkg/migrate/migrate_test.go | 10 +++++-----
pkg/providers/codex_cli_credentials.go | 2 +-
pkg/tools/edit.go | 2 +-
pkg/tools/filesystem.go | 8 +++++---
pkg/tools/i2c_linux.go | 2 +-
pkg/voice/transcriber.go | 6 +++---
17 files changed, 29 insertions(+), 61 deletions(-)
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 27782ced2..be1c10c52 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -24,25 +24,6 @@ jobs:
with:
version: v2.10.1
- # TODO: Remove once linter is properly configured
- vet:
- name: Vet
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Go
- uses: actions/setup-go@v6
- with:
- go-version-file: go.mod
-
- - name: Run go generate
- run: go generate ./...
-
- - name: Run go vet
- run: go vet ./...
-
test:
name: Tests
runs-on: ubuntu-latest
diff --git a/.golangci.yaml b/.golangci.yaml
index 6dafb6b56..d45d69e67 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -47,7 +47,6 @@ linters:
- godox
- goprintffuncname
- gosec
- - govet
- ineffassign
- lll
- maintidx
diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go
index 5bed7f116..729c56177 100644
--- a/cmd/picoclaw/cmd_auth.go
+++ b/cmd/picoclaw/cmd_auth.go
@@ -114,7 +114,7 @@ func authLoginOpenAI(useDeviceCode bool) {
os.Exit(1)
}
- if err := auth.SetCredential("openai", cred); err != nil {
+ if err = auth.SetCredential("openai", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
@@ -188,7 +188,7 @@ func authLoginGoogleAntigravity() {
fmt.Printf("Project: %s\n", projectID)
}
- if err := auth.SetCredential("google-antigravity", cred); err != nil {
+ if err = auth.SetCredential("google-antigravity", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
@@ -265,7 +265,7 @@ func authLoginPasteToken(provider string) {
os.Exit(1)
}
- if err := auth.SetCredential(provider, cred); err != nil {
+ if err = auth.SetCredential(provider, cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go
index 00ec0f96d..9a3b6aa19 100644
--- a/cmd/picoclaw/cmd_gateway.go
+++ b/cmd/picoclaw/cmd_gateway.go
@@ -98,7 +98,8 @@ func gatewayCmd() {
channel, chatID = "cli", "direct"
}
// Use ProcessHeartbeat - no session history, each heartbeat is independent
- response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
+ var response string
+ response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
}
diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go
index 2dd46756a..0814494b3 100644
--- a/cmd/picoclaw/cmd_skills.go
+++ b/cmd/picoclaw/cmd_skills.go
@@ -118,7 +118,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
workspace := cfg.WorkspacePath()
targetDir := filepath.Join(workspace, "skills", slug)
- if _, err := os.Stat(targetDir); err == nil {
+ if _, err = os.Stat(targetDir); err == nil {
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
os.Exit(1)
}
@@ -126,7 +126,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
- if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
+ if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
os.Exit(1)
}
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index 2a971e147..a0a1c8d0a 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -267,10 +267,10 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
transcribedText := ""
if c.transcriber != nil && c.transcriber.IsAvailable() {
- ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
- result, err := c.transcriber.Transcribe(ctx, voicePath)
+ result, err := c.transcriber.Transcribe(transcriberCtx, voicePath)
if err != nil {
logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{
"error": err.Error(),
diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go
index 07bd8488c..f8daf89de 100644
--- a/pkg/channels/wecom.go
+++ b/pkg/channels/wecom.go
@@ -272,7 +272,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
AgentID string `xml:"AgentID"`
}
- if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
+ if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{
"error": err.Error(),
})
diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go
index 878504106..715c48707 100644
--- a/pkg/channels/wecom_app.go
+++ b/pkg/channels/wecom_app.go
@@ -348,7 +348,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
AgentID string `xml:"AgentID"`
}
- if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
+ if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{
"error": err.Error(),
})
diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom_app_test.go
index 6778520f3..abf15c52b 100644
--- a/pkg/channels/wecom_app_test.go
+++ b/pkg/channels/wecom_app_test.go
@@ -852,19 +852,6 @@ func TestWeComAppMessageStructures(t *testing.T) {
}
})
- t.Run("WeComImageMessage structure", func(t *testing.T) {
- msg := WeComImageMessage{
- ToUser: "user123",
- MsgType: "image",
- AgentID: 1000002,
- }
- msg.Image.MediaID = "media_123456"
-
- if msg.Image.MediaID != "media_123456" {
- t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456")
- }
- })
-
t.Run("WeComAccessTokenResponse structure", func(t *testing.T) {
jsonData := `{
"errcode": 0,
diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom_test.go
index 53cde2693..8afa7e8c3 100644
--- a/pkg/channels/wecom_test.go
+++ b/pkg/channels/wecom_test.go
@@ -198,10 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) {
Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
- base := NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom)
chEmpty := &WeComBotChannel{
- BaseChannel: base,
- config: cfgEmpty,
+ config: cfgEmpty,
}
if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go
index ab2635890..cfa82b7d7 100644
--- a/pkg/migrate/migrate.go
+++ b/pkg/migrate/migrate.go
@@ -67,7 +67,7 @@ func Run(opts Options) (*Result, error) {
return nil, err
}
- if _, err := os.Stat(openclawHome); os.IsNotExist(err) {
+ if _, err = os.Stat(openclawHome); os.IsNotExist(err) {
return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
}
diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go
index ccc00f72c..b6b3d70aa 100644
--- a/pkg/migrate/migrate_test.go
+++ b/pkg/migrate/migrate_test.go
@@ -58,10 +58,10 @@ func TestConvertKeysToSnake(t *testing.T) {
t.Fatal("expected map[string]interface{}")
}
- if _, ok := m["api_key"]; !ok {
+ if _, ok = m["api_key"]; !ok {
t.Error("expected key 'api_key' after conversion")
}
- if _, ok := m["api_base"]; !ok {
+ if _, ok = m["api_base"]; !ok {
t.Error("expected key 'api_base' after conversion")
}
@@ -69,10 +69,10 @@ func TestConvertKeysToSnake(t *testing.T) {
if !ok {
t.Fatal("expected nested map")
}
- if _, ok := nested["max_tokens"]; !ok {
+ if _, ok = nested["max_tokens"]; !ok {
t.Error("expected key 'max_tokens' in nested map")
}
- if _, ok := nested["allow_from"]; !ok {
+ if _, ok = nested["allow_from"]; !ok {
t.Error("expected key 'allow_from' in nested map")
}
@@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(configPath, data, 0o644); err != nil {
+ if err = os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatal(err)
}
diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/codex_cli_credentials.go
index 46ba24b12..40f3ee2a1 100644
--- a/pkg/providers/codex_cli_credentials.go
+++ b/pkg/providers/codex_cli_credentials.go
@@ -31,7 +31,7 @@ func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Ti
}
var auth CodexCliAuth
- if err := json.Unmarshal(data, &auth); err != nil {
+ if err = json.Unmarshal(data, &auth); err != nil {
return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err)
}
diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go
index 39d2642d4..c28ca6ca2 100644
--- a/pkg/tools/edit.go
+++ b/pkg/tools/edit.go
@@ -72,7 +72,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult(err.Error())
}
- if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
+ if _, err = os.Stat(resolvedPath); os.IsNotExist(err) {
return ErrorResult(fmt.Sprintf("file not found: %s", path))
}
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index dd996bc0d..1bf50906e 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -34,17 +34,19 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
return "", fmt.Errorf("access denied: path is outside the workspace")
}
+ var resolved string
workspaceReal := absWorkspace
- if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil {
+ if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil {
workspaceReal = resolved
}
- if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
+ if resolved, err = filepath.EvalSymlinks(absPath); err == nil {
if !isWithinWorkspace(resolved, workspaceReal) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
} else if os.IsNotExist(err) {
- if parentResolved, err := resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
+ var parentResolved string
+ if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
if !isWithinWorkspace(parentResolved, workspaceReal) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go
index 2a0626340..4eaaf8f09 100644
--- a/pkg/tools/i2c_linux.go
+++ b/pkg/tools/i2c_linux.go
@@ -182,7 +182,7 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
if reg < 0 || reg > 255 {
return ErrorResult("register must be between 0x00 and 0xFF")
}
- _, err := syscall.Write(fd, []byte{byte(reg)})
+ _, err = syscall.Write(fd, []byte{byte(reg)})
if err != nil {
return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err))
}
diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go
index ad8767d40..f973e77fe 100644
--- a/pkg/voice/transcriber.go
+++ b/pkg/voice/transcriber.go
@@ -79,17 +79,17 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied})
- if err := writer.WriteField("model", "whisper-large-v3"); err != nil {
+ if err = writer.WriteField("model", "whisper-large-v3"); err != nil {
logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err})
return nil, fmt.Errorf("failed to write model field: %w", err)
}
- if err := writer.WriteField("response_format", "json"); err != nil {
+ if err = writer.WriteField("response_format", "json"); err != nil {
logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err})
return nil, fmt.Errorf("failed to write response_format field: %w", err)
}
- if err := writer.Close(); err != nil {
+ if err = writer.Close(); err != nil {
logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err})
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
}
From 244eb0b47d0f694df4bdc96c316b23698eab7407 Mon Sep 17 00:00:00 2001
From: Goksu Ceylan <79890826+GoCeylan@users.noreply.github.com>
Date: Fri, 20 Feb 2026 19:15:46 -0500
Subject: [PATCH 20/88] fix (security): ExecTool `working_dir` sandbox escape
(#478)
* fix (security) Shell working_dir bypass
* Feedback from @mengzhuo & Discord
- reuse internal security package to validate path
- add tests for workspace escape
---
pkg/tools/shell.go | 10 ++++++-
pkg/tools/shell_test.go | 60 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 69 insertions(+), 1 deletion(-)
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index d2adb6468..a1ee0b6e1 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -144,7 +144,15 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
cwd := t.workingDir
if wd, ok := args["working_dir"].(string); ok && wd != "" {
- cwd = wd
+ if t.restrictToWorkspace && t.workingDir != "" {
+ resolvedWD, err := validatePath(wd, t.workingDir, true)
+ if err != nil {
+ return ErrorResult("Command blocked by safety guard (" + err.Error() + ")")
+ }
+ cwd = resolvedWD
+ } else {
+ cwd = wd
+ }
}
if cwd == "" {
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index f85b5a008..60f2b7b91 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -186,6 +186,66 @@ func TestShellTool_OutputTruncation(t *testing.T) {
}
}
+// TestShellTool_WorkingDir_OutsideWorkspace verifies that working_dir cannot escape the workspace directly
+func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
+ root := t.TempDir()
+ workspace := filepath.Join(root, "workspace")
+ outsideDir := filepath.Join(root, "outside")
+ if err := os.MkdirAll(workspace, 0755); err != nil {
+ t.Fatalf("failed to create workspace: %v", err)
+ }
+ if err := os.MkdirAll(outsideDir, 0755); err != nil {
+ t.Fatalf("failed to create outside dir: %v", err)
+ }
+
+ tool := NewExecTool(workspace, true)
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "command": "pwd",
+ "working_dir": outsideDir,
+ })
+
+ if !result.IsError {
+ t.Fatalf("expected working_dir outside workspace to be blocked, got output: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "blocked") {
+ t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM)
+ }
+}
+
+// TestShellTool_WorkingDir_SymlinkEscape verifies that a symlink inside the workspace
+// pointing outside cannot be used as working_dir to escape the sandbox.
+func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
+ root := t.TempDir()
+ workspace := filepath.Join(root, "workspace")
+ secretDir := filepath.Join(root, "secret")
+ if err := os.MkdirAll(workspace, 0755); err != nil {
+ t.Fatalf("failed to create workspace: %v", err)
+ }
+ if err := os.MkdirAll(secretDir, 0755); err != nil {
+ t.Fatalf("failed to create secret dir: %v", err)
+ }
+ os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0644)
+
+ // symlink lives inside the workspace but resolves to secretDir outside it
+ link := filepath.Join(workspace, "escape")
+ if err := os.Symlink(secretDir, link); err != nil {
+ t.Skipf("symlinks not supported in this environment: %v", err)
+ }
+
+ tool := NewExecTool(workspace, true)
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "command": "cat secret.txt",
+ "working_dir": link,
+ })
+
+ if !result.IsError {
+ t.Fatalf("expected symlink working_dir escape to be blocked, got output: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "blocked") {
+ t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM)
+ }
+}
+
// TestShellTool_RestrictToWorkspace verifies workspace restriction
func TestShellTool_RestrictToWorkspace(t *testing.T) {
tmpDir := t.TempDir()
From 80c8b5753338dc75286d93d8d62fa01654b1a2f0 Mon Sep 17 00:00:00 2001
From: Luke Milby
Date: Fri, 20 Feb 2026 19:21:38 -0500
Subject: [PATCH 21/88] Fix Memory Write (#557)
* fix issue where memory will only trigger when asked to remember something
* updated prompt for memory usage
---
pkg/agent/context.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index e989ffaaf..a9db5afdd 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -80,7 +80,7 @@ Your workspace is at: %s
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
-3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
+3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`,
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
}
From 3df7f705408d5767e43a22975fd2d093f33b7705 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 21 Feb 2026 16:05:39 +0800
Subject: [PATCH 22/88] fix: golangci-lint fmt
---
pkg/tools/shell_test.go | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index 60f2b7b91..d0a300c6c 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -191,10 +191,10 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
root := t.TempDir()
workspace := filepath.Join(root, "workspace")
outsideDir := filepath.Join(root, "outside")
- if err := os.MkdirAll(workspace, 0755); err != nil {
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
- if err := os.MkdirAll(outsideDir, 0755); err != nil {
+ if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatalf("failed to create outside dir: %v", err)
}
@@ -218,13 +218,13 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
root := t.TempDir()
workspace := filepath.Join(root, "workspace")
secretDir := filepath.Join(root, "secret")
- if err := os.MkdirAll(workspace, 0755); err != nil {
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
- if err := os.MkdirAll(secretDir, 0755); err != nil {
+ if err := os.MkdirAll(secretDir, 0o755); err != nil {
t.Fatalf("failed to create secret dir: %v", err)
}
- os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0644)
+ os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0o644)
// symlink lives inside the workspace but resolves to secretDir outside it
link := filepath.Join(workspace, "escape")
From 00666022949fb993f9b07ae8c2bb844fde977b23 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 21 Feb 2026 16:20:15 +0800
Subject: [PATCH 23/88] fix: golangci-lint run --fix
---
pkg/tools/shell_test.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index d0a300c6c..6d35815e8 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -199,7 +199,7 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
}
tool := NewExecTool(workspace, true)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"command": "pwd",
"working_dir": outsideDir,
})
@@ -233,7 +233,7 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
}
tool := NewExecTool(workspace, true)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"command": "cat secret.txt",
"working_dir": link,
})
From 023b245a285780edfcfbe90ae81b4c9cd7e7913d Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 21 Feb 2026 15:33:35 +0800
Subject: [PATCH 24/88] docs: add Chinese channel documentation
---
README.zh.md | 458 ++++++---------------
docs/channels/dingtalk/README.zh.md | 33 ++
docs/channels/discord/README.zh.md | 35 ++
docs/channels/feishu/README.zh.md | 37 ++
docs/channels/line/README.zh.md | 41 ++
docs/channels/maixcam/README.zh.md | 31 ++
docs/channels/onebot/README.zh.md | 31 ++
docs/channels/qq/README.zh.md | 32 ++
docs/channels/slack/README.zh.md | 33 ++
docs/channels/telegram/README.zh.md | 33 ++
docs/channels/wecom/wecom_app/README.zh.md | 47 +++
docs/channels/wecom/wecom_bot/README.zh.md | 41 ++
12 files changed, 510 insertions(+), 342 deletions(-)
create mode 100644 docs/channels/dingtalk/README.zh.md
create mode 100644 docs/channels/discord/README.zh.md
create mode 100644 docs/channels/feishu/README.zh.md
create mode 100644 docs/channels/line/README.zh.md
create mode 100644 docs/channels/maixcam/README.zh.md
create mode 100644 docs/channels/onebot/README.zh.md
create mode 100644 docs/channels/qq/README.zh.md
create mode 100644 docs/channels/slack/README.zh.md
create mode 100644 docs/channels/telegram/README.zh.md
create mode 100644 docs/channels/wecom/wecom_app/README.zh.md
create mode 100644 docs/channels/wecom/wecom_bot/README.zh.md
diff --git a/README.zh.md b/README.zh.md
index ab896b6c0..4d739c5eb 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -14,7 +14,8 @@
- **äøę** | [ę„ę¬čŖ](README.ja.md) | [PortuguĆŖs](README.pt-br.md) | [Tiįŗæng Viį»t](README.vi.md) | [FranƧais](README.fr.md) | [English](README.md)
+**äøę** | [ę„ę¬čŖ](README.ja.md) | [PortuguĆŖs](README.pt-br.md) | [Tiįŗæng Viį»t](README.vi.md) | [FranƧais](README.fr.md) | [English](README.md)
+
---
@@ -42,14 +43,15 @@
> [!CAUTION]
> **šØ SECURITY & OFFICIAL CHANNELS / å®å
Øå£°ę**
-> * **ę å åÆč“§åø (NO CRYPTO):** PicoClaw **ę²”ę** åč”ä»»ä½å®ę¹ä»£åøćToken ęčęč“§åøćęęåØ `pump.fun` ęå
¶ä»äŗ¤ęå¹³å°äøēēøå
³å£°ē§°åäøŗ **čÆéŖ**ć
-> * **å®ę¹åå:** åÆäøēå®ę¹ē½ē«ęÆ **[picoclaw.io](https://picoclaw.io)**ļ¼å
¬åøå®ē½ęÆ **[sipeed.com](https://sipeed.com)**ć
-> * **č¦ę:** č®øå¤ `.ai/.org/.com/.net/...` åē¼ēåå被第äøę¹ę¢ę³Øļ¼čÆ·åæč½»äæ”ć
-> * **注ę:** picoclawę£åØåęēåæ«éåč½å¼åé¶ę®µļ¼åÆč½ęå°ęŖäæ®å¤ēē½ē»å®å
Øé®é¢ļ¼åØ1.0ę£å¼ēååøåļ¼čÆ·äøč¦å°å
¶éØē½²å°ēäŗ§ēÆå¢äø
-> * **注ę:** picoclawęčæåå¹¶äŗå¤§éPRsļ¼čæęēę¬åÆč½å
åå ēØč¾å¤§(10~20MB)ļ¼ę们å°åØåč½č¾äøŗę¶ęåčæč”čµęŗå ēØä¼å.
-
+>
+> - **ę å åÆč“§åø (NO CRYPTO):** PicoClaw **ę²”ę** åč”ä»»ä½å®ę¹ä»£åøćToken ęčęč“§åøćęęåØ `pump.fun` ęå
¶ä»äŗ¤ęå¹³å°äøēēøå
³å£°ē§°åäøŗ **čÆéŖ**ć
+> - **å®ę¹åå:** åÆäøēå®ę¹ē½ē«ęÆ **[picoclaw.io](https://picoclaw.io)**ļ¼å
¬åøå®ē½ęÆ **[sipeed.com](https://sipeed.com)**ć
+> - **č¦ę:** č®øå¤ `.ai/.org/.com/.net/...` åē¼ēåå被第äøę¹ę¢ę³Øļ¼čÆ·åæč½»äæ”ć
+> - **注ę:** picoclawę£åØåęēåæ«éåč½å¼åé¶ę®µļ¼åÆč½ęå°ęŖäæ®å¤ēē½ē»å®å
Øé®é¢ļ¼åØ1.0ę£å¼ēååøåļ¼čÆ·äøč¦å°å
¶éØē½²å°ēäŗ§ēÆå¢äø
+> - **注ę:** picoclawęčæåå¹¶äŗå¤§éPRsļ¼čæęēę¬åÆč½å
åå ēØč¾å¤§(10~20MB)ļ¼ę们å°åØåč½č¾äøŗę¶ęåčæč”čµęŗå ēØä¼å.
## š¢ ę°é» (News)
+
2026-02-16 š PicoClaw åØäøåØå
ēŖē “äŗ12K star! ę谢大家ēå
³ę³Øļ¼PicoClaw ēęéæéåŗ¦č¶
ä¹ę们é¢ę. ē±äŗPRę°éēåæ«éčØčļ¼ę们äŗé社åŗå¼åč
åäøē»“ę¤. ę们éč¦ēåæęæč
č§č²åroadmapå·²ē»ååøå°äŗ[čæé](docs/picoclaw_community_roadmap_260216.md), ęå¾
ä½ ēåäøļ¼
2026-02-13 š **PicoClaw åØ 4 天å
ēŖē “ 5000 Starsļ¼** ę谢社åŗēęÆęļ¼ē±äŗę£å¼äøå½ę„čåęļ¼PR å Issue ę¶å
„č¾å¤ļ¼ę们ę£åØå©ēØčæę®µę¶é“ę²å® **锹ē®č·Æēŗæå¾ (Roadmap)** å¹¶ē»å»ŗ **å¼åč
群ē»**ļ¼ä»„便å é PicoClaw ēå¼åć
@@ -69,12 +71,12 @@
š¤ **AI čŖäø¾**: ēŗÆ Go čÆčØåēå®ē° ā 95% ēę øåæä»£ē ē± Agent ēęļ¼å¹¶ē»ē±āäŗŗęŗåēÆ (Human-in-the-loop)āå¾®č°ć
-| | OpenClaw | NanoBot | **PicoClaw** |
-| --- | --- | --- | --- |
-| **čÆčØ** | TypeScript | Python | **Go** |
-| **RAM** | >1GB | >100MB | **< 10MB** |
-| **åÆåØę¶é“**(0.8GHz core) | >500s | >30s | **<1s** |
-| **ęę¬** | Mac Mini $599 | 大å¤ę° Linux å¼åęæ ~$50 | **ä»»ę Linux å¼åęæ****ä½č³ $10** |
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
+| **čÆčØ** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **åÆåØę¶é“**(0.8GHz core) | >500s | >30s | **<1s** |
+| **ęę¬** | Mac Mini $599 | 大å¤ę° Linux å¼åęæ ~$50 | **ä»»ę Linux å¼åęæ****ä½č³ $10** |
@@ -101,9 +103,12 @@
### š± åØęęŗäøč½»ę¾čæč”
+
picoclaw åÆä»„å°ä½ 10幓åēčę§ęęŗåŗē©å©ēØļ¼åčŗ«ęäøŗä½ ēAIå©ēļ¼åæ«éęå:
+
1. å
å»åŗēØååŗäøč½½å®č£
Termux
2. ęå¼åę§č”ę令
+
```bash
# 注ę: äøé¢ēv0.1.1 åÆä»„ę¢äøŗä½ å®é
ēå°ēęę°ēę¬
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
@@ -111,19 +116,17 @@ chmod +x picoclaw-linux-arm64
pkg install proot
termux-chroot ./picoclaw-linux-arm64 onboard
```
-ē¶åč·éäøé¢ēāåæ«éå¼å§āē« čē»§ē»é
ē½®picoclawå³åÆä½æēØļ¼
+
+ē¶åč·éäøé¢ēāåæ«éå¼å§āē« čē»§ē»é
ē½®picoclawå³åÆä½æēØļ¼
-
-
-
### š åę°ēä½å ēØéØē½²
PicoClaw å ä¹åÆä»„éØē½²åØä»»ä½ Linux 设å¤äøļ¼
-* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(ē½å£) ę W(WiFi6) ēę¬ļ¼ēØäŗęē®å®¶åŗå©ęć
-* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html)ļ¼ę $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html)ļ¼ēØäŗčŖåØåęå”åØčæē»“ć
-* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ę $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera)ļ¼ēØäŗęŗč½ēę§ć
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(ē½å£) ę W(WiFi6) ēę¬ļ¼ēØäŗęē®å®¶åŗå©ęć
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html)ļ¼ę $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html)ļ¼ēØäŗčŖåØåęå”åØčæē»“ć
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ę $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera)ļ¼ēØäŗęŗč½ēę§ć
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
@@ -253,15 +256,14 @@ picoclaw onboard
}
}
}
-
```
> **ę°åč½**: `model_list` é
ē½®ę ¼å¼ęÆęé¶ä»£ē ę·»å providerć详č§[樔åé
ē½®](#樔åé
ē½®-model_list)ē« čć
**3. č·å API Key**
-* **LLM ęä¾å**: [OpenRouter](https://openrouter.ai/keys) Ā· [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) Ā· [Anthropic](https://console.anthropic.com) Ā· [OpenAI](https://platform.openai.com) Ā· [Gemini](https://aistudio.google.com/api-keys)
-* **ē½ē»ęē“¢** (åÆé): [Brave Search](https://brave.com/search/api) - ęä¾å
蓹å±ēŗ§ (2000 请ę±/ę)
+- **LLM ęä¾å**: [OpenRouter](https://openrouter.ai/keys) Ā· [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) Ā· [Anthropic](https://console.anthropic.com) Ā· [OpenAI](https://platform.openai.com) Ā· [Gemini](https://aistudio.google.com/api-keys)
+- **ē½ē»ęē“¢** (åÆé): [Brave Search](https://brave.com/search/api) - ęä¾å
蓹å±ēŗ§ (2000 请ę±/ę)
> **注ę**: å®ę“ēé
置樔ęæčÆ·åč `config.example.json`ć
@@ -278,260 +280,28 @@ picoclaw agent -m "2+2 ēäŗå ļ¼"
## š¬ č天åŗēØéę (Chat Apps)
-éčæ Telegram, Discord, ééęä¼äøå¾®äæ”äøęØē PicoClaw 对čÆć
-
-| ęø é | 设置é¾åŗ¦ |
-| --- | --- |
-| **Telegram** | ē®å (ä»
é token) |
-| **Discord** | ē®å (bot token + intents) |
-| **QQ** | ē®å (AppID + AppSecret) |
-| **éé (DingTalk)** | äøē (åŗēØåčÆ) |
-| **ä¼äøå¾®äæ” (WeCom)** | äøē (ä¼äøID + Webhooké
ē½®) |
-
-
-Telegram (ęØč)
-
-**1. å建ęŗåØäŗŗ**
-
-* ęå¼ Telegramļ¼ęē“¢ `@BotFather`
-* åé `/newbot`ļ¼ęē
§ę示ęä½
-* å¤å¶ token
-
-**2. é
ē½®**
-
-```json
-{
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-
-```
-
-> ä» Telegram äøē `@userinfobot` č·åęØēēØę· IDć
-
-**3. čæč”**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-Discord
-
-**1. å建ęŗåØäŗŗ**
-
-* åå¾ [https://discord.com/developers/applications](https://discord.com/developers/applications)
-* Create an application ā Bot ā Add Bot
-* å¤å¶ bot token
-
-**2. å¼åÆ Intents**
-
-* åØ Bot 设置äøļ¼å¼åÆ **MESSAGE CONTENT INTENT**
-* (åÆé) å¦ęč®”ååŗäŗęåę°ę®ä½æēØē½ååļ¼å¼åÆ **SERVER MEMBERS INTENT**
-
-**3. č·åęØē User ID**
-
-* Discord 设置 ā Advanced ā å¼åÆ **Developer Mode**
-* å³é®ē¹å»ęØē夓å ā **Copy User ID**
-
-**4. é
ē½®**
-
-```json
-{
- "channels": {
- "discord": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"],
- "mention_only": false
- }
- }
-}
-
-```
-
-**5. é请ęŗåØäŗŗ**
-
-* OAuth2 ā URL Generator
-* Scopes: `bot`
-* Bot Permissions: `Send Messages`, `Read Message History`
-* ęå¼ēęēé请 URLļ¼å°ęŗåØäŗŗę·»å å°ęØēęå”åØ
-
-**6. čæč”**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-QQ
-
-**1. å建ęŗåØäŗŗ**
-
-* åå¾ [QQ å¼ę¾å¹³å°](https://q.qq.com/#)
-* å建åŗēØ ā č·å **AppID** å **AppSecret**
-
-**2. é
ē½®**
-
-```json
-{
- "channels": {
- "qq": {
- "enabled": true,
- "app_id": "YOUR_APP_ID",
- "app_secret": "YOUR_APP_SECRET",
- "allow_from": []
- }
- }
-}
-
-```
-
-> å° `allow_from` 设为空仄å
许ęęēØę·ļ¼ęęå® QQ å·ä»„éå¶č®æé®ć
-
-**3. čæč”**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-éé (DingTalk)
-
-**1. å建ęŗåØäŗŗ**
-
-* åå¾ [å¼ę¾å¹³å°](https://open.dingtalk.com/)
-* å建å
éØåŗēØ
-* å¤å¶ Client ID å Client Secret
-
-**2. é
ē½®**
-
-```json
-{
- "channels": {
- "dingtalk": {
- "enabled": true,
- "client_id": "YOUR_CLIENT_ID",
- "client_secret": "YOUR_CLIENT_SECRET",
- "allow_from": []
- }
- }
-}
-
-```
-
-> å° `allow_from` 设为空仄å
许ęęēØę·ļ¼ęęå® ID 仄éå¶č®æé®ć
-
-**3. čæč”**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-ä¼äøå¾®äæ” (WeCom)
-
-PicoClaw ęÆęäø¤ē§ä¼äøå¾®äæ”éęę¹å¼ļ¼
-
-**é锹1: ęŗč½ęŗåØäŗŗ (WeCom Bot)** - 设置ę“ē®åļ¼ęÆę群č
-**é锹2: čŖå»ŗåŗēØ (WeCom App)** - åč½ę“äø°åÆļ¼ęÆęäø»åØęØéę¶ęÆ
-
-čÆ¦č§ [ä¼äøå¾®äæ”čŖå»ŗåŗēØé
ē½®ęå](docs/wecom-app-configuration.md)ć
-
-**åæ«é设置 - ęŗč½ęŗåØäŗŗļ¼**
-
-**1. å建ęŗåØäŗŗ**
-
-* åå¾ä¼äøå¾®äæ”ē®”ēåå° ā 群č ā ę·»å 群ęŗåØäŗŗ
-* å¤å¶ Webhook URL (ę ¼å¼: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
-
-**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_host": "0.0.0.0",
- "webhook_port": 18793,
- "webhook_path": "/webhook/wecom",
- "allow_from": []
- }
- }
-}
-```
-
-**åæ«é设置 - čŖå»ŗåŗēØļ¼**
-
-**1. å建åŗēØ**
-
-* åå¾ä¼äøå¾®äæ”ē®”ēåå° ā åŗēØē®”ē ā å建åŗēØ
-* å¤å¶ **AgentId** å **Secret**
-* åå¾"ęēä¼äø"锵é¢ļ¼å¤å¶ **CorpID**
-
-**2. é
ē½®ę„ę¶ę¶ęÆ**
-
-* åØåŗēØčƦę
锵ļ¼ē¹å»"ę„ę¶ę¶ęÆ" ā "设置API"
-* 设置 URL 为 `http://your-server:18792/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_host": "0.0.0.0",
- "webhook_port": 18792,
- "webhook_path": "/webhook/wecom-app",
- "allow_from": []
- }
- }
-}
-```
-
-**4. čæč”**
-
-```bash
-picoclaw gateway
-
-```
-
-> **注ę**: čŖå»ŗåŗēØéč¦å¼ę¾ 18792 端å£ēØäŗę„ę¶ Webhook åč°ćēäŗ§ēÆå¢å»ŗč®®ä½æēØåå代ēé
ē½® HTTPSć
-
-
+PicoClaw ęÆęå¤ē§č天平å°ļ¼ä½æęØē Agent č½å¤čæę„å°ä»»ä½å°ę¹ć
+
+### ę øåæęø é
+
+| ęø é | 设置é¾åŗ¦ | ē¹ę§čÆ“ę | ę攣é¾ę„ |
+| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| **Telegram** | ā ē®å | ęØčļ¼ęÆęčÆé³č½¬ęåļ¼éæč½®čÆ¢ę éå
¬ē½ | [ę„ēę攣](docs/channels/telegram/README.zh.md) |
+| **Discord** | ā ē®å | Socket Modeļ¼ęÆę群ē»/ē§äæ”ļ¼Bot ēęęē | [ę„ēę攣](docs/channels/discord/README.zh.md) |
+| **Slack** | ā ē®å | **Socket Mode** (ę éå
¬ē½ IP)ļ¼ä¼äøēŗ§ęÆę | [ę„ēę攣](docs/channels/slack/README.zh.md) |
+| **QQ** | āā äøē | å®ę¹ęŗåØäŗŗ APIļ¼éåå½å
社群 | [ę„ēę攣](docs/channels/qq/README.zh.md) |
+| **éé (DingTalk)** | āā äøē | Stream 樔å¼ę éå
¬ē½ļ¼ä¼äøåå
¬é¦é | [ę„ēę攣](docs/channels/dingtalk/README.zh.md) |
+| **ä¼äøå¾®äæ” (WeCom)** | āāā č¾é¾ | ęÆę群ęŗåØäŗŗ(Webhook)åčŖå»ŗåŗēØ(API) | [Bot ę攣](docs/channels/wecom/wecom_bot/README.zh.md) / [App ę攣](docs/channels/wecom/wecom_app/README.zh.md) |
+| **é£ä¹¦ (Feishu)** | āāā č¾é¾ | ä¼äøēŗ§åä½ļ¼åč½äø°åÆ | [ę„ēę攣](docs/channels/feishu/README.zh.md) |
+| **Line** | āāā č¾é¾ | éč¦ HTTPS Webhook | [ę„ēę攣](docs/channels/line/README.zh.md) |
+| **OneBot** | āā äøē | å
¼å®¹ NapCat/Go-CQHTTPļ¼ē¤¾åŗēęäø°åÆ | [ę„ēę攣](docs/channels/onebot/README.zh.md) |
+| **MaixCam** | ā ē®å | äøäøŗ AI ęå夓设讔ē甬件éęéé | [ę„ēę攣](docs/channels/maixcam/README.zh.md) |
##
å å
„ Agent 社交ē½ē»
åŖééčæ CLI ęä»»ä½éęēč天åŗēØåéäøę”ę¶ęÆļ¼å³åÆå° PicoClaw čæę„å° Agent 社交ē½ē»ć
-**é
读 `https://clawdchat.ai/skill.md` å¹¶ęē
§čÆ“ęå å
„ [ClawdChat.ai](https://clawdchat.ai)
+\*\*é
读 `https://clawdchat.ai/skill.md` å¹¶ęē
§čÆ“ęå å
„ [ClawdChat.ai](https://clawdchat.ai)
## āļø é
置详解
@@ -567,7 +337,6 @@ PicoClaw åÆä»„čŖåØę§č”åØęę§ä»»å”ćåØå·„ä½åŗå建 `HEARTBEAT.md`
- Check my email for important messages
- Review my calendar for upcoming events
- Check the weather forecast
-
```
Agent å°ęÆé 30 åéļ¼åÆé
ē½®ļ¼čÆ»åę¤ęä»¶ļ¼å¹¶ä½æēØåÆēØå·„å
·ę§č”ä»»å”ć
@@ -580,22 +349,23 @@ Agent å°ęÆé 30 åéļ¼åÆé
ē½®ļ¼čÆ»åę¤ęä»¶ļ¼å¹¶ä½æēØåÆēØå·„å
·
# Periodic Tasks
## Quick Tasks (respond directly)
+
- Report current time
## Long Tasks (use spawn for async)
+
- Search the web for AI news and summarize
- Check email and report important messages
-
```
**å
³é®č”äøŗļ¼**
-| ē¹ę§ | ęčæ° |
-| --- | --- |
-| **spawn** | å建å¼ę„å Agentļ¼äøé»å”äø»åæč·³čæēØ |
-| **ē¬ē«äøäøę** | å Agent ę„ęē¬ē«äøäøęļ¼ę ä¼čÆåå² |
+| ē¹ę§ | ęčæ° |
+| ---------------- | ---------------------------------------- |
+| **spawn** | å建å¼ę„å Agentļ¼äøé»å”äø»åæč·³čæēØ |
+| **ē¬ē«äøäøę** | å Agent ę„ęē¬ē«äøäøęļ¼ę ä¼čÆåå² |
| **message tool** | å Agent éčæ message å·„å
·ē“ę„äøēØę·éäæ” |
-| **éé»å”** | spawn åļ¼åæč·³ē»§ē»å¤ēäøäøäøŖä»»å” |
+| **éé»å”** | spawn åļ¼åæč·³ē»§ē»å¤ēäøäøäøŖä»»å” |
#### å Agent éäæ”åē
@@ -625,35 +395,34 @@ Agent 读å HEARTBEAT.md
"interval": 30
}
}
-
```
-| é锹 | é»č®¤å¼ | ęčæ° |
-| --- | --- | --- |
-| `enabled` | `true` | åÆēØ/ē¦ēØåæč·³ |
-| `interval` | `30` | ę£ę„é“éļ¼åä½åé (ęå°: 5) |
+| é锹 | é»č®¤å¼ | ęčæ° |
+| ---------- | ------ | ---------------------------- |
+| `enabled` | `true` | åÆēØ/ē¦ēØåæč·³ |
+| `interval` | `30` | ę£ę„é“éļ¼åä½åé (ęå°: 5) |
**ēÆå¢åé:**
-* `PICOCLAW_HEARTBEAT_ENABLED=false` ē¦ēØ
-* `PICOCLAW_HEARTBEAT_INTERVAL=60` ę“ę¹é“é
+- `PICOCLAW_HEARTBEAT_ENABLED=false` ē¦ēØ
+- `PICOCLAW_HEARTBEAT_INTERVAL=60` ę“ę¹é“é
### ęä¾å (Providers)
> [!NOTE]
> Groq éčæ Whisper ęä¾å
蓹ēčÆé³č½¬å½ćå¦ęé
ē½®äŗ Groqļ¼Telegram čÆé³ę¶ęÆå°č¢«čŖåØč½¬å½äøŗęåć
-| ęä¾å | ēØé | č·å API Key |
-| --- | --- | --- |
-| `gemini` | LLM (Gemini ē“čæ) | [aistudio.google.com](https://aistudio.google.com) |
-| `zhipu` | LLM (ęŗč°±ē“čæ) | [bigmodel.cn](bigmodel.cn) |
-| `openrouter(å¾
ęµčÆ)` | LLM (ęØčļ¼åÆč®æé®ęę樔å) | [openrouter.ai](https://openrouter.ai) |
-| `anthropic(å¾
ęµčÆ)` | LLM (Claude ē“čæ) | [console.anthropic.com](https://console.anthropic.com) |
-| `openai(å¾
ęµčÆ)` | LLM (GPT ē“čæ) | [platform.openai.com](https://platform.openai.com) |
-| `deepseek(å¾
ęµčÆ)` | LLM (DeepSeek ē“čæ) | [platform.deepseek.com](https://platform.deepseek.com) |
-| `qwen` | LLM (éä¹åé®) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
-| `groq` | LLM + **čÆé³č½¬å½** (Whisper) | [console.groq.com](https://console.groq.com) |
-| `cerebras` | LLM (Cerebras ē“čæ) | [cerebras.ai](https://cerebras.ai) |
+| ęä¾å | ēØé | č·å API Key |
+| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
+| `gemini` | LLM (Gemini ē“čæ) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (ęŗč°±ē“čæ) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter(å¾
ęµčÆ)` | LLM (ęØčļ¼åÆč®æé®ęę樔å) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic(å¾
ęµčÆ)` | LLM (Claude ē“čæ) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai(å¾
ęµčÆ)` | LLM (GPT ē“čæ) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek(å¾
ęµčÆ)` | LLM (DeepSeek ē“čæ) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (éä¹åé®) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `groq` | LLM + **čÆé³č½¬å½** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras ē“čæ) | [cerebras.ai](https://cerebras.ai) |
### 樔åé
ē½® (model_list)
@@ -668,25 +437,25 @@ Agent 读å HEARTBEAT.md
#### š ęęęÆęēåå
-| åå | `model` åē¼ | é»č®¤ API Base | åč®® | č·å API Key |
-|------|-------------|---------------|------|--------------|
-| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [č·ååÆé„](https://platform.openai.com) |
-| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [č·ååÆé„](https://console.anthropic.com) |
-| **ęŗč°± AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [č·ååÆé„](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
-| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [č·ååÆé„](https://platform.deepseek.com) |
-| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [č·ååÆé„](https://aistudio.google.com/api-keys) |
-| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [č·ååÆé„](https://console.groq.com) |
-| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [č·ååÆé„](https://platform.moonshot.cn) |
-| **éä¹åé® (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [č·ååÆé„](https://dashscope.console.aliyun.com) |
-| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [č·ååÆé„](https://build.nvidia.com) |
-| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ę¬å°ļ¼ę éåÆé„ļ¼ |
-| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [č·ååÆé„](https://openrouter.ai/keys) |
-| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ę¬å° |
-| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [č·ååÆé„](https://cerebras.ai) |
-| **ē«å±±å¼ę** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [č·ååÆé„](https://console.volcengine.com) |
-| **ē„ē®äŗ** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
-| **Antigravity** | `antigravity/` | Google Cloud | čŖå®ä¹ | ä»
OAuth |
-| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+| åå | `model` åē¼ | é»č®¤ API Base | åč®® | č·å API Key |
+| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [č·ååÆé„](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [č·ååÆé„](https://console.anthropic.com) |
+| **ęŗč°± AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [č·ååÆé„](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [č·ååÆé„](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [č·ååÆé„](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [č·ååÆé„](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [č·ååÆé„](https://platform.moonshot.cn) |
+| **éä¹åé® (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [č·ååÆé„](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [č·ååÆé„](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ę¬å°ļ¼ę éåÆé„ļ¼ |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [č·ååÆé„](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ę¬å° |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [č·ååÆé„](https://cerebras.ai) |
+| **ē«å±±å¼ę** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [č·ååÆé„](https://console.volcengine.com) |
+| **ē„ē®äŗ** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | čŖå®ä¹ | ä»
OAuth |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
#### åŗē”é
置示ä¾
@@ -720,6 +489,7 @@ Agent 读å HEARTBEAT.md
#### åååé
置示ä¾
**OpenAI**
+
```json
{
"model_name": "gpt-5.2",
@@ -729,6 +499,7 @@ Agent 读å HEARTBEAT.md
```
**ęŗč°± AI (GLM)**
+
```json
{
"model_name": "glm-4.7",
@@ -738,6 +509,7 @@ Agent 读å HEARTBEAT.md
```
**DeepSeek**
+
```json
{
"model_name": "deepseek-chat",
@@ -747,6 +519,7 @@ Agent 读å HEARTBEAT.md
```
**Anthropic (ä½æēØ OAuth)**
+
```json
{
"model_name": "claude-sonnet-4.6",
@@ -754,9 +527,11 @@ Agent 读å HEARTBEAT.md
"auth_method": "oauth"
}
```
+
> čæč” `picoclaw auth login --provider anthropic` ę„设置 OAuth åčÆć
**Ollama (ę¬å°)**
+
```json
{
"model_name": "llama3",
@@ -765,6 +540,7 @@ Agent 读å HEARTBEAT.md
```
**čŖå®ä¹ä»£ē/API**
+
```json
{
"model_name": "my-custom-model",
@@ -802,6 +578,7 @@ Agent 读å HEARTBEAT.md
ę§ē `providers` é
ē½®ę ¼å¼**å·²å¼ēØ**ļ¼ä½äøŗååå
¼å®¹ä»ęÆęć
**ę§é
ē½®ļ¼å·²å¼ēØļ¼ļ¼**
+
```json
{
"providers": {
@@ -820,6 +597,7 @@ Agent 读å HEARTBEAT.md
```
**ę°é
ē½®ļ¼ęØčļ¼ļ¼**
+
```json
{
"model_list": [
@@ -844,7 +622,7 @@ Agent 读å HEARTBEAT.md
**1. č·å API key å base URL**
-* č·å [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+- č·å [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
**2. é
ē½®**
@@ -866,7 +644,6 @@ Agent 读å HEARTBEAT.md
}
}
}
-
```
**3. čæč”**
@@ -946,30 +723,29 @@ picoclaw agent -m "ä½ å„½"
"interval": 30
}
}
-
```
## CLI å½ä»¤č”åč
-| å½ä»¤ | ęčæ° |
-| --- | --- |
-| `picoclaw onboard` | åå§åé
ē½®åå·„ä½åŗ |
-| `picoclaw agent -m "..."` | äø Agent åÆ¹čÆ |
-| `picoclaw agent` | äŗ¤äŗå¼čå¤©ęØ”å¼ |
-| `picoclaw gateway` | åÆåØē½å
³ (Gateway) |
-| `picoclaw status` | ę¾ē¤ŗē¶ę |
-| `picoclaw cron list` | ååŗęęå®ę¶ä»»å” |
-| `picoclaw cron add ...` | ę·»å å®ę¶ä»»å” |
+| å½ä»¤ | ęčæ° |
+| ------------------------- | ------------------ |
+| `picoclaw onboard` | åå§åé
ē½®åå·„ä½åŗ |
+| `picoclaw agent -m "..."` | äø Agent åÆ¹čÆ |
+| `picoclaw agent` | äŗ¤äŗå¼čå¤©ęØ”å¼ |
+| `picoclaw gateway` | åÆåØē½å
³ (Gateway) |
+| `picoclaw status` | ę¾ē¤ŗē¶ę |
+| `picoclaw cron list` | ååŗęęå®ę¶ä»»å” |
+| `picoclaw cron add ...` | ę·»å å®ę¶ä»»å” |
### å®ę¶ä»»å” / ęé (Scheduled Tasks)
PicoClaw éčæ `cron` å·„å
·ęÆęå®ę¶ęéåéå¤ä»»å”ļ¼
-* **äøę¬”ę§ęé**: "Remind me in 10 minutes" (10åéåęéę) ā 10åéå触åäøę¬”
-* **éå¤ä»»å”**: "Remind me every 2 hours" (ęÆ2å°ę¶ęéę) ā ęÆ2å°ę¶č§¦å
-* **Cron 蔨达å¼**: "Remind me at 9am daily" (ęÆå¤©äøå9ē¹ęéę) ā ä½æēØ cron 蔨达å¼
+- **äøę¬”ę§ęé**: "Remind me in 10 minutes" (10åéåęéę) ā 10åéå触åäøę¬”
+- **éå¤ä»»å”**: "Remind me every 2 hours" (ęÆ2å°ę¶ęéę) ā ęÆ2å°ę¶č§¦å
+- **Cron 蔨达å¼**: "Remind me at 9am daily" (ęÆå¤©äøå9ē¹ęéę) ā ä½æēØ cron 蔨达å¼
ä»»å”ååØåØ `~/.picoclaw/workspace/cron/` äøå¹¶čŖåØå¤ēć
@@ -983,7 +759,7 @@ PicoClaw éčæ `cron` å·„å
·ęÆęå®ę¶ęéåéå¤ä»»å”ļ¼
ēØę·ē¾¤ē»ļ¼
-Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
+Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
@@ -997,6 +773,7 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
1. åØ [https://brave.com/search/api](https://brave.com/search/api) č·åå
蓹 API Key (ęÆę 2000 ꬔå
蓹ę„询)
2. ę·»å å° `~/.picoclaw/config.json`:
+
```json
{
"tools": {
@@ -1013,11 +790,8 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
}
}
}
-
```
-
-
### éå°å
容čæę»¤é误 (Content Filtering Errors)
ęäŗęä¾åļ¼å¦ęŗč°±ļ¼ęäø„ę ¼ēå
容čæę»¤ćå°čÆę¹åęØēé®é¢ę使ēØå
¶ä»ęØ”åć
@@ -1030,10 +804,10 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
## š API Key 对ęÆ
-| ęå” | å
蓹å±ēŗ§ | éēØåŗęÆ |
-| --- | --- | --- |
-| **OpenRouter** | 200K tokens/ę | å¤ęØ”åčå (Claude, GPT-4 ē) |
-| **ęŗč°± (Zhipu)** | 200K tokens/ę | ęéåäøå½ēØę· |
-| **Brave Search** | 2000 ꬔę„询/ę | ē½ē»ęē“¢åč½ |
-| **Groq** | ęä¾å
蓹å±ēŗ§ | ęéęØē (Llama, Mixtral) |
-| **Cerebras** | ęä¾å
蓹å±ēŗ§ | ęéęØē (Llama, Qwen ē) |
\ No newline at end of file
+| ęå” | å
蓹å±ēŗ§ | éēØåŗęÆ |
+| ---------------- | -------------- | ----------------------------- |
+| **OpenRouter** | 200K tokens/ę | å¤ęØ”åčå (Claude, GPT-4 ē) |
+| **ęŗč°± (Zhipu)** | 200K tokens/ę | ęéåäøå½ēØę· |
+| **Brave Search** | 2000 ꬔę„询/ę | ē½ē»ęē“¢åč½ |
+| **Groq** | ęä¾å
蓹å±ēŗ§ | ęéęØē (Llama, Mixtral) |
+| **Cerebras** | ęä¾å
蓹å±ēŗ§ | ęéęØē (Llama, Qwen ē) |
diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md
new file mode 100644
index 000000000..1e445d0b0
--- /dev/null
+++ b/docs/channels/dingtalk/README.zh.md
@@ -0,0 +1,33 @@
+# éé
+
+ééęÆéæéå·“å·“ēä¼äøé讯平å°ļ¼åØäøå½čåŗäøå¹æå欢čæćå®éēØęµå¼ SDK ę„结ęęä¹
čæę„ć
+
+## é
ē½®
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| åꮵ | ē±»å | åæ
唫 | ęčæ° |
+| ------------- | ------ | ---- | -------------------------------- |
+| enabled | bool | ęÆ | ęÆå¦åÆēØééé¢é |
+| client_id | string | ęÆ | ééåŗēØē Client ID |
+| client_secret | string | ęÆ | ééåŗēØē Client Secret |
+| allow_from | array | å¦ | ēØę·IDē½ååļ¼ē©ŗč”Øē¤ŗå
许ęęēØę· |
+
+## 设置ęµēØ
+
+1. åå¾ [ééå¼ę¾å¹³å°](https://open.dingtalk.com/)
+2. å建äøäøŖä¼äøå
éØåŗēØ
+3. ä»åŗēØč®¾ē½®äøč·å Client ID å Client Secret
+4. é
ē½®OAuthåäŗä»¶č®¢é
(å¦éč¦)
+5. å° Client ID å Client Secret 唫å
„é
ē½®ęä»¶äø
diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md
new file mode 100644
index 000000000..5b597eced
--- /dev/null
+++ b/docs/channels/discord/README.zh.md
@@ -0,0 +1,35 @@
+# Discord
+
+Discord ęÆäøäøŖäøäøŗē¤¾åŗč®¾č®”ēå
蓹čÆé³ćč§é¢åęę¬č天åŗēØćPicoClaw éčæ Discord Bot API čæę„å° Discord ęå”åØļ¼ęÆęę„ę¶ååéę¶ęÆć
+
+## é
ē½®
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"],
+ "mention_only": false
+ }
+ }
+}
+```
+
+| åꮵ | ē±»å | åæ
唫 | ęčæ° |
+| ------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | ęÆ | ęÆå¦åÆēØ Discord é¢é |
+| token | string | ęÆ | Discord ęŗåØäŗŗ Token |
+| allow_from | array | å¦ | ēØę·IDē½ååļ¼ē©ŗč”Øē¤ŗå
许ęęēØę· |
+| mention_only | bool | å¦ | ęÆå¦ä»
ååŗęåęŗåØäŗŗēę¶ęÆ |
+
+## 设置ęµēØ
+
+1. åå¾ [Discord å¼åč
éØę·](https://discord.com/developers/applications) å建äøäøŖę°ēåŗēØ
+2. åÆēØ Intents:
+ - Message Content Intent
+ - Server Members Intent
+3. č·å Bot Token
+4. å° Bot Token 唫å
„é
ē½®ęä»¶äø
+5. é请ęŗåØäŗŗå å
„ęå”åØå¹¶ęäŗåæ
č¦ęé(ä¾å¦åéę¶ęÆć读åę¶ęÆåå²ē)
diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md
new file mode 100644
index 000000000..310827723
--- /dev/null
+++ b/docs/channels/feishu/README.zh.md
@@ -0,0 +1,37 @@
+# é£ä¹¦
+
+é£ä¹¦ļ¼å½é
ēåē§°ļ¼Larkļ¼ęÆåčč·³åØęäøēä¼äøåä½å¹³å°ćå®éčæäŗä»¶é©±åØē Webhook åę¶ęÆęäøå½åå
Øēåøåŗć
+
+## é
ē½®
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| åꮵ | ē±»å | åæ
唫 | ęčæ° |
+| ------------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | ęÆ | ęÆå¦åÆēØé£ä¹¦é¢é |
+| app_id | string | ęÆ | é£ä¹¦åŗēØē App ID(仄cli\_å¼å¤“) |
+| app_secret | string | ęÆ | é£ä¹¦åŗēØē App Secret |
+| encrypt_key | string | å¦ | äŗä»¶åč°å åÆåÆé„ |
+| verification_token | string | å¦ | ēØäŗWebhookäŗä»¶éŖčÆēToken |
+| allow_from | array | å¦ | ēØę·IDē½ååļ¼ē©ŗč”Øē¤ŗå
许ęęēØę· |
+
+## 设置ęµēØ
+
+1. åå¾ [é£ä¹¦å¼ę¾å¹³å°](https://open.feishu.cn/)å建åŗēØēØåŗ
+2. č·å App ID å App Secret
+3. é
ē½®äŗä»¶č®¢é
åWebhook URL
+4. 设置å åÆ(åÆé,ēäŗ§ēÆå¢å»ŗč®®åÆēØ)
+5. å° App IDćApp SecretćEncrypt Key å Verification Token(å¦ęåÆēØå åÆ) 唫å
„é
ē½®ęä»¶äø
diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md
new file mode 100644
index 000000000..fd3aa80da
--- /dev/null
+++ b/docs/channels/line/README.zh.md
@@ -0,0 +1,41 @@
+# Line
+
+PicoClaw éčæ LINE Messaging API é
å Webhook åč°åč½å®ē°åƹ LINE ēęÆęć
+
+## é
ē½®
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| åꮵ | ē±»å | åæ
唫 | ęčæ° |
+| -------------------- | ------ | ---- | ------------------------------------------ |
+| enabled | bool | ęÆ | ęÆå¦åÆēØ LINE Channel |
+| channel_secret | string | ęÆ | LINE Messaging API ē Channel Secret |
+| channel_access_token | string | ęÆ | LINE Messaging API ē Channel Access Token |
+| webhook_host | string | ęÆ | Webhook ēå¬ēäø»ęŗå°å (éåøøäøŗ 0.0.0.0) |
+| webhook_port | int | ęÆ | Webhook ēå¬ēē«Æå£ (é»č®¤äøŗ 18791) |
+| webhook_path | string | ęÆ | Webhook ēč·Æå¾ (é»č®¤äøŗ /webhook/line) |
+| allow_from | array | å¦ | ēØę·IDē½ååļ¼ē©ŗč”Øē¤ŗå
许ęęēØę· |
+
+## 设置ęµēØ
+
+1. åå¾ [LINE Developers Console](https://developers.line.biz/console/) å建äøäøŖęå”ęä¾ååäøäøŖ Messaging API Channel
+2. č·å Channel Secret å Channel Access Token
+3. é
ē½®Webhook:
+ - Lineč¦ę±Webhookåæ
锻使ēØHTTPSåč®®ļ¼å ę¤éč¦éØē½²äøäøŖęÆęHTTPSēęå”åØļ¼ęč
使ēØåå代ēå·„å
·å¦ngrokå°ę¬å°ęå”åØę“é²å°å
¬ē½
+ - å° Webhook URL 设置为 `https://your-domain.com/webhook/line`
+ - åÆēØ Webhook å¹¶éŖčÆ URL
+4. å° Channel Secret å Channel Access Token 唫å
„é
ē½®ęä»¶äø
diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md
new file mode 100644
index 000000000..8d53d4bef
--- /dev/null
+++ b/docs/channels/maixcam/README.zh.md
@@ -0,0 +1,31 @@
+# MaixCam
+
+MaixCam ęÆäøēØäŗčæę„ē½éē§ę MaixCAM äø MaixCAM2 AI ęå设å¤ēééćå®éēØ TCP å„ę„åå®ē°ååéäæ”ļ¼ęÆęč¾¹ē¼ AI éØē½²åŗęÆć
+
+## é
ē½®
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true,
+ "server_address": "0.0.0.0:8899",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| åꮵ | ē±»å | åæ
唫 | ęčæ° |
+| -------------- | ------ | ---- | -------------------------------- |
+| enabled | bool | ęÆ | ęÆå¦åÆēØ MaixCam é¢é |
+| server_address | string | ęÆ | TCP ęå”åØēå¬å°ååē«Æå£ |
+| allow_from | array | å¦ | 设å¤IDē½ååļ¼ē©ŗč”Øē¤ŗå
许ęęč®¾å¤ |
+
+## 使ēØåŗęÆ
+
+MaixCam éé使 PicoClaw č½å¤ä½äøŗč¾¹ē¼č®¾å¤ē AI å端čæč”ļ¼
+
+- **ęŗč½ēę§** ļ¼MaixCAM åéå¾ååø§ļ¼PicoClaw éčæč§č§ęØ”åčæč”åę
+- **ē©čē½ę§å¶** ļ¼č®¾å¤åéä¼ ęåØę°ę®ļ¼PicoClaw åč°ååŗ
+- **离线AI** ļ¼åØę¬å°ē½ē»éØē½² PicoClaw å®ē°ä½å»¶čæęØē
diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md
new file mode 100644
index 000000000..6195f1c98
--- /dev/null
+++ b/docs/channels/onebot/README.zh.md
@@ -0,0 +1,31 @@
+# OneBot
+
+OneBot ęÆäøäøŖé¢å QQ ęŗåØäŗŗēå¼ę¾åč®®ę åļ¼äøŗå¤ē§ QQ ęŗåØäŗŗå®ē°ļ¼ä¾å¦ go-cqhttpćMiraiļ¼ęä¾äŗē»äøēę„å£ćå®ä½æēØ WebSocket čæč”éäæ”ć
+
+## é
ē½®
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://localhost:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| åꮵ | ē±»å | åæ
唫 | ęčæ° |
+| ------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | ęÆ | ęÆå¦åÆēØ OneBot é¢é |
+| ws_url | string | ęÆ | OneBot ęå”åØē WebSocket URL |
+| access_token | string | å¦ | čæę„ OneBot ęå”åØē访é®ä»¤ē |
+| allow_from | array | å¦ | ēØę·IDē½ååļ¼ē©ŗč”Øē¤ŗå
许ęęēØę· |
+
+## 设置ęµēØ
+
+1. éØē½²äøäøŖ OneBot å
¼å®¹ēå®ē°(ä¾å¦napcat)
+2. é
ē½® OneBot å®ē°ä»„åÆēØ WebSocket ęå”并设置访é®ä»¤ē(å¦ęéč¦)
+3. å° WebSocket URL å访é®ä»¤ē唫å
„é
ē½®ęä»¶äø
diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md
new file mode 100644
index 000000000..bd774960f
--- /dev/null
+++ b/docs/channels/qq/README.zh.md
@@ -0,0 +1,32 @@
+# QQ
+
+PicoClaw éčæ QQ å¼ę¾å¹³å°ēå®ę¹ęŗåØäŗŗ API ęä¾åƹ QQ ēęÆęć
+
+## é
ē½®
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| åꮵ | ē±»å | åæ
唫 | ęčæ° |
+| ---------- | ------ | ---- | -------------------------------- |
+| enabled | bool | ęÆ | ęÆå¦åÆēØ QQ Channel |
+| app_id | string | ęÆ | QQ ęŗåØäŗŗåŗēØē App ID |
+| app_secret | string | ęÆ | QQ ęŗåØäŗŗåŗēØē App Secret |
+| allow_from | array | å¦ | ēØę·IDē½ååļ¼ē©ŗč”Øē¤ŗå
许ęęēØę· |
+
+## 设置ęµēØ
+
+1. åå¾ [QQ å¼ę¾å¹³å°](https://q.qq.com/) å建äøäøŖęŗåØäŗŗ
+2. éčæä»Ŗč”Øēč·å App ID å App Secret
+3. å¼åÆęŗåØäŗŗę²ē®±ęØ”å¼, å°ēØę·å群添å å°ę²ē®±äø
+4. å° App ID å App Secret 唫å
„é
ē½®ęä»¶äø
diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md
new file mode 100644
index 000000000..58ebcb566
--- /dev/null
+++ b/docs/channels/slack/README.zh.md
@@ -0,0 +1,33 @@
+# Slack
+
+Slack ęÆå
Øēé¢å
ēä¼äøēŗ§å³ę¶é讯平å°ćPicoClaw éēØ Slack ē Socket Mode å®ē°å®ę¶ååéäæ”ļ¼ę éé
ē½®å
¬å¼ē Webhook 端ē¹ć
+
+## é
ē½®
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-...",
+ "app_token": "xapp-...",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| åꮵ | ē±»å | åæ
唫 | ęčæ° |
+| ---------- | ------ | ---- | -------------------------------------------------------- |
+| enabled | bool | ęÆ | ęÆå¦åÆēØ Slack é¢é |
+| bot_token | string | ęÆ | Slack ęŗåØäŗŗē Bot User OAuth Token (仄 xoxb- å¼å¤“) |
+| app_token | string | ęÆ | Slack åŗēØē Socket Mode App Level Token (仄 xapp- å¼å¤“) |
+| allow_from | array | å¦ | ēØę·IDē½ååļ¼ē©ŗč”Øē¤ŗå
许ęęēØę· |
+
+## 设置ęµēØ
+
+1. åå¾ [Slack API](https://api.slack.com/) å建äøäøŖę°ē Slack åŗēØ
+2. åÆēØ Socket Mode å¹¶č·å App Level Token
+3. ę·»å Bot Token Scopes(ä¾å¦`chat:write`ć`im:history`ē)
+4. å®č£
åŗēØå°å·„ä½åŗå¹¶č·å Bot User OAuth Token
+5. å° Bot Token å App Token 唫å
„é
ē½®ęä»¶äø
diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md
new file mode 100644
index 000000000..d453c68fa
--- /dev/null
+++ b/docs/channels/telegram/README.zh.md
@@ -0,0 +1,33 @@
+# Telegram
+
+Telegram Channel éčæ Telegram ęŗåØäŗŗ API 使ēØéæč½®čÆ¢å®ē°åŗäŗęŗåØäŗŗēéäæ”ćå®ęÆęęę¬ę¶ęÆćåŖä½éä»¶ļ¼ē
§ēćčÆé³ćé³é¢ćę攣ļ¼ćéčæ Groq Whisper čæč”čÆé³č½¬å½ä»„åå
ē½®å½ä»¤å¤ēåØć
+
+## é
ē½®
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
+ "allow_from": ["123456789"],
+ "proxy": ""
+ }
+ }
+}
+```
+
+| åꮵ | ē±»å | åæ
唫 | ęčæ° |
+| ---------- | ------ | ---- | --------------------------------------------------------- |
+| enabled | bool | ęÆ | ęÆå¦åÆēØ Telegram é¢é |
+| token | string | ęÆ | Telegram ęŗåØäŗŗ API Token |
+| allow_from | array | å¦ | ēØę·IDē½ååļ¼ē©ŗč”Øē¤ŗå
许ęęēØę· |
+| proxy | string | å¦ | čæę„ Telegram API ē代ē URL (ä¾å¦ http://127.0.0.1:7890) |
+
+## 设置ęµēØ
+
+1. åØ Telegram äøęē“¢ `@BotFather`
+2. åé `/newbot` å½ä»¤å¹¶ęē
§ę示å建ę°ęŗåØäŗŗ
+3. č·å HTTP API Token
+4. å° Token 唫å
„é
ē½®ęä»¶äø
+5. (åÆé) é
ē½® `allow_from` 仄éå¶å
许äŗåØēēØę· ID (åÆéčæ `@userinfobot` č·å ID)
diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md
new file mode 100644
index 000000000..1e6a0e2b3
--- /dev/null
+++ b/docs/channels/wecom/wecom_app/README.zh.md
@@ -0,0 +1,47 @@
+# ä¼äøå¾®äæ”čŖå»ŗåŗēØ
+
+ä¼äøå¾®äæ”čŖå»ŗåŗēØęÆęä¼äøåØä¼äøå¾®äæ”äøå建ēåŗēØļ¼äø»č¦ēØäŗä¼äøå
éØä½æēØćéčæä¼äøå¾®äæ”čŖå»ŗåŗēØļ¼ä¼äøåÆä»„å®ē°äøåå·„ēé«ęę²éååä½ļ¼ęé«å·„ä½ęēć
+
+## é
ē½®
+
+```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_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| åꮵ | ē±»å | åæ
唫 | ęčæ° |
+| ---------------- | ------ | ---- | ---------------------------------------- |
+| corp_id | string | ęÆ | ä¼äø ID |
+| corp_secret | string | ęÆ | åŗēØēØåŗåÆé„ |
+| agent_id | int | ęÆ | åŗēØēØåŗä»£ē ID |
+| token | string | ęÆ | åč°éŖčÆä»¤ē |
+| encoding_aes_key | string | ęÆ | 43 å符 AES åÆé„ |
+| webhook_host | string | å¦ | HTTP ęå”åØē»å®å°å |
+| webhook_port | int | å¦ | HTTP ęå”åØē«Æå£ļ¼é»č®¤ļ¼18792ļ¼ |
+| webhook_path | string | å¦ | Webhook č·Æå¾ļ¼é»č®¤ļ¼/webhook/wecom-appļ¼ |
+| allow_from | array | å¦ | ēØę· ID ē½åå |
+| reply_timeout | int | å¦ | åå¤č¶
ę¶ę¶é“ļ¼ē§ļ¼ |
+
+## 设置ęµēØ
+
+1. ē»å½ [ä¼äøå¾®äæ”ē®”ēåå°](https://work.weixin.qq.com/)
+2. čæå
„āåŗēØē®”ēā -> āå建åŗēØā
+3. č·åä¼äø ID (CorpID) ååŗēØ Secret
+4. åØåŗēØč®¾ē½®äøé
ē½®āę„ę¶ę¶ęÆāļ¼č·å Token å EncodingAESKey
+5. 设置åč° URL äøŗ `http://:/webhook/wecom-app`
+6. å° CorpID, Secret, AgentID ēäæ”ęÆå”«å
„é
ē½®ęä»¶
diff --git a/docs/channels/wecom/wecom_bot/README.zh.md b/docs/channels/wecom/wecom_bot/README.zh.md
new file mode 100644
index 000000000..c4bb1c87e
--- /dev/null
+++ b/docs/channels/wecom/wecom_bot/README.zh.md
@@ -0,0 +1,41 @@
+# ä¼äøå¾®äæ”ęŗåØäŗŗ
+
+ä¼äøå¾®äæ”ęŗåØäŗŗęÆä¼äøå¾®äæ”ęä¾ēäøē§åæ«éę„å
„ę¹å¼ļ¼åÆä»„éčæ Webhook URL ę„ę¶ę¶ęÆć
+
+## é
ē½®
+
+```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_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| åꮵ | ē±»å | åæ
唫 | ęčæ° |
+| ---------------- | ------ | ---- | -------------------------------------------- |
+| token | string | ęÆ | ē¾åéŖčÆä»£åø |
+| encoding_aes_key | string | ęÆ | ēØäŗč§£åÆē 43 å符 AES åÆé„ |
+| webhook_url | string | ęÆ | ēØäŗåéåå¤ēä¼äøå¾®äæ”群čęŗåØäŗŗ Webhook URL |
+| webhook_host | string | å¦ | HTTP ęå”åØē»å®å°åļ¼é»č®¤ļ¼0.0.0.0ļ¼ |
+| webhook_port | int | å¦ | HTTP ęå”åØē«Æå£ļ¼é»č®¤ļ¼18793ļ¼ |
+| webhook_path | string | å¦ | Webhook 端ē¹č·Æå¾ļ¼é»č®¤ļ¼/webhook/wecomļ¼ |
+| allow_from | array | å¦ | ēØę· ID ē½ååļ¼ē©ŗå¼ = å
许ęęēØę·ļ¼ |
+| reply_timeout | int | å¦ | åå¤č¶
ę¶ę¶é“ļ¼åä½ļ¼ē§ļ¼é»č®¤å¼ļ¼5ļ¼ |
+
+## 设置ęµēØ
+
+1. åØä¼äøå¾®äæ”群äøę·»å ęŗåØäŗŗ
+2. č·å Webhook URL
+3. (å¦éę„ę¶ę¶ęÆ) åØęŗåØäŗŗé
置锵é¢č®¾ē½®ę„ę¶ę¶ęÆē API å°åļ¼åč°å°åļ¼ä»„å Token å EncodingAESKey
+4. å°ēøå
³äæ”ęÆå”«å
„é
ē½®ęä»¶
From aea4f25c8387aee5e16b03a126beebbd712ff26d Mon Sep 17 00:00:00 2001
From: zepan
Date: Sat, 21 Feb 2026 22:45:47 +0800
Subject: [PATCH 25/88] 1. update wechat qrcode. 2. add CONTRIBUTING.md
---
CONTRIBUTING.md | 302 ++++++++++++++++++++++++++++++++++++++++++++
CONTRIBUTING.zh.md | 303 +++++++++++++++++++++++++++++++++++++++++++++
assets/wechat.png | Bin 144319 -> 144045 bytes
3 files changed, 605 insertions(+)
create mode 100644 CONTRIBUTING.md
create mode 100644 CONTRIBUTING.zh.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..88227f493
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,302 @@
+# Contributing to PicoClaw
+
+Thank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing.
+
+PicoClaw itself was substantially developed with AI assistance ā we embrace this approach and have built our contribution process around it.
+
+## Table of Contents
+
+- [Code of Conduct](#code-of-conduct)
+- [Ways to Contribute](#ways-to-contribute)
+- [Getting Started](#getting-started)
+- [Development Setup](#development-setup)
+- [Making Changes](#making-changes)
+- [AI-Assisted Contributions](#ai-assisted-contributions)
+- [Pull Request Process](#pull-request-process)
+- [Branch Strategy](#branch-strategy)
+- [Code Review](#code-review)
+- [Communication](#communication)
+
+---
+
+## Code of Conduct
+
+We are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated.
+
+---
+
+## Ways to Contribute
+
+- **Bug reports** ā Open an issue using the bug report template.
+- **Feature requests** ā Open an issue using the feature request template; discuss before implementing.
+- **Code** ā Fix bugs or implement features. See the workflow below.
+- **Documentation** ā Improve READMEs, docs, inline comments, or translations.
+- **Testing** ā Run PicoClaw on new hardware, channels, or LLM providers and report your results.
+
+For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
+
+---
+
+## Getting Started
+
+1. **Fork** the repository on GitHub.
+2. **Clone** your fork locally:
+ ```bash
+ git clone https://github.com//picoclaw.git
+ cd picoclaw
+ ```
+3. Add the upstream remote:
+ ```bash
+ git remote add upstream https://github.com/sipeed/picoclaw.git
+ ```
+
+---
+
+## Development Setup
+
+### Prerequisites
+
+- Go 1.25 or later
+- `make`
+
+### Build
+
+```bash
+make build # Build binary (runs go generate first)
+make generate # Run go generate only
+make check # Full pre-commit check: deps + fmt + vet + test
+```
+
+### Running Tests
+
+```bash
+make test # Run all tests
+go test -run TestName -v ./pkg/session/ # Run a single test
+go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
+```
+
+### Code Style
+
+```bash
+make fmt # Format code
+make vet # Static analysis
+make lint # Full linter run
+```
+
+All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.
+
+---
+
+## Making Changes
+
+### Branching
+
+Always branch off `main` and target `main` in your PR. Never push directly to `main` or any `release/*` branch:
+
+```bash
+git checkout main
+git pull upstream main
+git checkout -b your-feature-branch
+```
+
+Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider`, `docs/contributing-guide`.
+
+### Commits
+
+- Write clear, concise commit messages in English.
+- Use the imperative mood: "Add retry logic" not "Added retry logic".
+- Reference the related issue when relevant: `Fix session leak (#123)`.
+- Keep commits focused. One logical change per commit is preferred.
+- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
+- Refer toĀ https://www.conventionalcommits.org/zh-hans/v1.0.0/
+
+### Keeping Up to Date
+
+Rebase your branch onto upstream `main` before opening a PR:
+
+```bash
+git fetch upstream
+git rebase upstream/main
+```
+
+---
+
+## AI-Assisted Contributions
+
+PicoClaw was built with substantial AI assistance, and we fully embrace AI-assisted development. However, contributors must understand their responsibilities when using AI tools.
+
+### Disclosure Is Required
+
+Every PR must disclose AI involvement using the PR template's **š¤ AI Code Generation** section. There are three levels:
+
+| Level | Description |
+|---|---|
+| š¤ Fully AI-generated | AI wrote the code; contributor reviewed and validated it |
+| š ļø Mostly AI-generated | AI produced the draft; contributor made significant modifications |
+| šØāš» Mostly Human-written | Contributor led; AI provided suggestions or none at all |
+
+Honest disclosure is expected. There is no stigma attached to any level ā what matters is the quality of the contribution.
+
+### You Are Responsible for What You Submit
+
+Using AI to generate code does not reduce your responsibility as the contributor. Before opening a PR with AI-generated code, you must:
+
+- **Read and understand** every line of the generated code.
+- **Test it** in a real environment (see the Test Environment section of the PR template).
+- **Check for security issues** ā AI models can generate subtly insecure code (e.g., path traversal, injection, credential exposure). Review carefully.
+- **Verify correctness** ā AI-generated logic can be plausible-sounding but wrong. Validate the behavior, not just the syntax.
+
+PRs where it is clear the contributor has not read or tested the AI-generated code will be closed without review.
+
+### AI-Generated Code Quality Standards
+
+AI-generated contributions are held to the **same quality bar** as human-written code:
+
+- It must pass all CI checks (`make check`).
+- It must be idiomatic Go and consistent with the existing codebase style.
+- It must not introduce unnecessary abstractions, dead code, or over-engineering.
+- It must include or update tests where appropriate.
+
+### Security Review
+
+AI-generated code requires extra security scrutiny. Pay special attention to:
+
+- File path handling and sandbox escapes (see commit `244eb0b` for a real example)
+- External input validation in channel handlers and tool implementations
+- Credential or secret handling
+- Command execution (`exec.Command`, shell invocations)
+
+If you are unsure whether a piece of AI-generated code is safe, say so in the PR ā reviewers will help.
+
+---
+
+## Pull Request Process
+
+### Before Opening a PR
+
+- [ ] Run `make check` and ensure it passes locally.
+- [ ] Fill in the PR template completely, including the AI disclosure section.
+- [ ] Link any related issue(s) in the PR description.
+- [ ] Keep the PR focused. Avoid bundling unrelated changes together.
+
+### PR Template Sections
+
+The PR template asks for:
+
+- **Description** ā What does this change do and why?
+- **Type of Change** ā Bug fix, feature, docs, or refactor.
+- **AI Code Generation** ā Disclosure of AI involvement (required).
+- **Related Issue** ā Link to the issue this addresses.
+- **Technical Context** ā Reference URLs and reasoning (skip for pure docs PRs).
+- **Test Environment** ā Hardware, OS, model/provider, and channels used for testing.
+- **Evidence** ā Optional logs or screenshots demonstrating the change works.
+- **Checklist** ā Self-review confirmation.
+
+### PR Size
+
+Prefer small, reviewable PRs. A PR that changes 200 lines across 5 files is much easier to review than one that changes 2000 lines across 30 files. If your feature is large, consider splitting it into a series of smaller, logically complete PRs.
+
+---
+
+## Branch Strategy
+
+### Long-Lived Branches
+
+- **`main`** ā the active development branch. All feature PRs target `main`. The branch is protected: direct pushes are not permitted, and at least one maintainer approval is required before merging.
+- **`release/x.y`** ā stable release branches, cut from `main` when a version is ready to ship. These branches are more strictly protected than `main`.
+
+### Requirements to Merge into `main`
+
+A PR can only be merged when all of the following are satisfied:
+
+1. **CI passes** ā All GitHub Actions workflows (lint, test, build) must be green.
+2. **Reviewer approval** ā At least one maintainer has approved the PR.
+3. **No unresolved review comments** ā All review threads must be resolved.
+4. **PR template is complete** ā Including AI disclosure and test environment.
+
+### Who Can Merge
+
+Only maintainers can merge PRs. Contributors cannot merge their own PRs, even if they have write access.
+
+### Merge Strategy
+
+We use **squash merge** for most PRs to keep the `main` history clean and readable. Each merged PR becomes a single commit referencing the PR number, e.g.:
+
+```
+feat: Add Ollama provider support (#491)
+```
+
+If a PR consists of multiple independent, well-separated commits that tell a clear story, a regular merge may be used at the maintainer's discretion.
+
+### Release Branches
+
+When a version is ready, maintainers cut a `release/x.y` branch from `main`. After that point:
+
+- **New features are not backported.** The release branch receives no new functionality after it is cut.
+- **Security fixes and critical bug fixes are cherry-picked.** If a fix in `main` qualifies (security vulnerability, data loss, crash), maintainers will cherry-pick the relevant commit(s) onto the affected `release/x.y` branch and issue a patch release.
+
+If you believe a fix in `main` should be backported to a release branch, note it in the PR description or open a separate issue. The decision rests with the maintainers.
+
+Release branches have stricter protections than `main` and are never directly pushed to under any circumstances.
+
+---
+
+## Code Review
+
+### For Contributors
+
+- Respond to review comments within a reasonable time. If you need more time, say so.
+- When you update a PR in response to feedback, briefly note what changed (e.g., "Updated to use `sync.RWMutex` as suggested").
+- If you disagree with feedback, engage respectfully. Explain your reasoning; reviewers can be wrong too.
+- Do not force-push after a review has started ā it makes it harder for reviewers to see what changed. Use additional commits instead; the maintainer will squash on merge.
+
+### For Reviewers
+
+Review for:
+
+1. **Correctness** ā Does the code do what it claims? Are there edge cases?
+2. **Security** ā Especially for AI-generated code, tool implementations, and channel handlers.
+3. **Architecture** ā Is the approach consistent with the existing design?
+4. **Simplicity** ā Is there a simpler solution? Does this add unnecessary complexity?
+5. **Tests** ā Are the changes covered by tests? Are existing tests still meaningful?
+
+Be constructive and specific. "This could have a race condition if two goroutines call this concurrently ā consider using a mutex here" is better than "this looks wrong".
+
+
+### Reviewer List
+Once your PR is submitted, you can reach out to the assigned reviewers listed in the following table.
+
+|Function| Reviewer|
+|--- |--- |
+|Provider|@yinwm |
+|Channel |@yinwm |
+|Agent |@lxowalle|
+|Tools |@lxowalle|
+|SKill ||
+|MCP ||
+|Optimization|@lxowalle|
+|Security||
+|AI CI |@imguoguo|
+|UX ||
+|Document||
+
+---
+
+## Communication
+
+- **GitHub Issues** ā Bug reports, feature requests, design discussions.
+- **GitHub Discussions** ā General questions, ideas, community conversation.
+- **Pull Request comments** ā Code-specific feedback.
+- **Wechat&Discord** ā We will invite you when you have at least one merged PR
+
+When in doubt, open an issue before writing code. It costs little and prevents wasted effort.
+
+---
+
+## A Note on the Project's AI-Driven Origin
+
+PicoClaw's architecture was substantially designed and implemented with AI assistance, guided by human oversight. If you find something that looks odd or over-engineered, it may be an artifact of that process ā opening an issue to discuss it is always welcome.
+
+We believe AI-assisted development done responsibly produces great results. We also believe humans must remain accountable for what they ship. These two beliefs are not in conflict.
+
+Thank you for contributing!
diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md
new file mode 100644
index 000000000..01a1abfd5
--- /dev/null
+++ b/CONTRIBUTING.zh.md
@@ -0,0 +1,303 @@
+# åäøč“”ē® PicoClaw
+
+ęč°¢ä½ åÆ¹ PicoClaw ēå
³ę³Øļ¼ę¬é”¹ē®ęÆäøäøŖē¤¾åŗé©±åØēå¼ęŗé”¹ē®ļ¼ē®ę ęÆę建 č½»éēµę“»,äŗŗäŗŗåÆēØ ēäøŖäŗŗAIå©ęćę们欢čæäøåå½¢å¼ēč“”ē®ļ¼Bug äæ®å¤ćę°åč½ćę攣ćēæ»čÆåęµčÆć
+
+PicoClaw ę¬čŗ«åØå¾å¤§ēØåŗ¦äøęÆåå© AI č¾
å©å¼åēāāę们ę„ę±čæē§ę¹å¼ļ¼å¹¶å“ē»å®ę建äŗč“”ē®ęµēØć
+
+## ē®å½
+
+- [č”äøŗåå](#č”äøŗåå)
+- [č“”ē®ę¹å¼](#č“”ē®ę¹å¼)
+- [åæ«éå¼å§](#åæ«éå¼å§)
+- [å¼åēÆå¢é
ē½®](#å¼åēÆå¢é
ē½®)
+- [ę交修ę¹](#ę交修ę¹)
+- [AI č¾
å©č“”ē®](#ai-č¾
å©č“”ē®)
+- [Pull Request ęµēØ](#pull-request-ęµēØ)
+- [åęÆēē„](#åęÆēē„)
+- [代ē å®”ę„](#代ē å®”ę„)
+- [ę²éęø é](#ę²éęø é)
+
+---
+
+## č”äøŗåå
+
+ę们č“åäŗē»“ę¤äøäøŖå儽ćäŗēøå°éē社åŗēÆå¢ć请äæęåęć建设ę§ēęåŗ¦ļ¼å¹¶åęå°ēč§£ä»äŗŗćä»»ä½å½¢å¼ēéŖę°ęę§č§åäøč¢«ę„åć
+
+---
+
+## č“”ē®ę¹å¼
+
+- **Bug åé¦** ā ä½æēØ Bug ę„å樔ęæęäŗ¤ Issueć
+- **åč½å»ŗč®®** ā 使ēØåč½čÆ·ę±ęØ”ęæęäŗ¤ Issueļ¼å»ŗč®®åØå¼å§å®ē°åå
čæč”讨论ć
+- **代ē č“”ē®** ā äæ®å¤ Bug ęå®ē°ę°åč½ļ¼åč§äøę¹å·„ä½ęµēØć
+- **ę攣ę¹čæ** ā å®å READMEćę攣ć代ē 注éęēæ»čÆć
+- **ęµčÆäøéŖčÆ** ā åØę°ē”¬ä»¶ćę°ęø éęę° LLM ęä¾åäøčæč” PicoClaw å¹¶åé¦ē»ęć
+
+对äŗč¾å¤§ēę°åč½ļ¼čÆ·å
ęäŗ¤ Issue 讨论设讔ę¹ę”ļ¼ååØęå代ē ćčæč½éæå
ę ęęå
„ļ¼ä¹ē”®äæäøé”¹ē®ę¹åäæęäøč“ć
+
+---
+
+## åæ«éå¼å§
+
+1. åØ GitHub äø **Fork** ę¬ä»åŗć
+2. å°ä½ ē Fork **å
é**å°ę¬å°ļ¼
+ ```bash
+ git clone https://github.com/<ä½ ēēØę·å>/picoclaw.git
+ cd picoclaw
+ ```
+3. ę·»å äøęøøčæēØä»åŗļ¼
+ ```bash
+ git remote add upstream https://github.com/sipeed/picoclaw.git
+ ```
+
+---
+
+## å¼åēÆå¢é
ē½®
+
+### åē½®ä¾čµ
+
+- Go 1.25 ęę“é«ēę¬
+- `make`
+
+### ę建
+
+```bash
+make build # ę建äŗčæå¶ęä»¶ļ¼ä¼å
ę§č” go generateļ¼
+make generate # ä»
ę§č” go generate
+make check # å®ę“ēęäŗ¤åę£ę„ļ¼deps + fmt + vet + test
+```
+
+### čæč”ęµčÆ
+
+```bash
+make test # čæč”ęęęµčÆ
+go test -run TestName -v ./pkg/session/ # čæč”åäøŖęµčÆ
+go test -bench=. -benchmem -run='^$' ./... # čæč”åŗåęµčÆ
+```
+
+### 代ē é£ę ¼
+
+```bash
+make fmt # ę ¼å¼å代ē
+make vet # éęåę
+make lint # å®ę“ē lint ę£ę„
+```
+
+ęę CI ę£ę„éčæå PR ęč½č¢«åå¹¶ćęØé代ē å请å
åØę¬å°čæč” `make check`ļ¼ęååē°é®é¢ć
+
+---
+
+## ę交修ę¹
+
+### åęÆē®”ē
+
+å§ē»ä» `main` åęÆååŗļ¼å¹¶åØ PR äøä»„ `main` äøŗē®ę åęÆćäøč¦ē“ę„å `main` ęä»»ä½ `release/*` åęÆęØé代ē ļ¼
+
+```bash
+git checkout main
+git pull upstream main
+git checkout -b ä½ ēåč½åęÆå
+```
+
+请使ēØęčæ°ę§ēåęÆåļ¼ä¾å¦ļ¼`fix/telegram-timeout`ć`feat/ollama-provider`ć`docs/contributing-guide`ć
+
+### Commit č§č
+
+- 使ēØč±ęę°åęø
ę°ćē®ę“ē commit äæ”ęÆć
+- 使ēØē„使å„ļ¼å "Add retry logic"ļ¼čäøęÆ "Added retry logic"ć
+- ęå
³č Issue ę¶čÆ·å¼ēØļ¼`Fix session leak (#123)`ć
+- äæę commit äøę³Øļ¼ęÆäøŖ commit åŖåäøä»¶äŗć
+- 对äŗå°ēęø
ēęę¼åäæ®ę£ļ¼ę PR å请å°å
¶åå¹¶äøŗäøäøŖ commitć
+- ęē
§Ā https://www.conventionalcommits.org/zh-hans/v1.0.0/Ā č§čę„ę°å
+
+### äæęäøäøęøøåę„
+
+ę PR åļ¼čÆ·å°ä½ ēåęÆååŗå°äøęøø `main`ļ¼
+
+```bash
+git fetch upstream
+git rebase upstream/main
+```
+
+---
+
+## AI č¾
å©č“”ē®
+
+PicoClaw åØå¾å¤§ēØåŗ¦äøåå© AI č¾
å©å¼åļ¼ę们å®å
Øę„ę±čæē§å¼åę¹å¼ćä½č“”ē®č
åæ
é”»ęø
ę„å°äŗč§£čŖå·±åØä½æēØ AI å·„å
·ę¶ęęæę
ē蓣任ć
+
+### åæ
é”»ę«é² AI 使ēØę
åµ
+
+ęÆäøŖ PR é½åæ
é”»éčæ PR 樔ęæäøē **š¤ AI 代ē ēę** éØåę«é² AI åäøę
åµļ¼å
±åäøäøŖēŗ§å«ļ¼
+
+| ēŗ§å« | 诓ę |
+|---|---|
+| š¤ å®å
Øē± AI ēę | AI ē¼å代ē ļ¼č“”ē®č
č“蓣宔ę„åéŖčÆ |
+| š ļø äø»č¦ē± AI ēę | AI čµ·čļ¼č“”ē®č
åäŗč¾å¤§äæ®ę¹ |
+| šØāš» äø»č¦ē±äŗŗå·„ē¼å | č“”ē®č
主导ļ¼AI ä»
ęä¾č¾
å©ęęŖä½æēØ AI |
+
+ę们ęęä½ čÆå®å”«åćäøē§ēŗ§å«ååÆę„åļ¼ę²”ęä»»ä½ę§č§āāéč¦ēęÆč“”ē®ē蓨éć
+
+### ä½ åÆ¹ęäŗ¤ē代ē č“å
Øč“£
+
+ä½æēØ AI ēę代ē å¹¶äøč½åč½»ä½ ä½äøŗč“”ē®č
ē蓣任ćåØęäŗ¤å«ę AI ēę代ē ē PR ä¹åļ¼ä½ åæ
é”»ļ¼
+
+- **éč”é
读并ēč§£**ēęē代ē ć
+- **åØēå®ēÆå¢äøęµčÆ**ļ¼åč§ PR 樔ęæäøēęµčÆēÆå¢éØåļ¼ć
+- **ę£ę„å®å
Øé®é¢** ā AI 樔ååÆč½ēęååØå®å
Øéę£ē代ē ļ¼å¦č·Æå¾ē©æč¶ć注å
„ę»å»ćåę®ę³é²ēļ¼ļ¼čÆ·ä»ē»å®”ę„ć
+- **éŖčÆę£ē”®ę§** ā AI ēęēé»č¾åÆč½å¬čµ·ę„åēä½å®é
äøęÆé误ēļ¼čÆ·éŖčÆč”äøŗļ¼čäøä»
ä»
ęÆčÆę³ć
+
+å¦ęęę¾åÆä»„ēåŗč“”ē®č
ę²”ęé
读ęęµčÆ AI ēęē代ē ļ¼čÆ„ PR å°č¢«ē“ę„å
³éļ¼äøäŗå®”ę„ć
+
+### AI ēę代ē ē蓨éę å
+
+AI ēęē代ē äøäŗŗå·„ē¼åē代ē éµå¾Ŗ**ēøåē蓨éč¦ę±**ļ¼
+
+- åæ
é”»éčæęę CI ę£ę„ļ¼`make check`ļ¼ć
+- åæ
锻符å Go ęÆēØåę³ļ¼å¹¶äøē°ę代ē åŗēé£ę ¼äæęäøč“ć
+- äøå¾å¼å
„äøåæ
č¦ēę½č±”ćę»ä»£ē ęčæåŗ¦č®¾č®”ć
+- é”»åØéå½ēå°ę¹å
å«ęę“ę°ęµčÆć
+
+### å®å
Øå®”ę„
+
+AI ēęē代ē éč¦ę ¼å¤ä»ē»ēå®å
Øå®”ę„ć请ē¹å«å
³ę³Øä»„äøę¹é¢ļ¼
+
+- ęä»¶č·Æå¾å¤ēäøę²ē®±ééøļ¼é”¹ē®åå²äøē commit `244eb0b` å°±ęÆēå®ę”ä¾ļ¼
+- channel å¤ēåØå tool å®ē°äøēå¤éØč¾å
„ę ”éŖ
+- åę®ęåÆé„ēå¤ē
+- å½ä»¤ę§č”ļ¼`exec.Command`ćshell č°ēØēļ¼
+
+å¦ęä½ äøē”®å®ęꮵ AI ēę代ē ęÆå¦å®å
Øļ¼čÆ·åØ PR äøčÆ“ęāāå®”ę„č
ä¼åø®å©å¤ęć
+
+---
+
+## Pull Request ęµēØ
+
+### ę PR åēę£ę„
+
+- [ ] åØę¬å°čæč” `make check` 并甮认éčæć
+- [ ] å®ę“唫å PR 樔ęæļ¼å
ę¬ AI ę«é²éØåć
+- [ ] åØ PR ęčæ°äøå
³čēøå
³ Issueć
+- [ ] äæę PR äøę³Øļ¼éæå
å°äøēøå
³ēäæ®ę¹ę··åØäøčµ·ć
+
+### PR 樔ęæåéØå诓ę
+
+PR 樔ęæč¦ę±å”«åļ¼
+
+- **ęčæ°** ā čæäøŖę¹åØåäŗä»ä¹ļ¼äøŗä»ä¹č¦åļ¼
+- **åę“ē±»å** ā Bug äæ®å¤ćę°åč½ćę攣ęéęć
+- **AI 代ē ēę** ā AI åäøę
åµę«é²ļ¼åæ
唫ļ¼ć
+- **å
³č Issue** ā ę¤ PR č§£å³ē Issue é¾ę„ć
+- **ęęÆčęÆ** ā åčé¾ę„å设讔ēē±ļ¼ēŗÆę攣类 PR åÆč·³čæļ¼ć
+- **ęµčÆēÆå¢** ā ēØäŗęµčÆē甬件ćęä½ē³»ē»ć樔å/ęä¾ååęø éć
+- **éŖčÆčÆę®** ā åÆéēę„åæęęŖå¾ļ¼ēØäŗčÆęę¹åØęęć
+- **ę£ę„ęø
å** ā čŖęå®”ę„甮认ć
+
+### PR č§ęØ”
+
+请尽éęäŗ¤å°čęäŗå®”ę„ē PRćäøäøŖę¶å 5 äøŖęä»¶å
± 200 č”ę¹åØē PRļ¼čæęÆę¶å 30 äøŖęä»¶å
± 2000 č”ę¹åØē PR 容ęå®”ę„ćå¦ęä½ ēåč½č¾å¤§ļ¼åÆä»„ččå°å
¶ęåäøŗäøē³»åé»č¾å®ę“ēå° PRć
+
+---
+
+## åęÆēē„
+
+### éæęåęÆ
+
+- **`main`** ā ę“»č·å¼ååęÆćęęåč½ PR å仄 `main` äøŗē®ę ć评åęÆåäæę¤ļ¼ē¦ę¢ē“ę„ęØéļ¼åå¹¶ååæ
é”»č·å¾č³å°äøå结ę¤č
ēę¹åć
+- **`release/x.y`** ā 稳å®ååøåęÆļ¼åØęäøŖēę¬åå¤ååøę¶ä» `main` ååŗćčæäŗåęÆēäæę¤ēŗ§å«é«äŗ `main`ć
+
+### åå¹¶å° `main` ēåęę”ä»¶
+
+PR åæ
é”»åę¶ę»”足仄äøęęę”ä»¶ļ¼ęč½č¢«åå¹¶ļ¼
+
+1. **CI å
ØéØéčæ** ā ęę GitHub Actions å·„ä½ęµļ¼lintćtestćbuildļ¼å为绿č²ć
+2. **č·å¾å®”ę„č
ę¹å** ā č³å°äøå结ę¤č
å·²ę¹å评 PRć
+3. **ę ęŖč§£å³ēå®”ę„ęč§** ā ęęå®”ę„讨论线ēØåå·²å
³éć
+4. **PR 樔ęæå”«åå®ę“** ā å
ę¬ AI ę«é²åęµčÆēÆå¢äæ”ęÆć
+
+### č°åÆä»„åå¹¶
+
+åŖę结ę¤č
ęč½åå¹¶ PRćč“”ē®č
äøč½åå¹¶čŖå·±ē PRļ¼å³ä½æę„ęåęéä¹äøč”ć
+
+### åå¹¶ēē„
+
+äøŗäæę `main` åå²ęø
ę°åÆčÆ»ļ¼ę们对大å¤ę° PR ä½æēØ **Squash Merge**ćęÆäøŖåå¹¶ē PR åäøŗäøäøŖå
å« PR ē¼å·ēåē¬ commitļ¼ä¾å¦ļ¼
+
+```
+feat: Add Ollama provider support (#491)
+```
+
+å¦ęäøäøŖ PR å
å«å¤äøŖē¬ē«ćē»ęęø
ę°ćč½č®²čæ°å®ę“ę
äŗē commitļ¼ē»“ę¤č
åÆč§ę
åµä½æēØę®é mergeć
+
+### Release åęÆ
+
+å½ęäøŖēę¬åå¤å°±ē»Ŗę¶ļ¼ē»“ę¤č
ä¼ä» `main` ååŗ `release/x.y` åęÆćę¤åļ¼
+
+- **ę°åč½äøä¼č¢«åęŗÆļ¼backportļ¼ć** Release åęÆååŗåļ¼äøåę„ę¶ä»»ä½ę°åč½ć
+- **å®å
Øäæ®å¤åå
³é® Bug äæ®å¤ä¼č¢« cherry-pick čæę„ć** č„ `main` äøēęäøŖäæ®å¤å±äŗå®å
Øę¼ę“ćę°ę®äø¢å¤±ęå“©ęŗē±»é®é¢ļ¼ē»“ę¤č
ä¼å°ēøå
³ commit cherry-pick å°åå½±åē `release/x.y` åęÆļ¼å¹¶ååøč”„äøēę¬ć
+
+å¦ęä½ č®¤äøŗ `main` äøēęäøŖäæ®å¤åŗčÆ„č¢«åęŗÆå°ęäøŖ release åęÆļ¼čÆ·åØ PR ęčæ°äøę³Øęļ¼ęåē¬å¼äøäøŖ Issue 诓ęćęē»å³å®ē±ē»“ę¤č
ååŗć
+
+Release åęÆēäæę¤ēŗ§å«é«äŗ `main`ļ¼åØä»»ä½ę
åµäøåäøå
许ē“ę„ęØéć
+
+---
+
+## 代ē å®”ę„
+
+### 对蓔ē®č
ē建议
+
+- åØåēę¶é“å
åå¤å®”ę„ęč§ćå¦ęéč¦ę“å¤ę¶é“ļ¼čÆ·åē„ć
+- ę“ę° PR 仄ååŗåé¦ę¶ļ¼ē®č¦čÆ“ęę¹åØå
容ļ¼ä¾å¦ļ¼"ę建议ę¹ēØäŗ `sync.RWMutex`"ļ¼ć
+- å¦ęä½ äøåęęę”åé¦ļ¼čÆ·ē¤¼č²å°éčæ°ä½ ēēē±āāå®”ę„č
ä¹åÆč½ęå¤ę失误ēę¶åć
+- å®”ę„å¼å§å请äøč¦ force pushāāčæä¼č®©å®”ę„č
é¾ä»„追踪ååć请使ēØé¢å¤ē commitļ¼ē»“ę¤č
åØåå¹¶ę¶ä¼čæč” squashć
+
+### 对宔ę„č
ē建议
+
+å®”ę„éē¹ļ¼
+
+1. **ę£ē”®ę§** ā 代ē ęÆå¦å®ē°äŗå
¶å£°ē§°ēåč½ļ¼ęÆå¦ååØč¾¹ēę
åµļ¼
+2. **å®å
Øę§** ā 对 AI ēę代ē ćtool å®ē°å channel å¤ēåØå°¤å
¶éč¦å
³ę³Øć
+3. **ę¶ę** ā å®ē°ę¹å¼ęÆå¦äøē°ę设讔äøč“ļ¼
+4. **ē®ę“ę§** ā ęÆå¦ęę“ē®åēę¹ę”ļ¼ęÆå¦å¼å
„äŗäøåæ
č¦ēå¤ęåŗ¦ļ¼
+5. **ęµčÆ** ā ę¹åØęÆå¦ęęµčÆč¦ēļ¼ē°ęęµčÆęÆå¦ä»ē¶ęęä¹ļ¼
+
+请ē»åŗå»ŗč®¾ę§äøå
·ä½ēåé¦ć"å¦ę两个 goroutine åę¶č°ēØčæäøŖå½ę°åÆč½ä¼ęē«ęę”ä»¶ļ¼å»ŗč®®åØčæéå äøäøŖ mutex" čæęÆ "čæéēčµ·ę„ęé®é¢" ę“ęåø®å©ć
+
+### å®”ę„č
å蔨
+ę交对åŗPRåļ¼åÆä»„åčäøč”Øč系对åŗēå®”ę„äŗŗåę²é
+
+|Function| Reviewer|
+|--- |--- |
+|Provider|@yinwm |
+|Channel |@yinwm |
+|Agent |@lxowalle|
+|Tools |@lxowalle|
+|SKill ||
+|MCP ||
+|Optimization|@lxowalle|
+|Security||
+|AI CI |@imguoguo|
+|UX ||
+|Document||
+
+
+
+---
+
+## ę²éęø é
+
+- **GitHub Issues** ā Bug ę„åćåč½å»ŗč®®ć设讔讨论ć
+- **GitHub Discussions** ā äøč¬ę§é®é¢ćę³ę³äŗ¤ęµć社åŗč®Øč®ŗć
+- **Pull Request čÆč®ŗ** ā äøå
·ä½ä»£ē ēøå
³ēåé¦ć
+- **Wechat&Discord** ā å½ä½ ęč³å°äøäøŖå·²åå¹¶ēPRåļ¼ę们ä¼éčÆ·ä½ å å
„å¼åč
äŗ¤ęµē¾¤
+
+ęēé®ę¶ļ¼čÆ·å
å¼ Issue 讨论ļ¼ååØęå代ē ćčæå ä¹ę²”ęęę¬ļ¼å“č½éæå
大éę ęęå
„ć
+
+---
+
+## å
³äŗę¬é”¹ē®ē AI 驱åØčµ·ęŗ
+
+PicoClaw ēę¶ęåØäŗŗå·„ēē£äøļ¼ē»ē± AI č¾
å©å®ęäŗå¤§é设讔åå®ē°å·„ä½ćå¦ęä½ åē°ęå¤ēčµ·ę„å„ęŖęčæåŗ¦č®¾č®”ļ¼čæåÆč½ęÆčÆ„čæēØēäøēēčæ¹āā欢čæę Issue 讨论ć
+
+ę们ēøäæ”ļ¼č“蓣任å°ä½æēØ AI č¾
å©å¼åč½äŗ§ēä¼ē§ēęęćę们åę ·ēøäæ”ļ¼äŗŗē±»åæ
锻对čŖå·±ęäŗ¤ēå
容č“č“£ćčæäø¤ē¹å¹¶äøēē¾ć
+
+ęč°¢ä½ ēč“”ē®ļ¼
diff --git a/assets/wechat.png b/assets/wechat.png
index 8fc41ea7d53cfc9e0ccb7b6fe5a4fd6d079cee7c..a34217c335542a13aace2103bd15ccebf94acde2 100644
GIT binary patch
literal 144045
zcmeFZcT`hdw>P@!hyv0(K{^5gQl&*enurtyq(%j#2}tjRA|Sm9NReKY`p~5#y-06L
z551F61BB$p=Pl>`&iU>b=iWcQGwyqzI2Ll>R}+dGJ(
zwztJ6!-*pL1DAynyxF*amA~fTFCF+x2maE5zjWX)9r#NJ{?dW}IUV>kp;AQ3j(4DJzO+;m
zqs#H0<=Rd*!F!ZQ^XwCNvj%ehGjCrQI%3^+SZUG&9=7hi7y?`|fK4#kM;Qb7MaWMO
zq0S=FS2RD&Q5#Xvj8P)3|JtyHLoM<=?GuaE>`M%gIR;&N9b5Aq1IXoKfYxp&`{Eb(
z3dk~Nw9EGTEYu9)Kk^tjjzOPou47iwUUFIV@OOJQ1R>HV_C0O{D8ISer3i4{h5)*b_FHe=;0w-S33
z$RhdYqaptJXn*$O!jLD1GjUx=esM^5ea5%V>3Gg!yyaq2#`}haJ~k(ttSX{!q89I)
zs>r3d-KcPG1TXOKR*Jn#LuHg=HffxzPe+i)kn`RnF`Tf|+`#dO*Cf2T0ly37SBT%~
zCDf?dHW~vgLW)ic`RvR$D=5FaeBG_y)jxhb?v>Sm|FQ>XnS8pX8aC5~<5kc}+jd6-
zzC!!*eUSQv7GBhvv(F5jqdDo~8AG(;lgM5oq9plESwB*vs!9#_V~RydId^mXn6Nl+
zX3&=kq1}5la{IA)C1KG<>RfMPy_ZaSDpv(*`=raRo5zb5)$&PWO)`U;S9T5!8Ya@l
zO>IwtTsAE6{O(gbnON3`4X9SBB|&(&q(x5GHsNKKQPC|E3CV{1SBFZY4zgb6#h#%u
zZr=iP;y%FcGD|>b=Yf&zC@Jq(S5hq~G$i>o4;UV*PFm5B3_?&@}rJ%>0`bG!u$>
zUzw^SSrnVe
zDUdpNayEM8!+Wo+c0reF!!K=7Gp^*w)O(YsTpgUmsL~)?Wl~;mPnH@k0wh@ci2NC!
zd6ztD)#O`Ky*=dySM`go`oaWIls5NVs?y5)DnNmjdaqhG)VWaW+nmF^uowcBlQFpn4LEWD*V_mnfb
zZ_g)opaDvYlvah$+MJPoSup8b*KnS2I67P9Qr{vzp%>rWcl^SyaUIh$JlQv7&-l3s
zM;6yRF~e=>k+8SKdZlx>v4x)?j9PTA1r^RfMM=>xtZ=6?PO6UsUS8LmpB3WFN@)B+
zmMh26^jGtY>&`bA{rh+$P3=k`h0eqnx59!Sd
zsL4J)yRDtNkM4O&rV2CW=5dVA<03aHw@aO}chfLc+3@3L+La8Y
z{GwDE|5&7lgJEtioA8g4#|K;cc@Z@a7#4Qi6ED6>+tQxZ6@xvi9+Dj*DL81lJwIB{
zx{W0;*r(Y*y=ot?H}tYJ#v7c>y*QASyH`>wcRG3Q6qYt@oth+p2&9#{G|!m%aSA
z=U_hBoo%!uOYo=ZM2Xu?!!Lg=&MtP!Rnu^95IqTU7FOFV^PuG&0Lf2Hk+Q!t(3-7U
zp!TyaRKw01HK6USt8M-5=3*lY4_X1?5B@PDXG)fc0oagjpbhJn#<>xExBjtq|6Q)6
zR=Zh>3F|>1kkqo93NM^Z)2(V~JY8&JMqB=Eh81(P{Ze=h;n{{JHxB39TdPj#9-j
zAaXwE)ZrF2rPRC`$jVX}6;cQT{P_f5AdzM)7VZ=~oNWDFco-m};+{Ru=Icsc!5}|r
zeWiYnf>~2#2Vrg|V~+TUX(>kXhL0i%R;|cqy|bzj;)K-`v|CRqQ?=6RbpjslChi(F
zE`2CH315wXYowUA=Wa9`oI(2$(PNG+XObh=XSK5^eXP8-Dc4xo^()%)7T1HG=dM~t
z&S>+<{F(!FI)VWzurqQaGeJ5M+vaUAa^@o;$8-jLmJ#F^y`wOCs?_@nqCD}Z33RCv
zEnbvsC(+pWk-}sWY-&VWK2S#k_sv
z)4q23+l*0l9$D7Mc|GpE@qI)yi(jA}{iD$BL-aNDbT^+Pw4UK977
zh_h}_93<-OZ-C##x?zC#7(fLB6lGnVVt|=kA&_+fhnT(d&y!fJ#Ad81&tjYL$Sz7q
zzo?MCu8p9WeGwT`
zy3`z?+N8JLN+SJ09VwUk^&VGqFMcR$t;9p$$C^!TwBk;(r9aL5skZUR+28RXGim8@
z(}wz~?5WD*Bbkw)d?k*@cdEZ|7=t7eGv*PQ-ftT+rOLyI^G3g3()1pB0
z7lJ$rbjj^Oz2!TznZ|sAvJLmx9g*}!vtIZjbwA$`u0)Z#ysp&!s`-_l3HA|BdA_4_=+aG7tq_y|f{FQ@ZW$_|~Ar){GgRnPMHk
z$d|VK<-&Z|3|ib-FsC8lY`h0)PDk3;4&HiXSvk5bI)$dfuFm{)|HDiSFy8X)pBQZo
zOi+mdh}C|5ZnOPZ?k81YIu2fAAzwued7msSr*?`>rdbZ~=9VfbQ)}b=8x`PR(VEeJ
zz&J4+te?%yyt6?2M(*ULlZCbu#$!^tXT;R-)FR3aVl-{CGieE=S=xrb6N&{!E1R1nIOH{nt*H;Ri)`XzD}7gnvd@6R!3
zlfr1ak>laWoN`-n&b0dcL;K}SD3Ve_h^q@K5dV!da|_yEe%K?=UNf~f-Z6b7p`PJY
zBz2(*9&h;aM8Y5iYIWEA9Wd)hD604P#^h6&M%J%du7+(0k;T%gOxERz<2C7BNQ_Kq
zTT$^4Cv7CLa&si>3zh0gdAnKA^xzc9OwNib%19dG305KaGnGe?J`ZdP-W-snjY?U1ODv;;A*(B2pl!gLC>r@1AOM{2ewY`Eq~M
zguQXQD%{X)d&)oMnsq_c{oCJ%D2KIGKUPgQHmr-pdj4-M{$1R%f5<`e`M0|d#d`Z?
zjKeeF)gkZ_sBe@g+Sd_2T)W~&*+Gg+on~El4_5HJor7h_v(uLqv3gpCHSlxu&{fD2
zWTYXwCzS5!Nv;Zi^v~Yy7f^VH0|rRJLR%>Ga_W9JuTRP!Q#W_bH#_0RJs9AG47&~D
z=3$qB0`|TgQHYHx2I^pdXun&sHW=V34;qaDPQNa}Q^_w7;BzcwD>J-)1Nvc5WwYmt
zzyKu1Jr{iwIV0D8s%UX`Uq-~N-ynSodT|xo<0T!MO6bxFe148?rHbNKLi=i6qp;vs
zyHfip>ib=-%61e592D*=w341ajD`yf)^2?$P}NJ8_TG1J^g{3-h-Y
z5Xc#WnPTRI!LR*$zDq(Ku{0kqT(@ps%@-jj@nDisS?gD~Zejq(8))&9;>DblH?}r!
zC|oqncW?)@V^iB;Pmv|ay_F^OPzN;HJM6pT4ck_l+9~sC_7}73t9;=!-?V>iX?>|s
zNI(i$1|6IYF*@WB=HPhVm=Ckd*(oe6ysyVGFjgi@ZPRM;PM#a?caXQTTXIsZA6;jk
z=)DGexSfIBi9HQ;BTLz!?O3S)s={{qQvS2t<85oS!qR;
zhaCGEM_N~`Q|raLU%B(R-3`xV_-a^;q>c%4`_M|Y{;z_HecOU#VyL@j4PuFIQtBe)-4L7N#eqN>oErMFIOs2a}+ci|*aK;Ca
zuNp4>s%Nygv&pU)CGRxvQ4Z~%-P3pOW=6aBlda!aO8fM@k+I0#wQeF@(D0fZ1C*z9
z;&Qc4>5J3`e|>c^eJ>r#(vvVk!QleE{hg))CiAJ>jnvrca?({5h4;EnRZiJ1lN?d~
ztTAlmUIC>Bci;FO#xORagUJ(=UC@}|(E6^b*dT7my&vessci^tX4ghB1e+M7~ciG6~BbBSZIuO8RrPluVYip?|+6lqK{N>cQ<
zDtek6*}f2KSgZ3g;|UuCHP1`YY|mto3Dpcq$Q-}ZGk@PC>E<9klR58M{^G5;jHKnq
zp`)fiZ)*|_u00zi0gw80TN`jw7
zt+seTs8la$!m<8zG^!-D&h&`wv_h1o>{)JBd(>dl_KFb0nt5!2i|!z23ahK==P&bL
zPRo0{jIS
z2Hg%vzb;44E-U#tbS;;9ffdy%lt^0Ip%me7eVeWV_Rrg$`N!T0>YX523EM63{W-)p
zX2Uxb1kAF6WKy
z9Vw3w+viB(SAtKs*&
z_cl)@k|58Oq3_T{Yw!BqER(4r#BkjKts?}nFPd#hMakf&R4y3fF{wyocwA0Tc_r!`
zSdskUp`PN#S<^C6Sd%)fR-hP_6RIYDY|zz4Y+M@2`ib@d2f=%dzULq<-?THxO{6V?
zW7#%U)q
zm2q?^Wh}7dlzA*X*Lp688fPtw%0oOU<=WM8-Gn%OZ%Kx{zy}mA=H<}*EtvhnKI7Z4
zUF?i3x!V`}BS*p2_TIa831xJAE*#dHT;@^ev18Rkugghp<)nsoNP%lGtNxFv1g(W?IP!g(zwW5%qQf=2bBh_b;Df68Mr3iX)W2!7Fr
z|8AHUD-SN;a*So?7x`1=llNeEMfp7OB4xk=1DMBN9AbbN=;;=^7j(RYje%i3%jsHb
zoW$!G@MarY1{)5`yJCipKv7t~qQYoW=iPm1WiJZrjP^n=PoRHqkM(YMjUw(5YMn`8
zc{LaSiS5)Zb`NW0_3Ws!t~!N{c3HHBHY8*k~0%&Mdc)mh^JxI#w94&HfVlG|*XG?Ki~+S9nnBNQ82pI3;Dr051@F?=K1C
z>iHb4oX0foKQ(-@Kag_~p!k%KP*n_P&(#2%DepK#Vy%-B2(4~{wKR<<1AlUX{>CTB
z{q`+%R_mBflE|79Nd*+a^)=ioZmwoBq)&?RihjIq+*%ii@y?YGdE(iaX)pd>&`
znMQHbXuQl#N^I`)(;gO;rn*5sZ_RAeZRRvXJc
zil14qj>9-IfEiTg6M`Mh%p5_qsukvYbA=<%_ROeeMv*Js
ztyHJ#RdtM!@`Kg!06FOh_!82lf)E3Yigtm|S3qbP)A~+ugtSnXx*PEcmKsA?_1s4O
z7bCCCxjB<*TcaN@UUP1_7I%@|8cxLkTKYiMIeqGbp6rbcs=_=6ROcE7DvTsr;A0@|&H@o$lF;==@ZZ>wJ_pEfM
zj?vR0bZ4D?WWtF?BB^+Lwxunr05S{t&HfZDG&DHYRR&D{%dh@_o|=jGqg4>44zCDy
zEUm!+G&XeJky;FycUWqk-Hl2@OVG=@S7i9|tX7aqNU4yQ0Yc+gfse
zkId`VJfsjdMaYQE^)vLfhaK)&)W3+XyrMos+q|A@n-&=sZjpY|>W}0?s4By08&-^4
zUQg(x3985E7{3^cfMtB1`|NZc_T;?cODiLG0cs$_@=a}}1zYxR8;_iM7}qGjo>Q{0
ze?#9%c7g+2l#6{d-exPvaLPHcFBb1mrL|ei`9NfF&?@Gs^m(`PTUnV>^py0Av56xu@9mVY4;zi^dWivT#A2kNXNb+Nxgi6M>edv^hGgScF7&R^&Hb?p
zvQT1F33)GCbkf%ajytD4l~WUH45ci~r%HtQ!BJtRv-SEY@`dT|N`^Caf$rw4Sx^$tgDJwX?#d
zOE4q@0RN)77QnM&Zvr8W0lIZekh{t;br*{D=mJGcr4
z=z`F4Af6b_zxyRNI>dgzGEJ0TrI+R$I3>I$t{CuQ5IQ!7+yBAN`cccmEN1#dpQEQoNaZ&
zViU-7^gNdKB-B2xaW?&mbf~+|cY;q=nSq6PmD43LM%ZpqP<&TTsiVKb=IF-Tg4!#S
zx1Iz03nMwFCl#NdmZnmkVJq^rD}owSCPrPB1Sx3aC%r;@??A`dq>i`HLy{?H9mhS4
zsJKPV<7V2y4dIdVeDi)3nCb;u49TD(Ok;Y3jHsgj%o7V9k*&3eQQll&0+#+)NUUB<6x@`@-qIG%p8xh!E
z7PjjDum1yF=IfXlAa=W0#l~uUczwwWHAPMP*EM}eyee*R5@>%8=Q+InaC1Ep@8BR=*Yb~9{VSa4q>#paFLD-R!n^mNP!uD~em=~{dL!wyzafy;c74Td%C
zDq5wm==Pn5Ys{|CGNwTvnjtG-LjTd}S^t94#X;C^c*lb%SqWE$4>~WM08w>8_+>zUdGai&l>tu6&B-y
z+JDsCr=slVXNe%n{o*(tARk|Dyy72L?3vg;o0*us&};S8#LMjYk=j_XeG)^}zzqgH
zj=;snG+w-QC{hQCWyEaRS7F1YNi!ILsnpmpG*i40A-EK_kMb+%FE#3^8nPAFC>tn3
z|60z{e!#%cqwKBK;}|`*9fbzL#Ug#m)E1vDD9UyR#xiN&I`m%*o+dM98)dBfo=smladGr$O}nX)7{K
zNw4oqUA*q+T*V%4$je2sBZJGfN0+wcr6%s}Z7`_6o^tpAz96rtH@bA}k!KqBHOMxx
z@kX&5(_Qp0)bw=bh3=_hUGs8*f#HXhw0BvPrHh;iW
zT}}=jR%jxX5I1{LT3HZs$!)nBZpEJdzaq5mncp8MRtr^FKn}wu-Fg({DI|(kpcKd+
zAxh7@&c>MLoT}u|8??;0?%6n6edO@-!;Fjo*GxjBP#LnRGq@{ly3X|j@v-3IhD7`<
zo9KqsyV(}C)7=UDpKws;2b7B-%801TC!DL=S6bbMT~x@1n!)SY1UOTwybJXb8GDES
z6?}0^r5J!z&bNZuq$>Eo)gS|NkfDxDzmP_Uo%>7-YqWIrtdEsE
z97}t^UK6VQwNhoiDd+)F%*QNZG)E^))4Yegz{VpbEwrlh>h3I)6Wb!AV)0HXnY_t2skd06Bqo#;u4GXm}PYN@xX&
zYsbi)^{PzO&Fgl|Z#Nk48>?De=3?j<6f1HSRzZNo?Npi)s0}Y)_A<W(d8h
zMy|B|kQD>Kdkv?tZXn^W9ai3nwrV#+Mg_t8cutvz?j-IH1ny_E`q?q9wnZ1gc>;JQ
z1%$BY{_{PJ`(s7DY67TKQJ+%<+gOra>vCL(FSO^>VMbt>M8
z9%UtIK=gKQm!5PleKmSg?>@FhF7lCvFL9^z=Q(bGtPCvWJJaEZG(&_|q)M
zX{@kwg-A$e9MY<%Zl+Gs+s0ZK!dX9<;NEz>{tA0AgK?)d`iDxfqD$vq4Kqz{)6bd@ZmsM%WDvYtv;=b
zx@!gF{Oud20aOoJgaZ7zeZ{9_UlqRC@+S$?oGgqhl~mHIiyV-7`gHvPvxU>H>D1{+
zhX(d!P{jtEtNTY;qk55@s)u|*EayzTXXNv-W~D1l1_IR=6E_>3e`Pg#e
z@=i2E`uDaIN(A{e1_;A4ldx+XJ(2~-?gbqRD~Cu8FpT9&{^W}1tSGUXX>BBB!zs#s
zdmp0ii3O|Qa|$O=Om47+E{3(baf>jyk4*wr*JD}P70L3%sC#409;L{%3H`>&SFxI|
zFEndD6jTk9?f3n<$}5za!4fJ+Tgk0KCnh)LJ$sOPCfUh2!Wvbq;PT
z13YJXmPoOW^6Ki*3v0#0J?Pu&XkQE<^y62pg=15ER#QyNPEkNJn=H=NfI9o!4H
z2*W36t)%lZF2|;ZMn4-_g#y&`88pw}ioIy(_?)eT!`bviq!YC=oih>c8@4r(2Wtj0
z^1-Vu#;bzmiKVuMgWdHMoBNRxBZBw>KNsk<;)GXch{^z^d|#zWS2?@hA(-7A&)NBe
zfqNvP7q|Hz4{^7H8!!5$Oe{8%trwUi0`#AaE;0{`UKvqF$dM~JT2f#5Ws9ejsPj7cV<=7y%#tL7W|QxX3|&o{TZjr
zANcNUx8-vKB-(7i7o28zm&zC*f2B38;*(fKpg$Xede(HYdPPgJ^Jl~glR=u{XLd{c
zY|@Ztu_{cL@M*X;P8iO9V1RyF_~vypbo)u|b`4IiuL#nwz*l~Zaqx+d
zTvwOe(0l+dEWddA~WI5e{D$kmJ*i*i0oVgOZxDe5S3ZR#P8)sP}R~G|GZ1W9=G{n_5-9Yf2NrG8mu1Y|a7aIj%@+dppm@@KHNBKoEOa*g$I)GE1&Hej0g`{N@~sXHAZSnc#p#CT^G0@dHjDrx+B^J{T4m
zOXVx~$Bdxh8{-z<)V09&V@d{UuYdrxfh0w*Ev7W!)DhnXZewOHjlyvrb*_h55B{oa)SZ
zaICr6A>5_o2&n`+h|gbe^88@=jbWJ6UBCc7ywrh>n>4|OtYDC?pC}vHx6d_$;X^$K
zk5xGmTX}kw-)8tQlPL?!*X1^6b%0rY>B@ep%!VMwh5nEo^eK=cU9t;MVA#$8y?Ozn
z0$aUY$J4Cu*7*SgNc?g`u!C53@1omZsSP
z9Abt5h0BuLeL+_B$QXYmKltpg|1GvAMC8b7cSIXtZ}quOYE~}r^y!xFZmsuB)?HL)
z7QUgtH2;9fFO~!Ab`q^*b+b^x7HTy7Ih!VnbVq+FK77L>DFSwL>G3)vB5d}=?&y+J
z+Ul?oqKAlTn0lyBeBh*|#M+EFBF5O5d{)`P_tnjjoYLfR>bV9ZAabtpkTiaY)a?2T
z>Lr{kJ}>gi>F$Hv=hq9hStEB&w1W@OQrOs)I}%c2YgE4vD%_DCO8dEEASo&a=hVEd
zA}qyWN2%gB;(Y>{#Q>c>raa0B&YR(amJ*4=yOn4Qa4;@-S#J|`>$JgF{YuTIu?sNsx6>AG#tNbFq%s;C2o~&a@{%8qO(nlRAppD}U5B8urM(5K
zugdJ#)yAPRR6$xJeV_c**-&vs$`7X
z<|`POG#iAZ@h!@ow>1-BrH8+Gb6eGSmzZJT;zwXOi@a&6%YohDF8_k_?+795Z6E7MndAn@7QwW>`_Z-U&7=r4
z7XIN(yYT{E$+s7o7#vgTb<)mh5NGFp-iFh9ue`|oRu$6x8PEAk*b@uX*ydHAm&sYL
zY}%zyml^k%ucWPm_=4_ukNl!*fT+*iQ}aEXigdIv%D5V_AB$pdr5atE4C#u{-SCV}
zZFUvzk$-!-wi<eswSC&)bVa|L9oR0W7iMhko#ePuoPBv9;0Sw#;vnx!Wl8tT5
zlexFHz6V}Xm^of4BR_ch!1RlC&gO0D|A@>0En0b?rC0+*{RvPSlm2&X8IR_--Rq{^
zqe@dKEDh@^DPAxKDOPZ@cE;F!Lb{aNdVp%`BVDhevFtBm-POUm1b6GvaE>18j9V5b
zr{%$CP80Uc9B^0L%e;bOGqJR&qdupqI~@sr66MfkyAakU>sH{^m
z%lb$8rQ%|oJ@OX7&Qr;3n&-L*rBI+%0_iX4rY;azg#F-EEEGl^0D=&T7{sJRTy!0L9FAqz-&M#M76vE@U87&>24H3Gt32b`L#rLknTIGec;G
z7|sb-$mhB&E7F1}3gg+1K#mA5k}rALfGf>$+rG;e>tqR92_2g=B1o%~Kt0q-N%~Y<=K)^F^XA
zVkXke6s}xEHLmRb1OPWi%=yjnRQBPa_z*HM_axYYM5i|VVOvws0;OH5>#miJ
z33+N&FAt9#_UswGuOiZ?Ou3AdN2vATqd&o
z5LO>pvlMp8d1)3aw{~`HB0eZSQ6l`hIG?mQk6vMJx;f>n1zvklkZ;4vRuxhh_Vk#&
zk@yC0Y)zPa`&XiC*h4a;&6T`$0@8m@2a(#)IwfRoxJV)LD9l5O>#1SG+9t#Hj&rLJ
zimnH>k~yroO<7|9QiAF^=fRWxDctKv1ce+kYB$p
z;d{yqT=$)Uva__tt4yUnaj$@$=D6kuh>5$y>&AJ+f(PyN`@=}nmT$#~MqkamfVDqwQVy4tpM@s@W=U2hmiu5QQL7+!^DhDmZ
zKIDj0r8r80tvt~O^X4vO*Zk94+2mO@TERxswFvg}E-Nm1e$y=~Ly}Rid%j-wp!4kQc|EJzSNeyAm>qQ5QgUr@VEhmI-z0
zyPQ6y#)*Z2$!*Xyqk>}gEUYp&P0>b8DI6{-vRUrpmCDqB0aLlJ1pJ_W^(L5V=l0Xt
z+Pp4=^pAn;p1H)kQnE9a$^Z)%Ks}%ADi}(E$Ig1G*hhHY;cG!
znEotVJucvkupo;msw4`!>&=VTSG)A4XbT&I(sd|W4)4&x%ji*&Ny28B8gf4@<)ouy<*9313-s}%t{
z`&s?1w&l3HpZ1tWiOv4ktTUn3t3b09WxyFN1qX$p9IdEX?^IHKa#?>8rK@&4b4$i`
z8PV-z2D;9YCC!Hj{)k($HHLV1`w3fr-#;oYSFN^M65p-SCL*_oH+9msNxUo=yC#y}bH9fY<0sye$|GefRItWt-~m=rBsCQ8etS7RIf
zYY+TM;XI)zCdYLoR3m6sQTRB%)!?$BzqiiGRE`?x&x!HO8udAr1n{hx6k)9
z(w|i<=^c%V^UlNuz&w%mCBDkl6L%)WjeXSiQs=+RzSUQG3(SQ*0ot7wTl6EaJTz
zhE)8J%G!)|)K|P%*)nJL!*WB7WyiiYZh!osJ>CJUXz)z@RweD8
zIO~_>o&05@tE?rf29vK67Ef7}zeI(aepg)_28T94KS1!s
zO|5#)7rEeV;bbW;?l`V8V@Y)(Qmzla_c%qINAThe`QP*v0h8^!sWwhRYus2=9@!KS
zKL?urAXMjE|CD%gQU;0gr*1`5bY3edj<45+9>7*CeUdE|>XgkBvNMCnn~Zqzx>4d+
z@}7#MxE+E}tt*w%cpNtYxJ!G!v79>0F$|65418E)&tG*?ZfCM2zW_;Km(q1hMDTQHI3tIaqF$$
zd6h@1ge^f22~6NcSV9s$(&32zo7ja=c`jRi{kT_?+F=dQaG2pVN3&g{jmt{{=mf~<
z{{N56_vb5}{u`vfzv^M_*kAMX*Btz%1OEqfz@QggW6-nzX1A+8lNnyNHtX=YXkb*!
z$zh%QThdde4d&t?VPGvzT{b}8#r|2*H7p{w4UUsY(yjG
z0X6x6;5jzXV;w9E{bzv)U=LFlxV4cZH}%Gv!Fk(-6I!WdMjGoL3F^1wen?0MG%S;8
zF^ottf$^Z$ECxMjsj$x0JL9FV?E2>CG+(p0-{+7aA{&a+drG{es+18xp7-T9N$_7w
zMJ!6`>Nzr8ys3LkOp{wq+=3ryHe`LB7gcG1$AiC2CK{DtaS7QwMPNw@D=O7rwk|h5
z+u8dNw9T$0|G3{X7W&-s<&UR(jd2R!8Mb2{%1uWg4_{oD$f~(%rc``+By&&xh`eIDL&Do%ZAb%_0-O*FDo`OcA-sY*wuVfN5^4SQB~HQ(`$cf4a#1OKi*?5$$X
zki|ZIgAAz@VZ|}y+&IPk7q{D}-D4obpBhEq9u;9DQwe4Xi$W!J+%=93hJ4-M^ZU
zfv1^-t26lfyY0(!Uo0qdvaDdQr4n_Lxp>@?Ns^=&LUnj#Rw2YAXO)b9twD`*45tt$
z=`Lq68d@HhUo>>>WJhGqtHt?~eNbWGK-yQ}2KVr!
zZ#&)Wo{KEPtnJO`YsG(F{N|qi-rRMZ-SY4wwauqSVD=PIgv~rXClC50f&hbwsoZ{q
zxAP$ot6?gKf1KRDbM(N$_}6WWtJbW0IaivEG)7^KcybL10Oeu<-VP|7GpT^WIU0rA
z_BSiVex3>-E5$B1zPWsaJ53eTN6dacNhcR&e{q6Aunm%bXn43%`;>z6N$DZ^xFZ!G8!ZS`o?u45j)@K
z@m>vu;AfZP-|uQ_VEaFOL40{$R$ReU-nbM4pL3c~B^TJsWNDW7ScP!*sRRHaEu|p_
zeoW%yO~C7bE)e@oopV0%FlHavz45GSe@oO0&84~v0)Diw#uI%pYXZc6F@h_oDSwRc
zuSn;F7xmZVrk`JVsJS`1_=!SF$nK{yN#aq3&B_ZOXOCaYdlIDj8Dfa}t0=8{{>53TmMM;^*Po{O8a`hqy+o|;LgdZm
z*2$ha$w2h`>5(t6jE&9w)t$Wsyd(x#VLPwk`3JhB%aQ*aC82x(c+!FnpGK{q+#w}s
z3e+Z(;L{CU3R4vaV0i3nP_aDD1RJeEV21N=4FBn#T>@K
zVd2O4qw>R}cj
z!b@*Zn9~wmO2P4gEB;-F=)626kWD2Z6H9*d7JD@gqh+Hxfx*ZLB)Yu-QEuPPdA(dYv?zWfhA
zY3lDVdfVER1T^Ixosq2)VWjYiWzsd8P#MeySu=@?GKs6%&Iyz4R1~(Vys$E@FsCzD
zGV9yZ@O>kLcU%LHDlcMkSvyo8nVlBMYuEOn*QOL-a;B9rhB##>FHcGTj_JzgM-s=U
z9U||aW++N-AZQO~)zro-HU<5^3CGPmGK_pIz^Yp)SkAuOOv3pdGeV4sgR6{Gm};+T
zj|$mTHB=>b3Tdb1L$f$vxcJ2!LyxWvOPG;W(K}aycHCswTen?hVMczWw_7hG7xPuzQp)n
zI}|y;d34M5y;DiWNz^kXrI~HE^Q`i2>YK(-)XYX6sSO@-Xg{{&DXU6aex*%L^01A%
zCm1GrL*k^@KH9vd;#-7IS|_iY>XTO?%@3)$!a4*ub|w*4-Nv#(Cd=*}M
z-nu+^I8_lcj9=I@Hl}x&pp6uQv#0N?9ZW{QAbU95A
z5;P0Y{^99bXkcu#aoN7TVcVPRvxd}m(=&CRrvv=epj9ELW+3{OzU)yJpbcf!y?*yO^RXjZ3qvK2^s41UMwUtVA=T2#Q4dpkxUS^u2cEEu5`=#)g?+@kO
zKr!ZXq2Lt~+7Vj#GeUc<>EYW@&kwzIc2BG7>mND&{6_cUVUhj_aYvvsV3RCQ3+kU+
zT5u*Wj^0oI*euHBpDC64YiDu?ZME8lqBNU*UZr9
z!IB7?FJEhu`sgk1E{N9^+z1RY*ZKO9x(~(e{CgSfDfa8V)KvETM=}l7g#Mb7yVd6i
z(pC6v(sywyMB{AA&aMOZz_zzoQ11*vArkBx3-W&;c)!MXIAM%$5EqW_he>UPrg4r}
zs7Gv4_zy_gDlhSxM(6#E3ZmbWf9-e@gn4wyCcz$60H`KKM-39V?|fecYP}s
zCASjiwf*$RJX`TL*m@Lo{dxZqqba`8;j&f=H#!0g^4AiH1h!fI{4?1!;0getkY5OvCcVE71wI6m(K?w<&J
z6U^u#tOdc8$&Mqvhb5jSH@q<#)<$)^OpeE5BAM5pRtfrH?(Y0P*oMK5t%)0N@!LX_
zLBoaN_rEdgF#`!a)qI8kYl*sBgWtUu@!9erVF@JyAE|qx|Juz1C9(7^$Fr!dwh{z7hD}9m(|NA9D3}Y$$YGOFs5Tqp0jM1P6b5
zq&rRC5axe)Q)wnHlsI33#U>2??#IV?Ut0I@XBUDhAnnhJST!i9z$;h`0)P%
z8UT_%-0#R!EPms1>Nr91DTvAlS;(pOr^s;*e2=3?5?P764s2=>+DXaq}=9&I*-(aLfl2R3|`LZxlOoKPpAW2(?fAjGg
z@gez%{e1xSKo|cXhz>UUKm7~YJ0Qc7xTL|)GC
z-3zez12KPzI;9&S`dtdZ!u)T+M!G+cud%RW#{Vr=VB8z%fK4&CWia7F@@bw
zQIv|$s_sd|1#2w6B~BFGeu!s0{aCMo-ia~_3PBpIN6g`5g;GlE9oeokclX{>>rs{7
zRD$Xb!LJ7xo@UEyE)GDSPu&R1UAMP+daYX6ldbC~rqEI>SfLa*7TL6uFn0x;+r|c?
zwm-V;{Ms)mYPmu+wxgR(<8{pkt`|JjMv!A*`|y#ve=X3MxO?c0
zs|GI%+j(QxnqtEe)qW(pC^Jq4b!Tvl!4@5jHz;#v`I6Hsa(q9>e#$wxp3Tq7n-P|A
zag8#t4xld&yvgodr5#qT{hI5SIgt#UpF47(b;JXo)6k(-w^n-gJt*K*=fp85C5ezhD?C8+8U9Eux1O2t}5
zzLc~V=Fu7EF0HKb;W3WNQ2R|atVv3oba8#I4mpXzc-3G@+r3joqJ)ZQit}q??C)Am
zSH591|L&gNn)?M3NC^xhNmp#5@PZ}lvWKVPgja!#BlKdjzP$SxA7euS=Ak3wiU;m}
z7EtQvM+E6HPy2LPG>44DN~QR{4@Ajd;zEOcVb?{a*E?KaWg^#rlQs_Z1fU=I)ohnz
zvk%$L&rE)c4$yaPG*sVoxrjgS*SWQfeQSV=E7(XiLs{5xv{!Ji$ZQjHC(jf-&gV}{
zTZM%~{M+?LxqT0MyFQr?YmgVjVtDh<6#-=Y&U=n+Y|ELyvToQp`&-C&w<{rKMO0+#
zaIK(rBhK7s0S^%V777iY(PYn(9#1!n)R+@3Er{*^NIi}zc>XI}YuMTOJw@mg>AR28
zvy~bhh)a-agGSJZHiBnGP8h1Kn3~%CqPhE?*uBD`Kun#OH$Infk7Hn42V+x{TwHN_
zNr3$IWqCT8Cj*cwY!YbzFxg;krj9*Mh{f}xXYZJOtuih?N8}OI`^GTwb1XAS8g!P^
z&F=smtSC`4DEW>*iM65-w;UHED_Iu`Zkval#(pDnqELC%qffkDHX_?Xq4>+@w3^fY
zjrp6MD?3f{3(jV)p>Cvi5@0o_`SlLtbd?Pe;p}|TcYS!D6=o1Anh%VhYZy3k#TDn_
z@BgTLGgegmvGIqQP_JXM29p+!O@o(qIb&6ZLx6ZSyG9!6k$j$sR=ZEOS4oTP7`jBG!xYN)RB*SIS)vBa&oK&8Ie
z)S+_^a!?>p@(7K5$<{=GjN&2ZM;+%Q^%Zv+*pvO+y3jVu8;}
zpcwVG;6;_f+a-4&s+&nt$%UV;@wrdzD?u3%7SA&eC>#DjP?QA5U$Cvx&+}_QoZw(F
zD$G}zp}7(Ph?kYHm&CD-SF}KGkyy(
zjL^qDqJ!?vVu*M05~A|PYeZ4N71T@3ij)ll>lpOiD61FG-TLBlIefOwYE1Y$2H12K
zs3U*X&L&srOFbQC(R=7cY7zXMl$ZK`>@ffwM}+oK#2RZLphKW|!5!per)I-YBc+em
zz~icvu{EJ#mM<(;`tN7m2k2uazlSc6sG$NH>pa8}zpBtHZNnQUv=5ig=|uoa=(+XvMUFlZexe;r=VPyslV!
z&nPv^Ut#ImRN{Q45}@(#GiZXB!?I$%Lo4EZi`&cLN_?UhW*C-2#X-?~NPiUybh03_
z0g30=7pQ)en&97Y)BHbilR{7u9ttAlgN2!wf_QC5jkK^
z6W$@_q5Z+IM+NaH;Nm2#Mrim98pyTG3V^U3A6yANK6(v2^CgIWyZ>c#Yzl8d{JtIp
zvXQT&ZwU$amIVuOfa&7XX+|Ag1|03sx)St@WOu;70iLS=?|=tN8Z@Lxjb+9Q&jxKM
zFcIwSYnXhjA}+i#y%ds`r!ayY%RNoS^Pov^&7aKrvf<5kHY>gIZ*P=(dh2PVk2-CWs1N!m(_bRV*Z)&4*frnik*1NhE&bF!wVs4@6flFk8b#YBF7=p@ia9^
za1&1Z*0>T&!qkFp?R~{x{V*i?7v&ebFXxcS~jBnko_(
z!a~K)2U4Gi;_jG5HbLp=RyDs>2kLb@I*cv&sv43!3cWee^gX(}X%%^)u>_z3F(-Kr
zw7q}y(+{mvWxZ?+o+s>)B
zrI5VltML(yNku5ejrj>q<}t}I>ZuFqHj?wN>zv;&X9d;(r9|w);Ds-Yy8!f7%qOJk
zi&njf(zp`To1Xncu5hZ65^psc@L(6yJ~dWj;mG$Z@}qxj(|vmv`D&NX?iKiVT>}p~
z*@OMt-B64Y(|s0;sA!4pTl5OseX*sjG+fNj>WNZ+GwDp(ckb{o$llABr!HFsO+xxZ
zu=0eqI32V|gvjdHLWqci6N#1(@$)VgC#c9XSx?X=#qE5_C)zDnc{Buiu*#3qA3~Ag
zSVZL$~_Kmp(foCda<12(?k%+usR(GvySF(?-ChwzO``k2QYO<}irZG8-H
zMlF_3M|Ow-j!1y`hy4H$*E2uBk~aT=(Aw2Xme0b~U#TZ%vAyFR>Y4yFrEz@j$CU;u
zfC4};e4*0@JAjO$cIk8oR~zGZJ45lRFw0Dub*;}cC0~3!L>#vbh-|aUjayGfPHeKP-{em
zhz0h{MYt28H}&e5tbnWKVFu+X{l2+Li`;LXR*AA{aHzTt?!!TGrd12CL^#{&&%Us~
zRc42&p3dY3reo)+gQt(>3Fn|r%CPg}{aeQYlhYZqw#0LS-Kggr%n!zcXy=tT{-4&Z=SdQY*CVko$0N5T%Ni6Znu@2!J;^@htO*5Qj)@NsNCB5W1jSA&VIQorB
zdQn%qCegHXzf3zM2!Uu3M2j3lHs*1pXRA-(-qnxN*?OI|ZgH>1@m~MZT;KD;_e?yz
z->K$mrn!@371FA)2$SB0x`2s+Yoqb_H!hqk6EI?+55jcNTzO=4vjN|hT
z@0!V4c{MJ*=Jgemx8{pf0W}OPY-;uP$l-kWSa0^EmM>q<7mn}UmwsBss$FLy&R-tS
zE%Dc{(tjT`*r+(7$bq9$c%&A}>RjSX*MI9n@=G_lluoQGr)a0iQ9C4n2i!$<6zpGj
z@p1lq#mjB-MdwI~ZN1rIKA+6XfA#LhLGIS$|m=USCu+vjPK#jppuAh)DqN0|t
z-XRr^e*PX+DaS;DZE-bE%9NWGzI~)hLP--q;jP;g>wNlxkKlPM15ZP+{edi%fZALO
zAPi(7i<<{;7fJC+38aV#?s1@t6b^FKUl(bi{>{e;ifCU|kc1c`$Y$X_9NmdehOr_h
ztbc)Kp@yJ0`YWjR1Q5~1z^D3l2nl|W<-e+ff``nlK#fiG1F<;;J0;Oy9u4}0LlBf?
zolpJM0C5rnLBUg);n+i|lhxmVvkEdWMRbnL?_{nj(dut}0Y_tPeEA=jmIOT+8ADCkT
zHLQ)Zb0E4O?K+=E6rm3M^soIp#(|t(E73&%!Y5_20H%+7=MUs=rDx^8V;sQj#{3Mr
zaJEjnZ%OO5SPhH5hva(sxkQnzGxD;$*#UgY;+rUMz
z2se#gZJ&dJ%-*&UnlwDz)11OCAtjCU(2yUT5B$YUqvB13Y8MX_dpg$sBlz@Y*9fPAw0
zf>#o}OFmO!M8!2v$CaUKEaAGW
zd~DAh0iG=AJ+LyMsCVAytv2r6aZc{1=S)nuj*@t~y@~In1e_=TMmbBZoqQw-Pzs#c
zXZ0us+g=^(`O@*ay__7Ie?C_Xy;B)YUb~xNgogA01~7G7lh}l|Mk#Fg&uH``N0*49
zl4q&XFC1C*bd`xi!D|KV6_P$R@?&kbp_o@_Q!R-ahoYkFbW8+wc~Ll{w#+jzjSg@|
z3`82Kb(*Sp(_#7k^Lt|E*Ir}q5;Q-{U*{O4Thsuy}lC74gLKMRbrqXm2KX_Bz5z`?5l<@Fz
z2%KgER)I{&9l3F}>|0qyrT0|<#qSBrNUIrDov{Ywkp&Kk#1S@)e&98ae7{uO%@B-A
zRCUoF4p$@1aUXv@9t#P`_1|6X@Ot}tT4T}F@pE*KXiSIxL$&PlTnVmlbwVd6{dfgE
z+IZIaUfqv{8X6Uv+Ss3F+UF(x3zH@!`vXt?jEvxZL+gww#Xdr|X`-jy+me
zVirJ#JAoe2sE0GXyi#5?cYhi_GrnscoWVe!hz>X)wxI&e`?_^Pe^f!OB)gme1T=a*K^m^?XJL9F@7&mE8H54KM}*xta3$XY
z#oJ@ZGpBUWyyackxrvt8wjw+0a9#RA8-C(ke#vb(F+qG&k#CndPEv$sO4-wQvChJe
zll#iaJ(gvc7m(YD&v;x0D5Noo)XBNHKnzQc&vZ-e&jUW~>5B%Iel*`;58!u#1_;Fvdv;qml_ne9
z!oK8z)Ybr2!~hqa^&HbC+T<*;{z|1yu)I&7LP>Ct=JB3vsB{-^aDc}g1REnK=<^8P
zlg4hhPy0I@Sp>7Brm$D!*o`Q^T9a#Hz}EZ4$Ku=VB*rJ#H6C@b9&VcbHZYSLhqfwG
zj8%TiGF6|UG*SDQmBNfoN(c5NZolKqS@BX}-!93VxiJjsXy79L2
z)E)G-0sMWT-~s$^wa#_;JwiW}#uFZ8Mp0Kc{t#LP6
zyUekWKaf}YaL{V)F^|^@j6UJRdFz2D3b{PbUe+y}+7Zi|c-E2Z-`3KDbh)p3q%Jh%
z=?h|Y+V*|K7mq5DQ^uXHu2y8W*>4iVCqvIoECm~|Q`oR{_1h8`d}^cxkvIVt2eMp2
z82(%WUT{WU5Sy!Cf0|J_z&dX*tyyHJ4}JPu!(hcr^!%GVc8h*iUEc{8eQyZoshRJI
zvTbT)R1l3%q}EVz>c;WoXiPXz>!eDVD`=0(dn4ZrZ1Z5*GI8%F0T@Fpnyr4sTaG#X
zf!LTDcX;3OLKfQ%2PQ0c#p{b)zIf@VGxg$w=IgG(E)Ydfmv})AlqUz*H-We3jSqhy
z(a3C}fQxA5ldE&KtM}5L56_&|2z8zAyy4Y9Y4A3uuSPo9#O%XxcF?H#16q)YHvwal
zmWG~A3{yG(*0MWBsYbdP}*L9HNmb++Kt)`#Go5=6#
zzwSkzYxMs2@n_}jun7jGr_YOh{RL_=d)f>Kvm@YTeGOYA
z#zvlAss$=Ka=_)F?RN&k{3L%x5}0w`nxk1a;l|VFQ@E7{H26t5ej^hRi=P#F5QFzX
zpo3t5ugrHfb2uOgzi1YgGGD#
zVg8i!fT-uM(zgDFNF;zq#NbPfS0Ikbki7+`D!XSKkjMP-Q37~77>I&ULN`#D4d-72
z`^b@F{8umw3HhjbSbs1#a`Ya)5d85UbY=gdIQ~MD{~dqh3-gN+m=)-+&i%aS_W!68
zf=Zt3#}Q$|_)~WWwx_9A6THM@L&c9rfaYgaEvk{$XY=mpPa)%$);u|infifYk~Bm7_2L%6MAqJ2lA)$?x2(;IftjgX<^6*qAzp&%*xu3)rxrU
zOS$!9-l(1{YDW4A716VMm{^RAA~;nESk1b@nNdq`aGiu^X2daHVex-Knvfs|efM1{
z!o8grs4X69Lb!>fHxOI`@UKZ|xe3V9$3(|cXGNvf%nE2LlG!A{U|ggEI1xOrKGH8K
zZJ2~7@KEi2O@D`7;zs7U+dIUrz44?DV{bd0
z;|aHh6}dMr>xKAVNPn8!-_%u`T7Ou5zo^H#7nr^l=
z&v4_SfZiV6)$pWSi8m**WhO(ZOZ?r4R#BG~FZ+BC(7`hHRYx2eZyNV@J1u1NjQ*s}
z_7;*+a`C6}h2EzhBz(`L!W&v)Ewn}twTK`5@HkbJNi}pXo}<`_#^{DL&A83h5)C+<
zemN6fb-);d6mdZKb)hcT)_ZV$jEOmEvXgFF?V7W253|sQwn3=tpvNx-DNvWoZg0GX
zJzi71S`$i``pN9^x=THSNk#b8R|x314vgYSR8q_XE}6vpt7vE|jO`94-Me?Us@#*|
zZBifmSc;n#TVbjBo$HA{JZnBkoztXxO0*-i^Vd{W<)`PDt=@QvT-coUeoOoSq60x71thwsta^T_61j!qp?AnsdvO))i$*
zw&?~-jGhn4nxWc}B(%WP;tGzft(HHo64-tWIal;v$*nB-Fmaj5_p-r+qaGyj9DC-M
zz^7+Z*FxYu%~s?xv9BJ;*rs5t`ZGn+eqzrjrM(7Z||cx
z&e+pdD$SOBkLKew_E0J@nf|jo(#zhh?lcNKZU3A>H*V!)l9e>qVQAstt^edo$e`Do#C+Fw!>Y!c}16~DF*I=)WmH#^1{XE+B?1pZVYzy2DCc|)H
zQ$22dWr0UL8S|*2@%Zgo?;*>6Oj=_OIHR2rtc;1niI3Y=9WHui^VwR2W`qfc}jI
zXQp62*TQ15`qQ5mNh|$#whni9+K%891RmvlyVrKVWDldu%ObG%#l)j>P0GIZlvtV2
z?@S}#nGl9xbk^Te9*-3-sJZnrc->5nrEdxCF(%IC&3tITrZee}wg&cZnq%8|^=BFT
z_kHfoa+8P^)$JA)K+Xr0cWsbiViT#8a&h*UDC;EtT||^74wrYhp}IAJEmRp$#?l8r_+cl)fsO#&8XN9*1|Je3
zCv=_)FR!NxIMI9#Id^^e&0altBpO;`Am?3xH=hR}fan8+u@_!tw_Qbj3lGS9d4BH6
z=o8TV;~EV0OVPm?f`)L?|~WwYpmpfRszr<=5A&BX5D&weUn~WB&7=+*tCal
z3t-%UlK=8M+HCjb$4kK429Bva|9Q#w%nnK~oyl5Ehgh@2)Do3Y{p>f@z>QGTbn$%~
z=lk)kF_7OD1jbH~RIF)X`3aX+&!FP=&g75Qa9XSHH24NS(8yH^Fh9YEoL=dMGOYP@I=+%
zZUnbz#>$F}JDUTr(G3QP;30pTgl(M&Gw#gxM&cpQ>2DUH=%X18URS!ux74Kv
zq`TzVF(aTTp7EdY_x~U6brA-hh>Li*4VK5G{wq-E>a{+qPd&Q0FLM8soF9*F@i5&!h
zIPwe-8;^c0)uDKP^h}i~x4y!MJI;;hSw93L)3kJi96XhsnX)??Yp;Iy%;1*=YZI}g
zg@kqRrT>8VQI9tNV?7%ZgL)#kSAu#X@IQU<5Aj|}CY!@6c^haTqmKFca(rsn;iFJ0Y&TMHLVLwcV};`M@(*uIO#FRD-m%y@wn}`Qc;i4
zNWm7ZacqQ$KM-nAIp}~AuA^=NF~Be!XV`cFX!B96U^5g2X;Dlzfyp@pwdM>GA_4{A
zbWa9Ij>ffp%hV06|-cC~F
z23+=kC7;3f;;R^kvjkk@sCVQk-c!GQ^rTl558>baNIhf(_;29e{E@)s(}F=)Rp;3e
zIKkO(f-A;R)@9Yc<952=_>^1E<%Vtf(LMr14VAp971l<5`dBfTA22B+X;Qi_|(q#37V{KiMNU{~pbq5Iz#ZqzaoKtk|&K`F`&v
zWAD-ACwCSDjdpt82WRw;X?#kgPweTrsZ8P@|Kc1O((d$C05RH;ynU*wres~p$m(6G
zy@YXB>NVb1pX2uOTKSajyxve^U4=gJ1|@VE&x99qa=^8qka@QX={6{%BRf0{hV-)Z
zG@(J9`pE0o1$8wNVQ++$*rztueV(;TDKk1vlB|zy=Z!ZsNPJ;qOT6RJ-PBJd->G-{
zsgcJN_=hnWefMHIL)y>EL&o^FveL<+`I4ZHUbfl}r}vpR7?a3km}w6%4>nbCyP}>R
zWd4Ori6P!S%?8O2DDT;V00HbAIFfI&dr`~UE2M|jv1loUzta=kQK0=AbdtE4lY94&
z&`@AM^-gxv;%Yw>La-4-&;kj0~yh~*3
zP3#sZUgAmHPc@m*8C>6Xk-?P>Zao6aHDy(H78>Fe)eswwnvYbe-;i;NP!v|DnmW>1D`lFYM4jb23FhA^^XGlUhuOVLmd!
zLL*nd?nxFu+y#OSUAcEAw0X}~T@VPU6`-z}^BlLrgsJ-8dmSZ+4
z2aQt8@T0F=OLr&C>VsX}NaGN#%6M%M>OKJFo-hJ88)$JNhns(d-D%CM6n5`%yiFci
zkz_FL{Gs9+)nk{7v;ERt3sk|x=mc;@@PK5&+F<`+yFV2*vf9VkxOu=OtRwJ-ZMCK`%o);i+|XAqEjg*+U55i|kY-~7#tv0epC4-dV^tCeY(RFZTMs<@8ArJl?$GKB{&W=1qZhYl^(ceWiv??*LPsD0_5M)TQ7xyV*13>QUD(N9*${oJo+=Y?@_kq8l!_D74w_@(_$NV*55ec8
z@InK`h(XL>AA9fzLN4Ake;|$X2hP@_2<(&JT(!Lfe%%@fv)JyP#Re4pHdl(%*3WA6H)ljGib3Kx(m^
zNLaKy{c=^7%XG;WUm{m>t#jOm;6PjU*teAwhsyZ^;43
zx-%54Rdx~Bgv7xLT>1cV@B@KO7>;7}
zSmyuYAS$`dJ5f^lqRr43l`7uS7Yynvp?VHPfGMdv3d9LzQej@}jeUvPZ1bjlS58mQ
zSZ^6t>2|?j!K$=VxkKY9^>1kr*y9pVbfntR{`!?pQ>a>9m%XElF;LsUb&b*pw~8(7I2LK^vrr$b
zeDY&U$n>c?raJBy{$mP&*xLTCP5F%wyfwC@0Smh~?xZq~0E^7%Lu1?b@MwaSdUe
z89pa6+<0xm-l#v$HOKBaz+EE2>kCCV#&%TKTgD-yiMZc|dSc{nE@)>OIEF?#+zeh7
z%YKx>+1OB7+nBn+x@{rG!y9knCKo)xGgg;5>AqO~QbH%H%>MF9f_u)yv{AiP$(wBT
zfhID!1DON7N}#jy-!#$G13N5uPV9DdOr>Q)tNt(75AQknD`UIaW4^0jpTDipc_+-Q
z^5klO99At--lkwvk!v{jhofTCSX4DvcGotqb240sM@A8Yls?WlgRfyg7&JwS7VfC@
z=gX<8y0xOWx4l>y_4zp!U0-$WT%EN6si-uxP!i__B$ap_Vh7y1H(ExH9iK*Vhx5$BIx4
z8)CKumeE6L9z(ve^3-M8TG6{gM4bR8?eAe0N!!S-CSBdlDEJ<$`{t!vk^+k*?N2Jo
zz-uQwKFsjl`1feRdD=^
zKs6=NQCCO#4L|Wr>M!aLD6_mK5;VxHWFfn$vV0e(#r;W;EU~@sRs!Sci6Lz=5Kb*f
z;1z=)`Qe|E10+6?_sRrrAS+H@+QvqbHvnL&5TTBYSizlOuWOi&x5cqo3(-1qwVY67
zNgt2b;^{yiy_WYPBJNPjb$iE?^4#Q4^Y_^oR|r4VE-kv;ykrq9EkOjNp*+}QIT1X!
zQR#JcK6Bn65>I44!gA+=eIxB|O3@s5nLSYbC8I;lcrSg-7iuW;^WIs{OtS)K>0C>H
zA8@aWWtH6S0%`ROG6!Zg0JU$F-Ob1$@TK1<*>bvT@4`biEJ!(j(}YUag%oseOLpT8
z29W_;yO_4iif?%AH|zA&^;D-=%c#Vsr90&V1&d1H=y;ST5DrfLtj_)H>}};>8Ko{(
zR|eq?k^!t7bqgH&vlHBH2V!$(d>mdj@WL~J6fc!A-*zi!_~#x$voa*1KkA_bTjaBQ
z^a-~WllRuatqSb}?nyov)OWw2&H`}(!WzM5&nbrs1Y|>$RlNVS@p#34@;O!pZfR@c
zSUhfls2$=r>XONhm(262ThwdFw(Vt`?|)UV!Q)6}k<5FYI-g8wfR%@r%D(O_<3cPx
zzyW?FoC6LdjeUXPC*nSudb@~L>8-;PS9^bVPrixZDl-WyUE*V@-Zb}MRR9-@K>gEq
zV4HNYShGFzQe~jX-4{AR_uY97n*-xnLUw{(1cv}4(+|fz&VT^~r)dCG4eoHepaI3p
zBlv5($a_CLyY4biCRs`{M@7`hN=g!MUw@_^1)IoQ0M%j@FSj}_o;>pQwn1=AE9I_wT;!$+
ztLY6(Q1agm3I(&s1Di&w+^7ZV}~05s{o4777#vQnL4jOJ(E
z0hmb|BRLLc#Rs`fx@)M&-N{t^sh6=jY8yjKEKbYVW}d`Yns&3h)gmJ@mUbIJn$;)s
z%>z+-)nZBnhLKr}v@pXn6`1NnWjrMSX_pevq#(j}5{OezFaKka@Je7N)&4eJfLVyU2Sdc*u6@b}#OJIbyQG;)ifF}hU
zV>T}2ENYj6sJ((x{l{7%XkdSz&7uF-J5a)@U?GXHMcUEdhr#1(qYOm*`c2f6gw0mJ
zdQM(!b&4dp*R0)BhLAR`9|5t_{DbGz+}xXv_5N?16W9n$0|@-M>=v_Y!xeBU@L1b4
z9o^p3;MTSDvg5dwMT7r)IzzcT`=5MS4p8*bYycDGk4{&iX#`yS5M81=0AN`hg}CTYz7H
z-KQ@rD9PoDYS&g?`cH2>tuMI;QzMKZCoM6*HIFiJx#$53yi%mV}Y!j$xJplr|B+^usYro-ZS&kPcS!dObw7WF$4I7|Xmcb7A=OEeKS{s_vn
zK@;v|LEe}bu@Gg}E}2arA$1*sUm*<5GvS|R1!y%49tc0F*koftU;4`ZGqL{pQjNKr
z+XPEf({bvmA|2iq+t=BaKy9!gJCYine7L0NuIv({?-0-Mb&7~WJP{!e65Q=$LGZ#%
z@uOE`Jzz0L8PQoA13-end;R6P8z%J{SFDAowBhXL=G4WO0j#)g%*&nVRHn>=O}FPv
z`s3vGLQ-U(pC&vACVM(DNIG=@G?k#1y}HO8=cyATJC^_1c6ftHs=r)CxQ;laf-;xa
zJT&(pA7QieVorRBaLrD(u`*i&s3Lrmy7^j(>O{-V>x#clAq8E@z;^WSYuSciCWzx!
zrqQ=h7CH70?T+-MlBQHBYXwY8Yjiugk~ogL*q#7_4)L>}oqXCAI13hXg!tXCq)?PVPc
zRo^nFJTg`trehG#($yxC?mj?<{%n-Nt7NR$MMgV4qmx10koaG`y;oRMUAQ$GjDkvU
z(u;ti(m|w!CelQVsPqyQkR~D^5+HQx9Rw7CfHaX3>Agq?X)3*w&}%}C5ctmI+vVTq
z;@tgbU+{Q=&&p)3mAT&Wj`5D6Qjm~JR^=zOf*F^>QiP%$EU^l{3^OM+<%d|q7~51&}9X5fHM8nPvC~Ue?d5qFz?d)Ft<+u3qMr$LtjS0vq~e&BL_K7w-@mPfovU|>sECaQ0n70}mQrt|(W
zaQ_hPkEX1LXOn~pBWZ;uozJ~6CJhyZQrf#Wcd}ImA~(-h@58v|ZC3BM5&8@61kDxs
z8kS!iy~6I@K6F<#Q8ccMDjLW8h~h^)iHR5*{S%BRRO~*BOT48D@(XCkpZs9o4ulDU
zv56LKy8}5)v4PgyOLBF64wvRPlAf*z@oqvLR;sHH)D61WExBebyfR_p$m>9#&{2nj
zUs?-kOMbEA#SzEpTI#n^DD%AJ$63iZS&&G~&on1~o}X#TvTH<4iiw!WzK^QVdE*SI
zemDId5JXc|z6R>!(^=KTzpa4)wF#Ly8XyB=}&OeYo<&MUZlVlQC
zAyR05x*Z+r*j}+XO-g=2J#IUgZ40%GT)Z@we(}h#hni4zCM(sK$y&)j>$x-%8L@d`
zubXT@oY|Khq^NY-t@)zo=~|6=rH5>ObKiF#D!h@Tm4h5#D3E4Lpk`6-HqlR*6uG)Z9jcjef#mH#HPVa?UrXDLBIj}da_Xx>Vji^cXud@VHQ
zF%#pK?U@Gi42kY2Pq=rObO!ivsd>$>2URxIHboEiK9b^Nit9GR({_-k+g8i6f5K?=2eQo%hRYv(IU4!-3w&FiAjjld1*~{5KP`^IJw%6<
z$Gkr!AKLT!TIjUD{ScLUvnIG^Id#e-rN(`p
zA;aZDWlkl2HJFQU3;gXd>z&bP$)do)s0N{FUt4&%()C|3$~lDwn$_FuMV>=5X5E)j
zJ@?*J4o|0wS}W!G519FR3#t2fPNJNNQ5Sk2gRhXTn^@BPfi^qyE6%y7`ap}#V=dA{
zf=o!g;#|j|6AL`qpi;)2;jUlh-HyA(8}ly9v;KV$@0;pAUgIQJVuX`B)h5>T)BD$)
z?YFLK{^)1Du;Q@2d27CS@2AeEb0H~AaRGbh{g4@yPyd(PT2a4_pP+;YnvwN-IqMc|
zr8>l{vMVkvrQhV4-QUbgK=Nm!X-?al4*n(h#c{yUE
zM2%sZUhX^-^cIwPyZmw5KMVkSHs!_&vdMWWXj?GM#JY}3`GMea!dKOUYn5-c^|}`9rCqndOssdFpRl6LXyj)zB^+2G$=_PDOtC2sB20abRJ0jLNBhRbc;)#m2m?W(}^Q08!Kyj3w$4ygC_B+W6_B
zttNNx|0wFr@9fQV??bqK&Rxr
zxQqN|SBC?1UZ#1L+lZt-C{2yfVs`_gvBLBBJiJwA@H!7~GI#i8B8*;F{FD0)a*@q&oq+p?w?lqJ&yUk|*|uPtrEeO2cEk?s#xh}dYI_J99gZJKYLf}FexpMF%REx2=tDYyL}6ZgaroNqgteG>I(
z^wsTY&uw^VN$Nx^b(V)cvlrSxQCm3!Vu0D0Cg^mqW7-|1G)CMC(llBhX~*BIbv(X&
zZ--oMY!R~eL#Yk&m{^5Z?sRXWcUwQ<*8;0MnW}Qur6PPirzuh%@=T3t?x1LPd~|L(
zvR;uO-)c^vNvU;AeD9L5^dS0oav1J%=B3p-LD^L^bcb?8qf87I5&CLFdc8=gzhlQ#
z>sh&z$T^L6RmGp2RtZxCe?NKJ@d9jeq_k7G0lR2fAw1v6p1!B_+W3s+O~$UmkdmIc
z{qu|+Z&b)ZUcG-Ktd1Ch<=7O|JRK})Y)sj?nVBxlVdZkH^^$^L7G|h-EQ`8I2mxdA
z?ZwC#U3Ry$LWUa*U|nVTkC?71N+qNk(i5COwG)+q)ePNy<+ff&sU!5aa6aHy53g2g
zhU`}7Ywdl(jAkyxMmMnSsU28B>n8#zaqsB;=e&x=KnLOk|X$*1)#lLV40)4h`EOlq{U
zxDqvGp;HNK+Di?s2M>v1SYL*9&!t2xHMrWvBBfErDbjBZAf==}`Aw@fQoig`r(e-_O-Q`rj{xkoB1HG}!hsxs)Smm&K6_}nK#$M81+s0n(su%jx
za1DpMue*OI=7H59#h*k?^D=MEdqcum*4m#Vme{m@{}CU)r>^=^wU<
z`oI`HE?or(2=A(9r!XO*Tf~RrhoaFC@MTgXG
zMdXz`xO=>^4DEVTS9#G~7i&}CJ?h3c#3h2pYr
zQx!vcFu(WBA(z{%to~}|YW6Cwuk?V1&-y8oavSD|c?q=MzA6)IkE#yr@eeU`@R0a1
zq(6V~(boPp)s6Yc`;Tv5ZgWOYF9&E!uQNR&Gn47qrkX{z&cCk9T;(=ys@9sg=IYlW
z_gJ@PL+dU@P4Ns$=e)WWuiH
zgK)ycgXAx_v{9v+Z{nmJckgf}Qsly45fH4tEz@UxE49~vlJqTD5sC?$5#%ZDwctN~
z5#^%Y*!;Q$b&P5O6!vWM@k~8{B{X22^aLTWPGAr4??U^CT=NnCKqyF}gL}GW8e*Fx
znu&T<7sQ0RtCTXtDW(it!T+rC0nM|?0&tyOgqi~&MPP82jhZZE5=R$qR4ng~iak9O351CDgxirg81nt!l{GPVre$kHkU*Y)&;SBe#
zYU7k;VR3__T|D@z#{XK^(Gg20nvn#j-58k1&`+9oIu+Sh3E4QiF*rWU>i>zcwp=KN
z!#{neV1?jkiI4t)Ff^?gf{i)%{Omc}!~|LJ)ZsC$eU$fxOs?0LRb9Fny6A2dPqrP2
zJu|Wkssmk|BMvv;<5Pzf-gdtk)SCC}aN*F)KvZ*D-_80Tc#a-Y!#UpGs&nra32
zKW4NZSW@om586p%~?+Nzr#wRBoz%Rm}!9(
z=dJ|*j`SXFCp;`&@Q)8YlJU`Rj6ut>c~vxa%gb$IE;Q~SQ*MG%Mv(}W?c(L;XK6-+
zN!|}d$?%asqm=HIMY!(nVO)u;_|I)A%dMDmMz#`azFzZ_5;ibxUbUMNEkT2o-p*TEjl#p145*+wvCeC>=e-OYP(DO7)>~{I(|X*;w|RTLiEcdNXHC(E
z0-=kUj$gGEo_>1h3BVGNKZv8w6BC7hWg#7LG(U72h7=41^IH=@cVYrM4-Z4B&iS4j
zU!iONYQ6%f1FS&o)!2_%^YuC?Tj)MWgdG41Yt}~IAGcjF;rn-N$HW=58y?blPy{GO
zd7sQv-*>cWF}a@qt|X^@Gm=G8_*oDYp@1$U-Z`N|JA*c;sF94Mor1jqbR(DRf+V$F
zX@<`4%euUg#|wcbyX|&R`Za=JNIiQxcV)~Yv{vPvgtquw64xv_Mm?ZxYO}ELBaOy)
zC&e%iEYBtN`JZAmYYcWET6i}n#RwU@F~+RAq2wC3mqANWM!Z1a#;&nu<9Ai!Wet*a
zKEBHk+~$;%Xr30SePw-;eTju~sTxe@D@9=DTbh02~mYEHL%k))%`kRt$$xBi?;kyJ|?
zt7^CfhXi+0l@no~aVVbJ)@tdk*$Uq_1Ow4QfADP|^l|dT0EZ{;LX)Ucg{@m;PFsHR
zG|Q=T&!DTdpfjLD940{FLq!`fBVq?
zx5Li=t>2ODA~x0VpWBu05FUOiLR$1IEa+Ud5#~lYpCP^RcaXp(%fNKS^`82h$BLwT
zP7JF}6YMa9D`E2WRg%*!>+Jd6fAxMcc92s%mh$iOq}X)EZ8NNJi~oM>NNZ5{=hbO*
zRphHh+jLDk0r)5XZV1480~Qjv85&|4?z0&4swu5jS}4`;$dC
z*x3U0?9-F1Q)*&syE2`r8NM&lB&w*FKI_($CgZ)XbGu8D3)~rzhyAhxLinmVv6xp%
zSCPJtM#{hLty5BtIbBQ9`D)b_)w+2(x>JyKHT=)X*G7J1!2@E_ky2dI%xdM8cZAYw
zABrx$bEE2Ad}y=>{SNi-7K{driB3lZL9F%Np%4EMrpQTbzAR;CXMQOvUYnVg3dzd}
z6rHQFDvFc`p};6uj1i?(H(D=kc_qijo6|$TIvUKtNk0&eOO~6A)$k_FqKUeAj?N?=
z`rBNZX1}q2-X2p{Zws6)ZhG;%qrcwR0**UyPucdD$^~XZ%%%`Qs6EBE-CIZVcg4?5
z`Br!|Q@Fy-6s}3EHXGXRZ?sv=Gm1v%Mhes;LIO!|&`}Jf_H}lAW=f=R(4zm(RYOG;
z%T}s$7k}WmE}e&Nd7gQ22w{~09d8p+)Oc&%#3v22ChwgXQApQn^9El$omqM}-6#o$
zem?kgwhY~dX0ad{|GwR)gBX(L;2;P*ZuL^=&A2$;DvE2h*5t3WJ-6NA?7b{
zQXH78o^OQcef{`pZ8+cO+N?`#4>jZ?Bk2bo-3DyVK){R?eza{<*@AFAjXTZyS%OTT
zD^rmW49@z9|7C~FvLKhxI-m#v-ibhvk=(zM?v1*M&_0NG{C!9?yDr*jD>K6BV|%4!
z*Ba1hS@Br3dL1B%LO3yM)cw*?lFsuhpKYFVW(%2V3OyL$xXz@}7x}tdkb8V{Kaq)y
zfM{3tOIK_p24U9NuP0y0dG6~az3@cSjS9&i=N)7|v|z)XIONlhAg{Na_YHMkZ@m6J
z;$eA3u)4lO=%UA`p<*VDUI>@Az&oIvN+mrb=HWDmiC7&k_*ry%rQz;)>xIPDR`zTc
zNgAe|hmzWB@Lb4xlXX*Qqwvt__0Fudqv@@j=Uz@%-!>%X-ss9nj4kO&&%g!H^82Y!
zjD7geHWNw${Pp9zSXP3@*NN9L+?g*AX$1@=rkf-9N~RCZThB=a#tm_2rR2U|VL1gqzyTcA+Lt4w?F`$FioTuX3_mZg
zMZSo7x(UM0DIi|yc>)O*HOG9!!?uZkAd&ly9OlDY`$Eb9qMZ~QB;wrSrT-d7t6c5Z
z{C?O)k&X;Ja0k7!A`5lPtoE-aVcOv!_l(}^Ntc3oD@}cX{86;S2vEyzzdyZ!*9@!B
zb!xv;ViYFamuhh8a(}z#*Uv>(Kk{C;?tv;L3M?WbU17-W_!vXf-Zw=^zT~6SNe;fz
zEPMGY4Fp2#Iu!5XFy3V4%R~I!#w{|ur|WA_GqkDHG8#JSa=TPxUBE8rg==|;9CwB#
z=K{3P&1rlRE0UYzd?7lDJJXRPF?tCs&Dx@uc}g6ly7nE5fm~7LfC_ILeS&VG0k?P6
z_|>Z2c*9qvB`%K=p^})zZ6>|wo=_SHmvTB>
zdJBj^cw$gGSS~NqU;8H}%TFqz$ILrMUt7n^Onp;v?FX2zLqkK-g>2Czd1ITvJ#mB-
zxvjPREUKr|nQP;6Ddr_vs`x-A^QLl{W{WQ)QWd7{ijA->|0}lHo-O`2Ux<6LTZ5Oz
zIdVC06P;z7LF>QWWaArl=di5RDO1yp>9xLK^ue@&3M~~>_8Dz8c|+B~TToLWF59My
zAQ;tX&`L$GMV^QB85_STmWh8Ki8_%=r}kz6j2xx=F^r@Wyk`4lkO6!9oqmeK`kPY7
z8&>jIPVRa?Zxu|YvbAsU3@@HKBvg<9qn|Ld7dzMa>Y6c|ftb|NwM!PLeu!ehbb(NN
z?fVJMAh)#pV^`DrdmKVrBlE59w(|es^OLs4^b4WG(_u9!#tQ_W>Qb%6G0k^`uL8?K
zkI%(CgK!nh`r2T^d;}Yb4us1%{YLr3fsn$e&D2qkxSy=Kj>Y3YZ%9zNFGS*4Ax8n>
zq**i;9drHDqA$yOW?BSYa&bhwYfAuxyZHt1W{a!0D<)!EQ>Lpzofd~U;3>-2J^EMQ
zb#W%zz`kEV4)DwNLm<#uAv7^Cvk!}auBV6;#Luf0`(4h01Q4Utl7`Gx`iP
zF{&|hvr|W7PbVn=a5Mb#52Q@YZy!yD
z77bwJFU{q!6+r`2EwpW6Gri5pFRoB&m2@8;i!)TF;S=K(&um+!fg=7UecTnruP(A8JDm
zF5f7ozzu{-(^8VI5y&xF_pdeu%f{byj!(DhW_!MM#+s;b?Y}{A!EXBE;K3%9QQ54g
z(}KyuI#(w+vw|nn{ZcIjV@!xvBsgLAgy#RO;MY3@1M0zSY1&NV;bKMg%j0u1amRh>
zFLJTIO%)||7`F932y%cVwcQ5r$Q9ompV5CH(W7-!Bt8P8PoD%*U8#R!&*h~2#}d_*
z8AQ+Qq5CtO9LT9%YP1B~g~l}G#N6x|3sxQR_G#y~xMd}iEwSPU9#)B_vi*+ix3B82((k2B>;FR$FS
z(3BvDy*^ozM0TwK)yyRe_^~drzyP1z^0#=8GQrNn)s-tVO>=-<{6#x0zUpV>KAXTZ
z027mx=h2OVzJow7nQ+iVa%mWCSEfLzxgX1}DAAR98CUe(TA019dQg}X+163`hK$?&
ziQl_}J``Z{C}lbkd$LH-5g-Q;ji7%h){LQF;}#}9$#9VG4HM0j*NX2dsPc$5jFjNc
z<`v|$N+knr1YlPX>_bcga*u&iI5?aMJ%<=h6d&Qj@&;iZP>ZXZ4sJzuGL&x#1)JLN
zZI|#q|G1@nSqi6Dqk|Q=rV4m?Ma(6VR4}gf(8-fUPYwSdMc;xy8lLW)NKj
zL3RHFp*jIl_#iY1T>%0E62bAtphQm$c}%3AM^ml7oY*${m3A%uS}b3$*0Y|-F;1re
z*?#5Ae+K8v?~C)wQz$t!=UQDVy72J{(9d!(nrQrrHWxsc%74&A@yLDm%dG
zNv6WX76alRoOyYu$5r5bNS`CqOJc>nE(1`e54Vcvm_vjTB5_}FnR1Z7)5*Y%d#4k*oPF_}gC?LqA;xOeSN8%x{geq|(
zIUeHi0jI~Ul>z-mYK0oFIvH5*E|3LYd80LV{G!nz1RE2LO%#B$tmni9LJmvCyY|n|
z#aOm`L&5aVeS&mMD^JL%y#B7;s1k1(Fit4C6m@tp)BEdqPrhX8;T#o?*Uy6qgfp~t
z+eFL3V6g?_Pxxmi6II|(D#17h{?Y-ard}jZDPlf~UngEYapTo|r5)2ViF=o|xQUnn
z2j6fXSXFhnEeTP~5*7G0o-nOV0V`l&^`cYVMr5SZ4CRiX_`le@zmTqOC<$27W4)?Y
zZ{G<4=yO|zLx0Y`n9;FZ>bzEy$p3=Jj*2q2nnNOTIpq*ExaPc+
z$8p%Fhq3KSfn4Pe=PleD&qil{(iI!|kNW9)AvSN~v)X3F0$Zh!FJ^@P-d=aLyRGDM
zZ<=g|GFD;-$2PzpU43*1XleN$o}@Jjn9ZtE_lGg3GRPWM#~(~5Nlb?t3OlcmfdQtx
zQzjz1V2?)apeYf1cPrZH1cGS*(BuDB_|pI9b%Yk-6NjBX*9Pm2vsNa!OFVLoIhgBR
zt)$Le${n-o<)y@a;9yso687O@aja6i9p@}WBpKHJ2bbaf<_)F~DTV1`X=Xut|
z-`GM|d?!NY#llFASVu7vd_%eW58?LI%B81a2@Z83Kfj;nB6jM<_`nKUl*>BX1POOO
zx+#is$#>frrYz}HThV_@>s&koau8@f8?ROL@o1MH;M4`oM<=r2zCpTPg
zi|*WxzdF^U%6p!bOHLY>PD9MXdGP><8N;+L!)wM@F5?~#XDr~#mXxAHo^gu2?2yBl
z3Dd@Sv4}9kT5uj0ei?FAC3wev?Y@|Srnh}$~t#fmKkI?(xBqZxr9nQ}-!IVYu%ynm(pZ~mj
z7bEH%ksqf_i+b?KZGe7EJ$Gyst1UT7fB$zGKkp;@Xr%)eZ=v;6fI83ev6%5r9ll|W
zl1^cbe3cc1>jjn$)?Omn;>PCM=k4Qa^mg*uKy<{8&qrwH$;VSZWa%-s5uLS`a*&yC
zcl6$+umoWSJHCH!D~FQd>7th#Mfw;79^M2YxqA_4Er#AI-VuSrhs3TmL|fH_i!@p$
z-_W1p%$FTd`*FYp-sSi1|*f79gWBYkUT^zUBNeaBNe+Q!3dugr-3E$!%~
z_b)FdspzUO>1lB!-qJ(dP(x?K~VaNQ}YfQ--vcV;sr)!M_i*_|%`2
zU%2;-0^;qN3I-1;HiB?yhV^vm;c{ijVQ+oC|Nk(gedm
z9$l!x(f*$2>tvc7t813jL+)=n1pWnb(Ef}3Uu6Ycyd4gJ+6L=%9+~iT6!zr2!F3%q
zYwE9w*W9O87Q64NK%cSpKZHMU-2(~lc%QOkS<=0-VNu78+h5%{w5CFfv+9B+ZXGxl
z&xZ^i0w249V^J`WDdu0{XlY1zAU=K@PpRd2uR!SgRH502YbtXPse}P2RQcZ76R7Wf
z|7F#m;=C9RT-dbhA6{VS4&1b+)v)f-PEL8XzAO&E%KOc-Wp+BkwfmX%{q$BK%^07P
zWRw?H7p_7mwLi+~&we|p>i+65{&s({{tl%?HzL+~A5aVVLJDoQb|WacZ>tVB-p@?v
zyb=(qd$}i+>fYgLxa>IFw#_*;)%WI9jBpNCdONbjo}t4pA@x=PBr6>Pp&8y|*%&2I
zw55dj`SAWMuy`q{w8&DxW6rE6-gwC)>mut|oSK{4Ux2P%@bYbL3amX0Y2-mpJ|u9oKKI7zJh6%mDC2OW@9`fic((8}
zEo0B3eh&51l_1qABh}|D7>T-z5lZG$ylXVX;MxM#GzOutKb#$32VGQ*5<5x*{dj0;
z==E(%VC|=i?68A=%zGupm}J&gO)+%P0f80c0Dezq+R;d%dUC5oj}M{3+^ndvOhGXP
z-9%`f0&qdS-xPAnGmLI4pkhh+@U>-_Qwx|9K#V^m3T*Y0$+9jB`0p@*BC*~XwsZef
zoN&?oMcehPTcX+nmsuf-e(m1$;Mh_lNH4d*d8X}6QmYO3teZtOvh2wL7vFw
zX#__@PTw!du1j}krS9lFueDkIbIwl*UF?8049O+f_)9lj{%t!~-B=}|Dd4whFc*nS
zb^Zskju{vw#^Btq#YDn16Gqunn|)XG_d7R|v1vAKr;tYZ6Vm9;xWz}lly_oR?}m@P
zgjET9D1N^IU4|2Fn!?a~qL@>PNf69&*mMrXGy1B@rq8Q|{lmyeZ9hg~`c|2f7RRBf
z-na`5s`&mgM;#9B3%Zo&mCKgfW@b1jPG>iph>PC($8t!J7xtj($(e2{
zEAc=VzVpH~tn-puxKt{f;E;nqaTiOi*)n^nUbSGQ?4tC=#{%JIHa4FmHM9=N`u~M$
z&c1B*BJKdHc`gO!cC%ZU3pmmzY$ZkCi@m8>R>roOLKG%x5;}d^kO}xao$T`(~GZr3bqY*+}}>`Yz|C!uW3r}43LD+jFATyf$Io6{0y5;
zpv&N4(r_l!&60*u(e$mIo7t7S3yafF$aHwHuiOL^h894Gh!8NrRz&FnWxTU5UJ39M
z(IAKg3gh>liwUl^h$^`K@-JoItqvD{n-`N(gK?m`8@Ru{dV|cN56H#NuURM(-R^w)
zavL#0_b}O6UC5DaF>&9xGnmY$M{zO=UtetvI>1;rX4aljPg4|!Csz;N(*wjD+hCq-X|bP
z!cPD1-T8&4^WjIP`Mw0EpM&;rr$YN=9XnTd$j-tI*y+=l+Rh%cxad4q0G7t`3sre#?
z+18`Pj