Ctrl K
ComunidadGuías…

internal/app/moderation/evidence.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package moderation

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"sort"
	"strconv"
	"time"

	"telesrv/internal/domain"
)

type privateMessageReader interface {
	GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
	GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
}

type channelMessageReader interface {
	GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error)
	FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
}

type storyReader interface {
	GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error)
}

type userReader interface {
	ByID(ctx context.Context, viewerUserID, userID int64) (domain.User, bool, error)
}

type channelPeerReader interface {
	ResolveChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error)
}

type profilePhotoReader interface {
	GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error)
}

func (s *Service) ReportMessages(ctx context.Context, req domain.ModerationMessageReportRequest) (domain.ModerationReport, bool, error) {
	ids, err := canonicalPositiveIDs(req.MessageIDs, domain.MaxMessageBoxID)
	if err != nil || req.ReporterUserID <= 0 || req.Target.ID <= 0 {
		return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
	}
	items := make([]domain.ModerationReportItem, 0, len(ids))
	holds := make([]domain.ModerationMediaHold, 0)
	switch req.Target.Type {
	case domain.PeerTypeUser:
		if s == nil || s.privateMessages == nil {
			return domain.ModerationReport{}, false, fmt.Errorf("moderation private message reader is not configured")
		}
		list, err := s.privateMessages.GetMessages(ctx, req.ReporterUserID, ids)
		if err != nil {
			return domain.ModerationReport{}, false, err
		}
		byID := make(map[int]domain.Message, len(list.Messages))
		for _, message := range list.Messages {
			if message.Peer == req.Target {
				byID[message.ID] = message
			}
		}
		for _, id := range ids {
			message, found := byID[id]
			if !found {
				return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
			}
			evidence, err := json.Marshal(privateMessageEvidence(message))
			if err != nil {
				return domain.ModerationReport{}, false, fmt.Errorf("marshal private message evidence: %w", err)
			}
			items = append(items, domain.ModerationReportItem{
				Kind: domain.ModerationItemMessage, Peer: req.Target,
				ItemID: int64(message.ID), AuthorUserID: message.From.ID,
				EvidenceSchemaVersion: 1, Evidence: evidence,
			})
			holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
		}
	case domain.PeerTypeChannel:
		if s == nil || s.channelMessages == nil {
			return domain.ModerationReport{}, false, fmt.Errorf("moderation channel message reader is not configured")
		}
		history, err := s.channelMessages.GetMessages(ctx, req.ReporterUserID, req.Target.ID, ids)
		if err != nil {
			return domain.ModerationReport{}, false, err
		}
		byID := make(map[int]domain.ChannelMessage, len(history.Messages))
		for _, message := range history.Messages {
			if message.ChannelID == req.Target.ID && !message.Deleted {
				byID[message.ID] = message
			}
		}
		for _, id := range ids {
			message, found := byID[id]
			if !found {
				return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
			}
			evidence, err := marshalChannelMessageEvidence(message)
			if err != nil {
				return domain.ModerationReport{}, false, fmt.Errorf("marshal channel message evidence: %w", err)
			}
			items = append(items, domain.ModerationReportItem{
				Kind: domain.ModerationItemMessage, Peer: req.Target,
				ItemID: int64(message.ID), AuthorUserID: message.SenderUserID,
				EvidenceSchemaVersion: 1, Evidence: evidence,
			})
			holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
		}
	default:
		return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
	}
	return s.AcceptReport(ctx, domain.ModerationReportDraft{
		ReporterUserID: req.ReporterUserID,
		Source:         domain.ModerationSourceMessages,
		Target:         req.Target,
		Reason:         req.Reason,
		Option:         req.Option,
		Comment:        req.Comment,
		Items:          items,
		MediaHolds:     dedupeMediaHolds(holds),
		CreatedAt:      req.CreatedAt,
	})
}

