From 43142323d0fc3c752173b9c0f7f70f00487531c0 Mon Sep 17 00:00:00 2001 From: highesttt Date: Mon, 27 Jul 2026 16:44:49 -0400 Subject: [PATCH 1/2] feat: show images in album notifications --- pkg/connector/handle_message.go | 2 +- pkg/connector/handle_message_test.go | 3 +- pkg/connector/handlers/handler.go | 10 + pkg/connector/handlers/post_notification.go | 176 ++++++++++-- .../handlers/post_notification_test.go | 255 +++++++++++++++--- pkg/line/client.go | 26 +- pkg/line/obs_test.go | 44 +++ 7 files changed, 455 insertions(+), 61 deletions(-) diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index 80565d2..685a050 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -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 { diff --git a/pkg/connector/handle_message_test.go b/pkg/connector/handle_message_test.go index 30c12e1..7f0c3e9 100644 --- a/pkg/connector/handle_message_test.go +++ b/pkg/connector/handle_message_test.go @@ -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 diff --git a/pkg/connector/handlers/handler.go b/pkg/connector/handlers/handler.go index 38b4bad..a3ea031 100644 --- a/pkg/connector/handlers/handler.go +++ b/pkg/connector/handlers/handler.go @@ -29,10 +29,20 @@ 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) + // DecryptMedia decrypts E2EE encrypted media data using the given key material. DecryptMedia func(data []byte, keyMaterial string) ([]byte, error) } +func (h *Handler) downloadOBSResource(ctx context.Context, client *line.Client, service, sid, oid string) ([]byte, error) { + if h.DownloadOBSResource != nil { + return h.DownloadOBSResource(ctx, client, service, sid, oid) + } + return client.DownloadOBSResource(ctx, service, sid, oid, "") +} + func obsTalkMetaMessageID(messageID string, isPlainMedia bool) string { if isPlainMedia { return "" diff --git a/pkg/connector/handlers/post_notification.go b/pkg/connector/handlers/post_notification.go index 87d1aff..1e5a385 100644 --- a/pkg/connector/handlers/post_notification.go +++ b/pkg/connector/handlers/post_notification.go @@ -1,22 +1,39 @@ package handlers import ( - "html" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" "strings" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "github.com/highesttt/matrix-line-messenger/pkg/line" ) +type postPreviewMedia struct { + Service string `json:"svc"` + SID string `json:"sid"` + OID string `json:"mediaOid"` + MediaType string `json:"mediaType"` +} + // 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 { @@ -40,32 +57,153 @@ 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.") - } 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{ + converted := &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ { Type: event.EventMessage, Content: content, }, }, - }, nil + } + 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 + } + if h.NewClient == nil || intent == nil || portal == nil { + return nil, errors.New("album preview conversion requires LINE and Matrix media clients") + } + + client := h.NewClient() + for index, media := range previewMedias { + imageData, err := h.downloadOBSResource(ctx, client, media.Service, media.SID, media.OID) + if newClient, ok := h.tryRecoverClient(ctx, err); ok { + client = newClient + imageData, err = h.downloadOBSResource(ctx, client, media.Service, media.SID, media.OID) + } + if errors.Is(err, line.ErrOBSObjectNotFound) { + h.Log.Warn(). + Str("msg_id", data.ID). + Str("media_oid", media.OID). + Msg("LINE album preview image expired before it could be bridged") + continue + } 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", data.ID). + Str("media_oid", media.OID). + Msg("Ignoring LINE album preview with unsupported image data") + continue + } + 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) + } + + converted.Parts = append(converted.Parts, &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, + }, + }) + } + return converted, nil +} + +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 "", "" + } } diff --git a/pkg/connector/handlers/post_notification_test.go b/pkg/connector/handlers/post_notification_test.go index f9d2704..a51c3a3 100644 --- a/pkg/connector/handlers/post_notification_test.go +++ b/pkg/connector/handlers/post_notification_test.go @@ -1,52 +1,60 @@ package handlers import ( + "context" + "errors" + "fmt" + "strings" "testing" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" "github.com/highesttt/matrix-line-messenger/pkg/line" ) +type postNotificationTestMatrix struct { + bridgev2.MatrixAPI + uploads [][]byte +} + +func (m *postNotificationTestMatrix) UploadMedia(_ context.Context, _ id.RoomID, data []byte, _, _ string) (id.ContentURIString, *event.EncryptedFileInfo, error) { + m.uploads = append(m.uploads, append([]byte(nil), data...)) + return id.ContentURIString(fmt.Sprintf("mxc://example/album-%d", len(m.uploads))), nil, nil +} + func TestConvertPostNotification(t *testing.T) { relatesTo := &event.RelatesTo{} tests := []struct { - name string - metadata map[string]string - expected string - expectedHTML string + name string + metadata map[string]string + expected string }{ { - name: "note with multiline preview and link", + name: "note with multiline preview ignores 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", + expected: "You received a LINE note.\n\nPreview:\nFirst line\nSecond line", }, { - name: "album with escaped name and deep link", + name: "album with name ignores deep link", metadata: map[string]string{ "serviceType": "AB", - "albumName": "Summer ", + "albumName": "Summer photos", "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", + expected: "LINE album update: Summer photos", }, { name: "missing metadata", metadata: nil, - expected: "You received a LINE post notification.\n\nOpen LINE for full details.", + expected: "You received a LINE post notification.", }, { name: "unknown service uses available preview", @@ -54,25 +62,209 @@ func TestConvertPostNotification(t *testing.T) { "serviceType": "OTHER", "text": "Post preview", }, - expected: "You received a LINE post notification.\n\nPreview:\nPost preview\n\n" + - "Open LINE for full details.", + expected: "You received a LINE post notification.\n\nPreview:\nPost preview", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - converted, err := (&Handler{}).ConvertPostNotification(line.Message{ - ContentMetadata: test.metadata, - }, relatesTo) + converted, err := (&Handler{}).ConvertPostNotification( + t.Context(), + nil, + nil, + line.Message{ContentMetadata: test.metadata}, + relatesTo, + ) if err != nil { t.Fatalf("ConvertPostNotification returned error: %v", err) } - assertPostNotificationContent(t, converted, test.expected, test.expectedHTML, relatesTo) + assertPostNotificationContent(t, converted, test.expected, relatesTo) }) } } -func assertPostNotificationContent(t *testing.T, converted *bridgev2.ConvertedMessage, expectedBody, expectedHTML string, relatesTo *event.RelatesTo) { +func TestConvertPostNotificationUploadsAlbumPreviewImages(t *testing.T) { + relatesTo := &event.RelatesTo{} + matrix := &postNotificationTestMatrix{} + var downloads []string + handler := &Handler{ + NewClient: func() *line.Client { + return line.NewClient("token") + }, + DownloadOBSResource: func(_ context.Context, _ *line.Client, service, sid, oid string) ([]byte, error) { + downloads = append(downloads, service+"/"+sid+"/"+oid) + return []byte{0xff, 0xd8, 0xff, byte(len(downloads))}, nil + }, + } + converted, err := handler.ConvertPostNotification( + t.Context(), + &bridgev2.Portal{Portal: &database.Portal{MXID: id.RoomID("!room:example.com")}}, + matrix, + line.Message{ + ID: "message-id", + ContentMetadata: map[string]string{ + "serviceType": "AB", + "albumName": "Summer photos", + "previewMedias": `[ + {"svc":"album","sid":"a","mediaOid":"oid-one","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-two","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-one","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"video-oid","mediaType":"V"} + ]`, + "mediaOid": "oid-one", + "mediaType": "I", + }, + }, + relatesTo, + ) + if err != nil { + t.Fatalf("ConvertPostNotification returned error: %v", err) + } + if got := strings.Join(downloads, ","); got != "album/a/oid-one,album/a/oid-two" { + t.Fatalf("downloads = %q", got) + } + if len(converted.Parts) != 3 { + t.Fatalf("parts = %d, want notice plus two images", len(converted.Parts)) + } + assertPostNotificationContent(t, &bridgev2.ConvertedMessage{Parts: converted.Parts[:1]}, "LINE album update: Summer photos", relatesTo) + for index, part := range converted.Parts[1:] { + wantNumber := index + 1 + if part.ID != networkid.PartID(fmt.Sprintf("album-image-%d", wantNumber)) { + t.Fatalf("image %d part ID = %q", wantNumber, part.ID) + } + if part.Type != event.EventMessage || part.Content.MsgType != event.MsgImage { + t.Fatalf("image %d content = %#v", wantNumber, part.Content) + } + if part.Content.Body != fmt.Sprintf("album-image-%d.jpg", wantNumber) { + t.Fatalf("image %d body = %q", wantNumber, part.Content.Body) + } + if part.Content.URL != id.ContentURIString(fmt.Sprintf("mxc://example/album-%d", wantNumber)) { + t.Fatalf("image %d URL = %q", wantNumber, part.Content.URL) + } + if part.Content.Info == nil || part.Content.Info.MimeType != "image/jpeg" || part.Content.Info.Size != 4 { + t.Fatalf("image %d info = %#v", wantNumber, part.Content.Info) + } + if part.Content.RelatesTo != relatesTo { + t.Fatalf("image %d relates_to = %#v", wantNumber, part.Content.RelatesTo) + } + } + if len(matrix.uploads) != 2 { + t.Fatalf("uploads = %d, want 2", len(matrix.uploads)) + } +} + +func TestConvertPostNotificationUsesTopLevelAlbumMediaFallback(t *testing.T) { + matrix := &postNotificationTestMatrix{} + var downloadedOID string + handler := &Handler{ + NewClient: func() *line.Client { + return line.NewClient("token") + }, + DownloadOBSResource: func(_ context.Context, _ *line.Client, _, _, oid string) ([]byte, error) { + downloadedOID = oid + return []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, nil + }, + } + converted, err := handler.ConvertPostNotification( + t.Context(), + &bridgev2.Portal{Portal: &database.Portal{MXID: id.RoomID("!room:example.com")}}, + matrix, + line.Message{ContentMetadata: map[string]string{ + "serviceType": "AB", + "previewMedias": `{broken`, + "mediaOid": "fallback-oid", + "mediaType": "I", + }}, + nil, + ) + if err != nil { + t.Fatalf("ConvertPostNotification returned error: %v", err) + } + if downloadedOID != "fallback-oid" { + t.Fatalf("downloaded OID = %q, want fallback-oid", downloadedOID) + } + if len(converted.Parts) != 2 || converted.Parts[1].Content.Info.MimeType != "image/png" { + t.Fatalf("converted = %#v, want notice and PNG fallback", converted) + } +} + +func TestConvertPostNotificationKeepsSuccessfulAlbumImagesWhenOneExpired(t *testing.T) { + matrix := &postNotificationTestMatrix{} + handler := &Handler{ + NewClient: func() *line.Client { + return line.NewClient("token") + }, + IsLoggedOut: func(error) bool { + return false + }, + ShouldRecover: func(context.Context, error) bool { + return false + }, + DownloadOBSResource: func(_ context.Context, _ *line.Client, _, _, oid string) ([]byte, error) { + if oid == "expired-oid" { + return nil, line.ErrOBSObjectNotFound + } + return []byte{'G', 'I', 'F', '8', '9', 'a'}, nil + }, + } + converted, err := handler.ConvertPostNotification( + t.Context(), + &bridgev2.Portal{Portal: &database.Portal{MXID: id.RoomID("!room:example.com")}}, + matrix, + line.Message{ContentMetadata: map[string]string{ + "serviceType": "AB", + "previewMedias": `[ + {"svc":"album","sid":"a","mediaOid":"expired-oid","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"available-oid","mediaType":"I"} + ]`, + }}, + nil, + ) + if err != nil { + t.Fatalf("ConvertPostNotification returned error: %v", err) + } + if len(converted.Parts) != 2 { + t.Fatalf("parts = %d, want notice plus available image", len(converted.Parts)) + } + if converted.Parts[1].ID != "album-image-2" || converted.Parts[1].Content.Info.MimeType != "image/gif" { + t.Fatalf("available image part = %#v", converted.Parts[1]) + } +} + +func TestConvertPostNotificationLeavesTransientAlbumFailureRetryable(t *testing.T) { + handler := &Handler{ + NewClient: func() *line.Client { + return line.NewClient("token") + }, + IsLoggedOut: func(error) bool { + return false + }, + ShouldRecover: func(context.Context, error) bool { + return false + }, + DownloadOBSResource: func(context.Context, *line.Client, string, string, string) ([]byte, error) { + return nil, line.ErrOBSEncodingIncomplete + }, + } + converted, err := handler.ConvertPostNotification( + t.Context(), + &bridgev2.Portal{Portal: &database.Portal{MXID: id.RoomID("!room:example.com")}}, + &postNotificationTestMatrix{}, + line.Message{ContentMetadata: map[string]string{ + "serviceType": "AB", + "previewMedias": `[{"svc":"album","sid":"a","mediaOid":"pending-oid","mediaType":"I"}]`, + }}, + nil, + ) + if converted != nil { + t.Fatalf("converted = %#v, want nil for retryable failure", converted) + } + if !errors.Is(err, line.ErrOBSEncodingIncomplete) || !errors.Is(err, bridgev2.ErrIgnoringRemoteEvent) { + t.Fatalf("err = %v, want encoding and ignoring sentinels", err) + } +} + +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) @@ -87,17 +279,8 @@ 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.Format != "" || part.Content.FormattedBody != "" { + t.Fatalf("formatted message = %q / %q, want plain text only", part.Content.Format, part.Content.FormattedBody) } if part.Content.RelatesTo != relatesTo { t.Fatalf("relates_to = %#v, want original pointer %#v", part.Content.RelatesTo, relatesTo) diff --git a/pkg/line/client.go b/pkg/line/client.go index 551752d..ed4744a 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -702,9 +702,29 @@ func (c *Client) DownloadOBSWithSID(ctx context.Context, oid string, messageID s } func (c *Client) DownloadOBSWithSIDOptions(ctx context.Context, oid string, messageID string, sid string, opts OBSDownloadOptions) ([]byte, error) { - // URL structure: https://obs.line-apps.com/r/talk/{SID}/{OID} - // SID: emi (images), emv (videos), ema (audio), emf (files) - obsURL := fmt.Sprintf("%s/r/talk/%s/%s", OBSBaseURL, sid, oid) + return c.downloadOBSWithServiceAndSIDOptions(ctx, "talk", sid, oid, messageID, opts) +} + +// DownloadOBSResource retrieves a non-talk resource using the service, SID, +// and OID supplied by LINE metadata. Album post previews use service "album" +// and SID "a". +func (c *Client) DownloadOBSResource(ctx context.Context, service, sid, oid, messageID string) ([]byte, error) { + return c.downloadOBSWithServiceAndSIDOptions(ctx, service, sid, oid, messageID, OBSDownloadOptions{}) +} + +func (c *Client) downloadOBSWithServiceAndSIDOptions(ctx context.Context, service, sid, oid, messageID string, opts OBSDownloadOptions) ([]byte, error) { + if service == "" || sid == "" || oid == "" { + return nil, errors.New("OBS service, SID, and OID are required") + } + + // URL structure: https://obs.line-apps.com/r/{service}/{SID}/{OID} + obsURL := fmt.Sprintf( + "%s/r/%s/%s/%s", + OBSBaseURL, + url.PathEscape(service), + url.PathEscape(sid), + url.PathEscape(oid), + ) objectInfoURL := obsURL if opts.TID != "" { obsURL += "/" + url.PathEscape(opts.TID) diff --git a/pkg/line/obs_test.go b/pkg/line/obs_test.go index 20485ba..7353d81 100644 --- a/pkg/line/obs_test.go +++ b/pkg/line/obs_test.go @@ -88,6 +88,50 @@ func TestDownloadOBSPlainMatchesChromeRequestFlow(t *testing.T) { } } +func TestDownloadOBSResourceUsesReceiveServiceAndSID(t *testing.T) { + installCachedOBSToken(t) + + var requests []observedOBSRequest + client := NewClient("line-token") + client.OBSClient = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requests = append(requests, observedOBSRequest{ + path: req.URL.Path, + headers: req.Header.Clone(), + }) + if len(requests) == 1 { + return obsResponse(http.StatusOK, `{"status":"exist","encodeStatus":"done"}`), nil + } + return obsResponse(http.StatusOK, "album-image"), nil + }), + } + + data, err := client.DownloadOBSResource(context.Background(), "album", "a", "preview-oid", "") + if err != nil { + t.Fatal(err) + } + if string(data) != "album-image" { + t.Fatalf("data = %q, want album-image", data) + } + if len(requests) != 2 { + t.Fatalf("requests = %d, want object-info preflight plus download", len(requests)) + } + if requests[0].path != "/r/album/a/preview-oid/object_info.obs" || requests[1].path != "/r/album/a/preview-oid" { + t.Fatalf("request paths = %q / %q", requests[0].path, requests[1].path) + } + for i, req := range requests { + if req.headers.Get("X-Line-Access") != "obs-token" { + t.Fatalf("request %d X-Line-Access = %q", i, req.headers.Get("X-Line-Access")) + } + if req.headers.Get("X-Line-Application") != lineApplicationHeader { + t.Fatalf("request %d X-Line-Application = %q, want %q", i, req.headers.Get("X-Line-Application"), lineApplicationHeader) + } + if req.headers.Get("X-Talk-Meta") != "" { + t.Fatalf("album request %d unexpectedly sent X-Talk-Meta", i) + } + } +} + func TestDownloadOBSPreflightsBaseObjectBeforeTID(t *testing.T) { installCachedOBSToken(t) From c6f1c77fffcfddc81d31f83ae72de9c0b8dceb1e Mon Sep 17 00:00:00 2001 From: highesttt Date: Mon, 27 Jul 2026 18:27:22 -0400 Subject: [PATCH 2/2] fix: authenticate LINE album preview downloads --- pkg/connector/handlers/handler.go | 11 +- pkg/connector/handlers/post_notification.go | 234 +++++++++++--- .../handlers/post_notification_test.go | 296 +++++++++++++++++- pkg/line/client.go | 64 ++++ pkg/line/methods.go | 90 ++++++ pkg/line/obs_test.go | 135 ++++++++ 6 files changed, 767 insertions(+), 63 deletions(-) diff --git a/pkg/connector/handlers/handler.go b/pkg/connector/handlers/handler.go index a3ea031..3a2b1cd 100644 --- a/pkg/connector/handlers/handler.go +++ b/pkg/connector/handlers/handler.go @@ -32,15 +32,18 @@ type Handler struct { // 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) downloadOBSResource(ctx context.Context, client *line.Client, service, sid, oid string) ([]byte, error) { - if h.DownloadOBSResource != nil { - return h.DownloadOBSResource(ctx, client, service, sid, oid) +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.DownloadOBSResource(ctx, service, sid, oid, "") + return client.DownloadAlbumPreview(ctx, oid, chatID, albumID) } func obsTalkMetaMessageID(messageID string, isPlainMedia bool) string { diff --git a/pkg/connector/handlers/post_notification.go b/pkg/connector/handlers/post_notification.go index 1e5a385..fa80627 100644 --- a/pkg/connector/handlers/post_notification.go +++ b/pkg/connector/handlers/post_notification.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" + "sync" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" @@ -15,6 +17,8 @@ import ( "github.com/highesttt/matrix-line-messenger/pkg/line" ) +const albumPreviewWorkerLimit = 4 + type postPreviewMedia struct { Service string `json:"svc"` SID string `json:"sid"` @@ -22,6 +26,11 @@ type postPreviewMedia struct { 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, including album preview images. func (h *Handler) ConvertPostNotification( @@ -86,63 +95,194 @@ func (h *Handler) ConvertPostNotification( 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() - for index, media := range previewMedias { - imageData, err := h.downloadOBSResource(ctx, client, media.Service, media.SID, media.OID) - if newClient, ok := h.tryRecoverClient(ctx, err); ok { - client = newClient - imageData, err = h.downloadOBSResource(ctx, client, media.Service, media.SID, media.OID) - } - if errors.Is(err, line.ErrOBSObjectNotFound) { - h.Log.Warn(). - Str("msg_id", data.ID). - Str("media_oid", media.OID). - Msg("LINE album preview image expired before it could be bridged") - continue - } else if err != nil { - return nil, fmt.Errorf( - "%w: failed to download LINE album preview %q: %w", - bridgev2.ErrIgnoringRemoteEvent, - media.OID, - err, - ) + 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 +} - mimeType, extension := albumPreviewImageType(imageData) - if mimeType == "" { - h.Log.Warn(). - Str("msg_id", data.ID). - Str("media_oid", media.OID). - Msg("Ignoring LINE album preview with unsupported image data") - continue - } - 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) - } +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) - converted.Parts = append(converted.Parts, &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, + 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"]), } - return converted, nil + 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) { diff --git a/pkg/connector/handlers/post_notification_test.go b/pkg/connector/handlers/post_notification_test.go index a51c3a3..ffe33fb 100644 --- a/pkg/connector/handlers/post_notification_test.go +++ b/pkg/connector/handlers/post_notification_test.go @@ -4,8 +4,12 @@ import ( "context" "errors" "fmt" + "slices" "strings" + "sync" + "sync/atomic" "testing" + "time" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" @@ -18,12 +22,21 @@ import ( type postNotificationTestMatrix struct { bridgev2.MatrixAPI + mu sync.Mutex uploads [][]byte } -func (m *postNotificationTestMatrix) UploadMedia(_ context.Context, _ id.RoomID, data []byte, _, _ string) (id.ContentURIString, *event.EncryptedFileInfo, error) { +func (m *postNotificationTestMatrix) UploadMedia(_ context.Context, _ id.RoomID, data []byte, fileName, _ string) (id.ContentURIString, *event.EncryptedFileInfo, error) { + m.mu.Lock() m.uploads = append(m.uploads, append([]byte(nil), data...)) - return id.ContentURIString(fmt.Sprintf("mxc://example/album-%d", len(m.uploads))), nil, nil + m.mu.Unlock() + return id.ContentURIString("mxc://example/" + fileName), nil, nil +} + +func (m *postNotificationTestMatrix) uploadCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.uploads) } func TestConvertPostNotification(t *testing.T) { @@ -87,13 +100,17 @@ func TestConvertPostNotificationUploadsAlbumPreviewImages(t *testing.T) { relatesTo := &event.RelatesTo{} matrix := &postNotificationTestMatrix{} var downloads []string + var downloadsMu sync.Mutex handler := &Handler{ NewClient: func() *line.Client { return line.NewClient("token") }, - DownloadOBSResource: func(_ context.Context, _ *line.Client, service, sid, oid string) ([]byte, error) { - downloads = append(downloads, service+"/"+sid+"/"+oid) - return []byte{0xff, 0xd8, 0xff, byte(len(downloads))}, nil + DownloadAlbumPreview: func(_ context.Context, _ *line.Client, oid, chatID, albumID string) ([]byte, error) { + downloadsMu.Lock() + downloads = append(downloads, chatID+"/"+albumID+"/"+oid) + downloadNumber := len(downloads) + downloadsMu.Unlock() + return []byte{0xff, 0xd8, 0xff, byte(downloadNumber)}, nil }, } converted, err := handler.ConvertPostNotification( @@ -105,6 +122,8 @@ func TestConvertPostNotificationUploadsAlbumPreviewImages(t *testing.T) { ContentMetadata: map[string]string{ "serviceType": "AB", "albumName": "Summer photos", + "chatId": "chat-id", + "postEndUrl": "line://group/home/albums/album?albumId=legacy-id&albumIdV2=album-id-v2", "previewMedias": `[ {"svc":"album","sid":"a","mediaOid":"oid-one","mediaType":"I"}, {"svc":"album","sid":"a","mediaOid":"oid-two","mediaType":"I"}, @@ -120,9 +139,13 @@ func TestConvertPostNotificationUploadsAlbumPreviewImages(t *testing.T) { if err != nil { t.Fatalf("ConvertPostNotification returned error: %v", err) } - if got := strings.Join(downloads, ","); got != "album/a/oid-one,album/a/oid-two" { + downloadsMu.Lock() + slices.Sort(downloads) + if got := strings.Join(downloads, ","); got != "chat-id/album-id-v2/oid-one,chat-id/album-id-v2/oid-two" { + downloadsMu.Unlock() t.Fatalf("downloads = %q", got) } + downloadsMu.Unlock() if len(converted.Parts) != 3 { t.Fatalf("parts = %d, want notice plus two images", len(converted.Parts)) } @@ -138,7 +161,7 @@ func TestConvertPostNotificationUploadsAlbumPreviewImages(t *testing.T) { if part.Content.Body != fmt.Sprintf("album-image-%d.jpg", wantNumber) { t.Fatalf("image %d body = %q", wantNumber, part.Content.Body) } - if part.Content.URL != id.ContentURIString(fmt.Sprintf("mxc://example/album-%d", wantNumber)) { + if part.Content.URL != id.ContentURIString(fmt.Sprintf("mxc://example/album-image-%d.jpg", wantNumber)) { t.Fatalf("image %d URL = %q", wantNumber, part.Content.URL) } if part.Content.Info == nil || part.Content.Info.MimeType != "image/jpeg" || part.Content.Info.Size != 4 { @@ -148,8 +171,187 @@ func TestConvertPostNotificationUploadsAlbumPreviewImages(t *testing.T) { t.Fatalf("image %d relates_to = %#v", wantNumber, part.Content.RelatesTo) } } - if len(matrix.uploads) != 2 { - t.Fatalf("uploads = %d, want 2", len(matrix.uploads)) + if got := matrix.uploadCount(); got != 2 { + t.Fatalf("uploads = %d, want 2", got) + } +} + +func TestConvertPostNotificationProcessesAlbumPreviewsConcurrentlyInOrder(t *testing.T) { + const previewCount = 6 + + matrix := &postNotificationTestMatrix{} + started := make(chan string, previewCount) + release := make(map[string]chan struct{}, previewCount) + var active atomic.Int32 + var peak atomic.Int32 + for index := 1; index <= previewCount; index++ { + release[fmt.Sprintf("oid-%d", index)] = make(chan struct{}) + } + + handler := &Handler{ + NewClient: func() *line.Client { + return line.NewClient("token") + }, + DownloadAlbumPreview: func(ctx context.Context, _ *line.Client, oid, _, _ string) ([]byte, error) { + current := active.Add(1) + defer active.Add(-1) + for { + previousPeak := peak.Load() + if current <= previousPeak || peak.CompareAndSwap(previousPeak, current) { + break + } + } + started <- oid + select { + case <-release[oid]: + return []byte{0xff, 0xd8, 0xff, byte(len(oid))}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + } + + result := make(chan *bridgev2.ConvertedMessage, 1) + errs := make(chan error, 1) + go func() { + converted, err := handler.ConvertPostNotification( + t.Context(), + &bridgev2.Portal{Portal: &database.Portal{MXID: id.RoomID("!room:example.com")}}, + matrix, + line.Message{ContentMetadata: map[string]string{ + "serviceType": "AB", + "chatId": "chat-id", + "previewMedias": `[ + {"svc":"album","sid":"a","mediaOid":"oid-1","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-2","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-3","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-4","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-5","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-6","mediaType":"I"} + ]`, + }}, + nil, + ) + result <- converted + errs <- err + }() + + initialStarted := make(map[string]struct{}, albumPreviewWorkerLimit) + for range albumPreviewWorkerLimit { + select { + case oid := <-started: + initialStarted[oid] = struct{}{} + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for concurrent album preview workers") + } + } + if len(initialStarted) != albumPreviewWorkerLimit { + t.Fatalf("initial workers started %d unique previews, want %d", len(initialStarted), albumPreviewWorkerLimit) + } + select { + case oid := <-started: + t.Fatalf("preview %q started before a worker slot was released", oid) + case <-time.After(100 * time.Millisecond): + } + + close(release["oid-4"]) + select { + case oid := <-started: + if oid != "oid-5" { + t.Fatalf("next preview = %q, want oid-5", oid) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for fifth album preview") + } + close(release["oid-3"]) + select { + case oid := <-started: + if oid != "oid-6" { + t.Fatalf("next preview = %q, want oid-6", oid) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for sixth album preview") + } + close(release["oid-1"]) + close(release["oid-2"]) + close(release["oid-5"]) + close(release["oid-6"]) + + var converted *bridgev2.ConvertedMessage + select { + case converted = <-result: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for album conversion") + } + if err := <-errs; err != nil { + t.Fatalf("ConvertPostNotification returned error: %v", err) + } + if got := peak.Load(); got != albumPreviewWorkerLimit { + t.Fatalf("peak concurrent preview jobs = %d, want %d", got, albumPreviewWorkerLimit) + } + if len(converted.Parts) != previewCount+1 { + t.Fatalf("parts = %d, want notice plus %d images", len(converted.Parts), previewCount) + } + for index, part := range converted.Parts[1:] { + wantNumber := index + 1 + if part.ID != networkid.PartID(fmt.Sprintf("album-image-%d", wantNumber)) { + t.Fatalf("part %d ID = %q", wantNumber, part.ID) + } + wantURL := id.ContentURIString(fmt.Sprintf("mxc://example/album-image-%d.jpg", wantNumber)) + if part.Content.URL != wantURL { + t.Fatalf("part %d URL = %q, want %q", wantNumber, part.Content.URL, wantURL) + } + } +} + +func TestConvertPostNotificationCancelsQueuedAlbumPreviewsAfterFailure(t *testing.T) { + var calls atomic.Int32 + handler := &Handler{ + NewClient: func() *line.Client { + return line.NewClient("token") + }, + IsLoggedOut: func(error) bool { + return false + }, + ShouldRecover: func(context.Context, error) bool { + return false + }, + DownloadAlbumPreview: func(ctx context.Context, _ *line.Client, oid, _, _ string) ([]byte, error) { + calls.Add(1) + if oid == "fatal-oid" { + return nil, line.ErrOBSEncodingIncomplete + } + <-ctx.Done() + return nil, ctx.Err() + }, + } + + converted, err := handler.ConvertPostNotification( + t.Context(), + &bridgev2.Portal{Portal: &database.Portal{MXID: id.RoomID("!room:example.com")}}, + &postNotificationTestMatrix{}, + line.Message{ContentMetadata: map[string]string{ + "serviceType": "AB", + "chatId": "chat-id", + "previewMedias": `[ + {"svc":"album","sid":"a","mediaOid":"fatal-oid","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-2","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-3","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-4","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-5","mediaType":"I"}, + {"svc":"album","sid":"a","mediaOid":"oid-6","mediaType":"I"} + ]`, + }}, + nil, + ) + if converted != nil { + t.Fatalf("converted = %#v, want nil after fatal preview failure", converted) + } + if !errors.Is(err, line.ErrOBSEncodingIncomplete) || !errors.Is(err, bridgev2.ErrIgnoringRemoteEvent) { + t.Fatalf("err = %v, want encoding and ignoring sentinels", err) + } + if got := calls.Load(); got > albumPreviewWorkerLimit { + t.Fatalf("download calls = %d, want at most %d after cancellation", got, albumPreviewWorkerLimit) } } @@ -160,7 +362,7 @@ func TestConvertPostNotificationUsesTopLevelAlbumMediaFallback(t *testing.T) { NewClient: func() *line.Client { return line.NewClient("token") }, - DownloadOBSResource: func(_ context.Context, _ *line.Client, _, _, oid string) ([]byte, error) { + DownloadAlbumPreview: func(_ context.Context, _ *line.Client, oid, _, _ string) ([]byte, error) { downloadedOID = oid return []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, nil }, @@ -171,6 +373,7 @@ func TestConvertPostNotificationUsesTopLevelAlbumMediaFallback(t *testing.T) { matrix, line.Message{ContentMetadata: map[string]string{ "serviceType": "AB", + "chatId": "chat-id", "previewMedias": `{broken`, "mediaOid": "fallback-oid", "mediaType": "I", @@ -200,7 +403,7 @@ func TestConvertPostNotificationKeepsSuccessfulAlbumImagesWhenOneExpired(t *test ShouldRecover: func(context.Context, error) bool { return false }, - DownloadOBSResource: func(_ context.Context, _ *line.Client, _, _, oid string) ([]byte, error) { + DownloadAlbumPreview: func(_ context.Context, _ *line.Client, oid, _, _ string) ([]byte, error) { if oid == "expired-oid" { return nil, line.ErrOBSObjectNotFound } @@ -213,6 +416,7 @@ func TestConvertPostNotificationKeepsSuccessfulAlbumImagesWhenOneExpired(t *test matrix, line.Message{ContentMetadata: map[string]string{ "serviceType": "AB", + "chatId": "chat-id", "previewMedias": `[ {"svc":"album","sid":"a","mediaOid":"expired-oid","mediaType":"I"}, {"svc":"album","sid":"a","mediaOid":"available-oid","mediaType":"I"} @@ -242,7 +446,7 @@ func TestConvertPostNotificationLeavesTransientAlbumFailureRetryable(t *testing. ShouldRecover: func(context.Context, error) bool { return false }, - DownloadOBSResource: func(context.Context, *line.Client, string, string, string) ([]byte, error) { + DownloadAlbumPreview: func(context.Context, *line.Client, string, string, string) ([]byte, error) { return nil, line.ErrOBSEncodingIncomplete }, } @@ -252,6 +456,7 @@ func TestConvertPostNotificationLeavesTransientAlbumFailureRetryable(t *testing. &postNotificationTestMatrix{}, line.Message{ContentMetadata: map[string]string{ "serviceType": "AB", + "chatId": "chat-id", "previewMedias": `[{"svc":"album","sid":"a","mediaOid":"pending-oid","mediaType":"I"}]`, }}, nil, @@ -264,6 +469,73 @@ func TestConvertPostNotificationLeavesTransientAlbumFailureRetryable(t *testing. } } +func TestConvertPostNotificationKeepsNoticeWhenAlbumChatIDIsMissing(t *testing.T) { + var downloads atomic.Int32 + handler := &Handler{ + DownloadAlbumPreview: func(context.Context, *line.Client, string, string, string) ([]byte, error) { + downloads.Add(1) + return nil, errors.New("unexpected download") + }, + } + + converted, err := handler.ConvertPostNotification( + t.Context(), + nil, + nil, + line.Message{ContentMetadata: map[string]string{ + "serviceType": "AB", + "albumName": "Missing metadata", + "previewMedias": `[{"svc":"album","sid":"a","mediaOid":"preview-oid","mediaType":"I"}]`, + }}, + nil, + ) + if err != nil { + t.Fatalf("ConvertPostNotification returned error: %v", err) + } + assertPostNotificationContent(t, converted, "LINE album update: Missing metadata", nil) + if downloads.Load() != 0 { + t.Fatalf("downloads = %d, want zero without chatId", downloads.Load()) + } +} + +func TestParseAlbumPreviewContext(t *testing.T) { + tests := []struct { + name string + metadata map[string]string + want albumPreviewContext + }{ + { + name: "prefers v2 album ID", + metadata: map[string]string{ + "chatId": " chat-id ", + "postEndUrl": "line://group/home/albums/album?albumId=legacy-id&albumIdV2=v2-id", + }, + want: albumPreviewContext{ChatID: "chat-id", AlbumID: "v2-id"}, + }, + { + name: "falls back to legacy album ID", + metadata: map[string]string{ + "chatId": "chat-id", + "postEndUrl": "line://group/home/albums/album?albumId=legacy-id", + }, + want: albumPreviewContext{ChatID: "chat-id", AlbumID: "legacy-id"}, + }, + { + name: "keeps chat ID without URL", + metadata: map[string]string{"chatId": "chat-id"}, + want: albumPreviewContext{ChatID: "chat-id"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := parseAlbumPreviewContext(test.metadata); got != test.want { + t.Fatalf("context = %#v, want %#v", got, test.want) + } + }) + } +} + 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 { diff --git a/pkg/line/client.go b/pkg/line/client.go index ed4744a..f9f980e 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" "strings" + "sync" "time" gen "github.com/highesttt/matrix-line-messenger/pkg" @@ -29,6 +30,8 @@ const ( rpcClientTimeout = 30 * time.Second obsMaxRetries = 5 lineApplicationHeader = "CHROMEOS\t" + ExtensionVersion + "\tChrome_OS\t" + albumPreviewChannelID = "1341209850" + albumPreviewTID = "f482x482" ) var ( @@ -43,6 +46,9 @@ type Client struct { HTTPClient *http.Client OBSClient *http.Client AccessToken string + + channelTokenMu sync.Mutex + channelTokenCache map[string]cachedChannelAccessToken } type OBSDownloadOptions struct { @@ -50,6 +56,11 @@ type OBSDownloadOptions struct { OBSPop string } +type cachedChannelAccessToken struct { + token string + expiresAt time.Time +} + func NewClient(token string) *Client { return &Client{ HTTPClient: &http.Client{Timeout: rpcClientTimeout}, @@ -712,6 +723,59 @@ func (c *Client) DownloadOBSResource(ctx context.Context, service, sid, oid, mes return c.downloadOBSWithServiceAndSIDOptions(ctx, service, sid, oid, messageID, OBSDownloadOptions{}) } +// DownloadAlbumPreview retrieves the thumbnail referenced by an album post +// notification. Album thumbnails use a dedicated TID and are authorized by the +// notification's chatId through LINE's Chrome home channel token. +func (c *Client) DownloadAlbumPreview(ctx context.Context, oid, chatID, albumID string) ([]byte, error) { + if oid == "" || chatID == "" { + return nil, errors.New("album preview OID and chat ID are required") + } + channelToken, err := c.AcquireChannelAccessToken(albumPreviewChannelID) + if err != nil { + return nil, fmt.Errorf("failed to acquire album preview channel token: %w", err) + } + + requestURL := fmt.Sprintf( + "%s/r/album/a/%s/%s", + OBSBaseURL, + url.PathEscape(oid), + albumPreviewTID, + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create album preview request: %w", err) + } + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("X-Line-ChannelToken", channelToken) + req.Header.Set("X-Line-Mid", chatID) + if albumID != "" { + req.Header.Set("X-Line-Album", albumID) + } + if c.AccessToken != "" { + req.Header.Set("X-Line-Access", c.AccessToken) + } + + resp, err := c.obsHTTPClient().Do(req) + if err != nil { + return nil, fmt.Errorf("OBS download request failed: %w", err) + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("failed to read OBS response body: %w", readErr) + } + switch resp.StatusCode { + case http.StatusOK: + return body, nil + case http.StatusAccepted: + return nil, ErrOBSEncodingIncomplete + case http.StatusNotFound: + return nil, ErrOBSObjectNotFound + default: + return nil, fmt.Errorf("OBS download failed (%d): %s", resp.StatusCode, string(body)) + } +} + func (c *Client) downloadOBSWithServiceAndSIDOptions(ctx context.Context, service, sid, oid, messageID string, opts OBSDownloadOptions) ([]byte, error) { if service == "" || sid == "" || oid == "" { return nil, errors.New("OBS service, SID, and OID are required") diff --git a/pkg/line/methods.go b/pkg/line/methods.go index 5f79a21..913d8a3 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -19,6 +19,8 @@ var ( const obsTokenBuffer = 30 * time.Second +const defaultChannelTokenLifetime = 5 * time.Minute + // InvalidateOBSTokenCache clears the cached OBS access token. The OBS token is // derived from the main LINE access token; when the latter is rotated (refresh // or re-login) any previously-issued OBS token is invalidated server-side, but @@ -714,6 +716,94 @@ func (c *Client) AcquireEncryptedAccessToken() (string, error) { return token, nil } +// AcquireChannelAccessToken returns a cached token for an official LINE +// channel, issuing one through ChannelService when necessary. +func (c *Client) AcquireChannelAccessToken(channelID string) (string, error) { + if channelID == "" { + return "", fmt.Errorf("channel ID is required") + } + + c.channelTokenMu.Lock() + defer c.channelTokenMu.Unlock() + + now := time.Now() + if cached, ok := c.channelTokenCache[channelID]; ok && + cached.token != "" && + now.Before(cached.expiresAt) { + return cached.token, nil + } + + resp, err := c.callRPC("ChannelService", "issueChannelToken", channelID) + if err != nil { + return "", err + } + var wrapper struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + ChannelAccessToken string `json:"channelAccessToken"` + Expiration json.RawMessage `json:"expiration"` + } `json:"data"` + } + if err = json.Unmarshal(resp, &wrapper); err != nil { + return "", fmt.Errorf("failed to decode issueChannelToken response: %w", err) + } + if wrapper.Code != 0 { + return "", fmt.Errorf("issueChannelToken API error: %s", wrapper.Message) + } + if wrapper.Data.ChannelAccessToken == "" { + return "", fmt.Errorf("issueChannelToken returned an empty token") + } + + expiresAt := parseChannelTokenExpiration(wrapper.Data.Expiration, now) + if c.channelTokenCache == nil { + c.channelTokenCache = make(map[string]cachedChannelAccessToken) + } + c.channelTokenCache[channelID] = cachedChannelAccessToken{ + token: wrapper.Data.ChannelAccessToken, + expiresAt: expiresAt, + } + return wrapper.Data.ChannelAccessToken, nil +} + +func parseChannelTokenExpiration(raw json.RawMessage, now time.Time) time.Time { + fallback := now.Add(defaultChannelTokenLifetime) + if len(raw) == 0 || string(raw) == "null" { + return fallback + } + + var numericValue int64 + if err := json.Unmarshal(raw, &numericValue); err != nil { + var stringValue string + if err = json.Unmarshal(raw, &stringValue); err != nil { + return fallback + } + if parsedTime, parseErr := time.Parse(time.RFC3339, stringValue); parseErr == nil { + return parsedTime.Add(-obsTokenBuffer) + } + numericValue, err = strconv.ParseInt(stringValue, 10, 64) + if err != nil { + return fallback + } + } + + var expiresAt time.Time + switch { + case numericValue > 100_000_000_000: + expiresAt = time.UnixMilli(numericValue) + case numericValue > 1_000_000_000: + expiresAt = time.Unix(numericValue, 0) + case numericValue > 0: + expiresAt = now.Add(time.Duration(numericValue) * time.Second) + default: + return fallback + } + if expiresAt.Before(now.Add(obsTokenBuffer)) { + return fallback + } + return expiresAt.Add(-obsTokenBuffer) +} + func (c *Client) GetMessageBoxes(options MessageBoxesOptions) (*MessageBoxesResponse, error) { resp, err := c.callRPC("TalkService", "getMessageBoxes", options, 2) if err != nil { diff --git a/pkg/line/obs_test.go b/pkg/line/obs_test.go index 7353d81..01dcb74 100644 --- a/pkg/line/obs_test.go +++ b/pkg/line/obs_test.go @@ -336,3 +336,138 @@ func TestDownloadOBSClassifiesEncodingRetryExhaustion(t *testing.T) { t.Fatalf("requests = %d, want %d", requests, obsMaxRetries+1) } } + +func TestDownloadAlbumPreviewMatchesLINERequestContract(t *testing.T) { + var channelRequests int + client := NewClient("line-token") + client.HTTPClient = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + channelRequests++ + if req.URL.Path != "/api/talk/thrift/Talk/ChannelService/issueChannelToken" { + t.Fatalf("channel token path = %q", req.URL.Path) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != `["1341209850"]` { + t.Fatalf("channel token body = %q", body) + } + return obsResponse( + http.StatusOK, + `{"code":0,"message":"OK","data":{"channelAccessToken":"channel-token","expiration":9999999999999}}`, + ), nil + }), + } + + var requests []observedOBSRequest + client.OBSClient = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requests = append(requests, observedOBSRequest{ + path: req.URL.Path, + query: req.URL.RawQuery, + headers: req.Header.Clone(), + }) + return obsResponse(http.StatusOK, "album-thumbnail"), nil + }), + } + + for range 2 { + data, err := client.DownloadAlbumPreview( + context.Background(), + "preview-oid", + "chat-id", + "album-id", + ) + if err != nil { + t.Fatal(err) + } + if string(data) != "album-thumbnail" { + t.Fatalf("data = %q, want album-thumbnail", data) + } + } + + if channelRequests != 1 { + t.Fatalf("channel token requests = %d, want one cached request", channelRequests) + } + if len(requests) != 2 { + t.Fatalf("OBS requests = %d, want two direct thumbnail downloads", len(requests)) + } + for index, request := range requests { + if request.path != "/r/album/a/preview-oid/f482x482" { + t.Fatalf("request %d path = %q", index, request.path) + } + if request.query != "" { + t.Fatalf("request %d query = %q, want empty", index, request.query) + } + if request.headers.Get("X-Line-ChannelToken") != "channel-token" { + t.Fatalf("request %d channel token = %q", index, request.headers.Get("X-Line-ChannelToken")) + } + if request.headers.Get("X-Line-Mid") != "chat-id" { + t.Fatalf("request %d MID = %q", index, request.headers.Get("X-Line-Mid")) + } + if request.headers.Get("X-Line-Album") != "album-id" { + t.Fatalf("request %d album = %q", index, request.headers.Get("X-Line-Album")) + } + if request.headers.Get("X-Line-Access") != "line-token" { + t.Fatalf("request %d access token = %q", index, request.headers.Get("X-Line-Access")) + } + if request.headers.Get("User-Agent") == "" { + t.Fatalf("request %d omitted User-Agent", index) + } + if request.headers.Get("X-Line-Application") != "" { + t.Fatalf("request %d unexpectedly sent X-Line-Application", index) + } + } +} + +func TestDownloadAlbumPreviewClassifiesHTTPStatus(t *testing.T) { + tests := []struct { + name string + status int + check func(error) bool + }{ + { + name: "encoding incomplete", + status: http.StatusAccepted, + check: func(err error) bool { return errors.Is(err, ErrOBSEncodingIncomplete) }, + }, + { + name: "missing object", + status: http.StatusNotFound, + check: func(err error) bool { return errors.Is(err, ErrOBSObjectNotFound) }, + }, + { + name: "unauthorized", + status: http.StatusForbidden, + check: IsUnauthorizedStatus, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := NewClient("line-token") + client.channelTokenCache = map[string]cachedChannelAccessToken{ + albumPreviewChannelID: { + token: "channel-token", + expiresAt: time.Now().Add(time.Hour), + }, + } + client.OBSClient = &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return obsResponse(test.status, "error"), nil + }), + } + + _, err := client.DownloadAlbumPreview( + context.Background(), + "preview-oid", + "chat-id", + "", + ) + if !test.check(err) { + t.Fatalf("err = %v, wrong classification", err) + } + }) + } +}