From d653465d415a4845fcd8c467a0756e047901ffd0 Mon Sep 17 00:00:00 2001 From: highesttt Date: Mon, 2 Nov 2026 13:38:41 -0500 Subject: [PATCH 1/3] connector: include received sticker metadata --- pkg/connector/handlers/sticker.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/pkg/connector/handlers/sticker.go b/pkg/connector/handlers/sticker.go index 89ffe4b9..5336a374 100644 --- a/pkg/connector/handlers/sticker.go +++ b/pkg/connector/handlers/sticker.go @@ -81,8 +81,26 @@ func (h *Handler) ConvertSticker(ctx context.Context, portal *bridgev2.Portal, i bodyName := "sticker." + ext info := &event.FileInfo{ - MimeType: mimeType, - Size: len(stkData), + MimeType: mimeType, + Size: len(stkData), + BridgedSticker: &event.BridgedSticker{Network: "line", ID: stkID}, + } + if packID := data.ContentMetadata["STKPKGID"]; packID != "" { + info.BridgedSticker.PackURL = "https://store.line.me/stickershop/product/" + packID + } + stickerInfo := map[string]any{"id": stkID} + for key, field := range map[string]string{ + "STKPKGID": "package_id", + "STKVER": "version", + "STKPKGNAME": "pack_name", + } { + if value := data.ContentMetadata[key]; value != "" { + stickerInfo[field] = value + } + } + if _, ok := data.ContentMetadata["STKOPT"]; ok { + stickerInfo["animated"] = isAnimated + stickerInfo["sound"] = strings.Contains(stkOpt, "S") } if cfg, _, err := image.DecodeConfig(bytes.NewReader(stkData)); err == nil { info.Width = cfg.Width * 2 / 3 @@ -96,7 +114,8 @@ func (h *Handler) ConvertSticker(ctx context.Context, portal *bridgev2.Portal, i return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ { - Type: event.EventSticker, + Type: event.EventSticker, + Extra: map[string]any{"fi.mau.line.sticker": stickerInfo}, Content: &event.MessageEventContent{ Body: bodyName, URL: mxc, From 9666cca83861c4169748d827291face33257a596 Mon Sep 17 00:00:00 2001 From: highesttt Date: Mon, 2 Nov 2026 15:39:54 -0500 Subject: [PATCH 2/3] stickers: support line sticker packs Co-authored-by: formeo14 --- pkg/connector/client.go | 4 + pkg/connector/connector.go | 2 +- pkg/connector/imagepack.go | 308 ++++++++++++++++++++++++++++++++++ pkg/connector/send_message.go | 29 +++- pkg/line/stickers.go | 66 ++++++++ 5 files changed, 406 insertions(+), 3 deletions(-) create mode 100644 pkg/connector/imagepack.go create mode 100644 pkg/line/stickers.go diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 25d95548..2d8e5dfd 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -62,6 +62,10 @@ var getProfileWithToken = func(ctx context.Context, token string) (*line.Profile } type LineClient struct { + stickerPacksMu sync.Mutex + stickerPacksFetching chan struct{} + stickerPacks map[string]line.ShopProduct + UserLogin *bridgev2.UserLogin AccessToken string RefreshToken string diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 037f908b..9eade7ff 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -60,7 +60,7 @@ func (lc *LineConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities return &bridgev2.NetworkGeneralCapabilities{ AggressiveUpdateInfo: true, Provisioning: bridgev2.ProvisioningCapabilities{ - ImagePackImport: false, + ImagePackImport: true, ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{ Search: true, ContactList: true, diff --git a/pkg/connector/imagepack.go b/pkg/connector/imagepack.go new file mode 100644 index 00000000..6657a70a --- /dev/null +++ b/pkg/connector/imagepack.go @@ -0,0 +1,308 @@ +package connector + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "image/png" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/line/pkg/line" +) + +var _ bridgev2.StickerImportingNetworkAPI = (*LineClient)(nil) + +type lineSticker struct { + ProductID string + ID string + Version string + Option string + Hash string + Body string +} + +func linePackURL(productID string) string { + return "https://store.line.me/stickershop/product/" + productID +} + +func parseLinePackURL(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + var productID string + 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] + } + } + if _, err = strconv.ParseUint(productID, 10, 64); err != nil { + return "", fmt.Errorf("use a LINE Store sticker product URL or line://stickershop/PACK_ID") + } + return productID, nil +} + +func (lc *LineClient) ownedStickerProducts(ctx context.Context, refresh bool) (map[string]line.ShopProduct, error) { + for { + if err := lc.checkLineCall(ctx); err != nil { + return nil, err + } + lc.stickerPacksMu.Lock() + if !refresh && lc.stickerPacks != nil { + packs := lc.stickerPacks + lc.stickerPacksMu.Unlock() + return packs, nil + } + if pending := lc.stickerPacksFetching; pending != nil { + lc.stickerPacksMu.Unlock() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-pending: + } + refresh = false + continue + } + lc.stickerPacksFetching = make(chan struct{}) + lc.stickerPacksMu.Unlock() + break + } + defer func() { + lc.stickerPacksMu.Lock() + close(lc.stickerPacksFetching) + lc.stickerPacksFetching = nil + lc.stickerPacksMu.Unlock() + }() + _, profile, err := callLineResult(lc, ctx, func(client *line.Client) (*line.Profile, error) { + return client.GetProfileContext(ctx) + }) + if err != nil { + return nil, err + } + 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 + } + lc.stickerPacksMu.Lock() + lc.stickerPacks = packs + lc.stickerPacksMu.Unlock() + return packs, nil +} + +func productStickers(product line.ShopProduct) ([]lineSticker, error) { + expiry, err := product.ValidUntil.Int64() + if err != nil || (expiry != -1 && expiry <= time.Now().UnixMilli()) { + return nil, fmt.Errorf("LINE sticker 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") + } + summary := product.ProductTypeSummary.Sticker + if summary == nil || summary.ResourceType < 1 || summary.ResourceType > 6 { + return nil, fmt.Errorf("unsupported LINE sticker resource type") + } + base := lineSticker{ + ProductID: product.ID, Version: version, Hash: summary.Hash, Body: product.Name, + Option: []string{"", "A", "S", "AS", "P", "PS"}[summary.ResourceType-1], + } + var stickers []lineSticker + for _, idRange := range summary.Ranges { + start, err := strconv.ParseUint(idRange.Start.String(), 10, 63) + if err != nil || start == 0 || idRange.Size < 0 || idRange.Size > 1000-len(stickers) { + return nil, fmt.Errorf("invalid LINE sticker range") + } + for i := range idRange.Size { + sticker := base + sticker.ID = strconv.FormatUint(start+uint64(i), 10) + stickers = append(stickers, sticker) + } + } + if len(stickers) == 0 { + return nil, fmt.Errorf("LINE sticker pack is empty") + } + return stickers, nil +} + +func packMetadata(product line.ShopProduct) *event.ImagePackMetadata { + return &event.ImagePackMetadata{ + DisplayName: product.Name, + Usage: []event.ImagePackUsage{event.ImagePackUsageSticker}, + BridgedPack: &event.BridgedStickerPack{Network: "line", URL: linePackURL(product.ID)}, + } +} + +func (lc *LineClient) ListImagePacks(ctx context.Context) ([]*event.ImagePackMetadata, error) { + products, err := lc.ownedStickerProducts(ctx, true) + if err != nil { + return nil, err + } + packs := make([]*event.ImagePackMetadata, 0, len(products)) + for _, product := range products { + if _, err = productStickers(product); err == nil { + packs = append(packs, packMetadata(product)) + } + } + return packs, nil +} + +func (lc *LineClient) ownedStickerPack(ctx context.Context, productID string) (*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) + return &product, stickers, err + } + } + return nil, nil, fmt.Errorf("LINE sticker pack is not owned") +} + +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 + } + name := "sticker.png" + if strings.Contains(sticker.Option, "A") { + name = "sticker_animation.png" + } else if strings.Contains(sticker.Option, "P") { + name = "sticker_popup.png" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, prefix+"/android/"+name, nil) + if err != nil { + return nil, err + } + client := http.Client{Timeout: 30 * time.Second} + if lc.HTTPClient != nil { + client = *lc.HTTPClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("LINE sticker CDN returned %d", resp.StatusCode) + } + const maxSize = 10 * 1024 * 1024 + data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1)) + if err != nil { + return nil, err + } + if len(data) > maxSize { + return nil, fmt.Errorf("LINE sticker asset too large") + } + config, err := png.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("invalid LINE sticker PNG: %w", err) + } + mxc, _, err := lc.UserLogin.Bridge.Bot.UploadMedia(ctx, "", data, name, "image/png") + if err != nil { + return nil, err + } + bridged := &event.BridgedSticker{Network: "line", ID: sticker.ID, PackURL: linePackURL(sticker.ProductID)} + raw, err := json.Marshal(bridged) + if err != nil { + return nil, err + } + kv := lc.UserLogin.Bridge.DB.KV + key := stickerMXCKey(mxc) + kv.Set(ctx, key, string(raw)) + return &event.ImagePackImage{ + URL: mxc, Body: sticker.Body, + Info: &event.FileInfo{ + MimeType: "image/png", Size: len(data), Width: config.Width, Height: config.Height, + IsAnimated: strings.ContainsAny(sticker.Option, "AP"), BridgedSticker: bridged, + }, + }, nil +} + +func (lc *LineClient) DownloadImagePack(ctx context.Context, rawURL string) (*bridgev2.ImportedImagePack, error) { + productID, err := parseLinePackURL(rawURL) + if err != nil { + return nil, err + } + product, stickers, err := lc.ownedStickerPack(ctx, productID) + if err != nil { + return nil, err + } + pack := &bridgev2.ImportedImagePack{ + Shortcode: "line_" + productID, + Content: &event.ImagePackEventContent{ + Metadata: *packMetadata(*product), + Images: make(map[string]*event.ImagePackImage, len(stickers)), + }, + } + for _, sticker := range stickers { + img, err := lc.importSticker(ctx, sticker) + if err != nil { + return nil, err + } + pack.Content.Images["line_"+sticker.ID] = img + } + 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)) + if raw != "" { + if err := json.Unmarshal([]byte(raw), &bridged); err != nil { + return nil, err + } + } + } + if bridged == nil || bridged.Network != "line" || bridged.PackURL == "" { + return nil, nil + } + productID, err := parseLinePackURL(bridged.PackURL) + if err != nil { + return nil, err + } + _, stickers, err := lc.ownedStickerPack(ctx, productID) + if err != nil { + return nil, err + } + for _, sticker := range stickers { + if sticker.ID == bridged.ID { + return &sticker, nil + } + } + return nil, fmt.Errorf("sticker does not belong to the LINE pack") +} diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index 321b6b83..eb0f98ad 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -102,10 +102,21 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat if !isGroup && lc.isUserBlocked(portalMid) { return nil, errBlockedContact } + var sticker *lineSticker + if msg.Content.MsgType == event.CapMsgSticker { + var err error + sticker, err = lc.resolveSticker(ctx, msg.Content) + if err != nil { + return nil, wrapSendError(err) + } + } // Determine whether we need to send as plain text (peer/group has Letter Sealing off). plainText := false - if lc.E2EE == nil { + if sticker != nil { + // LINE sends native stickers as metadata without an encrypted payload. + plainText = true + } else if lc.E2EE == nil { plainText = true lc.UserLogin.Bridge.Log.Warn().Msg("E2EE not initialized, sending as plain text") } else if isGroup { @@ -134,7 +145,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat // Matrix clients often send media as MsgFile (e.g. drag-and-drop), which would otherwise // be sent as ContentFile (14) instead of the appropriate media type on LINE. effectiveMsgType := msg.Content.MsgType - if effectiveMsgType == event.CapMsgSticker { + if effectiveMsgType == event.CapMsgSticker && sticker == nil { effectiveMsgType = event.MsgImage } else if effectiveMsgType == event.MsgFile && msg.Content.Info != nil { mime := msg.Content.Info.MimeType @@ -198,6 +209,20 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat var rawFileName string switch effectiveMsgType { + case event.CapMsgSticker: + contentType = int(ContentSticker) + contentMetadata = map[string]string{ + "STKPKGID": sticker.ProductID, + "STKID": sticker.ID, + "STKVER": sticker.Version, + "STKTXT": sticker.Body, + } + if sticker.Option != "" { + contentMetadata["STKOPT"] = sticker.Option + } + if sticker.Hash != "" { + contentMetadata["STKHASH"] = sticker.Hash + } case event.MsgText: contentType = int(ContentText) if plainText { diff --git a/pkg/line/stickers.go b/pkg/line/stickers.go new file mode 100644 index 00000000..9de345e0 --- /dev/null +++ b/pkg/line/stickers.go @@ -0,0 +1,66 @@ +package line + +import ( + "context" + "encoding/json" + "fmt" +) + +type ShopProduct struct { + ID string `json:"id"` + Name string `json:"name"` + LatestVersion json.Number `json:"latestVersion"` + ValidUntil json.Number `json:"validUntil"` + ProductTypeSummary struct { + Sticker *StickerSummary `json:"stickerSummary"` + } `json:"productTypeSummary"` +} + +type StickerSummary struct { + ResourceType int `json:"stickerResourceType"` + Hash string `json:"stickerHash"` + Ranges []struct { + Start json.Number `json:"start"` + Size int `json:"size"` + } `json:"stickerIdRanges"` +} + +func (c *Client) GetOwnedStickerProducts(ctx context.Context, country string) ([]ShopProduct, error) { + const pageSize, maxProducts = 1000, 100000 + var products []ShopProduct + for offset := 0; offset < maxProducts; { + resp, err := c.callRPCWithBaseURLContext(ctx, ShopBaseURL, "ShopService", "getOwnedProductSummaries", + "stickershop", offset, pageSize, map[string]string{"language": "en", "country": country}) + if err != nil { + return nil, err + } + var result struct { + Code int `json:"code"` + Message string `json:"message"` + Data *struct { + Products []ShopProduct `json:"productList"` + Offset int `json:"offset"` + Total int `json:"totalSize"` + } `json:"data"` + } + if err = json.Unmarshal(resp, &result); err != nil { + return nil, err + } + if result.Code != 0 { + return nil, fmt.Errorf("getOwnedProductSummaries failed: code %d: %s", result.Code, result.Message) + } + data := result.Data + if data == nil || data.Offset != offset || data.Total < offset || data.Total > maxProducts { + return nil, fmt.Errorf("invalid LINE shop pagination") + } + products = append(products, data.Products...) + offset += len(data.Products) + if offset >= data.Total { + return products, nil + } + if len(data.Products) == 0 { + return nil, fmt.Errorf("empty LINE shop page before end of catalog") + } + } + return nil, fmt.Errorf("LINE shop catalog exceeded pagination limit") +} From 96cf59501bb1ed0074c02e030c466aa348f92fe9 Mon Sep 17 00:00:00 2001 From: highesttt Date: Mon, 2 Nov 2026 16:08:22 -0500 Subject: [PATCH 3/3] stickers: fall back to images when sticker lookup fails --- pkg/connector/imagepack.go | 9 ++++++--- pkg/connector/send_message.go | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/connector/imagepack.go b/pkg/connector/imagepack.go index 6657a70a..588ce870 100644 --- a/pkg/connector/imagepack.go +++ b/pkg/connector/imagepack.go @@ -171,7 +171,7 @@ func (lc *LineClient) ListImagePacks(ctx context.Context) ([]*event.ImagePackMet return packs, nil } -func (lc *LineClient) ownedStickerPack(ctx context.Context, productID string) (*line.ShopProduct, []lineSticker, error) { +func (lc *LineClient) ownedStickerPack(ctx context.Context, productID string, refreshOnMiss bool) (*line.ShopProduct, []lineSticker, error) { for _, refresh := range []bool{false, true} { products, err := lc.ownedStickerProducts(ctx, refresh) if err != nil { @@ -181,6 +181,9 @@ func (lc *LineClient) ownedStickerPack(ctx context.Context, productID string) (* stickers, err := productStickers(product) return &product, stickers, err } + if !refreshOnMiss { + break + } } return nil, nil, fmt.Errorf("LINE sticker pack is not owned") } @@ -254,7 +257,7 @@ func (lc *LineClient) DownloadImagePack(ctx context.Context, rawURL string) (*br if err != nil { return nil, err } - product, stickers, err := lc.ownedStickerPack(ctx, productID) + product, stickers, err := lc.ownedStickerPack(ctx, productID, true) if err != nil { return nil, err } @@ -295,7 +298,7 @@ func (lc *LineClient) resolveSticker(ctx context.Context, content *event.Message if err != nil { return nil, err } - _, stickers, err := lc.ownedStickerPack(ctx, productID) + _, stickers, err := lc.ownedStickerPack(ctx, productID, false) if err != nil { return nil, err } diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index eb0f98ad..bbdac0f7 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -107,7 +107,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat var err error sticker, err = lc.resolveSticker(ctx, msg.Content) if err != nil { - return nil, wrapSendError(err) + lc.UserLogin.Bridge.Log.Debug().Err(err).Msg("Failed to resolve LINE sticker, sending as image") } }