func (s *Service) ReportPeer(ctx context.Context, reporterUserID int64, source domain.ModerationReportSource, target domain.Peer, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error) {
	if source != domain.ModerationSourceAccountPeer && source != domain.ModerationSourceMessagesSpam {
		return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
	}
	snapshot := peerEvidenceV1{SchemaVersion: 1, Target: target}
	switch target.Type {
	case domain.PeerTypeUser:
		if s == nil || s.users == nil {
			return domain.ModerationReport{}, false, fmt.Errorf("moderation user reader is not configured")
		}
		user, found, err := s.users.ByID(ctx, reporterUserID, target.ID)
		if err != nil {
			return domain.ModerationReport{}, false, err
		}
		if !found || user.Deleted {
			return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
		}
		snapshot.User = &peerUserEvidenceV1{
			ID: user.ID, FirstName: user.FirstName, LastName: user.LastName,
			Username: user.Username, About: user.About, Bot: user.Bot,
			Verified: user.Verified, Scam: user.Scam, Fake: user.Fake,
			PhotoID: user.PhotoID,
		}
	case domain.PeerTypeChannel:
		if s == nil || s.channels == nil {
			return domain.ModerationReport{}, false, fmt.Errorf("moderation channel reader is not configured")
		}
		view, err := s.channels.ResolveChannel(ctx, reporterUserID, target.ID)
		if err != nil {
			return domain.ModerationReport{}, false, err
		}
		channel := view.Channel
		snapshot.Channel = &peerChannelEvidenceV1{
			ID: channel.ID, Title: channel.Title, About: channel.About,
			Username: channel.Username, Broadcast: channel.Broadcast,
			Megagroup: channel.Megagroup, Verified: channel.Verified,
			Scam: channel.Scam, Fake: channel.Fake, PhotoID: channel.PhotoID,
		}
	default:
		return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
	}
	evidence, err := json.Marshal(snapshot)
	if err != nil {
		return domain.ModerationReport{}, false, fmt.Errorf("marshal peer evidence: %w", err)
	}
	authorUserID := int64(0)
	if target.Type == domain.PeerTypeUser {
		authorUserID = target.ID
	}
	return s.AcceptReport(ctx, domain.ModerationReportDraft{
		ReporterUserID: reporterUserID, Source: source, Target: target,
		Reason: reason, Option: option, Comment: comment,
		Items: []domain.ModerationReportItem{{
			Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
			AuthorUserID: authorUserID, EvidenceSchemaVersion: 1,
			Evidence: evidence,
		}},
		CreatedAt: createdAt,
	})
}

func (s *Service) ReportProfilePhoto(ctx context.Context, req domain.ModerationProfilePhotoReportRequest) (domain.ModerationReport, bool, error) {
	if req.ReporterUserID <= 0 || req.Target.ID <= 0 || req.PhotoID <= 0 ||
		!req.Reason.Valid() || s == nil || s.photos == nil {
		return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
	}
	photos, _, err := s.photos.GetProfilePhotos(ctx, req.Target.Type, req.Target.ID, -1, 1, req.PhotoID)
	if err != nil {
		return domain.ModerationReport{}, false, err
	}
	if len(photos) != 1 || photos[0].ID != req.PhotoID ||
		photos[0].AccessHash != req.AccessHash {
		return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
	}
	photo := photos[0]
	if len(req.FileReference) > 0 && len(photo.FileReference) > 0 &&
		!bytes.Equal(req.FileReference, photo.FileReference) {
		return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
	}
	evidence, err := json.Marshal(profilePhotoEvidenceV1{
		SchemaVersion: 1, Owner: req.Target, Photo: photo,
	})
	if err != nil {
		return domain.ModerationReport{}, false, fmt.Errorf("marshal profile photo evidence: %w", err)
	}
	authorUserID := int64(0)
	if req.Target.Type == domain.PeerTypeUser {
		authorUserID = req.Target.ID
	}
	return s.AcceptReport(ctx, domain.ModerationReportDraft{
		ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceProfilePhoto,
		Target: req.Target, Reason: req.Reason, Option: string(req.Reason),
		Comment: req.Comment, CreatedAt: req.CreatedAt,
		Items: []domain.ModerationReportItem{{
			Kind: domain.ModerationItemProfilePhoto, Peer: req.Target,
			ItemID: req.PhotoID, AuthorUserID: authorUserID,
			EvidenceSchemaVersion: 1, Evidence: evidence,
		}},
		MediaHolds: photoHolds(0, photo),
	})
}

