Skip to content

Commit 24c0d58

Browse files
committed
add QQ channel support
1 parent f7d6a9c commit 24c0d58

8 files changed

Lines changed: 502 additions & 17 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ coverage.html
1313
.DS_Store
1414
build
1515

16+
picoclaw

README.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ Talk to your picoclaw through Telegram
181181
|---------|-------|
182182
| **Telegram** | Easy (just a token) |
183183
| **Discord** | Easy (bot token + intents) |
184+
| **QQ** | Easy (AppID + AppSecret) |
184185

185186
<details>
186187
<summary><b>Telegram</b> (Recommended)</summary>
@@ -254,9 +255,42 @@ picoclaw gateway
254255
**6. Run**
255256

256257
```bash
257-
nanobot gateway
258+
picoclaw gateway
259+
```
260+
261+
</details>
262+
263+
264+
<details>
265+
<summary><b>QQ</b></summary>
266+
267+
**1. Create a bot**
268+
269+
- Go to [QQ Open Platform](https://connect.qq.com/)
270+
- Create an application → Get **AppID** and **AppSecret**
271+
272+
**2. Configure**
273+
274+
```json
275+
{
276+
"channels": {
277+
"qq": {
278+
"enabled": true,
279+
"app_id": "YOUR_APP_ID",
280+
"app_secret": "YOUR_APP_SECRET",
281+
"allow_from": []
282+
}
283+
}
284+
}
258285
```
259286

287+
> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
288+
289+
**3. Run**
290+
291+
```bash
292+
picoclaw gateway
293+
```
260294
</details>
261295

262296
## ⚙️ Configuration
@@ -353,6 +387,12 @@ picoclaw agent -m "Hello"
353387
"encryptKey": "",
354388
"verificationToken": "",
355389
"allowFrom": []
390+
},
391+
"qq": {
392+
"enabled": false,
393+
"app_id": "",
394+
"app_secret": "",
395+
"allow_from": []
356396
}
357397
},
358398
"tools": {

go.mod

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
module github.com/sipeed/picoclaw
22

3-
go 1.18
3+
go 1.24.0
44

55
require (
6-
github.com/bwmarrin/discordgo v0.28.1
6+
github.com/bwmarrin/discordgo v0.29.0
77
github.com/caarlos0/env/v11 v11.3.1
88
github.com/chzyer/readline v1.5.1
99
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
1010
github.com/gorilla/websocket v1.5.3
11+
github.com/tencent-connect/botgo v0.2.1
12+
golang.org/x/oauth2 v0.35.0
1113
)
1214

1315
require (
14-
golang.org/x/crypto v0.28.0 // indirect
15-
golang.org/x/sys v0.40.0 // indirect
16+
github.com/go-resty/resty/v2 v2.17.1 // indirect
17+
github.com/tidwall/gjson v1.18.0 // indirect
18+
github.com/tidwall/match v1.2.0 // indirect
19+
github.com/tidwall/pretty v1.2.1 // indirect
20+
golang.org/x/crypto v0.47.0 // indirect
21+
golang.org/x/net v0.49.0 // indirect
22+
golang.org/x/sync v0.19.0 // indirect
23+
golang.org/x/sys v0.41.0 // indirect
1624
)

go.sum

Lines changed: 167 additions & 6 deletions
Large diffs are not rendered by default.

pkg/channels/base.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package channels
22

33
import (
44
"context"
5+
"fmt"
56

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

64+
// 生成 SessionKey: channel:chatID
65+
sessionKey := fmt.Sprintf("%s:%s", c.name, chatID)
66+
6367
msg := bus.InboundMessage{
64-
Channel: c.name,
65-
SenderID: senderID,
66-
ChatID: chatID,
67-
Content: content,
68-
Media: media,
69-
Metadata: metadata,
68+
Channel: c.name,
69+
SenderID: senderID,
70+
ChatID: chatID,
71+
Content: content,
72+
Media: media,
73+
Metadata: metadata,
74+
SessionKey: sessionKey,
7075
}
7176

7277
c.bus.PublishInbound(msg)

pkg/channels/manager.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ func (m *Manager) initChannels() error {
9797
}
9898
}
9999

100+
if m.config.Channels.QQ.Enabled {
101+
logger.DebugC("channels", "Attempting to initialize QQ channel")
102+
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
103+
if err != nil {
104+
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{
105+
"error": err.Error(),
106+
})
107+
} else {
108+
m.channels["qq"] = qq
109+
logger.InfoC("channels", "QQ channel enabled successfully")
110+
}
111+
}
112+
100113
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
101114
"enabled_channels": len(m.channels),
102115
})

