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
2 changes: 1 addition & 1 deletion pkg/connector/handle_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ func (lc *LineClient) convertLineMessage(ctx context.Context, portal *bridgev2.P
// a shared post's text fallback was marked as encrypted but could not be
// decrypted.
if isPostNotification(&data) {
return lc.newMessageHandler().ConvertPostNotification(data, replyRelatesTo)
return lc.newMessageHandler().ConvertPostNotification(ctx, portal, intent, data, replyRelatesTo)
}

if decryptionFailed && strings.TrimSpace(unwrappedText) == "" && ContentType(data.ContentType) == ContentText {
Expand Down
3 changes: 1 addition & 2 deletions pkg/connector/handle_message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,7 @@ func TestConvertLineMessageDispatchesSharedPostBeforeTextFallback(t *testing.T)
},
}

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"
expectedBody := "You received a LINE note.\n\nPreview:\nShared note preview"

tests := []struct {
name string
Expand Down
13 changes: 13 additions & 0 deletions pkg/connector/handlers/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,23 @@ type Handler struct {
// NewClient creates a new LINE API client with the current access token.
NewClient func() *line.Client

// DownloadOBSResource overrides non-talk OBS downloads in tests.
DownloadOBSResource func(ctx context.Context, client *line.Client, service, sid, oid string) ([]byte, error)

// DownloadAlbumPreview overrides album thumbnail downloads in tests.
DownloadAlbumPreview func(ctx context.Context, client *line.Client, oid, chatID, albumID string) ([]byte, error)

// DecryptMedia decrypts E2EE encrypted media data using the given key material.
DecryptMedia func(data []byte, keyMaterial string) ([]byte, error)
}

func (h *Handler) downloadAlbumPreview(ctx context.Context, client *line.Client, oid, chatID, albumID string) ([]byte, error) {
if h.DownloadAlbumPreview != nil {
return h.DownloadAlbumPreview(ctx, client, oid, chatID, albumID)
}
return client.DownloadAlbumPreview(ctx, oid, chatID, albumID)
}

func obsTalkMetaMessageID(messageID string, isPlainMedia bool) string {
if isPlainMedia {
return ""
Expand Down
314 changes: 296 additions & 18 deletions pkg/connector/handlers/post_notification.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,48 @@
package handlers

import (
"html"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"

"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"

"github.com/highesttt/matrix-line-messenger/pkg/line"
)

const albumPreviewWorkerLimit = 4

type postPreviewMedia struct {
Service string `json:"svc"`
SID string `json:"sid"`
OID string `json:"mediaOid"`
MediaType string `json:"mediaType"`
}

type albumPreviewContext struct {
ChatID string
AlbumID string
}

// 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) {
// notification into a readable Matrix notice, including album preview images.
func (h *Handler) ConvertPostNotification(
ctx context.Context,
portal *bridgev2.Portal,
intent bridgev2.MatrixAPI,
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 {
Expand All @@ -40,32 +66,284 @@ func (*Handler) ConvertPostNotification(data line.Message, relatesTo *event.Rela
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.")
}

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.

The previous implementation appended Open in LINE: <postEndUrl> (plain + HTML anchor) for every notification, and Open LINE for full details. when the URL was missing. Both branches are gone now, so GB notes, unknown service types, and albums that produce zero image parts (all previews expired/unsupported, or intent/portal missing) drop back to a bare notice with no way to reach the original post. The commit message only mentions adding album images, so this collateral UX change looks worth calling out explicitly. If it is intentional, consider updating the commit message; otherwise, keeping the link for non-AB types (and as a fallback when no image parts get appended) would preserve the previous behavior.

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{
converted := &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{
{
Type: event.EventMessage,
Content: content,
},
},
}
if serviceType != "AB" {
return converted, nil
}

previewMedias, parseErr := parseAlbumPreviewMedias(data.ContentMetadata)
if parseErr != nil {
h.Log.Warn().
Err(parseErr).
Str("msg_id", data.ID).
Msg("Failed to parse LINE album preview media metadata")
}
if len(previewMedias) == 0 {
return converted, nil
}
previewContext := parseAlbumPreviewContext(data.ContentMetadata)
if previewContext.ChatID == "" {
h.Log.Warn().
Str("msg_id", data.ID).
Msg("LINE album preview metadata is missing chatId")
return converted, nil
}
if h.NewClient == nil || intent == nil || portal == nil {
return nil, errors.New("album preview conversion requires LINE and Matrix media clients")
}

client := h.NewClient()
parts, err := h.convertAlbumPreviews(
ctx,
portal,
intent,
client,
data.ID,
previewContext,
previewMedias,
relatesTo,
)
if err != nil {
return nil, err
}
for _, part := range parts {
if part != nil {
converted.Parts = append(converted.Parts, part)
}
}
return converted, nil
}

func (h *Handler) convertAlbumPreviews(
ctx context.Context,
portal *bridgev2.Portal,
intent bridgev2.MatrixAPI,
client *line.Client,
messageID string,
previewContext albumPreviewContext,
previewMedias []postPreviewMedia,
relatesTo *event.RelatesTo,
) ([]*bridgev2.ConvertedMessagePart, error) {
workCtx, cancel := context.WithCancel(ctx)
defer cancel()

jobs := make(chan int, len(previewMedias))
for index := range previewMedias {
jobs <- index
}
close(jobs)

parts := make([]*bridgev2.ConvertedMessagePart, len(previewMedias))
var workers sync.WaitGroup
var errOnce sync.Once
var firstErr error
workerCount := min(albumPreviewWorkerLimit, len(previewMedias))
workers.Add(workerCount)
for range workerCount {
go func() {
defer workers.Done()
for index := range jobs {
if workCtx.Err() != nil {
continue
}
part, err := h.convertAlbumPreview(
workCtx,
portal,
intent,
client,
messageID,
previewContext,
previewMedias[index],
index,
relatesTo,
)
if err != nil {
errOnce.Do(func() {
firstErr = err
cancel()
})
continue
}
parts[index] = part
}
}()
}
workers.Wait()

if firstErr != nil {
return nil, firstErr
}
if err := ctx.Err(); err != nil {
return nil, err
}
return parts, nil
}

func (h *Handler) convertAlbumPreview(
ctx context.Context,
portal *bridgev2.Portal,
intent bridgev2.MatrixAPI,
client *line.Client,
messageID string,
previewContext albumPreviewContext,
media postPreviewMedia,
index int,
relatesTo *event.RelatesTo,
) (*bridgev2.ConvertedMessagePart, error) {
imageData, err := h.downloadAlbumPreview(
ctx,
client,
media.OID,
previewContext.ChatID,
previewContext.AlbumID,
)
if newClient, ok := h.tryRecoverClient(ctx, err); ok {
imageData, err = h.downloadAlbumPreview(
ctx,
newClient,
media.OID,
previewContext.ChatID,
previewContext.AlbumID,
)
}
if errors.Is(err, line.ErrOBSObjectNotFound) {
h.Log.Warn().
Str("msg_id", messageID).
Str("media_oid", media.OID).
Msg("LINE album preview image expired before it could be bridged")
return nil, nil
} else if err != nil {
return nil, fmt.Errorf(
"%w: failed to download LINE album preview %q: %w",
bridgev2.ErrIgnoringRemoteEvent,
media.OID,
err,
)
}

mimeType, extension := albumPreviewImageType(imageData)
if mimeType == "" {
h.Log.Warn().
Str("msg_id", messageID).
Str("media_oid", media.OID).
Msg("Ignoring LINE album preview with unsupported image data")
return nil, nil
}
fileName := fmt.Sprintf("album-image-%d.%s", index+1, extension)
mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imageData, fileName, mimeType)
if err != nil {
return nil, fmt.Errorf("failed to upload LINE album preview to Matrix: %w", err)
}

return &bridgev2.ConvertedMessagePart{
ID: networkid.PartID(fmt.Sprintf("album-image-%d", index+1)),
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgImage,
Body: fileName,
URL: mxc,
File: file,
Info: &event.FileInfo{
MimeType: mimeType,
Size: len(imageData),
},
RelatesTo: relatesTo,
},
}, nil
}

func parseAlbumPreviewContext(metadata map[string]string) albumPreviewContext {
previewContext := albumPreviewContext{
ChatID: strings.TrimSpace(metadata["chatId"]),
}
postEndURL := strings.TrimSpace(metadata["postEndUrl"])
if postEndURL == "" {
return previewContext
}
parsedURL, err := url.Parse(postEndURL)
if err != nil {
return previewContext
}
previewContext.AlbumID = strings.TrimSpace(parsedURL.Query().Get("albumIdV2"))
if previewContext.AlbumID == "" {
previewContext.AlbumID = strings.TrimSpace(parsedURL.Query().Get("albumId"))
}
return previewContext
}

func parseAlbumPreviewMedias(metadata map[string]string) ([]postPreviewMedia, error) {
if metadata == nil {
return nil, nil
}

var parsed []postPreviewMedia
var parseErr error
if raw := strings.TrimSpace(metadata["previewMedias"]); raw != "" {
parseErr = json.Unmarshal([]byte(raw), &parsed)
if parseErr != nil {
parsed = nil
}
}

seen := make(map[string]struct{}, len(parsed))
medias := make([]postPreviewMedia, 0, len(parsed))
for _, media := range parsed {
media.Service = strings.ToLower(strings.TrimSpace(media.Service))
media.SID = strings.ToLower(strings.TrimSpace(media.SID))
media.OID = strings.TrimSpace(media.OID)
media.MediaType = strings.ToUpper(strings.TrimSpace(media.MediaType))
if media.Service != "album" || media.SID != "a" || media.OID == "" || media.MediaType != "I" {
continue
}
if _, duplicate := seen[media.OID]; duplicate {
continue
}
seen[media.OID] = struct{}{}
medias = append(medias, media)
}

// LINE duplicates the first preview in the top-level metadata. Only use it
// when previewMedias was absent, malformed, or had no supported images.
if len(medias) == 0 {
oid := strings.TrimSpace(metadata["mediaOid"])
mediaType := strings.ToUpper(strings.TrimSpace(metadata["mediaType"]))
if oid != "" && mediaType == "I" {
medias = append(medias, postPreviewMedia{
Service: "album",
SID: "a",
OID: oid,
MediaType: mediaType,
})
}
}
return medias, parseErr
}

func albumPreviewImageType(data []byte) (mimeType, extension string) {
switch http.DetectContentType(data) {
case "image/jpeg":
return "image/jpeg", "jpg"
case "image/png":
return "image/png", "png"
case "image/gif":
return "image/gif", "gif"
case "image/webp":
return "image/webp", "webp"
default:
return "", ""
}
}
Loading
Loading