feat(channels): enhance QQ channel with group support, typing, media, and URL sanitization

Add group message routing alongside existing C2C (direct) support using
chatType sync.Map to track whether a chatID is group or direct. Implement
passive reply with msg_id/msg_seq tracking for multi-part responses.

Add StartTyping (InputNotify msg_type=6 with periodic resend), SendMedia
(RichMediaMessage for HTTP/HTTPS URLs), and configurable Markdown message
support. Replace unbounded dedup map with TTL-based expiry and janitor
goroutine.

Sanitize URLs in group messages by replacing dots in domains with fullwidth
period to avoid QQ's URL blacklist rejection (error 40054010). Add rate
limit config (5 msg/s) and MaxMessageLength/SendMarkdown config fields.
This commit is contained in:
Hoshina 2026-03-07 17:47:46 +08:00
parent 7f6d95c026
commit f2e3945b46
4 changed files with 345 additions and 39 deletions

View file

@ -62,6 +62,7 @@ var channelRateConfig = map[string]float64{
"discord": 1, "discord": 1,
"slack": 1, "slack": 1,
"line": 10, "line": 10,
"qq": 5,
} }
type channelWorker struct { type channelWorker struct {

View file

@ -3,7 +3,10 @@ package qq
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo"
@ -20,6 +23,13 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
) )
const (
dedupTTL = 5 * time.Minute
dedupInterval = 60 * time.Second
typingResend = 8 * time.Second
typingSeconds = 10
)
type QQChannel struct { type QQChannel struct {
*channels.BaseChannel *channels.BaseChannel
config config.QQConfig config config.QQConfig
@ -28,12 +38,32 @@ type QQChannel struct {
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
sessionManager botgo.SessionManager sessionManager botgo.SessionManager
processedIDs map[string]bool
mu sync.RWMutex // Chat routing: track whether a chatID is group or direct.
chatType sync.Map // chatID → "group" | "direct"
// 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.Uint64
// Time-based dedup replacing the unbounded map.
dedup map[string]time.Time
muDedup sync.Mutex
// done is closed on Stop to shut down the dedup janitor.
done chan struct{}
} }
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
maxLen := cfg.MaxMessageLength
if maxLen == 0 {
maxLen = 2000
}
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom, base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(maxLen),
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID), channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
@ -41,7 +71,8 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel,
return &QQChannel{ return &QQChannel{
BaseChannel: base, BaseChannel: base,
config: cfg, config: cfg,
processedIDs: make(map[string]bool), dedup: make(map[string]time.Time),
done: make(chan struct{}),
}, nil }, nil
} }
@ -99,6 +130,9 @@ func (c *QQChannel) Start(ctx context.Context) error {
} }
}() }()
// start dedup janitor goroutine
go c.dedupJanitor()
c.SetRunning(true) c.SetRunning(true)
logger.InfoC("qq", "QQ bot started successfully") logger.InfoC("qq", "QQ bot started successfully")
@ -109,6 +143,9 @@ func (c *QQChannel) Stop(ctx context.Context) error {
logger.InfoC("qq", "Stopping QQ bot") logger.InfoC("qq", "Stopping QQ bot")
c.SetRunning(false) c.SetRunning(false)
// Signal the dedup janitor to stop.
close(c.done)
if c.cancel != nil { if c.cancel != nil {
c.cancel() c.cancel()
} }
@ -121,15 +158,67 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return channels.ErrNotRunning return channels.ErrNotRunning
} }
// construct message // Determine chat type (fallback to "direct" if not tracked).
msgToCreate := &dto.MessageToCreate{ chatKind := "direct"
Content: msg.Content, if v, ok := c.chatType.Load(msg.ChatID); ok {
if k, ok := v.(string); ok {
chatKind = k
}
}
// Build message with content.
msgToCreate := &dto.MessageToCreate{
Content: msg.Content,
MsgType: dto.TextMsg,
}
// Use Markdown message type if enabled in config.
if c.config.SendMarkdown {
msgToCreate.MsgType = dto.MarkdownMsg
msgToCreate.Markdown = &dto.Markdown{
Content: msg.Content,
}
// Clear plain content to avoid sending duplicate text.
msgToCreate.Content = ""
}
// Attach passive reply msg_id and msg_seq if available.
if v, ok := c.lastMsgID.Load(msg.ChatID); ok {
if msgID, ok := v.(string); ok && msgID != "" {
msgToCreate.MsgID = msgID
// Increment msg_seq atomically for multi-part replies.
if counterVal, ok := c.msgSeqCounters.Load(msg.ChatID); ok {
if counter, ok := counterVal.(*atomic.Uint64); ok {
seq := counter.Add(1)
msgToCreate.MsgSeq = uint32(seq)
}
}
}
}
// Sanitize URLs in group messages to avoid QQ's URL blacklist rejection.
if chatKind == "group" {
if msgToCreate.Content != "" {
msgToCreate.Content = sanitizeURLs(msgToCreate.Content)
}
if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" {
msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content)
}
}
// Route to group or C2C.
var err error
if chatKind == "group" {
_, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate)
} else {
_, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
} }
// send C2C message
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
if err != nil { if err != nil {
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{ logger.ErrorCF("qq", "Failed to send message", map[string]any{
"chat_id": msg.ChatID,
"chat_kind": chatKind,
"error": err.Error(), "error": err.Error(),
}) })
return fmt.Errorf("qq send: %w", channels.ErrTemporary) return fmt.Errorf("qq send: %w", channels.ErrTemporary)
@ -138,7 +227,152 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return nil return nil
} }
// handleC2CMessage handles QQ private messages // StartTyping implements channels.TypingCapable.
// It sends an InputNotify (msg_type=6) immediately and re-sends every 8 seconds.
// The returned stop function is idempotent and cancels the goroutine.
func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
// We need a stored msg_id for passive InputNotify; skip if none available.
v, ok := c.lastMsgID.Load(chatID)
if !ok {
return func() {}, nil
}
msgID, ok := v.(string)
if !ok || msgID == "" {
return func() {}, nil
}
chatKind := "direct"
if kv, ok := c.chatType.Load(chatID); ok {
if k, ok := kv.(string); ok {
chatKind = k
}
}
sendTyping := func(sendCtx context.Context) {
typingMsg := &dto.MessageToCreate{
MsgType: dto.InputNotifyMsg,
MsgID: msgID,
InputNotify: &dto.InputNotify{
InputType: 1,
InputSecond: typingSeconds,
},
}
var err error
if chatKind == "group" {
_, err = c.api.PostGroupMessage(sendCtx, chatID, typingMsg)
} else {
_, err = c.api.PostC2CMessage(sendCtx, chatID, typingMsg)
}
if err != nil {
logger.DebugCF("qq", "Failed to send typing indicator", map[string]any{
"chat_id": chatID,
"error": err.Error(),
})
}
}
// Send immediately.
sendTyping(ctx)
typingCtx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(typingResend)
defer ticker.Stop()
for {
select {
case <-typingCtx.Done():
return
case <-ticker.C:
sendTyping(typingCtx)
}
}
}()
return cancel, nil
}
// SendMedia implements the channels.MediaSender interface.
// It sends rich media (images, videos, audio, files) via QQ RichMediaMessage.
// Note: RichMediaMessage requires an HTTP/HTTPS URL. Local-only files are skipped
// with a warning since QQ API does not accept local file paths.
func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
chatKind := "direct"
if v, ok := c.chatType.Load(msg.ChatID); ok {
if k, ok := v.(string); ok {
chatKind = k
}
}
store := c.GetMediaStore()
if store == nil {
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
continue
}
// QQ RichMediaMessage requires an HTTP/HTTPS URL.
// If the resolved path is a local file, skip with a warning.
if !isHTTPURL(localPath) {
logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, skipping local file", map[string]any{
"ref": part.Ref,
"path": localPath,
})
continue
}
// Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file.
var fileType uint64
switch part.Type {
case "image":
fileType = 1
case "video":
fileType = 2
case "audio":
fileType = 3
default:
fileType = 4 // file
}
richMedia := &dto.RichMediaMessage{
FileType: fileType,
URL: localPath,
SrvSendMsg: true,
}
var sendErr error
if chatKind == "group" {
_, sendErr = c.api.PostGroupMessage(ctx, msg.ChatID, richMedia)
} else {
_, sendErr = c.api.PostC2CMessage(ctx, msg.ChatID, richMedia)
}
if sendErr != nil {
logger.ErrorCF("qq", "Failed to send media", map[string]any{
"type": part.Type,
"chat_id": msg.ChatID,
"error": sendErr.Error(),
})
return fmt.Errorf("qq send media: %w", channels.ErrTemporary)
}
}
return nil
}
// handleC2CMessage handles QQ private messages.
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
// deduplication check // deduplication check
@ -167,7 +401,14 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
"length": len(content), "length": len(content),
}) })
// 转发到消息总线 // Store chat routing context.
c.chatType.Store(senderID, "direct")
c.lastMsgID.Store(senderID, data.ID)
// Reset msg_seq counter for new inbound message.
var counter atomic.Uint64
c.msgSeqCounters.Store(senderID, &counter)
metadata := map[string]string{} metadata := map[string]string{}
sender := bus.SenderInfo{ sender := bus.SenderInfo{
@ -195,7 +436,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
} }
} }
// handleGroupATMessage handles QQ group @ messages // handleGroupATMessage handles QQ group @ messages.
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error { return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
// deduplication check // deduplication check
@ -232,7 +473,14 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
"length": len(content), "length": len(content),
}) })
// 转发到消息总线(使用 GroupID 作为 ChatID // Store chat routing context using GroupID as chatID.
c.chatType.Store(data.GroupID, "group")
c.lastMsgID.Store(data.GroupID, data.ID)
// Reset msg_seq counter for new inbound message.
var counter atomic.Uint64
c.msgSeqCounters.Store(data.GroupID, &counter)
metadata := map[string]string{ metadata := map[string]string{
"group_id": data.GroupID, "group_id": data.GroupID,
} }
@ -262,29 +510,83 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
} }
} }
// isDuplicate 检查消息是否重复 // isDuplicate checks whether a message has been seen within the TTL window.
func (c *QQChannel) isDuplicate(messageID string) bool { func (c *QQChannel) isDuplicate(messageID string) bool {
c.mu.Lock() c.muDedup.Lock()
defer c.mu.Unlock() defer c.muDedup.Unlock()
if c.processedIDs[messageID] { if ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL {
return true return true
} }
c.processedIDs[messageID] = true c.dedup[messageID] = time.Now()
// 简单清理:限制 map 大小
if len(c.processedIDs) > 10000 {
// 清空一半
count := 0
for id := range c.processedIDs {
if count >= 5000 {
break
}
delete(c.processedIDs, id)
count++
}
}
return false return false
} }
// dedupJanitor periodically evicts expired entries from the dedup map.
func (c *QQChannel) dedupJanitor() {
ticker := time.NewTicker(dedupInterval)
defer ticker.Stop()
for {
select {
case <-c.done:
return
case <-ticker.C:
c.muDedup.Lock()
now := time.Now()
for id, ts := range c.dedup {
if now.Sub(ts) >= dedupTTL {
delete(c.dedup, id)
}
}
c.muDedup.Unlock()
}
}
}
// isHTTPURL returns true if s starts with http:// or https://.
func isHTTPURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
// urlPattern matches URLs like http(s)://domain.tld/path and bare domain.tld/path patterns.
var urlPattern = regexp.MustCompile(
`(?i)` +
`(?:https?://)?` + // optional scheme
`(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+` + // domain parts
`[a-zA-Z]{2,}` + // TLD
`(?:[/?#]\S*)?`, // optional path/query/fragment
)
// sanitizeURLs replaces dots in URL domains with "。" (fullwidth period)
// to prevent QQ's URL blacklist from rejecting the message.
func sanitizeURLs(text string) string {
return urlPattern.ReplaceAllStringFunc(text, func(match string) string {
// Split into scheme + rest.
var scheme, rest string
if idx := strings.Index(match, "://"); idx != -1 {
scheme = match[:idx+3]
rest = match[idx+3:]
} else {
rest = match
}
// Find where the domain ends (first / ? or #).
domainEnd := len(rest)
for i, ch := range rest {
if ch == '/' || ch == '?' || ch == '#' {
domainEnd = i
break
}
}
domain := rest[:domainEnd]
path := rest[domainEnd:]
// Replace dots in domain only.
domain = strings.ReplaceAll(domain, ".", "。")
return scheme + domain + path
})
}

View file

@ -309,6 +309,8 @@ type QQConfig struct {
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
} }

View file

@ -84,6 +84,7 @@ func DefaultConfig() *Config {
AppID: "", AppID: "",
AppSecret: "", AppSecret: "",
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
MaxMessageLength: 2000,
}, },
DingTalk: DingTalkConfig{ DingTalk: DingTalkConfig{
Enabled: false, Enabled: false,