Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkg/connector/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ type LineClient struct {
stickerPacksMu sync.Mutex
stickerPacksFetching chan struct{}
stickerPacks map[string]line.ShopProduct
stickerPacksPartial bool
sticonPacksMu sync.Mutex
sticonPacks map[string][]lineSticker

UserLogin *bridgev2.UserLogin
AccessToken string
Expand Down
225 changes: 176 additions & 49 deletions pkg/connector/imagepack.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
Expand All @@ -24,38 +25,55 @@ import (

var _ bridgev2.StickerImportingNetworkAPI = (*LineClient)(nil)

var lineAssetID = regexp.MustCompile(`^[0-9A-Za-z]{1,32}$`)

type lineSticker struct {
ProductID string
ID string
Version string
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
}
if !valid {
return "", false, fmt.Errorf("use a LINE Store sticker or emoji product URL or line://stickershop/PACK_ID")
}
return productID, nil
return productID, sticon, nil
}

func (lc *LineClient) ownedStickerProducts(ctx context.Context, refresh bool) (map[string]line.ShopProduct, error) {
Expand Down Expand Up @@ -98,30 +116,45 @@ 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)
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
}
for _, product := range products {
packs[productPackURL(product)] = product
}
}
lc.stickerPacksMu.Lock()
lc.stickerPacks = packs
lc.stickerPacksPartial = partial
lc.stickerPacksMu.Unlock()
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 {
Expand Down Expand Up @@ -149,11 +182,86 @@ 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")
}
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
}
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 {
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)},
}
}

Expand All @@ -164,24 +272,27 @@ 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)
Comment thread
indent[bot] marked this conversation as resolved.
Resolved
return &product, stickers, err
}
if !refreshOnMiss {
lc.stickerPacksMu.Lock()
partial := lc.stickerPacksPartial
lc.stickerPacksMu.Unlock()
if !refreshOnMiss && !partial {
break
}
}
Expand All @@ -192,18 +303,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
}
Expand All @@ -227,6 +349,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)
Expand All @@ -235,7 +366,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
Expand All @@ -253,11 +384,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
}
Expand All @@ -278,13 +409,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
Expand All @@ -294,11 +421,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
}
Expand Down
Loading
Loading