func (s *Service) ReportChannelSpam(ctx context.Context, req domain.ModerationChannelSpamReportRequest) (domain.ModerationReport, bool, error) {
	if req.ReporterUserID <= 0 || req.ChannelID <= 0 || req.ParticipantUserID <= 0 ||
		s == nil || s.channelMessages == nil {
		return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
	}
	ids, err := canonicalPositiveIDs(req.MessageIDs, domain.MaxMessageBoxID)
	if err != nil {
		return domain.ModerationReport{}, false, err
	}
	history, err := s.channelMessages.GetMessages(ctx, req.ReporterUserID, req.ChannelID, ids)
	if err != nil {
		return domain.ModerationReport{}, false, err
	}
	byID := make(map[int]domain.ChannelMessage, len(history.Messages))
	for _, message := range history.Messages {
		if message.ChannelID == req.ChannelID && !message.Deleted {
			byID[message.ID] = message
		}
	}
	items := make([]domain.ModerationReportItem, 0, len(ids))
	holds := make([]domain.ModerationMediaHold, 0)
	target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
	for _, id := range ids {
		message, found := byID[id]
		if !found || message.SenderUserID != req.ParticipantUserID {
			return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
		}
		evidence, err := marshalChannelMessageEvidence(message)
		if err != nil {
			return domain.ModerationReport{}, false, err
		}
		items = append(items, domain.ModerationReportItem{
			Kind: domain.ModerationItemMessage, Peer: target,
			ItemID: int64(message.ID), AuthorUserID: req.ParticipantUserID,
			EvidenceSchemaVersion: 1, Evidence: evidence,
		})
		holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
	}
	return s.AcceptReport(ctx, domain.ModerationReportDraft{
		ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceChannelSpam,
		Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
		Items: items, MediaHolds: dedupeMediaHolds(holds), CreatedAt: req.CreatedAt,
	})
}

