From d9728e03972c8740ceb0f9381323d0b3e63e00d1 Mon Sep 17 00:00:00 2001 From: highesttt Date: Tue, 3 Nov 2026 00:32:21 -0500 Subject: [PATCH 1/3] sticker: bridge LINE emoji packs and send them as sticons --- pkg/connector/imagepack.go | 196 ++++++++++++++++++++++-------- pkg/connector/imagepack_test.go | 75 ++++++++++++ pkg/connector/send_message.go | 51 +++++--- pkg/connector/sticon_send.go | 79 ++++++++++++ pkg/connector/sticon_send_test.go | 55 +++++++++ pkg/line/stickers.go | 14 ++- pkg/line/stickers_test.go | 2 +- 7 files changed, 401 insertions(+), 71 deletions(-) create mode 100644 pkg/connector/imagepack_test.go create mode 100644 pkg/connector/sticon_send.go create mode 100644 pkg/connector/sticon_send_test.go diff --git a/pkg/connector/imagepack.go b/pkg/connector/imagepack.go index 588ce87..b649650 100644 --- a/pkg/connector/imagepack.go +++ b/pkg/connector/imagepack.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "net/url" + "regexp" "strconv" "strings" "time" @@ -24,6 +25,8 @@ import ( var _ bridgev2.StickerImportingNetworkAPI = (*LineClient)(nil) +var lineAssetID = regexp.MustCompile(`^[0-9A-Za-z]{1,32}$`) + type lineSticker struct { ProductID string ID string @@ -31,31 +34,46 @@ type lineSticker struct { Option string Hash string Body string + Sticon bool } -func linePackURL(productID string) string { +func linePackURL(productID string, sticon bool) string { + if sticon { + return "https://store.line.me/emojishop/product/" + productID + } return "https://store.line.me/stickershop/product/" + productID } -func parseLinePackURL(rawURL string) (string, error) { +func productPackURL(product line.ShopProduct) string { + return linePackURL(product.ID, product.ProductTypeSummary.Sticon != nil) +} + +func parseLinePackURL(rawURL string) (string, bool, error) { u, err := url.Parse(rawURL) if err != nil { - return "", err + return "", false, err } parts := strings.Split(strings.Trim(u.Path, "/"), "/") var productID string + var sticon bool if u.User == nil && u.RawQuery == "" && u.Fragment == "" { switch { - case u.Scheme == "line" && u.Host == "stickershop" && len(parts) == 1: - productID = parts[0] - case u.Scheme == "https" && u.Host == "store.line.me" && (len(parts) == 3 || len(parts) == 4) && parts[0] == "stickershop" && parts[1] == "product": - productID = parts[2] + case u.Scheme == "line" && (u.Host == line.StickerShop || u.Host == line.SticonShop) && len(parts) == 1: + productID, sticon = parts[0], u.Host == line.SticonShop + case u.Scheme == "https" && u.Host == "store.line.me" && (len(parts) == 3 || len(parts) == 4) && + (parts[0] == "stickershop" || parts[0] == "emojishop") && parts[1] == "product": + productID, sticon = parts[2], parts[0] == "emojishop" } } - if _, err = strconv.ParseUint(productID, 10, 64); err != nil { - return "", fmt.Errorf("use a LINE Store sticker product URL or line://stickershop/PACK_ID") + valid := lineAssetID.MatchString(productID) + if !sticon { + _, err = strconv.ParseUint(productID, 10, 64) + valid = err == nil } - return productID, nil + if !valid { + return "", false, fmt.Errorf("use a LINE Store sticker or emoji product URL or line://stickershop/PACK_ID") + } + return productID, sticon, nil } func (lc *LineClient) ownedStickerProducts(ctx context.Context, refresh bool) (map[string]line.ShopProduct, error) { @@ -98,15 +116,19 @@ func (lc *LineClient) ownedStickerProducts(ctx context.Context, refresh bool) (m if profile == nil || profile.RegionCode == "" { return nil, fmt.Errorf("LINE profile has no shop region") } - _, products, err := callLineResult(lc, ctx, func(client *line.Client) ([]line.ShopProduct, error) { - return client.GetOwnedStickerProducts(ctx, profile.RegionCode) - }) - if err != nil { - return nil, err - } - packs := make(map[string]line.ShopProduct, len(products)) - for _, product := range products { - packs[product.ID] = product + packs := make(map[string]line.ShopProduct) + for _, shop := range []string{line.StickerShop, line.SticonShop} { + _, products, err := callLineResult(lc, ctx, func(client *line.Client) ([]line.ShopProduct, error) { + return client.GetOwnedProducts(ctx, shop, profile.RegionCode) + }) + if err != nil && shop == line.SticonShop { + lc.UserLogin.Log.Warn().Err(err).Msg("Failed to fetch owned LINE emoji packs") + } else if err != nil { + return nil, err + } + for _, product := range products { + packs[productPackURL(product)] = product + } } lc.stickerPacksMu.Lock() lc.stickerPacks = packs @@ -114,14 +136,22 @@ func (lc *LineClient) ownedStickerProducts(ctx context.Context, refresh bool) (m return packs, nil } -func productStickers(product line.ShopProduct) ([]lineSticker, error) { +func productVersion(product line.ShopProduct) (string, error) { expiry, err := product.ValidUntil.Int64() if err != nil || (expiry != -1 && expiry <= time.Now().UnixMilli()) { - return nil, fmt.Errorf("LINE sticker pack has expired") + return "", fmt.Errorf("LINE pack has expired") } version := product.LatestVersion.String() if n, err := strconv.ParseUint(version, 10, 32); err != nil || n == 0 { - return nil, fmt.Errorf("invalid LINE pack version") + return "", fmt.Errorf("invalid LINE pack version") + } + return version, nil +} + +func productStickers(product line.ShopProduct) ([]lineSticker, error) { + version, err := productVersion(product) + if err != nil { + return nil, err } summary := product.ProductTypeSummary.Sticker if summary == nil || summary.ResourceType < 1 || summary.ResourceType > 6 { @@ -149,11 +179,65 @@ func productStickers(product line.ShopProduct) ([]lineSticker, error) { return stickers, nil } +func productSticons(product line.ShopProduct, metaJSON []byte) ([]lineSticker, error) { + version, err := productVersion(product) + if err != nil { + return nil, err + } + summary := product.ProductTypeSummary.Sticon + if summary == nil || (summary.ResourceType != 1 && summary.ResourceType != 2) { + return nil, fmt.Errorf("unsupported LINE emoji resource type") + } + var meta struct { + Orders []string `json:"orders"` + AltTexts map[string]string `json:"altTexts"` + } + if err = json.Unmarshal(metaJSON, &meta); err != nil { + return nil, err + } + if len(meta.Orders) == 0 || len(meta.Orders) > 1000 { + return nil, fmt.Errorf("invalid LINE emoji pack size") + } + sticons := make([]lineSticker, len(meta.Orders)) + for i, sticonID := range meta.Orders { + if !lineAssetID.MatchString(sticonID) { + return nil, fmt.Errorf("invalid LINE emoji ID") + } + body := "(emoji)" + if alt := meta.AltTexts[sticonID]; alt != "" { + body = "(" + alt + ")" + } + sticons[i] = lineSticker{ProductID: product.ID, ID: sticonID, Version: version, Body: body, Sticon: true} + if summary.ResourceType == 2 { + sticons[i].Option = "A" + } + } + return sticons, nil +} + +func (lc *LineClient) packStickers(ctx context.Context, product line.ShopProduct) ([]lineSticker, error) { + if product.ProductTypeSummary.Sticon == nil { + return productStickers(product) + } + if !lineAssetID.MatchString(product.ID) { + return nil, fmt.Errorf("invalid LINE emoji product ID") + } + meta, err := lc.fetchStickerCDN(ctx, "https://stickershop.line-scdn.net/sticonshop/v1/product/"+product.ID+"/android/meta.json") + if err != nil { + return nil, err + } + return productSticons(product, meta) +} + func packMetadata(product line.ShopProduct) *event.ImagePackMetadata { + usage := event.ImagePackUsageSticker + if product.ProductTypeSummary.Sticon != nil { + usage = event.ImagePackUsageEmoji + } return &event.ImagePackMetadata{ DisplayName: product.Name, - Usage: []event.ImagePackUsage{event.ImagePackUsageSticker}, - BridgedPack: &event.BridgedStickerPack{Network: "line", URL: linePackURL(product.ID)}, + Usage: []event.ImagePackUsage{usage}, + BridgedPack: &event.BridgedStickerPack{Network: "line", URL: productPackURL(product)}, } } @@ -164,21 +248,21 @@ func (lc *LineClient) ListImagePacks(ctx context.Context) ([]*event.ImagePackMet } packs := make([]*event.ImagePackMetadata, 0, len(products)) for _, product := range products { - if _, err = productStickers(product); err == nil { + if _, err = lc.packStickers(ctx, product); err == nil { packs = append(packs, packMetadata(product)) } } return packs, nil } -func (lc *LineClient) ownedStickerPack(ctx context.Context, productID string, refreshOnMiss bool) (*line.ShopProduct, []lineSticker, error) { +func (lc *LineClient) ownedStickerPack(ctx context.Context, packURL string, refreshOnMiss bool) (*line.ShopProduct, []lineSticker, error) { for _, refresh := range []bool{false, true} { products, err := lc.ownedStickerProducts(ctx, refresh) if err != nil { return nil, nil, err } - if product, ok := products[productID]; ok { - stickers, err := productStickers(product) + if product, ok := products[packURL]; ok { + stickers, err := lc.packStickers(ctx, product) return &product, stickers, err } if !refreshOnMiss { @@ -192,18 +276,29 @@ func stickerMXCKey(mxc id.ContentURIString) database.Key { return database.Key(fmt.Sprintf("line.sticker.mxc.%x", sha256.Sum256([]byte(mxc)))) } -func (lc *LineClient) importSticker(ctx context.Context, sticker lineSticker) (*event.ImagePackImage, error) { - prefix := "https://stickershop.line-scdn.net/stickershop/v1/sticker/" + sticker.ID - if sticker.Hash != "" { - prefix = "https://stickershop.line-scdn.net/stickershop/v2/sticker/" + sticker.ID + "/" + sticker.Hash +func (s lineSticker) asset() (string, string) { + if s.Sticon { + name := s.ID + ".png" + if s.Option == "A" { + name = s.ID + "_animation.png" + } + return "https://stickershop.line-scdn.net/sticonshop/v1/sticon/" + s.ProductID + "/android/" + name, name + } + prefix := "https://stickershop.line-scdn.net/stickershop/v1/sticker/" + s.ID + if s.Hash != "" { + prefix = "https://stickershop.line-scdn.net/stickershop/v2/sticker/" + s.ID + "/" + s.Hash } name := "sticker.png" - if strings.Contains(sticker.Option, "A") { + if strings.Contains(s.Option, "A") { name = "sticker_animation.png" - } else if strings.Contains(sticker.Option, "P") { + } else if strings.Contains(s.Option, "P") { name = "sticker_popup.png" } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, prefix+"/android/"+name, nil) + return prefix + "/android/" + name, name +} + +func (lc *LineClient) fetchStickerCDN(ctx context.Context, assetURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) if err != nil { return nil, err } @@ -227,6 +322,15 @@ func (lc *LineClient) importSticker(ctx context.Context, sticker lineSticker) (* if len(data) > maxSize { return nil, fmt.Errorf("LINE sticker asset too large") } + return data, nil +} + +func (lc *LineClient) importSticker(ctx context.Context, sticker lineSticker) (*event.ImagePackImage, error) { + assetURL, name := sticker.asset() + data, err := lc.fetchStickerCDN(ctx, assetURL) + if err != nil { + return nil, err + } config, err := png.DecodeConfig(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("invalid LINE sticker PNG: %w", err) @@ -235,7 +339,7 @@ func (lc *LineClient) importSticker(ctx context.Context, sticker lineSticker) (* if err != nil { return nil, err } - bridged := &event.BridgedSticker{Network: "line", ID: sticker.ID, PackURL: linePackURL(sticker.ProductID)} + bridged := &event.BridgedSticker{Network: "line", ID: sticker.ID, PackURL: linePackURL(sticker.ProductID, sticker.Sticon)} raw, err := json.Marshal(bridged) if err != nil { return nil, err @@ -253,11 +357,11 @@ func (lc *LineClient) importSticker(ctx context.Context, sticker lineSticker) (* } func (lc *LineClient) DownloadImagePack(ctx context.Context, rawURL string) (*bridgev2.ImportedImagePack, error) { - productID, err := parseLinePackURL(rawURL) + productID, sticon, err := parseLinePackURL(rawURL) if err != nil { return nil, err } - product, stickers, err := lc.ownedStickerPack(ctx, productID, true) + product, stickers, err := lc.ownedStickerPack(ctx, linePackURL(productID, sticon), true) if err != nil { return nil, err } @@ -278,13 +382,9 @@ func (lc *LineClient) DownloadImagePack(ctx context.Context, rawURL string) (*br return pack, nil } -func (lc *LineClient) resolveSticker(ctx context.Context, content *event.MessageEventContent) (*lineSticker, error) { - var bridged *event.BridgedSticker - if content.Info != nil { - bridged = content.Info.BridgedSticker - } - if bridged == nil && content.URL != "" { - raw := lc.UserLogin.Bridge.DB.KV.Get(ctx, stickerMXCKey(content.URL)) +func (lc *LineClient) resolveSticker(ctx context.Context, mxc id.ContentURIString, bridged *event.BridgedSticker, sticon bool) (*lineSticker, error) { + if bridged == nil && mxc != "" { + raw := lc.UserLogin.Bridge.DB.KV.Get(ctx, stickerMXCKey(mxc)) if raw != "" { if err := json.Unmarshal([]byte(raw), &bridged); err != nil { return nil, err @@ -294,11 +394,11 @@ func (lc *LineClient) resolveSticker(ctx context.Context, content *event.Message if bridged == nil || bridged.Network != "line" || bridged.PackURL == "" { return nil, nil } - productID, err := parseLinePackURL(bridged.PackURL) - if err != nil { + productID, isSticon, err := parseLinePackURL(bridged.PackURL) + if err != nil || isSticon != sticon { return nil, err } - _, stickers, err := lc.ownedStickerPack(ctx, productID, false) + _, stickers, err := lc.ownedStickerPack(ctx, linePackURL(productID, sticon), false) if err != nil { return nil, err } diff --git a/pkg/connector/imagepack_test.go b/pkg/connector/imagepack_test.go new file mode 100644 index 0000000..f2dfb05 --- /dev/null +++ b/pkg/connector/imagepack_test.go @@ -0,0 +1,75 @@ +package connector + +import ( + "encoding/json" + "testing" + + "github.com/beeper/line/pkg/line" +) + +func TestParseLinePackURLEmoji(t *testing.T) { + const productID = "5ac1bfd5040ab15980c9b435" + for _, rawURL := range []string{ + "https://store.line.me/emojishop/product/" + productID, + "https://store.line.me/emojishop/product/" + productID + "/en", + "line://sticonshop/" + productID, + } { + got, sticon, err := parseLinePackURL(rawURL) + if err != nil || got != productID || !sticon { + t.Fatalf("parseLinePackURL(%q) = %q, %v, %v", rawURL, got, sticon, err) + } + } + if got, sticon, err := parseLinePackURL("https://store.line.me/stickershop/product/123"); err != nil || got != "123" || sticon { + t.Fatalf("sticker URL = %q, %v, %v", got, sticon, err) + } + for _, rawURL := range []string{ + "https://store.line.me/stickershop/product/" + productID, + "https://store.line.me/emojishop/product/../x", + "line://sticonshop/a%2Fb", + } { + if _, _, err := parseLinePackURL(rawURL); err == nil { + t.Fatalf("parseLinePackURL(%q) succeeded", rawURL) + } + } +} + +func testSticonProduct(resourceType int) line.ShopProduct { + product := line.ShopProduct{ID: "5ac1bfd5040ab15980c9b435", Name: "Moon", LatestVersion: "3", ValidUntil: "-1"} + product.ProductTypeSummary.Sticon = &line.SticonSummary{ResourceType: resourceType} + return product +} + +func TestProductSticons(t *testing.T) { + meta := []byte(`{"orders":["001","002"],"altTexts":{"001":"moon heart eyes"}}`) + sticons, err := productSticons(testSticonProduct(2), meta) + if err != nil { + t.Fatal(err) + } + if len(sticons) != 2 || sticons[0].ID != "001" || sticons[0].Body != "(moon heart eyes)" || + sticons[1].Body != "(emoji)" || sticons[0].Version != "3" || sticons[0].Option != "A" || !sticons[0].Sticon { + t.Fatalf("unexpected sticons: %+v", sticons) + } + assetURL, name := sticons[0].asset() + if assetURL != "https://stickershop.line-scdn.net/sticonshop/v1/sticon/5ac1bfd5040ab15980c9b435/android/001_animation.png" || name != "001_animation.png" { + t.Fatalf("asset() = %q, %q", assetURL, name) + } + if packMetadata(testSticonProduct(1)).BridgedPack.URL != "https://store.line.me/emojishop/product/5ac1bfd5040ab15980c9b435" { + t.Fatal("unexpected emoji pack URL") + } + + for name, tc := range map[string]struct { + product line.ShopProduct + meta string + }{ + "bad id": {testSticonProduct(1), `{"orders":["../x"]}`}, + "empty": {testSticonProduct(1), `{"orders":[]}`}, + "bad type": {testSticonProduct(3), `{"orders":["001"]}`}, + "invalid json": {testSticonProduct(1), `{`}, + "expired": {func() line.ShopProduct { p := testSticonProduct(1); p.ValidUntil = json.Number("1"); return p }(), `{"orders":["001"]}`}, + "no summary": {line.ShopProduct{ID: "a", LatestVersion: "1", ValidUntil: "-1"}, `{"orders":["001"]}`}, + } { + if _, err := productSticons(tc.product, []byte(tc.meta)); err == nil { + t.Fatalf("%s: productSticons succeeded", name) + } + } +} diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index bbdac0f..f17cf4d 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -9,6 +9,7 @@ import ( "fmt" "image" "image/jpeg" + "maps" "regexp" "strconv" "strings" @@ -19,6 +20,7 @@ import ( "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" gen "github.com/beeper/line/pkg" "github.com/beeper/line/pkg/e2ee" @@ -104,8 +106,12 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat } var sticker *lineSticker if msg.Content.MsgType == event.CapMsgSticker { + var bridged *event.BridgedSticker + if msg.Content.Info != nil { + bridged = msg.Content.Info.BridgedSticker + } var err error - sticker, err = lc.resolveSticker(ctx, msg.Content) + sticker, err = lc.resolveSticker(ctx, msg.Content.URL, bridged, false) if err != nil { lc.UserLogin.Bridge.Log.Debug().Err(err).Msg("Failed to resolve LINE sticker, sending as image") } @@ -194,6 +200,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat // For plain text, we set lineMsg.Text directly; payload is used only for E2EE. var payload []byte var plainTextBody string // used when plainText == true for text messages + textBody := msg.Content.Body // For plain media: we send the message first, then upload media to r/talk/m/{msgId}. var plainMediaData []byte // raw media data to upload after sending @@ -225,10 +232,23 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat } case event.MsgText: contentType = int(ContentText) + var sticonMetadata map[string]string + textBody, sticonMetadata = outgoingSticons(ctx, msg.Content, func(mxc id.ContentURIString) *lineSticker { + sticon, err := lc.resolveSticker(ctx, mxc, nil, true) + if err != nil { + lc.UserLogin.Bridge.Log.Debug().Err(err).Msg("Failed to resolve LINE emoji, sending alt text") + } + return sticon + }) + maps.Copy(contentMetadata, sticonMetadata) if plainText { - plainTextBody = msg.Content.Body + plainTextBody = textBody } else { - payload, err = json.Marshal(map[string]string{"text": msg.Content.Body}) + textPayload := map[string]any{"text": textBody} + if replace := sticonMetadata["REPLACE"]; replace != "" { + textPayload["REPLACE"] = json.RawMessage(replace) + } + payload, err = json.Marshal(textPayload) if err != nil { return nil, wrapConvertError(fmt.Errorf("failed to marshal text payload: %w", err)) } @@ -634,7 +654,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat msg.Content.Mentions.Room = true } } - mentionMeta := lc.buildMentionMetadata(ctx, msg.Content.Body, msg.Content.FormattedBody, msg.Content.Mentions) + mentionMeta := lc.buildMentionMetadata(ctx, textBody, msg.Content.FormattedBody, msg.Content.Mentions) for k, v := range mentionMeta { contentMetadata[k] = v } @@ -651,21 +671,13 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat return nil, errFetch } } - if contentType != int(ContentText) { - chunks, err = lc.E2EE.EncryptGroupMessageRaw(portalMid, fromMid, contentType, payload) - } else { - chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) - } + chunks, err = lc.E2EE.EncryptGroupMessageRaw(portalMid, fromMid, contentType, payload) if err != nil { if isTerminalCryptoError(err) { return nil, err } if errFetch := lc.fetchAndUnwrapGroupKey(ctx, portalMid, 0); errFetch == nil { - if contentType != int(ContentText) { - chunks, err = lc.E2EE.EncryptGroupMessageRaw(portalMid, fromMid, contentType, payload) - } else { - chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) - } + chunks, err = lc.E2EE.EncryptGroupMessageRaw(portalMid, fromMid, contentType, payload) } else if isTerminalCryptoError(errFetch) { return nil, errFetch } else if errFetch = lineGroupE2EEFetchFailureError(errFetch); errFetch != nil { @@ -684,7 +696,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat err = nil delete(contentMetadata, "e2eeVersion") if contentType == int(ContentText) { - plainTextBody = msg.Content.Body + plainTextBody = textBody } else { delete(contentMetadata, "OID") delete(contentMetadata, "SID") @@ -717,6 +729,9 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat lc.UserLogin.Bridge.Log.Info().Str("portal", portalMid).Int("content_type", contentType).Msg("Sending plain text message (no E2EE)") } + if !plainText { + delete(contentMetadata, "REPLACE") + } now := time.Now().UnixMilli() lineMsg := &line.Message{ ID: fmt.Sprintf("local-%d", now), @@ -845,11 +860,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat Msg("autoRegisterGroupKey succeeded, retrying send") if !plainText && lc.E2EE != nil { if fetchErr := lc.fetchAndUnwrapGroupKey(ctx, portalMid, 0); fetchErr == nil { - if contentType != int(ContentText) { - chunks, err = lc.E2EE.EncryptGroupMessageRaw(portalMid, fromMid, contentType, payload) - } else { - chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) - } + chunks, err = lc.E2EE.EncryptGroupMessageRaw(portalMid, fromMid, contentType, payload) if err == nil { lineMsg.Chunks = chunks contentMetadata["e2eeVersion"] = strconv.Itoa(e2ee.ChunksVersion(chunks)) diff --git a/pkg/connector/sticon_send.go b/pkg/connector/sticon_send.go new file mode 100644 index 0000000..99d098b --- /dev/null +++ b/pkg/connector/sticon_send.go @@ -0,0 +1,79 @@ +package connector + +import ( + "context" + "encoding/json" + "slices" + "strconv" + "strings" + "unicode/utf16" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" + + "github.com/beeper/line/pkg/connector/handlers" +) + +func outgoingSticons(ctx context.Context, content *event.MessageEventContent, resolve func(id.ContentURIString) *lineSticker) (string, map[string]string) { + if content.Format != event.FormatHTML || !strings.Contains(content.FormattedBody, "data-mx-emoticon") { + return content.Body, nil + } + var sticons []*lineSticker + resolved := make(map[id.ContentURIString]*lineSticker) + parser := *format.TextHTMLParser + parser.ImageConverter = func(src, alt, _, _, _ string, isEmoji bool) string { + if isEmoji { + mxc := id.ContentURIString(src) + sticon, ok := resolved[mxc] + if !ok { + sticon = resolve(mxc) + resolved[mxc] = sticon + } + if sticon != nil { + sticons = append(sticons, sticon) + return "\x00" + } + } + return alt + } + parts := strings.Split(parser.Parse(content.FormattedBody, format.NewContext(ctx)), "\x00") + if len(sticons) == 0 || len(parts) != len(sticons)+1 { + return content.Body, nil + } + var body strings.Builder + var offset int + var ownership []string + resources := make([]handlers.SticonResource, len(sticons)) + for i, sticon := range sticons { + body.WriteString(parts[i]) + offset += len(utf16.Encode([]rune(parts[i]))) + body.WriteString(sticon.Body) + version, _ := strconv.Atoi(sticon.Version) + resources[i] = handlers.SticonResource{ + Start: offset, + End: offset + len(utf16.Encode([]rune(sticon.Body))), + ProductID: sticon.ProductID, + SticonID: sticon.ID, + Version: version, + ResourceType: "STATIC", + } + if sticon.Option == "A" { + resources[i].ResourceType = "ANIMATION" + } + offset = resources[i].End + if !slices.Contains(ownership, sticon.ProductID) { + ownership = append(ownership, sticon.ProductID) + } + } + body.WriteString(parts[len(sticons)]) + var replace struct { + Sticon struct { + Resources []handlers.SticonResource `json:"resources"` + } `json:"sticon"` + } + replace.Sticon.Resources = resources + replaceJSON, _ := json.Marshal(replace) + ownershipJSON, _ := json.Marshal(ownership) + return body.String(), map[string]string{"REPLACE": string(replaceJSON), "STICON_OWNERSHIP": string(ownershipJSON)} +} diff --git a/pkg/connector/sticon_send_test.go b/pkg/connector/sticon_send_test.go new file mode 100644 index 0000000..8b09957 --- /dev/null +++ b/pkg/connector/sticon_send_test.go @@ -0,0 +1,55 @@ +package connector + +import ( + "context" + "testing" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func TestOutgoingSticons(t *testing.T) { + sticons := map[id.ContentURIString]*lineSticker{ + "mxc://example.com/moon": {ProductID: "p1", ID: "001", Version: "3", Body: "(moon)", Sticon: true}, + "mxc://example.com/cat": {ProductID: "p2", ID: "007", Version: "1", Option: "A", Body: "(cat)", Sticon: true}, + } + calls := 0 + resolve := func(mxc id.ContentURIString) *lineSticker { + calls++ + return sticons[mxc] + } + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "😀 :moon: and :cat::moon: :other:", + Format: event.FormatHTML, + FormattedBody: `😀 :moon: and ` + + `:cat::moon: ` + + `:other:`, + } + body, metadata := outgoingSticons(context.Background(), content, resolve) + if body != "😀 (moon) and (cat)(moon) :other:" || calls != 3 { + t.Fatalf("body = %q, resolve calls = %d", body, calls) + } + wantReplace := `{"sticon":{"resources":[` + + `{"S":3,"E":9,"productId":"p1","sticonId":"001","version":3,"resourceType":"STATIC"},` + + `{"S":14,"E":19,"productId":"p2","sticonId":"007","version":1,"resourceType":"ANIMATION"},` + + `{"S":19,"E":25,"productId":"p1","sticonId":"001","version":3,"resourceType":"STATIC"}]}}` + if metadata["REPLACE"] != wantReplace { + t.Fatalf("REPLACE = %s", metadata["REPLACE"]) + } + if metadata["STICON_OWNERSHIP"] != `["p1","p2"]` { + t.Fatalf("STICON_OWNERSHIP = %s", metadata["STICON_OWNERSHIP"]) + } +} + +func TestOutgoingSticonsFallsBackToBody(t *testing.T) { + resolve := func(id.ContentURIString) *lineSticker { return nil } + for _, content := range []*event.MessageEventContent{ + {Body: "plain"}, + {Body: ":other:", Format: event.FormatHTML, FormattedBody: `:other:`}, + } { + if body, metadata := outgoingSticons(context.Background(), content, resolve); body != content.Body || metadata != nil { + t.Fatalf("outgoingSticons(%q) = %q, %v", content.Body, body, metadata) + } + } +} diff --git a/pkg/line/stickers.go b/pkg/line/stickers.go index a0abdf1..e85af03 100644 --- a/pkg/line/stickers.go +++ b/pkg/line/stickers.go @@ -6,6 +6,11 @@ import ( "fmt" ) +const ( + StickerShop = "stickershop" + SticonShop = "sticonshop" +) + type ShopProduct struct { ID string `json:"id"` Name string `json:"name"` @@ -13,9 +18,14 @@ type ShopProduct struct { ValidUntil json.Number `json:"validUntil"` ProductTypeSummary struct { Sticker *StickerSummary `json:"stickerSummary"` + Sticon *SticonSummary `json:"sticonSummary"` } `json:"productTypeSummary"` } +type SticonSummary struct { + ResourceType int `json:"sticonResourceType"` +} + type StickerSummary struct { ResourceType int `json:"stickerResourceType"` Hash string `json:"stickerHash"` @@ -25,12 +35,12 @@ type StickerSummary struct { } `json:"stickerIdRanges"` } -func (c *Client) GetOwnedStickerProducts(ctx context.Context, country string) ([]ShopProduct, error) { +func (c *Client) GetOwnedProducts(ctx context.Context, shop, country string) ([]ShopProduct, error) { const pageSize, maxProducts = 1000, 100000 var products []ShopProduct for len(products) < maxProducts { resp, err := c.callRPCWithBaseURLContext(ctx, ShopBaseURL, "ShopService", "getOwnedProductSummaries", - "stickershop", len(products), pageSize, map[string]string{"language": "en", "country": country}) + shop, len(products), pageSize, map[string]string{"language": "en", "country": country}) if err != nil { return nil, err } diff --git a/pkg/line/stickers_test.go b/pkg/line/stickers_test.go index e0ce2f9..b8884df 100644 --- a/pkg/line/stickers_test.go +++ b/pkg/line/stickers_test.go @@ -60,7 +60,7 @@ func TestGetOwnedStickerProductsPaging(t *testing.T) { } return obsResponse(http.StatusOK, tt.pages[len(offsets)-1]), nil })} - products, err := client.GetOwnedStickerProducts(context.Background(), "JP") + products, err := client.GetOwnedProducts(context.Background(), StickerShop, "JP") if (err != nil) != tt.wantErr { t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) } From 1c59e0814f7ed191fe3e8503e995d9dbaa8dbe8d Mon Sep 17 00:00:00 2001 From: highesttt Date: Tue, 3 Nov 2026 00:59:36 -0500 Subject: [PATCH 2/3] sticker: cache LINE emoji pack metadata --- pkg/connector/client.go | 2 ++ pkg/connector/imagepack.go | 23 ++++++++++++++++++++++- pkg/connector/imagepack_test.go | 30 ++++++++++++++++++++++++++++++ pkg/connector/send_message.go | 4 +++- 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 2d8e5df..29d62eb 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -65,6 +65,8 @@ type LineClient struct { stickerPacksMu sync.Mutex stickerPacksFetching chan struct{} stickerPacks map[string]line.ShopProduct + sticonPacksMu sync.Mutex + sticonPacks map[string][]lineSticker UserLogin *bridgev2.UserLogin AccessToken string diff --git a/pkg/connector/imagepack.go b/pkg/connector/imagepack.go index b649650..1ca6176 100644 --- a/pkg/connector/imagepack.go +++ b/pkg/connector/imagepack.go @@ -222,11 +222,32 @@ func (lc *LineClient) packStickers(ctx context.Context, product line.ShopProduct if !lineAssetID.MatchString(product.ID) { return nil, fmt.Errorf("invalid LINE emoji product ID") } + version, err := productVersion(product) + if err != nil { + return nil, err + } + cacheKey := product.ID + "/" + version + lc.sticonPacksMu.Lock() + sticons, ok := lc.sticonPacks[cacheKey] + lc.sticonPacksMu.Unlock() + if ok { + return sticons, nil + } meta, err := lc.fetchStickerCDN(ctx, "https://stickershop.line-scdn.net/sticonshop/v1/product/"+product.ID+"/android/meta.json") if err != nil { return nil, err } - return productSticons(product, meta) + sticons, err = productSticons(product, meta) + if err != nil { + return nil, err + } + lc.sticonPacksMu.Lock() + if lc.sticonPacks == nil || len(lc.sticonPacks) >= 256 { + lc.sticonPacks = make(map[string][]lineSticker) + } + lc.sticonPacks[cacheKey] = sticons + lc.sticonPacksMu.Unlock() + return sticons, nil } func packMetadata(product line.ShopProduct) *event.ImagePackMetadata { diff --git a/pkg/connector/imagepack_test.go b/pkg/connector/imagepack_test.go index f2dfb05..393f7d2 100644 --- a/pkg/connector/imagepack_test.go +++ b/pkg/connector/imagepack_test.go @@ -1,7 +1,11 @@ package connector import ( + "context" "encoding/json" + "io" + "net/http" + "strings" "testing" "github.com/beeper/line/pkg/line" @@ -39,6 +43,32 @@ func testSticonProduct(resourceType int) line.ShopProduct { return product } +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestPackStickersCachesSticonMeta(t *testing.T) { + var fetches []string + lc := &LineClient{HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + fetches = append(fetches, req.URL.String()) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"orders":["001"]}`))}, nil + })}} + product := testSticonProduct(1) + for range 2 { + sticons, err := lc.packStickers(context.Background(), product) + if err != nil || len(sticons) != 1 || sticons[0].ID != "001" { + t.Fatalf("packStickers = %+v, %v", sticons, err) + } + } + if len(fetches) != 1 || fetches[0] != "https://stickershop.line-scdn.net/sticonshop/v1/product/5ac1bfd5040ab15980c9b435/android/meta.json" { + t.Fatalf("meta.json fetches = %v", fetches) + } + product.LatestVersion = "4" + if _, err := lc.packStickers(context.Background(), product); err != nil || len(fetches) != 2 { + t.Fatalf("new version fetches = %v, %v", fetches, err) + } +} + func TestProductSticons(t *testing.T) { meta := []byte(`{"orders":["001","002"],"altTexts":{"001":"moon heart eyes"}}`) sticons, err := productSticons(testSticonProduct(2), meta) diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index f17cf4d..0b87701 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -234,7 +234,9 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat contentType = int(ContentText) var sticonMetadata map[string]string textBody, sticonMetadata = outgoingSticons(ctx, msg.Content, func(mxc id.ContentURIString) *lineSticker { - sticon, err := lc.resolveSticker(ctx, mxc, nil, true) + resolveCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + sticon, err := lc.resolveSticker(resolveCtx, mxc, nil, true) if err != nil { lc.UserLogin.Bridge.Log.Debug().Err(err).Msg("Failed to resolve LINE emoji, sending alt text") } From 55d1e5d80c7fcb794ee382f0975376124c88ffcf Mon Sep 17 00:00:00 2001 From: highesttt Date: Tue, 3 Nov 2026 01:11:29 -0500 Subject: [PATCH 3/3] sticker: retry emoji shop after a partial pack fetch --- pkg/connector/client.go | 1 + pkg/connector/imagepack.go | 8 ++++++- pkg/connector/imagepack_test.go | 41 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 29d62eb..238664e 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -65,6 +65,7 @@ type LineClient struct { stickerPacksMu sync.Mutex stickerPacksFetching chan struct{} stickerPacks map[string]line.ShopProduct + stickerPacksPartial bool sticonPacksMu sync.Mutex sticonPacks map[string][]lineSticker diff --git a/pkg/connector/imagepack.go b/pkg/connector/imagepack.go index 1ca6176..d86175b 100644 --- a/pkg/connector/imagepack.go +++ b/pkg/connector/imagepack.go @@ -117,12 +117,14 @@ func (lc *LineClient) ownedStickerProducts(ctx context.Context, refresh bool) (m return nil, fmt.Errorf("LINE profile has no shop region") } packs := make(map[string]line.ShopProduct) + partial := false for _, shop := range []string{line.StickerShop, line.SticonShop} { _, products, err := callLineResult(lc, ctx, func(client *line.Client) ([]line.ShopProduct, error) { return client.GetOwnedProducts(ctx, shop, profile.RegionCode) }) if err != nil && shop == line.SticonShop { lc.UserLogin.Log.Warn().Err(err).Msg("Failed to fetch owned LINE emoji packs") + partial = true } else if err != nil { return nil, err } @@ -132,6 +134,7 @@ func (lc *LineClient) ownedStickerProducts(ctx context.Context, refresh bool) (m } lc.stickerPacksMu.Lock() lc.stickerPacks = packs + lc.stickerPacksPartial = partial lc.stickerPacksMu.Unlock() return packs, nil } @@ -286,7 +289,10 @@ func (lc *LineClient) ownedStickerPack(ctx context.Context, packURL string, refr stickers, err := lc.packStickers(ctx, product) return &product, stickers, err } - if !refreshOnMiss { + lc.stickerPacksMu.Lock() + partial := lc.stickerPacksPartial + lc.stickerPacksMu.Unlock() + if !refreshOnMiss && !partial { break } } diff --git a/pkg/connector/imagepack_test.go b/pkg/connector/imagepack_test.go index 393f7d2..cbac55c 100644 --- a/pkg/connector/imagepack_test.go +++ b/pkg/connector/imagepack_test.go @@ -8,6 +8,9 @@ import ( "strings" "testing" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "github.com/beeper/line/pkg/line" ) @@ -69,6 +72,44 @@ func TestPackStickersCachesSticonMeta(t *testing.T) { } } +func TestOwnedStickerPackRetriesFailedEmojiShop(t *testing.T) { + oldNewClient := newLineAPIClient + t.Cleanup(func() { newLineAPIClient = oldNewClient }) + emojiCalls := 0 + newLineAPIClient = func(token string) *line.Client { + client := line.NewClient(token) + client.HTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + resp := `{"code":0,"data":{"regionCode":"JP"}}` + switch { + case strings.Contains(string(body), `"stickershop"`): + resp = `{"code":0,"data":{"totalSize":0,"productList":[]}}` + case strings.Contains(string(body), `"sticonshop"`): + emojiCalls++ + resp = `{"code":1,"message":"unavailable"}` + if emojiCalls > 1 { + resp = `{"code":0,"data":{"totalSize":1,"productList":[{"id":"5ac1bfd5040ab15980c9b435","latestVersion":3,"validUntil":-1,"productTypeSummary":{"sticonSummary":{"sticonResourceType":1}}}]}}` + } + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(resp))}, nil + })} + return client + } + lc := &LineClient{ + UserLogin: &bridgev2.UserLogin{Log: zerolog.Nop()}, + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"orders":["001"]}`))}, nil + })}, + } + _, sticons, err := lc.ownedStickerPack(context.Background(), linePackURL("5ac1bfd5040ab15980c9b435", true), false) + if err != nil || len(sticons) != 1 || emojiCalls != 2 { + t.Fatalf("ownedStickerPack = %+v, %v, emoji shop calls = %d", sticons, err, emojiCalls) + } + if _, _, err = lc.ownedStickerPack(context.Background(), linePackURL("123", false), false); err == nil || emojiCalls != 2 { + t.Fatalf("complete cache miss = %v, emoji shop calls = %d", err, emojiCalls) + } +} + func TestProductSticons(t *testing.T) { meta := []byte(`{"orders":["001","002"],"altTexts":{"001":"moon heart eyes"}}`) sticons, err := productSticons(testSticonProduct(2), meta)