This commit is contained in:
aishannon 2026-03-12 17:30:14 +08:00 committed by shannonchen
parent 1eb6b37de0
commit e0b12c99b2

View file

@ -5,10 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
"github.com/tidwall/gjson"
"math/rand"
"math"
"os"
"path/filepath"
"regexp"
@ -17,6 +14,10 @@ import (
"sync/atomic"
"time"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
"github.com/tidwall/gjson"
"github.com/tencent-connect/botgo"
"github.com/tencent-connect/botgo/dto"
"github.com/tencent-connect/botgo/event"
@ -36,9 +37,11 @@ const (
dedupInterval = 60 * time.Second
dedupMaxSize = 10000 // hard cap on dedup map entries
typingResend = 8 * time.Second
typingSeconds = 10
typingSeconds = 20
)
var emojiRegexp = regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`)
type QQChannel struct {
*channels.BaseChannel
config config.QQConfig
@ -54,8 +57,8 @@ type QQChannel struct {
// Passive reply: store last inbound message ID per chat.
lastMsgID sync.Map // chatID → string
// msg_seq: per-chat atomic counter for multi-part replies.
msgSeqCounters sync.Map // chatID → *atomic.Uint32
replySeq atomic.Uint32
seqLock sync.Mutex
// Time-based dedup replacing the unbounded map.
dedup map[string]time.Time
@ -86,7 +89,6 @@ func (c *QQChannel) Start(ctx context.Context) error {
return fmt.Errorf("QQ app_id and app_secret not configured")
}
botgo.SetLogger(logger.NewLogger("botgo"))
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
// Reinitialize shutdown signal for clean restart.
@ -193,14 +195,13 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
chatKind := c.getChatKind(msg.ChatID)
textMsg, mdMsg := c.genReplyMsg(ctx, msg, chatKind)
for _, _v := range []dto.MessageToCreate{mdMsg, textMsg} {
var err error
var err error
for _, replyMsg := range []dto.MessageToCreate{mdMsg, textMsg} {
var replyMsgID *dto.Message
if chatKind == "group" {
replyMsgID, err = c.api.PostGroupMessage(ctx, msg.ChatID, _v)
replyMsgID, err = c.api.PostGroupMessage(ctx, msg.ChatID, replyMsg)
} else {
replyMsgID, err = c.api.PostC2CMessage(ctx, msg.ChatID, _v)
replyMsgID, err = c.api.PostC2CMessage(ctx, msg.ChatID, replyMsg)
}
if err == nil {
logger.InfoCF("qq", "Sent message", map[string]any{"postrsp ": replyMsgID})
@ -214,7 +215,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
})
}
}
return nil
return err
}
func (c *QQChannel) genReplyMsg(ctx context.Context, msg bus.OutboundMessage, chatKind string) (dto.MessageToCreate,
@ -241,14 +242,15 @@ func (c *QQChannel) getReplyExtInfo(ctx context.Context, chatID string) (replyID
replyID = msgID
}
}
// Increment msg_seq atomically for multi-part replies.
if counterVal, ok := c.msgSeqCounters.Load(chatID); ok {
if counter, ok := counterVal.(*atomic.Uint32); ok {
seq = counter.Add(1)
}
} else {
seq = rand.Uint32()
// Attach msg_seq for active reply.
c.seqLock.Lock()
defer c.seqLock.Unlock()
seq = c.replySeq.Add(1)
if seq > math.MaxInt32 {
c.replySeq.Store(1)
}
return replyID, seq
}
@ -449,7 +451,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
scope := channels.BuildMediaScope("qq", senderID, data.ID)
content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope)
content, mediaPaths := c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope)
if content == "" {
logger.DebugC("qq", "Received empty C2C message, ignoring")
return nil
@ -464,12 +466,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
c.chatType.Store(senderID, "direct")
c.lastMsgID.Store(senderID, data.ID)
// Reset msg_seq counter for new inbound message.
c.msgSeqCounters.Store(senderID, new(atomic.Uint32))
metadata := map[string]string{
"account_id": senderID,
}
metadata := map[string]string{}
sender := bus.SenderInfo{
Platform: "qq",
@ -514,7 +511,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
}
scope := channels.BuildMediaScope("qq", data.GroupID, data.ID)
content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope)
content, mediaPaths := c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope)
if content == "" {
logger.DebugC("qq", "Received empty group message, ignoring")
return nil
@ -535,9 +532,9 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
// Store chat routing context using GroupID as chatID.
c.chatType.Store(data.GroupID, "group")
c.lastMsgID.Store(data.GroupID, data.ID)
metadata := map[string]string{
"account_id": senderID,
"group_id": data.GroupID,
"group_id": data.GroupID,
}
sender := bus.SenderInfo{
@ -621,7 +618,8 @@ func (c *QQChannel) dedupJanitor() {
}
}
func (c *QQChannel) decodeMesasge(ctx context.Context, event *dto.WSPayload, data *dto.Message, scope string) (content string, mediaPaths []string) {
func (c *QQChannel) decodeMessage(ctx context.Context, event *dto.WSPayload, data *dto.Message,
scope string) (content string, mediaPaths []string) {
content = parseEmojiText(data.Content)
wavURL, asrReferText := getVoiceInfo(event)
@ -658,9 +656,8 @@ func (c *QQChannel) decodeMesasge(ctx context.Context, event *dto.WSPayload, dat
}
// processAttachments processes all attachments in a message
func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment, scope string) ([]string, string) {
mediaPaths := []string{}
content := ""
func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment,
scope string) (mediaPaths []string, content string) {
// Helper to register a local file with the media store
storeMedia := func(localPath, filename string) string {
@ -672,7 +669,7 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag
return ""
}
ref, err := store.Store(localPath, media.MediaMeta{Filename: filename, Source: "qq"}, scope)
if err != nil {
if err == nil {
logger.InfoCF("qq", "Stored media", map[string]any{
"scope": scope,
"localPath": localPath,
@ -680,10 +677,10 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag
})
return ref
}
logger.ErrorCF("qq", "Stored media", map[string]any{
logger.ErrorCF("qq", "Stored media err ", map[string]any{
"scope": scope,
"localPath": localPath,
"ref": ref,
"err": err.Error(),
})
return localPath
}
@ -810,10 +807,8 @@ func parseEmojiText(content string) string {
content = strings.ReplaceAll(content, "\\u003e", ">")
content = strings.ReplaceAll(content, `\"`, `"`)
combinedRegexp := regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`)
contentParts := combinedRegexp.Split(content, -1)
matches := combinedRegexp.FindAllString(content, -1)
contentParts := emojiRegexp.Split(content, -1)
matches := emojiRegexp.FindAllString(content, -1)
var result strings.Builder
for i, part := range contentParts {