func (s *Service) ReportReaction(ctx context.Context, req domain.ModerationReactionReportRequest) (domain.ModerationReport, bool, error) {
	if req.ReporterUserID <= 0 || req.Target.ID <= 0 || req.MessageID <= 0 ||
		req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID <= 0 {
		return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
	}
	var evidence []byte
	var err error
	switch req.Target.Type {
	case domain.PeerTypeUser:
		if s == nil || s.privateMessages == nil {
			return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
		}
		result, err := s.privateMessages.GetMessageReactions(ctx, req.ReporterUserID, domain.PrivateMessageReactionsRequest{
			OwnerUserID: req.ReporterUserID, Peer: req.Target, IDs: []int{req.MessageID},
		})
		if err != nil {
			return domain.ModerationReport{}, false, err
		}
		if len(result.Messages) != 1 || result.Messages[0].ID != req.MessageID ||
			result.Messages[0].Peer != req.Target {
			return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
		}
		reactions := reactionRowsForUser(result.Messages[0].Reactions, req.ReactorUserID)
		if len(reactions) == 0 {
			return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
		}
		evidence, err = json.Marshal(privateReactionEvidenceV1{
			SchemaVersion: 1, Message: privateMessageEvidence(result.Messages[0]),
			ReactorUserID: req.ReactorUserID, Reactions: reactionEvidenceRows(reactions),
		})
	case domain.PeerTypeChannel:
		if s == nil || s.channelMessages == nil {
			return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
		}
		lookup, found, lookupErr := s.channelMessages.FindMessageReaction(ctx, req.ReporterUserID, domain.ChannelMessageReactionLookupRequest{
			ViewerUserID: req.ReporterUserID, ChannelID: req.Target.ID,
			MessageID: req.MessageID, ReactorUserID: req.ReactorUserID,
		})
		if lookupErr != nil {
			return domain.ModerationReport{}, false, lookupErr
		}
		if !found {
			return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
		}
		evidence, err = json.Marshal(channelReactionEvidenceV1{
			SchemaVersion: 1, Message: channelMessageEvidence(lookup.Message),
			ReactorUserID: req.ReactorUserID, Reactions: reactionEvidenceRows(lookup.Reactions),
		})
	default:
		return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
	}
	if err != nil {
		return domain.ModerationReport{}, false, fmt.Errorf("marshal reaction evidence: %w", err)
	}
	return s.AcceptReport(ctx, domain.ModerationReportDraft{
		ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceReaction,
		Target: req.Target, Reason: domain.ModerationReasonOther,
		Option: "reaction", CreatedAt: req.CreatedAt,
		Items: []domain.ModerationReportItem{{
			Kind: domain.ModerationItemReaction, Peer: req.Target,
			ItemID: int64(req.MessageID), SecondaryID: req.ReactorUserID,
			AuthorUserID: req.ReactorUserID, EvidenceSchemaVersion: 1,
			Evidence: evidence,
		}},
	})
}

func (s *Service) ReportEncryptedSpam(ctx context.Context, reporterUserID int64, chat domain.SecretChat, createdAt time.Time) (domain.ModerationReport, bool, error) {
	if reporterUserID <= 0 || !chat.HasParticipant(reporterUserID) || chat.ID <= 0 {
		return domain.ModerationReport{}, false, domain.ErrModerationPermissionDenied
	}
	offenderUserID := chat.PeerOf(reporterUserID)
	if offenderUserID <= 0 {
		return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
	}
	target := domain.Peer{Type: domain.PeerTypeUser, ID: offenderUserID}
	evidence, err := json.Marshal(encryptedChatEvidenceV1{
		SchemaVersion: 1, ChatID: chat.ID, State: chat.State,
		AdminUserID: chat.AdminUserID, ParticipantUserID: chat.ParticipantUserID,
		Date: chat.Date,
	})
	if err != nil {
		return domain.ModerationReport{}, false, fmt.Errorf("marshal encrypted chat evidence: %w", err)
	}
	return s.AcceptReport(ctx, domain.ModerationReportDraft{
		ReporterUserID: reporterUserID, Source: domain.ModerationSourceEncryptedSpam,
		Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
		CreatedAt: createdAt,
		Items: []domain.ModerationReportItem{{
			Kind: domain.ModerationItemEncryptedChat, Peer: target,
			ItemID: int64(chat.ID), AuthorUserID: offenderUserID,
			EvidenceSchemaVersion: 1, Evidence: evidence,
		}},
	})
}

