feat: sync durable moderation and appeals

This commit is contained in:
iamxvbaba 2026-07-24 11:56:59 +08:00
parent e1a95c7318
commit 9f467f4be7
140 changed files with 13730 additions and 316 deletions

View file

@ -0,0 +1,83 @@
package memory
import (
"context"
"sync"
"time"
"telesrv/internal/domain"
)
type AuthDeliveryReportStore struct {
mu sync.Mutex
nextID int64
byFingerprint map[[32]byte]domain.AuthDeliveryReport
}
func NewAuthDeliveryReportStore() *AuthDeliveryReportStore {
return &AuthDeliveryReportStore{
nextID: 1, byFingerprint: make(map[[32]byte]domain.AuthDeliveryReport),
}
}
func (s *AuthDeliveryReportStore) CreateAuthDeliveryReport(_ context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error) {
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.byFingerprint[report.Fingerprint]; ok {
return existing, false, nil
}
var hourly, phoneDaily int
hourAgo := report.CreatedAt.Add(-time.Hour)
dayAgo := report.CreatedAt.Add(-24 * time.Hour)
for _, existing := range s.byFingerprint {
if existing.CreatedAt.After(report.CreatedAt) {
continue
}
if existing.AuthKeyID == report.AuthKeyID && !existing.CreatedAt.Before(hourAgo) {
hourly++
}
if existing.PhoneHash == report.PhoneHash && !existing.CreatedAt.Before(dayAgo) {
phoneDaily++
}
}
if hourly >= domain.MaxAuthDeliveryReportsPerHour ||
phoneDaily >= domain.MaxAuthDeliveryReportsPerPhoneDay {
return domain.AuthDeliveryReport{}, false, domain.ErrAuthDeliveryRateLimited
}
report.ID = s.nextID
s.nextID++
s.byFingerprint[report.Fingerprint] = report
return report, true, nil
}
func (s *AuthDeliveryReportStore) Reports() []domain.AuthDeliveryReport {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.AuthDeliveryReport, 0, len(s.byFingerprint))
for _, report := range s.byFingerprint {
out = append(out, report)
}
return out
}
func (s *AuthDeliveryReportStore) DeleteExpiredAuthDeliveryReports(_ context.Context, olderThan time.Time, limit int) (int, error) {
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrAuthDeliveryReportInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
deleted := 0
for fingerprint, report := range s.byFingerprint {
if deleted >= limit {
break
}
if report.CreatedAt.Before(olderThan) {
delete(s.byFingerprint, fingerprint)
deleted++
}
}
return deleted, nil
}

View file

