diff --git a/pkg/connector/client.go b/pkg/connector/client.go
index 2d8e5df..238664e 100644
--- a/pkg/connector/client.go
+++ b/pkg/connector/client.go
@@ -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
diff --git a/pkg/connector/imagepack.go b/pkg/connector/imagepack.go
index 588ce87..d86175b 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
+ }
+ 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) {
@@ -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 {
@@ -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)},
}
}
@@ -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)
return &product, stickers, err
}
- if !refreshOnMiss {
+ lc.stickerPacksMu.Lock()
+ partial := lc.stickerPacksPartial
+ lc.stickerPacksMu.Unlock()
+ if !refreshOnMiss && !partial {
break
}
}
@@ -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
}
@@ -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)
@@ -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
@@ -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
}
@@ -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
@@ -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
}
diff --git a/pkg/connector/imagepack_test.go b/pkg/connector/imagepack_test.go
new file mode 100644
index 0000000..cbac55c
--- /dev/null
+++ b/pkg/connector/imagepack_test.go
@@ -0,0 +1,146 @@
+package connector
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+
+ "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
+}
+
+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 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)
+ 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..0b87701 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,25 @@ 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 {
+ 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")
+ }
+ 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 +656,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 +673,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 +698,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 +731,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 +862,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: `😀
and ` +
+ `
` +
+ `
`,
+ }
+ 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: `
`},
+ } {
+ 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)
}