func (s *Service) ReportStories(ctx context.Context, req domain.ModerationStoryReportRequest) (domain.ModerationReport, bool, error) {
	ids, err := canonicalPositiveIDs(req.StoryIDs, domain.MaxStoryID)
	if err != nil || req.ReporterUserID <= 0 || req.Target.ID <= 0 {
		return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
	}
	if s == nil || s.stories == nil {
		return domain.ModerationReport{}, false, fmt.Errorf("moderation story reader is not configured")
	}
	list, err := s.stories.GetStoriesByID(ctx, req.ReporterUserID, req.Target, ids, int(req.CreatedAt.Unix()))
	if err != nil {
		return domain.ModerationReport{}, false, err
	}
	byID := make(map[int]domain.Story, len(list.Stories))
	for _, story := range list.Stories {
		if story.Owner == req.Target && !story.Deleted {
			byID[story.ID] = story
		}
	}
	items := make([]domain.ModerationReportItem, 0, len(ids))
	holds := make([]domain.ModerationMediaHold, 0)
	for _, id := range ids {
		story, found := byID[id]
		if !found {
			return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
		}
		evidence, err := json.Marshal(storyEvidenceV1{
			SchemaVersion: 1, Owner: story.Owner, StoryID: story.ID,
			Date: story.Date, ExpireDate: story.ExpireDate, Pinned: story.Pinned,
			Public: story.Public, CloseFriends: story.CloseFriends,
			Contacts: story.Contacts, SelectedContacts: story.SelectedContacts,
			NoForwards: story.NoForwards, Edited: story.Edited,
			Caption: story.Caption, Entities: story.Entities, Media: story.Media,
			MediaAreas: story.MediaAreas, Forward: story.Forward,
		})
		if err != nil {
			return domain.ModerationReport{}, false, fmt.Errorf("marshal story evidence: %w", err)
		}
		authorUserID := int64(0)
		if story.Owner.Type == domain.PeerTypeUser {
			authorUserID = story.Owner.ID
		}
		items = append(items, domain.ModerationReportItem{
			Kind: domain.ModerationItemStory, Peer: story.Owner,
			ItemID: int64(story.ID), AuthorUserID: authorUserID,
			EvidenceSchemaVersion: 1, Evidence: evidence,
		})
		holds = append(holds, mediaHolds(len(items)-1, story.Media)...)
	}
	return s.AcceptReport(ctx, domain.ModerationReportDraft{
		ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceStory,
		Target: req.Target, Reason: req.Reason, Option: req.Option,
		Comment: req.Comment, Items: items,
		MediaHolds: dedupeMediaHolds(holds), CreatedAt: req.CreatedAt,
	})
}

func (s *Service) ReportEphemeral(ctx context.Context, reporterUserID int64, target domain.EphemeralMessage, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error) {
	legacy := domain.NewEphemeralAbuseReport(reporterUserID, option, comment, target, createdAt)
	if err := legacy.Validate(); err != nil {
		return domain.ModerationReport{}, false, err
	}
	evidence, err := json.Marshal(struct {
		SchemaVersion int                            `json:"schema_version"`
		Evidence      domain.EphemeralReportEvidence `json:"evidence"`
	}{SchemaVersion: 1, Evidence: legacy.Evidence})
	if err != nil {
		return domain.ModerationReport{}, false, fmt.Errorf("marshal ephemeral report evidence: %w", err)
	}
	holds := mediaHolds(0, target.Content.Media)
	return s.AcceptReport(ctx, domain.ModerationReportDraft{
		ReporterUserID: reporterUserID, Source: domain.ModerationSourceEphemeral,
		Target: target.Peer, Reason: reason, Option: option, Comment: comment,
		Items: []domain.ModerationReportItem{{
			Kind: domain.ModerationItemEphemeral, Peer: target.Peer,
			ItemID: int64(target.ID), AuthorUserID: target.SenderUserID,
			EvidenceSchemaVersion: 1, Evidence: evidence,
		}},
		MediaHolds: holds, CreatedAt: createdAt,
	})
}

type peerEvidenceV1 struct {
	SchemaVersion int                    `json:"schema_version"`
	Target        domain.Peer            `json:"target"`
	User          *peerUserEvidenceV1    `json:"user,omitempty"`
	Channel       *peerChannelEvidenceV1 `json:"channel,omitempty"`
}

type peerUserEvidenceV1 struct {
	ID        int64  `json:"id"`
	FirstName string `json:"first_name"`
	LastName  string `json:"last_name"`
	Username  string `json:"username"`
	About     string `json:"about"`
	Bot       bool   `json:"bot,omitempty"`
	Verified  bool   `json:"verified,omitempty"`
	Scam      bool   `json:"scam,omitempty"`
	Fake      bool   `json:"fake,omitempty"`
	PhotoID   int64  `json:"photo_id,omitempty"`
}

