Files
Memoh/internal/channel/adapters/wecom/outbound_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

312 lines
8.6 KiB
Go

package wecom
import (
"context"
"crypto/md5" //nolint:gosec // test verifies protocol checksum formatting.
"encoding/base64"
"fmt"
"io"
"strings"
"testing"
"github.com/memohai/memoh/internal/channel"
)
func TestBuildSendPayload_Text(t *testing.T) {
payload, cmd, reqID, err := buildSendPayload(channel.Message{
Format: channel.MessageFormatPlain,
Text: "hello",
}, "chat_1")
if err != nil {
t.Fatalf("buildSendPayload error = %v", err)
}
if cmd != WSCmdSendMessage {
t.Fatalf("unexpected cmd: %q", cmd)
}
if reqID == "" {
t.Fatal("reqID should not be empty")
}
p, ok := payload.(SendMessageMarkdownBody)
if !ok {
t.Fatalf("unexpected payload type: %T", payload)
}
if p.Markdown.Content != "hello" {
t.Fatalf("unexpected payload content: %q", p.Markdown.Content)
}
}
func TestBuildSendPayload_AttachmentNotSupported(t *testing.T) {
_, _, _, err := buildSendPayload(channel.Message{
Attachments: []channel.Attachment{
{Type: channel.AttachmentImage, Base64: "aGVsbG8="},
},
}, "chat_1")
if err == nil {
t.Fatal("expected error for proactive attachment send")
}
}
func TestBuildRespondPayload_Stream(t *testing.T) {
payload, cmd, reqID, err := buildRespondPayload(channel.Message{
Format: channel.MessageFormatMarkdown,
Text: "world",
}, "req_abc")
if err != nil {
t.Fatalf("buildRespondPayload error = %v", err)
}
if cmd != WSCmdRespond {
t.Fatalf("unexpected cmd: %q", cmd)
}
if reqID != "req_abc" {
t.Fatalf("unexpected req id: %q", reqID)
}
p, ok := payload.(StreamReplyBody)
if !ok {
t.Fatalf("unexpected payload type: %T", payload)
}
if p.MsgType != "stream" || p.Stream.Content != "world" || !p.Stream.Finish {
t.Fatalf("unexpected stream payload: %+v", p)
}
}
func TestBuildRespondPayload_StreamWithFeedback(t *testing.T) {
payload, cmd, _, err := buildRespondPayload(channel.Message{
Text: "world",
Metadata: map[string]any{
"wecom_feedback_id": "feedback_1",
},
}, "req_abc")
if err != nil {
t.Fatalf("buildRespondPayload error = %v", err)
}
if cmd != WSCmdRespond {
t.Fatalf("unexpected cmd: %q", cmd)
}
p, ok := payload.(StreamReplyBody)
if !ok {
t.Fatalf("unexpected payload type: %T", payload)
}
if p.Stream.Feedback == nil || p.Stream.Feedback.ID != "feedback_1" {
t.Fatalf("unexpected feedback: %+v", p.Stream.Feedback)
}
}
func TestBuildRespondPayloadWithStream_Delta(t *testing.T) {
payload, cmd, reqID, err := buildRespondPayloadWithStream(channel.Message{
Text: "delta",
}, "req_abc", "stream_1", false)
if err != nil {
t.Fatalf("buildRespondPayloadWithStream error = %v", err)
}
if cmd != WSCmdRespond || reqID != "req_abc" {
t.Fatalf("unexpected cmd/reqid: %q %q", cmd, reqID)
}
p, ok := payload.(StreamReplyBody)
if !ok {
t.Fatalf("unexpected payload type: %T", payload)
}
if p.Stream.ID != "stream_1" || p.Stream.Finish {
t.Fatalf("unexpected stream payload: %+v", p.Stream)
}
}
func TestBuildRespondPayloadWithStream_EmptyDeltaRejected(t *testing.T) {
_, _, _, err := buildRespondPayloadWithStream(channel.Message{}, "req_abc", "stream_1", false)
if err == nil {
t.Fatal("expected error for empty delta content")
}
}
func TestBuildSendPayload_TemplateCard(t *testing.T) {
payload, cmd, _, err := buildSendPayload(channel.Message{
Metadata: map[string]any{
"wecom_template_card": map[string]any{
"card_type": "text_notice",
},
},
}, "chat_1")
if err != nil {
t.Fatalf("buildSendPayload error = %v", err)
}
if cmd != WSCmdSendMessage {
t.Fatalf("unexpected cmd: %q", cmd)
}
p, ok := payload.(SendMessageTemplateCardBody)
if !ok {
t.Fatalf("unexpected payload type: %T", payload)
}
if p.MsgType != "template_card" {
t.Fatalf("unexpected msg type: %q", p.MsgType)
}
}
func TestBuildRespondPayload_StreamWithTemplateCard(t *testing.T) {
payload, cmd, _, err := buildRespondPayload(channel.Message{
Text: "x",
Metadata: map[string]any{
"wecom_template_card": map[string]any{
"card_type": "text_notice",
},
},
}, "req_abc")
if err != nil {
t.Fatalf("buildRespondPayload error = %v", err)
}
if cmd != WSCmdRespond {
t.Fatalf("unexpected cmd: %q", cmd)
}
p, ok := payload.(StreamWithTemplateCardReplyBody)
if !ok {
t.Fatalf("unexpected payload type: %T", payload)
}
if p.MsgType != "stream_with_template_card" {
t.Fatalf("unexpected msg type: %q", p.MsgType)
}
}
func TestBuildRespondPayload_UpdateTemplateCard(t *testing.T) {
payload, cmd, reqID, err := buildRespondPayload(channel.Message{
Metadata: map[string]any{
"wecom_update_template_card": map[string]any{
"card_type": "text_notice",
"task_id": "task-1",
},
"wecom_update_userids": []any{"u1", "u2"},
},
}, "req_abc")
if err != nil {
t.Fatalf("buildRespondPayload error = %v", err)
}
if cmd != WSCmdRespondUpdate {
t.Fatalf("unexpected cmd: %q", cmd)
}
if reqID != "req_abc" {
t.Fatalf("unexpected req id: %q", reqID)
}
p, ok := payload.(UpdateTemplateCardBody)
if !ok {
t.Fatalf("unexpected payload type: %T", payload)
}
if p.ResponseType != "update_template_card" {
t.Fatalf("unexpected response type: %q", p.ResponseType)
}
if len(p.UserIDs) != 2 || p.UserIDs[0] != "u1" || p.UserIDs[1] != "u2" {
t.Fatalf("unexpected userids: %#v", p.UserIDs)
}
}
func TestBuildRespondPayload_WelcomeText(t *testing.T) {
payload, cmd, reqID, err := buildRespondPayload(channel.Message{
Text: "welcome",
Metadata: map[string]any{
"wecom_welcome": true,
},
}, "req_abc")
if err != nil {
t.Fatalf("buildRespondPayload error = %v", err)
}
if cmd != WSCmdRespondWelcome {
t.Fatalf("unexpected cmd: %q", cmd)
}
if reqID != "req_abc" {
t.Fatalf("unexpected req id: %q", reqID)
}
p, ok := payload.(WelcomeTextReplyBody)
if !ok {
t.Fatalf("unexpected payload type: %T", payload)
}
if p.MsgType != "text" || p.Text.Content != "welcome" {
t.Fatalf("unexpected welcome payload: %+v", p)
}
}
func TestBuildRespondPayload_WelcomeTemplateCard(t *testing.T) {
payload, cmd, _, err := buildRespondPayload(channel.Message{
Metadata: map[string]any{
"wecom_welcome": true,
"wecom_template_card": map[string]any{
"card_type": "text_notice",
},
},
}, "req_abc")
if err != nil {
t.Fatalf("buildRespondPayload error = %v", err)
}
if cmd != WSCmdRespondWelcome {
t.Fatalf("unexpected cmd: %q", cmd)
}
p, ok := payload.(WelcomeTemplateCardReplyBody)
if !ok {
t.Fatalf("unexpected payload type: %T", payload)
}
if p.MsgType != "template_card" {
t.Fatalf("unexpected msg type: %q", p.MsgType)
}
}
func TestBuildPreparedRespondPayloadWithStream_ImageAttachment(t *testing.T) {
const rawImage = "image-bytes"
payload, cmd, reqID, err := buildPreparedRespondPayloadWithStream(context.Background(), channel.PreparedMessage{
Message: channel.Message{
Reply: &channel.ReplyRef{MessageID: "msg_1"},
},
Attachments: []channel.PreparedAttachment{
{
Logical: channel.Attachment{Type: channel.AttachmentImage},
Kind: channel.PreparedAttachmentUpload,
Mime: "image/png",
Open: func(context.Context) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader(rawImage)), nil
},
},
},
}, "req_abc", "stream_1", true)
if err != nil {
t.Fatalf("buildPreparedRespondPayloadWithStream error = %v", err)
}
if cmd != WSCmdRespond {
t.Fatalf("unexpected cmd: %q", cmd)
}
if reqID != "req_abc" {
t.Fatalf("unexpected req id: %q", reqID)
}
p, ok := payload.(StreamReplyBody)
if !ok {
t.Fatalf("unexpected payload type: %T", payload)
}
if p.Stream.ID != "stream_1" || !p.Stream.Finish {
t.Fatalf("unexpected stream block: %+v", p.Stream)
}
if len(p.Stream.MsgItems) != 1 || p.Stream.MsgItems[0].Image == nil {
t.Fatalf("expected one image item, got %+v", p.Stream.MsgItems)
}
if got := p.Stream.MsgItems[0].Image.Base64; got != base64.StdEncoding.EncodeToString([]byte(rawImage)) {
t.Fatalf("unexpected image base64: %q", got)
}
if got := p.Stream.MsgItems[0].Image.MD5; got != fmt.Sprintf("%x", md5.Sum([]byte(rawImage))) { //nolint:gosec
t.Fatalf("unexpected image md5: %q", got)
}
}
func TestBuildPreparedRespondPayloadWithStream_RejectsNonImageAttachment(t *testing.T) {
_, _, _, err := buildPreparedRespondPayloadWithStream(context.Background(), channel.PreparedMessage{
Attachments: []channel.PreparedAttachment{
{
Logical: channel.Attachment{Type: channel.AttachmentFile},
Kind: channel.PreparedAttachmentUpload,
Mime: "application/pdf",
Open: func(context.Context) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("pdf")), nil
},
},
},
}, "req_abc", "stream_1", true)
if err == nil {
t.Fatal("expected non-image attachment error")
}
if !strings.Contains(err.Error(), "only support images") {
t.Fatalf("unexpected error: %v", err)
}
}