From 15e3c7d08a1ff877e0d21ecc304e01e6fd96f138 Mon Sep 17 00:00:00 2001 From: seth <117851610+Sethispr@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:05:14 -0800 Subject: [PATCH 01/18] chore: lint readme MD004 / ul-style Unordered list style [Expected: asterisk; Actual: dash] MD009 / no-trailing-spaces Trailing spaces [Expected: 0 or 2; Actual: 1] MD012 / no-multiple-blanks Multiple consecutive blank lines [Expected: 1; Actual: 2] MD022 / blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] MD031 / blanks-around-fences Fenced code blocks should be surrounded by blank lines MD032 / blanks-around-lists Lists should be surrounded by blank lines --- README.md | 84 +++++++++++++++++++++++++++---------------------------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 2ba70881b..81d155f89 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,12 @@ [中文](README.zh.md) | [日本語](README.ja.md) | **English** - --- 🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [nanobot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization. ⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini! -
@@ -40,20 +38,19 @@
- > [!CAUTION] > **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** > > * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**. > * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)** -> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties. +> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties. > ## 📢 News + 2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development. 🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting. - 2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go! ## ✨ Features @@ -103,9 +100,9 @@ PicoClaw can be deployed on almost any Linux device! -- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant -- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance -- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring +* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant +* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance +* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring @@ -220,8 +217,8 @@ picoclaw onboard **3. Get API Keys** -- **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) -- **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month) +* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month) > **Note**: See `config.example.json` for a complete configuration template. @@ -251,9 +248,9 @@ Talk to your picoclaw through Telegram, Discord, or DingTalk **1. Create a bot** -- Open Telegram, search `@BotFather` -- Send `/newbot`, follow prompts -- Copy the token +* Open Telegram, search `@BotFather` +* Send `/newbot`, follow prompts +* Copy the token **2. Configure** @@ -284,19 +281,19 @@ picoclaw gateway **1. Create a bot** -- Go to -- Create an application → Bot → Add Bot -- Copy the bot token +* Go to +* Create an application → Bot → Add Bot +* Copy the bot token **2. Enable intents** -- In the Bot settings, enable **MESSAGE CONTENT INTENT** -- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data +* In the Bot settings, enable **MESSAGE CONTENT INTENT** +* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data **3. Get your User ID** -- Discord Settings → Advanced → enable **Developer Mode** -- Right-click your avatar → **Copy User ID** +* Discord Settings → Advanced → enable **Developer Mode** +* Right-click your avatar → **Copy User ID** **4. Configure** @@ -314,10 +311,10 @@ picoclaw gateway **5. Invite the bot** -- OAuth2 → URL Generator -- Scopes: `bot` -- Bot Permissions: `Send Messages`, `Read Message History` -- Open the generated invite URL and add the bot to your server +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Open the generated invite URL and add the bot to your server **6. Run** @@ -332,8 +329,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://connect.qq.com/) +* Create an application → Get **AppID** and **AppSecret** **2. Configure** @@ -365,9 +362,9 @@ picoclaw gateway **1. Create a bot** -- Go to [Open Platform](https://open.dingtalk.com/) -- Create an internal app -- Copy Client ID and Client Secret +* Go to [Open Platform](https://open.dingtalk.com/) +* Create an internal app +* Copy Client ID and Client Secret **2. Configure** @@ -400,8 +397,6 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message **Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)** - - ## ⚙️ Configuration Config file: `~/.picoclaw/config.json` @@ -464,12 +459,12 @@ When `restrict_to_workspace: true`, the following tools are sandboxed: Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands: -- `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion -- `format`, `mkfs`, `diskpart` — Disk formatting -- `dd if=` — Disk imaging -- Writing to `/dev/sd[a-z]` — Direct disk writes -- `shutdown`, `reboot`, `poweroff` — System shutdown -- Fork bomb `:(){ :|:& };:` +* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion +* `format`, `mkfs`, `diskpart` — Disk formatting +* `dd if=` — Disk imaging +* Writing to `/dev/sd[a-z]` — Direct disk writes +* `shutdown`, `reboot`, `poweroff` — System shutdown +* Fork bomb `:(){ :|:& };:` #### Error Examples @@ -488,6 +483,7 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous If you need the agent to access paths outside the workspace: **Method 1: Config file** + ```json { "agents": { @@ -499,6 +495,7 @@ If you need the agent to access paths outside the workspace: ``` **Method 2: Environment variable** + ```bash export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false ``` @@ -590,8 +587,9 @@ The subagent has access to tools (message, web_search, etc.) and can communicate | `interval` | `30` | Check interval in minutes (min: 5) | **Environment variables:** -- `PICOCLAW_HEARTBEAT_ENABLED=false` to disable -- `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval + +* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable +* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval ### Providers @@ -613,7 +611,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate **1. Get API key and base URL** -- Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) +* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) **2. Configure** @@ -724,9 +722,9 @@ picoclaw agent -m "Hello" PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool: -- **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min -- **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours -- **Cron expressions**: "Remind me at 9am daily" → uses cron expression +* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min +* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours +* **Cron expressions**: "Remind me at 9am daily" → uses cron expression Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically. From 7fa641a2edb41bd69268112d8683ded97a65a767 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 14 Feb 2026 16:50:21 +0800 Subject: [PATCH 02/18] feat: add OneBot channel support --- config/config.example.json | 8 + pkg/channels/manager.go | 13 + pkg/channels/onebot.go | 677 +++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 18 + 4 files changed, 716 insertions(+) create mode 100644 pkg/channels/onebot.go diff --git a/config/config.example.json b/config/config.example.json index ee3ac97f7..f7e88357f 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -60,6 +60,14 @@ "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": { diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 69e9b2b43..15f8c6037 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -163,6 +163,19 @@ func (m *Manager) initChannels() error { } } + 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 000000000..fe6edbeea --- /dev/null +++ b/pkg/channels/onebot.go @@ -0,0 +1,677 @@ +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 string `json:"status"` +} + +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 { + return fmt.Errorf("failed to connect to OneBot: %w", err) + } + + go c.listen() + + if c.config.ReconnectInterval > 0 { + go c.reconnectLoop() + } + + 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() { + logger.InfoC("onebot", "WebSocket listener started") + for { + select { + case <-c.ctx.Done(): + logger.InfoC("onebot", "WebSocket listener stopping (context cancelled)") + 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 != "" { + 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/config/config.go b/pkg/config/config.go index 6af9438a5..03dfe3133 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -77,6 +77,7 @@ type ChannelsConfig struct { DingTalk DingTalkConfig `json:"dingtalk"` Slack SlackConfig `json:"slack"` LINE LINEConfig `json:"line"` + OneBot OneBotConfig `json:"onebot"` } type WhatsAppConfig struct { @@ -145,6 +146,15 @@ type LINEConfig struct { 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 @@ -265,6 +275,14 @@ func DefaultConfig() *Config { 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{}, From 1fcf3fde0f6e3d89ad753f05e413ab82ee7e62ee Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 14 Feb 2026 17:23:18 +0800 Subject: [PATCH 03/18] style: fix format --- pkg/channels/onebot.go | 55 +++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index fe6edbeea..7f48ef6c0 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -18,19 +18,18 @@ import ( 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 + 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"` @@ -44,9 +43,9 @@ type oneBotRawEvent struct { 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 string `json:"status"` + Echo string `json:"echo"` + RetCode json.RawMessage `json:"retcode"` + Status string `json:"status"` } type oneBotSender struct { @@ -56,19 +55,19 @@ type oneBotSender struct { } 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 + 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 { @@ -264,7 +263,7 @@ func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, inter return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID) } return "send_private_msg", oneBotSendPrivateMsgParams{ - UserID: userID, + UserID: userID, Message: msg.Content, }, nil } From f7421128a0074fe7577ada0614a2eb58afe3d082 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 14 Feb 2026 17:46:47 +0800 Subject: [PATCH 04/18] style: remove unnecessary log messages in WebSocket listener --- pkg/channels/onebot.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index 7f48ef6c0..ae563b831 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -280,11 +280,9 @@ func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, inter } func (c *OneBotChannel) listen() { - logger.InfoC("onebot", "WebSocket listener started") for { select { case <-c.ctx.Done(): - logger.InfoC("onebot", "WebSocket listener stopping (context cancelled)") return default: c.mu.Lock() From 773ce9bcb6ba6a9cd59d0ff51053b2e31d610912 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 14 Feb 2026 19:58:19 +0800 Subject: [PATCH 05/18] fix: update BotStatus type in oneBotRawEvent and adjust status checks --- pkg/channels/onebot.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index ae563b831..dadc9bc02 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -45,7 +45,12 @@ type oneBotRawEvent struct { MetaEventType string `json:"meta_event_type"` Echo string `json:"echo"` RetCode json.RawMessage `json:"retcode"` - Status string `json:"status"` + Status BotStatus `json:"status"` +} + +type BotStatus struct { + Online bool `json:"online"` + Good bool `json:"good"` } type oneBotSender struct { @@ -322,7 +327,7 @@ func (c *OneBotChannel) listen() { continue } - if raw.Echo != "" || raw.Status != "" { + 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, From 60c3d96b5e5dbe155e2e4fdafea0a25bd78579fe Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 14 Feb 2026 20:02:30 +0800 Subject: [PATCH 06/18] style: fix alignment of fields in BotStatus struct --- pkg/channels/onebot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index dadc9bc02..674acf520 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -50,7 +50,7 @@ type oneBotRawEvent struct { type BotStatus struct { Online bool `json:"online"` - Good bool `json:"good"` + Good bool `json:"good"` } type oneBotSender struct { From 8cff6cf3123c71bacf0ee534352a3cac55e2b3f0 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 14 Feb 2026 20:25:55 +0800 Subject: [PATCH 07/18] fix: improve connection handling in OneBotChannel --- pkg/channels/onebot.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index 674acf520..5d97fab9c 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -116,13 +116,20 @@ func (c *OneBotChannel) Start(ctx context.Context) error { c.ctx, c.cancel = context.WithCancel(ctx) if err := c.connect(); err != nil { - return fmt.Errorf("failed to connect to OneBot: %w", err) + logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{ + "error": err.Error(), + }) + } else { + go c.listen() } - 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) From a24cbd43855b19c4bb7defe1774e6dc17ff4c6db Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Sat, 14 Feb 2026 14:44:19 +0200 Subject: [PATCH 08/18] feat(docker): Added curl for Docker image --- .gitignore | 6 +++++- Dockerfile | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 6ba411758..6a61e26b1 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,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 8db995545..5587198fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,9 +18,9 @@ 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 diff --git a/go.mod b/go.mod index f4c233ea8..78683203f 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ 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 diff --git a/go.sum b/go.sum index 9174d2889..80e40a33f 100644 --- a/go.sum +++ b/go.sum @@ -92,8 +92,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= From 5faa67b77d9ceba411eb8de2627a43319a17b3dd Mon Sep 17 00:00:00 2001 From: Lixeer <1612655510@qq.com> Date: Sun, 15 Feb 2026 05:23:42 +0800 Subject: [PATCH 09/18] feat: add `Github Copilot` provider --- go.mod | 5 ++ go.sum | 4 ++ pkg/config/config.go | 4 ++ pkg/providers/github_copilot_provider.go | 85 ++++++++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 pkg/providers/github_copilot_provider.go diff --git a/go.mod b/go.mod index f4c233ea8..206f1aafd 100644 --- a/go.mod +++ b/go.mod @@ -19,12 +19,16 @@ require ( 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 9174d2889..8f9575122 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= diff --git a/pkg/config/config.go b/pkg/config/config.go index bbfa2e40b..eafdb006e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -168,6 +168,7 @@ type ProvidersConfig struct { Moonshot ProviderConfig `json:"moonshot"` ShengSuanYun ProviderConfig `json:"shengsuanyun"` DeepSeek ProviderConfig `json:"deepseek"` + GitHubCopilot ProviderConfig `json:"github_copilot"` } type ProviderConfig struct { @@ -175,8 +176,11 @@ type ProviderConfig struct { 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 { Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go new file mode 100644 index 000000000..67fbdd568 --- /dev/null +++ b/pkg/providers/github_copilot_provider.go @@ -0,0 +1,85 @@ +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 { + + var session *copilot.Session + + switch connectMode { + + case "stdio": + + case "grpc": + client := copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: uri, + }) + if err := client.Start(context.Background()); err != 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, + } +} + +// 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" +} From 0aab8d8afcb69659c663dbcbff2fd0ec77cb4208 Mon Sep 17 00:00:00 2001 From: Lixeer <1612655510@qq.com> Date: Sun, 15 Feb 2026 06:10:00 +0800 Subject: [PATCH 10/18] feat:add `github_copilot` to providers factory --- pkg/providers/http_provider.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 6fcbd3055..013f787d1 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -314,7 +314,17 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { model = "deepseek-chat" } } + case "github_copilot", "copilot": + if cfg.Providers.GitHubCopilot.APIBase != "" { + apiBase = cfg.Providers.GitHubCopilot.APIBase + }else { + apiBase = "localhost:4321" // no `http://` beacause grpc mode` + } + return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model), nil + } + + } // Fallback: detect provider from model name From 6c392c338758053b928a92bff86769fbd6160c3a Mon Sep 17 00:00:00 2001 From: Lixeer <1612655510@qq.com> Date: Sun, 15 Feb 2026 06:11:07 +0800 Subject: [PATCH 11/18] fix: openclaw crash because of empty config --- pkg/providers/github_copilot_provider.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index 67fbdd568..4774ac703 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -4,8 +4,10 @@ import ( "context" "fmt" - json "encoding/json" + json "encoding/json" + copilot "github.com/github/copilot-sdk/go" + ) type GitHubCopilotProvider struct { @@ -18,7 +20,9 @@ type GitHubCopilotProvider struct { func NewGitHubCopilotProvider(uri string, connectMode string, model string) *GitHubCopilotProvider { var session *copilot.Session - + if connectMode == "" { + connectMode = "grpc" + } switch connectMode { case "stdio": @@ -37,6 +41,7 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) *Git }, }) + } return &GitHubCopilotProvider{ @@ -66,10 +71,11 @@ func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, to - + fmt.Println("[DEBUG] call p.session.Send") content,_ := p.session.Send(ctx,copilot.MessageOptions{ Prompt: string(fullcontent), }) + fmt.Println("[DEBUG] end cal") return &LLMResponse{ FinishReason : "stop", From ecbe31599efae9f622a6c4a502503be005b59eab Mon Sep 17 00:00:00 2001 From: Lixeer <1612655510@qq.com> Date: Sun, 15 Feb 2026 06:30:31 +0800 Subject: [PATCH 12/18] chore: remove redundant debug output --- pkg/providers/github_copilot_provider.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index 4774ac703..a6cd20d9f 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -71,11 +71,11 @@ func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, to - fmt.Println("[DEBUG] call p.session.Send") + content,_ := p.session.Send(ctx,copilot.MessageOptions{ Prompt: string(fullcontent), }) - fmt.Println("[DEBUG] end cal") + return &LLMResponse{ FinishReason : "stop", From 7a9659971df856f9dd91b302dcb33ebde5d0e4a3 Mon Sep 17 00:00:00 2001 From: Lixeer <1612655510@qq.com> Date: Sun, 15 Feb 2026 06:55:20 +0800 Subject: [PATCH 13/18] make fmt --- pkg/config/config.go | 34 ++++++------ pkg/providers/github_copilot_provider.go | 66 +++++++++++------------- pkg/providers/http_provider.go | 9 ++-- 3 files changed, 49 insertions(+), 60 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index eafdb006e..b6d4e0b7f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -157,30 +157,28 @@ type DevicesConfig struct { } 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"` - DeepSeek ProviderConfig `json:"deepseek"` + 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"` - ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` + APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` + APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` + Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` + AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` + ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` } - - type GatewayConfig struct { Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index a6cd20d9f..39a3f7e9f 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -7,21 +7,20 @@ import ( json "encoding/json" copilot "github.com/github/copilot-sdk/go" - ) type GitHubCopilotProvider struct { uri string connectMode string // `stdio` or `grpc`` - - session *copilot.Session + + session *copilot.Session } func NewGitHubCopilotProvider(uri string, connectMode string, model string) *GitHubCopilotProvider { var session *copilot.Session - if connectMode == "" { - connectMode = "grpc" + if connectMode == "" { + connectMode = "grpc" } switch connectMode { @@ -37,55 +36,48 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) *Git defer client.Stop() session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{ Model: model, - Hooks: &copilot.SessionHooks{ - - }, + Hooks: &copilot.SessionHooks{}, }) - + } return &GitHubCopilotProvider{ uri: uri, connectMode: connectMode, - - session: session, + + session: session, } } // 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)) + 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, - }) - } + for _, msg := range messages { + out = append(out, tempMessage{ + Role: msg.Role, + Content: msg.Content, + }) + } - fullcontent,_ := json.Marshal(out) - + fullcontent, _ := json.Marshal(out) + content, _ := p.session.Send(ctx, copilot.MessageOptions{ + Prompt: string(fullcontent), + }) + + return &LLMResponse{ + FinishReason: "stop", + Content: content, + }, nil - - 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" + return "gpt-4.1" } diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 013f787d1..1bd440fc0 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -317,13 +317,12 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { case "github_copilot", "copilot": if cfg.Providers.GitHubCopilot.APIBase != "" { apiBase = cfg.Providers.GitHubCopilot.APIBase - }else { - apiBase = "localhost:4321" // no `http://` beacause grpc mode` - } + } else { + apiBase = "localhost:4321" // no `http://` beacause grpc mode` + } return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model), nil - + } - } From f6d6221c09d67ce3c395c9281b17d8547cc61bbb Mon Sep 17 00:00:00 2001 From: Lixeer <1612655510@qq.com> Date: Sun, 15 Feb 2026 07:00:40 +0800 Subject: [PATCH 14/18] make fmt --- pkg/providers/http_provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 1bd440fc0..d723ba872 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -318,7 +318,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { if cfg.Providers.GitHubCopilot.APIBase != "" { apiBase = cfg.Providers.GitHubCopilot.APIBase } else { - apiBase = "localhost:4321" // no `http://` beacause grpc mode` + apiBase = "localhost:4321" } return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model), nil From 5a6ad37dab6edf3068fea334c407ad234dee8382 Mon Sep 17 00:00:00 2001 From: Lixeer <1612655510@qq.com> Date: Sun, 15 Feb 2026 07:34:12 +0800 Subject: [PATCH 15/18] code fmt --- cmd/picoclaw/main.go | 66 +++++++++++++++++----------------- pkg/providers/http_provider.go | 2 +- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 191ff8239..e424fb7d3 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -9,6 +9,7 @@ package main import ( "bufio" "context" + "embed" "fmt" "io" "io/fs" @@ -18,7 +19,6 @@ import ( "runtime" "strings" "time" - "embed" "github.com/chzyer/readline" "github.com/sipeed/picoclaw/pkg/agent" @@ -41,7 +41,6 @@ import ( //go:embed workspace var embeddedFiles embed.FS - var ( version = "dev" gitCommit string @@ -214,7 +213,6 @@ func printHelp() { fmt.Println(" version Show version information") } - func onboard() { configPath := getConfigPath() @@ -246,50 +244,50 @@ func onboard() { } 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) - } + // 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 - } + // 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 - } + // 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) - } + // 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) + // 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) - } + // 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) - } + // 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 nil + }) - return err + return err } func createWorkspaceTemplates(workspace string) { diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index d723ba872..52a380239 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -318,7 +318,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { if cfg.Providers.GitHubCopilot.APIBase != "" { apiBase = cfg.Providers.GitHubCopilot.APIBase } else { - apiBase = "localhost:4321" + apiBase = "localhost:4321" } return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model), nil From 0a4bf32e81fb8bb93b78c76b5dbbce5a84cb8b30 Mon Sep 17 00:00:00 2001 From: Lixeer <1612655510@qq.com> Date: Sun, 15 Feb 2026 07:54:04 +0800 Subject: [PATCH 16/18] try after workflow fix --- pkg/providers/github_copilot_provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index 39a3f7e9f..ec9b1b68d 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -25,7 +25,7 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) *Git switch connectMode { case "stdio": - + //todo case "grpc": client := copilot.NewClient(&copilot.ClientOptions{ CLIUrl: uri, From 4b886b65739a91a771a38d793b89b2c2c250a50f Mon Sep 17 00:00:00 2001 From: Lixeer <1612655510@qq.com> Date: Sun, 15 Feb 2026 08:02:23 +0800 Subject: [PATCH 17/18] fix: not used result --- pkg/providers/github_copilot_provider.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index ec9b1b68d..5058819f5 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -16,7 +16,7 @@ type GitHubCopilotProvider struct { session *copilot.Session } -func NewGitHubCopilotProvider(uri string, connectMode string, model string) *GitHubCopilotProvider { +func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) { var session *copilot.Session if connectMode == "" { @@ -31,7 +31,7 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) *Git CLIUrl: uri, }) if err := client.Start(context.Background()); err != nil { - fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details") + return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details") } defer client.Stop() session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{ @@ -44,9 +44,8 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) *Git return &GitHubCopilotProvider{ uri: uri, connectMode: connectMode, - - session: session, - } + session: session, + }, nil } // Chat sends a chat request to GitHub Copilot From 16e5a0295386c034b8812c16831dda17b7a209b1 Mon Sep 17 00:00:00 2001 From: Lixeer <1612655510@qq.com> Date: Sun, 15 Feb 2026 08:06:29 +0800 Subject: [PATCH 18/18] fix(http_provider): Remove extra parameter from CreateProvider function. --- pkg/providers/http_provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 52a380239..0bea16d6a 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -320,7 +320,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { } else { apiBase = "localhost:4321" } - return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model), nil + return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model) }