This commit is contained in:
aishannon 2026-03-12 17:30:14 +08:00
parent 5af7904834
commit c8b6523542

View file

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