Skip to content
Open
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
4 changes: 3 additions & 1 deletion pkg/connector/handle_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
235 changes: 176 additions & 59 deletions pkg/connector/handlers/emoji.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"fmt"
"io"
"regexp"
"sort"
"strings"
"unicode/utf8"

"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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 <img> 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(`<img data-mx-emoticon src="%s" alt="%s" title="%s" height="32" />`, r.mxc, htmlEscape(placeholder), htmlEscape(placeholder)))
escapedFallback := htmlEscape(fallback)
htmlBuf.WriteString(fmt.Sprintf(`<img data-mx-emoticon src="%s" alt="%s" title="%s" height="32" />`, 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.
Expand All @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// parseSticonBody extracts sticon resources from the REPLACE.sticon.resources
// field of the encrypted message body JSON.
func parseSticonBody(body string) ([]SticonResource, error) {
Expand All @@ -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 {
Expand Down
Loading
Loading