1.process attachment
This commit is contained in:
parent
9cd276b6ec
commit
eb478d1f96
2 changed files with 487 additions and 93 deletions
34
go.mod
34
go.mod
|
|
@ -7,13 +7,11 @@ require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/adhocore/gronx v1.19.6
|
github.com/adhocore/gronx v1.19.6
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||||
github.com/atotto/clipboard v0.1.4
|
github.com/aws/aws-sdk-go-v2 v1.41.4
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
||||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.4.0
|
github.com/caarlos0/env/v11 v11.4.0
|
||||||
github.com/creack/pty v1.1.24
|
|
||||||
github.com/ergochat/irc-go v0.6.0
|
github.com/ergochat/irc-go v0.6.0
|
||||||
github.com/ergochat/readline v0.1.3
|
github.com/ergochat/readline v0.1.3
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
|
|
@ -23,13 +21,10 @@ require (
|
||||||
github.com/h2non/filetype v1.1.3
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/minio/selfupdate v0.6.0
|
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||||
github.com/mymmrac/telego v1.7.0
|
github.com/mymmrac/telego v1.7.0
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/openai/openai-go/v3 v3.22.0
|
github.com/openai/openai-go/v3 v3.22.0
|
||||||
github.com/pion/rtp v1.8.7
|
|
||||||
github.com/pion/webrtc/v3 v3.3.6
|
|
||||||
github.com/rivo/tview v0.42.0
|
github.com/rivo/tview v0.42.0
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/slack-go/slack v0.17.3
|
github.com/slack-go/slack v0.17.3
|
||||||
|
|
@ -40,22 +35,21 @@ require (
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/term v0.41.0
|
golang.org/x/term v0.41.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.14.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mautrix v0.26.4
|
maunium.net/go/mautrix v0.26.4
|
||||||
modernc.org/sqlite v1.47.0
|
modernc.org/sqlite v1.46.1
|
||||||
rsc.io/qr v0.2.0
|
rsc.io/qr v0.2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
aead.dev/minisign v0.2.0 // indirect
|
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||||
|
|
@ -65,14 +59,11 @@ require (
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
|
||||||
github.com/aws/smithy-go v1.24.2 // indirect
|
github.com/aws/smithy-go v1.24.2 // indirect
|
||||||
github.com/beeper/argo-go v1.1.2 // indirect
|
github.com/beeper/argo-go v1.1.2 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
|
||||||
github.com/coder/websocket v1.8.14 // indirect
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
|
@ -81,7 +72,6 @@ require (
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
|
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
|
@ -90,13 +80,9 @@ require (
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
go.mau.fi/libsignal v0.2.1 // indirect
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
|
||||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
modernc.org/libc v1.70.0 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
@ -107,7 +93,7 @@ require (
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/github/copilot-sdk/go v0.2.0
|
github.com/github/copilot-sdk/go v0.1.32
|
||||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
|
@ -129,5 +115,3 @@ require (
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sys v0.42.0
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,17 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
"github.com/tencent-connect/botgo/constant"
|
||||||
|
"github.com/tencent-connect/botgo/openapi/options"
|
||||||
"math"
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -15,20 +23,17 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
|
||||||
"github.com/tidwall/gjson"
|
"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"
|
||||||
"github.com/tencent-connect/botgo/openapi"
|
|
||||||
"github.com/tencent-connect/botgo/token"
|
"github.com/tencent-connect/botgo/token"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/identity"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -38,14 +43,11 @@ const (
|
||||||
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 = 10
|
||||||
|
bytesPerMiB = 1024 * 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
type kindType string
|
type kindType string
|
||||||
|
|
||||||
func (k kindType) String() string {
|
|
||||||
return string(k)
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
kindDirect kindType = "direct"
|
kindDirect kindType = "direct"
|
||||||
kindGroup kindType = "group"
|
kindGroup kindType = "group"
|
||||||
|
|
@ -54,14 +56,27 @@ const (
|
||||||
var emojiRegexp = regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`)
|
var emojiRegexp = regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`)
|
||||||
var extRegexp = regexp.MustCompile(`ext="([^"]+)"`)
|
var extRegexp = regexp.MustCompile(`ext="([^"]+)"`)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
type QQChannel struct {
|
type QQChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
config config.QQConfig
|
config config.QQConfig
|
||||||
api openapi.OpenAPI
|
api qqAPI
|
||||||
tokenSource oauth2.TokenSource
|
tokenSource oauth2.TokenSource
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
sessionManager botgo.SessionManager
|
sessionManager botgo.SessionManager
|
||||||
|
downloadFn func(urlStr, filename string) string
|
||||||
|
|
||||||
// Chat routing: track whether a chatID is group or direct.
|
// Chat routing: track whether a chatID is group or direct.
|
||||||
chatType sync.Map // kindType → "group" | "direct"
|
chatType sync.Map // kindType → "group" | "direct"
|
||||||
|
|
@ -99,11 +114,11 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *QQChannel) Start(ctx context.Context) error {
|
func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
if c.config.AppID == "" || c.config.AppSecret == "" {
|
if c.config.AppID == "" || c.config.AppSecret() == "" {
|
||||||
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"))
|
botgo.SetLogger(newBotGoLogger("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.
|
||||||
|
|
@ -113,7 +128,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
// create token source
|
// create token source
|
||||||
credentials := &token.QQBotCredentials{
|
credentials := &token.QQBotCredentials{
|
||||||
AppID: c.config.AppID,
|
AppID: c.config.AppID,
|
||||||
AppSecret: c.config.AppSecret,
|
AppSecret: c.config.AppSecret(),
|
||||||
}
|
}
|
||||||
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
||||||
|
|
||||||
|
|
@ -163,7 +178,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
// Pre-register reasoning_channel_id as group chat if configured,
|
// Pre-register reasoning_channel_id as group chat if configured,
|
||||||
// so outbound-only destinations are routed correctly.
|
// so outbound-only destinations are routed correctly.
|
||||||
if c.config.ReasoningChannelID != "" {
|
if c.config.ReasoningChannelID != "" {
|
||||||
c.chatType.Store(c.config.ReasoningChannelID, kindGroup.String())
|
c.chatType.Store(c.config.ReasoningChannelID, kindGroup)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetRunning(true)
|
c.SetRunning(true)
|
||||||
|
|
@ -304,17 +319,35 @@ func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMedia implements the channels.MediaSender interface.
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
// QQ group/C2C media sending is a two-step flow:
|
||||||
|
// 1. Upload media to /files using a remote URL or local bytes.
|
||||||
|
// 2. Send a msg_type=7 message using the returned file_info.
|
||||||
func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
chatKind := c.getChatKind(msg.ChatID)
|
||||||
var err error
|
var err error
|
||||||
for _, part := range msg.Parts {
|
for _, part := range msg.Parts {
|
||||||
if err = c.sendOneMedia(ctx, msg.ChatID, part); err != nil {
|
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) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("qq send media: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo); err != nil {
|
||||||
logger.ErrorCF("qq", "Failed to send media", map[string]any{
|
logger.ErrorCF("qq", "Failed to send media", map[string]any{
|
||||||
"part": part,
|
"type": part.Type,
|
||||||
"error": err.Error(),
|
"chat_id": msg.ChatID,
|
||||||
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -322,6 +355,206 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *QQChannel) uploadMedia( ctx context.Context,
|
||||||
|
chatKind kindType, 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) (*RichMediaMessage, error) {
|
||||||
|
payload := &RichMediaMessage{}
|
||||||
|
|
||||||
|
mediaRef := part.Ref
|
||||||
|
if isHTTPURL(mediaRef) {
|
||||||
|
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
||||||
|
payload.URL = mediaRef
|
||||||
|
payload.FileName = qqUploadFilename(part, mediaRef, payload.FileType)
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, meta, err := store.ResolveWithMeta(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("qq resolve media ref %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
if part.Filename == "" {
|
||||||
|
part.Filename = meta.Filename
|
||||||
|
}
|
||||||
|
if part.ContentType == "" {
|
||||||
|
part.ContentType = meta.ContentType
|
||||||
|
}
|
||||||
|
payload.FileType = qqFileType(c.outboundMediaType(part, resolved))
|
||||||
|
payload.FileName = qqUploadFilename(part, resolved, payload.FileType)
|
||||||
|
if isHTTPURL(resolved) {
|
||||||
|
payload.URL = resolved
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = data
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Fix the sendUploadedMedia function syntax error
|
||||||
|
func (c *QQChannel) sendUploadedMedia(ctx context.Context, chatKind kindType, chatID string, part bus.MediaPart,
|
||||||
|
fileInfo []byte) error {
|
||||||
|
|
||||||
|
msg := &dto.MessageToCreate{
|
||||||
|
Content: part.Caption,
|
||||||
|
MsgType: dto.RichMediaMsg,
|
||||||
|
Media: &dto.MediaInfo{
|
||||||
|
FileInfo: fileInfo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.MsgID, msg.MsgSeq = c.getReplyExtInfo(ctx, chatID)
|
||||||
|
|
||||||
|
if chatKind == kindGroup {
|
||||||
|
if msg.Content != "" {
|
||||||
|
msg.Content = sanitizeURLs(msg.Content)
|
||||||
|
}
|
||||||
|
_, err := c.api.PostGroupMessage(ctx, chatID, msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := c.api.PostC2CMessage(ctx, chatID, msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *QQChannel) mediaUploadURL(chatKind kindType, chatID string) string {
|
||||||
|
base := constant.APIDomain
|
||||||
|
if chatKind == kindGroup {
|
||||||
|
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 {
|
||||||
|
if c.config.MaxBase64FileSizeMiB <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return c.config.MaxBase64FileSizeMiB * bytesPerMiB
|
||||||
|
}
|
||||||
|
|
||||||
// handleC2CMessage handles QQ private messages.
|
// 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 {
|
||||||
|
|
@ -343,7 +576,20 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
Platform: "qq",
|
Platform: "qq",
|
||||||
PlatformID: data.Author.ID,
|
PlatformID: data.Author.ID,
|
||||||
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
|
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
|
||||||
Username: data.Author.Username,
|
}
|
||||||
|
|
||||||
|
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 content/attachments, ignoring")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.IsAllowedSender(sender) {
|
if !c.IsAllowedSender(sender) {
|
||||||
|
|
@ -354,35 +600,38 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
scope := channels.BuildMediaScope("qq", senderID, data.ID)
|
scope := channels.BuildMediaScope("qq", senderID, data.ID)
|
||||||
content, mediaPaths := c.decodeMessage(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
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("qq", "Received C2C message", map[string]any{
|
logger.InfoCF("qq", "Received C2C message", map[string]any{
|
||||||
"sender": senderID,
|
"sender": senderID,
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
|
"media_count": len(mediaPaths),
|
||||||
|
"sender": senderID,
|
||||||
|
"length": len(content),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Store chat routing context.
|
// Store chat routing context.
|
||||||
c.saveChatKind(senderID, kindDirect)
|
c.saveChatKind(senderID, kindDirect)
|
||||||
c.lastMsgID.Store(senderID, data.ID)
|
c.lastMsgID.Store(senderID, data.ID)
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"account_id": senderID,
|
"account_id": senderID,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(c.ctx,
|
c.HandleMessage(c.ctx,
|
||||||
bus.Peer{Kind: kindDirect.String(), ID: senderID},
|
bus.Peer{Kind: string(kindDirect), ID: senderID},
|
||||||
data.ID,
|
data.ID,
|
||||||
senderID,
|
senderID,
|
||||||
senderID,
|
senderID,
|
||||||
content,
|
content,
|
||||||
mediaPaths,
|
mediaPaths,
|
||||||
metadata,
|
metadata,
|
||||||
sender,
|
sender,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -409,9 +658,19 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
Platform: "qq",
|
Platform: "qq",
|
||||||
PlatformID: data.Author.ID,
|
PlatformID: data.Author.ID,
|
||||||
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
|
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
|
||||||
Username: data.Author.Username,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.TrimSpace(data.Content)
|
||||||
|
mediaPaths, attachmentNotes := c.extractInboundAttachments(data.GroupID, data.ID, data.Attachments)
|
||||||
|
for _, note := range attachmentNotes {
|
||||||
|
content = appendContent(content, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if !c.IsAllowedSender(sender) {
|
if !c.IsAllowedSender(sender) {
|
||||||
logger.Infof("qq", "Received group message from unauthorized sender", map[string]any{
|
logger.Infof("qq", "Received group message from unauthorized sender", map[string]any{
|
||||||
"sender": sender,
|
"sender": sender,
|
||||||
|
|
@ -421,7 +680,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.decodeMessage(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
|
||||||
|
|
@ -432,11 +691,16 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content = cleaned
|
content = cleaned
|
||||||
|
if content == "" && len(mediaPaths) == 0 {
|
||||||
|
logger.DebugC("qq", "Received empty group message with no attachments, ignoring")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("qq", "Received group AT message", map[string]any{
|
logger.InfoCF("qq", "Received group AT message", map[string]any{
|
||||||
"sender": senderID,
|
"sender": senderID,
|
||||||
"group": data.GroupID,
|
"group": data.GroupID,
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
|
"media_count": len(mediaPaths),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Store chat routing context using GroupID as chatID.
|
// Store chat routing context using GroupID as chatID.
|
||||||
|
|
@ -448,21 +712,172 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
"group_id": data.GroupID,
|
"group_id": data.GroupID,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(c.ctx,
|
c.HandleMessage(c.ctx,
|
||||||
bus.Peer{Kind: kindGroup.String(), ID: data.GroupID},
|
bus.Peer{Kind: string(kindGroup), ID: data.GroupID},
|
||||||
data.ID,
|
data.ID,
|
||||||
senderID,
|
senderID,
|
||||||
data.GroupID,
|
data.GroupID,
|
||||||
content,
|
content,
|
||||||
mediaPaths,
|
mediaPaths,
|
||||||
metadata,
|
metadata,
|
||||||
sender,
|
sender,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
Filename: qqAttachmentFilename(attachment),
|
||||||
|
ContentType: attachment.ContentType,
|
||||||
|
Source: "qq",
|
||||||
|
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||||
|
}, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// isDuplicate checks whether a message has been seen within the TTL window.
|
// 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.
|
// It also enforces a hard cap on map size by evicting oldest entries.
|
||||||
func (c *QQChannel) isDuplicate(messageID string) bool {
|
func (c *QQChannel) isDuplicate(messageID string) bool {
|
||||||
|
|
@ -542,7 +957,7 @@ func (c *QQChannel) genReplyMsg(ctx context.Context, msg bus.OutboundMessage) (d
|
||||||
return mdMsg, textMsg
|
return mdMsg, textMsg
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *QQChannel) getReplyExtInfo(_ context.Context, chatID string) (replyID string, seq uint32) {
|
func (c *QQChannel) getReplyExtInfo(ctx context.Context, chatID string) (replyID string, seq uint32) {
|
||||||
// Attach passive reply msg_id and msg_seq if available.
|
// Attach passive reply msg_id and msg_seq if available.
|
||||||
if v, ok := c.lastMsgID.Load(chatID); ok {
|
if v, ok := c.lastMsgID.Load(chatID); ok {
|
||||||
if msgID, ok := v.(string); ok && msgID != "" {
|
if msgID, ok := v.(string); ok && msgID != "" {
|
||||||
|
|
@ -734,7 +1149,7 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag
|
||||||
|
|
||||||
for _, attachment := range attachments {
|
for _, attachment := range attachments {
|
||||||
attachmentType := c.getAttachmentType(attachment)
|
attachmentType := c.getAttachmentType(attachment)
|
||||||
localPath := c.downloadAttachment(ctx, attachment)
|
localPath := c.downloadAttachment(attachment.URL, attachment.FileName)
|
||||||
if localPath == "" {
|
if localPath == "" {
|
||||||
mediaPaths = append(mediaPaths, attachment.URL)
|
mediaPaths = append(mediaPaths, attachment.URL)
|
||||||
content = appendContent(content, fmt.Sprintf("[%v: %s]", attachment.ContentType, attachment.URL))
|
content = appendContent(content, fmt.Sprintf("[%v: %s]", attachment.ContentType, attachment.URL))
|
||||||
|
|
@ -752,19 +1167,10 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag
|
||||||
return mediaPaths, content
|
return mediaPaths, content
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadAttachment downloads an attachment from QQ server
|
|
||||||
func (c *QQChannel) downloadAttachment(ctx context.Context, attachment MessageAttachment) string {
|
|
||||||
logger.InfoCF("qq", "Downloading attachment", map[string]any{
|
|
||||||
"attachment": attachment,
|
|
||||||
})
|
|
||||||
return utils.DownloadFile(attachment.URL, attachment.FileName, utils.DownloadOptions{
|
|
||||||
LoggerPrefix: "qq",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// getAttachmentType determines the type of attachment (image, audio, video, file)
|
// getAttachmentType determines the type of attachment (image, audio, video, file)
|
||||||
func (c *QQChannel) getAttachmentType(attachment MessageAttachment) string {
|
func (c *QQChannel) getAttachmentType(attachment MessageAttachment) string {
|
||||||
|
|
||||||
if strings.HasPrefix(attachment.ContentType, "image") {
|
if strings.HasPrefix(attachment.ContentType, "image") {
|
||||||
return "image"
|
return "image"
|
||||||
}
|
}
|
||||||
|
|
@ -782,6 +1188,16 @@ func isHTTPURL(s string) bool {
|
||||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendContent(content, suffix string) string {
|
||||||
|
if suffix == "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
return suffix
|
||||||
|
}
|
||||||
|
return content + "\n" + suffix
|
||||||
|
}
|
||||||
|
|
||||||
// urlPattern matches URLs with explicit http(s):// scheme.
|
// urlPattern matches URLs with explicit http(s):// scheme.
|
||||||
// Only scheme-prefixed URLs are matched to avoid false positives on bare text
|
// 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.
|
// like version numbers (e.g., "1.2.3") or domain-like fragments.
|
||||||
|
|
@ -821,13 +1237,7 @@ func sanitizeURLs(text string) string {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendContent safely appends content to existing text
|
|
||||||
func appendContent(content, suffix string) string {
|
|
||||||
if content == "" {
|
|
||||||
return suffix
|
|
||||||
}
|
|
||||||
return content + "\n" + suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
func getVoiceInfo(event *dto.WSPayload) (string, string) {
|
func getVoiceInfo(event *dto.WSPayload) (string, string) {
|
||||||
_raw, err := json.Marshal(event.Data)
|
_raw, err := json.Marshal(event.Data)
|
||||||
|
|
@ -887,4 +1297,4 @@ func processEmoji(match string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue