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!
-
-
> [!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) {