feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
|
|
@ -2,20 +2,43 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type readMetricTelemetry struct {
|
||||
MessageID int `json:"message_id"`
|
||||
ViewID int64 `json:"view_id"`
|
||||
TimeInViewMS int `json:"time_in_view_ms"`
|
||||
ActiveTimeInViewMS int `json:"active_time_in_view_ms"`
|
||||
HeightToViewportRatioPermille int `json:"height_to_viewport_ratio_permille"`
|
||||
SeenRangeRatioPermille int `json:"seen_range_ratio_permille"`
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesReportSpam(ctx context.Context, peer tg.InputPeerClass) (bool, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil {
|
||||
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if r.deps.Moderation == nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if _, _, err := r.deps.Moderation.ReportPeer(
|
||||
ctx, userID, domain.ModerationSourceMessagesSpam, target,
|
||||
domain.ModerationReasonSpam, "spam", "", r.clock.Now(),
|
||||
); err != nil {
|
||||
return false, moderationReportError(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
@ -24,11 +47,12 @@ func (r *Router) onMessagesReport(ctx context.Context, req *tg.MessagesReportReq
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||||
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(req.ID) == 0 {
|
||||
return nil, tgerr.New(400, "MESSAGE_REQUIRED")
|
||||
return nil, tgerr.New(400, "MESSAGE_ID_REQUIRED")
|
||||
}
|
||||
if len(req.ID) > maxGetMessagesIDs || len(req.Option) > maxReportOptionLength || utf8.RuneCountInString(req.Message) > maxReportCommentLength {
|
||||
return nil, limitInvalidErr()
|
||||
|
|
@ -38,7 +62,28 @@ func (r *Router) onMessagesReport(ctx context.Context, req *tg.MessagesReportReq
|
|||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
}
|
||||
return reportResultForOption(string(req.Option))
|
||||
result, err := reportResultForOption(string(req.Option))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, final := result.(*tg.ReportResultReported); !final {
|
||||
return result, nil
|
||||
}
|
||||
reason, ok := moderationReasonForReportOption(string(req.Option))
|
||||
if !ok {
|
||||
return nil, tgerr.New(400, "OPTION_INVALID")
|
||||
}
|
||||
if r.deps.Moderation == nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if _, _, err := r.deps.Moderation.ReportMessages(ctx, domain.ModerationMessageReportRequest{
|
||||
ReporterUserID: userID, Target: target, MessageIDs: req.ID,
|
||||
Reason: reason, Option: string(req.Option), Comment: req.Message,
|
||||
CreatedAt: r.clock.Now(),
|
||||
}); err != nil {
|
||||
return nil, moderationReportError(err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesReportReaction(ctx context.Context, req *tg.MessagesReportReactionRequest) (bool, error) {
|
||||
|
|
@ -49,21 +94,50 @@ func (r *Router) onMessagesReportReaction(ctx context.Context, req *tg.MessagesR
|
|||
if req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
|
||||
return false, messageIDInvalidErr()
|
||||
}
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||||
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ReactionPeer); err != nil {
|
||||
reactor, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ReactionPeer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if reactor.Type != domain.PeerTypeUser || reactor.ID <= 0 {
|
||||
return false, peerIDInvalidErr()
|
||||
}
|
||||
if r.deps.Moderation == nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if _, _, err := r.deps.Moderation.ReportReaction(ctx, domain.ModerationReactionReportRequest{
|
||||
ReporterUserID: userID, Target: target, MessageID: req.ID,
|
||||
ReactorUserID: reactor.ID, CreatedAt: r.clock.Now(),
|
||||
}); err != nil {
|
||||
return false, moderationReportError(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func moderationReportError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrModerationEvidenceNotFound):
|
||||
return messageIDInvalidErr()
|
||||
case errors.Is(err, domain.ErrModerationPermissionDenied):
|
||||
return tgerr.New(403, "CHAT_ADMIN_REQUIRED")
|
||||
case errors.Is(err, domain.ErrModerationRateLimited):
|
||||
return floodWaitErr(60)
|
||||
case errors.Is(err, domain.ErrModerationReportInvalid):
|
||||
return inputRequestInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesReportMessagesDelivery(ctx context.Context, req *tg.MessagesReportMessagesDeliveryRequest) (bool, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if len(req.ID) > maxGetMessagesIDs {
|
||||
if len(req.ID) == 0 || len(req.ID) > maxGetMessagesIDs {
|
||||
return false, limitInvalidErr()
|
||||
}
|
||||
for _, msgID := range req.ID {
|
||||
|
|
@ -71,9 +145,26 @@ func (r *Router) onMessagesReportMessagesDelivery(ctx context.Context, req *tg.M
|
|||
return false, messageIDInvalidErr()
|
||||
}
|
||||
}
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.validateTelemetryMessageIDs(ctx, userID, peer, req.ID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
ids := messageIDs64(req.ID)
|
||||
if r.deps.ClientTelemetry == nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if _, _, err := r.deps.ClientTelemetry.Record(
|
||||
ctx, userID, domain.ClientTelemetryMessageDelivery, peer, ids,
|
||||
struct {
|
||||
Push bool `json:"push"`
|
||||
}{Push: req.Push},
|
||||
r.clock.Now(),
|
||||
); err != nil {
|
||||
return false, clientTelemetryError(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
@ -82,46 +173,210 @@ func (r *Router) onMessagesReportReadMetrics(ctx context.Context, req *tg.Messag
|
|||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if len(req.Metrics) > maxReadMetrics {
|
||||
if len(req.Metrics) == 0 || len(req.Metrics) > maxReadMetrics {
|
||||
return false, limitInvalidErr()
|
||||
}
|
||||
ids := make([]int, 0, len(req.Metrics))
|
||||
payload := make([]readMetricTelemetry, 0, len(req.Metrics))
|
||||
for _, metric := range req.Metrics {
|
||||
if metric.MsgID <= 0 || metric.MsgID > domain.MaxMessageBoxID {
|
||||
return false, messageIDInvalidErr()
|
||||
}
|
||||
if metric.TimeInViewMs < 0 || metric.ActiveTimeInViewMs < 0 || metric.HeightToViewportRatioPermille < 0 || metric.SeenRangeRatioPermille < 0 {
|
||||
if metric.ViewID == 0 || metric.TimeInViewMs < 0 ||
|
||||
metric.TimeInViewMs > 24*60*60*1000 ||
|
||||
metric.ActiveTimeInViewMs < 0 ||
|
||||
metric.ActiveTimeInViewMs > metric.TimeInViewMs ||
|
||||
metric.HeightToViewportRatioPermille < 0 ||
|
||||
metric.HeightToViewportRatioPermille > 1_000_000 ||
|
||||
metric.SeenRangeRatioPermille < 0 ||
|
||||
metric.SeenRangeRatioPermille > 1000 {
|
||||
return false, limitInvalidErr()
|
||||
}
|
||||
ids = append(ids, metric.MsgID)
|
||||
payload = append(payload, readMetricTelemetry{
|
||||
MessageID: metric.MsgID, ViewID: metric.ViewID,
|
||||
TimeInViewMS: metric.TimeInViewMs,
|
||||
ActiveTimeInViewMS: metric.ActiveTimeInViewMs,
|
||||
HeightToViewportRatioPermille: metric.HeightToViewportRatioPermille,
|
||||
SeenRangeRatioPermille: metric.SeenRangeRatioPermille,
|
||||
})
|
||||
}
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||||
sort.Slice(payload, func(i, j int) bool {
|
||||
return payload[i].MessageID < payload[j].MessageID
|
||||
})
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.validateTelemetryMessageIDs(ctx, userID, peer, ids); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if r.deps.ClientTelemetry == nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if _, _, err := r.deps.ClientTelemetry.Record(
|
||||
ctx, userID, domain.ClientTelemetryReadMetrics, peer,
|
||||
messageIDs64(ids),
|
||||
struct {
|
||||
Metrics []readMetricTelemetry `json:"metrics"`
|
||||
}{Metrics: payload},
|
||||
r.clock.Now(),
|
||||
); err != nil {
|
||||
return false, clientTelemetryError(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesReportMusicListen(ctx context.Context, req *tg.MessagesReportMusicListenRequest) (bool, error) {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if req.ID == nil {
|
||||
return false, tgerr.New(400, "DOCUMENT_INVALID")
|
||||
}
|
||||
if req.ListenedDuration < 0 {
|
||||
if req.ListenedDuration < 0 || req.ListenedDuration > 24*60*60 {
|
||||
return false, limitInvalidErr()
|
||||
}
|
||||
document, err := r.musicDocumentFromInput(ctx, req.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if r.deps.ClientTelemetry == nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
if _, _, err := r.deps.ClientTelemetry.Record(
|
||||
ctx, userID, domain.ClientTelemetryMusicListen, domain.Peer{},
|
||||
[]int64{document.ID},
|
||||
struct {
|
||||
ListenedDuration int `json:"listened_duration"`
|
||||
}{ListenedDuration: req.ListenedDuration},
|
||||
r.clock.Now(),
|
||||
); err != nil {
|
||||
return false, clientTelemetryError(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesReportSponsoredMessage(ctx context.Context, req *tg.MessagesReportSponsoredMessageRequest) (tg.ChannelsSponsoredMessageReportResultClass, error) {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if len(req.RandomID) == 0 || len(req.RandomID) > maxReportRandomIDLength || len(req.Option) > maxReportOptionLength {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
if r.deps.Moderation == nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if _, err := r.deps.Moderation.SponsoredImpression(
|
||||
ctx, userID, req.RandomID, r.clock.Now(),
|
||||
); err != nil {
|
||||
if errors.Is(err, domain.ErrModerationImpressionExpired) ||
|
||||
errors.Is(err, domain.ErrModerationEvidenceNotFound) {
|
||||
return nil, tgerr.New(400, "RANDOM_ID_INVALID")
|
||||
}
|
||||
return nil, internalErr()
|
||||
}
|
||||
option := string(req.Option)
|
||||
if option == "" {
|
||||
return &tg.ChannelsSponsoredMessageReportResultChooseOption{
|
||||
Title: "Report sponsored message",
|
||||
Options: []tg.SponsoredMessageReportOption{
|
||||
{Text: "Scam or spam", Option: []byte("spam")},
|
||||
{Text: "Violence", Option: []byte("violence")},
|
||||
{Text: "Pornography", Option: []byte("pornography")},
|
||||
{Text: "Child abuse", Option: []byte("child_abuse")},
|
||||
{Text: "Illegal drugs", Option: []byte("illegal_drugs")},
|
||||
{Text: "Personal details", Option: []byte("personal_details")},
|
||||
{Text: "Fake or impersonation", Option: []byte("fake")},
|
||||
{Text: "Other", Option: []byte("other")},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
reason, ok := moderationReasonForReportOption(option)
|
||||
if option == "other" {
|
||||
reason, ok = domain.ModerationReasonOther, true
|
||||
}
|
||||
if !ok {
|
||||
return nil, tgerr.New(400, "OPTION_INVALID")
|
||||
}
|
||||
if _, _, err := r.deps.Moderation.ReportSponsored(
|
||||
ctx, userID, req.RandomID, reason, option, r.clock.Now(),
|
||||
); err != nil {
|
||||
if errors.Is(err, domain.ErrModerationImpressionExpired) ||
|
||||
errors.Is(err, domain.ErrModerationEvidenceNotFound) {
|
||||
return nil, tgerr.New(400, "RANDOM_ID_INVALID")
|
||||
}
|
||||
return nil, moderationReportError(err)
|
||||
}
|
||||
return &tg.ChannelsSponsoredMessageReportResultReported{}, nil
|
||||
}
|
||||
|
||||
func (r *Router) validateTelemetryMessageIDs(ctx context.Context, userID int64, peer domain.Peer, ids []int) error {
|
||||
if len(ids) == 0 || len(ids) > domain.MaxGetMessageIDs {
|
||||
return limitInvalidErr()
|
||||
}
|
||||
needed := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return messageIDInvalidErr()
|
||||
}
|
||||
if _, duplicate := needed[id]; duplicate {
|
||||
return messageIDInvalidErr()
|
||||
}
|
||||
needed[id] = struct{}{}
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if r.deps.Messages == nil {
|
||||
return internalErr()
|
||||
}
|
||||
list, err := r.deps.Messages.GetMessages(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return internalErr()
|
||||
}
|
||||
for _, message := range list.Messages {
|
||||
if message.Peer == peer {
|
||||
delete(needed, message.ID)
|
||||
}
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
if r.deps.Channels == nil {
|
||||
return internalErr()
|
||||
}
|
||||
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, ids)
|
||||
if err != nil {
|
||||
return internalErr()
|
||||
}
|
||||
for _, message := range history.Messages {
|
||||
delete(needed, message.ID)
|
||||
}
|
||||
default:
|
||||
return peerIDInvalidErr()
|
||||
}
|
||||
if len(needed) != 0 {
|
||||
return messageIDInvalidErr()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func messageIDs64(ids []int) []int64 {
|
||||
out := make([]int64, len(ids))
|
||||
for i, id := range ids {
|
||||
out[i] = int64(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clientTelemetryError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrClientTelemetryRateLimited):
|
||||
return floodWaitErr(60)
|
||||
case errors.Is(err, domain.ErrClientTelemetryInvalid):
|
||||
return inputRequestInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesGetSponsoredMessages(ctx context.Context, req *tg.MessagesGetSponsoredMessagesRequest) (tg.MessagesSponsoredMessagesClass, error) {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue