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..80565d2 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
@@ -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{
diff --git a/pkg/connector/handle_message_test.go b/pkg/connector/handle_message_test.go
index 16c7774..30c12e1 100644
--- a/pkg/connector/handle_message_test.go
+++ b/pkg/connector/handle_message_test.go
@@ -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"
diff --git a/pkg/connector/handlers/post_notification.go b/pkg/connector/handlers/post_notification.go
new file mode 100644
index 0000000..87d1aff
--- /dev/null
+++ b/pkg/connector/handlers/post_notification.go
@@ -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", "
") +
+ `` + escapedURL + ``
+ }
+
+ return &bridgev2.ConvertedMessage{
+ Parts: []*bridgev2.ConvertedMessagePart{
+ {
+ Type: event.EventMessage,
+ Content: content,
+ },
+ },
+ }, 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..f9d2704
--- /dev/null
+++ b/pkg/connector/handlers/post_notification_test.go
@@ -0,0 +1,105 @@
+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
+ expectedHTML 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",
+ 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 escaped name and deep link",
+ metadata: map[string]string{
+ "serviceType": "AB",
+ "albumName": "Summer ",
+ "postEndUrl": "line://group/home/albums/album?example=1&source=chat",
+ },
+ 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",
+ 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, test.expectedHTML, 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)
+ }
+ 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 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)
+ }
+}