diff --git a/.env.example b/.env.example index c450b6e..66539b6 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ # ── Chat Channel ────────────────────────── # TELEGRAM_BOT_TOKEN=123456:ABC... # DISCORD_BOT_TOKEN=xxx +# LINE_CHANNEL_SECRET=xxx +# LINE_CHANNEL_ACCESS_TOKEN=xxx # ── Web Search (optional) ──────────────── # BRAVE_SEARCH_API_KEY=BSA... diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 465d1d6..0f075b0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,5 +16,10 @@ 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 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 35ad87a..fac7597 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -32,6 +32,9 @@ jobs: with: go-version-file: go.mod + - name: Run go generate + run: go generate ./... + - name: Run go vet run: go vet ./... @@ -47,6 +50,9 @@ jobs: with: go-version-file: go.mod + - name: Run go generate + run: go generate ./... + - name: Run go test run: go test ./... diff --git a/.gitignore b/.gitignore index 6ba4117..3e5cee8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ build/ *.out /picoclaw /picoclaw-test +cmd/picoclaw/workspace # Picoclaw specific @@ -35,4 +36,8 @@ coverage.html # Ralph workspace ralph/ .ralph/ -tasks/ \ No newline at end of file +tasks/ + +# Editors +.vscode/ +.idea/ diff --git a/Dockerfile b/Dockerfile index 8db9955..59c28a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,19 +18,15 @@ RUN make build # ============================================================ # Stage 2: Minimal runtime image # ============================================================ -FROM alpine:3.21 +FROM alpine:3.23 -RUN apk add --no-cache ca-certificates tzdata +RUN apk add --no-cache ca-certificates tzdata curl # Copy binary COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw -# Copy builtin skills -COPY --from=builder /src/skills /opt/picoclaw/skills - # Create picoclaw home directory -RUN mkdir -p /root/.picoclaw/workspace/skills && \ - cp -r /opt/picoclaw/skills/* /root/.picoclaw/workspace/skills/ 2>/dev/null || true +RUN /usr/local/bin/picoclaw onboard ENTRYPOINT ["picoclaw"] CMD ["gateway"] diff --git a/Makefile b/Makefile index 2defcce..058fdb7 100644 --- a/Makefile +++ b/Makefile @@ -63,16 +63,23 @@ BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH) # Default target all: build +## generate: Run generate +generate: + @echo "Run generate..." + @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true + @$(GO) generate ./... + @echo "Run generate complete" + ## build: Build the picoclaw binary for current platform -build: +build: generate @echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) + @$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) ## build-all: Build picoclaw for all platforms -build-all: +build-all: generate @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) @@ -89,35 +96,8 @@ install: build @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME) @chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME) @echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)" - @echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..." - @mkdir -p $(WORKSPACE_SKILLS_DIR) - @for skill in $(BUILTIN_SKILLS_DIR)/*/; do \ - if [ -d "$$skill" ]; then \ - skill_name=$$(basename "$$skill"); \ - if [ -f "$$skill/SKILL.md" ]; then \ - cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \ - echo " ✓ Installed skill: $$skill_name"; \ - fi; \ - fi; \ - done @echo "Installation complete!" -## install-skills: Install builtin skills to workspace -install-skills: - @echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..." - @mkdir -p $(WORKSPACE_SKILLS_DIR) - @for skill in $(BUILTIN_SKILLS_DIR)/*/; do \ - if [ -d "$$skill" ]; then \ - skill_name=$$(basename "$$skill"); \ - if [ -f "$$skill/SKILL.md" ]; then \ - mkdir -p $(WORKSPACE_SKILLS_DIR)/$$skill_name; \ - cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \ - echo " ✓ Installed skill: $$skill_name"; \ - fi; \ - fi; \ - done - @echo "Skills installation complete!" - ## uninstall: Remove picoclaw from system uninstall: @echo "Uninstalling $(BINARY_NAME)..." @@ -139,6 +119,14 @@ clean: @rm -rf $(BUILD_DIR) @echo "Clean complete" +## fmt: Format Go code +vet: + @$(GO) vet ./... + +## fmt: Format Go code +test: + @$(GO) test ./... + ## fmt: Format Go code fmt: @$(GO) fmt ./... diff --git a/README.ja.md b/README.ja.md index 48105ce..e33b312 100644 --- a/README.ja.md +++ b/README.ja.md @@ -223,7 +223,7 @@ picoclaw agent -m "What is 2+2?" ## 💬 チャットアプリ -Telegram、Discord、QQ、DingTalk で PicoClaw と会話できます +Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます | チャネル | セットアップ | |---------|------------| @@ -231,6 +231,7 @@ Telegram、Discord、QQ、DingTalk で PicoClaw と会話できます | **Discord** | 簡単(Bot トークン + Intents) | | **QQ** | 簡単(AppID + AppSecret) | | **DingTalk** | 普通(アプリ認証情報) | +| **LINE** | 普通(認証情報 + Webhook URL) |
Telegram(推奨) @@ -314,7 +315,7 @@ picoclaw gateway **1. Bot を作成** -- [QQ オープンプラットフォーム](https://connect.qq.com/) にアクセス +- [QQ オープンプラットフォーム](https://q.qq.com/#) にアクセス - アプリケーションを作成 → **AppID** と **AppSecret** を取得 **2. 設定** @@ -376,6 +377,56 @@ picoclaw gateway
+
+LINE + +**1. LINE 公式アカウントを作成** + +- [LINE Developers Console](https://developers.line.biz/) にアクセス +- プロバイダーを作成 → Messaging API チャネルを作成 +- **チャネルシークレット** と **チャネルアクセストークン** をコピー + +**2. 設定** + +```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": [] + } + } +} +``` + +**3. Webhook URL を設定** + +LINE の Webhook には HTTPS が必要です。リバースプロキシまたはトンネルを使用してください: + +```bash +# ngrok の例 +ngrok http 18791 +``` + +LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。 + +**4. 起動** + +```bash +picoclaw gateway +``` + +> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。 + +> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。 + +
+ ## ⚙️ 設定 設定ファイル: `~/.picoclaw/config.json` diff --git a/README.md b/README.md index b11409f..a61a1ab 100644 --- a/README.md +++ b/README.md @@ -239,14 +239,15 @@ That's it! You have a working AI assistant in 2 minutes. ## 💬 Chat Apps -Talk to your picoclaw through Telegram, Discord, or DingTalk +Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE -| Channel | Setup | -| ------------ | -------------------------- | -| **Telegram** | Easy (just a token) | -| **Discord** | Easy (bot token + intents) | -| **QQ** | Easy (AppID + AppSecret) | -| **DingTalk** | Medium (app credentials) | +| Channel | Setup | +| ------------ | ---------------------------------- | +| **Telegram** | Easy (just a token) | +| **Discord** | Easy (bot token + intents) | +| **QQ** | Easy (AppID + AppSecret) | +| **DingTalk** | Medium (app credentials) | +| **LINE** | Medium (credentials + webhook URL) |
Telegram (Recommended) @@ -334,8 +335,8 @@ picoclaw gateway **1. Create a bot** -* Go to [QQ Open Platform](https://connect.qq.com/) -* Create an application → Get **AppID** and **AppSecret** +- Go to [QQ Open Platform](https://q.qq.com/#) +- Create an application → Get **AppID** and **AppSecret** **2. Configure** @@ -396,6 +397,56 @@ picoclaw gateway
+
+LINE + +**1. Create a LINE Official Account** + +- Go to [LINE Developers Console](https://developers.line.biz/) +- Create a provider → Create a Messaging API channel +- Copy **Channel Secret** and **Channel Access Token** + +**2. Configure** + +```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": [] + } + } +} +``` + +**3. Set up Webhook URL** + +LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel: + +```bash +# Example with ngrok +ngrok http 18791 +``` + +Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**. + +**4. Run** + +```bash +picoclaw gateway +``` + +> In group chats, the bot responds only when @mentioned. Replies quote the original message. + +> **Docker Compose**: Add `ports: ["18791:18791"]` to the `picoclaw-gateway` service to expose the webhook port. + +
+ ## ClawdChat 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.zh.md b/README.zh.md index f2c9bf7..f94abce 100644 --- a/README.zh.md +++ b/README.zh.md @@ -342,7 +342,7 @@ picoclaw gateway **1. 创建机器人** -* 前往 [QQ 开放平台](https://connect.qq.com/) +* 前往 [QQ 开放平台](https://q.qq.com/#) * 创建应用 → 获取 **AppID** 和 **AppSecret** **2. 配置** diff --git a/assets/wechat.png b/assets/wechat.png index 73b09da..0f97fa3 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 21246cf..2129662 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -9,8 +9,10 @@ package main import ( "bufio" "context" + "embed" "fmt" "io" + "io/fs" "os" "os/signal" "path/filepath" @@ -25,15 +27,21 @@ import ( "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" + "github.com/sipeed/picoclaw/pkg/devices" "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/migrate" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/voice" ) +//go:generate cp -r ../../workspace . +//go:embed workspace +var embeddedFiles embed.FS + var ( version = "dev" gitCommit string @@ -227,10 +235,6 @@ func onboard() { } workspace := cfg.WorkspacePath() - os.MkdirAll(workspace, 0755) - os.MkdirAll(filepath.Join(workspace, "memory"), 0755) - os.MkdirAll(filepath.Join(workspace, "skills"), 0755) - createWorkspaceTemplates(workspace) fmt.Printf("%s picoclaw is ready!\n", logo) @@ -240,170 +244,57 @@ func onboard() { fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") } +func copyEmbeddedToTarget(targetDir string) error { + // Ensure target directory exists + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("Failed to create target directory: %w", err) + } + + // Walk through all files in embed.FS + err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories + if d.IsDir() { + return nil + } + + // Read embedded file + data, err := embeddedFiles.ReadFile(path) + if err != nil { + return fmt.Errorf("Failed to read embedded file %s: %w", path, err) + } + + new_path, err := filepath.Rel("workspace", path) + if err != nil { + return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err) + } + + // Build target file path + targetPath := filepath.Join(targetDir, new_path) + + // Ensure target file's directory exists + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); 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 { + return fmt.Errorf("Failed to write file %s: %w", targetPath, err) + } + + return nil + }) + + return err +} + func createWorkspaceTemplates(workspace string) { - templates := map[string]string{ - "AGENTS.md": `# Agent Instructions - -You are a helpful AI assistant. Be concise, accurate, and friendly. - -## Guidelines - -- Always explain what you're doing before taking actions -- Ask for clarification when request is ambiguous -- Use tools to help accomplish tasks -- Remember important information in your memory files -- Be proactive and helpful -- Learn from user feedback -`, - "SOUL.md": `# Soul - -I am picoclaw, a lightweight AI assistant powered by AI. - -## Personality - -- Helpful and friendly -- Concise and to the point -- Curious and eager to learn -- Honest and transparent - -## Values - -- Accuracy over speed -- User privacy and safety -- Transparency in actions -- Continuous improvement -`, - "USER.md": `# User - -Information about user goes here. - -## Preferences - -- Communication style: (casual/formal) -- Timezone: (your timezone) -- Language: (your preferred language) - -## Personal Information - -- Name: (optional) -- Location: (optional) -- Occupation: (optional) - -## Learning Goals - -- What the user wants to learn from AI -- Preferred interaction style -- Areas of interest -`, - "IDENTITY.md": `# Identity - -## Name -PicoClaw 🦞 - -## Description -Ultra-lightweight personal AI assistant written in Go, inspired by nanobot. - -## Version -0.1.0 - -## Purpose -- Provide intelligent AI assistance with minimal resource usage -- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.) -- Enable easy customization through skills system -- Run on minimal hardware ($10 boards, <10MB RAM) - -## Capabilities - -- Web search and content fetching -- File system operations (read, write, edit) -- Shell command execution -- Multi-channel messaging (Telegram, WhatsApp, Feishu) -- Skill-based extensibility -- Memory and context management - -## Philosophy - -- Simplicity over complexity -- Performance over features -- User control and privacy -- Transparent operation -- Community-driven development - -## Goals - -- Provide a fast, lightweight AI assistant -- Support offline-first operation where possible -- Enable easy customization and extension -- Maintain high quality responses -- Run efficiently on constrained hardware - -## License -MIT License - Free and open source - -## Repository -https://github.com/sipeed/picoclaw - -## Contact -Issues: https://github.com/sipeed/picoclaw/issues -Discussions: https://github.com/sipeed/picoclaw/discussions - ---- - -"Every bit helps, every bit matters." -- Picoclaw -`, - } - - for filename, content := range templates { - filePath := filepath.Join(workspace, filename) - if _, err := os.Stat(filePath); os.IsNotExist(err) { - os.WriteFile(filePath, []byte(content), 0644) - fmt.Printf(" Created %s\n", filename) - } - } - - memoryDir := filepath.Join(workspace, "memory") - os.MkdirAll(memoryDir, 0755) - memoryFile := filepath.Join(memoryDir, "MEMORY.md") - if _, err := os.Stat(memoryFile); os.IsNotExist(err) { - memoryContent := `# Long-term Memory - -This file stores important information that should persist across sessions. - -## User Information - -(Important facts about user) - -## Preferences - -(User preferences learned over time) - -## Important Notes - -(Things to remember) - -## Configuration - -- Model preferences -- Channel settings -- Skills enabled -` - os.WriteFile(memoryFile, []byte(memoryContent), 0644) - fmt.Println(" Created memory/MEMORY.md") - - skillsDir := filepath.Join(workspace, "skills") - if _, err := os.Stat(skillsDir); os.IsNotExist(err) { - os.MkdirAll(skillsDir, 0755) - fmt.Println(" Created skills/") - } - } - - for filename, content := range templates { - filePath := filepath.Join(workspace, filename) - if _, err := os.Stat(filePath); os.IsNotExist(err) { - os.WriteFile(filePath, []byte(content), 0644) - fmt.Printf(" Created %s\n", filename) - } + err := copyEmbeddedToTarget(workspace) + if err != nil { + fmt.Printf("Error copying workspace templates: %v\n", err) } } @@ -751,6 +642,18 @@ func gatewayCmd() { } fmt.Println("✓ Heartbeat service started") + stateManager := state.NewManager(cfg.WorkspacePath()) + deviceService := devices.NewService(devices.Config{ + Enabled: cfg.Devices.Enabled, + MonitorUSB: cfg.Devices.MonitorUSB, + }, stateManager) + deviceService.SetBus(msgBus) + if err := deviceService.Start(ctx); err != nil { + fmt.Printf("Error starting device service: %v\n", err) + } else if cfg.Devices.Enabled { + fmt.Println("✓ Device event service started") + } + if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) } @@ -763,6 +666,7 @@ func gatewayCmd() { fmt.Println("\nShutting down...") cancel() + deviceService.Stop() heartbeatService.Stop() cronService.Stop() agentLoop.Stop() diff --git a/config/config.example.json b/config/config.example.json index c71587a..aa75c83 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -51,6 +51,23 @@ "bot_token": "xoxb-YOUR-BOT-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [] + }, + "line": { + "enabled": false, + "channel_secret": "YOUR_LINE_CHANNEL_SECRET", + "channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + }, + "onebot": { + "enabled": false, + "ws_url": "ws://127.0.0.1:3001", + "access_token": "", + "reconnect_interval": 5, + "group_trigger_prefix": [], + "allow_from": [] } }, "providers": { @@ -104,6 +121,10 @@ "enabled": true, "interval": 30 }, + "devices": { + "enabled": false, + "monitor_usb": true + }, "gateway": { "host": "0.0.0.0", "port": 18790 diff --git a/go.mod b/go.mod index f4c233e..98aecd6 100644 --- a/go.mod +++ b/go.mod @@ -13,18 +13,22 @@ require ( github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mymmrac/telego v1.6.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 - github.com/openai/openai-go/v3 v3.21.0 + github.com/openai/openai-go/v3 v3.22.0 github.com/slack-go/slack v0.17.3 github.com/tencent-connect/botgo v0.2.1 golang.org/x/oauth2 v0.35.0 ) + + require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/github/copilot-sdk/go v0.1.23 + github.com/google/jsonschema-go v0.4.2 // indirect github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/grbit/go-json v0.11.0 // indirect @@ -43,4 +47,5 @@ require ( golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect + ) diff --git a/go.sum b/go.sum index 9174d28..6a565b9 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= +github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= @@ -56,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -92,8 +96,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= -github.com/openai/openai-go/v3 v3.21.0 h1:3GpIR/W4q/v1uUOVuK3zYtQiF3DnRrZag/sxbtvEdtc= -github.com/openai/openai-go/v3 v3.21.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= +github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ac8da9f..73e8371 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -81,6 +81,10 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg } registry.Register(tools.NewWebFetchTool(50000)) + // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms + registry.Register(tools.NewI2CTool()) + registry.Register(tools.NewSPITool()) + // Message tool - available to both agent and subagent // Subagent uses it to communicate directly with user messageTool := tools.NewMessageTool() diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go index f82b04c..78c6d1d 100644 --- a/pkg/channels/base_test.go +++ b/pkg/channels/base_test.go @@ -50,4 +50,3 @@ func TestBaseChannelIsAllowed(t *testing.T) { }) } } - diff --git a/pkg/channels/feishu_32.go b/pkg/channels/feishu_32.go new file mode 100644 index 0000000..4e60fbc --- /dev/null +++ b/pkg/channels/feishu_32.go @@ -0,0 +1,36 @@ +//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64 + +package channels + +import ( + "context" + "errors" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +// FeishuChannel is a stub implementation for 32-bit architectures +type FeishuChannel struct { + *BaseChannel +} + +// 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") +} + +// Start is a stub method to satisfy the Channel interface +func (c *FeishuChannel) Start(ctx context.Context) error { + return nil +} + +// Stop is a stub method to satisfy the Channel interface +func (c *FeishuChannel) Stop(ctx context.Context) error { + return nil +} + +// Send is a stub method to satisfy the Channel interface +func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + return errors.New("feishu channel is not supported on 32-bit architectures") +} diff --git a/pkg/channels/feishu.go b/pkg/channels/feishu_64.go similarity index 98% rename from pkg/channels/feishu.go rename to pkg/channels/feishu_64.go index 11dbd67..39dc40a 100644 --- a/pkg/channels/feishu.go +++ b/pkg/channels/feishu_64.go @@ -1,3 +1,5 @@ +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 + package channels import ( diff --git a/pkg/channels/line.go b/pkg/channels/line.go new file mode 100644 index 0000000..ffb5533 --- /dev/null +++ b/pkg/channels/line.go @@ -0,0 +1,598 @@ +package channels + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "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 ( + lineAPIBase = "https://api.line.me/v2/bot" + lineDataAPIBase = "https://api-data.line.me/v2/bot" + lineReplyEndpoint = lineAPIBase + "/message/reply" + linePushEndpoint = lineAPIBase + "/message/push" + lineContentEndpoint = lineDataAPIBase + "/message/%s/content" + lineBotInfoEndpoint = lineAPIBase + "/info" + lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" + lineReplyTokenMaxAge = 25 * time.Second +) + +type replyTokenEntry struct { + token string + timestamp time.Time +} + +// LINEChannel implements the Channel interface for LINE Official Account +// using the LINE Messaging API with HTTP webhook for receiving messages +// and REST API for sending messages. +type LINEChannel struct { + *BaseChannel + config config.LINEConfig + httpServer *http.Server + botUserID string // Bot's user ID + botBasicID string // Bot's basic ID (e.g. @216ru...) + botDisplayName string // Bot's display name for text-based mention detection + replyTokens sync.Map // chatID -> replyTokenEntry + quoteTokens sync.Map // chatID -> quoteToken (string) + ctx context.Context + cancel context.CancelFunc +} + +// NewLINEChannel creates a new LINE channel instance. +func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) { + if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" { + return nil, fmt.Errorf("line channel_secret and channel_access_token are required") + } + + base := NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom) + + return &LINEChannel{ + BaseChannel: base, + config: cfg, + }, nil +} + +// Start launches the HTTP webhook server. +func (c *LINEChannel) Start(ctx context.Context) error { + logger.InfoC("line", "Starting LINE channel (Webhook Mode)") + + c.ctx, c.cancel = context.WithCancel(ctx) + + // 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{}{ + "error": err.Error(), + }) + } else { + logger.InfoCF("line", "Bot info fetched", map[string]interface{}{ + "bot_user_id": c.botUserID, + "basic_id": c.botBasicID, + "display_name": c.botDisplayName, + }) + } + + mux := http.NewServeMux() + path := c.config.WebhookPath + if path == "" { + path = "/webhook/line" + } + mux.HandleFunc(path, c.webhookHandler) + + addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) + c.httpServer = &http.Server{ + Addr: addr, + Handler: mux, + } + + go func() { + logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{ + "addr": addr, + "path": path, + }) + if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.ErrorCF("line", "Webhook server error", map[string]interface{}{ + "error": err.Error(), + }) + } + }() + + c.setRunning(true) + logger.InfoC("line", "LINE channel started (Webhook Mode)") + return nil +} + +// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API. +func (c *LINEChannel) fetchBotInfo() error { + req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bot info API returned status %d", resp.StatusCode) + } + + var info struct { + UserID string `json:"userId"` + BasicID string `json:"basicId"` + DisplayName string `json:"displayName"` + } + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return err + } + + c.botUserID = info.UserID + c.botBasicID = info.BasicID + c.botDisplayName = info.DisplayName + return nil +} + +// Stop gracefully shuts down the HTTP server. +func (c *LINEChannel) Stop(ctx context.Context) error { + logger.InfoC("line", "Stopping LINE channel") + + if c.cancel != nil { + c.cancel() + } + + if c.httpServer != nil { + 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{}{ + "error": err.Error(), + }) + } + } + + c.setRunning(false) + logger.InfoC("line", "LINE channel stopped") + return nil +} + +// webhookHandler handles incoming LINE webhook requests. +func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + signature := r.Header.Get("X-Line-Signature") + if !c.verifySignature(body, signature) { + logger.WarnC("line", "Invalid webhook signature") + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + var payload struct { + Events []lineEvent `json:"events"` + } + if err := json.Unmarshal(body, &payload); err != nil { + logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Return 200 immediately, process events asynchronously + w.WriteHeader(http.StatusOK) + + for _, event := range payload.Events { + go c.processEvent(event) + } +} + +// verifySignature validates the X-Line-Signature using HMAC-SHA256. +func (c *LINEChannel) verifySignature(body []byte, signature string) bool { + if signature == "" { + return false + } + + mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret)) + mac.Write(body) + expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(expected), []byte(signature)) +} + +// LINE webhook event types +type lineEvent struct { + Type string `json:"type"` + ReplyToken string `json:"replyToken"` + Source lineSource `json:"source"` + Message json.RawMessage `json:"message"` + Timestamp int64 `json:"timestamp"` +} + +type lineSource struct { + Type string `json:"type"` // "user", "group", "room" + UserID string `json:"userId"` + GroupID string `json:"groupId"` + RoomID string `json:"roomId"` +} + +type lineMessage struct { + ID string `json:"id"` + Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker" + Text string `json:"text"` + QuoteToken string `json:"quoteToken"` + Mention *struct { + Mentionees []lineMentionee `json:"mentionees"` + } `json:"mention"` + ContentProvider struct { + Type string `json:"type"` + } `json:"contentProvider"` +} + +type lineMentionee struct { + Index int `json:"index"` + Length int `json:"length"` + Type string `json:"type"` // "user", "all" + UserID string `json:"userId"` +} + +func (c *LINEChannel) processEvent(event lineEvent) { + if event.Type != "message" { + logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{ + "type": event.Type, + }) + return + } + + senderID := event.Source.UserID + chatID := c.resolveChatID(event.Source) + isGroup := event.Source.Type == "group" || event.Source.Type == "room" + + var msg lineMessage + if err := json.Unmarshal(event.Message, &msg); err != nil { + logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{ + "error": err.Error(), + }) + return + } + + // 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{}{ + "chat_id": chatID, + }) + return + } + + // Store reply token for later use + if event.ReplyToken != "" { + c.replyTokens.Store(chatID, replyTokenEntry{ + token: event.ReplyToken, + timestamp: time.Now(), + }) + } + + // Store quote token for quoting the original message in reply + if msg.QuoteToken != "" { + c.quoteTokens.Store(chatID, msg.QuoteToken) + } + + var content string + var mediaPaths []string + localFiles := []string{} + + defer func() { + for _, file := range localFiles { + if err := os.Remove(file); err != nil { + logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{ + "file": file, + "error": err.Error(), + }) + } + } + }() + + switch msg.Type { + case "text": + content = msg.Text + // Strip bot mention from text in group chats + if isGroup { + content = c.stripBotMention(content, msg) + } + case "image": + localPath := c.downloadContent(msg.ID, "image.jpg") + if localPath != "" { + localFiles = append(localFiles, localPath) + mediaPaths = append(mediaPaths, localPath) + content = "[image]" + } + case "audio": + localPath := c.downloadContent(msg.ID, "audio.m4a") + if localPath != "" { + localFiles = append(localFiles, localPath) + mediaPaths = append(mediaPaths, localPath) + content = "[audio]" + } + case "video": + localPath := c.downloadContent(msg.ID, "video.mp4") + if localPath != "" { + localFiles = append(localFiles, localPath) + mediaPaths = append(mediaPaths, localPath) + content = "[video]" + } + case "file": + content = "[file]" + case "sticker": + content = "[sticker]" + default: + content = fmt.Sprintf("[%s]", msg.Type) + } + + if strings.TrimSpace(content) == "" { + return + } + + metadata := map[string]string{ + "platform": "line", + "source_type": event.Source.Type, + "message_id": msg.ID, + } + + logger.DebugCF("line", "Received message", map[string]interface{}{ + "sender_id": senderID, + "chat_id": chatID, + "message_type": msg.Type, + "is_group": isGroup, + "preview": utils.Truncate(content, 50), + }) + + // Show typing/loading indicator (requires user ID, not group ID) + c.sendLoading(senderID) + + c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) +} + +// isBotMentioned checks if the bot is mentioned in the message. +// It first checks the mention metadata (userId match), then falls back +// to text-based detection using the bot's display name, since LINE may +// not include userId in mentionees for Official Accounts. +func (c *LINEChannel) isBotMentioned(msg lineMessage) bool { + // Check mention metadata + if msg.Mention != nil { + for _, m := range msg.Mention.Mentionees { + if m.Type == "all" { + return true + } + if c.botUserID != "" && m.UserID == c.botUserID { + return true + } + } + // Mention metadata exists with mentionees but bot not matched by userId. + // The bot IS likely mentioned (LINE includes mention struct when bot is @-ed), + // so check if any mentionee overlaps with bot display name in text. + if c.botDisplayName != "" { + for _, m := range msg.Mention.Mentionees { + if m.Index >= 0 && m.Length > 0 { + runes := []rune(msg.Text) + end := m.Index + m.Length + if end <= len(runes) { + mentionText := string(runes[m.Index:end]) + if strings.Contains(mentionText, c.botDisplayName) { + return true + } + } + } + } + } + } + + // Fallback: text-based detection with display name + if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) { + return true + } + + return false +} + +// stripBotMention removes the @BotName mention text from the message. +func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string { + stripped := false + + // Try to strip using mention metadata indices + if msg.Mention != nil { + runes := []rune(text) + for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- { + m := msg.Mention.Mentionees[i] + // Strip if userId matches OR if the mention text contains the bot display name + shouldStrip := false + if c.botUserID != "" && m.UserID == c.botUserID { + shouldStrip = true + } else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 { + end := m.Index + m.Length + if end <= len(runes) { + mentionText := string(runes[m.Index:end]) + if strings.Contains(mentionText, c.botDisplayName) { + shouldStrip = true + } + } + } + if shouldStrip { + start := m.Index + end := m.Index + m.Length + if start >= 0 && end <= len(runes) { + runes = append(runes[:start], runes[end:]...) + stripped = true + } + } + } + if stripped { + return strings.TrimSpace(string(runes)) + } + } + + // Fallback: strip @DisplayName from text + if c.botDisplayName != "" { + text = strings.ReplaceAll(text, "@"+c.botDisplayName, "") + } + + return strings.TrimSpace(text) +} + +// resolveChatID determines the chat ID from the event source. +// For group/room messages, use the group/room ID; for 1:1, use the user ID. +func (c *LINEChannel) resolveChatID(source lineSource) string { + switch source.Type { + case "group": + return source.GroupID + case "room": + return source.RoomID + default: + return source.UserID + } +} + +// Send sends a message to LINE. It first tries the Reply API (free) +// using a cached reply token, then falls back to the Push API. +func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("line channel not running") + } + + // Load and consume quote token for this chat + var quoteToken string + if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok { + quoteToken = qt.(string) + } + + // Try reply token first (free, valid for ~25 seconds) + if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok { + 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{}{ + "chat_id": msg.ChatID, + "quoted": quoteToken != "", + }) + return nil + } + logger.DebugC("line", "Reply API failed, falling back to Push API") + } + } + + // Fall back to Push API + return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) +} + +// buildTextMessage creates a text message object, optionally with quoteToken. +func buildTextMessage(content, quoteToken string) map[string]string { + msg := map[string]string{ + "type": "text", + "text": content, + } + if quoteToken != "" { + msg["quoteToken"] = quoteToken + } + return msg +} + +// 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{}{ + "replyToken": replyToken, + "messages": []map[string]string{buildTextMessage(content, quoteToken)}, + } + + return c.callAPI(ctx, lineReplyEndpoint, payload) +} + +// 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{}{ + "to": to, + "messages": []map[string]string{buildTextMessage(content, quoteToken)}, + } + + return c.callAPI(ctx, linePushEndpoint, payload) +} + +// sendLoading sends a loading animation indicator to the chat. +func (c *LINEChannel) sendLoading(chatID string) { + payload := map[string]interface{}{ + "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{}{ + "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 { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// downloadContent downloads media content from the LINE API. +func (c *LINEChannel) downloadContent(messageID, filename string) string { + url := fmt.Sprintf(lineContentEndpoint, messageID) + return utils.DownloadFile(url, filename, utils.DownloadOptions{ + LoggerPrefix: "line", + ExtraHeaders: map[string]string{ + "Authorization": "Bearer " + c.config.ChannelAccessToken, + }, + }) +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 772551a..15f8c60 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -150,6 +150,32 @@ func (m *Manager) initChannels() error { } } + if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" { + 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{}{ + "error": err.Error(), + }) + } else { + m.channels["line"] = line + logger.InfoC("channels", "LINE channel enabled successfully") + } + } + + if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" { + 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{}{ + "error": err.Error(), + }) + } else { + m.channels["onebot"] = onebot + logger.InfoC("channels", "OneBot channel enabled successfully") + } + } + logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{ "enabled_channels": len(m.channels), }) diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go new file mode 100644 index 0000000..5d97fab --- /dev/null +++ b/pkg/channels/onebot.go @@ -0,0 +1,686 @@ +package channels + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type OneBotChannel struct { + *BaseChannel + config config.OneBotConfig + conn *websocket.Conn + ctx context.Context + cancel context.CancelFunc + dedup map[string]struct{} + dedupRing []string + dedupIdx int + mu sync.Mutex + writeMu sync.Mutex + echoCounter int64 +} + +type oneBotRawEvent struct { + PostType string `json:"post_type"` + MessageType string `json:"message_type"` + SubType string `json:"sub_type"` + MessageID json.RawMessage `json:"message_id"` + UserID json.RawMessage `json:"user_id"` + GroupID json.RawMessage `json:"group_id"` + RawMessage string `json:"raw_message"` + Message json.RawMessage `json:"message"` + Sender json.RawMessage `json:"sender"` + SelfID json.RawMessage `json:"self_id"` + Time json.RawMessage `json:"time"` + MetaEventType string `json:"meta_event_type"` + Echo string `json:"echo"` + RetCode json.RawMessage `json:"retcode"` + Status BotStatus `json:"status"` +} + +type BotStatus struct { + Online bool `json:"online"` + Good bool `json:"good"` +} + +type oneBotSender struct { + UserID json.RawMessage `json:"user_id"` + Nickname string `json:"nickname"` + Card string `json:"card"` +} + +type oneBotEvent struct { + PostType string + MessageType string + SubType string + MessageID string + UserID int64 + GroupID int64 + Content string + RawContent string + IsBotMentioned bool + Sender oneBotSender + SelfID int64 + Time int64 + MetaEventType string +} + +type oneBotAPIRequest struct { + Action string `json:"action"` + Params interface{} `json:"params"` + Echo string `json:"echo,omitempty"` +} + +type oneBotSendPrivateMsgParams struct { + UserID int64 `json:"user_id"` + Message string `json:"message"` +} + +type oneBotSendGroupMsgParams struct { + GroupID int64 `json:"group_id"` + Message string `json:"message"` +} + +func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { + base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom) + + const dedupSize = 1024 + return &OneBotChannel{ + BaseChannel: base, + config: cfg, + dedup: make(map[string]struct{}, dedupSize), + dedupRing: make([]string, dedupSize), + dedupIdx: 0, + }, nil +} + +func (c *OneBotChannel) Start(ctx context.Context) error { + if c.config.WSUrl == "" { + return fmt.Errorf("OneBot ws_url not configured") + } + + logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{ + "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{}{ + "error": err.Error(), + }) + } else { + go c.listen() + } + + if c.config.ReconnectInterval > 0 { + go c.reconnectLoop() + } else { + // If reconnect is disabled but initial connection failed, we cannot recover + if c.conn == nil { + return fmt.Errorf("failed to connect to OneBot and reconnect is disabled") + } + } + + c.setRunning(true) + logger.InfoC("onebot", "OneBot channel started successfully") + + return nil +} + +func (c *OneBotChannel) connect() error { + dialer := websocket.DefaultDialer + dialer.HandshakeTimeout = 10 * time.Second + + header := make(map[string][]string) + if c.config.AccessToken != "" { + header["Authorization"] = []string{"Bearer " + c.config.AccessToken} + } + + conn, _, err := dialer.Dial(c.config.WSUrl, header) + if err != nil { + return err + } + + c.mu.Lock() + c.conn = conn + c.mu.Unlock() + + logger.InfoC("onebot", "WebSocket connected") + return nil +} + +func (c *OneBotChannel) reconnectLoop() { + interval := time.Duration(c.config.ReconnectInterval) * time.Second + if interval < 5*time.Second { + interval = 5 * time.Second + } + + for { + select { + case <-c.ctx.Done(): + return + case <-time.After(interval): + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + logger.InfoC("onebot", "Attempting to reconnect...") + if err := c.connect(); err != nil { + logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{ + "error": err.Error(), + }) + } else { + go c.listen() + } + } + } + } +} + +func (c *OneBotChannel) Stop(ctx context.Context) error { + logger.InfoC("onebot", "Stopping OneBot channel") + c.setRunning(false) + + if c.cancel != nil { + c.cancel() + } + + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + + return nil +} + +func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("OneBot channel not running") + } + + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + return fmt.Errorf("OneBot WebSocket not connected") + } + + action, params, err := c.buildSendRequest(msg) + if err != nil { + return err + } + + c.writeMu.Lock() + c.echoCounter++ + echo := fmt.Sprintf("send_%d", c.echoCounter) + c.writeMu.Unlock() + + req := oneBotAPIRequest{ + Action: action, + Params: params, + Echo: echo, + } + + data, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal OneBot request: %w", err) + } + + c.writeMu.Lock() + err = conn.WriteMessage(websocket.TextMessage, data) + c.writeMu.Unlock() + + if err != nil { + logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{ + "error": err.Error(), + }) + return err + } + + return nil +} + +func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) { + chatID := msg.ChatID + + if len(chatID) > 6 && chatID[:6] == "group:" { + groupID, err := strconv.ParseInt(chatID[6:], 10, 64) + if err != nil { + return "", nil, fmt.Errorf("invalid group ID in chatID: %s", chatID) + } + return "send_group_msg", oneBotSendGroupMsgParams{ + GroupID: groupID, + Message: msg.Content, + }, nil + } + + if len(chatID) > 8 && chatID[:8] == "private:" { + userID, err := strconv.ParseInt(chatID[8:], 10, 64) + if err != nil { + return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID) + } + return "send_private_msg", oneBotSendPrivateMsgParams{ + UserID: userID, + Message: msg.Content, + }, nil + } + + userID, err := strconv.ParseInt(chatID, 10, 64) + if err != nil { + return "", nil, fmt.Errorf("invalid chatID for OneBot: %s", chatID) + } + + return "send_private_msg", oneBotSendPrivateMsgParams{ + UserID: userID, + Message: msg.Content, + }, nil +} + +func (c *OneBotChannel) listen() { + for { + select { + case <-c.ctx.Done(): + return + default: + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + logger.WarnC("onebot", "WebSocket connection is nil, listener exiting") + return + } + + _, message, err := conn.ReadMessage() + if err != nil { + logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{ + "error": err.Error(), + }) + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + return + } + + logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{ + "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{}{ + "error": err.Error(), + "payload": string(message), + }) + continue + } + + if raw.Echo != "" || raw.Status.Online || raw.Status.Good { + logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{ + "echo": raw.Echo, + "status": raw.Status, + }) + continue + } + + logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{ + "post_type": raw.PostType, + "message_type": raw.MessageType, + "sub_type": raw.SubType, + "meta_event_type": raw.MetaEventType, + }) + + c.handleRawEvent(&raw) + } + } +} + +func parseJSONInt64(raw json.RawMessage) (int64, error) { + if len(raw) == 0 { + return 0, nil + } + + var n int64 + if err := json.Unmarshal(raw, &n); err == nil { + return n, nil + } + + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return strconv.ParseInt(s, 10, 64) + } + return 0, fmt.Errorf("cannot parse as int64: %s", string(raw)) +} + +func parseJSONString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + + return string(raw) +} + +type parseMessageResult struct { + Text string + IsBotMentioned bool +} + +func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult { + if len(raw) == 0 { + return parseMessageResult{} + } + + var s string + if err := json.Unmarshal(raw, &s); err == nil { + mentioned := false + if selfID > 0 { + cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID) + if strings.Contains(s, cqAt) { + mentioned = true + s = strings.ReplaceAll(s, cqAt, "") + s = strings.TrimSpace(s) + } + } + return parseMessageResult{Text: s, IsBotMentioned: mentioned} + } + + var segments []map[string]interface{} + 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{}) + switch segType { + case "text": + if data != nil { + if t, ok := data["text"].(string); ok { + text += t + } + } + case "at": + if data != nil && selfID > 0 { + qqVal := fmt.Sprintf("%v", data["qq"]) + if qqVal == selfIDStr || qqVal == "all" { + mentioned = true + } + } + } + } + return parseMessageResult{Text: strings.TrimSpace(text), IsBotMentioned: mentioned} + } + return parseMessageResult{} +} + +func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { + switch raw.PostType { + case "message": + evt, err := c.normalizeMessageEvent(raw) + if err != nil { + logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{ + "error": err.Error(), + }) + return + } + c.handleMessage(evt) + case "meta_event": + c.handleMetaEvent(raw) + case "notice": + logger.DebugCF("onebot", "Notice event received", map[string]interface{}{ + "sub_type": raw.SubType, + }) + case "request": + logger.DebugCF("onebot", "Request event received", map[string]interface{}{ + "sub_type": raw.SubType, + }) + case "": + logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{ + "echo": raw.Echo, + "status": raw.Status, + }) + default: + logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{ + "post_type": raw.PostType, + }) + } +} + +func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent, error) { + userID, err := parseJSONInt64(raw.UserID) + if err != nil { + return nil, fmt.Errorf("parse user_id: %w (raw: %s)", err, string(raw.UserID)) + } + + groupID, _ := parseJSONInt64(raw.GroupID) + selfID, _ := parseJSONInt64(raw.SelfID) + ts, _ := parseJSONInt64(raw.Time) + messageID := parseJSONString(raw.MessageID) + + parsed := parseMessageContentEx(raw.Message, selfID) + isBotMentioned := parsed.IsBotMentioned + + content := raw.RawMessage + if content == "" { + content = parsed.Text + } else if selfID > 0 { + cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID) + if strings.Contains(content, cqAt) { + isBotMentioned = true + content = strings.ReplaceAll(content, cqAt, "") + content = strings.TrimSpace(content) + } + } + + 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{}{ + "error": err.Error(), + "sender": string(raw.Sender), + }) + } + } + + logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{ + "message_type": raw.MessageType, + "user_id": userID, + "group_id": groupID, + "message_id": messageID, + "content_len": len(content), + "nickname": sender.Nickname, + }) + + return &oneBotEvent{ + PostType: raw.PostType, + MessageType: raw.MessageType, + SubType: raw.SubType, + MessageID: messageID, + UserID: userID, + GroupID: groupID, + Content: content, + RawContent: raw.RawMessage, + IsBotMentioned: isBotMentioned, + Sender: sender, + SelfID: selfID, + Time: ts, + MetaEventType: raw.MetaEventType, + }, nil +} + +func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) { + switch raw.MetaEventType { + case "lifecycle": + logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{ + "sub_type": raw.SubType, + }) + case "heartbeat": + logger.DebugC("onebot", "Heartbeat received") + default: + logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{ + "meta_event_type": raw.MetaEventType, + }) + } +} + +func (c *OneBotChannel) handleMessage(evt *oneBotEvent) { + if c.isDuplicate(evt.MessageID) { + logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{ + "message_id": evt.MessageID, + }) + return + } + + content := evt.Content + if content == "" { + logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{ + "message_id": evt.MessageID, + }) + return + } + + senderID := strconv.FormatInt(evt.UserID, 10) + var chatID string + + metadata := map[string]string{ + "message_id": evt.MessageID, + } + + switch evt.MessageType { + case "private": + chatID = "private:" + senderID + logger.InfoCF("onebot", "Received private message", map[string]interface{}{ + "sender": senderID, + "message_id": evt.MessageID, + "length": len(content), + "content": truncate(content, 100), + }) + + case "group": + groupIDStr := strconv.FormatInt(evt.GroupID, 10) + chatID = "group:" + groupIDStr + metadata["group_id"] = groupIDStr + + senderUserID, _ := parseJSONInt64(evt.Sender.UserID) + if senderUserID > 0 { + metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10) + } + + if evt.Sender.Card != "" { + metadata["sender_name"] = evt.Sender.Card + } else if evt.Sender.Nickname != "" { + metadata["sender_name"] = evt.Sender.Nickname + } + + triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned) + if !triggered { + logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{ + "sender": senderID, + "group": groupIDStr, + "is_mentioned": evt.IsBotMentioned, + "content": truncate(content, 100), + }) + return + } + content = strippedContent + + logger.InfoCF("onebot", "Received group message", map[string]interface{}{ + "sender": senderID, + "group": groupIDStr, + "message_id": evt.MessageID, + "is_mentioned": evt.IsBotMentioned, + "length": len(content), + "content": truncate(content, 100), + }) + + default: + logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{ + "type": evt.MessageType, + "message_id": evt.MessageID, + "user_id": evt.UserID, + }) + return + } + + if evt.Sender.Nickname != "" { + metadata["nickname"] = evt.Sender.Nickname + } + + logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{ + "sender_id": senderID, + "chat_id": chatID, + "content": truncate(content, 100), + }) + + c.HandleMessage(senderID, chatID, content, []string{}, metadata) +} + +func (c *OneBotChannel) isDuplicate(messageID string) bool { + if messageID == "" || messageID == "0" { + return false + } + + c.mu.Lock() + defer c.mu.Unlock() + + if _, exists := c.dedup[messageID]; exists { + return true + } + + if old := c.dedupRing[c.dedupIdx]; old != "" { + delete(c.dedup, old) + } + c.dedupRing[c.dedupIdx] = messageID + c.dedup[messageID] = struct{}{} + c.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing) + + return false +} + +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "..." +} + +func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) { + if isBotMentioned { + return true, strings.TrimSpace(content) + } + + for _, prefix := range c.config.GroupTriggerPrefix { + if prefix == "" { + continue + } + if strings.HasPrefix(content, prefix) { + return true, strings.TrimSpace(strings.TrimPrefix(content, prefix)) + } + } + + return false, content +} diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 0934dbd..b14b163 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -320,37 +320,14 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat } } - // Create new context for thinking animation with timeout - thinkCtx, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) + // Create cancel function for thinking state + _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) if err == nil { pID := pMsg.MessageID c.placeholders.Store(chatIDStr, pID) - - go func(cid int64, mid int) { - dots := []string{".", "..", "..."} - emotes := []string{"💭", "🤔", "☁️"} - i := 0 - ticker := time.NewTicker(2000 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-thinkCtx.Done(): - return - case <-ticker.C: - i++ - text := fmt.Sprintf("Thinking%s %s", dots[i%len(dots)], emotes[i%len(emotes)]) - _, editErr := c.bot.EditMessageText(thinkCtx, tu.EditMessageText(tu.ID(chatID), mid, text)) - if editErr != nil { - logger.DebugCF("telegram", "Failed to edit thinking message", map[string]interface{}{ - "error": editErr.Error(), - }) - } - } - } - }(chatID, pID) } metadata := map[string]string{ diff --git a/pkg/config/config.go b/pkg/config/config.go index 374c6f8..112f916 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -50,6 +50,7 @@ type Config struct { Gateway GatewayConfig `json:"gateway"` Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` + Devices DevicesConfig `json:"devices"` mu sync.RWMutex } @@ -76,6 +77,8 @@ type ChannelsConfig struct { QQ QQConfig `json:"qq"` DingTalk DingTalkConfig `json:"dingtalk"` Slack SlackConfig `json:"slack"` + LINE LINEConfig `json:"line"` + OneBot OneBotConfig `json:"onebot"` } type WhatsAppConfig struct { @@ -134,29 +137,56 @@ type SlackConfig struct { AllowFrom []string `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"` + 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"` +} + +type OneBotConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` + WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` + AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` + ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` + GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` +} + type HeartbeatConfig struct { 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"` + MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` +} + type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI ProviderConfig `json:"openai"` - OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Zhipu ProviderConfig `json:"zhipu"` - VLLM ProviderConfig `json:"vllm"` - Gemini ProviderConfig `json:"gemini"` - Nvidia ProviderConfig `json:"nvidia"` - Moonshot ProviderConfig `json:"moonshot"` - ShengSuanYun ProviderConfig `json:"shengsuanyun"` + Anthropic ProviderConfig `json:"anthropic"` + OpenAI ProviderConfig `json:"openai"` + OpenRouter ProviderConfig `json:"openrouter"` + Groq ProviderConfig `json:"groq"` + Zhipu ProviderConfig `json:"zhipu"` + VLLM ProviderConfig `json:"vllm"` + Gemini ProviderConfig `json:"gemini"` + Nvidia ProviderConfig `json:"nvidia"` + Moonshot ProviderConfig `json:"moonshot"` + ShengSuanYun ProviderConfig `json:"shengsuanyun"` + DeepSeek ProviderConfig `json:"deepseek"` + GitHubCopilot ProviderConfig `json:"github_copilot"` } 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"` + 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 GatewayConfig struct { @@ -245,6 +275,23 @@ func DefaultConfig() *Config { AppToken: "", AllowFrom: []string{}, }, + LINE: LINEConfig{ + Enabled: false, + ChannelSecret: "", + ChannelAccessToken: "", + WebhookHost: "0.0.0.0", + WebhookPort: 18791, + WebhookPath: "/webhook/line", + AllowFrom: FlexibleStringSlice{}, + }, + OneBot: OneBotConfig{ + Enabled: false, + WSUrl: "ws://127.0.0.1:3001", + AccessToken: "", + ReconnectInterval: 5, + GroupTriggerPrefix: []string{}, + AllowFrom: FlexibleStringSlice{}, + }, }, Providers: ProvidersConfig{ Anthropic: ProviderConfig{}, @@ -279,6 +326,10 @@ func DefaultConfig() *Config { Enabled: true, Interval: 30, // default 30 minutes }, + Devices: DevicesConfig{ + Enabled: false, + MonitorUSB: true, + }, } } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 0a5e7b5..14618b1 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -136,11 +136,14 @@ func TestDefaultConfig_WebTools(t *testing.T) { cfg := DefaultConfig() // Verify web tools defaults - if cfg.Tools.Web.Search.MaxResults != 5 { - t.Error("Expected MaxResults 5, got ", cfg.Tools.Web.Search.MaxResults) + if cfg.Tools.Web.Brave.MaxResults != 5 { + t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) } - if cfg.Tools.Web.Search.APIKey != "" { - t.Error("Search API key should be empty by default") + if cfg.Tools.Web.Brave.APIKey != "" { + t.Error("Brave API key should be empty by default") + } + if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { + t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults) } } diff --git a/pkg/devices/events/events.go b/pkg/devices/events/events.go new file mode 100644 index 0000000..0122617 --- /dev/null +++ b/pkg/devices/events/events.go @@ -0,0 +1,57 @@ +package events + +import "context" + +type EventSource interface { + Kind() Kind + Start(ctx context.Context) (<-chan *DeviceEvent, error) + Stop() error +} + +type Action string + +const ( + ActionAdd Action = "add" + ActionRemove Action = "remove" + ActionChange Action = "change" +) + +type Kind string + +const ( + KindUSB Kind = "usb" + KindBluetooth Kind = "bluetooth" + KindPCI Kind = "pci" + KindGeneric Kind = "generic" +) + +type DeviceEvent struct { + Action Action + Kind Kind + DeviceID string // e.g. "1-2" for USB bus 1 dev 2 + Vendor string // Vendor name or ID + Product string // Product name or ID + Serial string // Serial number if available + Capabilities string // Human-readable capability description + Raw map[string]string // Raw properties for extensibility +} + +func (e *DeviceEvent) FormatMessage() string { + actionEmoji := "🔌" + actionText := "Connected" + if e.Action == ActionRemove { + actionEmoji = "🔌" + actionText = "Disconnected" + } + + msg := actionEmoji + " Device " + actionText + "\n\n" + msg += "Type: " + string(e.Kind) + "\n" + msg += "Device: " + e.Vendor + " " + e.Product + "\n" + if e.Capabilities != "" { + msg += "Capabilities: " + e.Capabilities + "\n" + } + if e.Serial != "" { + msg += "Serial: " + e.Serial + "\n" + } + return msg +} diff --git a/pkg/devices/service.go b/pkg/devices/service.go new file mode 100644 index 0000000..05a2547 --- /dev/null +++ b/pkg/devices/service.go @@ -0,0 +1,152 @@ +package devices + +import ( + "context" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/devices/events" + "github.com/sipeed/picoclaw/pkg/devices/sources" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/state" +) + +type Service struct { + bus *bus.MessageBus + state *state.Manager + sources []events.EventSource + enabled bool + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex +} + +type Config struct { + Enabled bool + MonitorUSB bool // When true, monitor USB hotplug (Linux only) + // Future: MonitorBluetooth, MonitorPCI, etc. +} + +func NewService(cfg Config, stateMgr *state.Manager) *Service { + s := &Service{ + state: stateMgr, + enabled: cfg.Enabled, + sources: make([]EventSource, 0), + } + + if cfg.Enabled && cfg.MonitorUSB { + s.sources = append(s.sources, sources.NewUSBMonitor()) + } + + return s +} + +func (s *Service) SetBus(msgBus *bus.MessageBus) { + s.mu.Lock() + defer s.mu.Unlock() + s.bus = msgBus +} + +func (s *Service) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.enabled || len(s.sources) == 0 { + logger.InfoC("devices", "Device event service disabled or no sources") + return nil + } + + s.ctx, s.cancel = context.WithCancel(ctx) + + for _, src := range s.sources { + eventCh, err := src.Start(s.ctx) + if err != nil { + logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{ + "kind": src.Kind(), + "error": err.Error(), + }) + continue + } + go s.handleEvents(src.Kind(), eventCh) + logger.InfoCF("devices", "Device source started", map[string]interface{}{ + "kind": src.Kind(), + }) + } + + logger.InfoC("devices", "Device event service started") + return nil +} + +func (s *Service) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cancel != nil { + s.cancel() + s.cancel = nil + } + + for _, src := range s.sources { + src.Stop() + } + + logger.InfoC("devices", "Device event service stopped") +} + +func (s *Service) handleEvents(kind events.Kind, eventCh <-chan *events.DeviceEvent) { + for ev := range eventCh { + if ev == nil { + continue + } + s.sendNotification(ev) + } +} + +func (s *Service) sendNotification(ev *events.DeviceEvent) { + s.mu.RLock() + msgBus := s.bus + s.mu.RUnlock() + + if msgBus == nil { + return + } + + lastChannel := s.state.GetLastChannel() + if lastChannel == "" { + logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{ + "event": ev.FormatMessage(), + }) + return + } + + platform, userID := parseLastChannel(lastChannel) + if platform == "" || userID == "" || constants.IsInternalChannel(platform) { + return + } + + msg := ev.FormatMessage() + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: platform, + ChatID: userID, + Content: msg, + }) + + logger.InfoCF("devices", "Device notification sent", map[string]interface{}{ + "kind": ev.Kind, + "action": ev.Action, + "to": platform, + }) +} + +func parseLastChannel(lastChannel string) (platform, userID string) { + if lastChannel == "" { + return "", "" + } + parts := strings.SplitN(lastChannel, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "" + } + return parts[0], parts[1] +} diff --git a/pkg/devices/source.go b/pkg/devices/source.go new file mode 100644 index 0000000..cbf0a7d --- /dev/null +++ b/pkg/devices/source.go @@ -0,0 +1,5 @@ +package devices + +import "github.com/sipeed/picoclaw/pkg/devices/events" + +type EventSource = events.EventSource diff --git a/pkg/devices/sources/usb_linux.go b/pkg/devices/sources/usb_linux.go new file mode 100644 index 0000000..1f6c068 --- /dev/null +++ b/pkg/devices/sources/usb_linux.go @@ -0,0 +1,198 @@ +//go:build linux + +package sources + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/devices/events" + "github.com/sipeed/picoclaw/pkg/logger" +) + +var usbClassToCapability = map[string]string{ + "00": "Interface Definition (by interface)", + "01": "Audio", + "02": "CDC Communication (Network Card/Modem)", + "03": "HID (Keyboard/Mouse/Gamepad)", + "05": "Physical Interface", + "06": "Image (Scanner/Camera)", + "07": "Printer", + "08": "Mass Storage (USB Flash Drive/Hard Disk)", + "09": "USB Hub", + "0a": "CDC Data", + "0b": "Smart Card", + "0e": "Video (Camera)", + "dc": "Diagnostic Device", + "e0": "Wireless Controller (Bluetooth)", + "ef": "Miscellaneous", + "fe": "Application Specific", + "ff": "Vendor Specific", +} + +type USBMonitor struct { + cmd *exec.Cmd + cancel context.CancelFunc + mu sync.Mutex +} + +func NewUSBMonitor() *USBMonitor { + return &USBMonitor{} +} + +func (m *USBMonitor) Kind() events.Kind { + return events.KindUSB +} + +func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // udevadm monitor outputs: UDEV/KERNEL [timestamp] action devpath (subsystem) + // Followed by KEY=value lines, empty line separates events + // Use -s/--subsystem-match (eudev) or --udev-subsystem-match (systemd udev) + cmd := exec.CommandContext(ctx, "udevadm", "monitor", "--property", "--subsystem-match=usb") + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("udevadm stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("udevadm start: %w (is udevadm installed?)", err) + } + + m.cmd = cmd + eventCh := make(chan *events.DeviceEvent, 16) + + go func() { + defer close(eventCh) + scanner := bufio.NewScanner(stdout) + var props map[string]string + var action string + isUdev := false // Only UDEV events have complete info (ID_VENDOR, ID_MODEL); KERNEL events come first with less info + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + // End of event block - only process UDEV events (skip KERNEL to avoid duplicate/incomplete notifications) + if isUdev && props != nil && (action == "add" || action == "remove") { + if ev := parseUSBEvent(action, props); ev != nil { + select { + case eventCh <- ev: + case <-ctx.Done(): + return + } + } + } + props = nil + action = "" + isUdev = false + continue + } + + idx := strings.Index(line, "=") + // First line of block: "UDEV [ts] action devpath" or "KERNEL[ts] action devpath" - no KEY=value + if idx <= 0 { + isUdev = strings.HasPrefix(strings.TrimSpace(line), "UDEV") + continue + } + + // Parse KEY=value + key := line[:idx] + val := line[idx+1:] + if props == nil { + props = make(map[string]string) + } + props[key] = val + + if key == "ACTION" { + action = val + } + } + + if err := scanner.Err(); err != nil { + logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()}) + } + cmd.Wait() + }() + + return eventCh, nil +} + +func (m *USBMonitor) Stop() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.cmd != nil && m.cmd.Process != nil { + m.cmd.Process.Kill() + m.cmd = nil + } + return nil +} + +func parseUSBEvent(action string, props map[string]string) *events.DeviceEvent { + // Only care about add/remove for physical devices (not interfaces) + subsystem := props["SUBSYSTEM"] + if subsystem != "usb" { + return nil + } + // Skip interface events - we want device-level only to avoid duplicates + devType := props["DEVTYPE"] + if devType == "usb_interface" { + return nil + } + // Prefer usb_device, but accept if DEVTYPE not set (varies by udev version) + if devType != "" && devType != "usb_device" { + return nil + } + + ev := &events.DeviceEvent{ + Raw: props, + } + switch action { + case "add": + ev.Action = events.ActionAdd + case "remove": + ev.Action = events.ActionRemove + default: + return nil + } + ev.Kind = events.KindUSB + + ev.Vendor = props["ID_VENDOR"] + if ev.Vendor == "" { + ev.Vendor = props["ID_VENDOR_ID"] + } + if ev.Vendor == "" { + ev.Vendor = "Unknown Vendor" + } + + ev.Product = props["ID_MODEL"] + if ev.Product == "" { + ev.Product = props["ID_MODEL_ID"] + } + if ev.Product == "" { + ev.Product = "Unknown Device" + } + + ev.Serial = props["ID_SERIAL_SHORT"] + ev.DeviceID = props["DEVPATH"] + if bus := props["BUSNUM"]; bus != "" { + if dev := props["DEVNUM"]; dev != "" { + ev.DeviceID = bus + ":" + dev + } + } + + // Map USB class to capability + if class := props["ID_USB_CLASS"]; class != "" { + ev.Capabilities = usbClassToCapability[strings.ToLower(class)] + } + if ev.Capabilities == "" { + ev.Capabilities = "USB Device" + } + + return ev +} diff --git a/pkg/devices/sources/usb_stub.go b/pkg/devices/sources/usb_stub.go new file mode 100644 index 0000000..f08c2d4 --- /dev/null +++ b/pkg/devices/sources/usb_stub.go @@ -0,0 +1,29 @@ +//go:build !linux + +package sources + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/devices/events" +) + +type USBMonitor struct{} + +func NewUSBMonitor() *USBMonitor { + return &USBMonitor{} +} + +func (m *USBMonitor) Kind() events.Kind { + return events.KindUSB +} + +func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) { + ch := make(chan *events.DeviceEvent) + close(ch) // Immediately close, no events + return ch, nil +} + +func (m *USBMonitor) Stop() error { + return nil +} diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go new file mode 100644 index 0000000..5058819 --- /dev/null +++ b/pkg/providers/github_copilot_provider.go @@ -0,0 +1,82 @@ +package providers + +import ( + "context" + "fmt" + + json "encoding/json" + + copilot "github.com/github/copilot-sdk/go" +) + +type GitHubCopilotProvider struct { + uri string + connectMode string // `stdio` or `grpc`` + + session *copilot.Session +} + +func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) { + + var session *copilot.Session + if connectMode == "" { + connectMode = "grpc" + } + switch connectMode { + + case "stdio": + //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") + } + defer client.Stop() + session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{ + Model: model, + Hooks: &copilot.SessionHooks{}, + }) + + } + + return &GitHubCopilotProvider{ + uri: uri, + connectMode: connectMode, + session: session, + }, nil +} + +// Chat sends a chat request to GitHub Copilot +func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { + type tempMessage struct { + Role string `json:"role"` + Content string `json:"content"` + } + out := make([]tempMessage, 0, len(messages)) + + for _, msg := range messages { + out = append(out, tempMessage{ + Role: msg.Role, + Content: msg.Content, + }) + } + + fullcontent, _ := json.Marshal(out) + + content, _ := p.session.Send(ctx, copilot.MessageOptions{ + Prompt: string(fullcontent), + }) + + return &LLMResponse{ + 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 fc78a18..0bea16d 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -303,7 +303,27 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { workspace = "." } return NewClaudeCliProvider(workspace), nil + case "deepseek": + if cfg.Providers.DeepSeek.APIKey != "" { + apiKey = cfg.Providers.DeepSeek.APIKey + apiBase = cfg.Providers.DeepSeek.APIBase + if apiBase == "" { + apiBase = "https://api.deepseek.com/v1" + } + if model != "deepseek-chat" && model != "deepseek-reasoner" { + model = "deepseek-chat" + } + } + case "github_copilot", "copilot": + if cfg.Providers.GitHubCopilot.APIBase != "" { + apiBase = cfg.Providers.GitHubCopilot.APIBase + } else { + apiBase = "localhost:4321" + } + return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model) + } + } // Fallback: detect provider from model name diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go new file mode 100644 index 0000000..abca5ec --- /dev/null +++ b/pkg/tools/i2c.go @@ -0,0 +1,147 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "runtime" +) + +// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals. +type I2CTool struct{} + +func NewI2CTool() *I2CTool { + return &I2CTool{} +} + +func (t *I2CTool) Name() string { + return "i2c" +} + +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{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "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{}{ + "type": "string", + "description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.", + }, + "address": map[string]interface{}{ + "type": "integer", + "description": "7-bit I2C device address (0x03-0x77). Required for read/write.", + }, + "register": map[string]interface{}{ + "type": "integer", + "description": "Register address to read from or write to. If set, sends register byte before read/write.", + }, + "data": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "integer"}, + "description": "Bytes to write (0-255 each). Required for write action.", + }, + "length": map[string]interface{}{ + "type": "integer", + "description": "Number of bytes to read (1-256). Default: 1. Used with read action.", + }, + "confirm": map[string]interface{}{ + "type": "boolean", + "description": "Must be true for write operations. Safety guard to prevent accidental writes.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *I2CTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if runtime.GOOS != "linux" { + return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.") + } + + action, ok := args["action"].(string) + if !ok { + return ErrorResult("action is required") + } + + switch action { + case "detect": + return t.detect() + case "scan": + return t.scan(args) + case "read": + return t.readDevice(args) + case "write": + return t.writeDevice(args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action)) + } +} + +// detect lists available I2C buses by globbing /dev/i2c-* +func (t *I2CTool) detect() *ToolResult { + matches, err := filepath.Glob("/dev/i2c-*") + if err != nil { + return ErrorResult(fmt.Sprintf("failed to scan for I2C buses: %v", err)) + } + + 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)") + } + + type busInfo struct { + Path string `json:"path"` + Bus string `json:"bus"` + } + + buses := make([]busInfo, 0, len(matches)) + re := regexp.MustCompile(`/dev/i2c-(\d+)`) + for _, m := range matches { + if sub := re.FindStringSubmatch(m); sub != nil { + buses = append(buses, busInfo{Path: m, Bus: sub[1]}) + } + } + + result, _ := json.MarshalIndent(buses, "", " ") + return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result))) +} + +// isValidBusID checks that a bus identifier is a simple number (prevents path injection) +func isValidBusID(id string) bool { + matched, _ := regexp.MatchString(`^\d+$`, id) + return matched +} + +// parseI2CAddress extracts and validates an I2C address from args +func parseI2CAddress(args map[string]interface{}) (int, *ToolResult) { + addrFloat, ok := args["address"].(float64) + if !ok { + return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)") + } + addr := int(addrFloat) + if addr < 0x03 || addr > 0x77 { + return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)") + } + return addr, nil +} + +// parseI2CBus extracts and validates an I2C bus from args +func parseI2CBus(args map[string]interface{}) (string, *ToolResult) { + bus, ok := args["bus"].(string) + if !ok || bus == "" { + return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)") + } + if !isValidBusID(bus) { + return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")") + } + return bus, nil +} diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go new file mode 100644 index 0000000..294f7ec --- /dev/null +++ b/pkg/tools/i2c_linux.go @@ -0,0 +1,282 @@ +package tools + +import ( + "encoding/json" + "fmt" + "syscall" + "unsafe" +) + +// I2C ioctl constants from Linux kernel headers (, ) +const ( + i2cSlave = 0x0703 // Set slave address (fails if in use by driver) + i2cFuncs = 0x0705 // Query adapter functionality bitmask + i2cSmbus = 0x0720 // Perform SMBus transaction + + // I2C_FUNC capability bits + i2cFuncSmbusQuick = 0x00010000 + i2cFuncSmbusReadByte = 0x00020000 + + // SMBus transaction types + i2cSmbusRead = 0 + i2cSmbusWrite = 1 + + // SMBus protocol sizes + i2cSmbusQuick = 0 + i2cSmbusByte = 1 +) + +// i2cSmbusData matches the kernel union i2c_smbus_data (34 bytes max). +// For quick and byte transactions only the first byte is used (if at all). +type i2cSmbusData [34]byte + +// i2cSmbusArgs matches the kernel struct i2c_smbus_ioctl_data. +type i2cSmbusArgs struct { + readWrite uint8 + command uint8 + size uint32 + data *i2cSmbusData +} + +// smbusProbe performs a single SMBus probe at the given address. +// Uses SMBus Quick Write (safest) or falls back to SMBus Read Byte for +// EEPROM address ranges where quick write can corrupt AT24RF08 chips. +// This matches i2cdetect's MODE_AUTO behavior. +func smbusProbe(fd int, addr int, hasQuick bool) bool { + // EEPROM ranges: use read byte (quick write can corrupt AT24RF08) + useReadByte := (addr >= 0x30 && addr <= 0x37) || (addr >= 0x50 && addr <= 0x5F) + + if !useReadByte && hasQuick { + // SMBus Quick Write: [START] [ADDR|W] [ACK/NACK] [STOP] + // Safest probe — no data transferred + args := i2cSmbusArgs{ + readWrite: i2cSmbusWrite, + command: 0, + size: i2cSmbusQuick, + data: nil, + } + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) + return errno == 0 + } + + // SMBus Read Byte: [START] [ADDR|R] [ACK/NACK] [DATA] [STOP] + var data i2cSmbusData + args := i2cSmbusArgs{ + readWrite: i2cSmbusRead, + command: 0, + size: i2cSmbusByte, + data: &data, + } + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) + return errno == 0 +} + +// 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 { + bus, errResult := parseI2CBus(args) + if errResult != nil { + return errResult + } + + devPath := fmt.Sprintf("/dev/i2c-%s", bus) + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and i2c-dev module)", devPath, err)) + } + defer syscall.Close(fd) + + // Query adapter capabilities to determine available probe methods. + // I2C_FUNCS writes an unsigned long, which is word-sized on Linux. + var funcs uintptr + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cFuncs, uintptr(unsafe.Pointer(&funcs))) + if errno != 0 { + return ErrorResult(fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno)) + } + + hasQuick := funcs&i2cFuncSmbusQuick != 0 + 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)) + } + + type deviceEntry struct { + Address string `json:"address"` + Status string `json:"status,omitempty"` + } + + var found []deviceEntry + // Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07 + for addr := 0x08; addr <= 0x77; addr++ { + // Set slave address — EBUSY means a kernel driver owns this address + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) + if errno != 0 { + if errno == syscall.EBUSY { + found = append(found, deviceEntry{ + Address: fmt.Sprintf("0x%02x", addr), + Status: "busy (in use by kernel driver)", + }) + } + continue + } + + if smbusProbe(fd, addr, hasQuick) { + found = append(found, deviceEntry{ + Address: fmt.Sprintf("0x%02x", addr), + }) + } + } + + if len(found) == 0 { + return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath)) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "bus": devPath, + "devices": found, + "count": len(found), + }, "", " ") + return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result))) +} + +// readDevice reads bytes from an I2C device, optionally at a specific register +func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult { + bus, errResult := parseI2CBus(args) + if errResult != nil { + return errResult + } + + addr, errResult := parseI2CAddress(args) + if errResult != nil { + return errResult + } + + length := 1 + if l, ok := args["length"].(float64); ok { + length = int(l) + } + if length < 1 || length > 256 { + return ErrorResult("length must be between 1 and 256") + } + + devPath := fmt.Sprintf("/dev/i2c-%s", bus) + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err)) + } + defer syscall.Close(fd) + + // Set slave address + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) + if errno != 0 { + return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno)) + } + + // If register is specified, write it first + if regFloat, ok := args["register"].(float64); ok { + reg := int(regFloat) + if reg < 0 || reg > 255 { + return ErrorResult("register must be between 0x00 and 0xFF") + } + _, err := syscall.Write(fd, []byte{byte(reg)}) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err)) + } + } + + // Read data + buf := make([]byte, length) + n, err := syscall.Read(fd, buf) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read from device 0x%02x: %v", addr, err)) + } + + // Format as hex bytes + hexBytes := make([]string, n) + intBytes := make([]int, n) + for i := 0; i < n; i++ { + hexBytes[i] = fmt.Sprintf("0x%02x", buf[i]) + intBytes[i] = int(buf[i]) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "bus": devPath, + "address": fmt.Sprintf("0x%02x", addr), + "bytes": intBytes, + "hex": hexBytes, + "length": n, + }, "", " ") + return SilentResult(string(result)) +} + +// writeDevice writes bytes to an I2C device, optionally at a specific register +func (t *I2CTool) writeDevice(args map[string]interface{}) *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.") + } + + bus, errResult := parseI2CBus(args) + if errResult != nil { + return errResult + } + + addr, errResult := parseI2CAddress(args) + if errResult != nil { + return errResult + } + + dataRaw, ok := args["data"].([]interface{}) + if !ok || len(dataRaw) == 0 { + return ErrorResult("data is required for write (array of byte values 0-255)") + } + if len(dataRaw) > 256 { + return ErrorResult("data too long: maximum 256 bytes per I2C transaction") + } + + data := make([]byte, 0, len(dataRaw)+1) + + // If register is specified, prepend it to the data + if regFloat, ok := args["register"].(float64); ok { + reg := int(regFloat) + if reg < 0 || reg > 255 { + return ErrorResult("register must be between 0x00 and 0xFF") + } + data = append(data, byte(reg)) + } + + for i, v := range dataRaw { + f, ok := v.(float64) + if !ok { + return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i)) + } + b := int(f) + if b < 0 || b > 255 { + return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b)) + } + data = append(data, byte(b)) + } + + devPath := fmt.Sprintf("/dev/i2c-%s", bus) + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err)) + } + defer syscall.Close(fd) + + // Set slave address + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) + if errno != 0 { + return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno)) + } + + // Write data + n, err := syscall.Write(fd, data) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to write to device 0x%02x: %v", addr, err)) + } + + return SilentResult(fmt.Sprintf("Wrote %d byte(s) to device 0x%02x on %s", n, addr, devPath)) +} diff --git a/pkg/tools/i2c_other.go b/pkg/tools/i2c_other.go new file mode 100644 index 0000000..d1d5813 --- /dev/null +++ b/pkg/tools/i2c_other.go @@ -0,0 +1,18 @@ +//go:build !linux + +package tools + +// scan is a stub for non-Linux platforms. +func (t *I2CTool) scan(args map[string]interface{}) *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 { + 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 { + return ErrorResult("I2C is only supported on Linux") +} diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go new file mode 100644 index 0000000..4805d6a --- /dev/null +++ b/pkg/tools/spi.go @@ -0,0 +1,156 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "runtime" +) + +// SPITool provides SPI bus interaction for high-speed peripheral communication. +type SPITool struct{} + +func NewSPITool() *SPITool { + return &SPITool{} +} + +func (t *SPITool) Name() string { + return "spi" +} + +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{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "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{}{ + "type": "string", + "description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.", + }, + "speed": map[string]interface{}{ + "type": "integer", + "description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).", + }, + "mode": map[string]interface{}{ + "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{}{ + "type": "integer", + "description": "Bits per word. Default: 8.", + }, + "data": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "integer"}, + "description": "Bytes to send (0-255 each). Required for transfer action.", + }, + "length": map[string]interface{}{ + "type": "integer", + "description": "Number of bytes to read (1-4096). Required for read action.", + }, + "confirm": map[string]interface{}{ + "type": "boolean", + "description": "Must be true for transfer operations. Safety guard to prevent accidental writes.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *SPITool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if runtime.GOOS != "linux" { + return ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.") + } + + action, ok := args["action"].(string) + if !ok { + return ErrorResult("action is required") + } + + switch action { + case "list": + return t.list() + case "transfer": + return t.transfer(args) + case "read": + return t.readDevice(args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, transfer, read)", action)) + } +} + +// list finds available SPI devices by globbing /dev/spidev* +func (t *SPITool) list() *ToolResult { + matches, err := filepath.Glob("/dev/spidev*") + if err != nil { + return ErrorResult(fmt.Sprintf("failed to scan for SPI devices: %v", err)) + } + + 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") + } + + type devInfo struct { + Path string `json:"path"` + Device string `json:"device"` + } + + devices := make([]devInfo, 0, len(matches)) + re := regexp.MustCompile(`/dev/spidev(\d+\.\d+)`) + for _, m := range matches { + if sub := re.FindStringSubmatch(m); sub != nil { + devices = append(devices, devInfo{Path: m, Device: sub[1]}) + } + } + + result, _ := json.MarshalIndent(devices, "", " ") + return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result))) +} + +// parseSPIArgs extracts and validates common SPI parameters +func parseSPIArgs(args map[string]interface{}) (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)" + } + matched, _ := regexp.MatchString(`^\d+\.\d+$`, dev) + if !matched { + return "", 0, 0, 0, "invalid device identifier: must be in format \"X.Y\" (e.g. \"2.0\")" + } + + speed = 1000000 // default 1 MHz + if s, ok := args["speed"].(float64); ok { + if s < 1 || s > 125000000 { + return "", 0, 0, 0, "speed must be between 1 Hz and 125 MHz" + } + speed = uint32(s) + } + + mode = 0 + if m, ok := args["mode"].(float64); ok { + if int(m) < 0 || int(m) > 3 { + return "", 0, 0, 0, "mode must be 0-3" + } + mode = uint8(m) + } + + bits = 8 + if b, ok := args["bits"].(float64); ok { + if int(b) < 1 || int(b) > 32 { + return "", 0, 0, 0, "bits must be between 1 and 32" + } + bits = uint8(b) + } + + return dev, speed, mode, bits, "" +} diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go new file mode 100644 index 0000000..12b6960 --- /dev/null +++ b/pkg/tools/spi_linux.go @@ -0,0 +1,196 @@ +package tools + +import ( + "encoding/json" + "fmt" + "runtime" + "syscall" + "unsafe" +) + +// SPI ioctl constants from Linux kernel headers. +// Calculated from _IOW('k', nr, size) macro: +// +// direction(1)<<30 | size<<16 | type(0x6B)<<8 | nr +const ( + spiIocWrMode = 0x40016B01 // _IOW('k', 1, __u8) + spiIocWrBitsPerWord = 0x40016B03 // _IOW('k', 3, __u8) + spiIocWrMaxSpeedHz = 0x40046B04 // _IOW('k', 4, __u32) + spiIocMessage1 = 0x40206B00 // _IOW('k', 0, struct spi_ioc_transfer) — 32 bytes +) + +// spiTransfer matches Linux kernel struct spi_ioc_transfer (32 bytes on all architectures). +type spiTransfer struct { + txBuf uint64 + rxBuf uint64 + length uint32 + speedHz uint32 + delayUsecs uint16 + bitsPerWord uint8 + csChange uint8 + txNbits uint8 + rxNbits uint8 + wordDelay uint8 + pad uint8 +} + +// configureSPI opens an SPI device and sets mode, bits per word, and speed +func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) { + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err)) + } + + // Set SPI mode + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMode, uintptr(unsafe.Pointer(&mode))) + if errno != 0 { + syscall.Close(fd) + return -1, ErrorResult(fmt.Sprintf("failed to set SPI mode %d: %v", mode, errno)) + } + + // Set bits per word + _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrBitsPerWord, uintptr(unsafe.Pointer(&bits))) + if errno != 0 { + syscall.Close(fd) + return -1, ErrorResult(fmt.Sprintf("failed to set bits per word %d: %v", bits, errno)) + } + + // Set max speed + _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMaxSpeedHz, uintptr(unsafe.Pointer(&speed))) + if errno != 0 { + syscall.Close(fd) + return -1, ErrorResult(fmt.Sprintf("failed to set SPI speed %d Hz: %v", speed, errno)) + } + + return fd, nil +} + +// transfer performs a full-duplex SPI transfer +func (t *SPITool) transfer(args map[string]interface{}) *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.") + } + + dev, speed, mode, bits, errMsg := parseSPIArgs(args) + if errMsg != "" { + return ErrorResult(errMsg) + } + + dataRaw, ok := args["data"].([]interface{}) + if !ok || len(dataRaw) == 0 { + return ErrorResult("data is required for transfer (array of byte values 0-255)") + } + if len(dataRaw) > 4096 { + return ErrorResult("data too long: maximum 4096 bytes per SPI transfer") + } + + txBuf := make([]byte, len(dataRaw)) + for i, v := range dataRaw { + f, ok := v.(float64) + if !ok { + return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i)) + } + b := int(f) + if b < 0 || b > 255 { + return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b)) + } + txBuf[i] = byte(b) + } + + devPath := fmt.Sprintf("/dev/spidev%s", dev) + fd, errResult := configureSPI(devPath, mode, bits, speed) + if errResult != nil { + return errResult + } + defer syscall.Close(fd) + + rxBuf := make([]byte, len(txBuf)) + + xfer := spiTransfer{ + txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), + rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), + length: uint32(len(txBuf)), + speedHz: speed, + bitsPerWord: bits, + } + + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) + runtime.KeepAlive(txBuf) + runtime.KeepAlive(rxBuf) + if errno != 0 { + return ErrorResult(fmt.Sprintf("SPI transfer failed: %v", errno)) + } + + // Format received bytes + hexBytes := make([]string, len(rxBuf)) + intBytes := make([]int, len(rxBuf)) + for i, b := range rxBuf { + hexBytes[i] = fmt.Sprintf("0x%02x", b) + intBytes[i] = int(b) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "device": devPath, + "sent": len(txBuf), + "received": intBytes, + "hex": hexBytes, + }, "", " ") + return SilentResult(string(result)) +} + +// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed) +func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult { + dev, speed, mode, bits, errMsg := parseSPIArgs(args) + if errMsg != "" { + return ErrorResult(errMsg) + } + + length := 0 + if l, ok := args["length"].(float64); ok { + length = int(l) + } + if length < 1 || length > 4096 { + return ErrorResult("length is required for read (1-4096)") + } + + devPath := fmt.Sprintf("/dev/spidev%s", dev) + fd, errResult := configureSPI(devPath, mode, bits, speed) + if errResult != nil { + return errResult + } + defer syscall.Close(fd) + + txBuf := make([]byte, length) // zeros + rxBuf := make([]byte, length) + + xfer := spiTransfer{ + txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), + rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), + length: uint32(length), + speedHz: speed, + bitsPerWord: bits, + } + + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) + runtime.KeepAlive(txBuf) + runtime.KeepAlive(rxBuf) + if errno != 0 { + return ErrorResult(fmt.Sprintf("SPI read failed: %v", errno)) + } + + hexBytes := make([]string, len(rxBuf)) + intBytes := make([]int, len(rxBuf)) + for i, b := range rxBuf { + hexBytes[i] = fmt.Sprintf("0x%02x", b) + intBytes[i] = int(b) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "device": devPath, + "bytes": intBytes, + "hex": hexBytes, + "length": len(rxBuf), + }, "", " ") + return SilentResult(string(result)) +} diff --git a/pkg/tools/spi_other.go b/pkg/tools/spi_other.go new file mode 100644 index 0000000..6dfc86f --- /dev/null +++ b/pkg/tools/spi_other.go @@ -0,0 +1,13 @@ +//go:build !linux + +package tools + +// transfer is a stub for non-Linux platforms. +func (t *SPITool) transfer(args map[string]interface{}) *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 { + return ErrorResult("SPI is only supported on Linux") +} diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 6fc89c9..ccd9958 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -114,7 +114,7 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) { // Simple regex based extraction for DDG HTML // Strategy: Find all result containers or key anchors directly - + // Try finding the result links directly first, as they are the most critical // Pattern: Title // The previous regex was a bit strict. Let's make it more flexible for attributes order/content @@ -133,14 +133,14 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query // But simple global search for snippets might mismatch order. // Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex) // Or better: Let's assume the snippet follows the link in the HTML - + // A better regex approach: iterate through text and find matches in order // But for now, let's grab all snippets too reSnippet := regexp.MustCompile(`([\s\S]*?)`) snippetMatches := reSnippet.FindAllStringSubmatch(html, count+5) maxItems := min(len(matches), count) - + for i := 0; i < maxItems; i++ { urlStr := matches[i][1] title := stripTags(matches[i][2]) @@ -157,7 +157,7 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query } lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr)) - + // Attempt to attach snippet if available and index aligns if i < len(snippetMatches) { snippet := stripTags(snippetMatches[i][1]) @@ -171,13 +171,6 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query return strings.Join(lines, "\n"), nil } -func min(a, b int) int { - if a < b { - return a - } - return b -} - func stripTags(content string) string { re := regexp.MustCompile(`<[^>]+>`) return re.ReplaceAllString(content, "") diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index 30bc7d9..988eada 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -173,30 +173,19 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { } } -// TestWebTool_WebSearch_NoApiKey verifies error handling when API key is missing +// TestWebTool_WebSearch_NoApiKey verifies that nil is returned when no provider is configured func TestWebTool_WebSearch_NoApiKey(t *testing.T) { - tool := NewWebSearchTool("", 5) - ctx := context.Background() - args := map[string]interface{}{ - "query": "test", - } + tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "", BraveMaxResults: 5}) - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error when API key is missing") - } - - // Should mention missing API key - if !strings.Contains(result.ForLLM, "BRAVE_API_KEY") && !strings.Contains(result.ForUser, "BRAVE_API_KEY") { - t.Errorf("Expected API key error message, got ForLLM: %s", result.ForLLM) + // Should return nil when no provider is enabled + if tool != nil { + t.Errorf("Expected nil when no search provider is configured") } } // TestWebTool_WebSearch_MissingQuery verifies error handling for missing query func TestWebTool_WebSearch_MissingQuery(t *testing.T) { - tool := NewWebSearchTool("test-key", 5) + tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "test-key", BraveMaxResults: 5, BraveEnabled: true}) ctx := context.Background() args := map[string]interface{}{} diff --git a/workspace/AGENT.md b/workspace/AGENT.md new file mode 100644 index 0000000..5f5fa64 --- /dev/null +++ b/workspace/AGENT.md @@ -0,0 +1,12 @@ +# Agent Instructions + +You are a helpful AI assistant. Be concise, accurate, and friendly. + +## Guidelines + +- Always explain what you're doing before taking actions +- Ask for clarification when request is ambiguous +- Use tools to help accomplish tasks +- Remember important information in your memory files +- Be proactive and helpful +- Learn from user feedback \ No newline at end of file diff --git a/workspace/IDENTITY.md b/workspace/IDENTITY.md new file mode 100644 index 0000000..dabb0e1 --- /dev/null +++ b/workspace/IDENTITY.md @@ -0,0 +1,56 @@ +# Identity + +## Name +PicoClaw 🦞 + +## Description +Ultra-lightweight personal AI assistant written in Go, inspired by nanobot. + +## Version +0.1.0 + +## Purpose +- Provide intelligent AI assistance with minimal resource usage +- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.) +- Enable easy customization through skills system +- Run on minimal hardware ($10 boards, <10MB RAM) + +## Capabilities + +- Web search and content fetching +- File system operations (read, write, edit) +- Shell command execution +- Multi-channel messaging (Telegram, WhatsApp, Feishu) +- Skill-based extensibility +- Memory and context management + +## Philosophy + +- Simplicity over complexity +- Performance over features +- User control and privacy +- Transparent operation +- Community-driven development + +## Goals + +- Provide a fast, lightweight AI assistant +- Support offline-first operation where possible +- Enable easy customization and extension +- Maintain high quality responses +- Run efficiently on constrained hardware + +## License +MIT License - Free and open source + +## Repository +https://github.com/sipeed/picoclaw + +## Contact +Issues: https://github.com/sipeed/picoclaw/issues +Discussions: https://github.com/sipeed/picoclaw/discussions + +--- + +"Every bit helps, every bit matters." +- Picoclaw \ No newline at end of file diff --git a/workspace/SOUL.md b/workspace/SOUL.md new file mode 100644 index 0000000..0be8834 --- /dev/null +++ b/workspace/SOUL.md @@ -0,0 +1,17 @@ +# Soul + +I am picoclaw, a lightweight AI assistant powered by AI. + +## Personality + +- Helpful and friendly +- Concise and to the point +- Curious and eager to learn +- Honest and transparent + +## Values + +- Accuracy over speed +- User privacy and safety +- Transparency in actions +- Continuous improvement \ No newline at end of file diff --git a/workspace/USER.md b/workspace/USER.md new file mode 100644 index 0000000..91398a0 --- /dev/null +++ b/workspace/USER.md @@ -0,0 +1,21 @@ +# User + +Information about user goes here. + +## Preferences + +- Communication style: (casual/formal) +- Timezone: (your timezone) +- Language: (your preferred language) + +## Personal Information + +- Name: (optional) +- Location: (optional) +- Occupation: (optional) + +## Learning Goals + +- What the user wants to learn from AI +- Preferred interaction style +- Areas of interest \ No newline at end of file diff --git a/workspace/memory/MEMORY.md b/workspace/memory/MEMORY.md new file mode 100644 index 0000000..265271d --- /dev/null +++ b/workspace/memory/MEMORY.md @@ -0,0 +1,21 @@ +# Long-term Memory + +This file stores important information that should persist across sessions. + +## User Information + +(Important facts about user) + +## Preferences + +(User preferences learned over time) + +## Important Notes + +(Things to remember) + +## Configuration + +- Model preferences +- Channel settings +- Skills enabled \ No newline at end of file diff --git a/skills/github/SKILL.md b/workspace/skills/github/SKILL.md similarity index 100% rename from skills/github/SKILL.md rename to workspace/skills/github/SKILL.md diff --git a/workspace/skills/hardware/SKILL.md b/workspace/skills/hardware/SKILL.md new file mode 100644 index 0000000..e89d1b6 --- /dev/null +++ b/workspace/skills/hardware/SKILL.md @@ -0,0 +1,64 @@ +--- +name: hardware +description: Read and control I2C and SPI peripherals on Sipeed boards (LicheeRV Nano, MaixCAM, NanoKVM). +homepage: https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html +metadata: {"nanobot":{"emoji":"🔧","requires":{"tools":["i2c","spi"]}}} +--- + +# Hardware (I2C / SPI) + +Use the `i2c` and `spi` tools to interact with sensors, displays, and other peripherals connected to the board. + +## Quick Start + +``` +# 1. Find available buses +i2c detect + +# 2. Scan for connected devices +i2c scan (bus: "1") + +# 3. Read from a sensor (e.g. AHT20 temperature/humidity) +i2c read (bus: "1", address: 0x38, register: 0xAC, length: 6) + +# 4. SPI devices +spi list +spi read (device: "2.0", length: 4) +``` + +## Before You Start — Pinmux Setup + +Most I2C/SPI pins are shared with WiFi on Sipeed boards. You must configure pinmux before use. + +See `references/board-pinout.md` for board-specific commands. + +**Common steps:** +1. Stop WiFi if using shared pins: `/etc/init.d/S30wifi stop` +2. Load i2c-dev module: `modprobe i2c-dev` +3. Configure pinmux with `devmem` (board-specific) +4. Verify with `i2c detect` and `i2c scan` + +## Safety + +- **Write operations** require `confirm: true` — always confirm with the user first +- I2C addresses are validated to 7-bit range (0x03-0x77) +- SPI modes are validated (0-3 only) +- Maximum per-transaction: 256 bytes (I2C), 4096 bytes (SPI) + +## Common Devices + +See `references/common-devices.md` for register maps and usage of popular sensors: +AHT20, BME280, SSD1306 OLED, MPU6050 IMU, DS3231 RTC, INA219 power monitor, PCA9685 PWM, and more. + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| No I2C buses found | `modprobe i2c-dev` and check device tree | +| Permission denied | Run as root or add user to `i2c` group | +| No devices on scan | Check wiring, pull-up resistors (4.7k typical), and pinmux | +| Bus number changed | I2C adapter numbers can shift between boots; use `i2c detect` to find current assignment | +| WiFi stopped working | I2C-1/SPI-2 share pins with WiFi SDIO; can't use both simultaneously | +| `devmem` not found | Download separately or use `busybox devmem` | +| SPI transfer returns all zeros | Check MISO wiring and device power | +| SPI transfer returns all 0xFF | Device not responding; check CS pin and clock polarity (mode) | diff --git a/workspace/skills/hardware/references/board-pinout.md b/workspace/skills/hardware/references/board-pinout.md new file mode 100644 index 0000000..827dd06 --- /dev/null +++ b/workspace/skills/hardware/references/board-pinout.md @@ -0,0 +1,131 @@ +# Board Pinout & Pinmux Reference + +## LicheeRV Nano (SG2002) + +### I2C Buses + +| Bus | Pins | Notes | +|-----|------|-------| +| I2C-1 | P18 (SCL), P21 (SDA) | **Shared with WiFi SDIO** — must stop WiFi first | +| I2C-3 | Available on header | Check device tree for pin assignment | +| I2C-5 | Software (BitBang) | Slower but no pin conflicts | + +### SPI Buses + +| Bus | Pins | Notes | +|-----|------|-------| +| SPI-2 | P18 (CS), P21 (MISO), P22 (MOSI), P23 (SCK) | **Shared with WiFi** — must stop WiFi first | +| SPI-4 | Software (BitBang) | Slower but no pin conflicts | + +### Setup Steps for I2C-1 + +```bash +# 1. Stop WiFi (shares pins with I2C-1) +/etc/init.d/S30wifi stop + +# 2. Configure pinmux for I2C-1 +devmem 0x030010D0 b 0x2 # P18 → I2C1_SCL +devmem 0x030010DC b 0x2 # P21 → I2C1_SDA + +# 3. Load i2c-dev module +modprobe i2c-dev + +# 4. Verify +ls /dev/i2c-* +``` + +### Setup Steps for SPI-2 + +```bash +# 1. Stop WiFi (shares pins with SPI-2) +/etc/init.d/S30wifi stop + +# 2. Configure pinmux for SPI-2 +devmem 0x030010D0 b 0x1 # P18 → SPI2_CS +devmem 0x030010DC b 0x1 # P21 → SPI2_MISO +devmem 0x030010E0 b 0x1 # P22 → SPI2_MOSI +devmem 0x030010E4 b 0x1 # P23 → SPI2_SCK + +# 3. Verify +ls /dev/spidev* +``` + +### Max Tested SPI Speed +- SPI-2 hardware: tested up to **93 MHz** +- `spidev_test` is pre-installed on the official image for loopback testing + +--- + +## MaixCAM + +### I2C Buses + +| Bus | Pins | Notes | +|-----|------|-------| +| I2C-1 | Overlaps with WiFi | Not recommended | +| I2C-3 | Overlaps with WiFi | Not recommended | +| I2C-5 | A15 (SCL), A27 (SDA) | **Recommended** — software I2C, no conflicts | + +### Setup Steps for I2C-5 + +```bash +# Configure pins using pinmap utility +# (MaixCAM uses a pinmap tool instead of devmem) +# Refer to: https://wiki.sipeed.com/hardware/en/maixcam/gpio.html + +# Load i2c-dev +modprobe i2c-dev + +# Verify +ls /dev/i2c-* +``` + +--- + +## MaixCAM2 + +### I2C Buses + +| Bus | Pins | Notes | +|-----|------|-------| +| I2C-6 | A1 (SCL), A0 (SDA) | Available on header | +| I2C-7 | Available | Check device tree | + +### Setup Steps + +```bash +# Configure pinmap for I2C-6 +# A1 → I2C6_SCL, A0 → I2C6_SDA +# Refer to MaixCAM2 documentation for pinmap commands + +modprobe i2c-dev +ls /dev/i2c-* +``` + +--- + +## NanoKVM + +Uses the same SG2002 SoC as LicheeRV Nano. GPIO and I2C access follows the same pinmux procedure. Refer to the LicheeRV Nano section above. + +Check NanoKVM-specific pin headers for available I2C/SPI lines: +- https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html + +--- + +## Common Issues + +### devmem not found +The `devmem` utility may not be in the default image. Options: +- Use `busybox devmem` if busybox is installed +- Download devmem from the Sipeed package repository +- Cross-compile from source (single C file) + +### Dynamic bus numbering +I2C adapter numbers can change between boots depending on driver load order. Always use `i2c detect` to find current bus assignments rather than hardcoding bus numbers. + +### Permissions +`/dev/i2c-*` and `/dev/spidev*` typically require root access. Options: +- Run picoclaw as root +- Add user to `i2c` and `spi` groups +- Create udev rules: `SUBSYSTEM=="i2c-dev", MODE="0666"` diff --git a/workspace/skills/hardware/references/common-devices.md b/workspace/skills/hardware/references/common-devices.md new file mode 100644 index 0000000..715e8ab --- /dev/null +++ b/workspace/skills/hardware/references/common-devices.md @@ -0,0 +1,78 @@ +# Common I2C/SPI Device Reference + +## I2C Devices + +### AHT20 — Temperature & Humidity +- **Address:** 0x38 +- **Init:** Write `[0xBE, 0x08, 0x00]` then wait 10ms +- **Measure:** Write `[0xAC, 0x33, 0x00]`, wait 80ms, read 6 bytes +- **Parse:** Status=byte[0], Humidity=(byte[1]<<12|byte[2]<<4|byte[3]>>4)/2^20*100, Temp=(byte[3]&0x0F<<16|byte[4]<<8|byte[5])/2^20*200-50 +- **Notes:** No register addressing — write command bytes directly (omit `register` param) + +### BME280 / BMP280 — Temperature, Humidity, Pressure +- **Address:** 0x76 or 0x77 (SDO pin selects) +- **Chip ID register:** 0xD0 → BMP280=0x58, BME280=0x60 +- **Data registers:** 0xF7-0xFE (pressure, temperature, humidity) +- **Config:** Write 0xF2 (humidity oversampling), 0xF4 (temp/press oversampling + mode), 0xF5 (standby, filter) +- **Forced measurement:** Write `[0x25]` to register 0xF4, wait 40ms, read 8 bytes from 0xF7 +- **Calibration:** Read 26 bytes from 0x88 and 7 bytes from 0xE1 for compensation formulas +- **Also available via SPI** (mode 0 or 3) + +### SSD1306 — 128x64 OLED Display +- **Address:** 0x3C (or 0x3D if SA0 high) +- **Command prefix:** 0x00 (write to register 0x00) +- **Data prefix:** 0x40 (write to register 0x40) +- **Init sequence:** `[0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, 0x8D, 0x14, 0x20, 0x00, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF, 0xD9, 0xF1, 0xDB, 0x40, 0xA4, 0xA6, 0xAF]` +- **Display on:** 0xAF, **Display off:** 0xAE +- **Also available via SPI** (faster, recommended for animations) + +### MPU6050 — 6-axis Accelerometer + Gyroscope +- **Address:** 0x68 (or 0x69 if AD0 high) +- **WHO_AM_I:** Register 0x75 → should return 0x68 +- **Wake up:** Write `[0x00]` to register 0x6B (clear sleep bit) +- **Read accel:** 6 bytes from register 0x3B (XH,XL,YH,YL,ZH,ZL) — signed 16-bit, default ±2g +- **Read gyro:** 6 bytes from register 0x43 — signed 16-bit, default ±250°/s +- **Read temp:** 2 bytes from register 0x41 — Temp°C = value/340 + 36.53 + +### DS3231 — Real-Time Clock +- **Address:** 0x68 +- **Read time:** 7 bytes from register 0x00 (seconds, minutes, hours, day, date, month, year) — BCD encoded +- **Set time:** Write 7 BCD bytes to register 0x00 +- **Temperature:** 2 bytes from register 0x11 (signed, 0.25°C resolution) +- **Status:** Register 0x0F — bit 2 = busy, bit 0 = alarm 1 flag + +### INA219 — Current & Power Monitor +- **Address:** 0x40-0x4F (A0,A1 pin selectable) +- **Config:** Register 0x00 — set voltage range, gain, ADC resolution +- **Shunt voltage:** Register 0x01 (signed 16-bit, LSB=10µV) +- **Bus voltage:** Register 0x02 (bits 15:3, LSB=4mV) +- **Power:** Register 0x03 (after calibration) +- **Current:** Register 0x04 (after calibration) +- **Calibration:** Register 0x05 — set based on shunt resistor value + +### PCA9685 — 16-Channel PWM / Servo Controller +- **Address:** 0x40-0x7F (A0-A5 selectable, default 0x40) +- **Mode 1:** Register 0x00 — bit 4=sleep, bit 5=auto-increment +- **Set PWM freq:** Sleep → write prescale to 0xFE → wake. Prescale = round(25MHz / (4096 × freq)) - 1 +- **Channel N on/off:** Registers 0x06+4*N to 0x09+4*N (ON_L, ON_H, OFF_L, OFF_H) +- **Servo 0°-180°:** ON=0, OFF=150-600 (at 50Hz). Typical: 0°=150, 90°=375, 180°=600 + +### AT24C256 — 256Kbit EEPROM +- **Address:** 0x50-0x57 (A0-A2 selectable) +- **Read:** Write 2-byte address (high, low), then read N bytes +- **Write:** Write 2-byte address + up to 64 bytes (page write), wait 5ms for write cycle +- **Page size:** 64 bytes. Writes that cross page boundary wrap around. + +## SPI Devices + +### MCP3008 — 8-Channel 10-bit ADC +- **Interface:** SPI mode 0, max 3.6 MHz @ 5V +- **Read channel N:** Send `[0x01, (0x80 | N<<4), 0x00]`, result in last 10 bits of bytes 1-2 +- **Formula:** value = ((byte[1] & 0x03) << 8) | byte[2] +- **Voltage:** value × Vref / 1024 + +### W25Q128 — 128Mbit SPI Flash +- **Interface:** SPI mode 0 or 3, up to 104 MHz +- **Read ID:** Send `[0x9F, 0, 0, 0]` → manufacturer + device ID +- **Read data:** Send `[0x03, addr_high, addr_mid, addr_low]` + N zero bytes +- **Status:** Send `[0x05, 0]` → bit 0 = BUSY diff --git a/skills/skill-creator/SKILL.md b/workspace/skills/skill-creator/SKILL.md similarity index 100% rename from skills/skill-creator/SKILL.md rename to workspace/skills/skill-creator/SKILL.md diff --git a/skills/summarize/SKILL.md b/workspace/skills/summarize/SKILL.md similarity index 100% rename from skills/summarize/SKILL.md rename to workspace/skills/summarize/SKILL.md diff --git a/skills/tmux/SKILL.md b/workspace/skills/tmux/SKILL.md similarity index 100% rename from skills/tmux/SKILL.md rename to workspace/skills/tmux/SKILL.md diff --git a/skills/tmux/scripts/find-sessions.sh b/workspace/skills/tmux/scripts/find-sessions.sh similarity index 100% rename from skills/tmux/scripts/find-sessions.sh rename to workspace/skills/tmux/scripts/find-sessions.sh diff --git a/skills/tmux/scripts/wait-for-text.sh b/workspace/skills/tmux/scripts/wait-for-text.sh similarity index 100% rename from skills/tmux/scripts/wait-for-text.sh rename to workspace/skills/tmux/scripts/wait-for-text.sh diff --git a/skills/weather/SKILL.md b/workspace/skills/weather/SKILL.md similarity index 100% rename from skills/weather/SKILL.md rename to workspace/skills/weather/SKILL.md