diff --git a/README.md b/README.md index 4c166b779..a61a1abf4 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 @@ -225,8 +222,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. @@ -257,9 +254,9 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE **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** @@ -290,19 +287,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** @@ -320,10 +317,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** @@ -371,9 +368,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** @@ -518,12 +515,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 @@ -542,6 +539,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": { @@ -553,6 +551,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 ``` @@ -644,8 +643,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 @@ -667,7 +667,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** @@ -784,9 +784,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. diff --git a/config/config.example.json b/config/config.example.json index 288e16c58..aa75c8338 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/auth/oauth.go b/pkg/auth/oauth.go index ecd9ba265..1a6589641 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -19,18 +19,20 @@ import ( ) type OAuthProviderConfig struct { - Issuer string - ClientID string - Scopes string - Port int + Issuer string + ClientID string + Scopes string + Originator string + Port int } func OpenAIOAuthConfig() OAuthProviderConfig { return OAuthProviderConfig{ - Issuer: "https://auth.openai.com", - ClientID: "app_EMoamEEZ73f0CkXaXp7hrann", - Scopes: "openid profile email offline_access", - Port: 1455, + Issuer: "https://auth.openai.com", + ClientID: "app_EMoamEEZ73f0CkXaXp7hrann", + Scopes: "openid profile email offline_access", + Originator: "codex_cli_rs", + Port: 1455, } } @@ -288,15 +290,20 @@ func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string { params := url.Values{ - "response_type": {"code"}, - "client_id": {cfg.ClientID}, - "redirect_uri": {redirectURI}, - "scope": {cfg.Scopes}, - "code_challenge": {pkce.CodeChallenge}, - "code_challenge_method": {"S256"}, - "state": {state}, + "response_type": {"code"}, + "client_id": {cfg.ClientID}, + "redirect_uri": {redirectURI}, + "scope": {cfg.Scopes}, + "code_challenge": {pkce.CodeChallenge}, + "code_challenge_method": {"S256"}, + "id_token_add_organizations": {"true"}, + "codex_cli_simplified_flow": {"true"}, + "state": {state}, } - return cfg.Issuer + "/authorize?" + params.Encode() + if cfg.Originator != "" { + params.Set("originator", cfg.Originator) + } + return cfg.Issuer + "/oauth/authorize?" + params.Encode() } func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) { @@ -352,6 +359,9 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) { if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" { cred.AccountID = accountID + } else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" { + // Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims. + cred.AccountID = accountID } return cred, nil diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 9f8013217..0d2ccc9a5 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -1,6 +1,7 @@ package auth import ( + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -10,10 +11,11 @@ import ( func TestBuildAuthorizeURL(t *testing.T) { cfg := OAuthProviderConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client-id", - Scopes: "openid profile", - Port: 1455, + Issuer: "https://auth.example.com", + ClientID: "test-client-id", + Scopes: "openid profile", + Originator: "codex_cli_rs", + Port: 1455, } pkce := PKCECodes{ CodeVerifier: "test-verifier", @@ -22,7 +24,7 @@ func TestBuildAuthorizeURL(t *testing.T) { u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback") - if !strings.HasPrefix(u, "https://auth.example.com/authorize?") { + if !strings.HasPrefix(u, "https://auth.example.com/oauth/authorize?") { t.Errorf("URL does not start with expected prefix: %s", u) } if !strings.Contains(u, "client_id=test-client-id") { @@ -40,6 +42,15 @@ func TestBuildAuthorizeURL(t *testing.T) { if !strings.Contains(u, "response_type=code") { t.Error("URL missing response_type") } + if !strings.Contains(u, "id_token_add_organizations=true") { + t.Error("URL missing id_token_add_organizations") + } + if !strings.Contains(u, "codex_cli_simplified_flow=true") { + t.Error("URL missing codex_cli_simplified_flow") + } + if !strings.Contains(u, "originator=codex_cli_rs") { + t.Error("URL missing originator") + } } func TestParseTokenResponse(t *testing.T) { @@ -81,6 +92,32 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) { } } +func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) { + idToken := makeJWTWithAccountID("acc-from-id") + resp := map[string]interface{}{ + "access_token": "not-a-jwt", + "refresh_token": "test-refresh-token", + "expires_in": 3600, + "id_token": idToken, + } + body, _ := json.Marshal(resp) + + cred, err := parseTokenResponse(body, "openai") + if err != nil { + t.Fatalf("parseTokenResponse() error: %v", err) + } + + if cred.AccountID != "acc-from-id" { + t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-from-id") + } +} + +func makeJWTWithAccountID(accountID string) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`)) + return header + "." + payload + ".sig" +} + func TestExchangeCodeForTokens(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/oauth/token" { 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..5d97fab9c --- /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/config/config.go b/pkg/config/config.go index b6d4e0b7f..112f9166d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -78,6 +78,7 @@ type ChannelsConfig struct { DingTalk DingTalkConfig `json:"dingtalk"` Slack SlackConfig `json:"slack"` LINE LINEConfig `json:"line"` + OneBot OneBotConfig `json:"onebot"` } type WhatsAppConfig struct { @@ -146,6 +147,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 @@ -274,6 +284,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{}, diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 3463389a5..c0b10bd5b 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -18,6 +18,8 @@ type CodexProvider struct { tokenSource func() (string, string, error) } +const defaultCodexInstructions = "You are Codex, a coding assistant." + func NewCodexProvider(token, accountID string) *CodexProvider { opts := []option.RequestOption{ option.WithBaseURL("https://chatgpt.com/backend-api/codex"), @@ -138,6 +140,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, if instructions != "" { params.Instructions = openai.Opt(instructions) + } else { + // ChatGPT Codex backend requires instructions to be present. + params.Instructions = openai.Opt(defaultCodexInstructions) } if maxTokens, ok := options["max_tokens"].(int); ok { diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 605183d5e..1a5a8cafa 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -21,6 +21,12 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) { if params.Model != "gpt-4o" { t.Errorf("Model = %q, want %q", params.Model, "gpt-4o") } + if !params.Instructions.Valid() { + t.Fatal("Instructions should be set") + } + if params.Instructions.Or("") != defaultCodexInstructions { + t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions) + } } func TestBuildCodexParams_SystemAsInstructions(t *testing.T) {