2026-02-20 15:25:44 +00:00
|
|
|
package qq
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-03-19 08:27:34 +00:00
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
2026-02-20 15:25:44 +00:00
|
|
|
"fmt"
|
2026-03-19 08:27:34 +00:00
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"path"
|
|
|
|
|
"path/filepath"
|
2026-03-10 04:07:02 +00:00
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
2026-02-20 15:25:44 +00:00
|
|
|
"sync"
|
2026-03-10 04:07:02 +00:00
|
|
|
"sync/atomic"
|
2026-02-20 15:25:44 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/tencent-connect/botgo"
|
2026-03-19 08:27:34 +00:00
|
|
|
"github.com/tencent-connect/botgo/constant"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/tencent-connect/botgo/dto"
|
|
|
|
|
"github.com/tencent-connect/botgo/event"
|
2026-03-19 08:27:34 +00:00
|
|
|
"github.com/tencent-connect/botgo/openapi/options"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/tencent-connect/botgo/token"
|
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-02-22 22:56:48 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/identity"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-03-19 08:27:34 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
2026-02-20 15:25:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
const (
|
|
|
|
|
dedupTTL = 5 * time.Minute
|
|
|
|
|
dedupInterval = 60 * time.Second
|
|
|
|
|
dedupMaxSize = 10000 // hard cap on dedup map entries
|
|
|
|
|
typingResend = 8 * time.Second
|
|
|
|
|
typingSeconds = 10
|
2026-03-19 08:27:34 +00:00
|
|
|
bytesPerMiB = 1024 * 1024
|
2026-03-10 04:07:02 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-19 08:27:34 +00:00
|
|
|
type qqAPI interface {
|
|
|
|
|
WS(ctx context.Context, params map[string]string, body string) (*dto.WebsocketAP, error)
|
|
|
|
|
PostGroupMessage(
|
|
|
|
|
ctx context.Context, groupID string, msg dto.APIMessage, opt ...options.Option,
|
|
|
|
|
) (*dto.Message, error)
|
|
|
|
|
PostC2CMessage(
|
|
|
|
|
ctx context.Context, userID string, msg dto.APIMessage, opt ...options.Option,
|
|
|
|
|
) (*dto.Message, error)
|
|
|
|
|
Transport(ctx context.Context, method, url string, body any) ([]byte, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
type QQChannel struct {
|
|
|
|
|
*channels.BaseChannel
|
2026-04-11 16:57:26 +00:00
|
|
|
bc *config.Channel
|
|
|
|
|
config *config.QQSettings
|
2026-03-19 08:27:34 +00:00
|
|
|
api qqAPI
|
2026-02-20 15:25:44 +00:00
|
|
|
tokenSource oauth2.TokenSource
|
|
|
|
|
ctx context.Context
|
|
|
|
|
cancel context.CancelFunc
|
|
|
|
|
sessionManager botgo.SessionManager
|
2026-03-19 08:27:34 +00:00
|
|
|
downloadFn func(urlStr, filename string) string
|
2026-03-10 04:07:02 +00:00
|
|
|
|
|
|
|
|
// 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{}
|
|
|
|
|
stopOnce sync.Once
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
func NewQQChannel(bc *config.Channel, cfg *config.QQSettings, messageBus *bus.MessageBus) (*QQChannel, error) {
|
|
|
|
|
base := channels.NewBaseChannel("qq", cfg, messageBus, bc.AllowFrom,
|
2026-03-10 04:07:02 +00:00
|
|
|
channels.WithMaxMessageLength(cfg.MaxMessageLength),
|
2026-04-11 16:57:26 +00:00
|
|
|
channels.WithGroupTrigger(bc.GroupTrigger),
|
|
|
|
|
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
)
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
return &QQChannel{
|
2026-03-10 04:07:02 +00:00
|
|
|
BaseChannel: base,
|
2026-04-11 16:57:26 +00:00
|
|
|
bc: bc,
|
2026-03-10 04:07:02 +00:00
|
|
|
config: cfg,
|
|
|
|
|
dedup: make(map[string]time.Time),
|
|
|
|
|
done: make(chan struct{}),
|
2026-02-20 15:25:44 +00:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *QQChannel) Start(ctx context.Context) error {
|
2026-03-27 16:03:34 +00:00
|
|
|
if c.config.AppID == "" || c.config.AppSecret.String() == "" {
|
2026-02-20 15:25:44 +00:00
|
|
|
return fmt.Errorf("QQ app_id and app_secret not configured")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 08:27:34 +00:00
|
|
|
botgo.SetLogger(newBotGoLogger("botgo"))
|
2026-02-20 15:25:44 +00:00
|
|
|
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// Reinitialize shutdown signal for clean restart.
|
|
|
|
|
c.done = make(chan struct{})
|
|
|
|
|
c.stopOnce = sync.Once{}
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// create token source
|
2026-02-20 15:25:44 +00:00
|
|
|
credentials := &token.QQBotCredentials{
|
|
|
|
|
AppID: c.config.AppID,
|
2026-03-27 16:03:34 +00:00
|
|
|
AppSecret: c.config.AppSecret.String(),
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// create child context
|
2026-02-20 15:25:44 +00:00
|
|
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// start auto-refresh token goroutine
|
2026-02-20 15:25:44 +00:00
|
|
|
if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to start token refresh: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// initialize OpenAPI client
|
2026-02-20 15:25:44 +00:00
|
|
|
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// register event handlers
|
2026-02-20 15:25:44 +00:00
|
|
|
intent := event.RegisterHandlers(
|
|
|
|
|
c.handleC2CMessage(),
|
|
|
|
|
c.handleGroupATMessage(),
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// get WebSocket endpoint
|
2026-02-20 15:25:44 +00:00
|
|
|
wsInfo, err := c.api.WS(c.ctx, nil, "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to get websocket info: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.InfoCF("qq", "Got WebSocket info", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"shards": wsInfo.Shards,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// create and save sessionManager
|
2026-02-20 15:25:44 +00:00
|
|
|
c.sessionManager = botgo.NewSessionManager()
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// start WebSocket connection in goroutine to avoid blocking
|
2026-02-20 15:25:44 +00:00
|
|
|
go func() {
|
|
|
|
|
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
c.SetRunning(false)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// start dedup janitor goroutine
|
|
|
|
|
go c.dedupJanitor()
|
|
|
|
|
|
|
|
|
|
// Pre-register reasoning_channel_id as group chat if configured,
|
|
|
|
|
// so outbound-only destinations are routed correctly.
|
2026-04-11 16:57:26 +00:00
|
|
|
if c.bc.ReasoningChannelID != "" {
|
|
|
|
|
c.chatType.Store(c.bc.ReasoningChannelID, "group")
|
2026-03-10 04:07:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
c.SetRunning(true)
|
|
|
|
|
logger.InfoC("qq", "QQ bot started successfully")
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *QQChannel) Stop(ctx context.Context) error {
|
|
|
|
|
logger.InfoC("qq", "Stopping QQ bot")
|
|
|
|
|
c.SetRunning(false)
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// Signal the dedup janitor to stop (idempotent).
|
|
|
|
|
c.stopOnce.Do(func() { close(c.done) })
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
if c.cancel != nil {
|
|
|
|
|
c.cancel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// getChatKind returns the chat type for a given chatID ("group" or "direct").
|
|
|
|
|
// Unknown chatIDs default to "group" and log a warning, since QQ group IDs are
|
|
|
|
|
// more common as outbound-only destinations (e.g. reasoning_channel_id).
|
|
|
|
|
func (c *QQChannel) getChatKind(chatID string) string {
|
|
|
|
|
if v, ok := c.chatType.Load(chatID); ok {
|
|
|
|
|
if k, ok := v.(string); ok {
|
|
|
|
|
return k
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
logger.DebugCF("qq", "Unknown chat type for chatID, defaulting to group", map[string]any{
|
|
|
|
|
"chat_id": chatID,
|
|
|
|
|
})
|
|
|
|
|
return "group"
|
|
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
2026-02-20 15:25:44 +00:00
|
|
|
if !c.IsRunning() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, channels.ErrNotRunning
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
chatKind := c.getChatKind(msg.ChatID)
|
|
|
|
|
|
|
|
|
|
// Build message with content.
|
2026-02-20 15:25:44 +00:00
|
|
|
msgToCreate := &dto.MessageToCreate{
|
|
|
|
|
Content: msg.Content,
|
2026-03-10 04:07:02 +00:00
|
|
|
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 = ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 08:27:34 +00:00
|
|
|
c.applyPassiveReplyMetadata(msg.ChatID, msgToCreate)
|
2026-03-10 04:07:02 +00:00
|
|
|
|
|
|
|
|
// 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.
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
var (
|
|
|
|
|
sentMsg *dto.Message
|
|
|
|
|
err error
|
|
|
|
|
)
|
2026-03-10 04:07:02 +00:00
|
|
|
if chatKind == "group" {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
sentMsg, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate)
|
2026-03-10 04:07:02 +00:00
|
|
|
} else {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
sentMsg, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
2026-03-10 04:07:02 +00:00
|
|
|
logger.ErrorCF("qq", "Failed to send message", map[string]any{
|
|
|
|
|
"chat_id": msg.ChatID,
|
|
|
|
|
"chat_kind": chatKind,
|
|
|
|
|
"error": err.Error(),
|
2026-02-20 15:25:44 +00:00
|
|
|
})
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("qq send: %w", channels.ErrTemporary)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
if sentMsg == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
return []string{sentMsg.ID}, nil
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// 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 := c.getChatKind(chatID)
|
|
|
|
|
|
|
|
|
|
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(c.ctx)
|
|
|
|
|
|
|
|
|
|
typingCtx, cancel := context.WithCancel(c.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.
|
2026-03-19 08:27:34 +00:00
|
|
|
// QQ group/C2C media sending is a two-step flow:
|
|
|
|
|
// 1. Upload media to /files using a remote URL or base64-encoded local bytes.
|
|
|
|
|
// 2. Send a msg_type=7 message using the returned file_info.
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
2026-03-10 04:07:02 +00:00
|
|
|
if !c.IsRunning() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, channels.ErrNotRunning
|
2026-03-10 04:07:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chatKind := c.getChatKind(msg.ChatID)
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
var messageIDs []string
|
2026-03-10 04:07:02 +00:00
|
|
|
for _, part := range msg.Parts {
|
2026-03-19 08:27:34 +00:00
|
|
|
fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorCF("qq", "Failed to upload media", map[string]any{
|
|
|
|
|
"type": part.Type,
|
|
|
|
|
"chat_id": msg.ChatID,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
if errors.Is(err, channels.ErrSendFailed) {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, err
|
2026-03-10 04:07:02 +00:00
|
|
|
}
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary)
|
2026-03-10 04:07:02 +00:00
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
sentMsg, err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo)
|
|
|
|
|
if err != nil {
|
2026-03-10 04:07:02 +00:00
|
|
|
logger.ErrorCF("qq", "Failed to send media", map[string]any{
|
|
|
|
|
"type": part.Type,
|
|
|
|
|
"chat_id": msg.ChatID,
|
2026-03-19 08:27:34 +00:00
|
|
|
"error": err.Error(),
|
2026-03-10 04:07:02 +00:00
|
|
|
})
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary)
|
|
|
|
|
}
|
|
|
|
|
if sentMsg != nil && sentMsg.ID != "" {
|
|
|
|
|
messageIDs = append(messageIDs, sentMsg.ID)
|
2026-03-10 04:07:02 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return messageIDs, nil
|
2026-03-10 04:07:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 08:27:34 +00:00
|
|
|
type qqMediaUpload struct {
|
|
|
|
|
FileType uint64 `json:"file_type"`
|
|
|
|
|
URL string `json:"url,omitempty"`
|
|
|
|
|
FileData string `json:"file_data,omitempty"`
|
2026-03-23 21:00:15 +00:00
|
|
|
FileName string `json:"file_name,omitempty"`
|
2026-03-19 08:27:34 +00:00
|
|
|
SrvSendMsg bool `json:"srv_send_msg,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *QQChannel) uploadMedia(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
chatKind, chatID string,
|
|
|
|
|
part bus.MediaPart,
|
|
|
|
|
) ([]byte, error) {
|
|
|
|
|
payload, err := c.buildMediaUpload(part)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body, err := c.api.Transport(ctx, http.MethodPost, c.mediaUploadURL(chatKind, chatID), payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var uploaded dto.Message
|
|
|
|
|
if err := json.Unmarshal(body, &uploaded); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("qq decode media upload response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if len(uploaded.FileInfo) == 0 {
|
|
|
|
|
return nil, fmt.Errorf("qq upload media: missing file_info")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return uploaded.FileInfo, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) {
|
2026-03-22 09:19:11 +00:00
|
|
|
payload := &qqMediaUpload{}
|
2026-03-19 08:27:34 +00:00
|
|
|
|
|
|
|
|
mediaRef := part.Ref
|
|
|
|
|
if isHTTPURL(mediaRef) {
|
2026-03-22 09:19:11 +00:00
|
|
|
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
2026-03-19 08:27:34 +00:00
|
|
|
payload.URL = mediaRef
|
2026-03-23 21:00:15 +00:00
|
|
|
payload.FileName = qqUploadFilename(part, mediaRef, payload.FileType)
|
2026-03-19 08:27:34 +00:00
|
|
|
return payload, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
store := c.GetMediaStore()
|
|
|
|
|
if store == nil {
|
|
|
|
|
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 09:19:11 +00:00
|
|
|
resolved, meta, err := store.ResolveWithMeta(part.Ref)
|
2026-03-19 08:27:34 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("qq resolve media ref %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
|
|
|
|
|
}
|
2026-03-22 09:19:11 +00:00
|
|
|
if part.Filename == "" {
|
|
|
|
|
part.Filename = meta.Filename
|
|
|
|
|
}
|
|
|
|
|
if part.ContentType == "" {
|
|
|
|
|
part.ContentType = meta.ContentType
|
|
|
|
|
}
|
2026-03-19 08:27:34 +00:00
|
|
|
|
|
|
|
|
if isHTTPURL(resolved) {
|
2026-03-22 09:19:11 +00:00
|
|
|
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
2026-03-19 08:27:34 +00:00
|
|
|
payload.URL = resolved
|
2026-03-23 21:00:15 +00:00
|
|
|
payload.FileName = qqUploadFilename(part, resolved, payload.FileType)
|
2026-03-19 08:27:34 +00:00
|
|
|
return payload, nil
|
|
|
|
|
}
|
2026-03-22 09:19:11 +00:00
|
|
|
payload.FileType = qqFileType(c.outboundMediaType(part, resolved))
|
2026-03-23 21:00:15 +00:00
|
|
|
payload.FileName = qqUploadFilename(part, resolved, payload.FileType)
|
2026-03-19 08:27:34 +00:00
|
|
|
|
|
|
|
|
if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 {
|
|
|
|
|
info, statErr := os.Stat(resolved)
|
|
|
|
|
if statErr != nil {
|
|
|
|
|
return nil, fmt.Errorf("qq stat local media %q: %v: %w", resolved, statErr, channels.ErrSendFailed)
|
|
|
|
|
}
|
|
|
|
|
if info.Size() > limitBytes {
|
|
|
|
|
return nil, fmt.Errorf(
|
|
|
|
|
"qq local media %q exceeds max_base64_file_size_mib (%d > %d bytes): %w",
|
|
|
|
|
resolved,
|
|
|
|
|
info.Size(),
|
|
|
|
|
limitBytes,
|
|
|
|
|
channels.ErrSendFailed,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data, err := os.ReadFile(resolved)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("qq read local media %q: %v: %w", resolved, err, channels.ErrSendFailed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload.FileData = base64.StdEncoding.EncodeToString(data)
|
|
|
|
|
return payload, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 21:00:15 +00:00
|
|
|
func qqUploadFilename(part bus.MediaPart, resolved string, fileType uint64) string {
|
|
|
|
|
if fileType != qqFileType("file") {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if part.Filename != "" {
|
|
|
|
|
return part.Filename
|
|
|
|
|
}
|
|
|
|
|
if isHTTPURL(resolved) {
|
|
|
|
|
if parsed, err := url.Parse(resolved); err == nil {
|
|
|
|
|
if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" {
|
|
|
|
|
return base
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if base := filepath.Base(resolved); base != "" && base != "." {
|
|
|
|
|
return base
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 09:19:11 +00:00
|
|
|
func (c *QQChannel) outboundMediaType(part bus.MediaPart, localPath string) string {
|
|
|
|
|
if part.Type != "audio" {
|
|
|
|
|
return part.Type
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if localPath == "" {
|
|
|
|
|
logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{
|
|
|
|
|
"ref": part.Ref,
|
|
|
|
|
"filename": part.Filename,
|
|
|
|
|
})
|
|
|
|
|
return "file"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
duration, ok, err := qqAudioDuration(localPath, part.Filename, part.ContentType)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.WarnCF("qq", "Failed to detect audio duration, sending as file", map[string]any{
|
|
|
|
|
"ref": part.Ref,
|
|
|
|
|
"filename": part.Filename,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
return "file"
|
|
|
|
|
}
|
|
|
|
|
if !ok {
|
|
|
|
|
logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{
|
|
|
|
|
"ref": part.Ref,
|
|
|
|
|
"filename": part.Filename,
|
|
|
|
|
})
|
|
|
|
|
return "file"
|
|
|
|
|
}
|
|
|
|
|
if duration > qqVoiceMaxDuration {
|
|
|
|
|
logger.InfoCF("qq", "Sending audio as file because it exceeds QQ voice limit", map[string]any{
|
|
|
|
|
"ref": part.Ref,
|
|
|
|
|
"filename": part.Filename,
|
|
|
|
|
"duration_seconds": duration.Seconds(),
|
|
|
|
|
"limit_seconds": qqVoiceMaxDuration.Seconds(),
|
|
|
|
|
})
|
|
|
|
|
return "file"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "audio"
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 08:27:34 +00:00
|
|
|
func (c *QQChannel) sendUploadedMedia(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
chatKind, chatID string,
|
|
|
|
|
part bus.MediaPart,
|
|
|
|
|
fileInfo []byte,
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
) (*dto.Message, error) {
|
2026-03-19 08:27:34 +00:00
|
|
|
msg := &dto.MessageToCreate{
|
|
|
|
|
Content: part.Caption,
|
|
|
|
|
MsgType: dto.RichMediaMsg,
|
|
|
|
|
Media: &dto.MediaInfo{
|
|
|
|
|
FileInfo: fileInfo,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
c.applyPassiveReplyMetadata(chatID, msg)
|
|
|
|
|
|
|
|
|
|
if chatKind == "group" && msg.Content != "" {
|
|
|
|
|
msg.Content = sanitizeURLs(msg.Content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if chatKind == "group" {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
sentMsg, err := c.api.PostGroupMessage(ctx, chatID, msg)
|
|
|
|
|
return sentMsg, err
|
2026-03-19 08:27:34 +00:00
|
|
|
}
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
sentMsg, err := c.api.PostC2CMessage(ctx, chatID, msg)
|
|
|
|
|
return sentMsg, err
|
2026-03-19 08:27:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) {
|
|
|
|
|
if v, ok := c.lastMsgID.Load(chatID); ok {
|
|
|
|
|
if msgID, ok := v.(string); ok && msgID != "" {
|
|
|
|
|
msg.MsgID = msgID
|
|
|
|
|
|
|
|
|
|
// Increment msg_seq atomically for multi-part replies.
|
|
|
|
|
if counterVal, ok := c.msgSeqCounters.Load(chatID); ok {
|
|
|
|
|
if counter, ok := counterVal.(*atomic.Uint64); ok {
|
|
|
|
|
seq := counter.Add(1)
|
|
|
|
|
msg.MsgSeq = uint32(seq)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *QQChannel) mediaUploadURL(chatKind, chatID string) string {
|
|
|
|
|
base := constant.APIDomain
|
|
|
|
|
if chatKind == "group" {
|
|
|
|
|
return fmt.Sprintf("%s/v2/groups/%s/files", base, chatID)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%s/v2/users/%s/files", base, chatID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func qqFileType(partType string) uint64 {
|
|
|
|
|
switch partType {
|
|
|
|
|
case "image":
|
|
|
|
|
return 1
|
|
|
|
|
case "video":
|
|
|
|
|
return 2
|
|
|
|
|
case "audio":
|
|
|
|
|
return 3
|
|
|
|
|
default:
|
|
|
|
|
return 4
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *QQChannel) maxBase64FileSizeBytes() int64 {
|
2026-04-13 16:34:17 +00:00
|
|
|
if c.config == nil {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
2026-03-19 08:27:34 +00:00
|
|
|
if c.config.MaxBase64FileSizeMiB <= 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return c.config.MaxBase64FileSizeMiB * bytesPerMiB
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 16:34:17 +00:00
|
|
|
func (c *QQChannel) accountID() string {
|
|
|
|
|
if c.config == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return c.config.AppID
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// handleC2CMessage handles QQ private messages.
|
2026-02-20 15:25:44 +00:00
|
|
|
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
|
|
|
|
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
2026-02-24 04:17:11 +00:00
|
|
|
// deduplication check
|
2026-02-20 15:25:44 +00:00
|
|
|
if c.isDuplicate(data.ID) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// extract user info
|
2026-02-20 15:25:44 +00:00
|
|
|
var senderID string
|
|
|
|
|
if data.Author != nil && data.Author.ID != "" {
|
|
|
|
|
senderID = data.Author.ID
|
|
|
|
|
} else {
|
|
|
|
|
logger.WarnC("qq", "Received message with no sender ID")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 08:27:34 +00:00
|
|
|
sender := bus.SenderInfo{
|
|
|
|
|
Platform: "qq",
|
|
|
|
|
PlatformID: data.Author.ID,
|
|
|
|
|
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !c.IsAllowedSender(sender) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
content := strings.TrimSpace(data.Content)
|
|
|
|
|
mediaPaths, attachmentNotes := c.extractInboundAttachments(senderID, data.ID, data.Attachments)
|
|
|
|
|
for _, note := range attachmentNotes {
|
|
|
|
|
content = appendContent(content, note)
|
|
|
|
|
}
|
|
|
|
|
if content == "" && len(mediaPaths) == 0 {
|
|
|
|
|
logger.DebugC("qq", "Received empty C2C message with no attachments, ignoring")
|
2026-02-20 15:25:44 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.InfoCF("qq", "Received C2C message", map[string]any{
|
2026-03-19 08:27:34 +00:00
|
|
|
"sender": senderID,
|
|
|
|
|
"length": len(content),
|
|
|
|
|
"media_count": len(mediaPaths),
|
2026-02-20 15:25:44 +00:00
|
|
|
})
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// Store chat routing context.
|
|
|
|
|
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.Uint64))
|
|
|
|
|
|
2026-03-13 15:08:55 +00:00
|
|
|
metadata := map[string]string{
|
|
|
|
|
"account_id": senderID,
|
|
|
|
|
}
|
2026-04-01 05:58:31 +00:00
|
|
|
inboundCtx := bus.InboundContext{
|
|
|
|
|
Channel: c.Name(),
|
2026-04-13 16:34:17 +00:00
|
|
|
Account: c.accountID(),
|
2026-04-01 05:58:31 +00:00
|
|
|
ChatID: senderID,
|
|
|
|
|
ChatType: "direct",
|
|
|
|
|
SenderID: senderID,
|
|
|
|
|
MessageID: data.ID,
|
|
|
|
|
Raw: metadata,
|
|
|
|
|
}
|
2026-02-22 13:57:12 +00:00
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
c.HandleInboundContext(c.ctx, senderID, content, mediaPaths, inboundCtx, sender)
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// handleGroupATMessage handles QQ group @ messages.
|
2026-02-20 15:25:44 +00:00
|
|
|
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
|
|
|
|
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
2026-02-24 04:17:11 +00:00
|
|
|
// deduplication check
|
2026-02-20 15:25:44 +00:00
|
|
|
if c.isDuplicate(data.ID) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// extract user info
|
2026-02-20 15:25:44 +00:00
|
|
|
var senderID string
|
|
|
|
|
if data.Author != nil && data.Author.ID != "" {
|
|
|
|
|
senderID = data.Author.ID
|
|
|
|
|
} else {
|
|
|
|
|
logger.WarnC("qq", "Received group message with no sender ID")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 08:27:34 +00:00
|
|
|
sender := bus.SenderInfo{
|
|
|
|
|
Platform: "qq",
|
|
|
|
|
PlatformID: data.Author.ID,
|
|
|
|
|
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !c.IsAllowedSender(sender) {
|
2026-02-20 15:25:44 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 08:27:34 +00:00
|
|
|
content := strings.TrimSpace(data.Content)
|
|
|
|
|
mediaPaths, attachmentNotes := c.extractInboundAttachments(data.GroupID, data.ID, data.Attachments)
|
|
|
|
|
for _, note := range attachmentNotes {
|
|
|
|
|
content = appendContent(content, note)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GroupAT event means bot is always mentioned; apply group trigger filtering.
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
respond, cleaned := c.ShouldRespondInGroup(true, content)
|
|
|
|
|
if !respond {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
content = cleaned
|
2026-03-19 08:27:34 +00:00
|
|
|
if content == "" && len(mediaPaths) == 0 {
|
|
|
|
|
logger.DebugC("qq", "Received empty group message with no attachments, ignoring")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.InfoCF("qq", "Received group AT message", map[string]any{
|
2026-03-19 08:27:34 +00:00
|
|
|
"sender": senderID,
|
|
|
|
|
"group": data.GroupID,
|
|
|
|
|
"length": len(content),
|
|
|
|
|
"media_count": len(mediaPaths),
|
2026-02-20 15:25:44 +00:00
|
|
|
})
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// 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.
|
|
|
|
|
c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
metadata := map[string]string{
|
2026-03-13 15:08:55 +00:00
|
|
|
"account_id": senderID,
|
|
|
|
|
"group_id": data.GroupID,
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
2026-04-01 05:58:31 +00:00
|
|
|
inboundCtx := bus.InboundContext{
|
|
|
|
|
Channel: c.Name(),
|
2026-04-13 16:34:17 +00:00
|
|
|
Account: c.accountID(),
|
2026-04-01 05:58:31 +00:00
|
|
|
ChatID: data.GroupID,
|
|
|
|
|
ChatType: "group",
|
|
|
|
|
SenderID: senderID,
|
|
|
|
|
MessageID: data.ID,
|
|
|
|
|
Mentioned: true,
|
|
|
|
|
Raw: metadata,
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
c.HandleInboundContext(c.ctx, data.GroupID, content, mediaPaths, inboundCtx, sender)
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 08:27:34 +00:00
|
|
|
func (c *QQChannel) extractInboundAttachments(
|
|
|
|
|
chatID, messageID string,
|
|
|
|
|
attachments []*dto.MessageAttachment,
|
|
|
|
|
) ([]string, []string) {
|
|
|
|
|
if len(attachments) == 0 {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope := channels.BuildMediaScope("qq", chatID, messageID)
|
|
|
|
|
mediaPaths := make([]string, 0, len(attachments))
|
|
|
|
|
notes := make([]string, 0, len(attachments))
|
|
|
|
|
|
|
|
|
|
storeMedia := func(localPath string, attachment *dto.MessageAttachment) string {
|
|
|
|
|
if store := c.GetMediaStore(); store != nil {
|
|
|
|
|
ref, err := store.Store(localPath, media.MediaMeta{
|
2026-03-23 04:13:59 +00:00
|
|
|
Filename: qqAttachmentFilename(attachment),
|
|
|
|
|
ContentType: attachment.ContentType,
|
|
|
|
|
Source: "qq",
|
|
|
|
|
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
2026-03-19 08:27:34 +00:00
|
|
|
}, scope)
|
|
|
|
|
if err == nil {
|
|
|
|
|
return ref
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return localPath
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, attachment := range attachments {
|
|
|
|
|
if attachment == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filename := qqAttachmentFilename(attachment)
|
|
|
|
|
if localPath := c.downloadAttachment(attachment.URL, filename); localPath != "" {
|
|
|
|
|
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment))
|
|
|
|
|
} else if attachment.URL != "" {
|
|
|
|
|
mediaPaths = append(mediaPaths, attachment.URL)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
notes = append(notes, qqAttachmentNote(attachment))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return mediaPaths, notes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *QQChannel) downloadAttachment(urlStr, filename string) string {
|
|
|
|
|
if urlStr == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if c.downloadFn != nil {
|
|
|
|
|
return c.downloadFn(urlStr, filename)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return utils.DownloadFile(urlStr, filename, utils.DownloadOptions{
|
|
|
|
|
LoggerPrefix: "qq",
|
|
|
|
|
ExtraHeaders: c.downloadHeaders(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *QQChannel) downloadHeaders() map[string]string {
|
|
|
|
|
headers := map[string]string{}
|
|
|
|
|
|
|
|
|
|
if c.config.AppID != "" {
|
|
|
|
|
headers["X-Union-Appid"] = c.config.AppID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.tokenSource != nil {
|
|
|
|
|
if tk, err := c.tokenSource.Token(); err == nil && tk.AccessToken != "" {
|
|
|
|
|
auth := strings.TrimSpace(tk.TokenType + " " + tk.AccessToken)
|
|
|
|
|
if auth != "" {
|
|
|
|
|
headers["Authorization"] = auth
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(headers) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return headers
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func qqAttachmentFilename(attachment *dto.MessageAttachment) string {
|
|
|
|
|
if attachment == nil {
|
|
|
|
|
return "attachment"
|
|
|
|
|
}
|
|
|
|
|
if attachment.FileName != "" {
|
|
|
|
|
return attachment.FileName
|
|
|
|
|
}
|
|
|
|
|
if attachment.URL != "" {
|
|
|
|
|
if parsed, err := url.Parse(attachment.URL); err == nil {
|
|
|
|
|
if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" {
|
|
|
|
|
return base
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch qqAttachmentKind(attachment) {
|
|
|
|
|
case "image":
|
|
|
|
|
return "image"
|
|
|
|
|
case "audio":
|
|
|
|
|
return "audio"
|
|
|
|
|
case "video":
|
|
|
|
|
return "video"
|
|
|
|
|
default:
|
|
|
|
|
return "attachment"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func qqAttachmentKind(attachment *dto.MessageAttachment) string {
|
|
|
|
|
if attachment == nil {
|
|
|
|
|
return "file"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
contentType := strings.ToLower(attachment.ContentType)
|
|
|
|
|
filename := strings.ToLower(attachment.FileName)
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasPrefix(contentType, "image/"):
|
|
|
|
|
return "image"
|
|
|
|
|
case strings.HasPrefix(contentType, "video/"):
|
|
|
|
|
return "video"
|
|
|
|
|
case strings.HasPrefix(contentType, "audio/"), contentType == "application/ogg", contentType == "application/x-ogg":
|
|
|
|
|
return "audio"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch filepath.Ext(filename) {
|
|
|
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
|
|
|
|
return "image"
|
|
|
|
|
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
|
|
|
|
return "video"
|
|
|
|
|
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus", ".silk":
|
|
|
|
|
return "audio"
|
|
|
|
|
default:
|
|
|
|
|
return "file"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func qqAttachmentNote(attachment *dto.MessageAttachment) string {
|
|
|
|
|
filename := qqAttachmentFilename(attachment)
|
|
|
|
|
|
|
|
|
|
switch qqAttachmentKind(attachment) {
|
|
|
|
|
case "image":
|
|
|
|
|
return fmt.Sprintf("[image: %s]", filename)
|
|
|
|
|
case "audio":
|
|
|
|
|
return fmt.Sprintf("[audio: %s]", filename)
|
|
|
|
|
case "video":
|
|
|
|
|
return fmt.Sprintf("[video: %s]", filename)
|
|
|
|
|
default:
|
|
|
|
|
return fmt.Sprintf("[file: %s]", filename)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// isDuplicate checks whether a message has been seen within the TTL window.
|
|
|
|
|
// It also enforces a hard cap on map size by evicting oldest entries.
|
2026-02-20 15:25:44 +00:00
|
|
|
func (c *QQChannel) isDuplicate(messageID string) bool {
|
2026-03-10 04:07:02 +00:00
|
|
|
c.muDedup.Lock()
|
|
|
|
|
defer c.muDedup.Unlock()
|
2026-02-20 15:25:44 +00:00
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
if ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL {
|
2026-02-20 15:25:44 +00:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// Enforce hard cap: evict oldest entries when at capacity.
|
|
|
|
|
if len(c.dedup) >= dedupMaxSize {
|
|
|
|
|
var oldestID string
|
|
|
|
|
var oldestTS time.Time
|
|
|
|
|
for id, ts := range c.dedup {
|
|
|
|
|
if oldestID == "" || ts.Before(oldestTS) {
|
|
|
|
|
oldestID = id
|
|
|
|
|
oldestTS = ts
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
2026-03-10 04:07:02 +00:00
|
|
|
}
|
|
|
|
|
if oldestID != "" {
|
|
|
|
|
delete(c.dedup, oldestID)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
c.dedup[messageID] = time.Now()
|
2026-02-20 15:25:44 +00:00
|
|
|
return false
|
|
|
|
|
}
|
2026-03-10 04:07:02 +00:00
|
|
|
|
|
|
|
|
// 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:
|
|
|
|
|
// Collect expired keys under read-like scan.
|
|
|
|
|
c.muDedup.Lock()
|
|
|
|
|
now := time.Now()
|
|
|
|
|
var expired []string
|
|
|
|
|
for id, ts := range c.dedup {
|
|
|
|
|
if now.Sub(ts) >= dedupTTL {
|
|
|
|
|
expired = append(expired, id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, id := range expired {
|
|
|
|
|
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://")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 08:27:34 +00:00
|
|
|
func appendContent(content, suffix string) string {
|
|
|
|
|
if suffix == "" {
|
|
|
|
|
return content
|
|
|
|
|
}
|
|
|
|
|
if content == "" {
|
|
|
|
|
return suffix
|
|
|
|
|
}
|
|
|
|
|
return content + "\n" + suffix
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 04:07:02 +00:00
|
|
|
// urlPattern matches URLs with explicit http(s):// scheme.
|
|
|
|
|
// Only scheme-prefixed URLs are matched to avoid false positives on bare text
|
|
|
|
|
// like version numbers (e.g., "1.2.3") or domain-like fragments.
|
|
|
|
|
var urlPattern = regexp.MustCompile(
|
|
|
|
|
`(?i)` +
|
|
|
|
|
`https?://` + // required 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 (scheme is always present).
|
|
|
|
|
idx := strings.Index(match, "://")
|
|
|
|
|
scheme := match[:idx+3]
|
|
|
|
|
rest := match[idx+3:]
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-04-01 04:21:21 +00:00
|
|
|
|
|
|
|
|
// VoiceCapabilities returns the voice capabilities of the channel.
|
|
|
|
|
func (c *QQChannel) VoiceCapabilities() channels.VoiceCapabilities {
|
|
|
|
|
return channels.VoiceCapabilities{ASR: true, TTS: true}
|
|
|
|
|
}
|