type peerChannelEvidenceV1 struct {
	ID        int64  `json:"id"`
	Title     string `json:"title"`
	About     string `json:"about"`
	Username  string `json:"username"`
	Broadcast bool   `json:"broadcast,omitempty"`
	Megagroup bool   `json:"megagroup,omitempty"`
	Verified  bool   `json:"verified,omitempty"`
	Scam      bool   `json:"scam,omitempty"`
	Fake      bool   `json:"fake,omitempty"`
	PhotoID   int64  `json:"photo_id,omitempty"`
}

type profilePhotoEvidenceV1 struct {
	SchemaVersion int          `json:"schema_version"`
	Owner         domain.Peer  `json:"owner"`
	Photo         domain.Photo `json:"photo"`
}

type privateReactionEvidenceV1 struct {
	SchemaVersion int                         `json:"schema_version"`
	Message       privateMessageEvidenceV1    `json:"message"`
	ReactorUserID int64                       `json:"reactor_user_id"`
	Reactions     []messageReactionEvidenceV1 `json:"reactions"`
}

type channelReactionEvidenceV1 struct {
	SchemaVersion int                         `json:"schema_version"`
	Message       channelMessageEvidenceV1    `json:"message"`
	ReactorUserID int64                       `json:"reactor_user_id"`
	Reactions     []messageReactionEvidenceV1 `json:"reactions"`
}

type messageReactionEvidenceV1 struct {
	UserID      int64                      `json:"user_id"`
	Type        domain.MessageReactionType `json:"type"`
	Value       string                     `json:"value"`
	Big         bool                       `json:"big,omitempty"`
	Unread      bool                       `json:"unread,omitempty"`
	ChosenOrder int                        `json:"chosen_order,omitempty"`
	Date        int                        `json:"date"`
}

type encryptedChatEvidenceV1 struct {
	SchemaVersion     int                    `json:"schema_version"`
	ChatID            int                    `json:"chat_id"`
	State             domain.SecretChatState `json:"state"`
	AdminUserID       int64                  `json:"admin_user_id"`
	ParticipantUserID int64                  `json:"participant_user_id"`
	Date              int                    `json:"date"`
}

type privateMessageEvidenceV1 struct {
	SchemaVersion int                             `json:"schema_version"`
	MessageID     int                             `json:"message_id"`
	UID           int64                           `json:"uid"`
	Peer          domain.Peer                     `json:"peer"`
	From          domain.Peer                     `json:"from"`
	Date          int                             `json:"date"`
	EditDate      int                             `json:"edit_date,omitempty"`
	Body          string                          `json:"body"`
	Entities      []domain.MessageEntity          `json:"entities,omitempty"`
	ReplyTo       *domain.MessageReply            `json:"reply_to,omitempty"`
	Forward       *domain.MessageForward          `json:"forward,omitempty"`
	Reactions     *domain.ChannelMessageReactions `json:"reactions,omitempty"`
	Media         *domain.MessageMedia            `json:"media,omitempty"`
	RichMessage   *domain.MessageRichMessage      `json:"rich_message,omitempty"`
	GroupedID     int64                           `json:"grouped_id,omitempty"`
}

