mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
e6a6dbe3f6
* feat(channel): add qq adapter and outbound delivery * feat(channel): ingest inbound qq messages * feat(web): expose qq channel in management ui * feat(channel): support qq attachment ingestion * fix(mcp): fail read raw immediately for missing files * fix(agent): parse inline image data into native image parts * test(agent): align read_media tool tests with SDK options * fix(channel): harden qq image delivery and reconnect loop Avoid data URLs for qq channel images, reset reconnect backoff after healthy sessions, and fall back gracefully for malformed public image URLs. * fix(channel): restore qq media delivery and target resolution * fix(qq,mcp,agent): fix message/qq regressions and pass go lint * fix(qq,agent): validate inline base64 and sync heartbeat seq * fix(qq): validate remote voice mime for upload checks * fix(qq): fall back intents and restore adapter wiring * fix(qq): prevent final text leakage and dedupe persisted inbound query
771 lines
22 KiB
Go
771 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/media"
|
|
)
|
|
|
|
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 := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
ID: "cfg-1",
|
|
BotID: "bot-1",
|
|
Credentials: map[string]any{
|
|
"appId": "1024",
|
|
"clientSecret": "secret",
|
|
},
|
|
}, 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",
|
|
},
|
|
})
|
|
err := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
ID: "cfg-2",
|
|
BotID: "bot-2",
|
|
Credentials: map[string]any{
|
|
"appId": "2048",
|
|
"clientSecret": "secret",
|
|
},
|
|
}, channel.OutboundMessage{
|
|
Target: "group:group-openid",
|
|
Message: channel.Message{
|
|
Attachments: []channel.Attachment{{
|
|
Type: channel.AttachmentImage,
|
|
ContentHash: "hash-1",
|
|
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 := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
ID: "cfg-2b",
|
|
BotID: "bot-2b",
|
|
Credentials: map[string]any{
|
|
"appId": "2049",
|
|
"clientSecret": "secret",
|
|
},
|
|
}, 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 := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
ID: "cfg-4",
|
|
BotID: "bot-4",
|
|
Credentials: map[string]any{
|
|
"appId": "8192",
|
|
"clientSecret": "secret",
|
|
},
|
|
}, channel.OutboundMessage{
|
|
Target: "channel:channel-1",
|
|
Message: channel.Message{
|
|
Attachments: []channel.Attachment{{
|
|
Type: channel.AttachmentImage,
|
|
URL: "https://example.com/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 := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
ID: "cfg-5",
|
|
BotID: "bot-5",
|
|
Credentials: map[string]any{
|
|
"appId": "16384",
|
|
"clientSecret": "secret",
|
|
},
|
|
}, 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 := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
ID: "cfg-6",
|
|
BotID: "bot-6",
|
|
Credentials: map[string]any{
|
|
"appId": "32768",
|
|
"clientSecret": "secret",
|
|
},
|
|
}, 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 := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
ID: "cfg-7",
|
|
BotID: "bot-7",
|
|
Credentials: map[string]any{
|
|
"appId": "65536",
|
|
"clientSecret": "secret",
|
|
},
|
|
}, channel.OutboundMessage{
|
|
Target: "channel:channel-1",
|
|
Message: channel.Message{
|
|
Attachments: []channel.Attachment{{
|
|
Type: channel.AttachmentFile,
|
|
URL: "https://example.com/files/report.pdf",
|
|
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 := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
ID: "cfg-8",
|
|
BotID: "bot-8",
|
|
Credentials: map[string]any{
|
|
"appId": "131072",
|
|
"clientSecret": "secret",
|
|
},
|
|
}, 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(), "requires http(s) URL, base64, or content_hash") {
|
|
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)
|
|
|
|
err := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
ID: "cfg-4",
|
|
BotID: "bot-4",
|
|
Credentials: map[string]any{
|
|
"appId": "8192",
|
|
"clientSecret": "secret",
|
|
},
|
|
}, channel.OutboundMessage{
|
|
Target: "channel:channel-1",
|
|
Message: channel.Message{
|
|
Attachments: []channel.Attachment{{
|
|
Type: channel.AttachmentImage,
|
|
ContentHash: "hash-1",
|
|
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 := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
ID: "cfg-voice",
|
|
BotID: "bot-voice",
|
|
Credentials: map[string]any{
|
|
"appId": "524288",
|
|
"clientSecret": "secret",
|
|
},
|
|
}, 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 := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
ID: "cfg-9",
|
|
BotID: "bot-9",
|
|
Credentials: map[string]any{
|
|
"appId": "262144",
|
|
"clientSecret": "secret",
|
|
},
|
|
}, 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
|
|
}
|