From ecfedcd33d00221c657a52863453cec3f441937e Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sun, 5 Jul 2026 11:05:40 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=F0=9F=90=9B=20inline=20icon=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- pkg/connector/handle_message.go | 4 +- pkg/connector/handlers/emoji.go | 230 ++++++++++++++++++++------- pkg/connector/handlers/emoji_test.go | 88 ++++++++++ 3 files changed, 262 insertions(+), 60 deletions(-) create mode 100644 pkg/connector/handlers/emoji_test.go diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index 13abba9..898e579 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -376,7 +376,9 @@ func (lc *LineClient) convertLineMessage(ctx context.Context, portal *bridgev2.P // Handle inline emoji/stamp embedded in text messages if data.ContentMetadata["STKID"] != "" || data.ContentMetadata["STKPKGID"] != "" || - data.ContentMetadata["STICON_OWNERSHIP"] != "" { + data.ContentMetadata["STICON_OWNERSHIP"] != "" || + handlers.HasSticonBody(bodyText) || + handlers.ContainsLineSticonPlaceholder(unwrappedText) { if data.ContentMetadata["STICON_OWNERSHIP"] != "" { h.Log.Debug(). Str("body_text", bodyText). diff --git a/pkg/connector/handlers/emoji.go b/pkg/connector/handlers/emoji.go index d5dba4a..d5258cb 100644 --- a/pkg/connector/handlers/emoji.go +++ b/pkg/connector/handlers/emoji.go @@ -7,7 +7,10 @@ import ( "fmt" "io" "regexp" + "sort" "strings" + "unicode" + "unicode/utf8" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/event" @@ -67,12 +70,14 @@ func (h *Handler) tryUploadEmoji(ctx context.Context, portal *bridgev2.Portal, i return string(mxc) } +const inlineSticonFallbackText = "[Emoji]" + // sticonRefRegex matches inline sticon references embedded in the text body. // LINE embeds stamps as $STK:productId:emojiId$ in the text. -var sticonRefRegex = regexp.MustCompile(`\$STK:(\d+):(\d+)\$`) +var sticonRefRegex = regexp.MustCompile(`\$STK:([[:alnum:]]+):([[:alnum:]]+)\$`) // SticonResource describes one inline sticon embedded in a text message. -// S and E are byte positions in the text body that the sticon replaces. +// S and E are UTF-16 code unit positions in the text body that the sticon replaces. type SticonResource struct { Start int `json:"S"` End int `json:"E"` @@ -104,15 +109,7 @@ func (h *Handler) ConvertInlineEmoji(ctx context.Context, portal *bridgev2.Porta stkTxt = data.ContentMetadata["STKTXT"] } if stkTxt == "" { - stkTxt = "[Emoji]" - } - - // Try the direct STKID/STKPKGID path first (standalone sticker metadata). - if stkID != "" && stkPkgID != "" { - if msg := h.tryDownloadSticon(ctx, portal, intent, stkPkgID, stkID, relatesTo); msg != nil { - return msg, nil - } - return h.ConvertText(stkTxt, relatesTo) + stkTxt = inlineSticonFallbackText } // Try parsing the full bodyText as JSON — LINE embeds sticon resources @@ -125,6 +122,16 @@ func (h *Handler) ConvertInlineEmoji(ctx context.Context, portal *bridgev2.Porta } } + // Try the direct STKID/STKPKGID path after body parsing. Inline sticons may + // also include sticker metadata, but REPLACE.sticon is the source of truth + // for preserving surrounding text. + if stkID != "" && stkPkgID != "" { + if msg := h.tryDownloadSticon(ctx, portal, intent, stkPkgID, stkID, relatesTo); msg != nil { + return msg, nil + } + return h.ConvertText(cleanInlineSticonPlaceholders(stkTxt), relatesTo) + } + // Try parsing the text body for inline sticon references ($STK:productId:emojiId$). if matches := sticonRefRegex.FindStringSubmatch(stkTxt); len(matches) == 3 { productID := matches[1] @@ -166,93 +173,144 @@ func (h *Handler) ConvertInlineEmoji(ctx context.Context, portal *bridgev2.Porta } h.Log.Debug().Str("text_body", stkTxt).Str("raw_body", bodyText).Interface("content_metadata", data.ContentMetadata).Msg("Falling back to text for inline emoji") - return h.ConvertText(stkTxt, relatesTo) + return h.ConvertText(cleanInlineSticonPlaceholders(stkTxt), relatesTo) +} + +type sticonReplacement struct { + start int + end int + mxc string } // convertSticonParts builds a single formatted text message with sticon images // embedded inline via tags (like Discord bridge custom emoji). func (h *Handler) convertSticonParts(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, stkTxt string, resources []SticonResource, relatesTo *event.RelatesTo) *bridgev2.ConvertedMessage { - var plainBuf, htmlBuf bytes.Buffer - pos := 0 - - // First pass: download all sticons, build maps of pos->mxc - type replacement struct { - start, end int - mxc string - } - var replacements []replacement + var replacements []sticonReplacement for _, r := range resources { - if r.Start < pos { - r.Start = pos - } - if r.End <= r.Start || r.Start > len(stkTxt) { + if r.ProductID == "" || r.SticonID == "" { continue } - if r.End > len(stkTxt) { - r.End = len(stkTxt) - } - if r.ProductID == "" || r.SticonID == "" { + start, end, ok := sticonResourceByteRange(stkTxt, r) + if !ok { + h.Log.Debug(). + Int("start", r.Start). + Int("end", r.End). + Str("text_body", stkTxt). + Msg("Skipping inline sticon with invalid text offsets") continue } mxc := h.tryUploadEmoji(ctx, portal, intent, r.ProductID, r.SticonID) - replacements = append(replacements, replacement{r.Start, r.End, mxc}) + replacements = append(replacements, sticonReplacement{start: start, end: end, mxc: mxc}) + } + if len(replacements) == 0 { + return nil } - // Second pass: build plain text and HTML body + body, formattedBody := buildSticonMessageBodies(stkTxt, replacements) + if body == "" { + return nil + } + + h.Log.Debug(). + Str("body", body). + Str("format", string(event.FormatHTML)). + Str("formatted_body", formattedBody). + Msg("Converted sticon parts to HTML message") + + msg := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: body, + Format: event.FormatHTML, + FormattedBody: formattedBody, + RelatesTo: relatesTo, + } + + return &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{ + {Type: event.EventMessage, Content: msg}, + }, + } +} + +func buildSticonMessageBodies(stkTxt string, replacements []sticonReplacement) (string, string) { + var plainBuf, htmlBuf bytes.Buffer + pos := 0 + + sort.SliceStable(replacements, func(i, j int) bool { + return replacements[i].start < replacements[j].start + }) + for _, r := range replacements { - // Leading text before this sticon + if r.start < pos || r.end <= r.start || r.start > len(stkTxt) || r.end > len(stkTxt) { + continue + } + if r.start > pos { seg := stkTxt[pos:r.start] plainBuf.WriteString(seg) htmlBuf.WriteString(htmlEscape(seg)) } - // Sticon placeholder / image placeholder := stkTxt[r.start:r.end] + fallback := inlineSticonFallback(placeholder) if r.mxc != "" { - htmlBuf.WriteString(fmt.Sprintf(`%s`, r.mxc, htmlEscape(placeholder), htmlEscape(placeholder))) + escapedFallback := htmlEscape(fallback) + htmlBuf.WriteString(fmt.Sprintf(`%s`, htmlEscape(r.mxc), escapedFallback, escapedFallback)) } else { - htmlBuf.WriteString(htmlEscape(placeholder)) + htmlBuf.WriteString(htmlEscape(fallback)) } - plainBuf.WriteString(placeholder) + plainBuf.WriteString(fallback) pos = r.end } - // Trailing text after last sticon if pos < len(stkTxt) { seg := stkTxt[pos:] plainBuf.WriteString(seg) htmlBuf.WriteString(htmlEscape(seg)) } - if plainBuf.Len() == 0 { - return nil - } - - body := plainBuf.String() - formattedBody := htmlBuf.String() - - h.Log.Debug(). - Str("body", body). - Str("format", string(event.FormatHTML)). - Str("formatted_body", formattedBody). - Msg("Converted sticon parts to HTML message") + return plainBuf.String(), htmlBuf.String() +} - msg := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: body, - Format: event.FormatHTML, - FormattedBody: formattedBody, - RelatesTo: relatesTo, +func sticonResourceByteRange(text string, r SticonResource) (int, int, bool) { + if r.End <= r.Start { + return 0, 0, false + } + if start, ok := utf16OffsetToByteIndex(text, r.Start); ok { + if end, ok := utf16OffsetToByteIndex(text, r.End); ok && end > start { + return start, end, true + } } + if r.Start >= 0 && r.End <= len(text) && utf8.ValidString(text[r.Start:r.End]) { + return r.Start, r.End, true + } + return 0, 0, false +} - return &bridgev2.ConvertedMessage{ - Parts: []*bridgev2.ConvertedMessagePart{ - {Type: event.EventMessage, Content: msg}, - }, +func utf16OffsetToByteIndex(text string, offset int) (int, bool) { + if offset < 0 { + return 0, false } + codeUnits := 0 + for i, r := range text { + if codeUnits == offset { + return i, true + } + if r <= 0xFFFF { + codeUnits++ + } else { + codeUnits += 2 + } + if codeUnits == offset { + return i + utf8.RuneLen(r), true + } + } + if codeUnits == offset { + return len(text), true + } + return 0, false } // htmlEscape escapes special HTML characters. @@ -264,6 +322,50 @@ func htmlEscape(s string) string { return s } +func inlineSticonFallback(placeholder string) string { + if placeholder == "" || containsLineSticonPlaceholder(placeholder) { + return inlineSticonFallbackText + } + return placeholder +} + +// ContainsLineSticonPlaceholder reports whether text contains LINE's hidden +// inline-sticon placeholder glyphs. They are private-use characters that Matrix +// clients commonly render as replacement boxes. +func ContainsLineSticonPlaceholder(text string) bool { + return containsLineSticonPlaceholder(text) +} + +func containsLineSticonPlaceholder(text string) bool { + for _, r := range text { + if isLineSticonPlaceholderRune(r) { + return true + } + } + return false +} + +func cleanInlineSticonPlaceholders(text string) string { + var b strings.Builder + replaced := false + for _, r := range text { + if isLineSticonPlaceholderRune(r) { + if !replaced { + b.WriteString(inlineSticonFallbackText) + replaced = true + } + continue + } + b.WriteRune(r) + replaced = false + } + return b.String() +} + +func isLineSticonPlaceholderRune(r rune) bool { + return unicode.Is(unicode.Co, r) +} + // parseSticonBody extracts sticon resources from the REPLACE.sticon.resources // field of the encrypted message body JSON. func parseSticonBody(body string) ([]SticonResource, error) { @@ -282,6 +384,16 @@ func parseSticonBody(body string) ([]SticonResource, error) { return rb.Replace.Sticon.Resources, nil } +// HasSticonBody reports whether a decrypted LINE JSON text body contains +// inline sticon replacement metadata. +func HasSticonBody(body string) bool { + if !strings.HasPrefix(body, "{") { + return false + } + resources, err := parseSticonBody(body) + return err == nil && len(resources) > 0 +} + // tryDownloadSticon attempts to download a sticon/stamp image from the LINE CDN // and upload it to Matrix. Returns a ConvertedMessage on success, nil on failure. func (h *Handler) tryDownloadSticon(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, productID, emojiID string, relatesTo *event.RelatesTo) *bridgev2.ConvertedMessage { diff --git a/pkg/connector/handlers/emoji_test.go b/pkg/connector/handlers/emoji_test.go new file mode 100644 index 0000000..ce991b9 --- /dev/null +++ b/pkg/connector/handlers/emoji_test.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "strings" + "testing" +) + +func TestSticonResourceByteRangeUsesUTF16Offsets(t *testing.T) { + prefix := "\u30d6\u30e9\u30b8\u30eb " + placeholder := "\U000f0084" + text := prefix + placeholder + " \u3064\u307e\u3089\u306a\u304b\u3063\u305f" + start := utf16Units(prefix) + end := start + utf16Units(placeholder) + + startByte, endByte, ok := sticonResourceByteRange(text, SticonResource{Start: start, End: end}) + if !ok { + t.Fatal("expected UTF-16 sticon offsets to resolve") + } + if got := text[startByte:endByte]; got != placeholder { + t.Fatalf("resolved substring = %q, want %q", got, placeholder) + } +} + +func TestBuildSticonMessageBodiesRemovesLinePlaceholders(t *testing.T) { + first := "\U000f0084" + second := "\U000f0085" + text := "\u30d6\u30e9\u30b8\u30eb " + first + " \u3064\u307e\u3089\u306a\u304b\u3063\u305f\n\n\u6226\u3048 " + second + " \u7b11" + firstStart := strings.Index(text, first) + secondStart := strings.Index(text, second) + + body, formatted := buildSticonMessageBodies(text, []sticonReplacement{ + {start: secondStart, end: secondStart + len(second), mxc: "mxc://line/emoji2"}, + {start: firstStart, end: firstStart + len(first), mxc: "mxc://line/emoji1"}, + }) + + if strings.Contains(body, first) || strings.Contains(body, second) { + t.Fatalf("body still contains LINE placeholders: %q", body) + } + if strings.Contains(formatted, first) || strings.Contains(formatted, second) { + t.Fatalf("formatted body still contains LINE placeholders: %q", formatted) + } + + wantBody := "\u30d6\u30e9\u30b8\u30eb [Emoji] \u3064\u307e\u3089\u306a\u304b\u3063\u305f\n\n\u6226\u3048 [Emoji] \u7b11" + if body != wantBody { + t.Fatalf("body = %q, want %q", body, wantBody) + } + for _, want := range []string{ + `[Emoji]`, + `[Emoji]`, + } { + if !strings.Contains(formatted, want) { + t.Fatalf("formatted body missing %q in %q", want, formatted) + } + } +} + +func TestCleanInlineSticonPlaceholders(t *testing.T) { + text := "a\U000f0084\U000f0085b" + if got, want := cleanInlineSticonPlaceholders(text), "a[Emoji]b"; got != want { + t.Fatalf("cleanInlineSticonPlaceholders = %q, want %q", got, want) + } +} + +func TestHasSticonBody(t *testing.T) { + body := `{"text":"x\udbc0\udc84","REPLACE":{"sticon":{"resources":[{"S":1,"E":3,"productId":"670e0cce840a8236ddd4ee4c","sticonId":"211","version":1,"resourceType":"STATIC"}]}}}` + if !HasSticonBody(body) { + t.Fatal("expected REPLACE.sticon body to be detected") + } +} + +func TestSticonRefRegexAllowsHexProductIDs(t *testing.T) { + matches := sticonRefRegex.FindStringSubmatch("$STK:670e0cce840a8236ddd4ee4c:211$") + if len(matches) != 3 { + t.Fatalf("expected sticon ref to match, got %#v", matches) + } +} + +func utf16Units(text string) int { + units := 0 + for _, r := range text { + if r <= 0xffff { + units++ + } else { + units += 2 + } + } + return units +} From b3e9574fc4b84d0c128ec241d6ffef20d49b4d89 Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sun, 5 Jul 2026 11:22:00 +0000 Subject: [PATCH 2/3] fix: address inline icon review feedback Co-authored-by: Codex --- pkg/connector/handlers/emoji.go | 11 +++-- pkg/connector/handlers/emoji_test.go | 71 ++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/pkg/connector/handlers/emoji.go b/pkg/connector/handlers/emoji.go index d5258cb..84351de 100644 --- a/pkg/connector/handlers/emoji.go +++ b/pkg/connector/handlers/emoji.go @@ -9,7 +9,6 @@ import ( "regexp" "sort" "strings" - "unicode" "unicode/utf8" "maunium.net/go/mautrix/bridgev2" @@ -70,7 +69,13 @@ func (h *Handler) tryUploadEmoji(ctx context.Context, portal *bridgev2.Portal, i return string(mxc) } -const inlineSticonFallbackText = "[Emoji]" +const ( + inlineSticonFallbackText = "[Emoji]" + // LINE uses supplementary private-use placeholders for inline sticons in + // decrypted text bodies, while REPLACE.sticon carries the actual image IDs. + lineSticonPlaceholderRuneStart = 0x100000 + lineSticonPlaceholderRuneEnd = 0x100fff +) // sticonRefRegex matches inline sticon references embedded in the text body. // LINE embeds stamps as $STK:productId:emojiId$ in the text. @@ -363,7 +368,7 @@ func cleanInlineSticonPlaceholders(text string) string { } func isLineSticonPlaceholderRune(r rune) bool { - return unicode.Is(unicode.Co, r) + return r >= lineSticonPlaceholderRuneStart && r <= lineSticonPlaceholderRuneEnd } // parseSticonBody extracts sticon resources from the REPLACE.sticon.resources diff --git a/pkg/connector/handlers/emoji_test.go b/pkg/connector/handlers/emoji_test.go index ce991b9..6b6c6a2 100644 --- a/pkg/connector/handlers/emoji_test.go +++ b/pkg/connector/handlers/emoji_test.go @@ -7,7 +7,7 @@ import ( func TestSticonResourceByteRangeUsesUTF16Offsets(t *testing.T) { prefix := "\u30d6\u30e9\u30b8\u30eb " - placeholder := "\U000f0084" + placeholder := "\U00100084" text := prefix + placeholder + " \u3064\u307e\u3089\u306a\u304b\u3063\u305f" start := utf16Units(prefix) end := start + utf16Units(placeholder) @@ -21,9 +21,27 @@ func TestSticonResourceByteRangeUsesUTF16Offsets(t *testing.T) { } } +func TestSticonResourceByteRangeRejectsInvalidOffsets(t *testing.T) { + text := "abc" + tests := map[string]SticonResource{ + "empty": {Start: 1, End: 1}, + "reversed": {Start: 2, End: 1}, + "negative": {Start: -1, End: 1}, + "unresolvable": {Start: 0, End: 100}, + } + + for name, resource := range tests { + t.Run(name, func(t *testing.T) { + if start, end, ok := sticonResourceByteRange(text, resource); ok { + t.Fatalf("sticonResourceByteRange = %d/%d/true, want false", start, end) + } + }) + } +} + func TestBuildSticonMessageBodiesRemovesLinePlaceholders(t *testing.T) { - first := "\U000f0084" - second := "\U000f0085" + first := "\U00100084" + second := "\U00100085" text := "\u30d6\u30e9\u30b8\u30eb " + first + " \u3064\u307e\u3089\u306a\u304b\u3063\u305f\n\n\u6226\u3048 " + second + " \u7b11" firstStart := strings.Index(text, first) secondStart := strings.Index(text, second) @@ -54,13 +72,58 @@ func TestBuildSticonMessageBodiesRemovesLinePlaceholders(t *testing.T) { } } +func TestBuildSticonMessageBodiesSkipsInvalidReplacements(t *testing.T) { + placeholder := "\U00100084" + text := "a" + placeholder + "def" + start := strings.Index(text, placeholder) + end := start + len(placeholder) + + body, formatted := buildSticonMessageBodies(text, []sticonReplacement{ + {start: start, end: end, mxc: "mxc://line/emoji1"}, + {start: start + 1, end: end + 1, mxc: "mxc://line/overlap"}, + {start: end, end: end, mxc: "mxc://line/empty"}, + {start: end, end: start, mxc: "mxc://line/reversed"}, + {start: 99, end: 100, mxc: "mxc://line/out-of-range"}, + }) + + if want := "a[Emoji]def"; body != want { + t.Fatalf("body = %q, want %q", body, want) + } + if strings.Contains(formatted, "overlap") || strings.Contains(formatted, "empty") || + strings.Contains(formatted, "reversed") || strings.Contains(formatted, "out-of-range") { + t.Fatalf("formatted body included skipped replacement: %q", formatted) + } + if !strings.Contains(formatted, "mxc://line/emoji1") { + t.Fatalf("formatted body missing valid replacement: %q", formatted) + } + + body, formatted = buildSticonMessageBodies("abcdef", []sticonReplacement{ + {start: 3, end: 3, mxc: "mxc://line/empty"}, + {start: 99, end: 100, mxc: "mxc://line/out-of-range"}, + }) + if body != "abcdef" || formatted != "abcdef" { + t.Fatalf("invalid-only replacements = %q/%q, want original text", body, formatted) + } +} + func TestCleanInlineSticonPlaceholders(t *testing.T) { - text := "a\U000f0084\U000f0085b" + text := "a\U00100084\U00100085b" if got, want := cleanInlineSticonPlaceholders(text), "a[Emoji]b"; got != want { t.Fatalf("cleanInlineSticonPlaceholders = %q, want %q", got, want) } } +func TestLineSticonPlaceholderDetectionIsNarrow(t *testing.T) { + if !ContainsLineSticonPlaceholder("\U00100084") { + t.Fatal("expected LINE sticon placeholder to be detected") + } + for _, text := range []string{"\uf8ff", "\U000f0084"} { + if ContainsLineSticonPlaceholder(text) { + t.Fatalf("unexpected placeholder match for %U", []rune(text)[0]) + } + } +} + func TestHasSticonBody(t *testing.T) { body := `{"text":"x\udbc0\udc84","REPLACE":{"sticon":{"resources":[{"S":1,"E":3,"productId":"670e0cce840a8236ddd4ee4c","sticonId":"211","version":1,"resourceType":"STATIC"}]}}}` if !HasSticonBody(body) { From ccfca0e36553b94aa08cdbe2a7e5800b95852e39 Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sun, 5 Jul 2026 11:43:22 +0000 Subject: [PATCH 3/3] test: name inline icon placeholders Co-authored-by: Codex --- pkg/connector/handlers/emoji_test.go | 41 ++++++++++++++-------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/pkg/connector/handlers/emoji_test.go b/pkg/connector/handlers/emoji_test.go index 6b6c6a2..4fed7c8 100644 --- a/pkg/connector/handlers/emoji_test.go +++ b/pkg/connector/handlers/emoji_test.go @@ -5,19 +5,23 @@ import ( "testing" ) +const ( + testLineSticonPlaceholder1 = "\U00100084" + testLineSticonPlaceholder2 = "\U00100085" +) + func TestSticonResourceByteRangeUsesUTF16Offsets(t *testing.T) { prefix := "\u30d6\u30e9\u30b8\u30eb " - placeholder := "\U00100084" - text := prefix + placeholder + " \u3064\u307e\u3089\u306a\u304b\u3063\u305f" + text := prefix + testLineSticonPlaceholder1 + " \u3064\u307e\u3089\u306a\u304b\u3063\u305f" start := utf16Units(prefix) - end := start + utf16Units(placeholder) + end := start + utf16Units(testLineSticonPlaceholder1) startByte, endByte, ok := sticonResourceByteRange(text, SticonResource{Start: start, End: end}) if !ok { t.Fatal("expected UTF-16 sticon offsets to resolve") } - if got := text[startByte:endByte]; got != placeholder { - t.Fatalf("resolved substring = %q, want %q", got, placeholder) + if got := text[startByte:endByte]; got != testLineSticonPlaceholder1 { + t.Fatalf("resolved substring = %q, want %q", got, testLineSticonPlaceholder1) } } @@ -40,21 +44,19 @@ func TestSticonResourceByteRangeRejectsInvalidOffsets(t *testing.T) { } func TestBuildSticonMessageBodiesRemovesLinePlaceholders(t *testing.T) { - first := "\U00100084" - second := "\U00100085" - text := "\u30d6\u30e9\u30b8\u30eb " + first + " \u3064\u307e\u3089\u306a\u304b\u3063\u305f\n\n\u6226\u3048 " + second + " \u7b11" - firstStart := strings.Index(text, first) - secondStart := strings.Index(text, second) + text := "\u30d6\u30e9\u30b8\u30eb " + testLineSticonPlaceholder1 + " \u3064\u307e\u3089\u306a\u304b\u3063\u305f\n\n\u6226\u3048 " + testLineSticonPlaceholder2 + " \u7b11" + firstStart := strings.Index(text, testLineSticonPlaceholder1) + secondStart := strings.Index(text, testLineSticonPlaceholder2) body, formatted := buildSticonMessageBodies(text, []sticonReplacement{ - {start: secondStart, end: secondStart + len(second), mxc: "mxc://line/emoji2"}, - {start: firstStart, end: firstStart + len(first), mxc: "mxc://line/emoji1"}, + {start: secondStart, end: secondStart + len(testLineSticonPlaceholder2), mxc: "mxc://line/emoji2"}, + {start: firstStart, end: firstStart + len(testLineSticonPlaceholder1), mxc: "mxc://line/emoji1"}, }) - if strings.Contains(body, first) || strings.Contains(body, second) { + if strings.Contains(body, testLineSticonPlaceholder1) || strings.Contains(body, testLineSticonPlaceholder2) { t.Fatalf("body still contains LINE placeholders: %q", body) } - if strings.Contains(formatted, first) || strings.Contains(formatted, second) { + if strings.Contains(formatted, testLineSticonPlaceholder1) || strings.Contains(formatted, testLineSticonPlaceholder2) { t.Fatalf("formatted body still contains LINE placeholders: %q", formatted) } @@ -73,10 +75,9 @@ func TestBuildSticonMessageBodiesRemovesLinePlaceholders(t *testing.T) { } func TestBuildSticonMessageBodiesSkipsInvalidReplacements(t *testing.T) { - placeholder := "\U00100084" - text := "a" + placeholder + "def" - start := strings.Index(text, placeholder) - end := start + len(placeholder) + text := "a" + testLineSticonPlaceholder1 + "def" + start := strings.Index(text, testLineSticonPlaceholder1) + end := start + len(testLineSticonPlaceholder1) body, formatted := buildSticonMessageBodies(text, []sticonReplacement{ {start: start, end: end, mxc: "mxc://line/emoji1"}, @@ -107,14 +108,14 @@ func TestBuildSticonMessageBodiesSkipsInvalidReplacements(t *testing.T) { } func TestCleanInlineSticonPlaceholders(t *testing.T) { - text := "a\U00100084\U00100085b" + text := "a" + testLineSticonPlaceholder1 + testLineSticonPlaceholder2 + "b" if got, want := cleanInlineSticonPlaceholders(text), "a[Emoji]b"; got != want { t.Fatalf("cleanInlineSticonPlaceholders = %q, want %q", got, want) } } func TestLineSticonPlaceholderDetectionIsNarrow(t *testing.T) { - if !ContainsLineSticonPlaceholder("\U00100084") { + if !ContainsLineSticonPlaceholder(testLineSticonPlaceholder1) { t.Fatal("expected LINE sticon placeholder to be detected") } for _, text := range []string{"\uf8ff", "\U000f0084"} {