Skip to content

Commit 52c22da

Browse files
authored
fix: mentions for users with UTC-16 characters (#222)
* fix: mentions for users with UTC-16 characters * fix: mention parsing & validation (indent)
1 parent 412eda7 commit 52c22da

5 files changed

Lines changed: 237 additions & 19 deletions

File tree

pkg/connector/handle_message.go

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"fmt"
77
"html"
88
"sort"
9-
"strconv"
109
"strings"
1110
"time"
1211

@@ -480,20 +479,16 @@ func (lc *LineClient) convertLineMessage(ctx context.Context, portal *bridgev2.P
480479
lc.UserLogin.Bridge.Log.Debug().Str("mxid", string(mxid)).Msg("Formatted MXID from LINE MID")
481480
mentions.UserIDs = append(mentions.UserIDs, mxid)
482481
if canFormatMentions {
483-
if s, errS := strconv.Atoi(ment.S); errS == nil && s >= 0 {
484-
if e, errE := strconv.Atoi(ment.E); errE == nil && e <= len(unwrappedText) && e > s {
485-
entries = append(entries, mentionEntry{start: s, end: e, mxid: string(mxid)})
486-
}
482+
if start, end, ok := resolveMentionRange(unwrappedText, ment.S, ment.E); ok {
483+
entries = append(entries, mentionEntry{start: start, end: end, mxid: string(mxid)})
487484
}
488485
}
489486
}
490487
if ment.A == "1" {
491488
mentions.Room = true
492489
if canFormatMentions {
493-
if s, errS := strconv.Atoi(ment.S); errS == nil && s >= 0 {
494-
if e, errE := strconv.Atoi(ment.E); errE == nil && e <= len(unwrappedText) && e > s {
495-
entries = append(entries, mentionEntry{start: s, end: e, mxid: "@room"})
496-
}
490+
if start, end, ok := resolveMentionRange(unwrappedText, ment.S, ment.E); ok {
491+
entries = append(entries, mentionEntry{start: start, end: end, mxid: "@room"})
497492
}
498493
}
499494
}

pkg/connector/handle_message_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,78 @@ func TestConvertLineMessagePreservesMentionsForSticonFallback(t *testing.T) {
164164
}
165165
}
166166

167+
func TestConvertLineMessageUsesUTF16MentionOffsets(t *testing.T) {
168+
text := "🙂 嗨 @กิ๊ก!"
169+
userMXID := id.UserID("@user:example.com")
170+
lc := &LineClient{
171+
Mid: "self-mid",
172+
UserLogin: &bridgev2.UserLogin{
173+
UserLogin: &database.UserLogin{UserMXID: userMXID},
174+
Bridge: &bridgev2.Bridge{Log: zerolog.New(io.Discard)},
175+
},
176+
}
177+
data := line.Message{
178+
ContentType: int(ContentText),
179+
ContentMetadata: map[string]string{
180+
"MENTION": `{"MENTIONEES":[{"M":"self-mid","S":"5","E":"10"}]}`,
181+
},
182+
}
183+
184+
converted, err := lc.convertLineMessage(t.Context(), nil, nil, data, text, text, false)
185+
if err != nil {
186+
t.Fatalf("convertLineMessage returned error: %v", err)
187+
}
188+
if converted == nil || len(converted.Parts) != 1 || converted.Parts[0].Content == nil {
189+
t.Fatalf("convertLineMessage returned %#v, want one message part", converted)
190+
}
191+
content := converted.Parts[0].Content
192+
if content.Body != text {
193+
t.Fatalf("Body = %q, want %q", content.Body, text)
194+
}
195+
if content.Mentions == nil || len(content.Mentions.UserIDs) != 1 || content.Mentions.UserIDs[0] != userMXID {
196+
t.Fatalf("Mentions = %#v, want user %s", content.Mentions, userMXID)
197+
}
198+
expectedLink := `<a href="https://matrix.to/#/@user:example.com">@กิ๊ก</a>`
199+
if !strings.Contains(content.FormattedBody, expectedLink) {
200+
t.Fatalf("FormattedBody = %q, want link %q", content.FormattedBody, expectedLink)
201+
}
202+
}
203+
204+
func TestConvertLineMessageUsesUTF16RoomMentionOffsets(t *testing.T) {
205+
text := "🙂 嗨 @everyone!"
206+
lc := &LineClient{
207+
UserLogin: &bridgev2.UserLogin{
208+
UserLogin: &database.UserLogin{UserMXID: "@user:example.com"},
209+
Bridge: &bridgev2.Bridge{Log: zerolog.New(io.Discard)},
210+
},
211+
}
212+
data := line.Message{
213+
ContentType: int(ContentText),
214+
ContentMetadata: map[string]string{
215+
"MENTION": `{"MENTIONEES":[{"A":"1","S":"5","E":"14"}]}`,
216+
},
217+
}
218+
219+
converted, err := lc.convertLineMessage(t.Context(), nil, nil, data, text, text, false)
220+
if err != nil {
221+
t.Fatalf("convertLineMessage returned error: %v", err)
222+
}
223+
if converted == nil || len(converted.Parts) != 1 || converted.Parts[0].Content == nil {
224+
t.Fatalf("convertLineMessage returned %#v, want one message part", converted)
225+
}
226+
content := converted.Parts[0].Content
227+
if content.Body != "🙂 嗨 @room!" {
228+
t.Fatalf("Body = %q, want room mention replacement", content.Body)
229+
}
230+
if content.Mentions == nil || !content.Mentions.Room {
231+
t.Fatalf("Mentions = %#v, want room mention", content.Mentions)
232+
}
233+
expectedLink := `<a href="https://matrix.to/#/@room">@everyone</a>`
234+
if !strings.Contains(content.FormattedBody, expectedLink) {
235+
t.Fatalf("FormattedBody = %q, want link %q", content.FormattedBody, expectedLink)
236+
}
237+
}
238+
167239
func TestDecryptMessageBodySkipsGeneratedFallbackWhenDecryptUnavailable(t *testing.T) {
168240
msg := &line.Message{
169241
Text: "",

pkg/connector/send_message.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -963,11 +963,15 @@ func (lc *LineClient) buildMentionMetadata(ctx context.Context, body, formattedB
963963
}
964964
}
965965
if pos >= 0 {
966-
mentionees = append(mentionees, mentionEntry{
967-
S: strconv.Itoa(pos),
968-
E: strconv.Itoa(pos + matchLen),
969-
A: "1",
970-
})
966+
start, startOK := byteIndexToUTF16Offset(body, pos)
967+
end, endOK := byteIndexToUTF16Offset(body, pos+matchLen)
968+
if startOK && endOK {
969+
mentionees = append(mentionees, mentionEntry{
970+
S: strconv.Itoa(start),
971+
E: strconv.Itoa(end),
972+
A: "1",
973+
})
974+
}
971975
}
972976
}
973977

@@ -1002,11 +1006,15 @@ func (lc *LineClient) buildMentionMetadata(ctx context.Context, body, formattedB
10021006
searchStr := "@" + displayName
10031007
pos := strings.Index(body, searchStr)
10041008
if pos >= 0 {
1005-
mentionees = append(mentionees, mentionEntry{
1006-
S: strconv.Itoa(pos),
1007-
E: strconv.Itoa(pos + len(searchStr)),
1008-
M: string(mid),
1009-
})
1009+
start, startOK := byteIndexToUTF16Offset(body, pos)
1010+
end, endOK := byteIndexToUTF16Offset(body, pos+len(searchStr))
1011+
if startOK && endOK {
1012+
mentionees = append(mentionees, mentionEntry{
1013+
S: strconv.Itoa(start),
1014+
E: strconv.Itoa(end),
1015+
M: string(mid),
1016+
})
1017+
}
10101018
}
10111019
}
10121020

pkg/connector/send_message_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,83 @@
11
package connector
22

33
import (
4+
"encoding/json"
45
"errors"
56
"fmt"
67
"testing"
78

89
"maunium.net/go/mautrix/bridgev2"
10+
"maunium.net/go/mautrix/bridgev2/networkid"
911
"maunium.net/go/mautrix/event"
12+
"maunium.net/go/mautrix/id"
1013

1114
"github.com/highesttt/matrix-line-messenger/pkg/e2ee"
1215
"github.com/highesttt/matrix-line-messenger/pkg/line"
1316
)
1417

18+
type mentionTestMatrix struct {
19+
bridgev2.MatrixConnector
20+
ghosts map[id.UserID]networkid.UserID
21+
}
22+
23+
func (matrix *mentionTestMatrix) ParseGhostMXID(userID id.UserID) (networkid.UserID, bool) {
24+
ghostID, ok := matrix.ghosts[userID]
25+
return ghostID, ok
26+
}
27+
28+
func TestBuildMentionMetadataUsesUTF16Offsets(t *testing.T) {
29+
userIDs := []id.UserID{
30+
"@line_zhang:example.com",
31+
"@line_kik:example.com",
32+
"@line_alice:example.com",
33+
}
34+
matrix := &mentionTestMatrix{ghosts: map[id.UserID]networkid.UserID{
35+
userIDs[0]: "u-zhang",
36+
userIDs[1]: "u-kik",
37+
userIDs[2]: "u-alice",
38+
}}
39+
lc := &LineClient{UserLogin: &bridgev2.UserLogin{
40+
Bridge: &bridgev2.Bridge{Matrix: matrix},
41+
}}
42+
body := "🙂 @张三 @กิ๊ก @Alice"
43+
formattedBody := `🙂 <a href="https://matrix.to/#/@line_zhang:example.com">张三</a> ` +
44+
`<a href="https://matrix.to/#/@line_kik:example.com">กิ๊ก</a> ` +
45+
`<a href="https://matrix.to/#/@line_alice:example.com">Alice</a>`
46+
47+
metadata := lc.buildMentionMetadata(t.Context(), body, formattedBody, &event.Mentions{UserIDs: userIDs})
48+
var payload struct {
49+
Mentionees []mentionEntry `json:"MENTIONEES"`
50+
}
51+
if err := json.Unmarshal([]byte(metadata["MENTION"]), &payload); err != nil {
52+
t.Fatalf("failed to unmarshal MENTION metadata: %v", err)
53+
}
54+
55+
expected := []mentionEntry{
56+
{S: "3", E: "6", M: "u-zhang"},
57+
{S: "7", E: "12", M: "u-kik"},
58+
{S: "13", E: "19", M: "u-alice"},
59+
}
60+
if fmt.Sprintf("%#v", payload.Mentionees) != fmt.Sprintf("%#v", expected) {
61+
t.Fatalf("MENTIONEES = %#v, want %#v", payload.Mentionees, expected)
62+
}
63+
}
64+
65+
func TestBuildRoomMentionMetadataUsesUTF16Offsets(t *testing.T) {
66+
lc := &LineClient{}
67+
metadata := lc.buildMentionMetadata(t.Context(), "你好🙂 @room", "", &event.Mentions{Room: true})
68+
var payload struct {
69+
Mentionees []mentionEntry `json:"MENTIONEES"`
70+
}
71+
if err := json.Unmarshal([]byte(metadata["MENTION"]), &payload); err != nil {
72+
t.Fatalf("failed to unmarshal MENTION metadata: %v", err)
73+
}
74+
75+
expected := []mentionEntry{{S: "5", E: "10", A: "1"}}
76+
if fmt.Sprintf("%#v", payload.Mentionees) != fmt.Sprintf("%#v", expected) {
77+
t.Fatalf("MENTIONEES = %#v, want %#v", payload.Mentionees, expected)
78+
}
79+
}
80+
1581
func TestLineGroupE2EEReconnectRequiredError(t *testing.T) {
1682
err := lineGroupE2EEReconnectRequiredError(fmt.Errorf("failed to unwrap group key: %w for 5625926", e2ee.ErrMissingOwnPrivateKey))
1783
if err == nil {

pkg/connector/text_offsets.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package connector
2+
3+
import (
4+
"strconv"
5+
"unicode/utf8"
6+
)
7+
8+
func resolveMentionRange(text, startOffset, endOffset string) (int, int, bool) {
9+
startUTF16, err := strconv.Atoi(startOffset)
10+
if err != nil {
11+
return 0, 0, false
12+
}
13+
endUTF16, err := strconv.Atoi(endOffset)
14+
if err != nil || endUTF16 <= startUTF16 {
15+
return 0, 0, false
16+
}
17+
18+
start, startOK := utf16OffsetToByteIndex(text, startUTF16)
19+
end, endOK := utf16OffsetToByteIndex(text, endUTF16)
20+
return start, end, startOK && endOK && end > start
21+
}
22+
23+
func utf16OffsetToByteIndex(text string, offset int) (int, bool) {
24+
if offset < 0 {
25+
return 0, false
26+
}
27+
28+
codeUnits := 0
29+
for byteIndex := 0; byteIndex < len(text); {
30+
if codeUnits == offset {
31+
return byteIndex, true
32+
}
33+
34+
r, size := utf8.DecodeRuneInString(text[byteIndex:])
35+
if r <= 0xFFFF {
36+
codeUnits++
37+
} else {
38+
codeUnits += 2
39+
}
40+
byteIndex += size
41+
42+
if codeUnits > offset {
43+
return 0, false
44+
}
45+
if codeUnits == offset {
46+
return byteIndex, true
47+
}
48+
}
49+
50+
return len(text), codeUnits == offset
51+
}
52+
53+
func byteIndexToUTF16Offset(text string, byteIndex int) (int, bool) {
54+
if byteIndex < 0 || byteIndex > len(text) {
55+
return 0, false
56+
}
57+
58+
codeUnits := 0
59+
for currentByteIndex := 0; currentByteIndex < len(text); {
60+
if currentByteIndex == byteIndex {
61+
return codeUnits, true
62+
}
63+
if currentByteIndex > byteIndex {
64+
return 0, false
65+
}
66+
67+
r, size := utf8.DecodeRuneInString(text[currentByteIndex:])
68+
if r <= 0xFFFF {
69+
codeUnits++
70+
} else {
71+
codeUnits += 2
72+
}
73+
currentByteIndex += size
74+
}
75+
76+
return codeUnits, byteIndex == len(text)
77+
}

0 commit comments

Comments
 (0)