type channelMessageEvidenceV1 struct {
	SchemaVersion int                             `json:"schema_version"`
	ChannelID     int64                           `json:"channel_id"`
	MessageID     int                             `json:"message_id"`
	SenderUserID  int64                           `json:"sender_user_id"`
	From          domain.Peer                     `json:"from"`
	SendAs        *domain.Peer                    `json:"send_as,omitempty"`
	Date          int                             `json:"date"`
	EditDate      int                             `json:"edit_date,omitempty"`
	Post          bool                            `json:"post,omitempty"`
	Body          string                          `json:"body"`
	Entities      []domain.MessageEntity          `json:"entities,omitempty"`
	ReplyTo       *domain.MessageReply            `json:"reply_to,omitempty"`
	Forward       *domain.MessageForward          `json:"forward,omitempty"`
	Reactions     *domain.ChannelMessageReactions `json:"reactions,omitempty"`
	Action        *domain.ChannelMessageAction    `json:"action,omitempty"`
	Media         *domain.MessageMedia            `json:"media,omitempty"`
	RichMessage   *domain.MessageRichMessage      `json:"rich_message,omitempty"`
	GroupedID     int64                           `json:"grouped_id,omitempty"`
}

type storyEvidenceV1 struct {
	SchemaVersion    int                     `json:"schema_version"`
	Owner            domain.Peer             `json:"owner"`
	StoryID          int                     `json:"story_id"`
	Date             int                     `json:"date"`
	ExpireDate       int                     `json:"expire_date"`
	Pinned           bool                    `json:"pinned,omitempty"`
	Public           bool                    `json:"public,omitempty"`
	CloseFriends     bool                    `json:"close_friends,omitempty"`
	Contacts         bool                    `json:"contacts,omitempty"`
	SelectedContacts bool                    `json:"selected_contacts,omitempty"`
	NoForwards       bool                    `json:"no_forwards,omitempty"`
	Edited           bool                    `json:"edited,omitempty"`
	Caption          string                  `json:"caption"`
	Entities         []domain.MessageEntity  `json:"entities,omitempty"`
	Media            *domain.MessageMedia    `json:"media,omitempty"`
	MediaAreas       []domain.StoryMediaArea `json:"media_areas,omitempty"`
	Forward          *domain.StoryForward    `json:"forward,omitempty"`
}

func privateMessageEvidence(message domain.Message) privateMessageEvidenceV1 {
	return privateMessageEvidenceV1{
		SchemaVersion: 1, MessageID: message.ID, UID: message.UID,
		Peer: message.Peer, From: message.From, Date: message.Date,
		EditDate: message.EditDate, Body: message.Body,
		Entities: message.Entities, ReplyTo: message.ReplyTo,
		Forward: message.Forward, Reactions: message.Reactions,
		Media: message.Media, RichMessage: message.RichMessage,
		GroupedID: message.GroupedID,
	}
}

func channelMessageEvidence(message domain.ChannelMessage) channelMessageEvidenceV1 {
	return channelMessageEvidenceV1{
		SchemaVersion: 1, ChannelID: message.ChannelID,
		MessageID: message.ID, SenderUserID: message.SenderUserID,
		From: message.From, SendAs: message.SendAs, Date: message.Date,
		EditDate: message.EditDate, Post: message.Post, Body: message.Body,
		Entities: message.Entities, ReplyTo: message.ReplyTo,
		Forward: message.Forward, Reactions: message.Reactions,
		Action: message.Action, Media: message.Media,
		RichMessage: message.RichMessage, GroupedID: message.GroupedID,
	}
}

func marshalChannelMessageEvidence(message domain.ChannelMessage) ([]byte, error) {
	evidence, err := json.Marshal(channelMessageEvidence(message))
	if err != nil {
		return nil, fmt.Errorf("marshal channel message evidence: %w", err)
	}
	return evidence, nil
}

func reactionRowsForUser(reactions *domain.ChannelMessageReactions, userID int64) []domain.ChannelMessagePeerReaction {
	if reactions == nil || userID <= 0 {
		return nil
	}
	rows := make([]domain.ChannelMessagePeerReaction, 0, len(reactions.Recent))
	for _, reaction := range reactions.Recent {
		if reaction.UserID == userID {
			rows = append(rows, reaction)
		}
	}
	return rows
}

