Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ coverage.html
.DS_Store
build

picoclaw
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ Talk to your picoclaw through Telegram
|---------|-------|
| **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) |
| **QQ** | Easy (AppID + AppSecret) |

<details>
<summary><b>Telegram</b> (Recommended)</summary>
Expand Down Expand Up @@ -256,9 +257,42 @@ picoclaw gateway
**6. Run**

```bash
nanobot gateway
picoclaw gateway
```

</details>


<details>
<summary><b>QQ</b></summary>

**1. Create a bot**

- Go to [QQ Open Platform](https://connect.qq.com/)
- Create an application → Get **AppID** and **AppSecret**

**2. Configure**

```json
{
"channels": {
"qq": {
"enabled": true,
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET",
"allow_from": []
}
}
}
```

> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.

**3. Run**

```bash
picoclaw gateway
```
</details>

## ⚙️ Configuration
Expand Down Expand Up @@ -355,6 +389,12 @@ picoclaw agent -m "Hello"
"encryptKey": "",
"verificationToken": "",
"allowFrom": []
},
"qq": {
"enabled": false,
"app_id": "",
"app_secret": "",
"allow_from": []
}
},
"tools": {
Expand Down
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@ require (
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/gorilla/websocket v1.5.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/tencent-connect/botgo v0.2.1
golang.org/x/oauth2 v0.35.0
)

require (
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
)
142 changes: 142 additions & 0 deletions go.sum

Large diffs are not rendered by default.

17 changes: 11 additions & 6 deletions pkg/channels/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package channels

import (
"context"
"fmt"

"github.com/sipeed/picoclaw/pkg/bus"
)
Expand Down Expand Up @@ -60,13 +61,17 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st
return
}

// 生成 SessionKey: channel:chatID
sessionKey := fmt.Sprintf("%s:%s", c.name, chatID)

msg := bus.InboundMessage{
Channel: c.name,
SenderID: senderID,
ChatID: chatID,
Content: content,
Media: media,
Metadata: metadata,
Channel: c.name,
SenderID: senderID,
ChatID: chatID,
Content: content,
Media: media,
Metadata: metadata,
SessionKey: sessionKey,
}

c.bus.PublishInbound(msg)
Expand Down
13 changes: 13 additions & 0 deletions pkg/channels/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@ func (m *Manager) initChannels() error {
}
}

if m.config.Channels.QQ.Enabled {
logger.DebugC("channels", "Attempting to initialize QQ channel")
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{
"error": err.Error(),
})
} else {
m.channels["qq"] = qq
logger.InfoC("channels", "QQ channel enabled successfully")
}
}

logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
"enabled_channels": len(m.channels),
})
Expand Down
243 changes: 243 additions & 0 deletions pkg/channels/qq.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package channels

import (
"context"
"fmt"
"sync"
"time"

"github.com/tencent-connect/botgo"
"github.com/tencent-connect/botgo/dto"
"github.com/tencent-connect/botgo/event"
"github.com/tencent-connect/botgo/openapi"
"github.com/tencent-connect/botgo/token"
"golang.org/x/oauth2"

"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)

type QQChannel struct {
*BaseChannel
config config.QQConfig
api openapi.OpenAPI
tokenSource oauth2.TokenSource
ctx context.Context
cancel context.CancelFunc
sessionManager botgo.SessionManager
processedIDs map[string]bool
mu sync.RWMutex
}

func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom)

return &QQChannel{
BaseChannel: base,
config: cfg,
processedIDs: make(map[string]bool),
}, nil
}

func (c *QQChannel) Start(ctx context.Context) error {
if c.config.AppID == "" || c.config.AppSecret == "" {
return fmt.Errorf("QQ app_id and app_secret not configured")
}

logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")

// 创建 token source
credentials := &token.QQBotCredentials{
AppID: c.config.AppID,
AppSecret: c.config.AppSecret,
}
c.tokenSource = token.NewQQBotTokenSource(credentials)

// 创建子 context
c.ctx, c.cancel = context.WithCancel(ctx)

// 启动自动刷新 token 协程
if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
return fmt.Errorf("failed to start token refresh: %w", err)
}

// 初始化 OpenAPI 客户端
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)

// 注册事件处理器
intent := event.RegisterHandlers(
c.handleC2CMessage(),
c.handleGroupATMessage(),
)

// 获取 WebSocket 接入点
wsInfo, err := c.api.WS(c.ctx, nil, "")
if err != nil {
return fmt.Errorf("failed to get websocket info: %w", err)
}

logger.InfoCF("qq", "Got WebSocket info", map[string]interface{}{
"shards": wsInfo.Shards,
})

// 创建并保存 sessionManager
c.sessionManager = botgo.NewSessionManager()

// 在 goroutine 中启动 WebSocket 连接,避免阻塞
go func() {
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{
"error": err.Error(),
})
c.setRunning(false)
}
}()

c.setRunning(true)
logger.InfoC("qq", "QQ bot started successfully")

return nil
}

func (c *QQChannel) Stop(ctx context.Context) error {
logger.InfoC("qq", "Stopping QQ bot")
c.setRunning(false)

if c.cancel != nil {
c.cancel()
}

return nil
}

func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("QQ bot not running")
}

// 构造消息
msgToCreate := &dto.MessageToCreate{
Content: msg.Content,
}

// C2C 消息发送
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
if err != nil {
logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{
"error": err.Error(),
})
return err
}

return nil
}

// handleC2CMessage 处理 QQ 私聊消息
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
// 去重检查
if c.isDuplicate(data.ID) {
return nil
}

// 提取用户信息
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
} else {
logger.WarnC("qq", "Received message with no sender ID")
return nil
}

// 提取消息内容
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty message, ignoring")
return nil
}

logger.InfoCF("qq", "Received C2C message", map[string]interface{}{
"sender": senderID,
"length": len(content),
})

// 转发到消息总线
metadata := map[string]string{
"message_id": data.ID,
}

c.HandleMessage(senderID, senderID, content, []string{}, metadata)

return nil
}
}

// handleGroupATMessage 处理群@消息
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
// 去重检查
if c.isDuplicate(data.ID) {
return nil
}

// 提取用户信息
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
} else {
logger.WarnC("qq", "Received group message with no sender ID")
return nil
}

// 提取消息内容(去掉 @ 机器人部分)
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty group message, ignoring")
return nil
}

logger.InfoCF("qq", "Received group AT message", map[string]interface{}{
"sender": senderID,
"group": data.GroupID,
"length": len(content),
})

// 转发到消息总线(使用 GroupID 作为 ChatID)
metadata := map[string]string{
"message_id": data.ID,
"group_id": data.GroupID,
}

c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)

return nil
}
}

// isDuplicate 检查消息是否重复
func (c *QQChannel) isDuplicate(messageID string) bool {
c.mu.Lock()
defer c.mu.Unlock()

if c.processedIDs[messageID] {
return true
}

c.processedIDs[messageID] = true

// 简单清理:限制 map 大小
if len(c.processedIDs) > 10000 {
// 清空一半
count := 0
for id := range c.processedIDs {
if count >= 5000 {
break
}
delete(c.processedIDs, id)
count++
}
}

return false
}
Loading