Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions pkg/connector/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 27 additions & 2 deletions pkg/connector/handle_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant nil guard.

isPostNotification is only called from (1) isBridgeableContentType, which already returns early on msg == nil, and (2) convertLineMessage, which always passes &data (never nil). The guard is only exercised by the "nil message" table entry in handle_message_test.go. Safe to remove for symmetry with queueIncomingMessage's existing invariants, or keep as defensive — either way, minor.

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
Expand Down Expand Up @@ -355,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{
Expand Down
141 changes: 141 additions & 0 deletions pkg/connector/handle_message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,147 @@ 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)},
},
}

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"

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)
}
})
}
}

func TestConvertLineMessageRendersPlaintextCustomEmoji(t *testing.T) {
const (
emtver4Token = "\U00100101\U00100211yoo-hoo\U0010ffff"
Expand Down
71 changes: 71 additions & 0 deletions pkg/connector/handlers/post_notification.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package handlers

import (
"html"
"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.")
}

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", "<br>") +
`<a href="` + escapedURL + `">` + escapedURL + `</a>`
}

return &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{
{
Type: event.EventMessage,
Content: content,
},
},
}, nil
}
Loading
Loading