package store import ( "encoding/json" "errors" "fmt" "reflect" "slices" "sort" "telesrv/internal/domain" ) const ( MaxBlocklistPeers = 1024 MaxBlocklistEvents = 4096 ) var ( ErrBlocklistInvalid = errors.New("blocklist mutation invalid") ErrBlocklistLimit = errors.New("blocklist mutation limit exceeded") ErrBlocklistConflict = errors.New("blocklist snapshot changed") ErrBlocklistRequired = errors.New("blocklist aggregate dependencies required") ) type BlocklistMutationKind uint8 const ( BlocklistBlock BlocklistMutationKind = iota + 1 BlocklistUnblock BlocklistReplace ) type BlocklistMutation struct { Kind BlocklistMutationKind OwnerUserID int64 PeerIDs []int64 ExpectedIDs []int64 Date int Stories []domain.Story } // The aggregate freezes these facts before building any delivery effects. type BlocklistPeerFacts struct { PeerUserID int64 Contact bool CloseFriend bool KnowsPhone bool Bot bool Premium bool SharedChatIDs []int64 } type BlocklistChange struct { PeerUserID int64 Blocked bool } type BlocklistMutationSnapshot struct { OwnerUserID int64 Date int Changes []BlocklistChange Stories []domain.Story PhoneRules domain.PrivacyRules Peers []BlocklistPeerFacts RequiredEvents []BlocklistRequiredEvent } type BlocklistRequiredEvent struct { TargetUserID int64 Event domain.UpdateEvent } func BlocklistIDs(ids []int64) []int64 { out := append([]int64{}, ids...) slices.Sort(out) return slices.Compact(out) } func (m BlocklistMutation) Prepare() (BlocklistMutation, error) { if m.OwnerUserID <= 0 || m.Date <= 0 || m.Kind < BlocklistBlock || m.Kind > BlocklistReplace { return BlocklistMutation{}, ErrBlocklistInvalid } if len(m.PeerIDs) > MaxBlocklistPeers || len(m.ExpectedIDs) > MaxBlocklistPeers || len(m.Stories) > domain.MaxStoryListLimit { return BlocklistMutation{}, ErrBlocklistLimit } m.PeerIDs = BlocklistIDs(m.PeerIDs) m.ExpectedIDs = BlocklistIDs(m.ExpectedIDs) raw, err := json.Marshal(m.Stories) if err != nil { return BlocklistMutation{}, fmt.Errorf("%w: story snapshot: %v", ErrBlocklistInvalid, err) } var stories []domain.Story if err := json.Unmarshal(raw, &stories); err != nil { return BlocklistMutation{}, err } m.Stories = stories if m.Kind != BlocklistReplace && (len(m.PeerIDs) != 1 || len(m.ExpectedIDs) != 0) { return BlocklistMutation{}, ErrBlocklistInvalid } ids := BlocklistIDs(append(append([]int64{}, m.PeerIDs...), m.ExpectedIDs...)) if len(ids) > MaxBlocklistPeers { return BlocklistMutation{}, ErrBlocklistLimit } for _, id := range ids { if id <= 0 || id == m.OwnerUserID { return BlocklistMutation{}, ErrBlocklistInvalid } } for i, story := range m.Stories { if story.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: m.OwnerUserID}) || !story.Active(m.Date) || story.ID <= 0 || (i > 0 && m.Stories[i-1].ID >= story.ID) { return BlocklistMutation{}, ErrBlocklistInvalid } } return m, nil } func BlocklistDiff(m BlocklistMutation, current []int64) ([]BlocklistChange, error) { current = BlocklistIDs(current) if len(current) > MaxBlocklistPeers { return nil, ErrBlocklistLimit } if m.Kind == BlocklistReplace && !slices.Equal(current, m.ExpectedIDs) { return nil, ErrBlocklistConflict } desired := map[int64]bool{} for _, id := range current { desired[id] = true } switch m.Kind { case BlocklistBlock: desired[m.PeerIDs[0]] = true case BlocklistUnblock: delete(desired, m.PeerIDs[0]) case BlocklistReplace: clear(desired) for _, id := range m.PeerIDs { desired[id] = true } } if len(desired) > MaxBlocklistPeers { return nil, ErrBlocklistLimit } var out []BlocklistChange for _, id := range current { if !desired[id] { out = append(out, BlocklistChange{PeerUserID: id}) } } for id := range desired { if !slices.Contains(current, id) { out = append(out, BlocklistChange{PeerUserID: id, Blocked: true}) } } sort.Slice(out, func(i, j int) bool { return out[i].PeerUserID < out[j].PeerUserID }) return out, nil } // BuildBlocklistSnapshot is pure. Neither a builder nor a post-commit recorder // may reload visibility facts or decide which required events to omit. func BuildBlocklistSnapshot(m BlocklistMutation, changes []BlocklistChange, phone domain.PrivacyRules, peers []BlocklistPeerFacts) (BlocklistMutationSnapshot, error) { s := BlocklistMutationSnapshot{OwnerUserID: m.OwnerUserID, Date: m.Date, Changes: append([]BlocklistChange(nil), changes...), Stories: append([]domain.Story(nil), m.Stories...), PhoneRules: cloneBlocklistPrivacyRules(phone), Peers: append([]BlocklistPeerFacts(nil), peers...)} if phone.OwnerUserID != m.OwnerUserID || phone.Key != domain.PrivacyKeyPhoneNumber || len(peers) != len(changes) { return BlocklistMutationSnapshot{}, ErrBlocklistInvalid } for i, c := range changes { f := peers[i] if f.PeerUserID != c.PeerUserID { return BlocklistMutationSnapshot{}, ErrBlocklistInvalid } visible := f.KnowsPhone || domain.EvaluatePrivacy(phone, domain.PrivacyContext{OwnerUserID: m.OwnerUserID, ViewerUserID: f.PeerUserID, ViewerIsContact: f.Contact, ViewerCloseFriend: f.CloseFriend, ViewerIsBot: f.Bot, ViewerIsPremium: f.Premium, SharedChatIDs: f.SharedChatIDs}) peer := domain.Peer{Type: domain.PeerTypeUser, ID: c.PeerUserID} s.RequiredEvents = append(s.RequiredEvents, BlocklistRequiredEvent{TargetUserID: m.OwnerUserID, Event: domain.UpdateEvent{Type: domain.UpdateEventPeerStoryBlocked, Peer: peer, Bool: c.Blocked, Date: m.Date, PtsCount: 1}}, BlocklistRequiredEvent{TargetUserID: m.OwnerUserID, Event: domain.UpdateEvent{Type: domain.UpdateEventPeerSettings, Peer: peer, Settings: domain.PeerSettings{AddContact: !f.Contact, BlockContact: !c.Blocked, ShareContact: f.Contact && !visible, NeedContactsException: !visible}, Date: m.Date, PtsCount: 1}}) for _, story := range s.Stories { before := story.VisibleToWithStoryFacts(c.PeerUserID, f.Contact, f.CloseFriend, !c.Blocked) after := story.VisibleToWithStoryFacts(c.PeerUserID, f.Contact, f.CloseFriend, c.Blocked) if before == after { continue } v := story v.Out = false v.Views = domain.StoryViews{} v.SentReaction = nil v.Deleted = !after s.RequiredEvents = append(s.RequiredEvents, BlocklistRequiredEvent{TargetUserID: c.PeerUserID, Event: domain.UpdateEvent{Type: domain.UpdateEventStory, Peer: story.Owner, Story: v, Date: m.Date, PtsCount: 1}}) if len(s.RequiredEvents) > MaxBlocklistEvents { return BlocklistMutationSnapshot{}, ErrBlocklistLimit } } if len(s.RequiredEvents) > MaxBlocklistEvents { return BlocklistMutationSnapshot{}, ErrBlocklistLimit } } sort.Slice(s.RequiredEvents, func(i, j int) bool { a, b := s.RequiredEvents[i], s.RequiredEvents[j] if a.TargetUserID != b.TargetUserID { return a.TargetUserID < b.TargetUserID } if a.Event.Peer.ID != b.Event.Peer.ID { return a.Event.Peer.ID < b.Event.Peer.ID } if a.Event.Type != b.Event.Type { return blocklistEventRank(a.Event.Type) < blocklistEventRank(b.Event.Type) } return a.Event.Story.ID < b.Event.Story.ID }) return CloneBlocklistSnapshot(s) } func blocklistEventRank(t domain.UpdateEventType) int { switch t { case domain.UpdateEventPeerStoryBlocked: return 0 case domain.UpdateEventPeerSettings: return 1 default: return 2 } } func CloneBlocklistSnapshot(s BlocklistMutationSnapshot) (BlocklistMutationSnapshot, error) { raw, err := json.Marshal(s) if err != nil { return BlocklistMutationSnapshot{}, fmt.Errorf("clone blocklist snapshot: %w", err) } var copy BlocklistMutationSnapshot if err := json.Unmarshal(raw, ©); err != nil { return BlocklistMutationSnapshot{}, err } return copy, nil } func cloneBlocklistPrivacyRules(in domain.PrivacyRules) domain.PrivacyRules { out := in out.Rules = append([]domain.PrivacyRule(nil), in.Rules...) for i := range out.Rules { out.Rules[i].UserIDs = append([]int64(nil), in.Rules[i].UserIDs...) out.Rules[i].ChatIDs = append([]int64(nil), in.Rules[i].ChatIDs...) } return out } func BlocklistDeliveryEffects(s BlocklistMutationSnapshot) ([]DeliveryEffect, error) { e := make([]DeliveryEffect, 0, len(s.RequiredEvents)) for _, r := range s.RequiredEvents { e = append(e, AccountPTSDeliveryEffect(r.TargetUserID, r.Event, [8]byte{}, 0)) } return e, nil } func ValidateBlocklistEffects(s BlocklistMutationSnapshot, effects []DeliveryEffect) error { if len(effects) != len(s.RequiredEvents) || len(effects) > MaxBlocklistEvents { return ErrBlocklistInvalid } for i, e := range effects { r := s.RequiredEvents[i] if err := e.Validate(); err != nil { return err } if e.Kind != DeliveryEffectAccountPTS || e.TargetUserID != r.TargetUserID || e.ExcludeAuthKeyID != ([8]byte{}) || e.ExcludeSessionID != 0 || !reflect.DeepEqual(e.Event, r.Event) { return fmt.Errorf("%w: effect %d differs from required event", ErrBlocklistInvalid, i) } } return nil } func BlocklistPrivacyChatIDs(r domain.PrivacyRules) []int64 { var ids []int64 for _, rule := range r.Rules { if rule.Kind == domain.PrivacyRuleAllowChatParticipants || rule.Kind == domain.PrivacyRuleDisallowChatParticipants { ids = append(ids, rule.ChatIDs...) } } return BlocklistIDs(ids) }