mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
d3bf6bc90a
* 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)
798 lines
22 KiB
Go
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
|
|
}
|