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)
1035 lines
33 KiB
Go
1035 lines
33 KiB
Go
package matrix
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/memohai/memoh/internal/channel"
|
|
)
|
|
|
|
func TestIsMatrixBotMentionedByMentionsMetadata(t *testing.T) {
|
|
content := map[string]any{
|
|
"body": "hi bot",
|
|
"m.mentions": map[string]any{
|
|
"user_ids": []any{"@memoh:example.com"},
|
|
},
|
|
}
|
|
if !isMatrixBotMentioned("@memoh:example.com", content) {
|
|
t.Fatal("expected mention metadata to be detected")
|
|
}
|
|
}
|
|
|
|
func TestIsMatrixBotMentionedByFormattedBody(t *testing.T) {
|
|
content := map[string]any{
|
|
"body": "hello Memoh",
|
|
"formatted_body": `<a href="https://matrix.to/#/@memoh:example.com">Memoh</a> hello`,
|
|
}
|
|
if !isMatrixBotMentioned("@memoh:example.com", content) {
|
|
t.Fatal("expected formatted body mention to be detected")
|
|
}
|
|
}
|
|
|
|
func TestIsMatrixBotMentionedByBodyFallback(t *testing.T) {
|
|
content := map[string]any{
|
|
"body": "@memoh:example.com ping",
|
|
}
|
|
if !isMatrixBotMentioned("@memoh:example.com", content) {
|
|
t.Fatal("expected body fallback mention to be detected")
|
|
}
|
|
}
|
|
|
|
func TestIsMatrixBotMentionedByLocalpartBodyFallback(t *testing.T) {
|
|
content := map[string]any{
|
|
"body": "@memoh ping",
|
|
}
|
|
if !isMatrixBotMentioned("@memoh:example.com", content) {
|
|
t.Fatal("expected localpart body fallback mention to be detected")
|
|
}
|
|
}
|
|
|
|
func TestIsMatrixBotMentionedDoesNotMatchSubstring(t *testing.T) {
|
|
content := map[string]any{
|
|
"body": "@memoh-helper:example.com ping",
|
|
}
|
|
if isMatrixBotMentioned("@memoh:example.com", content) {
|
|
t.Fatal("expected substring match not to count as mention")
|
|
}
|
|
}
|
|
|
|
func TestIsMatrixBotMentionedDoesNotMatchPlainMatrixURL(t *testing.T) {
|
|
content := map[string]any{
|
|
"body": "see https://matrix.to/#/@memoh:example.com",
|
|
}
|
|
if isMatrixBotMentioned("@memoh:example.com", content) {
|
|
t.Fatal("expected plain Matrix URL not to count as mention")
|
|
}
|
|
}
|
|
|
|
func TestMatrixSinceTokenFromRouting(t *testing.T) {
|
|
routing := map[string]any{
|
|
matrixRoutingStateKey: map[string]any{"since_token": "s123"},
|
|
}
|
|
if got := matrixSinceTokenFromRouting(routing); got != "s123" {
|
|
t.Fatalf("unexpected since token: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPersistSinceTokenUsesConfiguredSaver(t *testing.T) {
|
|
var gotConfigID string
|
|
var gotSince string
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.SetSyncStateSaver(func(_ context.Context, configID string, since string) error {
|
|
gotConfigID = configID
|
|
gotSince = since
|
|
return nil
|
|
})
|
|
if err := adapter.persistSinceToken(context.Background(), "cfg-1", "token-1"); err != nil {
|
|
t.Fatalf("persistSinceToken returned error: %v", err)
|
|
}
|
|
if gotConfigID != "cfg-1" || gotSince != "token-1" {
|
|
t.Fatalf("unexpected saver args: %q %q", gotConfigID, gotSince)
|
|
}
|
|
}
|
|
|
|
func TestBootstrapSinceTokenPersistsLatestCursor(t *testing.T) {
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"next_batch":"s123","rooms":{"join":{"!room:example.com":{"timeline":{"events":[{"event_id":"$evt1"}]}}}}}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
})}
|
|
var gotConfigID string
|
|
var gotSince string
|
|
adapter.SetSyncStateSaver(func(_ context.Context, configID string, since string) error {
|
|
gotConfigID = configID
|
|
gotSince = since
|
|
return nil
|
|
})
|
|
|
|
since, err := adapter.bootstrapSinceToken(context.Background(), channel.ChannelConfig{ID: "cfg-1"}, Config{
|
|
HomeserverURL: "https://matrix.example.com",
|
|
AccessToken: "tok",
|
|
AutoJoinInvites: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("bootstrapSinceToken returned error: %v", err)
|
|
}
|
|
if since != "s123" {
|
|
t.Fatalf("unexpected since token: %q", since)
|
|
}
|
|
if gotConfigID != "cfg-1" || gotSince != "s123" {
|
|
t.Fatalf("unexpected persisted cursor: %q %q", gotConfigID, gotSince)
|
|
}
|
|
if !adapter.seenEvent("cfg-1", "$evt1") {
|
|
t.Fatal("expected bootstrap event to be remembered as seen")
|
|
}
|
|
}
|
|
|
|
func TestBootstrapSinceTokenAutoJoinsInvitedRooms(t *testing.T) {
|
|
joinRequests := 0
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Path {
|
|
case "/_matrix/client/v3/sync":
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"next_batch":"s123","rooms":{"invite":{"!room:example.com":{"invite_state":{"events":[{"type":"m.room.member"}]}}}}}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
case "/_matrix/client/v3/join/!room:example.com":
|
|
joinRequests++
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
default:
|
|
t.Fatalf("unexpected request path: %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})}
|
|
|
|
since, err := adapter.bootstrapSinceToken(context.Background(), channel.ChannelConfig{ID: "cfg-1"}, Config{
|
|
HomeserverURL: "https://matrix.example.com",
|
|
AccessToken: "tok",
|
|
AutoJoinInvites: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("bootstrapSinceToken returned error: %v", err)
|
|
}
|
|
if since != "s123" {
|
|
t.Fatalf("unexpected since token: %q", since)
|
|
}
|
|
if joinRequests != 1 {
|
|
t.Fatalf("expected invited room to be auto-joined once, got %d", joinRequests)
|
|
}
|
|
}
|
|
|
|
func TestValidateConnectionChecksHomeserverVersions(t *testing.T) {
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Path != "/_matrix/client/versions" {
|
|
t.Fatalf("unexpected request path: %s", req.URL.Path)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusNotFound,
|
|
Body: io.NopCloser(strings.NewReader("not found")),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
})}
|
|
|
|
err := adapter.validateConnection(context.Background(), Config{
|
|
HomeserverURL: "https://matrix.example.com",
|
|
AccessToken: "tok",
|
|
UserID: "@memoh:example.com",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected homeserver validation to fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "homeserver check failed") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateConnectionRejectsTokenUserMismatch(t *testing.T) {
|
|
requests := make([]string, 0, 2)
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
requests = append(requests, req.URL.Path)
|
|
switch req.URL.Path {
|
|
case "/_matrix/client/versions":
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"versions":["v1.11"]}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
case "/_matrix/client/v3/account/whoami":
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"user_id":"@alice:example.com"}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
default:
|
|
t.Fatalf("unexpected request path: %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})}
|
|
|
|
err := adapter.validateConnection(context.Background(), Config{
|
|
HomeserverURL: "https://matrix.example.com",
|
|
AccessToken: "tok",
|
|
UserID: "@memoh:example.com",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected token mismatch validation to fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "token belongs to @alice:example.com, expected @memoh:example.com") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(requests) != 2 {
|
|
t.Fatalf("expected homeserver and whoami checks, got %d requests", len(requests))
|
|
}
|
|
}
|
|
|
|
func TestValidateConnectionSkipsSyncProbe(t *testing.T) {
|
|
requests := make([]string, 0, 3)
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
requests = append(requests, req.URL.RequestURI())
|
|
switch req.URL.Path {
|
|
case "/_matrix/client/versions":
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"versions":["v1.11"]}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
case "/_matrix/client/v3/account/whoami":
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"user_id":"@memoh:example.com"}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
case "/_matrix/client/v3/sync":
|
|
t.Fatal("did not expect /sync probe during connection validation")
|
|
return nil, nil
|
|
default:
|
|
t.Fatalf("unexpected request path: %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})}
|
|
|
|
err := adapter.validateConnection(context.Background(), Config{
|
|
HomeserverURL: "https://matrix.example.com",
|
|
AccessToken: "tok",
|
|
UserID: "@memoh:example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("validateConnection returned error: %v", err)
|
|
}
|
|
if len(requests) != 2 {
|
|
t.Fatalf("expected homeserver and whoami checks only, got %d requests", len(requests))
|
|
}
|
|
}
|
|
|
|
func TestHandleInvitesSkipsWhenAutoJoinDisabled(t *testing.T) {
|
|
joinRequests := 0
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Path == "/_matrix/client/v3/join/!room:example.com" {
|
|
joinRequests++
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
})}
|
|
|
|
joined, err := adapter.handleInvites(
|
|
context.Background(),
|
|
channel.ChannelConfig{ID: "cfg-1"},
|
|
Config{HomeserverURL: "https://matrix.example.com", AccessToken: "tok", AutoJoinInvites: false},
|
|
matrixSyncResponse{Rooms: struct {
|
|
Join map[string]matrixSyncJoinedRoom `json:"join"`
|
|
Invite map[string]matrixSyncInvitedRoom `json:"invite"`
|
|
}{Invite: map[string]matrixSyncInvitedRoom{"!room:example.com": {}}}},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("handleInvites returned error: %v", err)
|
|
}
|
|
if joined {
|
|
t.Fatal("expected no room to be joined")
|
|
}
|
|
if joinRequests != 0 {
|
|
t.Fatalf("expected no join requests, got %d", joinRequests)
|
|
}
|
|
}
|
|
|
|
func TestBuildMatrixMessageContentIncludesFormattedHTMLForMarkdown(t *testing.T) {
|
|
content := buildMatrixMessageContent(channel.Message{
|
|
Text: "**bold**\n\n- item",
|
|
Format: channel.MessageFormatMarkdown,
|
|
}, false, "")
|
|
|
|
if got := content["body"]; got != "**bold**\n\n- item" {
|
|
t.Fatalf("unexpected body: %#v", got)
|
|
}
|
|
if got := content["format"]; got != matrixHTMLFormat {
|
|
t.Fatalf("unexpected format: %#v", got)
|
|
}
|
|
html, ok := content["formatted_body"].(string)
|
|
if !ok || !strings.Contains(html, "<strong>bold</strong>") || !strings.Contains(html, "<ul>") {
|
|
t.Fatalf("unexpected formatted body: %#v", content["formatted_body"])
|
|
}
|
|
}
|
|
|
|
func TestBuildMatrixMessageContentAddsFormattedHTMLToEdits(t *testing.T) {
|
|
content := buildMatrixMessageContent(channel.Message{
|
|
Text: "`code`",
|
|
Format: channel.MessageFormatMarkdown,
|
|
}, true, "$evt1")
|
|
|
|
newContent, ok := content["m.new_content"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected m.new_content map, got %#v", content["m.new_content"])
|
|
}
|
|
if got := newContent["format"]; got != matrixHTMLFormat {
|
|
t.Fatalf("unexpected edit format: %#v", got)
|
|
}
|
|
html, ok := newContent["formatted_body"].(string)
|
|
if !ok || !strings.Contains(html, "<code>code</code>") {
|
|
t.Fatalf("unexpected edit formatted body: %#v", newContent["formatted_body"])
|
|
}
|
|
}
|
|
|
|
func TestStripMatrixReplyFallback(t *testing.T) {
|
|
body := "> <@memoh:example.com> This looks like Antelope Canyon\n>\nWhere is Antelope Canyon?"
|
|
if got := stripMatrixReplyFallback(body); got != "Where is Antelope Canyon?" {
|
|
t.Fatalf("unexpected stripped body: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMatrixHandleEventExpandsRepliedImageContext(t *testing.T) {
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.rememberRoomConversationType("cfg-1", "!room:example.com", "group")
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if !strings.Contains(req.URL.Path, "/rooms/!room:example.com/event/$img1") {
|
|
t.Fatalf("unexpected request path: %s", req.URL.Path)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{
|
|
"event_id":"$img1",
|
|
"type":"m.room.message",
|
|
"sender":"@memoh:example.com",
|
|
"unsigned":{"displayname":"Memoh"},
|
|
"content":{
|
|
"msgtype":"m.image",
|
|
"body":"canyon.jpg",
|
|
"url":"mxc://matrix.example.com/media123",
|
|
"info":{"mimetype":"image/jpeg","w":640,"h":480}
|
|
}
|
|
}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
})}
|
|
|
|
var captured channel.InboundMessage
|
|
delivered, err := adapter.handleEvent(
|
|
context.Background(),
|
|
channel.ChannelConfig{ID: "cfg-1", BotID: "bot-1"},
|
|
Config{HomeserverURL: "https://matrix.example.com", AccessToken: "tok", UserID: "@memoh:example.com"},
|
|
matrixEvent{
|
|
EventID: "$evt2",
|
|
Type: "m.room.message",
|
|
Sender: "@alex:example.com",
|
|
RoomID: "!room:example.com",
|
|
Content: map[string]any{
|
|
"msgtype": "m.text",
|
|
"body": "> <@memoh:example.com> photo\n>\nWhere is Antelope Canyon?",
|
|
"m.relates_to": map[string]any{
|
|
"m.in_reply_to": map[string]any{"event_id": "$img1"},
|
|
},
|
|
},
|
|
},
|
|
func(_ context.Context, _ channel.ChannelConfig, msg channel.InboundMessage) error {
|
|
captured = msg
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("handleEvent returned error: %v", err)
|
|
}
|
|
if !delivered {
|
|
t.Fatal("expected event to be delivered")
|
|
}
|
|
if got := captured.Message.Text; got != "Where is Antelope Canyon?" {
|
|
t.Fatalf("unexpected message text: %q", got)
|
|
}
|
|
if captured.Message.Reply == nil {
|
|
t.Fatal("expected ReplyRef to be set")
|
|
}
|
|
if captured.Message.Reply.Sender != "Memoh" {
|
|
t.Fatalf("unexpected reply sender: %q", captured.Message.Reply.Sender)
|
|
}
|
|
if len(captured.Message.Attachments) != 1 {
|
|
t.Fatalf("expected one quoted attachment, got %d", len(captured.Message.Attachments))
|
|
}
|
|
if captured.Message.Attachments[0].PlatformKey != "mxc://matrix.example.com/media123" {
|
|
t.Fatalf("unexpected quoted attachment: %#v", captured.Message.Attachments[0])
|
|
}
|
|
isReplyToBot, _ := captured.Metadata["is_reply_to_bot"].(bool)
|
|
if !isReplyToBot {
|
|
t.Fatalf("expected is_reply_to_bot metadata to be true")
|
|
}
|
|
}
|
|
|
|
func TestMatrixHandleEventUsesImageCaptionAsMessageText(t *testing.T) {
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.rememberRoomConversationType("cfg-1", "!room:example.com", "group")
|
|
|
|
var captured channel.InboundMessage
|
|
delivered, err := adapter.handleEvent(
|
|
context.Background(),
|
|
channel.ChannelConfig{ID: "cfg-1", BotID: "bot-1"},
|
|
Config{HomeserverURL: "https://matrix.example.com", AccessToken: "tok", UserID: "@memoh:example.com"},
|
|
matrixEvent{
|
|
EventID: "$evt2",
|
|
Type: "m.room.message",
|
|
Sender: "@alex:example.com",
|
|
RoomID: "!room:example.com",
|
|
Content: map[string]any{
|
|
"msgtype": "m.image",
|
|
"body": "A hand-drawn system architecture diagram",
|
|
"filename": "diagram.png",
|
|
"url": "mxc://matrix.example.com/media123",
|
|
"info": map[string]any{
|
|
"mimetype": "image/png",
|
|
},
|
|
},
|
|
},
|
|
func(_ context.Context, _ channel.ChannelConfig, msg channel.InboundMessage) error {
|
|
captured = msg
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("handleEvent returned error: %v", err)
|
|
}
|
|
if !delivered {
|
|
t.Fatal("expected event to be delivered")
|
|
}
|
|
if got := captured.Message.Text; got != "A hand-drawn system architecture diagram" {
|
|
t.Fatalf("unexpected message text: %q", got)
|
|
}
|
|
if len(captured.Message.Attachments) != 1 {
|
|
t.Fatalf("expected one attachment, got %d", len(captured.Message.Attachments))
|
|
}
|
|
att := captured.Message.Attachments[0]
|
|
if att.Name != "diagram.png" || att.Caption != "A hand-drawn system architecture diagram" {
|
|
t.Fatalf("unexpected attachment metadata: %#v", att)
|
|
}
|
|
if rawText, _ := captured.Metadata["raw_text"].(string); rawText != "A hand-drawn system architecture diagram" {
|
|
t.Fatalf("unexpected raw_text metadata: %q", rawText)
|
|
}
|
|
}
|
|
|
|
func TestMatrixHandleEventMarksDirectConversationFromJoinedMembers(t *testing.T) {
|
|
joinedMembersRequests := 0
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Path {
|
|
case "/_matrix/client/v3/rooms/!room:example.com/joined_members":
|
|
joinedMembersRequests++
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{
|
|
"joined": {
|
|
"@alex:example.com": {"display_name": "Alex"},
|
|
"@memoh:example.com": {"display_name": "Memoh"}
|
|
}
|
|
}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
default:
|
|
t.Fatalf("unexpected request path: %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})}
|
|
|
|
var captured []channel.InboundMessage
|
|
for i := 0; i < 2; i++ {
|
|
delivered, err := adapter.handleEvent(
|
|
context.Background(),
|
|
channel.ChannelConfig{ID: "cfg-1", BotID: "bot-1"},
|
|
Config{HomeserverURL: "https://matrix.example.com", AccessToken: "tok", UserID: "@memoh:example.com"},
|
|
matrixEvent{
|
|
EventID: fmt.Sprintf("$evt%d", i+1),
|
|
Type: "m.room.message",
|
|
Sender: "@alex:example.com",
|
|
RoomID: "!room:example.com",
|
|
Content: map[string]any{
|
|
"msgtype": "m.text",
|
|
"body": "ping",
|
|
},
|
|
},
|
|
func(_ context.Context, _ channel.ChannelConfig, msg channel.InboundMessage) error {
|
|
captured = append(captured, msg)
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("handleEvent returned error: %v", err)
|
|
}
|
|
if !delivered {
|
|
t.Fatal("expected event to be delivered")
|
|
}
|
|
}
|
|
|
|
if len(captured) != 2 {
|
|
t.Fatalf("expected two captured messages, got %d", len(captured))
|
|
}
|
|
if captured[0].Conversation.Type != "direct" {
|
|
t.Fatalf("expected direct conversation type, got %q", captured[0].Conversation.Type)
|
|
}
|
|
if joinedMembersRequests != 1 {
|
|
t.Fatalf("expected joined_members lookup to be cached, got %d requests", joinedMembersRequests)
|
|
}
|
|
}
|
|
|
|
func TestMatrixSyncOnceAutoJoinsInvitedRooms(t *testing.T) {
|
|
joinRequests := 0
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.rememberRoomConversationType("cfg-1", "!joined:example.com", "group")
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Path {
|
|
case "/_matrix/client/v3/sync":
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{
|
|
"next_batch":"s124",
|
|
"rooms":{
|
|
"invite":{"!invite:example.com":{"invite_state":{"events":[{"type":"m.room.member"}]}}},
|
|
"join":{"!joined:example.com":{"timeline":{"events":[{"event_id":"$evt1","type":"m.room.message","sender":"@alex:example.com","content":{"msgtype":"m.text","body":"ping"}}]}}}
|
|
}
|
|
}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
case "/_matrix/client/v3/join/!invite:example.com":
|
|
joinRequests++
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
default:
|
|
t.Fatalf("unexpected request path: %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})}
|
|
|
|
var captured channel.InboundMessage
|
|
nextSince, healthy, err := adapter.syncOnce(
|
|
context.Background(),
|
|
channel.ChannelConfig{ID: "cfg-1", BotID: "bot-1"},
|
|
Config{HomeserverURL: "https://matrix.example.com", AccessToken: "tok", UserID: "@memoh:example.com", SyncTimeoutSeconds: 30, AutoJoinInvites: true},
|
|
"s123",
|
|
func(_ context.Context, _ channel.ChannelConfig, msg channel.InboundMessage) error {
|
|
captured = msg
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("syncOnce returned error: %v", err)
|
|
}
|
|
if nextSince != "s124" {
|
|
t.Fatalf("unexpected next since token: %q", nextSince)
|
|
}
|
|
if !healthy {
|
|
t.Fatal("expected sync session to be marked healthy")
|
|
}
|
|
if joinRequests != 1 {
|
|
t.Fatalf("expected invited room to be auto-joined once, got %d", joinRequests)
|
|
}
|
|
if captured.ReplyTarget != "!joined:example.com" || captured.Message.Text != "ping" {
|
|
t.Fatalf("unexpected captured message: %#v", captured)
|
|
}
|
|
}
|
|
|
|
func TestExtractMatrixDirectRoomIDs(t *testing.T) {
|
|
roomIDs := extractMatrixDirectRoomIDs(matrixSyncResponse{
|
|
AccountData: struct {
|
|
Events []matrixSyncEvent `json:"events"`
|
|
}{
|
|
Events: []matrixSyncEvent{{
|
|
Type: "m.direct",
|
|
Content: map[string]any{
|
|
"@alice:example.com": []any{"!dm:example.com", " !dm2:example.com "},
|
|
},
|
|
}},
|
|
},
|
|
})
|
|
|
|
if _, ok := roomIDs["!dm:example.com"]; !ok {
|
|
t.Fatal("expected first direct room id to be extracted")
|
|
}
|
|
if _, ok := roomIDs["!dm2:example.com"]; !ok {
|
|
t.Fatal("expected second direct room id to be extracted")
|
|
}
|
|
}
|
|
|
|
func TestExtractMatrixDirectRooms(t *testing.T) {
|
|
directRooms := extractMatrixDirectRooms(matrixSyncResponse{
|
|
AccountData: struct {
|
|
Events []matrixSyncEvent `json:"events"`
|
|
}{
|
|
Events: []matrixSyncEvent{{
|
|
Type: "m.direct",
|
|
Content: map[string]any{
|
|
"@alice:example.com": []any{"!dm:example.com", "!ignored:example.com"},
|
|
"@bob:example.com": []any{" !bob:example.com "},
|
|
},
|
|
}},
|
|
},
|
|
})
|
|
|
|
if got := directRooms["@alice:example.com"]; got != "!dm:example.com" {
|
|
t.Fatalf("unexpected Alice direct room: %q", got)
|
|
}
|
|
if got := directRooms["@bob:example.com"]; got != "!bob:example.com" {
|
|
t.Fatalf("unexpected Bob direct room: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEnsureDirectRoomReusesExistingRoom(t *testing.T) {
|
|
joinedRoomsRequests := 0
|
|
joinedMembersRequests := 0
|
|
createRoomRequests := 0
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Path {
|
|
case "/_matrix/client/v3/joined_rooms":
|
|
joinedRoomsRequests++
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"joined_rooms":["!dm:example.com"]}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
case "/_matrix/client/v3/rooms/!dm:example.com/joined_members":
|
|
joinedMembersRequests++
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"joined":{"@memoh:example.com":{},"@alice:example.com":{}}}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
case "/_matrix/client/v3/createRoom":
|
|
createRoomRequests++
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"room_id":"!new:example.com"}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
default:
|
|
t.Fatalf("unexpected request path: %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})}
|
|
|
|
cfg := Config{
|
|
HomeserverURL: "https://matrix.example.com",
|
|
AccessToken: "tok",
|
|
UserID: "@memoh:example.com",
|
|
}
|
|
|
|
roomID, err := adapter.ensureDirectRoom(context.Background(), cfg, "@alice:example.com")
|
|
if err != nil {
|
|
t.Fatalf("ensureDirectRoom returned error: %v", err)
|
|
}
|
|
if roomID != "!dm:example.com" {
|
|
t.Fatalf("unexpected room id: %q", roomID)
|
|
}
|
|
roomID, err = adapter.ensureDirectRoom(context.Background(), cfg, "@alice:example.com")
|
|
if err != nil {
|
|
t.Fatalf("ensureDirectRoom second call returned error: %v", err)
|
|
}
|
|
if roomID != "!dm:example.com" {
|
|
t.Fatalf("unexpected cached room id: %q", roomID)
|
|
}
|
|
if joinedRoomsRequests != 1 {
|
|
t.Fatalf("expected joined room lookup once, got %d", joinedRoomsRequests)
|
|
}
|
|
if joinedMembersRequests != 1 {
|
|
t.Fatalf("expected joined members lookup once, got %d", joinedMembersRequests)
|
|
}
|
|
if createRoomRequests != 0 {
|
|
t.Fatalf("expected no createRoom requests, got %d", createRoomRequests)
|
|
}
|
|
}
|
|
|
|
func TestEnsureDirectRoomCachesCreatedRoom(t *testing.T) {
|
|
joinedRoomsRequests := 0
|
|
createRoomRequests := 0
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Path {
|
|
case "/_matrix/client/v3/joined_rooms":
|
|
joinedRoomsRequests++
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"joined_rooms":[]}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
case "/_matrix/client/v3/createRoom":
|
|
createRoomRequests++
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"room_id":"!new:example.com"}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
default:
|
|
t.Fatalf("unexpected request path: %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})}
|
|
|
|
cfg := Config{
|
|
HomeserverURL: "https://matrix.example.com",
|
|
AccessToken: "tok",
|
|
UserID: "@memoh:example.com",
|
|
}
|
|
|
|
roomID, err := adapter.ensureDirectRoom(context.Background(), cfg, "@alice:example.com")
|
|
if err != nil {
|
|
t.Fatalf("ensureDirectRoom returned error: %v", err)
|
|
}
|
|
if roomID != "!new:example.com" {
|
|
t.Fatalf("unexpected room id: %q", roomID)
|
|
}
|
|
roomID, err = adapter.ensureDirectRoom(context.Background(), cfg, "@alice:example.com")
|
|
if err != nil {
|
|
t.Fatalf("ensureDirectRoom second call returned error: %v", err)
|
|
}
|
|
if roomID != "!new:example.com" {
|
|
t.Fatalf("unexpected cached room id: %q", roomID)
|
|
}
|
|
if joinedRoomsRequests != 1 {
|
|
t.Fatalf("expected joined room lookup once, got %d", joinedRoomsRequests)
|
|
}
|
|
if createRoomRequests != 1 {
|
|
t.Fatalf("expected createRoom once, got %d", createRoomRequests)
|
|
}
|
|
}
|
|
|
|
func TestExtractMatrixInboundContentParsesImageAttachment(t *testing.T) {
|
|
text, attachments := extractMatrixInboundContent(map[string]any{
|
|
"msgtype": "m.image",
|
|
"body": "diagram.png",
|
|
"url": "mxc://matrix.example.com/media123",
|
|
"info": map[string]any{
|
|
"mimetype": "image/png",
|
|
"size": 42,
|
|
"w": 640,
|
|
"h": 480,
|
|
},
|
|
})
|
|
if text != "" {
|
|
t.Fatalf("expected empty text for attachment message, got %q", text)
|
|
}
|
|
if len(attachments) != 1 {
|
|
t.Fatalf("expected 1 attachment, got %d", len(attachments))
|
|
}
|
|
att := attachments[0]
|
|
if att.Type != channel.AttachmentImage {
|
|
t.Fatalf("unexpected attachment type: %s", att.Type)
|
|
}
|
|
if att.PlatformKey != "mxc://matrix.example.com/media123" {
|
|
t.Fatalf("unexpected platform key: %q", att.PlatformKey)
|
|
}
|
|
if att.Name != "diagram.png" || att.Mime != "image/png" {
|
|
t.Fatalf("unexpected attachment metadata: %#v", att)
|
|
}
|
|
if att.Width != 640 || att.Height != 480 || att.Size != 42 {
|
|
t.Fatalf("unexpected attachment dimensions: %#v", att)
|
|
}
|
|
if att.Caption != "" {
|
|
t.Fatalf("expected empty caption, got %#v", att)
|
|
}
|
|
}
|
|
|
|
func TestExtractMatrixInboundContentParsesImageCaption(t *testing.T) {
|
|
text, attachments := extractMatrixInboundContent(map[string]any{
|
|
"msgtype": "m.image",
|
|
"body": "System architecture diagram",
|
|
"filename": "diagram.png",
|
|
"url": "mxc://matrix.example.com/media123",
|
|
"info": map[string]any{
|
|
"mimetype": "image/png",
|
|
},
|
|
})
|
|
if text != "System architecture diagram" {
|
|
t.Fatalf("expected caption text, got %q", text)
|
|
}
|
|
if len(attachments) != 1 {
|
|
t.Fatalf("expected 1 attachment, got %d", len(attachments))
|
|
}
|
|
att := attachments[0]
|
|
if att.Name != "diagram.png" {
|
|
t.Fatalf("unexpected attachment name: %#v", att)
|
|
}
|
|
if att.Caption != "System architecture diagram" {
|
|
t.Fatalf("unexpected attachment caption: %#v", att)
|
|
}
|
|
}
|
|
|
|
func TestMatrixSendUploadsBase64AttachmentAndSendsMediaEvent(t *testing.T) {
|
|
requests := make([]string, 0, 2)
|
|
uploadedContentTypes := make([]string, 0, 1)
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
requests = append(requests, req.URL.Path)
|
|
if strings.Contains(req.URL.Path, "/_matrix/media/v3/upload") {
|
|
uploadedContentTypes = append(uploadedContentTypes, req.Header.Get("Content-Type"))
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"content_uri":"mxc://matrix.example.com/uploaded1"}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}
|
|
payload, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var content map[string]any
|
|
if err := json.Unmarshal(payload, &content); err != nil {
|
|
return nil, err
|
|
}
|
|
if got := content["msgtype"]; got != "m.image" {
|
|
t.Fatalf("unexpected msgtype: %#v", got)
|
|
}
|
|
if got := content["url"]; got != "mxc://matrix.example.com/uploaded1" {
|
|
t.Fatalf("unexpected uploaded uri: %#v", got)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"event_id":"$evt1"}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
})}
|
|
|
|
logicalAttachment := channel.Attachment{
|
|
Type: channel.AttachmentImage,
|
|
Name: "chart.png",
|
|
Mime: "image/png",
|
|
}
|
|
err := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
BotID: "bot-1",
|
|
Credentials: map[string]any{
|
|
"homeserverUrl": "https://matrix.example.com",
|
|
"userId": "@memoh:example.com",
|
|
"accessToken": "tok",
|
|
},
|
|
}, channel.PreparedOutboundMessage{
|
|
Target: "!room:example.com",
|
|
Message: channel.PreparedMessage{
|
|
Message: channel.Message{
|
|
Attachments: []channel.Attachment{logicalAttachment},
|
|
},
|
|
Attachments: []channel.PreparedAttachment{{
|
|
Logical: logicalAttachment,
|
|
Kind: channel.PreparedAttachmentUpload,
|
|
Name: "chart.png",
|
|
Mime: "image/png",
|
|
Open: func(context.Context) (io.ReadCloser, error) {
|
|
return io.NopCloser(strings.NewReader("hello")), nil
|
|
},
|
|
}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send returned error: %v", err)
|
|
}
|
|
if len(requests) != 2 {
|
|
t.Fatalf("expected upload and send requests, got %d", len(requests))
|
|
}
|
|
if len(uploadedContentTypes) != 1 || uploadedContentTypes[0] != "image/png" {
|
|
t.Fatalf("unexpected upload content type: %#v", uploadedContentTypes)
|
|
}
|
|
}
|
|
|
|
func TestMatrixSendResolvesRoomAlias(t *testing.T) {
|
|
requests := make([]string, 0, 2)
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
requests = append(requests, req.URL.Path)
|
|
switch req.URL.Path {
|
|
case "/_matrix/client/v3/directory/room/#ops:example.com":
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"room_id":"!resolved:example.com"}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
default:
|
|
if !strings.Contains(req.URL.Path, "/_matrix/client/v3/rooms/!resolved:example.com/send/m.room.message/") {
|
|
t.Fatalf("unexpected request path: %s", req.URL.Path)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"event_id":"$evt1"}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}
|
|
})}
|
|
|
|
err := adapter.Send(context.Background(), channel.ChannelConfig{
|
|
Credentials: map[string]any{
|
|
"homeserverUrl": "https://matrix.example.com",
|
|
"userId": "@memoh:example.com",
|
|
"accessToken": "tok",
|
|
},
|
|
}, channel.PreparedOutboundMessage{
|
|
Target: "#ops:example.com",
|
|
Message: channel.PreparedMessage{
|
|
Message: channel.Message{
|
|
Text: "ping",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send returned error: %v", err)
|
|
}
|
|
if len(requests) != 2 {
|
|
t.Fatalf("expected alias lookup and send requests, got %d", len(requests))
|
|
}
|
|
}
|
|
|
|
func TestMatrixResolveAttachmentDownloadsMXC(t *testing.T) {
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if !strings.Contains(req.URL.Path, "/_matrix/client/v1/media/download/matrix.example.com/media123/image.png") {
|
|
t.Fatalf("unexpected download path: %s", req.URL.Path)
|
|
}
|
|
resp := &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader("file-bytes")),
|
|
Header: make(http.Header),
|
|
}
|
|
resp.Header.Set("Content-Type", "image/png")
|
|
resp.ContentLength = int64(len("file-bytes"))
|
|
return resp, nil
|
|
})}
|
|
|
|
payload, err := adapter.ResolveAttachment(context.Background(), channel.ChannelConfig{
|
|
Credentials: map[string]any{
|
|
"homeserverUrl": "https://matrix.example.com",
|
|
"userId": "@memoh:example.com",
|
|
"accessToken": "tok",
|
|
},
|
|
}, channel.Attachment{
|
|
PlatformKey: "mxc://matrix.example.com/media123",
|
|
Name: "image.png",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ResolveAttachment returned error: %v", err)
|
|
}
|
|
defer func() { _ = payload.Reader.Close() }()
|
|
data, err := io.ReadAll(payload.Reader)
|
|
if err != nil {
|
|
t.Fatalf("read payload: %v", err)
|
|
}
|
|
if string(data) != "file-bytes" {
|
|
t.Fatalf("unexpected payload: %q", string(data))
|
|
}
|
|
if payload.Mime != "image/png" || payload.Name != "image.png" || payload.Size != int64(len("file-bytes")) {
|
|
t.Fatalf("unexpected payload metadata: %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestMatrixResolveAttachmentFallsBackToLegacyMediaDownload(t *testing.T) {
|
|
paths := make([]string, 0, 2)
|
|
adapter := NewMatrixAdapter(nil)
|
|
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
paths = append(paths, req.URL.Path)
|
|
if strings.Contains(req.URL.Path, "/_matrix/client/v1/media/download/") {
|
|
return &http.Response{
|
|
StatusCode: http.StatusNotFound,
|
|
Body: io.NopCloser(strings.NewReader(`{"errcode":"M_NOT_FOUND"}`)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}
|
|
if !strings.Contains(req.URL.Path, "/_matrix/media/v3/download/matrix.example.com/media123") {
|
|
t.Fatalf("unexpected fallback path: %s", req.URL.Path)
|
|
}
|
|
resp := &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader("legacy-file")),
|
|
Header: make(http.Header),
|
|
}
|
|
resp.Header.Set("Content-Type", "application/octet-stream")
|
|
return resp, nil
|
|
})}
|
|
|
|
payload, err := adapter.ResolveAttachment(context.Background(), channel.ChannelConfig{
|
|
Credentials: map[string]any{
|
|
"homeserverUrl": "https://matrix.example.com",
|
|
"userId": "@memoh:example.com",
|
|
"accessToken": "tok",
|
|
},
|
|
}, channel.Attachment{
|
|
PlatformKey: "mxc://matrix.example.com/media123",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ResolveAttachment returned error: %v", err)
|
|
}
|
|
defer func() { _ = payload.Reader.Close() }()
|
|
if len(paths) != 2 {
|
|
t.Fatalf("expected authenticated and legacy download attempts, got %d", len(paths))
|
|
}
|
|
}
|