diff --git a/pkg/connector/client.go b/pkg/connector/client.go index ee0c355..4415b17 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -80,7 +80,6 @@ type LineClient struct { knownMemberChatMIDs map[string]struct{} // chatMid -> current member chats returned by getAllChatMids reactionIconMXC map[int]string // predefinedReactionType -> cached MXC URI paidReactionIconMXC map[string]string // LINE sticon URL -> cached MXC URI - recentReactions sync.Map // "msgID\x00emoji" -> struct{} to dedup concurrent 139/140 events unblockBackfills sync.Map // chat MID -> *unblockBackfillState while unblock history restoration is active wg sync.WaitGroup diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 0fdced6..54cd1cf 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -99,10 +99,12 @@ func (lc *LineConnector) GetConfig() (example string, data any, upgrader configu func (lc *LineConnector) GetDBMetaTypes() database.MetaTypes { return database.MetaTypes{ - Portal: nil, - Ghost: nil, - Message: nil, - Reaction: nil, + Portal: nil, + Ghost: nil, + Message: nil, + Reaction: func() any { + return &ReactionMetadata{} + }, UserLogin: func() any { return &UserLoginMetadata{} }, diff --git a/pkg/connector/reaction.go b/pkg/connector/reaction.go index b9f2b8b..6e210a8 100644 --- a/pkg/connector/reaction.go +++ b/pkg/connector/reaction.go @@ -36,8 +36,13 @@ type linePaidReactionRef struct { Version int } -func (ref linePaidReactionRef) networkEmojiID() networkid.EmojiID { - return networkid.EmojiID("paid:" + ref.ProductID + ":" + ref.EmojiID) +type lineReactionRef struct { + typ line.ReactionType +} + +type ReactionMetadata struct { + MatrixKey string `json:"matrix_key,omitempty"` + ReactionType line.ReactionType `json:"reaction_type"` } func (ref linePaidReactionRef) reactionType() line.ReactionType { @@ -51,6 +56,61 @@ func (ref linePaidReactionRef) reactionType() line.ReactionType { } } +func cloneLineReactionType(typ line.ReactionType) line.ReactionType { + cloned := line.ReactionType{ + PredefinedReactionType: typ.PredefinedReactionType, + } + if typ.PaidReactionType != nil { + paid := *typ.PaidReactionType + cloned.PaidReactionType = &paid + } + return cloned +} + +func newLineReactionRef(typ line.ReactionType) (lineReactionRef, error) { + hasPredefined := typ.PredefinedReactionType != 0 + hasPaid := typ.PaidReactionType != nil + if hasPredefined == hasPaid { + return lineReactionRef{}, errors.New("reaction type must contain exactly one predefined or paid reaction") + } + if hasPredefined { + if _, ok := line.PredefinedReactionEmoji[typ.PredefinedReactionType]; !ok { + return lineReactionRef{}, fmt.Errorf("unknown predefined reaction type %d", typ.PredefinedReactionType) + } + } else if typ.PaidReactionType.ProductID == "" || typ.PaidReactionType.EmojiID == "" { + return lineReactionRef{}, errors.New("paid reaction is missing product or emoji ID") + } + return lineReactionRef{typ: cloneLineReactionType(typ)}, nil +} + +func (ref lineReactionRef) reactionType() line.ReactionType { + return cloneLineReactionType(ref.typ) +} + +func (ref lineReactionRef) networkEmojiID() networkid.EmojiID { + if ref.typ.PaidReactionType != nil { + return networkid.EmojiID("paid:" + ref.typ.PaidReactionType.ProductID + ":" + ref.typ.PaidReactionType.EmojiID) + } + return networkid.EmojiID("predefined:" + strconv.Itoa(ref.typ.PredefinedReactionType)) +} + +func (ref lineReactionRef) equal(other lineReactionRef) bool { + if ref.typ.PredefinedReactionType != other.typ.PredefinedReactionType { + return false + } + if ref.typ.PaidReactionType == nil || other.typ.PaidReactionType == nil { + return ref.typ.PaidReactionType == nil && other.typ.PaidReactionType == nil + } + return *ref.typ.PaidReactionType == *other.typ.PaidReactionType +} + +func (ref lineReactionRef) metadata(matrixKey string) *ReactionMetadata { + return &ReactionMetadata{ + MatrixKey: matrixKey, + ReactionType: ref.reactionType(), + } +} + // These are the LINE emoji/sticon URLs from the issue's pack-based reaction // set. Add more entries here as more Matrix emoji -> LINE CDN URL mappings are // captured. @@ -302,6 +362,36 @@ func (lc *LineClient) getPaidReactionMXC(ctx context.Context, prt *line.PaidReac return mxc, nil } +func (lc *LineClient) convertReaction( + ctx context.Context, + typ line.ReactionType, + sender bridgev2.EventSender, + timestamp time.Time, +) (*bridgev2.BackfillReaction, error) { + ref, err := newLineReactionRef(typ) + if err != nil { + return nil, err + } + + var mxc string + if ref.typ.PaidReactionType != nil { + mxc, err = lc.getPaidReactionMXC(ctx, ref.typ.PaidReactionType) + } else { + mxc, err = lc.getPredefinedReactionMXC(ctx, ref.typ.PredefinedReactionType) + } + if err != nil { + return nil, err + } + + return &bridgev2.BackfillReaction{ + Timestamp: timestamp, + Sender: sender, + EmojiID: ref.networkEmojiID(), + Emoji: mxc, + DBMetadata: ref.metadata(mxc), + }, nil +} + func (lc *LineClient) convertMessageReactions(ctx context.Context, msg *line.Message) ([]*bridgev2.BackfillReaction, bool) { if msg == nil || msg.Reactions == nil { return nil, false @@ -319,18 +409,16 @@ func (lc *LineClient) convertMessageReactions(ctx context.Context, msg *line.Mes continue } - var ( - mxc string - err error - ) - switch { - case reaction.ReactionType.PaidReactionType != nil: - mxc, err = lc.getPaidReactionMXC(ctx, reaction.ReactionType.PaidReactionType) - case reaction.ReactionType.PredefinedReactionType != 0: - mxc, err = lc.getPredefinedReactionMXC(ctx, reaction.ReactionType.PredefinedReactionType) - default: - err = errors.New("reaction type is missing") + var timestamp time.Time + if timestampMillis, err := reaction.AtMillis.Int64(); err == nil && timestampMillis > 0 { + timestamp = time.UnixMilli(timestampMillis) } + convertedReaction, err := lc.convertReaction( + ctx, + reaction.ReactionType, + lc.eventSenderForMID(reaction.FromUserMID), + timestamp, + ) if err != nil { complete = false lc.UserLogin.Bridge.Log.Warn(). @@ -340,16 +428,7 @@ func (lc *LineClient) convertMessageReactions(ctx context.Context, msg *line.Mes Msg("Skipping unsupported historical reaction") continue } - - var timestamp time.Time - if timestampMillis, err := reaction.AtMillis.Int64(); err == nil && timestampMillis > 0 { - timestamp = time.UnixMilli(timestampMillis) - } - converted = append(converted, &bridgev2.BackfillReaction{ - Timestamp: timestamp, - Sender: lc.eventSenderForMID(reaction.FromUserMID), - Emoji: mxc, - }) + converted = append(converted, convertedReaction) } return converted, complete } @@ -457,6 +536,61 @@ func linePaidReactionForMatrixEmoji(key string) (linePaidReactionRef, bool) { return ref, true } +func storedLineReactionForMatrixKey(key string, reactions []*database.Reaction) (lineReactionRef, bool) { + var ( + found lineReactionRef + hasFound bool + ) + for _, reaction := range reactions { + meta, ok := reaction.Metadata.(*ReactionMetadata) + if !ok || meta == nil || meta.MatrixKey != key { + continue + } + ref, err := newLineReactionRef(meta.ReactionType) + if err != nil || (reaction.EmojiID != "" && reaction.EmojiID != ref.networkEmojiID()) { + continue + } + if hasFound && !found.equal(ref) { + return lineReactionRef{}, false + } + found = ref + hasFound = true + } + return found, hasFound +} + +func (lc *LineClient) resolveMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (lineReactionRef, error) { + key := msg.Content.RelatesTo.GetAnnotationKey() + if paidRef, ok := linePaidReactionForMatrixEmoji(key); ok { + ref, err := newLineReactionRef(paidRef.reactionType()) + if err != nil { + return lineReactionRef{}, err + } + return ref, nil + } + if !strings.HasPrefix(key, "mxc://") { + return lineReactionRef{}, unsupportedMatrixReactionError(key) + } + if msg.TargetMessage == nil || msg.Portal == nil || msg.Portal.Bridge == nil || msg.Portal.Bridge.DB == nil { + return lineReactionRef{}, errors.New("reaction target database context is missing") + } + + reactions, err := msg.Portal.Bridge.DB.Reaction.GetAllToMessagePart( + ctx, + msg.Portal.Receiver, + msg.TargetMessage.ID, + msg.TargetMessage.PartID, + ) + if err != nil { + return lineReactionRef{}, fmt.Errorf("get target message reactions: %w", err) + } + ref, ok := storedLineReactionForMatrixKey(key, reactions) + if !ok { + return lineReactionRef{}, unsupportedMatrixReactionError(key) + } + return ref, nil +} + func unsupportedMatrixReactionError(key string) error { return bridgev2.WrapErrorInStatus(fmt.Errorf("LINE does not support Matrix reaction %q", key)). WithStatus(event.MessageStatusFail). @@ -572,9 +706,9 @@ func (lc *LineClient) consumeSentReqSeq(reqSeq int) bool { func (lc *LineClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { key := msg.Content.RelatesTo.GetAnnotationKey() - ref, ok := linePaidReactionForMatrixEmoji(key) - if !ok { - return bridgev2.MatrixReactionPreResponse{}, unsupportedMatrixReactionError(key) + ref, err := lc.resolveMatrixReaction(ctx, msg) + if err != nil { + return bridgev2.MatrixReactionPreResponse{}, err } return bridgev2.MatrixReactionPreResponse{ SenderID: makeUserID(string(lc.UserLogin.ID)), @@ -586,9 +720,9 @@ func (lc *LineClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2 func (lc *LineClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (*database.Reaction, error) { key := msg.Content.RelatesTo.GetAnnotationKey() - ref, ok := linePaidReactionForMatrixEmoji(key) - if !ok { - return nil, unsupportedMatrixReactionError(key) + ref, err := lc.resolveMatrixReaction(ctx, msg) + if err != nil { + return nil, err } targetID, err := parseReactionTargetMessageID(msg.TargetMessage.ID) if err != nil { @@ -610,8 +744,9 @@ func (lc *LineClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.Ma } return &database.Reaction{ - EmojiID: ref.networkEmojiID(), - Emoji: key, + EmojiID: ref.networkEmojiID(), + Emoji: key, + Metadata: ref.metadata(key), }, nil } diff --git a/pkg/connector/reaction_test.go b/pkg/connector/reaction_test.go index 7746760..5bb86ea 100644 --- a/pkg/connector/reaction_test.go +++ b/pkg/connector/reaction_test.go @@ -31,6 +31,9 @@ func TestCapabilitiesAdvertiseSupportedReactions(t *testing.T) { if caps.ReactionCount != 1 { t.Fatalf("ReactionCount = %d, want 1", caps.ReactionCount) } + if caps.CustomEmojiReactions { + t.Fatal("CustomEmojiReactions must stay disabled because arbitrary Matrix custom emojis are unsupported") + } if len(caps.AllowedReactions) != len(lineEmojiReactionURLs) { t.Fatalf("AllowedReactions has %d entries, want %d", len(caps.AllowedReactions), len(lineEmojiReactionURLs)) } @@ -431,6 +434,170 @@ func TestLinePaidReactionForMatrixEmoji(t *testing.T) { } } +func TestLineReactionRefIdentityAndMetadata(t *testing.T) { + paidType := line.ReactionType{PaidReactionType: &line.PaidReactionType{ + ProductID: "product", + EmojiID: "emoji", + ResourceType: 2, + Version: 7, + }} + paidRef, err := newLineReactionRef(paidType) + if err != nil { + t.Fatal(err) + } + if paidRef.networkEmojiID() != "paid:product:emoji" { + t.Fatalf("paid EmojiID = %q", paidRef.networkEmojiID()) + } + + meta := paidRef.metadata("mxc://line/custom") + paidType.PaidReactionType.Version = 99 + if meta.MatrixKey != "mxc://line/custom" || meta.ReactionType.PaidReactionType.Version != 7 { + t.Fatalf("paid metadata = %#v", meta) + } + + raw, err := json.Marshal(meta) + if err != nil { + t.Fatal(err) + } + var decoded ReactionMetadata + if err = json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + decodedRef, err := newLineReactionRef(decoded.ReactionType) + if err != nil { + t.Fatal(err) + } + if !paidRef.equal(decodedRef) { + t.Fatalf("decoded ref = %#v, want %#v", decodedRef, paidRef) + } + + predefinedRef, err := newLineReactionRef(line.ReactionType{PredefinedReactionType: 2}) + if err != nil { + t.Fatal(err) + } + if predefinedRef.networkEmojiID() != "predefined:2" { + t.Fatalf("predefined EmojiID = %q", predefinedRef.networkEmojiID()) + } + + for _, invalid := range []line.ReactionType{ + {}, + {PredefinedReactionType: 1}, + {PaidReactionType: &line.PaidReactionType{}}, + { + PredefinedReactionType: 2, + PaidReactionType: &line.PaidReactionType{ProductID: "product", EmojiID: "emoji"}, + }, + } { + if _, err = newLineReactionRef(invalid); err == nil { + t.Fatalf("invalid reaction type was accepted: %#v", invalid) + } + } + + metaFactory := (&LineConnector{}).GetDBMetaTypes().Reaction + if metaFactory == nil { + t.Fatal("reaction metadata type is not registered") + } + if _, ok := metaFactory().(*ReactionMetadata); !ok { + t.Fatalf("reaction metadata factory returned %T", metaFactory()) + } +} + +func TestStoredLineReactionForMatrixKey(t *testing.T) { + key := "mxc://line/custom" + paidType := line.ReactionType{PaidReactionType: &line.PaidReactionType{ + ProductID: "product", + EmojiID: "emoji", + ResourceType: 2, + Version: 7, + }} + valid := &database.Reaction{ + EmojiID: "paid:product:emoji", + Metadata: &ReactionMetadata{ + MatrixKey: key, + ReactionType: paidType, + }, + } + same := &database.Reaction{ + EmojiID: "paid:product:emoji", + Metadata: &ReactionMetadata{ + MatrixKey: key, + ReactionType: cloneLineReactionType(paidType), + }, + } + unrelated := &database.Reaction{ + EmojiID: "predefined:2", + Metadata: &ReactionMetadata{ + MatrixKey: "mxc://line/other", + ReactionType: line.ReactionType{PredefinedReactionType: 2}, + }, + } + + ref, ok := storedLineReactionForMatrixKey(key, []*database.Reaction{valid, same, unrelated}) + if !ok || ref.networkEmojiID() != "paid:product:emoji" { + t.Fatalf("stored reaction = %#v, %v", ref, ok) + } + malformed := &database.Reaction{ + EmojiID: "predefined:999", + Metadata: &ReactionMetadata{ + MatrixKey: key, + ReactionType: line.ReactionType{PredefinedReactionType: 999}, + }, + } + mismatched := &database.Reaction{ + EmojiID: "paid:different:id", + Metadata: valid.Metadata, + } + ref, ok = storedLineReactionForMatrixKey(key, []*database.Reaction{malformed, mismatched, valid}) + if !ok || ref.networkEmojiID() != "paid:product:emoji" { + t.Fatalf("stored reaction after malformed rows = %#v, %v", ref, ok) + } + if _, ok = storedLineReactionForMatrixKey("mxc://line/arbitrary", []*database.Reaction{valid}); ok { + t.Fatal("arbitrary MXC was accepted") + } + if _, ok = storedLineReactionForMatrixKey(key, []*database.Reaction{{ + Emoji: key, + }}); ok { + t.Fatal("legacy reaction without metadata was accepted") + } + if _, ok = storedLineReactionForMatrixKey(key, []*database.Reaction{valid, { + EmojiID: "predefined:2", + Metadata: &ReactionMetadata{ + MatrixKey: key, + ReactionType: line.ReactionType{PredefinedReactionType: 2}, + }, + }}); ok { + t.Fatal("conflicting LINE reaction metadata was accepted") + } + if _, ok = storedLineReactionForMatrixKey(key, []*database.Reaction{{ + EmojiID: "paid:different:id", + Metadata: valid.Metadata, + }}); ok { + t.Fatal("reaction metadata with a mismatched stable ID was accepted") + } +} + +func TestPreHandleMatrixReactionKeepsUnicodeBehavior(t *testing.T) { + lc := &LineClient{UserLogin: &bridgev2.UserLogin{ + UserLogin: &database.UserLogin{ID: "Uself"}, + }} + msg := &bridgev2.MatrixReaction{ + MatrixEventBase: bridgev2.MatrixEventBase[*event.ReactionEventContent]{ + Content: &event.ReactionEventContent{RelatesTo: event.RelatesTo{ + Type: event.RelAnnotation, + Key: "\U0001F44D\uFE0F", + }}, + }, + } + + resp, err := lc.PreHandleMatrixReaction(context.Background(), msg) + if err != nil { + t.Fatal(err) + } + if resp.SenderID != "Uself" || resp.EmojiID == "" || resp.Emoji != "\U0001F44D\uFE0F" || resp.MaxReactions != 1 { + t.Fatalf("pre-handle response = %#v", resp) + } +} + func TestParseReactionTargetMessageID(t *testing.T) { messageID, err := parseReactionTargetMessageID(networkid.MessageID("616934195205767730")) if err != nil { @@ -565,12 +732,29 @@ func TestConvertMessageReactionsUsesEmbeddedHistory(t *testing.T) { if reactions[0].Emoji != "mxc://line/like" || reactions[0].Sender.Sender != "Uself" || !reactions[0].Sender.IsFromMe { t.Fatalf("predefined reaction = %#v", reactions[0]) } + if reactions[0].EmojiID != "predefined:2" { + t.Fatalf("predefined reaction EmojiID = %q", reactions[0].EmojiID) + } + predefinedMeta, ok := reactions[0].DBMetadata.(*ReactionMetadata) + if !ok || predefinedMeta.MatrixKey != reactions[0].Emoji || predefinedMeta.ReactionType.PredefinedReactionType != 2 { + t.Fatalf("predefined reaction metadata = %#v", reactions[0].DBMetadata) + } if want := time.UnixMilli(1784930400123); !reactions[0].Timestamp.Equal(want) { t.Fatalf("predefined reaction timestamp = %s, want %s", reactions[0].Timestamp, want) } if reactions[1].Emoji != "mxc://line/paid" || reactions[1].Sender.Sender != "Uother" || reactions[1].Sender.IsFromMe { t.Fatalf("paid reaction = %#v", reactions[1]) } + if reactions[1].EmojiID != "paid:paid-product:paid-emoji" { + t.Fatalf("paid reaction EmojiID = %q", reactions[1].EmojiID) + } + paidMeta, ok := reactions[1].DBMetadata.(*ReactionMetadata) + if !ok || paidMeta.MatrixKey != reactions[1].Emoji || + paidMeta.ReactionType.PaidReactionType == nil || + paidMeta.ReactionType.PaidReactionType.ProductID != paidType.ProductID || + paidMeta.ReactionType.PaidReactionType.EmojiID != paidType.EmojiID { + t.Fatalf("paid reaction metadata = %#v", reactions[1].DBMetadata) + } } func TestReactionUploadMXCRejectsEncryptedMedia(t *testing.T) { @@ -603,6 +787,45 @@ func TestQueueMessageReactionSyncSkipsSystemMarkers(t *testing.T) { } } +func TestLiveReactionSyncEventIsSenderAuthoritative(t *testing.T) { + lc := &LineClient{UserLogin: &bridgev2.UserLogin{ + UserLogin: &database.UserLogin{ID: "Uself"}, + }} + op := line.Operation{ + Param1: "616934195205767730", + CreatedTime: json.Number("1784930400123"), + } + reaction := &bridgev2.BackfillReaction{ + Sender: lc.eventSenderForMID("Uother"), + EmojiID: "paid:product:emoji", + Emoji: "mxc://line/custom", + } + + add := lc.liveReactionSyncEvent(op, "Cgroup", "Uother", reaction) + if add.Type != bridgev2.RemoteEventReactionSync || + add.PortalKey.ID != makePortalID("Cgroup") || + add.PortalKey.Receiver != "Uself" || + add.TargetMessage != "616934195205767730" { + t.Fatalf("add sync metadata = %#v", add) + } + if add.Reactions.HasAllUsers { + t.Fatal("single-sender live sync was marked authoritative for all users") + } + userSync := add.Reactions.Users["Uother"] + if userSync == nil || !userSync.HasAllReactions || len(userSync.Reactions) != 1 || userSync.Reactions[0] != reaction { + t.Fatalf("add user sync = %#v", userSync) + } + if want := time.UnixMilli(1784930400123); !add.Timestamp.Equal(want) { + t.Fatalf("add timestamp = %s, want %s", add.Timestamp, want) + } + + remove := lc.liveReactionSyncEvent(op, "Cgroup", "Uother", nil) + userSync = remove.Reactions.Users["Uother"] + if userSync == nil || !userSync.HasAllReactions || len(userSync.Reactions) != 0 { + t.Fatalf("remove user sync = %#v", userSync) + } +} + func TestResolveReactionSenderMID(t *testing.T) { lc := &LineClient{ UserLogin: &bridgev2.UserLogin{ diff --git a/pkg/connector/sync.go b/pkg/connector/sync.go index ba62364..1239219 100644 --- a/pkg/connector/sync.go +++ b/pkg/connector/sync.go @@ -2061,126 +2061,82 @@ func (lc *LineClient) handleOperation(ctx context.Context, op line.Operation) { } func (lc *LineClient) handlePaidReaction(ctx context.Context, op line.Operation, param2 *line.ReactionPayload) { - prt := param2.Curr.PaidReactionType - mxc, err := lc.getPaidReactionMXC(ctx, prt) + ts, _ := op.CreatedTime.Int64() + reaction, err := lc.convertReaction( + ctx, + line.ReactionType{PaidReactionType: param2.Curr.PaidReactionType}, + lc.eventSenderForMID(op.Param3), + time.UnixMilli(ts), + ) if err != nil { lc.UserLogin.Bridge.Log.Error().Err(err).Msg("Failed to prepare paid reaction icon") return } - portalKey := networkid.PortalKey{ID: makePortalID(param2.ChatMid), Receiver: lc.UserLogin.ID} - - // A fresh add invalidates any prior remove-dedup entries for this - // message — otherwise a later removal would be silently skipped. - lc.clearReactionDedupEntries(op.Param1, true) - - ts, _ := op.CreatedTime.Int64() - lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.Reaction{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventReaction, - PortalKey: portalKey, - Timestamp: time.UnixMilli(ts), - Sender: lc.eventSenderForMID(op.Param3), - }, - TargetMessage: networkid.MessageID(op.Param1), - Emoji: mxc, - }) + lc.UserLogin.Bridge.QueueRemoteEvent( + lc.UserLogin, + lc.liveReactionSyncEvent(op, param2.ChatMid, reaction.Sender.Sender, reaction), + ) } func (lc *LineClient) handlePredefinedReaction(ctx context.Context, op line.Operation, chatMid string, prt int) { - if prt < 2 || prt > 7 { - lc.UserLogin.Bridge.Log.Error().Int("predefined_reaction_type", prt).Msg("Unknown predefined reaction type") - return - } - - portalKey := networkid.PortalKey{ID: makePortalID(chatMid), Receiver: lc.UserLogin.ID} - - mxc, err := lc.getPredefinedReactionMXC(ctx, prt) + ts, _ := op.CreatedTime.Int64() + reaction, err := lc.convertReaction( + ctx, + line.ReactionType{PredefinedReactionType: prt}, + lc.eventSenderForMID(op.Param3), + time.UnixMilli(ts), + ) if err != nil { - lc.UserLogin.Bridge.Log.Error().Err(err).Int("prt", prt).Msg("Failed to prepare predefined reaction icon") + lc.UserLogin.Bridge.Log.Error().Err(err).Int("predefined_reaction_type", prt).Msg("Failed to prepare predefined reaction icon") return } + lc.UserLogin.Bridge.QueueRemoteEvent( + lc.UserLogin, + lc.liveReactionSyncEvent(op, chatMid, reaction.Sender.Sender, reaction), + ) +} - dedupKey := op.Param1 + "\x00" + mxc - if _, loaded := lc.recentReactions.LoadOrStore(dedupKey, struct{}{}); loaded { - lc.UserLogin.Bridge.Log.Debug().Str("msg_id", op.Param1).Msg("Skipping duplicate predefined reaction") - return - } - - // A fresh add invalidates any prior remove-dedup entries for this - // message — otherwise a later removal of this (or a replacement) - // reaction would be silently skipped. - lc.clearReactionDedupEntries(op.Param1, true) - +func (lc *LineClient) liveReactionSyncEvent( + op line.Operation, + chatMid string, + sender networkid.UserID, + reaction *bridgev2.BackfillReaction, +) *simplevent.ReactionSync { ts, _ := op.CreatedTime.Int64() - lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.Reaction{ + reactions := []*bridgev2.BackfillReaction(nil) + if reaction != nil { + reactions = []*bridgev2.BackfillReaction{reaction} + } + return &simplevent.ReactionSync{ EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventReaction, - PortalKey: portalKey, + Type: bridgev2.RemoteEventReactionSync, + PortalKey: networkid.PortalKey{ID: makePortalID(chatMid), Receiver: lc.UserLogin.ID}, Timestamp: time.UnixMilli(ts), - Sender: lc.eventSenderForMID(op.Param3), }, TargetMessage: networkid.MessageID(op.Param1), - Emoji: mxc, - }) + Reactions: &bridgev2.ReactionSyncData{ + Users: map[networkid.UserID]*bridgev2.ReactionSyncUser{ + sender: { + Reactions: reactions, + HasAllReactions: true, + }, + }, + HasAllUsers: false, + }, + } } -// handleReactionRemove queues a RemoteEventReactionRemove for each candidate -// sender. Reactions are stored with EmojiID="" (see handlePaidReaction / -// handlePredefinedReaction), so the framework's reaction lookup finds the -// single row keyed by (target_message, sender) and redacts it. A miss is -// silently ignored by bridgev2, which lets callers safely queue multiple -// sender candidates when the previous reaction's actor is ambiguous. -// -// It also evicts stale add-dedup entries for the target message so that -// re-adding the same emoji after a clear isn't silently dropped by the -// recentReactions sync.Map. +// handleReactionRemove queues an authoritative empty reaction sync for each +// candidate sender. LINE only allows one reaction per sender, so this removes +// both legacy empty-ID rows and stable paid/predefined reaction IDs without +// needing the previous reaction type. func (lc *LineClient) handleReactionRemove(op line.Operation, chatMid string, senders []networkid.UserID) { - ts, _ := op.CreatedTime.Int64() - portalKey := networkid.PortalKey{ID: makePortalID(chatMid), Receiver: lc.UserLogin.ID} - for _, sender := range senders { - dedupKey := op.Param1 + "\x00remove\x00" + string(sender) - if _, loaded := lc.recentReactions.LoadOrStore(dedupKey, struct{}{}); loaded { - lc.UserLogin.Bridge.Log.Debug().Str("msg_id", op.Param1).Str("sender", string(sender)).Msg("Skipping duplicate reaction removal") - continue - } - lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.Reaction{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventReactionRemove, - PortalKey: portalKey, - Timestamp: time.UnixMilli(ts), - Sender: lc.eventSenderForMID(string(sender)), - }, - TargetMessage: networkid.MessageID(op.Param1), - }) + lc.UserLogin.Bridge.QueueRemoteEvent( + lc.UserLogin, + lc.liveReactionSyncEvent(op, chatMid, sender, nil), + ) } - - lc.clearReactionDedupEntries(op.Param1, false) -} - -// clearReactionDedupEntries evicts recentReactions entries for the given -// message. The recentReactions sync.Map dedups concurrent 139/140 events -// from LINE; without periodic cleanup, the keys accumulate and silently -// block legitimate later events (e.g. add → remove → add of the same -// emoji, or remove → add → remove sequences). We use the inverse-direction -// event as the cleanup trigger: an add clears stale remove-dedup entries -// (removeOnly=true), a remove clears stale add-dedup entries -// (removeOnly=false). -func (lc *LineClient) clearReactionDedupEntries(msgID string, removeOnly bool) { - prefix := msgID + "\x00" - lc.recentReactions.Range(func(k, _ any) bool { - ks, ok := k.(string) - if !ok { - return true - } - if !strings.HasPrefix(ks, prefix) { - return true - } - if strings.Contains(ks, "\x00remove\x00") == removeOnly { - lc.recentReactions.Delete(ks) - } - return true - }) } func (lc *LineClient) syncSingleChat(ctx context.Context, op line.Operation) {