pkg/channels/qq.go

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
package channels
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"sync"
7+
"time"
8+
9+
"github.com/tencent-connect/botgo"
10+
"github.com/tencent-connect/botgo/dto"
11+
"github.com/tencent-connect/botgo/event"
12+
"github.com/tencent-connect/botgo/openapi"
13+
"github.com/tencent-connect/botgo/token"
14+
"golang.org/x/oauth2"
15+
16+
"github.com/sipeed/picoclaw/pkg/bus"
17+
"github.com/sipeed/picoclaw/pkg/config"
18+
"github.com/sipeed/picoclaw/pkg/logger"
19+
)
20+
21+
type QQChannel struct {
22+
*BaseChannel
23+
config config.QQConfig
24+
api openapi.OpenAPI
25+
tokenSource oauth2.TokenSource
26+
ctx context.Context
27+
cancel context.CancelFunc
28+
sessionManager botgo.SessionManager
29+
processedIDs map[string]bool
30+
mu sync.RWMutex
31+
}
32+
33+
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
34+
base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom)
35+
36+
return &QQChannel{
37+
BaseChannel: base,
38+
config: cfg,
39+
processedIDs: make(map[string]bool),
40+
}, nil
41+
}
42+
43+
func (c *QQChannel) Start(ctx context.Context) error {
44+
if c.config.AppID == "" || c.config.AppSecret == "" {
45+
return fmt.Errorf("QQ app_id and app_secret not configured")
46+
}
47+
48+
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
49+
50+
// 创建 token source
51+
credentials := &token.QQBotCredentials{
52+
AppID: c.config.AppID,
53+
AppSecret: c.config.AppSecret,
54+
}
55+
c.tokenSource = token.NewQQBotTokenSource(credentials)
56+
57+
// 创建子 context
58+
c.ctx, c.cancel = context.WithCancel(ctx)
59+
60+
// 启动自动刷新 token 协程
61+
if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
62+
return fmt.Errorf("failed to start token refresh: %w", err)
63+
}
64+
65+
// 初始化 OpenAPI 客户端
66+
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
67+
68+
// 注册事件处理器
69+
intent := event.RegisterHandlers(
70+
c.handleC2CMessage(),
71+
c.handleGroupATMessage(),
72+
)
73+
74+
// 获取 WebSocket 接入点
75+
wsInfo, err := c.api.WS(c.ctx, nil, "")
76+
if err != nil {
77+
return fmt.Errorf("failed to get websocket info: %w", err)
78+
}
79+
80+
logger.InfoCF("qq", "Got WebSocket info", map[string]interface{}{
81+
"shards": wsInfo.Shards,
82+
})
83+
84+
// 创建并保存 sessionManager
85+
c.sessionManager = botgo.NewSessionManager()
86+
87+
// 在 goroutine 中启动 WebSocket 连接,避免阻塞
88+
go func() {
89+
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
90+
logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{
91+
"error": err.Error(),
92+
})
93+
c.setRunning(false)
94+
}
95+
}()
96+
97+
c.setRunning(true)
98+
logger.InfoC("qq", "QQ bot started successfully")
99+
100+
return nil
101+
}
102+
103+
func (c *QQChannel) Stop(ctx context.Context) error {
104+
logger.InfoC("qq", "Stopping QQ bot")
105+
c.setRunning(false)
106+
107+
if c.cancel != nil {
108+
c.cancel()
109+
}
110+
111+
return nil
112+
}
113+
114+
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
115+
if !c.IsRunning() {
116+
return fmt.Errorf("QQ bot not running")
117+
}
118+
119+
// 构造消息
120+
msgToCreate := &dto.MessageToCreate{
121+
Content: msg.Content,
122+
}
123+
124+
// C2C 消息发送
125+
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
126+
if err != nil {
127+
logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{
128+
"error": err.Error(),
129+
})
130+
return err
131+
}
132+
133+
return nil
134+
}
135+
136+
// handleC2CMessage 处理 QQ 私聊消息
137+
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
138+
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
139+
// 去重检查
140+
if c.isDuplicate(data.ID) {
141+
return nil
142+
}
143+
144+
// 提取用户信息
145+
var senderID string
146+
if data.Author != nil && data.Author.ID != "" {
147+
senderID = data.Author.ID
148+
} else {
149+
logger.WarnC("qq", "Received message with no sender ID")
150+
return nil
151+
}
152+
153+
// 提取消息内容
154+
content := data.Content
155+
if content == "" {
156+
logger.DebugC("qq", "Received empty message, ignoring")
157+
return nil
158+
}
159+
160+
logger.InfoCF("qq", "Received C2C message", map[string]interface{}{
161+
"sender": senderID,
162+
"length": len(content),
163+
})
164+
165+
// 转发到消息总线
166+
metadata := map[string]string{
167+
"message_id": data.ID,
168+
}
169+
170+
c.HandleMessage(senderID, senderID, content, []string{}, metadata)
171+
172+
return nil
173+
}
174+
}
175+
176+
// handleGroupATMessage 处理群@消息
177+
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
178+
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
179+
// 去重检查
180+
if c.isDuplicate(data.ID) {
181+
return nil
182+
}
183+
184+
// 提取用户信息
185+
var senderID string
186+
if data.Author != nil && data.Author.ID != "" {
187+
senderID = data.Author.ID
188+
} else {
189+
logger.WarnC("qq", "Received group message with no sender ID")
190+
return nil
191+
}
192+
193+
// 提取消息内容(去掉 @ 机器人部分)
194+
content := data.Content
195+
if content == "" {
196+
logger.DebugC("qq", "Received empty group message, ignoring")
197+
return nil
198+
}
199+
200+
logger.InfoCF("qq", "Received group AT message", map[string]interface{}{
201+
"sender": senderID,
202+
"group": data.GroupID,
203+
"length": len(content),
204+
})
205+
206+
// 转发到消息总线(使用 GroupID 作为 ChatID)
207+
metadata := map[string]string{
208+
"message_id": data.ID,
209+
"group_id": data.GroupID,
210+
}
211+
212+
c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)
213+
214+
return nil
215+
}
216+
}
217+
218+
// isDuplicate 检查消息是否重复
219+
func (c *QQChannel) isDuplicate(messageID string) bool {
220+
c.mu.Lock()
221+
defer c.mu.Unlock()
222+
223+
if c.processedIDs[messageID] {
224+
return true
225+
}
226+
227+
c.processedIDs[messageID] = true
228+
229+
// 简单清理:限制 map 大小
230+
if len(c.processedIDs) > 10000 {
231+
// 清空一半
232+
count := 0
233+
for id := range c.processedIDs {
234+
if count >= 5000 {
235+
break
236+
}
237+
delete(c.processedIDs, id)
238+
count++
239+
}
240+
}
241+
242+
return false
243+
}

0 commit comments

Comments
 (0)