@ -479,6 +479,39 @@ func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain
}, nil
}
func (s *ChannelStore) FindChannelMessageReaction(_ context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID == 0 {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
s.mu.RLock()
defer s.mu.RUnlock()
channel, member, err := s.channelAndMemberLocked(req.ViewerUserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
message, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
if !ok || message.Deleted || message.ID <= member.AvailableMinID {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid
}
rows := cloneChannelPeerReactions(s.reactions[req.ChannelID][req.MessageID][req.ReactorUserID])
if len(rows) == 0 {
return domain.ChannelMessageReactionLookup{
Channel: cloneChannel(channel), Message: cloneChannelMessage(message),
}, false, nil
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].ChosenOrder != rows[j].ChosenOrder {
return rows[i].ChosenOrder < rows[j].ChosenOrder
}
return messageReactionKey(rows[i].Reaction) < messageReactionKey(rows[j].Reaction)
})
return domain.ChannelMessageReactionLookup{
Channel: cloneChannel(channel), Message: cloneChannelMessage(message),
Reactions: rows,
}, true, nil
}
func (s *ChannelStore) RecordMessageReactionUse(_ context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error {
if userID == 0 || len(reactions) == 0 {
return nil

View file

@ -0,0 +1,99 @@
package memory
import (
"context"
"sort"
"sync"
"time"
"telesrv/internal/domain"
)
type ClientTelemetryStore struct {
mu sync.Mutex
nextID int64
byID map[int64]domain.ClientTelemetryEvent
byFingerprint map[[32]byte]int64
}
func NewClientTelemetryStore() *ClientTelemetryStore {
return &ClientTelemetryStore{
nextID: 1, byID: make(map[int64]domain.ClientTelemetryEvent),
byFingerprint: make(map[[32]byte]int64),
}
}
func (s *ClientTelemetryStore) CreateClientTelemetry(_ context.Context, event domain.ClientTelemetryEvent) (domain.ClientTelemetryEvent, bool, error) {
if err := event.Validate(); err != nil || event.ID != 0 {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if id, ok := s.byFingerprint[event.Fingerprint]; ok {
return cloneClientTelemetry(s.byID[id]), false, nil
}
var hourly, daily int
for _, existing := range s.byID {
if existing.UserID != event.UserID ||
existing.CreatedAt.After(event.CreatedAt) {
continue
}
if !existing.CreatedAt.Before(event.CreatedAt.Add(-24 * time.Hour)) {
daily++
}
if !existing.CreatedAt.Before(event.CreatedAt.Add(-time.Hour)) {
hourly++
}
}
if hourly >= domain.MaxClientTelemetryEventsPerHour ||
daily >= domain.MaxClientTelemetryEventsPerDay {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryRateLimited
}
event.ID = s.nextID
s.nextID++
event = cloneClientTelemetry(event)
s.byID[event.ID] = event
s.byFingerprint[event.Fingerprint] = event.ID
return cloneClientTelemetry(event), true, nil
}
func (s *ClientTelemetryStore) DeleteExpiredClientTelemetry(_ context.Context, olderThan time.Time, limit int) (int, error) {
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrClientTelemetryInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
ids := make([]int64, 0)
for id, event := range s.byID {
if event.CreatedAt.Before(olderThan) {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
if len(ids) > limit {
ids = ids[:limit]
}
for _, id := range ids {
event := s.byID[id]
delete(s.byFingerprint, event.Fingerprint)
delete(s.byID, id)
}
return len(ids), nil
}
func (s *ClientTelemetryStore) Events() []domain.ClientTelemetryEvent {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.ClientTelemetryEvent, 0, len(s.byID))
for _, event := range s.byID {
out = append(out, cloneClientTelemetry(event))
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func cloneClientTelemetry(event domain.ClientTelemetryEvent) domain.ClientTelemetryEvent {
event.SubjectIDs = append([]int64(nil), event.SubjectIDs...)
event.Payload = append([]byte(nil), event.Payload...)
return event
}

View file

@ -0,0 +1,62 @@
package memory
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
func TestClientTelemetryStoreIdempotencyRateLimitAndRetention(t *testing.T) {
ctx := context.Background()
store := NewClientTelemetryStore()
now := time.Unix(1_750_000_000, 0).UTC()
newEvent := func(subject int64, at time.Time) domain.ClientTelemetryEvent {
event, err := domain.NewClientTelemetryEvent(
71, domain.ClientTelemetryMessageDelivery,
domain.Peer{Type: domain.PeerTypeUser, ID: 72},
[]int64{subject}, map[string]any{"push": true}, at,
)
if err != nil {
t.Fatal(err)
}
return event
}
first := newEvent(1, now)
stored, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || !created || stored.ID <= 0 {
t.Fatalf("first=%+v created=%v err=%v", stored, created, err)
}
retry, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || created || retry.ID != stored.ID {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
for i := 1; i < domain.MaxClientTelemetryEventsPerHour; i++ {
if _, created, err := store.CreateClientTelemetry(
ctx, newEvent(int64(i+1), now),
); err != nil || !created {
t.Fatalf("create %d created=%v err=%v", i, created, err)
}
}
if got, created, err := store.CreateClientTelemetry(ctx, first); err != nil ||
created || got.ID != stored.ID {
t.Fatalf("retry at limit got=%+v created=%v err=%v", got, created, err)
}
if _, _, err := store.CreateClientTelemetry(
ctx, newEvent(domain.MaxClientTelemetryEventsPerHour+1, now),
); !errors.Is(err, domain.ErrClientTelemetryRateLimited) {
t.Fatalf("overflow err=%v", err)
}
deleted, err := store.DeleteExpiredClientTelemetry(
ctx, now.Add(time.Second), domain.MaxClientTelemetryEventsPerHour+1,
)
if err != nil || deleted != domain.MaxClientTelemetryEventsPerHour {
t.Fatalf("deleted=%d err=%v", deleted, err)
}
recreated, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || !created || recreated.ID == stored.ID {
t.Fatalf("recreated=%+v created=%v err=%v", recreated, created, err)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,221 @@
package memory
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestModerationCaseLifecycleAndNewReportsDuringAction(t *testing.T) {
ctx := context.Background()
now := time.Now().UTC()
target := domain.Peer{Type: domain.PeerTypeUser, ID: 900}
store := NewModerationReportStore()
create := func(reporter int64, option string, at time.Time) domain.ModerationReport {
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonFake,
Option: option,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: at,
})
if err != nil {
t.Fatal(err)
}
stored, created, err := store.CreateModerationReport(ctx, report)
if err != nil || !created {
t.Fatalf("create report created=%v err=%v", created, err)
}
return stored
}
create(101, "fake", now)
create(102, "fake:impersonation", now.Add(time.Second))
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
item := cases[0]
if item.ReportCount != 2 || item.DistinctReporterCount != 2 ||
item.Version != 2 || item.Severity != domain.ModerationSeverityMedium {
t.Fatalf("case aggregate=%+v", item)
}
claimed, err := store.ClaimModerationCase(ctx, item.ID, item.Version, "reviewer", now.Add(2*time.Second))
if err != nil || claimed.Status != domain.ModerationCaseInReview {
t.Fatalf("claim=%+v err=%v", claimed, err)
}
decision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: item.ID, ExpectedVersion: claimed.Version, Actor: "reviewer",
Reason: "confirmed impersonation", CommandID: "decision-1",
Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(3 * time.Second),
})
if err != nil {
t.Fatal(err)
}
detail, created, err := store.DecideModerationCase(ctx, decision)
if err != nil || !created ||
detail.Case.Status != domain.ModerationCaseActionPending ||
len(detail.Actions) != 1 {
t.Fatalf("decision detail=%+v created=%v err=%v", detail, created, err)
}
if _, created, err := store.DecideModerationCase(ctx, decision); err != nil || created {
t.Fatalf("decision retry created=%v err=%v", created, err)
}
// Once a decision is durable, later reports open a new case instead of
// mutating the evidence set under the pending action.
create(103, "fake:new-evidence", now.Add(4*time.Second))
cases, err = store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 2 {
t.Fatalf("cases after new evidence=%+v err=%v", cases, err)
}
actions, err := store.ClaimModerationActions(ctx, now.Add(5*time.Second), 10, time.Minute)
if err != nil || len(actions) != 1 {
t.Fatalf("claimed actions=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "",
time.Time{}, now.Add(6*time.Second),
); err != nil {
t.Fatal(err)
}
resolved, found, err := store.GetModerationCase(ctx, item.ID)
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved {
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
}
appeal, err := domain.NewModerationAppeal(
item.ID, target.ID, domain.ModerationCaseResolved,
"This is a mistake.", now.Add(7*time.Second),
)
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationAppeal(ctx, appeal); err != nil || !created {
t.Fatalf("appeal created=%v err=%v", created, err)
}
appealed, _, _ := store.GetModerationCase(ctx, item.ID)
if appealed.Case.Status != domain.ModerationCaseAppealReview ||
len(appealed.Appeals) != 1 {
t.Fatalf("appealed detail=%+v", appealed)
}
}
func TestModerationActionFailedCanBeRedrivenByNewDecision(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_750_000_000, 0).UTC()
target := domain.Peer{Type: domain.PeerTypeUser, ID: 902}
store := NewModerationReportStore()
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 901, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationReport(ctx, report); err != nil || !created {
t.Fatalf("create report created=%v err=%v", created, err)
}
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
claimed, err := store.ClaimModerationCase(
ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second),
)
if err != nil {
t.Fatal(err)
}
firstDecision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "reviewer", Reason: "first command kept failing",
CommandID: "redrive-first", Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkScam, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(2 * time.Second),
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.DecideModerationCase(ctx, firstDecision); err != nil || !created {
t.Fatalf("first decision created=%v err=%v", created, err)
}
for attempt := 1; attempt <= domain.MaxModerationActionAttempts; attempt++ {
at := now.Add(time.Duration(attempt+2) * time.Second)
actions, err := store.ClaimModerationActions(ctx, at, 10, time.Second)
if err != nil || len(actions) != 1 {
t.Fatalf("attempt %d actions=%+v err=%v", attempt, actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, false, "downstream unavailable",
at.Add(time.Millisecond), at,
); err != nil {
t.Fatalf("attempt %d: %v", attempt, err)
}
}
failed, found, err := store.GetModerationCase(ctx, claimed.ID)
if err != nil || !found || failed.Case.Status != domain.ModerationCaseActionFailed ||
len(failed.Actions) != 1 ||
failed.Actions[0].Status != domain.ModerationActionFailed {
t.Fatalf("failed=%+v found=%v err=%v", failed, found, err)
}
redrive, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: failed.Case.Version,
Actor: "reviewer", Reason: "redrive after dependency recovery",
CommandID: "redrive-second", Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkScam, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(time.Minute),
})
if err != nil {
t.Fatal(err)
}
pending, created, err := store.DecideModerationCase(ctx, redrive)
if err != nil || !created ||
pending.Case.Status != domain.ModerationCaseActionPending ||
len(pending.Actions) != 2 {
t.Fatalf("pending=%+v created=%v err=%v", pending, created, err)
}
actions, err := store.ClaimModerationActions(ctx, now.Add(2*time.Minute), 10, time.Second)
if err != nil || len(actions) != 1 || actions[0].DecisionID == failed.Actions[0].DecisionID {
t.Fatalf("redrive actions=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "",
time.Time{}, now.Add(2*time.Minute+time.Second),
); err != nil {
t.Fatal(err)
}
resolved, found, err := store.GetModerationCase(ctx, claimed.ID)
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved {
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
}
var failedCount, succeededCount int
for _, action := range resolved.Actions {
switch action.Status {
case domain.ModerationActionFailed:
failedCount++
case domain.ModerationActionSucceeded:
succeededCount++
}
}
if failedCount != 1 || succeededCount != 1 {
t.Fatalf("action history failed=%d succeeded=%d", failedCount, succeededCount)
}
}

View file

@ -0,0 +1,92 @@
package memory
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestModerationReportStoreIdempotencyAndCopyIsolation(t *testing.T) {
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 11, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 22},
Reason: domain.ModerationReasonSpam, Option: "v1/spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 22},
ItemID: 5, AuthorUserID: 22, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"spam"}`),
}},
CreatedAt: time.Now().UTC(),
})
if err != nil {
t.Fatal(err)
}
store := NewModerationReportStore()
first, created, err := store.CreateModerationReport(context.Background(), report)
if err != nil || !created || first.ID <= 0 {
t.Fatalf("first = %+v created=%v err=%v", first, created, err)
}
first.Items[0].Evidence[0] = '['
retry, created, err := store.CreateModerationReport(context.Background(), report)
if err != nil || created || retry.ID != first.ID {
t.Fatalf("retry = %+v created=%v err=%v", retry, created, err)
}
if retry.Items[0].Evidence[0] != '{' {
t.Fatalf("caller mutation changed stored evidence: %s", retry.Items[0].Evidence)
}
}
func TestModerationReportStoreRateLimitDoesNotChargeIdempotentRetry(t *testing.T) {
ctx := context.Background()
store := NewModerationReportStore()
now := time.Unix(1_750_000_000, 0).UTC()
var first domain.ModerationReport
for i := 0; i < domain.MaxModerationReportsPerHour; i++ {
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 71, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
ItemID: int64(i + 1), AuthorUserID: 72,
EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"spam"}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationReport(ctx, report); err != nil || !created {
t.Fatalf("create %d: created=%v err=%v", i, created, err)
}
if i == 0 {
first = report
}
}
if got, created, err := store.CreateModerationReport(ctx, first); err != nil || created || got.ID == 0 {
t.Fatalf("retry after limit: got=%+v created=%v err=%v", got, created, err)
}
overflow, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 71, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
ItemID: 999, AuthorUserID: 72, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"overflow"}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateModerationReport(ctx, overflow); err != domain.ErrModerationRateLimited {
t.Fatalf("overflow err=%v, want ErrModerationRateLimited", err)
}
}

View file

@ -160,6 +160,18 @@ func (s *PasswordStore) GetAccountSettings(_ context.Context, userID int64) (dom
return settings, ok, nil // AccountSettings 全是值类型,无需深拷贝
}
func (s *PasswordStore) GetAccountSettingsBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
out := make(map[int64]domain.AccountSettings, len(userIDs))
s.mu.RLock()
for _, userID := range userIDs {
if settings, ok := s.accountSettings[userID]; ok {
out[userID] = settings
}
}
s.mu.RUnlock()
return out, nil
}
func (s *PasswordStore) SaveAccountSettings(_ context.Context, userID int64, settings domain.AccountSettings) error {
s.mu.Lock()
s.accountSettings[userID] = settings

View file

@ -105,9 +105,20 @@ func cloneUpdateEvent(event domain.UpdateEvent) domain.UpdateEvent {
event.Reaction = cloneUpdateReaction(event.Reaction)
event.QuickReplies = cloneUpdateQuickReplies(event.QuickReplies)
event.QuickReplyMessage = cloneUpdateQuickReplyMessage(event.QuickReplyMessage)
event.Privacy.Rules = clonePrivacyRuleSlice(event.Privacy.Rules)
return event
}
func clonePrivacyRuleSlice(in []domain.PrivacyRule) []domain.PrivacyRule {
out := make([]domain.PrivacyRule, len(in))
for i := range in {
out[i] = in[i]
out[i].UserIDs = append([]int64(nil), in[i].UserIDs...)
out[i].ChatIDs = append([]int64(nil), in[i].ChatIDs...)
}
return out
}
func cloneUpdateStory(story domain.Story) domain.Story {
story.Entities = append([]domain.MessageEntity(nil), story.Entities...)
story.Views.Reactions = append([]domain.ChannelMessageReactionCount(nil), story.Views.Reactions...)