feat: sync durable moderation and appeals

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

View file

@ -0,0 +1,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
}

View 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)
}
}

View 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
}

View 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)
}
}

View 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
}

View 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)
}
}

View 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
}

View 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
}
}

View 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)
}
}

View 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)
}

View 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)
}
}

View 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)
}

View 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)
}
}