From bcfbc2a55762d8f65f5da7a8428f7a442a4acbe6 Mon Sep 17 00:00:00 2001 From: highesttt Date: Mon, 27 Jul 2026 13:50:40 -0400 Subject: [PATCH 1/2] feat: Notes & Albumbs support --- pkg/connector/consts.go | 21 ++-- pkg/connector/handle_message.go | 28 ++++- pkg/connector/handle_message_test.go | 119 ++++++++++++++++++ pkg/connector/handlers/post_notification.go | 61 +++++++++ .../handlers/post_notification_test.go | 86 +++++++++++++ 5 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 pkg/connector/handlers/post_notification.go create mode 100644 pkg/connector/handlers/post_notification_test.go diff --git a/pkg/connector/consts.go b/pkg/connector/consts.go index 16d7b43..ee40756 100644 --- a/pkg/connector/consts.go +++ b/pkg/connector/consts.go @@ -30,16 +30,17 @@ const ( type ContentType int const ( - ContentText ContentType = 0 - ContentImage ContentType = 1 - ContentVideo ContentType = 2 - ContentAudio ContentType = 3 - ContentSticker ContentType = 7 - ContentContact ContentType = 13 - ContentFile ContentType = 14 - ContentLocation ContentType = 15 - ContentSystem ContentType = 18 - ContentFlex ContentType = 22 + ContentText ContentType = 0 + ContentImage ContentType = 1 + ContentVideo ContentType = 2 + ContentAudio ContentType = 3 + ContentSticker ContentType = 7 + ContentContact ContentType = 13 + ContentFile ContentType = 14 + ContentLocation ContentType = 15 + ContentPostNotification ContentType = 16 + ContentSystem ContentType = 18 + ContentFlex ContentType = 22 ) // ToType values for LINE message destinations. diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index fd01f43..08e3a1f 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -170,9 +170,15 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) bool { // isBridgeableContentType reports whether an inbound LINE message should be // bridged to Matrix. System messages (group created, member invited, etc.) are -// skipped, but call and contact notifications are let through regardless of -// content type because LINE may wrap them in non-standard content type values. +// skipped, but post, call, and contact notifications are let through regardless +// of content type because LINE may wrap them in non-standard content types. func isBridgeableContentType(msg *line.Message) bool { + if msg == nil { + return false + } + if isPostNotification(msg) { + return true + } switch ContentType(msg.ContentType) { case ContentText, ContentImage, ContentVideo, ContentAudio, ContentSticker, ContentContact, ContentFile, ContentLocation, ContentFlex: @@ -182,6 +188,17 @@ func isBridgeableContentType(msg *line.Message) bool { } } +// isPostNotification reports whether a LINE message represents a note, album, +// or another post notification. LINE sends native notifications as content +// type 16 and shared notifications as text messages with ORGCONTP metadata. +func isPostNotification(msg *line.Message) bool { + if msg == nil { + return false + } + return ContentType(msg.ContentType) == ContentPostNotification || + msg.ContentMetadata["ORGCONTP"] == "POSTNOTIFICATION" +} + // portalMIDForMessage returns the chat MID that owns a message (the portal key). func portalMIDForMessage(msg *line.Message, opType int) string { portalIDStr := msg.From @@ -372,6 +389,13 @@ func (lc *LineClient) convertLineMessage(ctx context.Context, portal *bridgev2.P h := lc.newMessageHandler() + // Handle LINE notes/albums before ordinary text conversion. Shared posts + // arrive as contentType 0 with LINE's unsupported-client fallback in Text, + // while the useful preview and link are in ContentMetadata. + if isPostNotification(&data) { + return h.ConvertPostNotification(data, replyRelatesTo) + } + // Handle call events (ORGCONTP == "CALL") if data.ContentMetadata["ORGCONTP"] == "CALL" { return h.ConvertCall(data, replyRelatesTo) diff --git a/pkg/connector/handle_message_test.go b/pkg/connector/handle_message_test.go index 16c7774..915d32c 100644 --- a/pkg/connector/handle_message_test.go +++ b/pkg/connector/handle_message_test.go @@ -32,6 +32,125 @@ func (m *inlineEmojiTestMatrix) UploadMedia(_ context.Context, _ id.RoomID, _ [] return "mxc://example/custom-emoji", nil, nil } +func TestPostNotificationClassification(t *testing.T) { + tests := []struct { + name string + message *line.Message + wantPost bool + wantBridgeable bool + }{ + { + name: "nil message", + message: nil, + wantPost: false, + wantBridgeable: false, + }, + { + name: "native post notification", + message: &line.Message{ContentType: int(ContentPostNotification)}, + wantPost: true, + wantBridgeable: true, + }, + { + name: "shared post notification", + message: &line.Message{ + ContentType: int(ContentText), + ContentMetadata: map[string]string{ + "ORGCONTP": "POSTNOTIFICATION", + }, + }, + wantPost: true, + wantBridgeable: true, + }, + { + name: "ordinary text", + message: &line.Message{ContentType: int(ContentText)}, + wantPost: false, + wantBridgeable: true, + }, + { + name: "post notification in unknown wrapper", + message: &line.Message{ + ContentType: 99, + ContentMetadata: map[string]string{ + "ORGCONTP": "POSTNOTIFICATION", + }, + }, + wantPost: true, + wantBridgeable: true, + }, + { + name: "unrelated system message", + message: &line.Message{ + ContentType: int(ContentSystem), + ContentMetadata: map[string]string{ + "LOC_KEY": "BD", + }, + }, + wantPost: false, + wantBridgeable: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isPostNotification(test.message); got != test.wantPost { + t.Fatalf("isPostNotification() = %t, want %t", got, test.wantPost) + } + if got := isBridgeableContentType(test.message); got != test.wantBridgeable { + t.Fatalf("isBridgeableContentType() = %t, want %t", got, test.wantBridgeable) + } + }) + } +} + +func TestConvertLineMessageDispatchesSharedPostBeforeTextFallback(t *testing.T) { + const fallbackText = "Your version of LINE doesn't support this type of message." + data := line.Message{ + ContentType: int(ContentText), + Text: fallbackText, + ContentMetadata: map[string]string{ + "ORGCONTP": "POSTNOTIFICATION", + "serviceType": "GB", + "text": "Shared note preview", + "postEndUrl": "https://line.me/R/group/home/posts/post?example=shared", + }, + } + lc := &LineClient{ + UserLogin: &bridgev2.UserLogin{ + Bridge: &bridgev2.Bridge{Log: zerolog.New(io.Discard)}, + }, + } + + converted, err := lc.convertLineMessage( + t.Context(), + nil, + nil, + data, + fallbackText, + fallbackText, + false, + ) + if err != nil { + t.Fatalf("convertLineMessage returned error: %v", err) + } + if converted == nil || len(converted.Parts) != 1 || converted.Parts[0].Content == nil { + t.Fatalf("convertLineMessage returned %#v, want one message part", converted) + } + content := converted.Parts[0].Content + if content.MsgType != event.MsgNotice { + t.Fatalf("MsgType = %s, want %s", content.MsgType, event.MsgNotice) + } + if strings.Contains(content.Body, fallbackText) { + t.Fatalf("Body = %q, must not contain LINE's unsupported-client fallback", content.Body) + } + expectedBody := "You received a LINE note.\n\nPreview:\nShared note preview\n\n" + + "Open in LINE: https://line.me/R/group/home/posts/post?example=shared" + if content.Body != expectedBody { + t.Fatalf("Body = %q, want %q", content.Body, expectedBody) + } +} + func TestConvertLineMessageRendersPlaintextCustomEmoji(t *testing.T) { const ( emtver4Token = "\U00100101\U00100211yoo-hoo\U0010ffff" diff --git a/pkg/connector/handlers/post_notification.go b/pkg/connector/handlers/post_notification.go new file mode 100644 index 0000000..cdd995d --- /dev/null +++ b/pkg/connector/handlers/post_notification.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "strings" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + + "github.com/highesttt/matrix-line-messenger/pkg/line" +) + +// ConvertPostNotification converts a LINE note, album, or unknown post +// notification into a readable Matrix notice. +func (*Handler) ConvertPostNotification(data line.Message, relatesTo *event.RelatesTo) (*bridgev2.ConvertedMessage, error) { + serviceType := strings.ToUpper(strings.TrimSpace(data.ContentMetadata["serviceType"])) + preview := strings.TrimSpace(data.ContentMetadata["text"]) + albumName := strings.TrimSpace(data.ContentMetadata["albumName"]) + postURL := strings.TrimSpace(data.ContentMetadata["postEndUrl"]) + + var body strings.Builder + switch serviceType { + case "GB": + body.WriteString("You received a LINE note.") + case "AB": + if albumName == "" { + body.WriteString("You received a LINE album update.") + } else { + body.WriteString("LINE album update: ") + body.WriteString(albumName) + } + default: + body.WriteString("You received a LINE post notification.") + if preview == "" { + preview = albumName + } + } + + if preview != "" { + body.WriteString("\n\nPreview:\n") + body.WriteString(preview) + } + if postURL != "" { + body.WriteString("\n\nOpen in LINE: ") + body.WriteString(postURL) + } else { + body.WriteString("\n\nOpen LINE for full details.") + } + + return &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{ + { + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: body.String(), + RelatesTo: relatesTo, + }, + }, + }, + }, nil +} diff --git a/pkg/connector/handlers/post_notification_test.go b/pkg/connector/handlers/post_notification_test.go new file mode 100644 index 0000000..b52f53e --- /dev/null +++ b/pkg/connector/handlers/post_notification_test.go @@ -0,0 +1,86 @@ +package handlers + +import ( + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + + "github.com/highesttt/matrix-line-messenger/pkg/line" +) + +func TestConvertPostNotification(t *testing.T) { + relatesTo := &event.RelatesTo{} + tests := []struct { + name string + metadata map[string]string + expected string + }{ + { + name: "note with multiline preview and link", + metadata: map[string]string{ + "serviceType": "GB", + "text": "First line\nSecond line", + "postEndUrl": "https://line.me/R/group/home/posts/post?example=1", + }, + expected: "You received a LINE note.\n\nPreview:\nFirst line\nSecond line\n\n" + + "Open in LINE: https://line.me/R/group/home/posts/post?example=1", + }, + { + name: "album with name and deep link", + metadata: map[string]string{ + "serviceType": "AB", + "albumName": "Summer photos", + "postEndUrl": "line://group/home/albums/album?example=1", + }, + expected: "LINE album update: Summer photos\n\n" + + "Open in LINE: line://group/home/albums/album?example=1", + }, + { + name: "missing metadata", + metadata: nil, + expected: "You received a LINE post notification.\n\nOpen LINE for full details.", + }, + { + name: "unknown service uses available preview", + metadata: map[string]string{ + "serviceType": "OTHER", + "text": "Post preview", + }, + expected: "You received a LINE post notification.\n\nPreview:\nPost preview\n\n" + + "Open LINE for full details.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + converted, err := (&Handler{}).ConvertPostNotification(line.Message{ + ContentMetadata: test.metadata, + }, relatesTo) + if err != nil { + t.Fatalf("ConvertPostNotification returned error: %v", err) + } + assertPostNotificationContent(t, converted, test.expected, relatesTo) + }) + } +} + +func assertPostNotificationContent(t *testing.T, converted *bridgev2.ConvertedMessage, expectedBody string, relatesTo *event.RelatesTo) { + t.Helper() + if converted == nil || len(converted.Parts) != 1 || converted.Parts[0].Content == nil { + t.Fatalf("converted = %#v, want one message part", converted) + } + part := converted.Parts[0] + if part.Type != event.EventMessage { + t.Fatalf("event type = %v, want %v", part.Type, event.EventMessage) + } + if part.Content.MsgType != event.MsgNotice { + t.Fatalf("message type = %v, want %v", part.Content.MsgType, event.MsgNotice) + } + if part.Content.Body != expectedBody { + t.Fatalf("body = %q, want %q", part.Content.Body, expectedBody) + } + if part.Content.RelatesTo != relatesTo { + t.Fatalf("relates_to = %#v, want original pointer %#v", part.Content.RelatesTo, relatesTo) + } +} From 30bb1bbf77c723084253affc148d2d96ce3dc4c2 Mon Sep 17 00:00:00 2001 From: highesttt Date: Mon, 27 Jul 2026 15:26:30 -0400 Subject: [PATCH 2/2] fix: Post notifications now bypass decrypt-failure fallback. --- pkg/connector/handle_message.go | 15 ++-- pkg/connector/handle_message_test.go | 70 ++++++++++++------- pkg/connector/handlers/post_notification.go | 22 ++++-- .../handlers/post_notification_test.go | 39 ++++++++--- 4 files changed, 99 insertions(+), 47 deletions(-) diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index 08e3a1f..80565d2 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -372,6 +372,14 @@ func (lc *LineClient) convertLineMessage(ctx context.Context, portal *bridgev2.P decryptedBody := bodyText replyRelatesTo := lc.resolveReplyRelatesTo(ctx, &data) + // Handle LINE notes/albums before decryption failures and ordinary text + // conversion. Post metadata is unencrypted, so it remains useful even when + // a shared post's text fallback was marked as encrypted but could not be + // decrypted. + if isPostNotification(&data) { + return lc.newMessageHandler().ConvertPostNotification(data, replyRelatesTo) + } + if decryptionFailed && strings.TrimSpace(unwrappedText) == "" && ContentType(data.ContentType) == ContentText { return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ @@ -389,13 +397,6 @@ func (lc *LineClient) convertLineMessage(ctx context.Context, portal *bridgev2.P h := lc.newMessageHandler() - // Handle LINE notes/albums before ordinary text conversion. Shared posts - // arrive as contentType 0 with LINE's unsupported-client fallback in Text, - // while the useful preview and link are in ContentMetadata. - if isPostNotification(&data) { - return h.ConvertPostNotification(data, replyRelatesTo) - } - // Handle call events (ORGCONTP == "CALL") if data.ContentMetadata["ORGCONTP"] == "CALL" { return h.ConvertCall(data, replyRelatesTo) diff --git a/pkg/connector/handle_message_test.go b/pkg/connector/handle_message_test.go index 915d32c..30c12e1 100644 --- a/pkg/connector/handle_message_test.go +++ b/pkg/connector/handle_message_test.go @@ -122,32 +122,54 @@ func TestConvertLineMessageDispatchesSharedPostBeforeTextFallback(t *testing.T) }, } - converted, err := lc.convertLineMessage( - t.Context(), - nil, - nil, - data, - fallbackText, - fallbackText, - false, - ) - if err != nil { - t.Fatalf("convertLineMessage returned error: %v", err) - } - if converted == nil || len(converted.Parts) != 1 || converted.Parts[0].Content == nil { - t.Fatalf("convertLineMessage returned %#v, want one message part", converted) - } - content := converted.Parts[0].Content - if content.MsgType != event.MsgNotice { - t.Fatalf("MsgType = %s, want %s", content.MsgType, event.MsgNotice) - } - if strings.Contains(content.Body, fallbackText) { - t.Fatalf("Body = %q, must not contain LINE's unsupported-client fallback", content.Body) - } expectedBody := "You received a LINE note.\n\nPreview:\nShared note preview\n\n" + "Open in LINE: https://line.me/R/group/home/posts/post?example=shared" - if content.Body != expectedBody { - t.Fatalf("Body = %q, want %q", content.Body, expectedBody) + + tests := []struct { + name string + bodyText string + unwrappedText string + decryptionFailed bool + }{ + { + name: "unsupported text fallback", + bodyText: fallbackText, + unwrappedText: fallbackText, + }, + { + name: "decryption failure", + decryptionFailed: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + converted, err := lc.convertLineMessage( + t.Context(), + nil, + nil, + data, + test.bodyText, + test.unwrappedText, + test.decryptionFailed, + ) + if err != nil { + t.Fatalf("convertLineMessage returned error: %v", err) + } + if converted == nil || len(converted.Parts) != 1 || converted.Parts[0].Content == nil { + t.Fatalf("convertLineMessage returned %#v, want one message part", converted) + } + content := converted.Parts[0].Content + if content.MsgType != event.MsgNotice { + t.Fatalf("MsgType = %s, want %s", content.MsgType, event.MsgNotice) + } + if strings.Contains(content.Body, fallbackText) { + t.Fatalf("Body = %q, must not contain LINE's unsupported-client fallback", content.Body) + } + if content.Body != expectedBody { + t.Fatalf("Body = %q, want %q", content.Body, expectedBody) + } + }) } } diff --git a/pkg/connector/handlers/post_notification.go b/pkg/connector/handlers/post_notification.go index cdd995d..87d1aff 100644 --- a/pkg/connector/handlers/post_notification.go +++ b/pkg/connector/handlers/post_notification.go @@ -1,6 +1,7 @@ package handlers import ( + "html" "strings" "maunium.net/go/mautrix/bridgev2" @@ -46,15 +47,24 @@ func (*Handler) ConvertPostNotification(data line.Message, relatesTo *event.Rela body.WriteString("\n\nOpen LINE for full details.") } + content := &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: body.String(), + RelatesTo: relatesTo, + } + if postURL != "" { + plainPrefix := strings.TrimSuffix(content.Body, postURL) + escapedURL := html.EscapeString(postURL) + content.Format = event.FormatHTML + content.FormattedBody = strings.ReplaceAll(html.EscapeString(plainPrefix), "\n", "
") + + `` + escapedURL + `` + } + return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ { - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: body.String(), - RelatesTo: relatesTo, - }, + Type: event.EventMessage, + Content: content, }, }, }, nil diff --git a/pkg/connector/handlers/post_notification_test.go b/pkg/connector/handlers/post_notification_test.go index b52f53e..f9d2704 100644 --- a/pkg/connector/handlers/post_notification_test.go +++ b/pkg/connector/handlers/post_notification_test.go @@ -12,9 +12,10 @@ import ( func TestConvertPostNotification(t *testing.T) { relatesTo := &event.RelatesTo{} tests := []struct { - name string - metadata map[string]string - expected string + name string + metadata map[string]string + expected string + expectedHTML string }{ { name: "note with multiline preview and link", @@ -25,16 +26,22 @@ func TestConvertPostNotification(t *testing.T) { }, expected: "You received a LINE note.\n\nPreview:\nFirst line\nSecond line\n\n" + "Open in LINE: https://line.me/R/group/home/posts/post?example=1", + expectedHTML: "You received a LINE note.

Preview:
First line
Second line

" + + `Open in LINE: ` + + "https://line.me/R/group/home/posts/post?example=1", }, { - name: "album with name and deep link", + name: "album with escaped name and deep link", metadata: map[string]string{ "serviceType": "AB", - "albumName": "Summer photos", - "postEndUrl": "line://group/home/albums/album?example=1", + "albumName": "Summer ", + "postEndUrl": "line://group/home/albums/album?example=1&source=chat", }, - expected: "LINE album update: Summer photos\n\n" + - "Open in LINE: line://group/home/albums/album?example=1", + expected: "LINE album update: Summer \n\n" + + "Open in LINE: line://group/home/albums/album?example=1&source=chat", + expectedHTML: "LINE album update: Summer <photos>

" + + `Open in LINE: ` + + "line://group/home/albums/album?example=1&source=chat", }, { name: "missing metadata", @@ -60,12 +67,12 @@ func TestConvertPostNotification(t *testing.T) { if err != nil { t.Fatalf("ConvertPostNotification returned error: %v", err) } - assertPostNotificationContent(t, converted, test.expected, relatesTo) + assertPostNotificationContent(t, converted, test.expected, test.expectedHTML, relatesTo) }) } } -func assertPostNotificationContent(t *testing.T, converted *bridgev2.ConvertedMessage, expectedBody string, relatesTo *event.RelatesTo) { +func assertPostNotificationContent(t *testing.T, converted *bridgev2.ConvertedMessage, expectedBody, expectedHTML string, relatesTo *event.RelatesTo) { t.Helper() if converted == nil || len(converted.Parts) != 1 || converted.Parts[0].Content == nil { t.Fatalf("converted = %#v, want one message part", converted) @@ -80,6 +87,18 @@ func assertPostNotificationContent(t *testing.T, converted *bridgev2.ConvertedMe if part.Content.Body != expectedBody { t.Fatalf("body = %q, want %q", part.Content.Body, expectedBody) } + if expectedHTML == "" { + if part.Content.Format != "" || part.Content.FormattedBody != "" { + t.Fatalf("formatted message = %q / %q, want plain text only", part.Content.Format, part.Content.FormattedBody) + } + } else { + if part.Content.Format != event.FormatHTML { + t.Fatalf("format = %q, want %q", part.Content.Format, event.FormatHTML) + } + if part.Content.FormattedBody != expectedHTML { + t.Fatalf("formatted body = %q, want %q", part.Content.FormattedBody, expectedHTML) + } + } if part.Content.RelatesTo != relatesTo { t.Fatalf("relates_to = %#v, want original pointer %#v", part.Content.RelatesTo, relatesTo) }