Files
Memoh/internal/channel/adapters/qq/send_test.go
T
BBQ d3bf6bc90a fix(channel,attachment): channel quality refactor & attachment pipeline fixes (#349)
* feat(channel): add DingTalk channel adapter

- Add DingTalk channel adapter (`internal/channel/adapters/dingtalk/`) using dingtalk-stream-sdk-go, supporting inbound message receiving and outbound text/markdown reply
- Register DingTalk adapter in cmd/agent and cmd/memoh
- Add go.mod dependency: github.com/memohai/dingtalk-stream-sdk-go
- Add Dingtalk and Wecom SVG icons and Vue components to @memohai/icon
- Refactor existing icon components to remove redundant inline wrappers
- Add `channelTypeDisplayName` util for consistent channel label resolution
- Add DingTalk/WeCom i18n entries (en/zh) for types and typesShort
- Extend channel-icon, bot-channels, channel-settings-panel to support dingtalk/wecom
- Use channelTypeDisplayName in profile page to replace ad-hoc i18n lookup

* fix(channel,attachment): channel quality refactor & attachment pipeline fixes

Channel module:
- Fix RemoveAdapter not cleaning connectionMeta (stale status leak)
- Fix preparedAttachmentTypeFromMime misclassifying image/gif
- Fix sleepWithContext time.After goroutine/timer leak
- Export IsDataURL/IsHTTPURL/IsDataPath, dedup across packages
- Cache OutboundPolicy in managerOutboundStream to avoid repeated lookups
- Split OutboundAttachmentStore: extract ContainerAttachmentIngester interface
- Add ManagerOption funcs (WithInboundQueueSize, WithInboundWorkers, WithRefreshInterval)
- Add thread-safety docs on OutboundStream / managerOutboundStream
- Add debug logs on successful send/edit paths
- Expand outbound_prepare_test.go with 21 new cases
- Convert no-receiver adapter helpers to package-level funcs; drop unused params

DingTalk adapter:
- Implement AttachmentResolver: download inbound media via /v1.0/robot/messageFiles/download
- Fix pure-image inbound messages failing due to missing resolver

Attachment pipeline:
- Fix images invisible to LLM in pipeline (DCP) path: inject InlineImages into
  last user message when cfg.Query is empty
- Fix public_url fallback: skip direct URL-to-LLM when ContentHash is set,
  always prefer inlined persisted asset
- Inject path: carry ImageParts through agent.InjectMessage; inline persisted
  attachments in resolver inject goroutine so mid-stream images reach the model
- Fix ResolveMime for images: prefer content-sniffed MIME over platform-declared
  MIME (fixes Feishu sending image/png header for actual JPEG content → API 400)
2026-04-09 14:36:11 +08:00

798 lines
22 KiB
Go

package qq
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/memohai/memoh/internal/channel"
"github.com/memohai/memoh/internal/channel/channeltest"
"github.com/memohai/memoh/internal/media"
)
func sendPreparedQQ(t *testing.T, adapter *QQAdapter, cfg channel.ChannelConfig, store channel.OutboundAttachmentStore, msg channel.OutboundMessage) error {
t.Helper()
if cfg.ChannelType == "" {
cfg.ChannelType = Type
}
if store == nil {
store = channeltest.NewMemoryAttachmentStore()
}
prepared, err := channel.PrepareOutboundMessage(context.Background(), store, cfg, msg)
if err != nil {
return err
}
return adapter.Send(context.Background(), cfg, prepared)
}
func TestQQSendTextReply(t *testing.T) {
t.Parallel()
var tokenCalls int
var messageBodies []map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/app/getAppAccessToken":
tokenCalls++
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "token-1",
"expires_in": 7200,
})
case "/v2/users/user-openid/messages":
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode message body: %v", err)
}
messageBodies = append(messageBodies, body)
_ = json.NewEncoder(w).Encode(map[string]any{"id": "m-1"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := newTestQQAdapter(server)
err := sendPreparedQQ(t, adapter, channel.ChannelConfig{
ID: "cfg-1",
BotID: "bot-1",
Credentials: map[string]any{
"appId": "1024",
"clientSecret": "secret",
},
}, nil, channel.OutboundMessage{
Target: "c2c:user-openid",
Message: channel.Message{
Text: "hello",
Reply: &channel.ReplyRef{MessageID: "source-msg"},
},
})
if err != nil {
t.Fatalf("send text: %v", err)
}
if tokenCalls != 1 {
t.Fatalf("unexpected token calls: %d", tokenCalls)
}
if len(messageBodies) != 1 {
t.Fatalf("unexpected message calls: %d", len(messageBodies))
}
if messageBodies[0]["msg_id"] != "source-msg" {
t.Fatalf("unexpected msg_id: %#v", messageBodies[0]["msg_id"])
}
if messageBodies[0]["msg_type"] != float64(0) {
t.Fatalf("unexpected msg_type: %#v", messageBodies[0]["msg_type"])
}
if messageBodies[0]["content"] != "hello" {
t.Fatalf("unexpected content: %#v", messageBodies[0]["content"])
}
}
func TestQQSendImageAttachment(t *testing.T) {
t.Parallel()
var uploadBody map[string]any
var messageBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/app/getAppAccessToken":
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "token-1",
"expires_in": 7200,
})
case "/v2/groups/group-openid/files":
if err := json.NewDecoder(r.Body).Decode(&uploadBody); err != nil {
t.Fatalf("decode upload body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"file_uuid": "file-uuid-1",
"file_info": "file-info-1",
"ttl": 60,
})
case "/v2/groups/group-openid/messages":
if err := json.NewDecoder(r.Body).Decode(&messageBody); err != nil {
t.Fatalf("decode message body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "m-2"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := newTestQQAdapter(server)
adapter.SetAssetOpener(&trackingAssetOpener{
data: []byte("png-bytes"),
asset: media.Asset{
ContentHash: "hash-1",
BotID: "bot-2",
Mime: "image/png",
},
})
store := channeltest.NewMemoryAttachmentStore()
asset, err := store.SeedAsset("bot-2", []byte("png-bytes"), "image/png", ".png")
if err != nil {
t.Fatalf("seed asset: %v", err)
}
err = sendPreparedQQ(t, adapter, channel.ChannelConfig{
ID: "cfg-2",
BotID: "bot-2",
Credentials: map[string]any{
"appId": "2048",
"clientSecret": "secret",
},
}, store, channel.OutboundMessage{
Target: "group:group-openid",
Message: channel.Message{
Attachments: []channel.Attachment{{
Type: channel.AttachmentImage,
ContentHash: asset.ContentHash,
Name: "image.png",
}},
Reply: &channel.ReplyRef{MessageID: "source-msg"},
},
})
if err != nil {
t.Fatalf("send attachment: %v", err)
}
if uploadBody["file_type"] != float64(qqMediaTypeImage) {
t.Fatalf("unexpected file_type: %#v", uploadBody["file_type"])
}
if uploadBody["file_data"] != base64.StdEncoding.EncodeToString([]byte("png-bytes")) {
t.Fatalf("unexpected file_data: %#v", uploadBody["file_data"])
}
if _, ok := uploadBody["file_name"]; ok {
t.Fatalf("unexpected file_name for image upload: %#v", uploadBody["file_name"])
}
if messageBody["msg_type"] != float64(7) {
t.Fatalf("unexpected msg_type: %#v", messageBody["msg_type"])
}
if messageBody["msg_id"] != "source-msg" {
t.Fatalf("unexpected msg_id: %#v", messageBody["msg_id"])
}
media, ok := messageBody["media"].(map[string]any)
if !ok {
t.Fatalf("expected media payload: %#v", messageBody["media"])
}
if media["file_info"] != "file-info-1" {
t.Fatalf("unexpected media.file_info: %#v", media["file_info"])
}
if len(media) != 1 {
t.Fatalf("unexpected media payload size: %#v", media)
}
}
func TestQQSendImageAttachmentCaptionUsesMediaContent(t *testing.T) {
t.Parallel()
var uploadBody map[string]any
var messageBody map[string]any
var messageCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/app/getAppAccessToken":
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "token-1",
"expires_in": 7200,
})
case "/v2/users/user-openid/files":
if err := json.NewDecoder(r.Body).Decode(&uploadBody); err != nil {
t.Fatalf("decode upload body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"file_uuid": "file-uuid-2",
"file_info": "file-info-2",
"ttl": 60,
})
case "/v2/users/user-openid/messages":
messageCalls++
if err := json.NewDecoder(r.Body).Decode(&messageBody); err != nil {
t.Fatalf("decode message body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "m-2b"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := newTestQQAdapter(server)
err := sendPreparedQQ(t, adapter, channel.ChannelConfig{
ID: "cfg-2b",
BotID: "bot-2b",
Credentials: map[string]any{
"appId": "2049",
"clientSecret": "secret",
},
}, nil, channel.OutboundMessage{
Target: "c2c:user-openid",
Message: channel.Message{
Attachments: []channel.Attachment{{
Type: channel.AttachmentImage,
Base64: "data:image/png;base64,cG5nLWJ5dGVz",
Caption: "test.jpg from QQ",
}},
},
})
if err != nil {
t.Fatalf("send attachment with caption: %v", err)
}
if uploadBody["file_type"] != float64(qqMediaTypeImage) {
t.Fatalf("unexpected file_type: %#v", uploadBody["file_type"])
}
if uploadBody["file_data"] != "cG5nLWJ5dGVz" {
t.Fatalf("unexpected file_data: %#v", uploadBody["file_data"])
}
if messageCalls != 1 {
t.Fatalf("unexpected message calls: %d", messageCalls)
}
if messageBody["msg_type"] != float64(7) {
t.Fatalf("unexpected msg_type: %#v", messageBody["msg_type"])
}
if messageBody["content"] != "test.jpg from QQ" {
t.Fatalf("unexpected content: %#v", messageBody["content"])
}
}
func TestQQProcessingStartedSendsInputHintForDirectMessages(t *testing.T) {
t.Parallel()
var messageBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/app/getAppAccessToken":
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "token-1",
"expires_in": 7200,
})
case "/v2/users/user-openid/messages":
if err := json.NewDecoder(r.Body).Decode(&messageBody); err != nil {
t.Fatalf("decode notify body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "m-3"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := newTestQQAdapter(server)
_, err := adapter.ProcessingStarted(context.Background(), channel.ChannelConfig{
ID: "cfg-3",
Credentials: map[string]any{
"appId": "4096",
"clientSecret": "secret",
"enableInputHint": true,
},
}, channel.InboundMessage{}, channel.ProcessingStatusInfo{
ReplyTarget: "c2c:user-openid",
SourceMessageID: "source-msg",
})
if err != nil {
t.Fatalf("processing started: %v", err)
}
if messageBody["msg_type"] != float64(6) {
t.Fatalf("unexpected msg_type: %#v", messageBody["msg_type"])
}
if messageBody["msg_id"] != "source-msg" {
t.Fatalf("unexpected msg_id: %#v", messageBody["msg_id"])
}
}
func TestQQSendChannelImageIsUnsupported(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
adapter := newTestQQAdapter(server)
err := sendPreparedQQ(t, adapter, channel.ChannelConfig{
ID: "cfg-4",
BotID: "bot-4",
Credentials: map[string]any{
"appId": "8192",
"clientSecret": "secret",
},
}, nil, channel.OutboundMessage{
Target: "channel:channel-1",
Message: channel.Message{
Attachments: []channel.Attachment{{
Type: channel.AttachmentImage,
Base64: "iVBORw0KGgo=",
Name: "output.png",
}},
},
})
if err == nil {
t.Fatal("expected channel image error")
}
if !strings.Contains(err.Error(), "does not support image attachments") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestQQSendChannelReplyIncludesMessageReference(t *testing.T) {
t.Parallel()
var messageBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/app/getAppAccessToken":
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "token-1",
"expires_in": 7200,
})
case "/channels/channel-1/messages":
if err := json.NewDecoder(r.Body).Decode(&messageBody); err != nil {
t.Fatalf("decode channel body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "m-6"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := newTestQQAdapter(server)
err := sendPreparedQQ(t, adapter, channel.ChannelConfig{
ID: "cfg-5",
BotID: "bot-5",
Credentials: map[string]any{
"appId": "16384",
"clientSecret": "secret",
},
}, nil, channel.OutboundMessage{
Target: "channel:channel-1",
Message: channel.Message{
Text: "hello",
Reply: &channel.ReplyRef{MessageID: "source-msg"},
},
})
if err != nil {
t.Fatalf("send channel reply: %v", err)
}
if messageBody["msg_id"] != "source-msg" {
t.Fatalf("unexpected msg_id: %#v", messageBody["msg_id"])
}
ref, ok := messageBody["message_reference"].(map[string]any)
if !ok {
t.Fatalf("expected message_reference: %#v", messageBody["message_reference"])
}
if ref["message_id"] != "source-msg" {
t.Fatalf("unexpected message_reference.message_id: %#v", ref["message_id"])
}
}
func TestQQSendGroupFileUsesNativeUpload(t *testing.T) {
t.Parallel()
var uploadBody map[string]any
var messageBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/app/getAppAccessToken":
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "token-1",
"expires_in": 7200,
})
case "/v2/groups/group-openid/files":
if err := json.NewDecoder(r.Body).Decode(&uploadBody); err != nil {
t.Fatalf("decode upload body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"file_uuid": "file-uuid-7",
"file_info": "file-info-7",
"ttl": 60,
})
case "/v2/groups/group-openid/messages":
if err := json.NewDecoder(r.Body).Decode(&messageBody); err != nil {
t.Fatalf("decode message body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "m-7"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := newTestQQAdapter(server)
err := sendPreparedQQ(t, adapter, channel.ChannelConfig{
ID: "cfg-6",
BotID: "bot-6",
Credentials: map[string]any{
"appId": "32768",
"clientSecret": "secret",
},
}, nil, channel.OutboundMessage{
Target: "group:group-openid",
Message: channel.Message{
Attachments: []channel.Attachment{{
Type: channel.AttachmentFile,
Base64: "JVBERi0xLjQ=",
Name: "report.pdf",
}},
Reply: &channel.ReplyRef{MessageID: "source-msg"},
},
})
if err != nil {
t.Fatalf("send group file: %v", err)
}
if uploadBody["file_type"] != float64(qqMediaTypeFile) {
t.Fatalf("unexpected file_type: %#v", uploadBody["file_type"])
}
if uploadBody["file_name"] != "report.pdf" {
t.Fatalf("unexpected file_name: %#v", uploadBody["file_name"])
}
if uploadBody["file_data"] != "JVBERi0xLjQ=" {
t.Fatalf("unexpected file_data: %#v", uploadBody["file_data"])
}
if messageBody["msg_type"] != float64(7) {
t.Fatalf("unexpected msg_type: %#v", messageBody["msg_type"])
}
if messageBody["msg_id"] != "source-msg" {
t.Fatalf("unexpected msg_id: %#v", messageBody["msg_id"])
}
media, ok := messageBody["media"].(map[string]any)
if !ok {
t.Fatalf("expected media payload: %#v", messageBody["media"])
}
if media["file_info"] != "file-info-7" {
t.Fatalf("unexpected media.file_info: %#v", media["file_info"])
}
}
func TestQQSendChannelFileIsUnsupported(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
adapter := newTestQQAdapter(server)
err := sendPreparedQQ(t, adapter, channel.ChannelConfig{
ID: "cfg-7",
BotID: "bot-7",
Credentials: map[string]any{
"appId": "65536",
"clientSecret": "secret",
},
}, nil, channel.OutboundMessage{
Target: "channel:channel-1",
Message: channel.Message{
Attachments: []channel.Attachment{{
Type: channel.AttachmentFile,
Base64: "JVBERi0xLjQ=",
Name: "report.pdf",
}},
Reply: &channel.ReplyRef{MessageID: "source-msg"},
},
})
if err == nil {
t.Fatal("expected channel file error")
}
if !strings.Contains(err.Error(), "does not support file attachments") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestQQSendImageWithLocalPathFailsBeforeAPI(t *testing.T) {
t.Parallel()
var tokenCalls int
var fileCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/app/getAppAccessToken":
tokenCalls++
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "token-1",
"expires_in": 7200,
})
case "/v2/groups/group-openid/files":
fileCalls++
_ = json.NewEncoder(w).Encode(map[string]any{"file_info": "unused"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := newTestQQAdapter(server)
err := sendPreparedQQ(t, adapter, channel.ChannelConfig{
ID: "cfg-8",
BotID: "bot-8",
Credentials: map[string]any{
"appId": "131072",
"clientSecret": "secret",
},
}, nil, channel.OutboundMessage{
Target: "group:group-openid",
Message: channel.Message{
Attachments: []channel.Attachment{{
Type: channel.AttachmentImage,
URL: "/tmp/output.png",
Name: "output.png",
}},
},
})
if err == nil {
t.Fatal("expected local path error")
}
if !strings.Contains(err.Error(), "attachment reference is required") {
t.Fatalf("unexpected error: %v", err)
}
if tokenCalls != 0 {
t.Fatalf("unexpected token calls: %d", tokenCalls)
}
if fileCalls != 0 {
t.Fatalf("unexpected file upload calls: %d", fileCalls)
}
}
func TestQQSendChannelImageFromStoredAssetIsUnsupported(t *testing.T) {
t.Parallel()
var tokenCalls int
var messageCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/app/getAppAccessToken":
tokenCalls++
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "token-1",
"expires_in": 7200,
})
case "/channels/channel-1/messages":
messageCalls++
_ = json.NewEncoder(w).Encode(map[string]any{"id": "m-5"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := newTestQQAdapter(server)
opener := &trackingAssetOpener{
data: []byte("png-bytes"),
asset: media.Asset{
ContentHash: "hash-1",
BotID: "bot-4",
Mime: "image/png",
},
}
adapter.SetAssetOpener(opener)
store := channeltest.NewMemoryAttachmentStore()
asset, err := store.SeedAsset("bot-4", []byte("png-bytes"), "image/png", ".png")
if err != nil {
t.Fatalf("seed asset: %v", err)
}
err = sendPreparedQQ(t, adapter, channel.ChannelConfig{
ID: "cfg-4",
BotID: "bot-4",
Credentials: map[string]any{
"appId": "8192",
"clientSecret": "secret",
},
}, store, channel.OutboundMessage{
Target: "channel:channel-1",
Message: channel.Message{
Attachments: []channel.Attachment{{
Type: channel.AttachmentImage,
ContentHash: asset.ContentHash,
Name: "image.png",
}},
},
})
if err == nil {
t.Fatal("expected channel image error")
}
if !strings.Contains(err.Error(), "does not support image attachments") {
t.Fatalf("unexpected error: %v", err)
}
if opener.called {
t.Fatal("expected stored asset opener to be skipped for channel images")
}
if tokenCalls != 0 {
t.Fatalf("unexpected token calls: %d", tokenCalls)
}
if messageCalls != 0 {
t.Fatalf("unexpected channel message calls: %d", messageCalls)
}
}
func TestQQSendVoiceAttachmentFromHTTPURLUsesDetectedMime(t *testing.T) {
t.Parallel()
const voiceBytes = "remote-voice-bytes"
var uploadBody map[string]any
var messageBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/app/getAppAccessToken":
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "token-1",
"expires_in": 7200,
})
case "/remote/voice":
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write([]byte(voiceBytes))
case "/v2/groups/group-openid/files":
if err := json.NewDecoder(r.Body).Decode(&uploadBody); err != nil {
t.Fatalf("decode upload body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"file_uuid": "file-uuid-voice",
"file_info": "file-info-voice",
"ttl": 60,
})
case "/v2/groups/group-openid/messages":
if err := json.NewDecoder(r.Body).Decode(&messageBody); err != nil {
t.Fatalf("decode message body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "m-voice"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := newTestQQAdapter(server)
err := sendPreparedQQ(t, adapter, channel.ChannelConfig{
ID: "cfg-voice",
BotID: "bot-voice",
Credentials: map[string]any{
"appId": "524288",
"clientSecret": "secret",
},
}, nil, channel.OutboundMessage{
Target: "group:group-openid",
Message: channel.Message{
Attachments: []channel.Attachment{{
Type: channel.AttachmentVoice,
URL: server.URL + "/remote/voice",
}},
},
})
if err != nil {
t.Fatalf("send remote voice attachment: %v", err)
}
if uploadBody["file_type"] != float64(qqMediaTypeVoice) {
t.Fatalf("unexpected file_type: %#v", uploadBody["file_type"])
}
if uploadBody["file_data"] != base64.StdEncoding.EncodeToString([]byte(voiceBytes)) {
t.Fatalf("unexpected file_data: %#v", uploadBody["file_data"])
}
mediaPayload, ok := messageBody["media"].(map[string]any)
if !ok {
t.Fatalf("expected media payload: %#v", messageBody["media"])
}
if mediaPayload["file_info"] != "file-info-voice" {
t.Fatalf("unexpected media.file_info: %#v", mediaPayload["file_info"])
}
}
func TestQQSendImageAttachmentFromHTTPURLUsesFetchedBytes(t *testing.T) {
t.Parallel()
const imageBytes = "remote-image-bytes"
var uploadBody map[string]any
var messageBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/app/getAppAccessToken":
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "token-1",
"expires_in": 7200,
})
case "/remote/test.jpg":
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte(imageBytes))
case "/v2/groups/group-openid/files":
if err := json.NewDecoder(r.Body).Decode(&uploadBody); err != nil {
t.Fatalf("decode upload body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"file_uuid": "file-uuid-9",
"file_info": "file-info-9",
"ttl": 60,
})
case "/v2/groups/group-openid/messages":
if err := json.NewDecoder(r.Body).Decode(&messageBody); err != nil {
t.Fatalf("decode message body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "m-9"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := newTestQQAdapter(server)
err := sendPreparedQQ(t, adapter, channel.ChannelConfig{
ID: "cfg-9",
BotID: "bot-9",
Credentials: map[string]any{
"appId": "262144",
"clientSecret": "secret",
},
}, nil, channel.OutboundMessage{
Target: "group:group-openid",
Message: channel.Message{
Attachments: []channel.Attachment{{
Type: channel.AttachmentImage,
URL: server.URL + "/remote/test.jpg",
Name: "test.jpg",
}},
},
})
if err != nil {
t.Fatalf("send remote image attachment: %v", err)
}
if uploadBody["file_type"] != float64(qqMediaTypeImage) {
t.Fatalf("unexpected file_type: %#v", uploadBody["file_type"])
}
if uploadBody["file_data"] != base64.StdEncoding.EncodeToString([]byte(imageBytes)) {
t.Fatalf("unexpected file_data: %#v", uploadBody["file_data"])
}
if _, ok := uploadBody["url"]; ok {
t.Fatalf("unexpected qq native url upload payload: %#v", uploadBody["url"])
}
mediaPayload, ok := messageBody["media"].(map[string]any)
if !ok {
t.Fatalf("expected media payload: %#v", messageBody["media"])
}
if mediaPayload["file_info"] != "file-info-9" {
t.Fatalf("unexpected media.file_info: %#v", mediaPayload["file_info"])
}
}
type trackingAssetOpener struct {
called bool
data []byte
asset media.Asset
}
func (t *trackingAssetOpener) Open(context.Context, string, string) (io.ReadCloser, media.Asset, error) {
t.called = true
return io.NopCloser(bytes.NewReader(t.data)), t.asset, nil
}
func newTestQQAdapter(server *httptest.Server) *QQAdapter {
adapter := NewQQAdapter(nil)
adapter.httpClient = server.Client()
adapter.apiBaseURL = server.URL
adapter.tokenURL = server.URL + "/app/getAppAccessToken"
return adapter
}