feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
512
internal/app/moderation/actions.go
Normal file
512
internal/app/moderation/actions.go
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type moderationAdminActions interface {
|
||||
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
||||
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
||||
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
|
||||
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
|
||||
}
|
||||
|
||||
type moderationChannelDeleter interface {
|
||||
ModerationDeleteMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error)
|
||||
}
|
||||
|
||||
type moderationChannelDeleteNotifier interface {
|
||||
NotifyModerationChannelDeletion(ctx context.Context, result domain.DeleteChannelMessagesResult)
|
||||
}
|
||||
|
||||
type moderationAccountDeleter interface {
|
||||
ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error)
|
||||
}
|
||||
|
||||
type moderationAppealLinkIssuer interface {
|
||||
IssueAppealLink(ctx context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error)
|
||||
}
|
||||
|
||||
type ActionExecutor struct {
|
||||
admin moderationAdminActions
|
||||
channels moderationChannelDeleter
|
||||
channelNotifier moderationChannelDeleteNotifier
|
||||
accounts moderationAccountDeleter
|
||||
appealLinks moderationAppealLinkIssuer
|
||||
publicBaseURL string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type ActionExecutorOption func(*ActionExecutor)
|
||||
|
||||
func WithAppealLinks(issuer moderationAppealLinkIssuer, publicBaseURL string) ActionExecutorOption {
|
||||
return func(executor *ActionExecutor) {
|
||||
executor.appealLinks = issuer
|
||||
executor.publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
|
||||
}
|
||||
}
|
||||
|
||||
func WithActionClock(now func() time.Time) ActionExecutorOption {
|
||||
return func(executor *ActionExecutor) {
|
||||
if now != nil {
|
||||
executor.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewActionExecutor(adminActions moderationAdminActions, channels moderationChannelDeleter, channelNotifier moderationChannelDeleteNotifier, accounts moderationAccountDeleter, opts ...ActionExecutorOption) *ActionExecutor {
|
||||
executor := &ActionExecutor{
|
||||
admin: adminActions, channels: channels,
|
||||
channelNotifier: channelNotifier, accounts: accounts,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(executor)
|
||||
}
|
||||
}
|
||||
return executor
|
||||
}
|
||||
|
||||
type freezeAccountActionPayload struct {
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
AppealURL string `json:"appeal_url,omitempty"`
|
||||
}
|
||||
|
||||
type deletePrivateMessageActionPayload struct {
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
IDs []int `json:"ids"`
|
||||
Revoke bool `json:"revoke"`
|
||||
}
|
||||
|
||||
type deleteChannelMessageActionPayload struct {
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
|
||||
func (e *ActionExecutor) Execute(ctx context.Context, detail domain.ModerationCaseDetail, action domain.ModerationAction) error {
|
||||
if e == nil || action.CaseID != detail.Case.ID {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
actor, _, ok := decisionAuditContext(detail.Decisions, action.DecisionID)
|
||||
if !ok {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
meta := admin.CommandMeta{
|
||||
CommandID: action.CommandID, Actor: actor,
|
||||
Reason: fmt.Sprintf("moderation case %d decision %d", detail.Case.ID, action.DecisionID),
|
||||
}
|
||||
switch action.Kind {
|
||||
case domain.ModerationActionMarkScam:
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.setPeerFlags(ctx, detail.Case.Target, true, false, meta)
|
||||
case domain.ModerationActionMarkFake:
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.setPeerFlags(ctx, detail.Case.Target, false, true, meta)
|
||||
case domain.ModerationActionClearPeerFlags:
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.setPeerFlags(ctx, detail.Case.Target, false, false, meta)
|
||||
case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount:
|
||||
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload freezeAccountActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
frozen := action.Kind == domain.ModerationActionFreezeAccount
|
||||
if !frozen && (!payload.Until.IsZero() || payload.AppealURL != "") {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if frozen {
|
||||
now := e.now().UTC()
|
||||
if payload.Until.IsZero() {
|
||||
payload.Until = now.Add(30 * 24 * time.Hour)
|
||||
}
|
||||
if !payload.Until.After(now) {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if payload.AppealURL == "" {
|
||||
if e.appealLinks == nil || e.publicBaseURL == "" {
|
||||
return fmt.Errorf("moderation appeal link issuer is not configured")
|
||||
}
|
||||
linkExpiresAt := payload.Until
|
||||
maxLinkExpiry := now.Add(domain.MaxModerationAppealLinkLifetime)
|
||||
if linkExpiresAt.After(maxLinkExpiry) {
|
||||
linkExpiresAt = maxLinkExpiry
|
||||
}
|
||||
token, err := e.appealLinks.IssueAppealLink(
|
||||
ctx, detail.Case.ID, detail.Case.Target.ID,
|
||||
linkExpiresAt, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload.AppealURL = e.publicBaseURL + "/appeal/" + token
|
||||
}
|
||||
}
|
||||
_, err := e.admin.SetAccountFrozen(ctx, admin.SetAccountFrozenRequest{
|
||||
CommandMeta: meta, UserID: detail.Case.Target.ID, Frozen: frozen,
|
||||
Until: payload.Until, AppealURL: payload.AppealURL,
|
||||
})
|
||||
return err
|
||||
case domain.ModerationActionDeletePrivateMessage:
|
||||
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload deletePrivateMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if payload.OwnerUserID <= 0 || len(payload.IDs) == 0 ||
|
||||
len(payload.IDs) > domain.MaxDeleteMessageIDs {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
_, err := e.admin.DeletePrivateMessages(ctx, admin.DeletePrivateMessagesRequest{
|
||||
CommandMeta: meta, OwnerUserID: payload.OwnerUserID,
|
||||
Peer: detail.Case.Target, IDs: payload.IDs, Revoke: payload.Revoke,
|
||||
})
|
||||
return err
|
||||
case domain.ModerationActionDeleteChannelMessage:
|
||||
if e.channels == nil || detail.Case.Target.Type != domain.PeerTypeChannel {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload deleteChannelMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload.IDs) == 0 || len(payload.IDs) > domain.MaxDeleteMessageIDs {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
result, err := e.channels.ModerationDeleteMessages(
|
||||
ctx, detail.Case.Target.ID, payload.IDs, int(e.now().Unix()),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.channelNotifier != nil {
|
||||
e.channelNotifier.NotifyModerationChannelDeletion(ctx, result)
|
||||
}
|
||||
return nil
|
||||
case domain.ModerationActionDeleteAccount:
|
||||
if e.accounts == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := e.accounts.ExecuteAccountDeletion(
|
||||
ctx, detail.Case.Target.ID, domain.AccountDeletionManual,
|
||||
fmt.Sprintf("moderation case %d", detail.Case.ID), e.now().UTC(),
|
||||
)
|
||||
return err
|
||||
default:
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) validateDecisionActions(ctx context.Context, detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[domain.ModerationActionKind]struct{}, len(actions))
|
||||
flagActions := 0
|
||||
freezeActions := 0
|
||||
hasDeleteAccount := false
|
||||
for _, action := range actions {
|
||||
if _, duplicate := seen[action.Kind]; duplicate {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
seen[action.Kind] = struct{}{}
|
||||
switch action.Kind {
|
||||
case domain.ModerationActionMarkScam, domain.ModerationActionMarkFake,
|
||||
domain.ModerationActionClearPeerFlags:
|
||||
flagActions++
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount:
|
||||
freezeActions++
|
||||
if detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload freezeAccountActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if action.Kind == domain.ModerationActionUnfreezeAccount &&
|
||||
(!payload.Until.IsZero() || payload.AppealURL != "") {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
case domain.ModerationActionDeletePrivateMessage:
|
||||
var payload deletePrivateMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if detail.Case.Target.Type != domain.PeerTypeUser ||
|
||||
payload.OwnerUserID <= 0 ||
|
||||
!validModerationMessageIDs(payload.IDs) ||
|
||||
!s.privateDeletionCoveredByEvidence(ctx, detail, payload) {
|
||||
return domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
case domain.ModerationActionDeleteChannelMessage:
|
||||
var payload deleteChannelMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if detail.Case.Target.Type != domain.PeerTypeChannel ||
|
||||
!validModerationMessageIDs(payload.IDs) ||
|
||||
!s.channelDeletionCoveredByEvidence(ctx, detail, payload.IDs) {
|
||||
return domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
case domain.ModerationActionDeleteAccount:
|
||||
hasDeleteAccount = true
|
||||
if detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
if flagActions > 1 || freezeActions > 1 ||
|
||||
(hasDeleteAccount && len(actions) != 1) {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) privateDeletionCoveredByEvidence(ctx context.Context, detail domain.ModerationCaseDetail, payload deletePrivateMessageActionPayload) bool {
|
||||
needed := make(map[int]struct{}, len(payload.IDs))
|
||||
for _, id := range payload.IDs {
|
||||
needed[id] = struct{}{}
|
||||
}
|
||||
for _, reportID := range detail.ReportIDs {
|
||||
report, found, err := s.Report(ctx, reportID)
|
||||
if err != nil || !found || report.ReporterUserID != payload.OwnerUserID {
|
||||
continue
|
||||
}
|
||||
for _, item := range report.Items {
|
||||
if item.Kind == domain.ModerationItemMessage &&
|
||||
item.Peer == detail.Case.Target {
|
||||
delete(needed, int(item.ItemID))
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(needed) == 0
|
||||
}
|
||||
|
||||
func (s *Service) channelDeletionCoveredByEvidence(ctx context.Context, detail domain.ModerationCaseDetail, ids []int) bool {
|
||||
needed := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
needed[id] = struct{}{}
|
||||
}
|
||||
for _, reportID := range detail.ReportIDs {
|
||||
report, found, err := s.Report(ctx, reportID)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
for _, item := range report.Items {
|
||||
if item.Kind == domain.ModerationItemMessage &&
|
||||
item.Peer == detail.Case.Target {
|
||||
delete(needed, int(item.ItemID))
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(needed) == 0
|
||||
}
|
||||
|
||||
func validModerationMessageIDs(ids []int) bool {
|
||||
if len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
|
||||
return false
|
||||
}
|
||||
seen := make(map[int]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := seen[id]; duplicate {
|
||||
return false
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *ActionExecutor) setPeerFlags(ctx context.Context, target domain.Peer, scam, fake bool, meta admin.CommandMeta) error {
|
||||
if e.admin == nil {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
switch target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
_, err := e.admin.SetUserFlags(ctx, admin.SetUserFlagsRequest{
|
||||
CommandMeta: meta, UserID: target.ID, Scam: scam, Fake: fake,
|
||||
})
|
||||
return err
|
||||
case domain.PeerTypeChannel:
|
||||
_, err := e.admin.SetChannelFlags(ctx, admin.SetChannelFlagsRequest{
|
||||
CommandMeta: meta, ChannelID: target.ID, Scam: scam, Fake: fake,
|
||||
})
|
||||
return err
|
||||
default:
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func decisionAuditContext(decisions []domain.ModerationDecision, decisionID int64) (string, string, bool) {
|
||||
for _, decision := range decisions {
|
||||
if decision.ID == decisionID {
|
||||
return decision.Actor, decision.Reason, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func decodeStrictActionPayload(raw json.RawMessage, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ActionWorker struct {
|
||||
store store.ModerationCaseStore
|
||||
executor *ActionExecutor
|
||||
interval time.Duration
|
||||
lease time.Duration
|
||||
batch int
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewActionWorker(caseStore store.ModerationCaseStore, executor *ActionExecutor, log *zap.Logger) *ActionWorker {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &ActionWorker{
|
||||
store: caseStore, executor: executor,
|
||||
interval: time.Second, lease: 30 * time.Second, batch: 20, log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ActionWorker) Run(ctx context.Context) {
|
||||
if w == nil || w.store == nil || w.executor == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := w.runOnce(ctx); err != nil && ctx.Err() == nil {
|
||||
w.log.Warn("审核处置任务执行失败", zap.Error(err))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ActionWorker) runOnce(ctx context.Context) error {
|
||||
now := time.Now().UTC()
|
||||
actions, err := w.store.ClaimModerationActions(ctx, now, w.batch, w.lease)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, action := range actions {
|
||||
current, currentErr := w.store.IsModerationActionCurrent(ctx, action)
|
||||
if currentErr == nil && !current {
|
||||
if err := w.store.SupersedeModerationAction(
|
||||
ctx, action.ID, action.Attempts, time.Now().UTC(),
|
||||
); err != nil {
|
||||
w.log.Warn("提交已被新案件取代的审核处置失败",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
w.log.Info("审核处置已被同目标的更新处置取代",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
detail, found, getErr := w.store.GetModerationCase(ctx, action.CaseID)
|
||||
execErr := currentErr
|
||||
if execErr == nil {
|
||||
execErr = getErr
|
||||
}
|
||||
if execErr == nil && !found {
|
||||
execErr = domain.ErrModerationCaseNotFound
|
||||
}
|
||||
if execErr == nil {
|
||||
execErr = w.executor.Execute(ctx, detail, action)
|
||||
}
|
||||
finishedAt := time.Now().UTC()
|
||||
retryAt := finishedAt
|
||||
errorText := ""
|
||||
if execErr != nil {
|
||||
errorText = execErr.Error()
|
||||
retryAt = finishedAt.Add(moderationActionRetryDelay(action.Attempts))
|
||||
}
|
||||
if err := w.store.CompleteModerationAction(
|
||||
ctx, action.ID, action.Attempts, execErr == nil,
|
||||
errorText, retryAt, finishedAt,
|
||||
); err != nil {
|
||||
w.log.Warn("提交审核处置结果失败",
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.Int("attempts", action.Attempts),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if execErr != nil {
|
||||
w.log.Warn("审核处置等待重试",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)),
|
||||
zap.Int("attempts", action.Attempts),
|
||||
zap.Error(execErr))
|
||||
} else {
|
||||
w.log.Info("审核处置完成",
|
||||
zap.Int64("case_id", action.CaseID),
|
||||
zap.Int64("action_id", action.ID),
|
||||
zap.String("kind", string(action.Kind)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func moderationActionRetryDelay(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
attempt = 1
|
||||
}
|
||||
delay := time.Second << min(attempt-1, 10)
|
||||
if delay > time.Hour {
|
||||
return time.Hour
|
||||
}
|
||||
return delay
|
||||
}
|
||||
325
internal/app/moderation/actions_test.go
Normal file
325
internal/app/moderation/actions_test.go
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type captureModerationAdmin struct {
|
||||
userFlags []admin.SetUserFlagsRequest
|
||||
frozen []admin.SetAccountFrozenRequest
|
||||
}
|
||||
|
||||
func (a *captureModerationAdmin) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
||||
a.frozen = append(a.frozen, req)
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (a *captureModerationAdmin) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
|
||||
a.userFlags = append(a.userFlags, req)
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (*captureModerationAdmin) SetChannelFlags(context.Context, admin.SetChannelFlagsRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (*captureModerationAdmin) DeletePrivateMessages(context.Context, admin.DeletePrivateMessagesRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func TestActionWorkerAppliesFakeFlagAndResolvesCase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Add(-10 * time.Second)
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: 101, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonFake, Option: "fake",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{Limit: 10})
|
||||
if err != nil || len(cases) != 1 {
|
||||
t.Fatalf("cases=%+v err=%v", cases, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decision, created, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "impersonation confirmed",
|
||||
CommandID: "mod-fake-1", Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(2 * time.Second),
|
||||
})
|
||||
if err != nil || !created || decision.Case.Status != domain.ModerationCaseActionPending {
|
||||
t.Fatalf("decision=%+v created=%v err=%v", decision, created, err)
|
||||
}
|
||||
adminActions := &captureModerationAdmin{}
|
||||
worker := NewActionWorker(
|
||||
reports,
|
||||
NewActionExecutor(adminActions, nil, nil, nil),
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err := worker.runOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(adminActions.userFlags) != 1 {
|
||||
t.Fatalf("flag actions=%d, want 1", len(adminActions.userFlags))
|
||||
}
|
||||
flag := adminActions.userFlags[0]
|
||||
if flag.UserID != target.ID || flag.Scam || !flag.Fake ||
|
||||
flag.CommandID != "mod-fake-1:000" || flag.Actor != "reviewer" {
|
||||
t.Fatalf("flag request=%+v", flag)
|
||||
}
|
||||
resolved, found, err := service.Case(ctx, claimed.ID)
|
||||
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved ||
|
||||
resolved.Actions[0].Status != domain.ModerationActionSucceeded {
|
||||
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionWorkerSupersedesOlderTargetSanction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
createDecision := func(reporter int64, command string, kind domain.ModerationActionKind, at time.Time) int64 {
|
||||
t.Helper()
|
||||
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonFake, Option: command,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: at,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{
|
||||
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
|
||||
Target: target, Limit: 10,
|
||||
})
|
||||
if err != nil || len(cases) != 1 {
|
||||
t.Fatalf("open cases=%+v err=%v", cases, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, cases[0].ID, cases[0].Version, "reviewer", at.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "confirmed", CommandID: command,
|
||||
Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{Kind: kind, Payload: []byte(`{}`)}},
|
||||
CreatedAt: at.Add(2 * time.Second),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return claimed.ID
|
||||
}
|
||||
oldCaseID := createDecision(101, "old-scam", domain.ModerationActionMarkScam, now)
|
||||
newCaseID := createDecision(102, "new-fake", domain.ModerationActionMarkFake, now.Add(3*time.Second))
|
||||
|
||||
adminActions := &captureModerationAdmin{}
|
||||
worker := NewActionWorker(
|
||||
reports,
|
||||
NewActionExecutor(adminActions, nil, nil, nil),
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err := worker.runOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(adminActions.userFlags) != 1 || adminActions.userFlags[0].Scam ||
|
||||
!adminActions.userFlags[0].Fake {
|
||||
t.Fatalf("flag actions=%+v", adminActions.userFlags)
|
||||
}
|
||||
oldDetail, _, err := service.Case(ctx, oldCaseID)
|
||||
if err != nil || oldDetail.Case.Status != domain.ModerationCaseResolved ||
|
||||
len(oldDetail.Actions) != 1 ||
|
||||
oldDetail.Actions[0].Status != domain.ModerationActionSuperseded {
|
||||
t.Fatalf("old detail=%+v err=%v", oldDetail, err)
|
||||
}
|
||||
newDetail, _, err := service.Case(ctx, newCaseID)
|
||||
if err != nil || newDetail.Case.Status != domain.ModerationCaseResolved ||
|
||||
len(newDetail.Actions) != 1 ||
|
||||
newDetail.Actions[0].Status != domain.ModerationActionSucceeded {
|
||||
t.Fatalf("new detail=%+v err=%v", newDetail, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppealCannotClearSanctionOwnedByNewerCase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
createCase := func(reporter int64, option, command string, at time.Time) domain.ModerationCase {
|
||||
t.Helper()
|
||||
if _, _, err := service.AcceptReport(ctx, 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,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := service.ListCases(ctx, domain.ModerationCaseFilter{
|
||||
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
|
||||
Target: target, Limit: 10,
|
||||
})
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("open cases=%+v err=%v", items, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, items[0].ID, items[0].Version, "reviewer", at.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "confirmed", CommandID: command,
|
||||
Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: at.Add(2 * time.Second),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, _, err := service.Case(ctx, claimed.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return detail.Case
|
||||
}
|
||||
oldCase := createCase(101, "old", "old", now)
|
||||
adminActions := &captureModerationAdmin{}
|
||||
worker := NewActionWorker(reports, NewActionExecutor(adminActions, nil, nil, nil), zap.NewNop())
|
||||
if err := worker.runOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appeal, _, err := service.SubmitAppeal(ctx, oldCase.ID, target.ID, "mistake", now.Add(3*time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newCase := createCase(102, "new", "new", now.Add(4*time.Second))
|
||||
if newCase.ID == oldCase.ID {
|
||||
t.Fatal("new report reused decided case")
|
||||
}
|
||||
oldDetail, _, err := service.Case(ctx, oldCase.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(ctx, oldCase.ID, oldDetail.Case.Version, "reviewer", now.Add(8*time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = service.ReviewAppeal(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: oldCase.ID, AppealID: appeal.ID,
|
||||
ExpectedVersion: claimed.Version, Actor: "reviewer",
|
||||
Reason: "grant", CommandID: "stale-appeal",
|
||||
Kind: domain.ModerationDecisionAppealGrant,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(9 * time.Second),
|
||||
})
|
||||
if !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||
t.Fatalf("ReviewAppeal error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type captureAppealLinkIssuer struct {
|
||||
caseID int64
|
||||
appellantID int64
|
||||
expiresAt time.Time
|
||||
issuedAt time.Time
|
||||
returnedToken string
|
||||
}
|
||||
|
||||
func (i *captureAppealLinkIssuer) IssueAppealLink(_ context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error) {
|
||||
i.caseID = caseID
|
||||
i.appellantID = appellantUserID
|
||||
i.expiresAt = expiresAt
|
||||
i.issuedAt = now
|
||||
return i.returnedToken, nil
|
||||
}
|
||||
|
||||
func TestActionExecutorFreezeDefaultsAndBoundsAppealLink(t *testing.T) {
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
adminActions := &captureModerationAdmin{}
|
||||
issuer := &captureAppealLinkIssuer{returnedToken: "token"}
|
||||
executor := NewActionExecutor(
|
||||
adminActions, nil, nil, nil,
|
||||
WithActionClock(func() time.Time { return now }),
|
||||
WithAppealLinks(issuer, "https://example.test/"),
|
||||
)
|
||||
detail := domain.ModerationCaseDetail{
|
||||
Case: domain.ModerationCase{
|
||||
ID: 10, Target: domain.Peer{Type: domain.PeerTypeUser, ID: 20},
|
||||
},
|
||||
Decisions: []domain.ModerationDecision{{
|
||||
ID: 30, Actor: "reviewer",
|
||||
}},
|
||||
}
|
||||
action := domain.ModerationAction{
|
||||
CaseID: 10, DecisionID: 30,
|
||||
Kind: domain.ModerationActionFreezeAccount,
|
||||
Payload: []byte(`{}`), CommandID: "freeze:000",
|
||||
}
|
||||
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(adminActions.frozen) != 1 {
|
||||
t.Fatalf("freeze calls=%d", len(adminActions.frozen))
|
||||
}
|
||||
req := adminActions.frozen[0]
|
||||
wantUntil := now.Add(30 * 24 * time.Hour)
|
||||
if !req.Frozen || req.UserID != 20 || !req.Until.Equal(wantUntil) ||
|
||||
req.AppealURL != "https://example.test/appeal/token" {
|
||||
t.Fatalf("freeze request=%+v", req)
|
||||
}
|
||||
if issuer.caseID != 10 || issuer.appellantID != 20 ||
|
||||
!issuer.expiresAt.Equal(wantUntil) || !issuer.issuedAt.Equal(now) {
|
||||
t.Fatalf("appeal issue=%+v", issuer)
|
||||
}
|
||||
|
||||
adminActions.frozen = nil
|
||||
longUntil := now.Add(365 * 24 * time.Hour)
|
||||
action.Payload = []byte(`{"until":"` + longUntil.Format(time.RFC3339Nano) + `"}`)
|
||||
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !adminActions.frozen[0].Until.Equal(longUntil) {
|
||||
t.Fatalf("long freeze until=%v", adminActions.frozen[0].Until)
|
||||
}
|
||||
if want := now.Add(domain.MaxModerationAppealLinkLifetime); !issuer.expiresAt.Equal(want) {
|
||||
t.Fatalf("link expiry=%v want=%v", issuer.expiresAt, want)
|
||||
}
|
||||
}
|
||||
83
internal/app/moderation/appeal_links.go
Normal file
83
internal/app/moderation/appeal_links.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const moderationAppealTokenBytes = 32
|
||||
|
||||
// IssueAppealLink creates a hash-only, time-bounded bearer capability. The raw
|
||||
// token is returned once and must only be embedded in the affected user's
|
||||
// client-visible appeal URL.
|
||||
func (s *Service) IssueAppealLink(ctx context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return "", fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
raw := make([]byte, moderationAppealTokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate moderation appeal token: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
link := domain.ModerationAppealLink{
|
||||
CaseID: caseID, AppellantUserID: appellantUserID,
|
||||
TokenHash: sha256.Sum256(raw), ExpiresAt: expiresAt.UTC(),
|
||||
CreatedAt: now.UTC(),
|
||||
}
|
||||
if _, err := s.cases.IssueModerationAppealLink(ctx, link); err == nil {
|
||||
return token, nil
|
||||
} else if !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", domain.ErrModerationActionConflict
|
||||
}
|
||||
|
||||
func (s *Service) ResolveAppealLink(ctx context.Context, token string, now time.Time) (domain.ModerationAppealLink, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppealLink{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
hash, err := moderationAppealTokenHash(token)
|
||||
if err != nil {
|
||||
return domain.ModerationAppealLink{}, false, err
|
||||
}
|
||||
return s.cases.GetModerationAppealLink(ctx, hash, now.UTC())
|
||||
}
|
||||
|
||||
func (s *Service) Appeal(ctx context.Context, appealID int64) (domain.ModerationAppeal, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.GetModerationAppeal(ctx, appealID)
|
||||
}
|
||||
|
||||
func (s *Service) SubmitAppealLink(ctx context.Context, token, text string, now time.Time) (domain.ModerationAppeal, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
hash, err := moderationAppealTokenHash(token)
|
||||
if err != nil {
|
||||
return domain.ModerationAppeal{}, false, err
|
||||
}
|
||||
return s.cases.SubmitModerationAppealByLink(ctx, hash, text, now.UTC())
|
||||
}
|
||||
|
||||
func moderationAppealTokenHash(token string) ([sha256.Size]byte, error) {
|
||||
if len(token) != base64.RawURLEncoding.EncodedLen(moderationAppealTokenBytes) {
|
||||
return [sha256.Size]byte{}, domain.ErrModerationAppealLinkInvalid
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil || len(raw) != moderationAppealTokenBytes ||
|
||||
base64.RawURLEncoding.EncodeToString(raw) != token {
|
||||
return [sha256.Size]byte{}, domain.ErrModerationAppealLinkInvalid
|
||||
}
|
||||
return sha256.Sum256(raw), nil
|
||||
}
|
||||
178
internal/app/moderation/appeal_links_test.go
Normal file
178
internal/app/moderation/appeal_links_test.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestAppealLinkSubmissionIsHashOnlyIdempotentAndExpires(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
store := memory.NewModerationReportStore()
|
||||
service := NewService(store)
|
||||
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||
ReporterUserID: 101, Source: domain.ModerationSourceAccountPeer,
|
||||
Target: target, Reason: domain.ModerationReasonFake, Option: "fake",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"schema_version":1}`),
|
||||
}},
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{Limit: 10})
|
||||
if err != nil || len(cases) != 1 {
|
||||
t.Fatalf("cases=%+v err=%v", cases, err)
|
||||
}
|
||||
claimed, err := service.ClaimCase(
|
||||
ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||
Actor: "reviewer", Reason: "confirmed", CommandID: "appeal-link-decision",
|
||||
Kind: domain.ModerationDecisionViolation,
|
||||
Actions: []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||
}},
|
||||
CreatedAt: now.Add(2 * time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(24*time.Hour), now.Add(3*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(token) != 43 {
|
||||
t.Fatalf("token length=%d", len(token))
|
||||
}
|
||||
link, found, err := service.ResolveAppealLink(ctx, token, now.Add(4*time.Second))
|
||||
if err != nil || !found || link.TokenHash == ([32]byte{}) {
|
||||
t.Fatalf("link=%+v found=%v err=%v", link, found, err)
|
||||
}
|
||||
expiredToken, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(10*time.Second), now.Add(4*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < domain.MaxModerationAppealLinksPerCase-2; i++ {
|
||||
issuedAt := now.Add(time.Duration(20+i) * time.Second)
|
||||
if _, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(time.Hour), issuedAt,
|
||||
); err != nil {
|
||||
t.Fatalf("issue bounded link %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := service.IssueAppealLink(
|
||||
ctx, claimed.ID, target.ID, now.Add(time.Hour), now.Add(time.Minute),
|
||||
); !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||
t.Fatalf("appeal link overflow err=%v", err)
|
||||
}
|
||||
if actions, err := store.ClaimModerationActions(
|
||||
ctx, now.Add(5*time.Second), 10, time.Minute,
|
||||
); err != nil || len(actions) != 1 {
|
||||
t.Fatalf("actions=%+v err=%v", actions, err)
|
||||
} else if err := store.CompleteModerationAction(
|
||||
ctx, actions[0].ID, actions[0].Attempts, true, "",
|
||||
time.Time{}, now.Add(6*time.Second),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appeal, created, err := service.SubmitAppealLink(
|
||||
ctx, token, "The account was impersonated.", now.Add(7*time.Second),
|
||||
)
|
||||
if err != nil || !created || appeal.CaseID != claimed.ID ||
|
||||
appeal.AppellantUserID != target.ID {
|
||||
t.Fatalf("appeal=%+v created=%v err=%v", appeal, created, err)
|
||||
}
|
||||
retry, created, err := service.SubmitAppealLink(
|
||||
ctx, token, "A different retry body must not create another appeal.",
|
||||
now.Add(8*time.Second),
|
||||
)
|
||||
if err != nil || created || retry.ID != appeal.ID || retry.Text != appeal.Text {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
appealed, found, err := service.Case(ctx, detail.Case.ID)
|
||||
if err != nil || !found ||
|
||||
appealed.Case.Status != domain.ModerationCaseAppealReview ||
|
||||
len(appealed.Appeals) != 1 {
|
||||
t.Fatalf("appealed=%+v found=%v err=%v", appealed, found, err)
|
||||
}
|
||||
appealClaim, err := service.ClaimCase(
|
||||
ctx, appealed.Case.ID, appealed.Case.Version,
|
||||
"appeal-reviewer", now.Add(8*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant := domain.ModerationDecisionRequest{
|
||||
CaseID: appealClaim.ID, AppealID: appeal.ID,
|
||||
ExpectedVersion: appealClaim.Version, Actor: "appeal-reviewer",
|
||||
Reason: "original evidence was insufficient",
|
||||
CommandID: "appeal-grant-without-remedy",
|
||||
Kind: domain.ModerationDecisionAppealGrant,
|
||||
CreatedAt: now.Add(9 * time.Second),
|
||||
}
|
||||
if _, _, err := service.ReviewAppeal(ctx, grant); !errors.Is(
|
||||
err, domain.ErrModerationActionInvalid,
|
||||
) {
|
||||
t.Fatalf("grant without required flag remedy err=%v", err)
|
||||
}
|
||||
grant.CommandID = "appeal-grant-with-remedy"
|
||||
grant.Actions = []domain.ModerationActionDraft{{
|
||||
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
|
||||
}}
|
||||
granted, created, err := service.ReviewAppeal(ctx, grant)
|
||||
if err != nil || !created ||
|
||||
granted.Case.Status != domain.ModerationCaseActionPending {
|
||||
t.Fatalf("granted=%+v created=%v err=%v", granted, created, err)
|
||||
}
|
||||
remedies, err := store.ClaimModerationActions(
|
||||
ctx, now.Add(10*time.Second), 10, time.Minute,
|
||||
)
|
||||
if err != nil || len(remedies) != 1 ||
|
||||
remedies[0].Kind != domain.ModerationActionClearPeerFlags {
|
||||
t.Fatalf("remedies=%+v err=%v", remedies, err)
|
||||
}
|
||||
if err := store.CompleteModerationAction(
|
||||
ctx, remedies[0].ID, remedies[0].Attempts, true, "",
|
||||
time.Time{}, now.Add(11*time.Second),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dismissed, found, err := service.Case(ctx, appealed.Case.ID)
|
||||
if err != nil || !found ||
|
||||
dismissed.Case.Status != domain.ModerationCaseDismissed {
|
||||
t.Fatalf("dismissed=%+v found=%v err=%v", dismissed, found, err)
|
||||
}
|
||||
|
||||
if _, found, err := service.ResolveAppealLink(
|
||||
ctx, expiredToken, now.Add(10*time.Second),
|
||||
); err != nil || found {
|
||||
t.Fatalf("expired resolve found=%v err=%v", found, err)
|
||||
}
|
||||
if _, _, err := service.SubmitAppealLink(
|
||||
ctx, expiredToken, "too late", now.Add(10*time.Second),
|
||||
); !errors.Is(err, domain.ErrModerationAppealLinkInvalid) {
|
||||
t.Fatalf("expired submit err=%v", err)
|
||||
}
|
||||
if _, _, err := service.ResolveAppealLink(ctx, "not-a-token", now); !errors.Is(
|
||||
err, domain.ErrModerationAppealLinkInvalid,
|
||||
) {
|
||||
t.Fatalf("invalid token err=%v", err)
|
||||
}
|
||||
}
|
||||
180
internal/app/moderation/cases.go
Normal file
180
internal/app/moderation/cases.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Service) ListCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return nil, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.ListModerationCases(ctx, filter)
|
||||
}
|
||||
|
||||
func (s *Service) Case(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.GetModerationCase(ctx, caseID)
|
||||
}
|
||||
|
||||
func (s *Service) ClaimCase(ctx context.Context, caseID, expectedVersion int64, actor string, now time.Time) (domain.ModerationCase, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCase{}, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
return s.cases.ClaimModerationCase(ctx, caseID, expectedVersion, actor, now)
|
||||
}
|
||||
|
||||
func (s *Service) DecideCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
prepared, err := domain.NewModerationDecisionRequest(request)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
detail, found, err := s.cases.GetModerationCase(ctx, prepared.CaseID)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
if err := s.validateDecisionActions(ctx, detail, prepared.Actions); err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
return s.cases.DecideModerationCase(ctx, prepared)
|
||||
}
|
||||
|
||||
func (s *Service) SubmitAppeal(ctx context.Context, caseID, appellantUserID int64, text string, now time.Time) (domain.ModerationAppeal, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
detail, found, err := s.cases.GetModerationCase(ctx, caseID)
|
||||
if err != nil {
|
||||
return domain.ModerationAppeal{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
switch detail.Case.Target.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if detail.Case.Target.ID != appellantUserID {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
if s.channels == nil {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||
}
|
||||
view, err := s.channels.ResolveChannel(ctx, appellantUserID, detail.Case.Target.ID)
|
||||
if err != nil || view.Forbidden ||
|
||||
(view.Self.Role != domain.ChannelRoleCreator &&
|
||||
view.Self.Role != domain.ChannelRoleAdmin) {
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||
}
|
||||
default:
|
||||
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseInvalid
|
||||
}
|
||||
appeal, err := domain.NewModerationAppeal(
|
||||
caseID, appellantUserID, detail.Case.Status, text, now,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ModerationAppeal{}, false, err
|
||||
}
|
||||
return s.cases.CreateModerationAppeal(ctx, appeal)
|
||||
}
|
||||
|
||||
func (s *Service) ReviewAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
if s == nil || s.cases == nil {
|
||||
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||
}
|
||||
prepared, err := domain.NewModerationDecisionRequest(request)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if prepared.AppealID <= 0 ||
|
||||
(prepared.Kind != domain.ModerationDecisionAppealGrant &&
|
||||
prepared.Kind != domain.ModerationDecisionAppealDeny) {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseInvalid
|
||||
}
|
||||
detail, found, err := s.cases.GetModerationCase(ctx, prepared.CaseID)
|
||||
if err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
appealFound := false
|
||||
for _, appeal := range detail.Appeals {
|
||||
if appeal.ID == prepared.AppealID &&
|
||||
appeal.Status == domain.ModerationAppealPending {
|
||||
appealFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !appealFound {
|
||||
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||
}
|
||||
if err := s.validateDecisionActions(ctx, detail, prepared.Actions); err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
if prepared.Kind == domain.ModerationDecisionAppealGrant {
|
||||
if err := validateAppealRemedyActions(detail, prepared.Actions); err != nil {
|
||||
return domain.ModerationCaseDetail{}, false, err
|
||||
}
|
||||
}
|
||||
return s.cases.ReviewModerationAppeal(ctx, prepared)
|
||||
}
|
||||
|
||||
func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
|
||||
history := append([]domain.ModerationAction(nil), detail.Actions...)
|
||||
sort.Slice(history, func(i, j int) bool { return history[i].ID < history[j].ID })
|
||||
var flagsActive, freezeActive, irreversible bool
|
||||
for _, action := range history {
|
||||
if action.Status != domain.ModerationActionSucceeded {
|
||||
continue
|
||||
}
|
||||
switch action.Kind {
|
||||
case domain.ModerationActionMarkScam, domain.ModerationActionMarkFake:
|
||||
flagsActive = true
|
||||
case domain.ModerationActionClearPeerFlags:
|
||||
flagsActive = false
|
||||
case domain.ModerationActionFreezeAccount:
|
||||
freezeActive = true
|
||||
case domain.ModerationActionUnfreezeAccount:
|
||||
freezeActive = false
|
||||
case domain.ModerationActionDeletePrivateMessage,
|
||||
domain.ModerationActionDeleteChannelMessage,
|
||||
domain.ModerationActionDeleteAccount:
|
||||
irreversible = true
|
||||
}
|
||||
}
|
||||
if irreversible {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
expected := make(map[domain.ModerationActionKind]bool, 2)
|
||||
if flagsActive {
|
||||
expected[domain.ModerationActionClearPeerFlags] = true
|
||||
}
|
||||
if freezeActive {
|
||||
expected[domain.ModerationActionUnfreezeAccount] = true
|
||||
}
|
||||
if len(actions) != len(expected) {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !expected[action.Kind] {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
delete(expected, action.Kind)
|
||||
}
|
||||
if len(expected) != 0 {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
43
internal/app/moderation/cases_test.go
Normal file
43
internal/app/moderation/cases_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestValidateAppealRemedyActionsMatchesOnlyAppliedReversibleState(t *testing.T) {
|
||||
detail := domain.ModerationCaseDetail{Actions: []domain.ModerationAction{
|
||||
{ID: 2, Kind: domain.ModerationActionFreezeAccount, Status: domain.ModerationActionSucceeded},
|
||||
{ID: 1, Kind: domain.ModerationActionMarkScam, Status: domain.ModerationActionSucceeded},
|
||||
{ID: 3, Kind: domain.ModerationActionDeletePrivateMessage, Status: domain.ModerationActionFailed},
|
||||
}}
|
||||
remedies := []domain.ModerationActionDraft{
|
||||
{Kind: domain.ModerationActionClearPeerFlags},
|
||||
{Kind: domain.ModerationActionUnfreezeAccount},
|
||||
}
|
||||
if err := validateAppealRemedyActions(detail, remedies); err != nil {
|
||||
t.Fatalf("valid remedies err=%v", err)
|
||||
}
|
||||
if err := validateAppealRemedyActions(
|
||||
detail, remedies[:1],
|
||||
); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||
t.Fatalf("missing unfreeze err=%v", err)
|
||||
}
|
||||
if err := validateAppealRemedyActions(detail, []domain.ModerationActionDraft{
|
||||
{Kind: domain.ModerationActionMarkFake},
|
||||
{Kind: domain.ModerationActionUnfreezeAccount},
|
||||
}); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||
t.Fatalf("new punishment in appeal err=%v", err)
|
||||
}
|
||||
detail.Actions = append(detail.Actions, domain.ModerationAction{
|
||||
ID: 4, Kind: domain.ModerationActionDeletePrivateMessage,
|
||||
Status: domain.ModerationActionSucceeded,
|
||||
})
|
||||
if err := validateAppealRemedyActions(
|
||||
detail, remedies,
|
||||
); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||
t.Fatalf("irreversible grant err=%v", err)
|
||||
}
|
||||
}
|
||||
739
internal/app/moderation/evidence.go
Normal file
739
internal/app/moderation/evidence.go
Normal file
|
|
@ -0,0 +1,739 @@
|
|||
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
|
||||
}
|
||||
106
internal/app/moderation/legacy.go
Normal file
106
internal/app/moderation/legacy.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// MigrateLegacyEphemeralReports converts every pre-unified durable report into
|
||||
// the canonical moderation shape. The store commits the new report and its
|
||||
// legacy provenance mapping atomically; rerunning after a crash is safe.
|
||||
func (s *Service) MigrateLegacyEphemeralReports(ctx context.Context, source store.LegacyEphemeralReportReader, batchSize int) (int, error) {
|
||||
if s == nil || s.reports == nil || source == nil {
|
||||
return 0, fmt.Errorf("legacy ephemeral report migration is not configured")
|
||||
}
|
||||
if batchSize <= 0 || batchSize > 1000 {
|
||||
return 0, fmt.Errorf("legacy ephemeral report batch limit out of range")
|
||||
}
|
||||
importer, ok := s.reports.(store.LegacyEphemeralReportImporter)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("moderation report store does not support legacy imports")
|
||||
}
|
||||
migrated := 0
|
||||
for {
|
||||
rows, err := source.ListUnmigratedEphemeralReports(ctx, batchSize)
|
||||
if err != nil {
|
||||
return migrated, err
|
||||
}
|
||||
for _, legacy := range rows {
|
||||
report, err := legacyEphemeralModerationReport(legacy.Report)
|
||||
if err != nil {
|
||||
return migrated, fmt.Errorf("convert legacy ephemeral report %d: %w", legacy.ID, err)
|
||||
}
|
||||
if _, _, err := importer.ImportLegacyEphemeralReport(ctx, legacy.ID, report); err != nil {
|
||||
return migrated, fmt.Errorf("import legacy ephemeral report %d: %w", legacy.ID, err)
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
if len(rows) < batchSize {
|
||||
return migrated, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func legacyEphemeralModerationReport(legacy domain.EphemeralAbuseReport) (domain.ModerationReport, error) {
|
||||
if err := legacy.Validate(); err != nil {
|
||||
return domain.ModerationReport{}, err
|
||||
}
|
||||
reason, ok := legacyEphemeralModerationReason(legacy.Option)
|
||||
if !ok {
|
||||
return domain.ModerationReport{}, fmt.Errorf("%w: unsupported legacy option %q", domain.ErrModerationReportInvalid, legacy.Option)
|
||||
}
|
||||
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{}, fmt.Errorf("marshal legacy ephemeral evidence: %w", err)
|
||||
}
|
||||
return domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: legacy.ReporterUserID,
|
||||
Source: domain.ModerationSourceEphemeral,
|
||||
Target: legacy.Evidence.Peer,
|
||||
Reason: reason,
|
||||
Option: legacy.Option,
|
||||
Comment: legacy.Comment,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemEphemeral,
|
||||
Peer: legacy.Evidence.Peer,
|
||||
ItemID: int64(legacy.Evidence.MessageID),
|
||||
AuthorUserID: legacy.Evidence.SenderUserID,
|
||||
EvidenceSchemaVersion: 1,
|
||||
Evidence: evidence,
|
||||
}},
|
||||
MediaHolds: mediaHolds(0, legacy.Evidence.Content.Media),
|
||||
CreatedAt: legacy.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func legacyEphemeralModerationReason(option string) (domain.ModerationReason, bool) {
|
||||
switch option {
|
||||
case "spam":
|
||||
return domain.ModerationReasonSpam, true
|
||||
case "violence":
|
||||
return domain.ModerationReasonViolence, true
|
||||
case "pornography":
|
||||
return domain.ModerationReasonPornography, true
|
||||
case "child_abuse":
|
||||
return domain.ModerationReasonChildAbuse, true
|
||||
case "illegal_drugs":
|
||||
return domain.ModerationReasonIllegalDrugs, true
|
||||
case "personal_details":
|
||||
return domain.ModerationReasonPersonalDetails, true
|
||||
case "copyright":
|
||||
return domain.ModerationReasonCopyright, true
|
||||
case "fake":
|
||||
return domain.ModerationReasonFake, true
|
||||
case "other", "other:comment":
|
||||
return domain.ModerationReasonOther, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
88
internal/app/moderation/legacy_test.go
Normal file
88
internal/app/moderation/legacy_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type legacyEphemeralReader struct {
|
||||
rows []store.LegacyEphemeralReport
|
||||
}
|
||||
|
||||
func (r *legacyEphemeralReader) ListUnmigratedEphemeralReports(_ context.Context, limit int) ([]store.LegacyEphemeralReport, error) {
|
||||
if len(r.rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit > len(r.rows) {
|
||||
limit = len(r.rows)
|
||||
}
|
||||
out := append([]store.LegacyEphemeralReport(nil), r.rows[:limit]...)
|
||||
r.rows = r.rows[limit:]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type legacyModerationImporter struct {
|
||||
*memory.ModerationReportStore
|
||||
mappings map[int64]int64
|
||||
}
|
||||
|
||||
func (s *legacyModerationImporter) ImportLegacyEphemeralReport(ctx context.Context, legacyID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
|
||||
if reportID, ok := s.mappings[legacyID]; ok {
|
||||
existing, _, err := s.GetModerationReport(ctx, reportID)
|
||||
return existing, false, err
|
||||
}
|
||||
stored, created, err := s.CreateModerationReport(ctx, report)
|
||||
if err == nil {
|
||||
s.mappings[legacyID] = stored.ID
|
||||
}
|
||||
return stored, created, err
|
||||
}
|
||||
|
||||
func TestMigrateLegacyEphemeralReportsPreservesEvidenceAndMediaHolds(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
reporter := int64(101)
|
||||
message := domain.EphemeralMessage{
|
||||
ID: 44, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 303},
|
||||
SenderUserID: 202, ReceiverUserID: reporter, Date: int(now.Unix()),
|
||||
Content: domain.EphemeralContent{
|
||||
Message: "evidence",
|
||||
Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindDocument,
|
||||
Document: &domain.Document{
|
||||
ID: 909, AccessHash: 1, MimeType: "text/plain", Size: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
Version: 1, CreatedAt: now, ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
legacy := domain.NewEphemeralAbuseReport(reporter, "spam", "review", message, now)
|
||||
source := &legacyEphemeralReader{rows: []store.LegacyEphemeralReport{{ID: 7, Report: legacy}}}
|
||||
target := &legacyModerationImporter{
|
||||
ModerationReportStore: memory.NewModerationReportStore(),
|
||||
mappings: make(map[int64]int64),
|
||||
}
|
||||
service := NewService(target)
|
||||
count, err := service.MigrateLegacyEphemeralReports(context.Background(), source, 10)
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("migrate count=%d err=%v", count, err)
|
||||
}
|
||||
reports := target.Reports()
|
||||
if len(reports) != 1 {
|
||||
t.Fatalf("reports=%d, want 1", len(reports))
|
||||
}
|
||||
got := reports[0]
|
||||
if got.Source != domain.ModerationSourceEphemeral ||
|
||||
got.Target != message.Peer || len(got.Items) != 1 ||
|
||||
got.Items[0].AuthorUserID != message.SenderUserID {
|
||||
t.Fatalf("migrated report=%+v", got)
|
||||
}
|
||||
if len(got.MediaHolds) != 1 ||
|
||||
got.MediaHolds[0].StorageKey != "doc:909" {
|
||||
t.Fatalf("media holds=%+v", got.MediaHolds)
|
||||
}
|
||||
}
|
||||
102
internal/app/moderation/registry_reports.go
Normal file
102
internal/app/moderation/registry_reports.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Service) SponsoredImpression(ctx context.Context, userID int64, randomID []byte, now time.Time) (domain.SponsoredMessageImpression, error) {
|
||||
if s == nil || s.registry == nil || len(randomID) == 0 {
|
||||
return domain.SponsoredMessageImpression{}, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
impression, found, err := s.registry.GetSponsoredMessageImpression(
|
||||
ctx, userID, sha256.Sum256(randomID), now,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.SponsoredMessageImpression{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.SponsoredMessageImpression{}, domain.ErrModerationImpressionExpired
|
||||
}
|
||||
return impression, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReportSponsored(ctx context.Context, userID int64, randomID []byte, reason domain.ModerationReason, option string, now time.Time) (domain.ModerationReport, bool, error) {
|
||||
impression, err := s.SponsoredImpression(ctx, userID, randomID, now)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if impression.ReportID > 0 {
|
||||
report, found, err := s.Report(ctx, impression.ReportID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
|
||||
}
|
||||
return report, false, nil
|
||||
}
|
||||
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: userID, Source: domain.ModerationSourceSponsored,
|
||||
Target: impression.Target, Reason: reason, Option: option,
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemSponsored, Peer: impression.Target,
|
||||
ItemID: impression.ID, AuthorUserID: impression.AuthorUserID,
|
||||
EvidenceSchemaVersion: impression.EvidenceSchemaVersion,
|
||||
Evidence: impression.Evidence,
|
||||
}},
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
return s.registry.CreateSponsoredModerationReport(ctx, impression.ID, report)
|
||||
}
|
||||
|
||||
func (s *Service) ReportAntiSpamFalsePositive(ctx context.Context, reporterUserID, channelID int64, messageID int, now time.Time) (domain.ModerationReport, bool, error) {
|
||||
if s == nil || s.registry == nil {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
decision, found, err := s.registry.GetChannelAntiSpamDecision(
|
||||
ctx, channelID, messageID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||
}
|
||||
if decision.ReportID > 0 {
|
||||
report, found, err := s.Report(ctx, decision.ReportID)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
|
||||
}
|
||||
return report, false, nil
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
|
||||
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||
ReporterUserID: reporterUserID,
|
||||
Source: domain.ModerationSourceAntiSpamFalsePositive,
|
||||
Target: target,
|
||||
Reason: domain.ModerationReasonOther,
|
||||
Option: "false_positive",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemAntiSpamDecision, Peer: target,
|
||||
ItemID: decision.ID, SecondaryID: int64(messageID),
|
||||
AuthorUserID: decision.AuthorUserID,
|
||||
EvidenceSchemaVersion: decision.EvidenceSchemaVersion,
|
||||
Evidence: decision.Evidence,
|
||||
}},
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
return s.registry.CreateAntiSpamFalsePositiveReport(ctx, decision.ID, report)
|
||||
}
|
||||
98
internal/app/moderation/registry_reports_test.go
Normal file
98
internal/app/moderation/registry_reports_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestSponsoredReportRequiresIssuedImpressionAndLinksAtomically(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
store := memory.NewModerationReportStore()
|
||||
service := NewService(store)
|
||||
randomID := []byte("server-issued-random-id")
|
||||
if _, _, err := service.ReportSponsored(
|
||||
ctx, 11, randomID, domain.ModerationReasonSpam, "spam", now,
|
||||
); !errors.Is(err, domain.ErrModerationImpressionExpired) {
|
||||
t.Fatalf("unseen impression err=%v", err)
|
||||
}
|
||||
impression, err := domain.NewSponsoredMessageImpression(
|
||||
11, randomID, domain.Peer{Type: domain.PeerTypeChannel, ID: 22},
|
||||
33, []byte(`{"author_id":33,"creative_id":"creative-1"}`),
|
||||
now, now.Add(time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
impression, created, err := store.CreateSponsoredMessageImpression(ctx, impression)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("impression=%+v created=%v err=%v", impression, created, err)
|
||||
}
|
||||
report, created, err := service.ReportSponsored(
|
||||
ctx, 11, randomID, domain.ModerationReasonSpam, "spam", now.Add(time.Second),
|
||||
)
|
||||
if err != nil || !created || report.ID <= 0 {
|
||||
t.Fatalf("report=%+v created=%v err=%v", report, created, err)
|
||||
}
|
||||
retry, created, err := service.ReportSponsored(
|
||||
ctx, 11, randomID, domain.ModerationReasonFake, "fake", now.Add(2*time.Second),
|
||||
)
|
||||
if err != nil || created || retry.ID != report.ID {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
if reports := store.Reports(); len(reports) != 1 ||
|
||||
reports[0].Items[0].EvidenceHash != impression.EvidenceHash {
|
||||
t.Fatalf("reports=%+v", reports)
|
||||
}
|
||||
if _, _, err := service.ReportSponsored(
|
||||
ctx, 11, []byte("expired"),
|
||||
domain.ModerationReasonSpam, "spam", now.Add(2*time.Hour),
|
||||
); !errors.Is(err, domain.ErrModerationImpressionExpired) {
|
||||
t.Fatalf("expired/unseen err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntiSpamFalsePositiveRequiresNativeDecisionAndIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_750_000_000, 0).UTC()
|
||||
store := memory.NewModerationReportStore()
|
||||
service := NewService(store)
|
||||
if _, _, err := service.ReportAntiSpamFalsePositive(
|
||||
ctx, 11, 22, 33, now,
|
||||
); !errors.Is(err, domain.ErrModerationEvidenceNotFound) {
|
||||
t.Fatalf("missing decision err=%v", err)
|
||||
}
|
||||
decision, err := domain.NewChannelAntiSpamDecision(
|
||||
22, 33, 44,
|
||||
[]byte(`{"engine":"native-v1","score":0.99}`), now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decision, created, err := store.CreateChannelAntiSpamDecision(ctx, decision)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("decision=%+v created=%v err=%v", decision, created, err)
|
||||
}
|
||||
report, created, err := service.ReportAntiSpamFalsePositive(
|
||||
ctx, 11, 22, 33, now.Add(time.Second),
|
||||
)
|
||||
if err != nil || !created || report.ID <= 0 {
|
||||
t.Fatalf("report=%+v created=%v err=%v", report, created, err)
|
||||
}
|
||||
retry, created, err := service.ReportAntiSpamFalsePositive(
|
||||
ctx, 11, 22, 33, now.Add(2*time.Second),
|
||||
)
|
||||
if err != nil || created || retry.ID != report.ID {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
if reports := store.Reports(); len(reports) != 1 ||
|
||||
reports[0].Items[0].EvidenceHash != decision.EvidenceHash ||
|
||||
reports[0].Items[0].SecondaryID != 33 {
|
||||
t.Fatalf("reports=%+v", reports)
|
||||
}
|
||||
}
|
||||
89
internal/app/moderation/service.go
Normal file
89
internal/app/moderation/service.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service owns moderation submission invariants. RPC handlers provide
|
||||
// domain-only snapshots; the service canonicalizes and persists them before a
|
||||
// client may observe a successful report response.
|
||||
type Service struct {
|
||||
reports store.ModerationReportStore
|
||||
cases store.ModerationCaseStore
|
||||
registry store.ModerationEvidenceRegistryStore
|
||||
privateMessages privateMessageReader
|
||||
channelMessages channelMessageReader
|
||||
stories storyReader
|
||||
users userReader
|
||||
channels channelPeerReader
|
||||
photos profilePhotoReader
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
func WithMessageReaders(private privateMessageReader, channels channelMessageReader) Option {
|
||||
return func(service *Service) {
|
||||
service.privateMessages = private
|
||||
service.channelMessages = channels
|
||||
}
|
||||
}
|
||||
|
||||
func WithStoryReader(stories storyReader) Option {
|
||||
return func(service *Service) {
|
||||
service.stories = stories
|
||||
}
|
||||
}
|
||||
|
||||
func WithPeerReaders(users userReader, channels channelPeerReader) Option {
|
||||
return func(service *Service) {
|
||||
service.users = users
|
||||
service.channels = channels
|
||||
}
|
||||
}
|
||||
|
||||
func WithProfilePhotoReader(photos profilePhotoReader) Option {
|
||||
return func(service *Service) {
|
||||
service.photos = photos
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(reports store.ModerationReportStore, opts ...Option) *Service {
|
||||
service := &Service{reports: reports}
|
||||
if cases, ok := reports.(store.ModerationCaseStore); ok {
|
||||
service.cases = cases
|
||||
}
|
||||
if registry, ok := reports.(store.ModerationEvidenceRegistryStore); ok {
|
||||
service.registry = registry
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(service)
|
||||
}
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) AcceptReport(ctx context.Context, draft domain.ModerationReportDraft) (domain.ModerationReport, bool, error) {
|
||||
if s == nil || s.reports == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
|
||||
}
|
||||
report, err := domain.NewModerationReport(draft)
|
||||
if err != nil {
|
||||
return domain.ModerationReport{}, false, err
|
||||
}
|
||||
return s.reports.CreateModerationReport(ctx, report)
|
||||
}
|
||||
|
||||
func (s *Service) Report(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error) {
|
||||
if s == nil || s.reports == nil {
|
||||
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
|
||||
}
|
||||
if reportID <= 0 {
|
||||
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||
}
|
||||
return s.reports.GetModerationReport(ctx, reportID)
|
||||
}
|
||||
36
internal/app/moderation/service_test.go
Normal file
36
internal/app/moderation/service_test.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestAcceptReportReturnsDurableRetry(t *testing.T) {
|
||||
reports := memory.NewModerationReportStore()
|
||||
service := NewService(reports)
|
||||
draft := domain.ModerationReportDraft{
|
||||
ReporterUserID: 100, Source: domain.ModerationSourceMessagesSpam,
|
||||
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
|
||||
Reason: domain.ModerationReasonSpam, Option: "v1/spam",
|
||||
Items: []domain.ModerationReportItem{{
|
||||
Kind: domain.ModerationItemPeer,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
|
||||
ItemID: 200, AuthorUserID: 200, EvidenceSchemaVersion: 1,
|
||||
Evidence: []byte(`{"snapshot":"peer"}`),
|
||||
}},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
first, created, err := service.AcceptReport(context.Background(), draft)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first created=%v err=%v", created, err)
|
||||
}
|
||||
draft.CreatedAt = draft.CreatedAt.Add(time.Minute)
|
||||
retry, created, err := service.AcceptReport(context.Background(), draft)
|
||||
if err != nil || created || retry.ID != first.ID {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue