mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
6c0798ca3f
* 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
741 lines
20 KiB
Go
741 lines
20 KiB
Go
package qq
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/tencent-connect/botgo/dto"
|
|
"github.com/tencent-connect/botgo/openapi/options"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
|
)
|
|
|
|
func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) {
|
|
messageBus := bus.NewMessageBus()
|
|
ch := &QQChannel{
|
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
|
dedup: make(map[string]time.Time),
|
|
done: make(chan struct{}),
|
|
ctx: context.Background(),
|
|
}
|
|
|
|
err := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{
|
|
ID: "msg-1",
|
|
Content: "hello",
|
|
Author: &dto.User{
|
|
ID: "7750283E123456",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("handleC2CMessage() error = %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatal("timeout waiting for inbound message")
|
|
return
|
|
case inbound, ok := <-messageBus.InboundChan():
|
|
if !ok {
|
|
t.Fatal("expected inbound message")
|
|
}
|
|
if inbound.Metadata["account_id"] != "7750283E123456" {
|
|
t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleC2CMessage_AttachmentOnlyPublishesMedia(t *testing.T) {
|
|
messageBus := bus.NewMessageBus()
|
|
store := media.NewFileMediaStore()
|
|
localPath := writeTempFile(t, t.TempDir(), "image.png", []byte("fake-image"))
|
|
|
|
ch := &QQChannel{
|
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
|
dedup: make(map[string]time.Time),
|
|
done: make(chan struct{}),
|
|
ctx: context.Background(),
|
|
downloadFn: func(urlStr, filename string) string {
|
|
if filename != "image.png" {
|
|
t.Fatalf("download filename = %q, want image.png", filename)
|
|
}
|
|
return localPath
|
|
},
|
|
}
|
|
ch.SetMediaStore(store)
|
|
|
|
err := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{
|
|
ID: "msg-attachment",
|
|
Content: "",
|
|
Author: &dto.User{
|
|
ID: "7750283E123456",
|
|
},
|
|
Attachments: []*dto.MessageAttachment{{
|
|
URL: "https://example.com/image.png",
|
|
FileName: "image.png",
|
|
ContentType: "image/png",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("handleC2CMessage() error = %v", err)
|
|
}
|
|
|
|
inbound := waitInboundMessage(t, messageBus)
|
|
if inbound.Content != "[image: image.png]" {
|
|
t.Fatalf("inbound.Content = %q", inbound.Content)
|
|
}
|
|
if len(inbound.Media) != 1 {
|
|
t.Fatalf("len(inbound.Media) = %d, want 1", len(inbound.Media))
|
|
}
|
|
if !strings.HasPrefix(inbound.Media[0], "media://") {
|
|
t.Fatalf("inbound.Media[0] = %q, want media:// ref", inbound.Media[0])
|
|
}
|
|
_, meta, err := store.ResolveWithMeta(inbound.Media[0])
|
|
if err != nil {
|
|
t.Fatalf("ResolveWithMeta() error = %v", err)
|
|
}
|
|
if meta.Filename != "image.png" {
|
|
t.Fatalf("meta.Filename = %q, want image.png", meta.Filename)
|
|
}
|
|
if meta.ContentType != "image/png" {
|
|
t.Fatalf("meta.ContentType = %q, want image/png", meta.ContentType)
|
|
}
|
|
}
|
|
|
|
func TestHandleGroupATMessage_AttachmentOnlyPublishesMedia(t *testing.T) {
|
|
messageBus := bus.NewMessageBus()
|
|
store := media.NewFileMediaStore()
|
|
localPath := writeTempFile(t, t.TempDir(), "report.pdf", []byte("fake-pdf"))
|
|
|
|
ch := &QQChannel{
|
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
|
dedup: make(map[string]time.Time),
|
|
done: make(chan struct{}),
|
|
ctx: context.Background(),
|
|
downloadFn: func(urlStr, filename string) string {
|
|
if filename != "report.pdf" {
|
|
t.Fatalf("download filename = %q, want report.pdf", filename)
|
|
}
|
|
return localPath
|
|
},
|
|
}
|
|
ch.SetMediaStore(store)
|
|
|
|
err := ch.handleGroupATMessage()(nil, &dto.WSGroupATMessageData{
|
|
ID: "group-attachment",
|
|
GroupID: "group-1",
|
|
Content: "",
|
|
Author: &dto.User{
|
|
ID: "7750283E123456",
|
|
},
|
|
Attachments: []*dto.MessageAttachment{{
|
|
URL: "https://example.com/report.pdf",
|
|
FileName: "report.pdf",
|
|
ContentType: "application/pdf",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("handleGroupATMessage() error = %v", err)
|
|
}
|
|
|
|
inbound := waitInboundMessage(t, messageBus)
|
|
if inbound.Content != "[file: report.pdf]" {
|
|
t.Fatalf("inbound.Content = %q", inbound.Content)
|
|
}
|
|
if len(inbound.Media) != 1 {
|
|
t.Fatalf("len(inbound.Media) = %d, want 1", len(inbound.Media))
|
|
}
|
|
if !strings.HasPrefix(inbound.Media[0], "media://") {
|
|
t.Fatalf("inbound.Media[0] = %q, want media:// ref", inbound.Media[0])
|
|
}
|
|
if inbound.Peer.Kind != "group" || inbound.Peer.ID != "group-1" {
|
|
t.Fatalf("inbound.Peer = %+v, want group/group-1", inbound.Peer)
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) {
|
|
messageBus := bus.NewMessageBus()
|
|
store := media.NewFileMediaStore()
|
|
|
|
tmpFile, err := os.CreateTemp(t.TempDir(), "qq-media-*.png")
|
|
if err != nil {
|
|
t.Fatalf("CreateTemp() error = %v", err)
|
|
}
|
|
defer tmpFile.Close()
|
|
|
|
content := []byte("local-image-data")
|
|
if _, writeErr := tmpFile.Write(content); writeErr != nil {
|
|
t.Fatalf("Write() error = %v", writeErr)
|
|
}
|
|
|
|
ref, err := store.Store(tmpFile.Name(), media.MediaMeta{
|
|
Filename: "reply.png",
|
|
ContentType: "image/png",
|
|
}, "qq:test")
|
|
if err != nil {
|
|
t.Fatalf("Store() error = %v", err)
|
|
}
|
|
|
|
api := &fakeQQAPI{
|
|
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("uploaded-file-info")}),
|
|
}
|
|
ch := &QQChannel{
|
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
|
api: api,
|
|
dedup: make(map[string]time.Time),
|
|
done: make(chan struct{}),
|
|
ctx: context.Background(),
|
|
}
|
|
ch.SetRunning(true)
|
|
ch.SetMediaStore(store)
|
|
ch.chatType.Store("group-1", "group")
|
|
ch.lastMsgID.Store("group-1", "msg-1")
|
|
ch.msgSeqCounters.Store("group-1", new(atomic.Uint64))
|
|
|
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "group-1",
|
|
Parts: []bus.MediaPart{{
|
|
Type: "image",
|
|
Ref: ref,
|
|
Caption: "see https://example.com/image",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SendMedia() error = %v", err)
|
|
}
|
|
|
|
if len(api.transportCalls) != 1 {
|
|
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
|
|
}
|
|
upload := api.transportCalls[0]
|
|
if upload.method != "POST" {
|
|
t.Fatalf("upload method = %q, want POST", upload.method)
|
|
}
|
|
if upload.url != "https://api.sgroup.qq.com/v2/groups/group-1/files" {
|
|
t.Fatalf("upload url = %q", upload.url)
|
|
}
|
|
if upload.body.URL != "" {
|
|
t.Fatalf("upload URL = %q, want empty", upload.body.URL)
|
|
}
|
|
wantBase64 := base64.StdEncoding.EncodeToString(content)
|
|
if upload.body.FileData != wantBase64 {
|
|
t.Fatalf("upload file_data = %q, want %q", upload.body.FileData, wantBase64)
|
|
}
|
|
if upload.body.FileType != 1 {
|
|
t.Fatalf("upload file_type = %d, want 1", upload.body.FileType)
|
|
}
|
|
|
|
if len(api.groupMessages) != 1 {
|
|
t.Fatalf("groupMessages = %d, want 1", len(api.groupMessages))
|
|
}
|
|
msg, ok := api.groupMessages[0].(*dto.MessageToCreate)
|
|
if !ok {
|
|
t.Fatalf("groupMessages[0] type = %T, want *dto.MessageToCreate", api.groupMessages[0])
|
|
}
|
|
if msg.MsgType != dto.RichMediaMsg {
|
|
t.Fatalf("msg.MsgType = %d, want %d", msg.MsgType, dto.RichMediaMsg)
|
|
}
|
|
if msg.MsgID != "msg-1" {
|
|
t.Fatalf("msg.MsgID = %q, want msg-1", msg.MsgID)
|
|
}
|
|
if msg.MsgSeq != 1 {
|
|
t.Fatalf("msg.MsgSeq = %d, want 1", msg.MsgSeq)
|
|
}
|
|
if msg.Content != "see https://example。com/image" {
|
|
t.Fatalf("msg.Content = %q", msg.Content)
|
|
}
|
|
if msg.Media == nil || string(msg.Media.FileInfo) != "uploaded-file-info" {
|
|
t.Fatalf("msg.Media.FileInfo = %q, want uploaded-file-info", string(msg.Media.FileInfo))
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_AudioAt60SecondsUsesVoiceUpload(t *testing.T) {
|
|
assertAudioWAVUploadType(t, 60*time.Second, 3)
|
|
}
|
|
|
|
func TestSendMedia_AudioOver60SecondsFallsBackToFileUpload(t *testing.T) {
|
|
assertAudioWAVUploadType(t, 61*time.Second, 4)
|
|
}
|
|
|
|
func assertAudioWAVUploadType(t *testing.T, duration time.Duration, wantFileType uint64) {
|
|
t.Helper()
|
|
|
|
messageBus := bus.NewMessageBus()
|
|
store := media.NewFileMediaStore()
|
|
|
|
localPath := writeWAVFile(t, t.TempDir(), "voice.wav", duration)
|
|
ref, err := store.Store(localPath, media.MediaMeta{
|
|
Filename: "voice.wav",
|
|
ContentType: "audio/wav",
|
|
}, "qq:test")
|
|
if err != nil {
|
|
t.Fatalf("Store() error = %v", err)
|
|
}
|
|
|
|
api := &fakeQQAPI{
|
|
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("file-info")}),
|
|
}
|
|
ch := &QQChannel{
|
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
|
api: api,
|
|
dedup: make(map[string]time.Time),
|
|
done: make(chan struct{}),
|
|
ctx: context.Background(),
|
|
}
|
|
ch.SetRunning(true)
|
|
ch.SetMediaStore(store)
|
|
ch.chatType.Store("group-1", "group")
|
|
|
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "group-1",
|
|
Parts: []bus.MediaPart{{
|
|
Type: "audio",
|
|
Ref: ref,
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SendMedia() error = %v", err)
|
|
}
|
|
|
|
if len(api.transportCalls) != 1 {
|
|
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
|
|
}
|
|
if api.transportCalls[0].body.FileType != wantFileType {
|
|
t.Fatalf("upload file_type = %d, want %d", api.transportCalls[0].body.FileType, wantFileType)
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_RemoteAudioFallsBackToFileUpload(t *testing.T) {
|
|
messageBus := bus.NewMessageBus()
|
|
api := &fakeQQAPI{
|
|
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("remote-file-info")}),
|
|
}
|
|
ch := &QQChannel{
|
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
|
api: api,
|
|
dedup: make(map[string]time.Time),
|
|
done: make(chan struct{}),
|
|
ctx: context.Background(),
|
|
}
|
|
ch.SetRunning(true)
|
|
ch.chatType.Store("user-1", "direct")
|
|
|
|
_, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "user-1",
|
|
Parts: []bus.MediaPart{{
|
|
Type: "audio",
|
|
Ref: "https://cdn.example.com/voice.ogg",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SendMedia() error = %v", err)
|
|
}
|
|
|
|
if len(api.transportCalls) != 1 {
|
|
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
|
|
}
|
|
if api.transportCalls[0].body.FileType != 4 {
|
|
t.Fatalf("upload file_type = %d, want 4", api.transportCalls[0].body.FileType)
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_LocalAudioWithUnknownDurationFallsBackToFileUpload(t *testing.T) {
|
|
messageBus := bus.NewMessageBus()
|
|
store := media.NewFileMediaStore()
|
|
|
|
localPath := writeTempFile(t, t.TempDir(), "voice.mp3", []byte("not-a-real-mp3"))
|
|
ref, err := store.Store(localPath, media.MediaMeta{
|
|
Filename: "voice.mp3",
|
|
ContentType: "audio/mpeg",
|
|
}, "qq:test")
|
|
if err != nil {
|
|
t.Fatalf("Store() error = %v", err)
|
|
}
|
|
|
|
api := &fakeQQAPI{
|
|
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("file-info")}),
|
|
}
|
|
ch := &QQChannel{
|
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
|
api: api,
|
|
dedup: make(map[string]time.Time),
|
|
done: make(chan struct{}),
|
|
ctx: context.Background(),
|
|
}
|
|
ch.SetRunning(true)
|
|
ch.SetMediaStore(store)
|
|
ch.chatType.Store("group-1", "group")
|
|
|
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "group-1",
|
|
Parts: []bus.MediaPart{{
|
|
Type: "audio",
|
|
Ref: ref,
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SendMedia() error = %v", err)
|
|
}
|
|
|
|
if len(api.transportCalls) != 1 {
|
|
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
|
|
}
|
|
if api.transportCalls[0].body.FileType != 4 {
|
|
t.Fatalf("upload file_type = %d, want 4", api.transportCalls[0].body.FileType)
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) {
|
|
messageBus := bus.NewMessageBus()
|
|
api := &fakeQQAPI{
|
|
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("remote-file-info")}),
|
|
}
|
|
ch := &QQChannel{
|
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
|
api: api,
|
|
dedup: make(map[string]time.Time),
|
|
done: make(chan struct{}),
|
|
ctx: context.Background(),
|
|
}
|
|
ch.SetRunning(true)
|
|
ch.chatType.Store("user-1", "direct")
|
|
|
|
_, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "user-1",
|
|
Parts: []bus.MediaPart{{
|
|
Type: "file",
|
|
Ref: "https://cdn.example.com/report.pdf",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SendMedia() error = %v", err)
|
|
}
|
|
|
|
if len(api.transportCalls) != 1 {
|
|
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
|
|
}
|
|
upload := api.transportCalls[0]
|
|
if upload.url != "https://api.sgroup.qq.com/v2/users/user-1/files" {
|
|
t.Fatalf("upload url = %q", upload.url)
|
|
}
|
|
if upload.body.URL != "https://cdn.example.com/report.pdf" {
|
|
t.Fatalf("upload URL = %q", upload.body.URL)
|
|
}
|
|
if upload.body.FileData != "" {
|
|
t.Fatalf("upload file_data = %q, want empty", upload.body.FileData)
|
|
}
|
|
if upload.body.FileType != 4 {
|
|
t.Fatalf("upload file_type = %d, want 4", upload.body.FileType)
|
|
}
|
|
if upload.body.FileName != "report.pdf" {
|
|
t.Fatalf("upload file_name = %q, want report.pdf", upload.body.FileName)
|
|
}
|
|
|
|
if len(api.c2cMessages) != 1 {
|
|
t.Fatalf("c2cMessages = %d, want 1", len(api.c2cMessages))
|
|
}
|
|
msg, ok := api.c2cMessages[0].(*dto.MessageToCreate)
|
|
if !ok {
|
|
t.Fatalf("c2cMessages[0] type = %T, want *dto.MessageToCreate", api.c2cMessages[0])
|
|
}
|
|
if msg.MsgType != dto.RichMediaMsg {
|
|
t.Fatalf("msg.MsgType = %d, want %d", msg.MsgType, dto.RichMediaMsg)
|
|
}
|
|
if msg.Media == nil || string(msg.Media.FileInfo) != "remote-file-info" {
|
|
t.Fatalf("msg.Media.FileInfo = %q, want remote-file-info", string(msg.Media.FileInfo))
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) {
|
|
messageBus := bus.NewMessageBus()
|
|
store := media.NewFileMediaStore()
|
|
|
|
localPath := writeTempFile(t, t.TempDir(), "report.pdf", []byte("fake-pdf"))
|
|
ref, err := store.Store(localPath, media.MediaMeta{
|
|
Filename: "report.pdf",
|
|
ContentType: "application/pdf",
|
|
}, "qq:test")
|
|
if err != nil {
|
|
t.Fatalf("Store() error = %v", err)
|
|
}
|
|
|
|
api := &fakeQQAPI{
|
|
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("local-file-info")}),
|
|
}
|
|
ch := &QQChannel{
|
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
|
api: api,
|
|
dedup: make(map[string]time.Time),
|
|
done: make(chan struct{}),
|
|
ctx: context.Background(),
|
|
}
|
|
ch.SetRunning(true)
|
|
ch.SetMediaStore(store)
|
|
ch.chatType.Store("user-1", "direct")
|
|
|
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "user-1",
|
|
Parts: []bus.MediaPart{{
|
|
Type: "file",
|
|
Ref: ref,
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SendMedia() error = %v", err)
|
|
}
|
|
|
|
if len(api.transportCalls) != 1 {
|
|
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
|
|
}
|
|
upload := api.transportCalls[0]
|
|
if upload.body.FileType != 4 {
|
|
t.Fatalf("upload file_type = %d, want 4", upload.body.FileType)
|
|
}
|
|
if upload.body.FileName != "report.pdf" {
|
|
t.Fatalf("upload file_name = %q, want report.pdf", upload.body.FileName)
|
|
}
|
|
if upload.body.FileData == "" {
|
|
t.Fatal("upload file_data = empty, want base64 payload")
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) {
|
|
messageBus := bus.NewMessageBus()
|
|
ch := &QQChannel{
|
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
|
api: &fakeQQAPI{},
|
|
dedup: make(map[string]time.Time),
|
|
done: make(chan struct{}),
|
|
ctx: context.Background(),
|
|
}
|
|
ch.SetRunning(true)
|
|
ch.chatType.Store("group-1", "group")
|
|
|
|
_, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "group-1",
|
|
Parts: []bus.MediaPart{{
|
|
Type: "image",
|
|
Ref: "media://missing",
|
|
}},
|
|
})
|
|
if !errors.Is(err, channels.ErrSendFailed) {
|
|
t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testing.T) {
|
|
messageBus := bus.NewMessageBus()
|
|
store := media.NewFileMediaStore()
|
|
|
|
tmpFile, err := os.CreateTemp(t.TempDir(), "qq-media-too-large-*.bin")
|
|
if err != nil {
|
|
t.Fatalf("CreateTemp() error = %v", err)
|
|
}
|
|
defer tmpFile.Close()
|
|
|
|
content := make([]byte, bytesPerMiB+1)
|
|
if _, writeErr := tmpFile.Write(content); writeErr != nil {
|
|
t.Fatalf("Write() error = %v", writeErr)
|
|
}
|
|
|
|
ref, err := store.Store(tmpFile.Name(), media.MediaMeta{
|
|
Filename: "large.bin",
|
|
ContentType: "application/octet-stream",
|
|
}, "qq:test")
|
|
if err != nil {
|
|
t.Fatalf("Store() error = %v", err)
|
|
}
|
|
|
|
api := &fakeQQAPI{}
|
|
ch := &QQChannel{
|
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
|
config: config.QQConfig{
|
|
MaxBase64FileSizeMiB: 1,
|
|
},
|
|
api: api,
|
|
dedup: make(map[string]time.Time),
|
|
done: make(chan struct{}),
|
|
ctx: context.Background(),
|
|
}
|
|
ch.SetRunning(true)
|
|
ch.SetMediaStore(store)
|
|
ch.chatType.Store("group-1", "group")
|
|
|
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "group-1",
|
|
Parts: []bus.MediaPart{{
|
|
Type: "file",
|
|
Ref: ref,
|
|
}},
|
|
})
|
|
if !errors.Is(err, channels.ErrSendFailed) {
|
|
t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err)
|
|
}
|
|
if len(api.transportCalls) != 0 {
|
|
t.Fatalf("transportCalls = %d, want 0", len(api.transportCalls))
|
|
}
|
|
}
|
|
|
|
type fakeQQAPI struct {
|
|
transportResp []byte
|
|
transportErr error
|
|
groupErr error
|
|
c2cErr error
|
|
transportCalls []fakeTransportCall
|
|
groupMessages []dto.APIMessage
|
|
c2cMessages []dto.APIMessage
|
|
}
|
|
|
|
type fakeTransportCall struct {
|
|
method string
|
|
url string
|
|
body qqMediaUpload
|
|
}
|
|
|
|
func (f *fakeQQAPI) WS(
|
|
context.Context,
|
|
map[string]string,
|
|
string,
|
|
) (*dto.WebsocketAP, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeQQAPI) PostGroupMessage(
|
|
_ context.Context,
|
|
_ string,
|
|
msg dto.APIMessage,
|
|
_ ...options.Option,
|
|
) (*dto.Message, error) {
|
|
f.groupMessages = append(f.groupMessages, msg)
|
|
return &dto.Message{}, f.groupErr
|
|
}
|
|
|
|
func (f *fakeQQAPI) PostC2CMessage(
|
|
_ context.Context,
|
|
_ string,
|
|
msg dto.APIMessage,
|
|
_ ...options.Option,
|
|
) (*dto.Message, error) {
|
|
f.c2cMessages = append(f.c2cMessages, msg)
|
|
return &dto.Message{}, f.c2cErr
|
|
}
|
|
|
|
func (f *fakeQQAPI) Transport(_ context.Context, method, url string, body any) ([]byte, error) {
|
|
upload, ok := body.(*qqMediaUpload)
|
|
if !ok {
|
|
return nil, errors.New("unexpected transport body type")
|
|
}
|
|
f.transportCalls = append(f.transportCalls, fakeTransportCall{
|
|
method: method,
|
|
url: url,
|
|
body: *upload,
|
|
})
|
|
return f.transportResp, f.transportErr
|
|
}
|
|
|
|
func mustJSON(t *testing.T, v any) []byte {
|
|
t.Helper()
|
|
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func waitInboundMessage(t *testing.T, messageBus *bus.MessageBus) bus.InboundMessage {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatal("timeout waiting for inbound message")
|
|
case inbound, ok := <-messageBus.InboundChan():
|
|
if !ok {
|
|
t.Fatal("expected inbound message")
|
|
}
|
|
return inbound
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeTempFile(t *testing.T, dir, name string, content []byte) string {
|
|
t.Helper()
|
|
|
|
path := dir + "/" + name
|
|
if err := os.WriteFile(path, content, 0o600); err != nil {
|
|
t.Fatalf("WriteFile() error = %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func writeWAVFile(t *testing.T, dir, name string, duration time.Duration) string {
|
|
t.Helper()
|
|
|
|
const (
|
|
sampleRate = 8000
|
|
numChannels = 1
|
|
bitsPerSample = 8
|
|
)
|
|
|
|
dataSize := uint32(duration / time.Second * sampleRate * numChannels * (bitsPerSample / 8))
|
|
byteRate := uint32(sampleRate * numChannels * (bitsPerSample / 8))
|
|
blockAlign := uint16(numChannels * (bitsPerSample / 8))
|
|
|
|
var buf bytes.Buffer
|
|
buf.WriteString("RIFF")
|
|
if err := binary.Write(&buf, binary.LittleEndian, uint32(36)+dataSize); err != nil {
|
|
t.Fatalf("binary.Write(riff size) error = %v", err)
|
|
}
|
|
buf.WriteString("WAVE")
|
|
buf.WriteString("fmt ")
|
|
if err := binary.Write(&buf, binary.LittleEndian, uint32(16)); err != nil {
|
|
t.Fatalf("binary.Write(fmt chunk size) error = %v", err)
|
|
}
|
|
if err := binary.Write(&buf, binary.LittleEndian, uint16(1)); err != nil {
|
|
t.Fatalf("binary.Write(audio format) error = %v", err)
|
|
}
|
|
if err := binary.Write(&buf, binary.LittleEndian, uint16(numChannels)); err != nil {
|
|
t.Fatalf("binary.Write(channels) error = %v", err)
|
|
}
|
|
if err := binary.Write(&buf, binary.LittleEndian, uint32(sampleRate)); err != nil {
|
|
t.Fatalf("binary.Write(sample rate) error = %v", err)
|
|
}
|
|
if err := binary.Write(&buf, binary.LittleEndian, byteRate); err != nil {
|
|
t.Fatalf("binary.Write(byte rate) error = %v", err)
|
|
}
|
|
if err := binary.Write(&buf, binary.LittleEndian, blockAlign); err != nil {
|
|
t.Fatalf("binary.Write(block align) error = %v", err)
|
|
}
|
|
if err := binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample)); err != nil {
|
|
t.Fatalf("binary.Write(bits per sample) error = %v", err)
|
|
}
|
|
buf.WriteString("data")
|
|
if err := binary.Write(&buf, binary.LittleEndian, dataSize); err != nil {
|
|
t.Fatalf("binary.Write(data size) error = %v", err)
|
|
}
|
|
buf.Write(make([]byte, dataSize))
|
|
|
|
return writeTempFile(t, dir, name, buf.Bytes())
|
|
}
|