Skip to content

Commit c8d3db3

Browse files
authored
Merge pull request sipeed#5 from yinwm/add-qq-channel
Add QQ channel support
2 parents 6a10db0 + a1dfb4c commit c8d3db3

8 files changed

Lines changed: 473 additions & 7 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
@@ -183,6 +183,7 @@ Talk to your picoclaw through Telegram
183183
|---------|-------|
184184
| **Telegram** | Easy (just a token) |
185185
| **Discord** | Easy (bot token + intents) |
186+
| **QQ** | Easy (AppID + AppSecret) |
186187

187188
<details>
188189
<summary><b>Telegram</b> (Recommended)</summary>
@@ -256,9 +257,42 @@ picoclaw gateway
256257
**6. Run**
257258

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

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

264298
## ⚙️ Configuration
@@ -355,6 +389,12 @@ picoclaw agent -m "Hello"
355389
"encryptKey": "",
356390
"verificationToken": "",
357391
"allowFrom": []
392+
},
393+
"qq": {
394+
"enabled": false,
395+
"app_id": "",
396+
"app_secret": "",
397+
"allow_from": []
358398
}
359399
},
360400
"tools": {

go.mod

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,18 @@ require (
99
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
1010
github.com/gorilla/websocket v1.5.3
1111
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
12+
github.com/tencent-connect/botgo v0.2.1
13+
golang.org/x/oauth2 v0.35.0
1214
)
1315

1416
require (
17+
github.com/go-resty/resty/v2 v2.17.1 // indirect
1518
github.com/gogo/protobuf v1.3.2 // indirect
19+
github.com/tidwall/gjson v1.18.0 // indirect
20+
github.com/tidwall/match v1.2.0 // indirect
21+
github.com/tidwall/pretty v1.2.1 // indirect
1622
golang.org/x/crypto v0.48.0 // indirect
23+
golang.org/x/net v0.49.0 // indirect
24+
golang.org/x/sync v0.19.0 // indirect
1725
golang.org/x/sys v0.41.0 // indirect
1826
)

go.sum

Lines changed: 142 additions & 0 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
@@ -110,6 +110,19 @@ func (m *Manager) initChannels() error {
110110
}
111111
}
112112

113+
if m.config.Channels.QQ.Enabled {
114+
logger.DebugC("channels", "Attempting to initialize QQ channel")
115+
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
116+
if err != nil {
117+
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{
118+
"error": err.Error(),
119+
})
120+
} else {
121+
m.channels["qq"] = qq
122+
logger.InfoC("channels", "QQ channel enabled successfully")
123+
}
124+
}
125+
113126
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
114127
"enabled_channels": len(m.channels),
115128
})

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)