Skip to content

Commit e2d3386

Browse files
authored
fix: backfilling system events and reactions (#224)
* fix: backfilling system events and reactions * fix: deprecated function * fix: indent comments
1 parent 6329262 commit e2d3386

10 files changed

Lines changed: 711 additions & 177 deletions

File tree

pkg/connector/client.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ type LineClient struct {
7979
generatedGroupNameCache map[string]bool // chatMid -> true when Matrix name should be generated from member names
8080
knownMemberChatMIDs map[string]struct{} // chatMid -> current member chats returned by getAllChatMids
8181
reactionIconMXC map[int]string // predefinedReactionType -> cached MXC URI
82+
paidReactionIconMXC map[string]string // LINE sticon URL -> cached MXC URI
8283
recentReactions sync.Map // "msgID\x00emoji" -> struct{} to dedup concurrent 139/140 events
8384
unblockBackfills sync.Map // chat MID -> *unblockBackfillState while unblock history restoration is active
8485

@@ -651,8 +652,7 @@ func (lc *LineClient) Connect(ctx context.Context) {
651652
// deliver messages. Otherwise an existing LINE group whose Matrix room
652653
// doesn't exist yet may be created by the first message, which makes the
653654
// sender's existing membership look like a fresh join.
654-
lc.wg.Add(1)
655-
lc.syncChats(ctx)
655+
lc.syncChatsNow(ctx)
656656
if ctx.Err() != nil {
657657
return
658658
}

pkg/connector/consts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const (
3838
ContentContact ContentType = 13
3939
ContentFile ContentType = 14
4040
ContentLocation ContentType = 15
41+
ContentSystem ContentType = 18
4142
ContentFlex ContentType = 22
4243
)
4344

pkg/connector/handle_message.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func (lc *LineClient) getChatInfoForIncomingMessage(ctx context.Context, portal
113113
return info, nil
114114
}
115115

116-
func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) {
116+
func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) bool {
117117
// Only process known content types; skip system messages (group created, member invited, etc.)
118118
if !isBridgeableContentType(msg) {
119119
lc.UserLogin.Bridge.Log.Debug().
@@ -123,7 +123,7 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) {
123123
Str("text", msg.Text).
124124
Int("chunk_count", len(msg.Chunks)).
125125
Msg("Skipping unsupported content type")
126-
return
126+
return false
127127
}
128128

129129
portalIDStr := portalMIDForMessage(msg, opType)
@@ -164,7 +164,8 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) {
164164
}
165165
}
166166

167-
lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, remoteEvent)
167+
result := lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, remoteEvent)
168+
return result.Success && !result.Ignored
168169
}
169170

170171
// isBridgeableContentType reports whether an inbound LINE message should be

