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..84351de 100644
--- a/pkg/connector/handlers/emoji.go
+++ b/pkg/connector/handlers/emoji.go
@@ -7,7 +7,9 @@ import (
"fmt"
"io"
"regexp"
+ "sort"
"strings"
+ "unicode/utf8"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
@@ -67,12 +69,20 @@ func (h *Handler) tryUploadEmoji(ctx context.Context, portal *bridgev2.Portal, i
return string(mxc)
}
+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.
-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 +114,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 +127,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 +178,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
+ }
+
+ body, formattedBody := buildSticonMessageBodies(stkTxt, replacements)
+ if body == "" {
+ return nil
}
- // Second pass: build plain text and HTML body
+ 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(`
`, r.mxc, htmlEscape(placeholder), htmlEscape(placeholder)))
+ escapedFallback := htmlEscape(fallback)
+ htmlBuf.WriteString(fmt.Sprintf(`
`, 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 +327,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 r >= lineSticonPlaceholderRuneStart && r <= lineSticonPlaceholderRuneEnd
+}
+
// parseSticonBody extracts sticon resources from the REPLACE.sticon.resources
// field of the encrypted message body JSON.
func parseSticonBody(body string) ([]SticonResource, error) {
@@ -282,6 +389,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..4fed7c8
--- /dev/null
+++ b/pkg/connector/handlers/emoji_test.go
@@ -0,0 +1,152 @@
+package handlers
+
+import (
+ "strings"
+ "testing"
+)
+
+const (
+ testLineSticonPlaceholder1 = "\U00100084"
+ testLineSticonPlaceholder2 = "\U00100085"
+)
+
+func TestSticonResourceByteRangeUsesUTF16Offsets(t *testing.T) {
+ prefix := "\u30d6\u30e9\u30b8\u30eb "
+ text := prefix + testLineSticonPlaceholder1 + " \u3064\u307e\u3089\u306a\u304b\u3063\u305f"
+ start := utf16Units(prefix)
+ 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 != testLineSticonPlaceholder1 {
+ t.Fatalf("resolved substring = %q, want %q", got, testLineSticonPlaceholder1)
+ }
+}
+
+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) {
+ 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(testLineSticonPlaceholder2), mxc: "mxc://line/emoji2"},
+ {start: firstStart, end: firstStart + len(testLineSticonPlaceholder1), mxc: "mxc://line/emoji1"},
+ })
+
+ if strings.Contains(body, testLineSticonPlaceholder1) || strings.Contains(body, testLineSticonPlaceholder2) {
+ t.Fatalf("body still contains LINE placeholders: %q", body)
+ }
+ if strings.Contains(formatted, testLineSticonPlaceholder1) || strings.Contains(formatted, testLineSticonPlaceholder2) {
+ 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{
+ `
`,
+ `
`,
+ } {
+ if !strings.Contains(formatted, want) {
+ t.Fatalf("formatted body missing %q in %q", want, formatted)
+ }
+ }
+}
+
+func TestBuildSticonMessageBodiesSkipsInvalidReplacements(t *testing.T) {
+ 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"},
+ {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" + 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(testLineSticonPlaceholder1) {
+ 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) {
+ 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
+}