func reactionEvidenceRows(rows []domain.ChannelMessagePeerReaction) []messageReactionEvidenceV1 {
	out := make([]messageReactionEvidenceV1, 0, len(rows))
	for _, row := range rows {
		out = append(out, messageReactionEvidenceV1{
			UserID: row.UserID, Type: row.Reaction.Type,
			Value: row.Reaction.Value(), Big: row.Big, Unread: row.Unread,
			ChosenOrder: row.ChosenOrder, Date: row.Date,
		})
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].ChosenOrder != out[j].ChosenOrder {
			return out[i].ChosenOrder < out[j].ChosenOrder
		}
		if out[i].Type != out[j].Type {
			return out[i].Type < out[j].Type
		}
		return out[i].Value < out[j].Value
	})
	return out
}

func canonicalPositiveIDs(ids []int, max int) ([]int, error) {
	if len(ids) == 0 || len(ids) > domain.MaxModerationReportItems {
		return nil, domain.ErrModerationReportInvalid
	}
	seen := make(map[int]struct{}, len(ids))
	out := make([]int, 0, len(ids))
	for _, id := range ids {
		if id <= 0 || id > max {
			return nil, domain.ErrModerationReportInvalid
		}
		if _, duplicate := seen[id]; !duplicate {
			seen[id] = struct{}{}
			out = append(out, id)
		}
	}
	sort.Ints(out)
	return out, nil
}

func mediaHolds(itemIndex int, media *domain.MessageMedia) []domain.ModerationMediaHold {
	if media == nil {
		return nil
	}
	holds := make([]domain.ModerationMediaHold, 0, 8)
	addPhoto := func(photo *domain.Photo) {
		if photo == nil || photo.ID <= 0 {
			return
		}
		for _, size := range photo.Sizes {
			if size.Type != "" {
				holds = append(holds, domain.ModerationMediaHold{
					ItemIndex: itemIndex, Kind: domain.ModerationMediaPhoto,
					StorageKey: "photo:" + strconv.FormatInt(photo.ID, 10) + ":" + size.Type,
				})
			}
		}
	}
	addDocument := func(document *domain.Document) {
		if document == nil || document.ID <= 0 {
			return
		}
		prefix := "doc:" + strconv.FormatInt(document.ID, 10)
		holds = append(holds, domain.ModerationMediaHold{
			ItemIndex: itemIndex, Kind: domain.ModerationMediaDocument,
			StorageKey: prefix,
		})
		for _, thumb := range document.Thumbs {
			if thumb.Type != "" {
				holds = append(holds, domain.ModerationMediaHold{
					ItemIndex: itemIndex, Kind: domain.ModerationMediaDocument,
					StorageKey: prefix + ":" + thumb.Type,
				})
			}
		}
	}
	addPhoto(media.Photo)
	addDocument(media.Document)
	addDocument(media.LivePhotoVideo)
	return dedupeMediaHolds(holds)
}

func photoHolds(itemIndex int, photo domain.Photo) []domain.ModerationMediaHold {
	if photo.ID <= 0 {
		return nil
	}
	holds := make([]domain.ModerationMediaHold, 0, len(photo.Sizes))
	for _, size := range photo.Sizes {
		if size.Type == "" {
			continue
		}
		holds = append(holds, domain.ModerationMediaHold{
			ItemIndex: itemIndex, Kind: domain.ModerationMediaPhoto,
			StorageKey: "photo:" + strconv.FormatInt(photo.ID, 10) + ":" + size.Type,
		})
	}
	return dedupeMediaHolds(holds)
}

func dedupeMediaHolds(holds []domain.ModerationMediaHold) []domain.ModerationMediaHold {
	if len(holds) == 0 {
		return nil
	}
	seen := make(map[domain.ModerationMediaHold]struct{}, len(holds))
	out := make([]domain.ModerationMediaHold, 0, len(holds))
	for _, hold := range holds {
		if _, duplicate := seen[hold]; duplicate {
			continue
		}
		seen[hold] = struct{}{}
		out = append(out, hold)
	}
	return out
}