pkg/connector/reaction.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
78
"net/url"
89
"slices"
910
"strconv"
@@ -15,6 +16,7 @@ import (
1516
"maunium.net/go/mautrix/bridgev2"
1617
"maunium.net/go/mautrix/bridgev2/database"
1718
"maunium.net/go/mautrix/bridgev2/networkid"
19+
"maunium.net/go/mautrix/bridgev2/simplevent"
1820
"maunium.net/go/mautrix/event"
1921

2022
"github.com/highesttt/matrix-line-messenger/pkg/line"
@@ -201,6 +203,201 @@ func lineSticonURL(productID, emojiID string) string {
201203
return fmt.Sprintf("https://stickershop.line-scdn.net/sticonshop/v1/sticon/%s/android/%s.png", productID, emojiID)
202204
}
203205

206+
func reactionUploadMXC(uploadedMXC string, uploadedFile *event.EncryptedFileInfo) (string, error) {
207+
if uploadedFile != nil {
208+
return "", errors.New("reaction icon upload returned encrypted media")
209+
}
210+
if uploadedMXC == "" {
211+
return "", errors.New("reaction icon upload returned an empty MXC URI")
212+
}
213+
return uploadedMXC, nil
214+
}
215+
216+
func (lc *LineClient) getPredefinedReactionMXC(ctx context.Context, prt int) (string, error) {
217+
if _, ok := line.PredefinedReactionEmoji[prt]; !ok {
218+
return "", fmt.Errorf("unknown predefined reaction type %d", prt)
219+
}
220+
221+
lc.cacheMu.Lock()
222+
mxc := lc.reactionIconMXC[prt]
223+
lc.cacheMu.Unlock()
224+
if mxc != "" {
225+
return mxc, nil
226+
}
227+
228+
pngData, err := getReactionIconData(prt)
229+
if err != nil {
230+
return "", fmt.Errorf("get reaction icon data: %w", err)
231+
}
232+
uploadedMXC, uploadedFile, err := lc.UserLogin.Bridge.Bot.UploadMedia(ctx, "", pngData, "reaction.png", "image/png")
233+
if err != nil {
234+
return "", fmt.Errorf("upload reaction icon: %w", err)
235+
}
236+
mxc, err = reactionUploadMXC(string(uploadedMXC), uploadedFile)
237+
if err != nil {
238+
return "", err
239+
}
240+
241+
lc.cacheMu.Lock()
242+
if lc.reactionIconMXC == nil {
243+
lc.reactionIconMXC = make(map[int]string)
244+
}
245+
if cached := lc.reactionIconMXC[prt]; cached != "" {
246+
mxc = cached
247+
} else {
248+
lc.reactionIconMXC[prt] = mxc
249+
}
250+
lc.cacheMu.Unlock()
251+
return mxc, nil
252+
}
253+
254+
func (lc *LineClient) getPaidReactionMXC(ctx context.Context, prt *line.PaidReactionType) (string, error) {
255+
if prt == nil || prt.ProductID == "" || prt.EmojiID == "" {
256+
return "", errors.New("paid reaction is missing product or emoji ID")
257+
}
258+
iconURL := lineSticonURL(prt.ProductID, prt.EmojiID)
259+
260+
lc.cacheMu.Lock()
261+
mxc := lc.paidReactionIconMXC[iconURL]
262+
lc.cacheMu.Unlock()
263+
if mxc != "" {
264+
return mxc, nil
265+
}
266+
267+
resp, err := lc.HTTPClient.Get(iconURL)
268+
if err != nil {
269+
return "", fmt.Errorf("download paid reaction icon: %w", err)
270+
}
271+
defer resp.Body.Close()
272+
if resp.StatusCode != 200 {
273+
return "", fmt.Errorf("download paid reaction icon: HTTP %d", resp.StatusCode)
274+
}
275+
data, err := io.ReadAll(resp.Body)
276+
if err != nil {
277+
return "", fmt.Errorf("read paid reaction icon: %w", err)
278+
}
279+
mimeType := resp.Header.Get("Content-Type")
280+
if mimeType == "" {
281+
mimeType = "image/png"
282+
}
283+
uploadedMXC, uploadedFile, err := lc.UserLogin.Bridge.Bot.UploadMedia(ctx, "", data, "reaction.png", mimeType)
284+
if err != nil {
285+
return "", fmt.Errorf("upload paid reaction icon: %w", err)
286+
}
287+
mxc, err = reactionUploadMXC(string(uploadedMXC), uploadedFile)
288+
if err != nil {
289+
return "", fmt.Errorf("paid %w", err)
290+
}
291+
292+
lc.cacheMu.Lock()
293+
if lc.paidReactionIconMXC == nil {
294+
lc.paidReactionIconMXC = make(map[string]string)
295+
}
296+
if cached := lc.paidReactionIconMXC[iconURL]; cached != "" {
297+
mxc = cached
298+
} else {
299+
lc.paidReactionIconMXC[iconURL] = mxc
300+
}
301+
lc.cacheMu.Unlock()
302+
return mxc, nil
303+
}
304+
305+
func (lc *LineClient) convertMessageReactions(ctx context.Context, msg *line.Message) ([]*bridgev2.BackfillReaction, bool) {
306+
if msg == nil || msg.Reactions == nil {
307+
return nil, false
308+
}
309+
310+
converted := make([]*bridgev2.BackfillReaction, 0, len(msg.Reactions))
311+
complete := true
312+
for _, reaction := range msg.Reactions {
313+
if !isUserMID(reaction.FromUserMID) {
314+
complete = false
315+
lc.UserLogin.Bridge.Log.Warn().
316+
Str("msg_id", msg.ID).
317+
Str("reaction_sender", reaction.FromUserMID).
318+
Msg("Skipping historical reaction without a valid sender MID")
319+
continue
320+
}
321+
322+
var (
323+
mxc string
324+
err error
325+
)
326+
switch {
327+
case reaction.ReactionType.PaidReactionType != nil:
328+
mxc, err = lc.getPaidReactionMXC(ctx, reaction.ReactionType.PaidReactionType)
329+
case reaction.ReactionType.PredefinedReactionType != 0:
330+
mxc, err = lc.getPredefinedReactionMXC(ctx, reaction.ReactionType.PredefinedReactionType)
331+
default:
332+
err = errors.New("reaction type is missing")
333+
}
334+
if err != nil {
335+
complete = false
336+
lc.UserLogin.Bridge.Log.Warn().
337+
Err(err).
338+
Str("msg_id", msg.ID).
339+
Str("reaction_sender", reaction.FromUserMID).
340+
Msg("Skipping unsupported historical reaction")
341+
continue
342+
}
343+
344+
var timestamp time.Time
345+
if timestampMillis, err := reaction.AtMillis.Int64(); err == nil && timestampMillis > 0 {
346+
timestamp = time.UnixMilli(timestampMillis)
347+
}
348+
converted = append(converted, &bridgev2.BackfillReaction{
349+
Timestamp: timestamp,
350+
Sender: lc.eventSenderForMID(reaction.FromUserMID),
351+
Emoji: mxc,
352+
})
353+
}
354+
return converted, complete
355+
}
356+
357+
func (lc *LineClient) queueMessageReactionSync(ctx context.Context, chatMID string, msg *line.Message) bool {
358+
if msg == nil || ContentType(msg.ContentType) == ContentSystem || msg.Reactions == nil {
359+
return false
360+
}
361+
362+
converted, complete := lc.convertMessageReactions(ctx, msg)
363+
if !complete {
364+
// The embedded list is authoritative, but an upload/parse failure
365+
// means our converted view is incomplete. Do not accidentally redact
366+
// reactions that LINE still reports.
367+
return false
368+
}
369+
users := make(map[networkid.UserID]*bridgev2.ReactionSyncUser, len(converted))
370+
var timestamp time.Time
371+
for _, reaction := range converted {
372+
if reaction.Timestamp.After(timestamp) {
373+
timestamp = reaction.Timestamp
374+
}
375+
// LINE allows one reaction per user per message. Keeping the last
376+
// record also makes malformed duplicate entries deterministic.
377+
users[reaction.Sender.Sender] = &bridgev2.ReactionSyncUser{
378+
Reactions: []*bridgev2.BackfillReaction{reaction},
379+
HasAllReactions: true,
380+
}
381+
}
382+
if timestamp.IsZero() {
383+
timestamp = lc.parseMessageTimestamp(msg)
384+
}
385+
386+
result := lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.ReactionSync{
387+
EventMeta: simplevent.EventMeta{
388+
Type: bridgev2.RemoteEventReactionSync,
389+
PortalKey: networkid.PortalKey{ID: makePortalID(chatMID), Receiver: lc.UserLogin.ID},
390+
Timestamp: timestamp,
391+
},
392+
TargetMessage: networkid.MessageID(msg.ID),
393+
Reactions: &bridgev2.ReactionSyncData{
394+
Users: users,
395+
HasAllUsers: true,
396+
},
397+
})
398+
return result.Success && !result.Ignored
399+
}
400+
204401
func parseLineSticonURL(rawURL string) (linePaidReactionRef, error) {
205402
parsed, err := url.Parse(rawURL)
206403
if err != nil {

pkg/connector/reaction_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package connector
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"slices"
78
"testing"
89
"time"
910

11+
"github.com/rs/zerolog"
12+
1013
"maunium.net/go/mautrix/bridgev2"
1114
"maunium.net/go/mautrix/bridgev2/database"
1215
"maunium.net/go/mautrix/bridgev2/networkid"
@@ -516,6 +519,90 @@ func TestEventSenderForMIDMarksOwnAccount(t *testing.T) {
516519
}
517520
}
518521

522+
func TestConvertMessageReactionsUsesEmbeddedHistory(t *testing.T) {
523+
paidType := &line.PaidReactionType{
524+
ProductID: "paid-product",
525+
EmojiID: "paid-emoji",
526+
}
527+
lc := &LineClient{
528+
Mid: "Uself",
529+
UserLogin: &bridgev2.UserLogin{
530+
UserLogin: &database.UserLogin{ID: "Uself"},
531+
Bridge: &bridgev2.Bridge{Log: zerolog.Nop()},
532+
},
533+
reactionIconMXC: map[int]string{
534+
2: "mxc://line/like",
535+
},
536+
paidReactionIconMXC: map[string]string{
537+
lineSticonURL(paidType.ProductID, paidType.EmojiID): "mxc://line/paid",
538+
},
539+
}
540+
msg := &line.Message{
541+
ID: "message-id",
542+
Reactions: []line.MessageReaction{
543+
{
544+
FromUserMID: "Uself",
545+
AtMillis: json.Number("1784930400123"),
546+
ReactionType: line.ReactionType{
547+
PredefinedReactionType: 2,
548+
},
549+
},
550+
{
551+
FromUserMID: "Uother",
552+
AtMillis: json.Number("1784930400456"),
553+
ReactionType: line.ReactionType{PaidReactionType: paidType},
554+
},
555+
},
556+
}
557+
558+
reactions, complete := lc.convertMessageReactions(context.Background(), msg)
559+
if !complete {
560+
t.Fatal("reaction conversion was unexpectedly incomplete")
561+
}
562+
if len(reactions) != 2 {
563+
t.Fatalf("reaction count = %d, want 2", len(reactions))
564+
}
565+
if reactions[0].Emoji != "mxc://line/like" || reactions[0].Sender.Sender != "Uself" || !reactions[0].Sender.IsFromMe {
566+
t.Fatalf("predefined reaction = %#v", reactions[0])
567+
}
568+
if want := time.UnixMilli(1784930400123); !reactions[0].Timestamp.Equal(want) {
569+
t.Fatalf("predefined reaction timestamp = %s, want %s", reactions[0].Timestamp, want)
570+
}
571+
if reactions[1].Emoji != "mxc://line/paid" || reactions[1].Sender.Sender != "Uother" || reactions[1].Sender.IsFromMe {
572+
t.Fatalf("paid reaction = %#v", reactions[1])
573+
}
574+
}
575+
576+
func TestReactionUploadMXCRejectsEncryptedMedia(t *testing.T) {
577+
if mxc, err := reactionUploadMXC("mxc://line/plain", nil); err != nil || mxc != "mxc://line/plain" {
578+
t.Fatalf("plain upload = %q, %v", mxc, err)
579+
}
580+
if _, err := reactionUploadMXC("mxc://line/encrypted", &event.EncryptedFileInfo{}); err == nil {
581+
t.Fatal("encrypted upload was accepted without decryption metadata")
582+
}
583+
if _, err := reactionUploadMXC("", nil); err == nil {
584+
t.Fatal("empty upload was accepted")
585+
}
586+
}
587+
588+
func TestQueueMessageReactionSyncSkipsSystemMarkers(t *testing.T) {
589+
lc := &LineClient{}
590+
msg := &line.Message{
591+
ID: "system-message",
592+
ContentType: int(ContentSystem),
593+
Reactions: []line.MessageReaction{{
594+
FromUserMID: "Ureactor",
595+
ReactionType: line.ReactionType{
596+
PredefinedReactionType: 2,
597+
},
598+
}},
599+
}
600+
601+
if lc.queueMessageReactionSync(context.Background(), "Cgroup", msg) {
602+
t.Fatal("system-message marker unexpectedly queued a reaction sync")
603+
}
604+
}
605+
519606
func TestResolveReactionSenderMID(t *testing.T) {
520607
lc := &LineClient{
521608
UserLogin: &bridgev2.UserLogin{

0 commit comments

Comments
 (0)