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,114 @@
package domain
import (
"crypto/sha256"
"encoding/json"
"errors"
"time"
"unicode/utf8"
)
const (
MaxAuthDeliveryMNCBytes = 8
MaxAuthDeliveryClientTypeBytes = 32
MaxAuthDeliveryIDBytes = 128
MaxAuthDeliveryReportsPerHour = 10
MaxAuthDeliveryReportsPerPhoneDay = 20
)
var (
ErrAuthDeliveryReportInvalid = errors.New("auth delivery report invalid")
ErrAuthDeliveryRateLimited = errors.New("auth delivery report rate limited")
)
// AuthDeliveryReport is operational delivery telemetry, not an abuse report.
// It deliberately stores only hashes of the phone and phone_code_hash and
// never stores the authentication code.
type AuthDeliveryReport struct {
ID int64
AuthKeyID [8]byte
SessionID int64
ClientType string
PhoneHash [sha256.Size]byte
CodeHash [sha256.Size]byte
IssuedUserID int64
DeliveryID string
Channel AuthCodeDeliveryKind
MNC string
Fingerprint [sha256.Size]byte
CreatedAt time.Time
}
type AuthMissingCodeReportRequest struct {
AuthKeyID [8]byte
SessionID int64
ClientType string
Phone string
PhoneCodeHash string
MNC string
CreatedAt time.Time
}
func NewAuthDeliveryReport(authKeyID [8]byte, sessionID int64, clientType, phone, phoneCodeHash string, issuedUserID int64, deliveryID string, channel AuthCodeDeliveryKind, mnc string, createdAt time.Time) (AuthDeliveryReport, error) {
report := AuthDeliveryReport{
AuthKeyID: authKeyID, SessionID: sessionID, ClientType: clientType,
PhoneHash: sha256.Sum256([]byte(phone)), CodeHash: sha256.Sum256([]byte(phoneCodeHash)),
IssuedUserID: issuedUserID, DeliveryID: deliveryID,
Channel: channel, MNC: mnc, CreatedAt: createdAt,
}
raw, err := json.Marshal(struct {
Version int
AuthKeyID [8]byte
SessionID int64
PhoneHash [sha256.Size]byte
CodeHash [sha256.Size]byte
DeliveryID string
Channel AuthCodeDeliveryKind
MNC string
}{
Version: 1, AuthKeyID: authKeyID, SessionID: sessionID,
PhoneHash: report.PhoneHash, CodeHash: report.CodeHash,
DeliveryID: deliveryID, Channel: channel, MNC: mnc,
})
if err != nil {
return AuthDeliveryReport{}, ErrAuthDeliveryReportInvalid
}
report.Fingerprint = sha256.Sum256(raw)
if err := report.Validate(); err != nil {
return AuthDeliveryReport{}, err
}
return report, nil
}
func (r AuthDeliveryReport) Validate() error {
if r.ID < 0 || r.AuthKeyID == ([8]byte{}) || r.SessionID == 0 ||
r.PhoneHash == ([sha256.Size]byte{}) || r.CodeHash == ([sha256.Size]byte{}) ||
r.Fingerprint == ([sha256.Size]byte{}) || r.IssuedUserID < 0 ||
len(r.ClientType) > MaxAuthDeliveryClientTypeBytes || !utf8.ValidString(r.ClientType) ||
len(r.DeliveryID) > MaxAuthDeliveryIDBytes || !utf8.ValidString(r.DeliveryID) ||
!validAuthDeliveryChannel(r.Channel) || !validMNC(r.MNC) || r.CreatedAt.IsZero() {
return ErrAuthDeliveryReportInvalid
}
return nil
}
func validAuthDeliveryChannel(channel AuthCodeDeliveryKind) bool {
switch channel {
case AuthCodeDeliveryPhone, AuthCodeDeliverySMS:
return true
default:
return false
}
}
func validMNC(mnc string) bool {
if len(mnc) > MaxAuthDeliveryMNCBytes {
return false
}
for _, r := range mnc {
if r < '0' || r > '9' {
return false
}
}
return true
}

View file

@ -508,6 +508,21 @@ type ChannelMember struct {
Guest bool
}
// CanInviteUsers reports whether this active member may directly add users.
// Keep this predicate aligned with both memory/postgres write boundaries so an
// RPC cannot expose another user's privacy decision before authorizing the
// actor.
func (m ChannelMember) CanInviteUsers(channel Channel) bool {
if m.Status != ChannelMemberActive {
return false
}
if m.Role == ChannelRoleCreator ||
(m.Role == ChannelRoleAdmin && (m.AdminRights.InviteUsers || m.AdminRights.ChangeInfo)) {
return true
}
return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !m.BannedRights.InviteUsers
}
// CanManageDirectMessages reports whether this active parent-channel member may
// see and address every subscriber topic in the linked direct-messages
// monoforum. Telegram deliberately does not grant this capability to an
@ -869,6 +884,22 @@ type ChannelMessageReactionsList struct {
NextOffset string
}
// ChannelMessageReactionLookupRequest is the bounded exact lookup used by
// moderation evidence capture. It avoids paging an arbitrarily large reactor
// list merely to prove that one named participant reacted.
type ChannelMessageReactionLookupRequest struct {
ViewerUserID int64
ChannelID int64
MessageID int
ReactorUserID int64
}
type ChannelMessageReactionLookup struct {
Channel Channel
Message ChannelMessage
Reactions []ChannelMessagePeerReaction
}
// RecentMessageReaction is one account-level recently used message reaction.
type RecentMessageReaction struct {
UserID int64

View file

@ -0,0 +1,131 @@
package domain
import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"sort"
"time"
)
const (
MaxClientTelemetrySubjects = 100
MaxClientTelemetryPayloadBytes = 64 << 10
MaxClientTelemetryEventsPerHour = 1000
MaxClientTelemetryEventsPerDay = 10000
)
var (
ErrClientTelemetryInvalid = errors.New("client telemetry invalid")
ErrClientTelemetryRateLimited = errors.New("client telemetry rate limited")
)
type ClientTelemetryKind string
const (
ClientTelemetryMessageDelivery ClientTelemetryKind = "message_delivery"
ClientTelemetryReadMetrics ClientTelemetryKind = "read_metrics"
ClientTelemetryMusicListen ClientTelemetryKind = "music_listen"
)
func (k ClientTelemetryKind) Valid() bool {
switch k {
case ClientTelemetryMessageDelivery, ClientTelemetryReadMetrics,
ClientTelemetryMusicListen:
return true
default:
return false
}
}
// ClientTelemetryEvent is operational product telemetry. It is deliberately
// isolated from moderation reports/cases and has TTL-based retention.
type ClientTelemetryEvent struct {
ID int64
UserID int64
Kind ClientTelemetryKind
Peer Peer
SubjectIDs []int64
Payload json.RawMessage
Fingerprint [sha256.Size]byte
CreatedAt time.Time
}
func NewClientTelemetryEvent(userID int64, kind ClientTelemetryKind, peer Peer, subjectIDs []int64, payload any, createdAt time.Time) (ClientTelemetryEvent, error) {
canonicalIDs := append([]int64(nil), subjectIDs...)
sort.Slice(canonicalIDs, func(i, j int) bool { return canonicalIDs[i] < canonicalIDs[j] })
for i, id := range canonicalIDs {
if id <= 0 || (i > 0 && canonicalIDs[i-1] == id) {
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
}
}
raw, err := json.Marshal(payload)
if err != nil {
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
}
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil || object == nil {
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
}
raw, err = json.Marshal(object)
if err != nil || len(raw) > MaxClientTelemetryPayloadBytes {
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
}
event := ClientTelemetryEvent{
UserID: userID, Kind: kind, Peer: peer,
SubjectIDs: canonicalIDs, Payload: raw, CreatedAt: createdAt.UTC(),
}
fingerprintInput, err := json.Marshal(struct {
Version int
UserID int64
Kind ClientTelemetryKind
Peer Peer
SubjectIDs []int64
Payload json.RawMessage
Minute int64
}{
Version: 1, UserID: event.UserID, Kind: event.Kind, Peer: event.Peer,
SubjectIDs: event.SubjectIDs, Payload: event.Payload,
Minute: event.CreatedAt.Truncate(time.Minute).Unix(),
})
if err != nil {
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
}
event.Fingerprint = sha256.Sum256(fingerprintInput)
if err := event.Validate(); err != nil {
return ClientTelemetryEvent{}, err
}
return event, nil
}
func (e ClientTelemetryEvent) Validate() error {
if e.ID < 0 || e.UserID <= 0 || !e.Kind.Valid() ||
len(e.SubjectIDs) == 0 ||
len(e.SubjectIDs) > MaxClientTelemetrySubjects ||
len(e.Payload) == 0 || len(e.Payload) > MaxClientTelemetryPayloadBytes ||
e.Fingerprint == ([sha256.Size]byte{}) || e.CreatedAt.IsZero() {
return ErrClientTelemetryInvalid
}
if e.Peer.ID == 0 {
if e.Peer.Type != "" || e.Kind != ClientTelemetryMusicListen {
return ErrClientTelemetryInvalid
}
} else if !moderationPeerValid(e.Peer) {
return ErrClientTelemetryInvalid
}
for i, id := range e.SubjectIDs {
if id <= 0 || (i > 0 && e.SubjectIDs[i-1] >= id) {
return ErrClientTelemetryInvalid
}
}
var object map[string]any
if err := json.Unmarshal(e.Payload, &object); err != nil || object == nil {
return ErrClientTelemetryInvalid
}
canonical, err := json.Marshal(object)
if err != nil || !bytes.Equal(canonical, e.Payload) {
return ErrClientTelemetryInvalid
}
return nil
}

View file

@ -0,0 +1,65 @@
package domain
import (
"errors"
"testing"
"time"
)
func TestNewClientTelemetryEventCanonicalizesSubjectsAndMinuteIdempotency(t *testing.T) {
now := time.Unix(1_750_000_000, 0).UTC().Truncate(time.Minute).Add(time.Second)
peer := Peer{Type: PeerTypeUser, ID: 22}
first, err := NewClientTelemetryEvent(
11, ClientTelemetryMessageDelivery, peer, []int64{3, 1, 2},
map[string]any{"push": true}, now,
)
if err != nil {
t.Fatal(err)
}
retry, err := NewClientTelemetryEvent(
11, ClientTelemetryMessageDelivery, peer, []int64{2, 3, 1},
struct {
Push bool `json:"push"`
}{Push: true},
now.Add(30*time.Second),
)
if err != nil {
t.Fatal(err)
}
if got := first.SubjectIDs; len(got) != 3 ||
got[0] != 1 || got[1] != 2 || got[2] != 3 {
t.Fatalf("canonical subjects = %v", got)
}
if first.Fingerprint != retry.Fingerprint {
t.Fatal("same telemetry inside one minute must have one fingerprint")
}
nextMinute, err := NewClientTelemetryEvent(
11, ClientTelemetryMessageDelivery, peer, []int64{1, 2, 3},
map[string]any{"push": true}, now.Add(time.Minute),
)
if err != nil {
t.Fatal(err)
}
if nextMinute.Fingerprint == first.Fingerprint {
t.Fatal("a new minute bucket must produce a new fingerprint")
}
}
func TestNewClientTelemetryEventRejectsDuplicateSubjectsAndInvalidPeer(t *testing.T) {
now := time.Now().UTC()
_, err := NewClientTelemetryEvent(
11, ClientTelemetryReadMetrics,
Peer{Type: PeerTypeUser, ID: 22},
[]int64{1, 1}, map[string]any{"metrics": []int{1}}, now,
)
if !errors.Is(err, ErrClientTelemetryInvalid) {
t.Fatalf("duplicate subjects err=%v", err)
}
_, err = NewClientTelemetryEvent(
11, ClientTelemetryMessageDelivery, Peer{},
[]int64{1}, map[string]any{"push": true}, now,
)
if !errors.Is(err, ErrClientTelemetryInvalid) {
t.Fatalf("missing message peer err=%v", err)
}
}

View file

@ -0,0 +1,486 @@
package domain
import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"sort"
"time"
"unicode/utf8"
)
const (
ModerationTaxonomyVersion = 1
MaxModerationReportItems = 100
MaxModerationMediaHolds = 1000
MaxModerationOptionBytes = 32
MaxModerationCommentRunes = 512
MaxModerationEvidenceBytes = 1 << 20
MaxModerationTotalEvidenceBytes = 4 << 20
MaxModerationMediaStorageKeyBytes = 512
MaxModerationReportsPerHour = 20
MaxModerationReportsPerDay = 100
)
var (
ErrModerationReportInvalid = errors.New("moderation report invalid")
ErrModerationReportNotFound = errors.New("moderation report not found")
ErrModerationCaseInvalid = errors.New("moderation case invalid")
ErrModerationCaseNotFound = errors.New("moderation case not found")
ErrModerationCaseConflict = errors.New("moderation case conflict")
ErrModerationActionInvalid = errors.New("moderation action invalid")
ErrModerationActionConflict = errors.New("moderation action conflict")
ErrModerationPermissionDenied = errors.New("moderation permission denied")
ErrModerationRateLimited = errors.New("moderation rate limited")
ErrModerationEvidenceNotFound = errors.New("moderation evidence not found")
ErrModerationDecisionNotFound = errors.New("moderation decision not found")
ErrModerationImpressionExpired = errors.New("moderation impression expired")
ErrModerationAppealLinkInvalid = errors.New("moderation appeal link invalid")
)
// ModerationReportSource identifies the client RPC and evidence admission path.
// Operational telemetry and authentication delivery diagnostics deliberately do
// not use this type or the moderation report tables.
type ModerationReportSource string
const (
ModerationSourceAccountPeer ModerationReportSource = "account_peer"
ModerationSourceProfilePhoto ModerationReportSource = "profile_photo"
ModerationSourceMessagesSpam ModerationReportSource = "messages_spam"
ModerationSourceMessages ModerationReportSource = "messages"
ModerationSourceEncryptedSpam ModerationReportSource = "encrypted_spam"
ModerationSourceReaction ModerationReportSource = "reaction"
ModerationSourceChannelSpam ModerationReportSource = "channel_spam"
ModerationSourceStory ModerationReportSource = "story"
ModerationSourceEphemeral ModerationReportSource = "ephemeral"
ModerationSourceSponsored ModerationReportSource = "sponsored"
ModerationSourceAntiSpamFalsePositive ModerationReportSource = "antispam_false_positive"
)
func (s ModerationReportSource) Valid() bool {
switch s {
case ModerationSourceAccountPeer, ModerationSourceProfilePhoto,
ModerationSourceMessagesSpam, ModerationSourceMessages,
ModerationSourceEncryptedSpam, ModerationSourceReaction,
ModerationSourceChannelSpam, ModerationSourceStory,
ModerationSourceEphemeral, ModerationSourceSponsored,
ModerationSourceAntiSpamFalsePositive:
return true
default:
return false
}
}
// ModerationReason is the canonical domain taxonomy shared by ReportReason
// constructors and the opaque multi-step report option flow.
type ModerationReason string
const (
ModerationReasonSpam ModerationReason = "spam"
ModerationReasonViolence ModerationReason = "violence"
ModerationReasonPornography ModerationReason = "pornography"
ModerationReasonChildAbuse ModerationReason = "child_abuse"
ModerationReasonOther ModerationReason = "other"
ModerationReasonCopyright ModerationReason = "copyright"
ModerationReasonGeoIrrelevant ModerationReason = "geo_irrelevant"
ModerationReasonFake ModerationReason = "fake"
ModerationReasonIllegalDrugs ModerationReason = "illegal_drugs"
ModerationReasonPersonalDetails ModerationReason = "personal_details"
)
func (r ModerationReason) Valid() bool {
switch r {
case ModerationReasonSpam, ModerationReasonViolence,
ModerationReasonPornography, ModerationReasonChildAbuse,
ModerationReasonOther, ModerationReasonCopyright,
ModerationReasonGeoIrrelevant, ModerationReasonFake,
ModerationReasonIllegalDrugs, ModerationReasonPersonalDetails:
return true
default:
return false
}
}
type ModerationReportItemKind string
const (
ModerationItemPeer ModerationReportItemKind = "peer"
ModerationItemMessage ModerationReportItemKind = "message"
ModerationItemProfilePhoto ModerationReportItemKind = "profile_photo"
ModerationItemReaction ModerationReportItemKind = "reaction"
ModerationItemStory ModerationReportItemKind = "story"
ModerationItemEncryptedChat ModerationReportItemKind = "encrypted_chat"
ModerationItemEphemeral ModerationReportItemKind = "ephemeral"
ModerationItemSponsored ModerationReportItemKind = "sponsored"
ModerationItemAntiSpamDecision ModerationReportItemKind = "antispam_decision"
)
func (k ModerationReportItemKind) Valid() bool {
switch k {
case ModerationItemPeer, ModerationItemMessage,
ModerationItemProfilePhoto, ModerationItemReaction,
ModerationItemStory, ModerationItemEncryptedChat,
ModerationItemEphemeral, ModerationItemSponsored,
ModerationItemAntiSpamDecision:
return true
default:
return false
}
}
type ModerationMediaKind string
const (
ModerationMediaPhoto ModerationMediaKind = "photo"
ModerationMediaDocument ModerationMediaKind = "document"
ModerationMediaBlob ModerationMediaKind = "blob"
)
func (k ModerationMediaKind) Valid() bool {
switch k {
case ModerationMediaPhoto, ModerationMediaDocument, ModerationMediaBlob:
return true
default:
return false
}
}
// ModerationReportItem is a stable reference plus a privacy-bounded evidence
// snapshot. Evidence must be a versioned JSON object produced by the owning app
// service; moderation never repairs malformed historical snapshots on read.
type ModerationReportItem struct {
Kind ModerationReportItemKind
Peer Peer
ItemID int64
SecondaryID int64
AuthorUserID int64
EvidenceSchemaVersion int
Evidence json.RawMessage
EvidenceHash [sha256.Size]byte
}
type ModerationMediaHold struct {
ItemIndex int
Kind ModerationMediaKind
StorageKey string
}
// ModerationReport is immutable after acceptance. ID is assigned by the store;
// Fingerprint is a deterministic SHA-256 of the immutable client intent and
// evidence identity, excluding CreatedAt.
type ModerationReport struct {
ID int64
ReporterUserID int64
Source ModerationReportSource
Target Peer
Reason ModerationReason
Option string
Comment string
CommentHash [sha256.Size]byte
Fingerprint [sha256.Size]byte
TaxonomyVersion int
Items []ModerationReportItem
MediaHolds []ModerationMediaHold
CreatedAt time.Time
}
type ModerationReportDraft struct {
ReporterUserID int64
Source ModerationReportSource
Target Peer
Reason ModerationReason
Option string
Comment string
TaxonomyVersion int
Items []ModerationReportItem
MediaHolds []ModerationMediaHold
CreatedAt time.Time
}
type ModerationMessageReportRequest struct {
ReporterUserID int64
Target Peer
MessageIDs []int
Reason ModerationReason
Option string
Comment string
CreatedAt time.Time
}
type ModerationStoryReportRequest struct {
ReporterUserID int64
Target Peer
StoryIDs []int
Reason ModerationReason
Option string
Comment string
CreatedAt time.Time
}
type ModerationProfilePhotoReportRequest struct {
ReporterUserID int64
Target Peer
PhotoID int64
AccessHash int64
FileReference []byte
Reason ModerationReason
Comment string
CreatedAt time.Time
}
type ModerationChannelSpamReportRequest struct {
ReporterUserID int64
ChannelID int64
ParticipantUserID int64
MessageIDs []int
CreatedAt time.Time
}
type ModerationReactionReportRequest struct {
ReporterUserID int64
Target Peer
MessageID int
ReactorUserID int64
CreatedAt time.Time
}
// NewModerationReport canonicalizes item order and computes all content hashes.
// Callers must pass snapshots, not mutable domain objects.
func NewModerationReport(draft ModerationReportDraft) (ModerationReport, error) {
originalItems := cloneModerationItems(draft.Items)
report := ModerationReport{
ReporterUserID: draft.ReporterUserID,
Source: draft.Source,
Target: draft.Target,
Reason: draft.Reason,
Option: draft.Option,
Comment: draft.Comment,
TaxonomyVersion: draft.TaxonomyVersion,
Items: cloneModerationItems(originalItems),
MediaHolds: append([]ModerationMediaHold(nil), draft.MediaHolds...),
CreatedAt: draft.CreatedAt,
}
if report.TaxonomyVersion == 0 {
report.TaxonomyVersion = ModerationTaxonomyVersion
}
report.CommentHash = sha256.Sum256([]byte(report.Comment))
for i := range report.Items {
evidence, err := CanonicalModerationEvidence(report.Items[i].Evidence)
if err != nil {
return ModerationReport{}, err
}
report.Items[i].Evidence = evidence
report.Items[i].EvidenceHash = sha256.Sum256(report.Items[i].Evidence)
}
sort.Slice(report.Items, func(i, j int) bool {
return moderationItemLess(report.Items[i], report.Items[j])
})
canonicalIndexes := make(map[moderationItemIdentity]int, len(report.Items))
for i, item := range report.Items {
canonicalIndexes[moderationItemIdentityOf(item)] = i
}
for i := range report.MediaHolds {
oldIndex := report.MediaHolds[i].ItemIndex
if oldIndex >= 0 && oldIndex < len(originalItems) {
if canonicalIndex, ok := canonicalIndexes[moderationItemIdentityOf(originalItems[oldIndex])]; ok {
report.MediaHolds[i].ItemIndex = canonicalIndex
}
}
}
sort.Slice(report.MediaHolds, func(i, j int) bool {
a, b := report.MediaHolds[i], report.MediaHolds[j]
if a.ItemIndex != b.ItemIndex {
return a.ItemIndex < b.ItemIndex
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
return a.StorageKey < b.StorageKey
})
fingerprint, err := moderationReportFingerprint(report)
if err != nil {
return ModerationReport{}, err
}
report.Fingerprint = fingerprint
if err := report.Validate(); err != nil {
return ModerationReport{}, err
}
return report, nil
}
func (r ModerationReport) Validate() error {
if r.ID < 0 || r.ReporterUserID <= 0 || !r.Source.Valid() ||
!moderationPeerValid(r.Target) || !r.Reason.Valid() ||
r.Option == "" || len(r.Option) > MaxModerationOptionBytes ||
!utf8.ValidString(r.Option) || !utf8.ValidString(r.Comment) ||
utf8.RuneCountInString(r.Comment) > MaxModerationCommentRunes ||
r.TaxonomyVersion <= 0 || r.TaxonomyVersion > 32767 ||
len(r.Items) == 0 || len(r.Items) > MaxModerationReportItems ||
len(r.MediaHolds) > MaxModerationMediaHolds || r.CreatedAt.IsZero() ||
r.CommentHash != sha256.Sum256([]byte(r.Comment)) {
return ErrModerationReportInvalid
}
totalEvidence := 0
seenItems := make(map[moderationItemIdentity]struct{}, len(r.Items))
for i, item := range r.Items {
if !item.Kind.Valid() || !moderationPeerValid(item.Peer) ||
item.ItemID <= 0 || item.SecondaryID < 0 || item.AuthorUserID < 0 ||
item.EvidenceSchemaVersion <= 0 || item.EvidenceSchemaVersion > 32767 ||
len(item.Evidence) == 0 || len(item.Evidence) > MaxModerationEvidenceBytes ||
!json.Valid(item.Evidence) ||
item.EvidenceHash != sha256.Sum256(item.Evidence) {
return ErrModerationReportInvalid
}
canonical, err := CanonicalModerationEvidence(item.Evidence)
if err != nil || !bytes.Equal(canonical, item.Evidence) {
return ErrModerationReportInvalid
}
if i > 0 && moderationItemLess(item, r.Items[i-1]) {
return ErrModerationReportInvalid
}
identity := moderationItemIdentityOf(item)
if _, duplicate := seenItems[identity]; duplicate {
return ErrModerationReportInvalid
}
seenItems[identity] = struct{}{}
totalEvidence += len(item.Evidence)
if totalEvidence > MaxModerationTotalEvidenceBytes {
return ErrModerationReportInvalid
}
}
seenHolds := make(map[ModerationMediaHold]struct{}, len(r.MediaHolds))
for _, hold := range r.MediaHolds {
if hold.ItemIndex < 0 || hold.ItemIndex >= len(r.Items) ||
!hold.Kind.Valid() || hold.StorageKey == "" ||
len(hold.StorageKey) > MaxModerationMediaStorageKeyBytes ||
!utf8.ValidString(hold.StorageKey) {
return ErrModerationReportInvalid
}
if _, duplicate := seenHolds[hold]; duplicate {
return ErrModerationReportInvalid
}
seenHolds[hold] = struct{}{}
}
fingerprint, err := moderationReportFingerprint(r)
if err != nil || fingerprint != r.Fingerprint {
return ErrModerationReportInvalid
}
return nil
}
type moderationItemIdentity struct {
Kind ModerationReportItemKind
PeerType PeerType
PeerID int64
ItemID int64
SecondaryID int64
}
func moderationItemIdentityOf(item ModerationReportItem) moderationItemIdentity {
return moderationItemIdentity{
Kind: item.Kind, PeerType: item.Peer.Type, PeerID: item.Peer.ID,
ItemID: item.ItemID, SecondaryID: item.SecondaryID,
}
}
func moderationItemLess(a, b ModerationReportItem) bool {
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
if a.Peer.Type != b.Peer.Type {
return a.Peer.Type < b.Peer.Type
}
if a.Peer.ID != b.Peer.ID {
return a.Peer.ID < b.Peer.ID
}
if a.ItemID != b.ItemID {
return a.ItemID < b.ItemID
}
return a.SecondaryID < b.SecondaryID
}
func moderationPeerValid(peer Peer) bool {
return peer.ID > 0 && (peer.Type == PeerTypeUser || peer.Type == PeerTypeChannel)
}
// CanonicalModerationEvidence normalizes a JSON object with Go's deterministic
// map-key ordering. This keeps evidence hashes stable after PostgreSQL jsonb
// normalizes whitespace and object key order.
func CanonicalModerationEvidence(raw json.RawMessage) (json.RawMessage, error) {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return nil, ErrModerationReportInvalid
}
if _, ok := value.(map[string]any); !ok {
return nil, ErrModerationReportInvalid
}
canonical, err := json.Marshal(value)
if err != nil || len(canonical) == 0 || len(canonical) > MaxModerationEvidenceBytes {
return nil, ErrModerationReportInvalid
}
return canonical, nil
}
type moderationFingerprintItem struct {
Kind ModerationReportItemKind `json:"kind"`
PeerType PeerType `json:"peer_type"`
PeerID int64 `json:"peer_id"`
ItemID int64 `json:"item_id"`
SecondaryID int64 `json:"secondary_id"`
AuthorUserID int64 `json:"author_user_id"`
EvidenceSchemaVersion int `json:"evidence_schema_version"`
EvidenceHash [sha256.Size]byte `json:"evidence_hash"`
}
type moderationFingerprintPayload struct {
Version int `json:"version"`
ReporterUserID int64 `json:"reporter_user_id"`
Source ModerationReportSource `json:"source"`
TargetType PeerType `json:"target_type"`
TargetID int64 `json:"target_id"`
Reason ModerationReason `json:"reason"`
Option string `json:"option"`
CommentHash [sha256.Size]byte `json:"comment_hash"`
TaxonomyVersion int `json:"taxonomy_version"`
Items []moderationFingerprintItem `json:"items"`
}
func moderationReportFingerprint(report ModerationReport) ([sha256.Size]byte, error) {
items := make([]moderationFingerprintItem, 0, len(report.Items))
for _, item := range report.Items {
items = append(items, moderationFingerprintItem{
Kind: item.Kind, PeerType: item.Peer.Type, PeerID: item.Peer.ID,
ItemID: item.ItemID, SecondaryID: item.SecondaryID,
AuthorUserID: item.AuthorUserID,
EvidenceSchemaVersion: item.EvidenceSchemaVersion,
EvidenceHash: item.EvidenceHash,
})
}
raw, err := json.Marshal(moderationFingerprintPayload{
Version: 1, ReporterUserID: report.ReporterUserID, Source: report.Source,
TargetType: report.Target.Type, TargetID: report.Target.ID,
Reason: report.Reason, Option: report.Option,
CommentHash: report.CommentHash, TaxonomyVersion: report.TaxonomyVersion,
Items: items,
})
if err != nil {
return [sha256.Size]byte{}, ErrModerationReportInvalid
}
return sha256.Sum256(raw), nil
}
func cloneModerationItems(items []ModerationReportItem) []ModerationReportItem {
out := make([]ModerationReportItem, len(items))
copy(out, items)
for i := range out {
out[i].Evidence = append(json.RawMessage(nil), items[i].Evidence...)
}
return out
}
func CloneModerationReport(report ModerationReport) ModerationReport {
report.Items = cloneModerationItems(report.Items)
report.MediaHolds = append([]ModerationMediaHold(nil), report.MediaHolds...)
return report
}

View file

@ -0,0 +1,523 @@
package domain
import (
"bytes"
"crypto/sha256"
"encoding/json"
"time"
"unicode/utf8"
)
const (
MaxModerationActorBytes = 128
MaxModerationDecisionCommandBytes = 120
MaxModerationDecisionTextRunes = 2000
MaxModerationActionPayload = 64 << 10
MaxModerationCasePage = 100
MaxModerationCaseDetailEntries = 100
MaxModerationActionsPerCase = 100
MaxModerationAppealTextRunes = 4000
MaxModerationActionAttempts = 20
MaxModerationAppealLinksPerCase = 20
MaxModerationAppealLinkLifetime = 90 * 24 * time.Hour
)
type ModerationSeverity int16
const (
ModerationSeverityLow ModerationSeverity = iota + 1
ModerationSeverityMedium
ModerationSeverityHigh
ModerationSeverityCritical
)
func (s ModerationSeverity) Valid() bool {
return s >= ModerationSeverityLow && s <= ModerationSeverityCritical
}
func ModerationSeverityForReason(reason ModerationReason) ModerationSeverity {
switch reason {
case ModerationReasonChildAbuse:
return ModerationSeverityCritical
case ModerationReasonViolence, ModerationReasonPornography,
ModerationReasonIllegalDrugs, ModerationReasonPersonalDetails:
return ModerationSeverityHigh
case ModerationReasonFake, ModerationReasonCopyright:
return ModerationSeverityMedium
default:
return ModerationSeverityLow
}
}
type ModerationCaseStatus string
const (
ModerationCaseOpen ModerationCaseStatus = "open"
ModerationCaseInReview ModerationCaseStatus = "in_review"
ModerationCaseActionPending ModerationCaseStatus = "action_pending"
ModerationCaseActionFailed ModerationCaseStatus = "action_failed"
ModerationCaseResolved ModerationCaseStatus = "resolved"
ModerationCaseDismissed ModerationCaseStatus = "dismissed"
ModerationCaseAppealReview ModerationCaseStatus = "appeal_review"
)
func (s ModerationCaseStatus) Valid() bool {
switch s {
case ModerationCaseOpen, ModerationCaseInReview,
ModerationCaseActionPending, ModerationCaseResolved,
ModerationCaseActionFailed, ModerationCaseDismissed,
ModerationCaseAppealReview:
return true
default:
return false
}
}
func (s ModerationCaseStatus) Active() bool {
switch s {
case ModerationCaseOpen, ModerationCaseInReview,
ModerationCaseActionPending, ModerationCaseActionFailed,
ModerationCaseAppealReview:
return true
default:
return false
}
}
type ModerationDecisionKind string
const (
ModerationDecisionNoViolation ModerationDecisionKind = "no_violation"
ModerationDecisionViolation ModerationDecisionKind = "violation"
ModerationDecisionAppealGrant ModerationDecisionKind = "appeal_granted"
ModerationDecisionAppealDeny ModerationDecisionKind = "appeal_denied"
)
func (k ModerationDecisionKind) Valid() bool {
switch k {
case ModerationDecisionNoViolation, ModerationDecisionViolation,
ModerationDecisionAppealGrant, ModerationDecisionAppealDeny:
return true
default:
return false
}
}
type ModerationActionKind string
const (
ModerationActionMarkScam ModerationActionKind = "mark_scam"
ModerationActionMarkFake ModerationActionKind = "mark_fake"
ModerationActionClearPeerFlags ModerationActionKind = "clear_peer_flags"
ModerationActionFreezeAccount ModerationActionKind = "freeze_account"
ModerationActionUnfreezeAccount ModerationActionKind = "unfreeze_account"
ModerationActionDeletePrivateMessage ModerationActionKind = "delete_private_message"
ModerationActionDeleteChannelMessage ModerationActionKind = "delete_channel_message"
ModerationActionDeleteAccount ModerationActionKind = "delete_account"
)
func (k ModerationActionKind) Valid() bool {
switch k {
case ModerationActionMarkScam, ModerationActionMarkFake,
ModerationActionClearPeerFlags, ModerationActionFreezeAccount,
ModerationActionUnfreezeAccount,
ModerationActionDeletePrivateMessage,
ModerationActionDeleteChannelMessage,
ModerationActionDeleteAccount:
return true
default:
return false
}
}
type ModerationActionStatus string
const (
ModerationActionPending ModerationActionStatus = "pending"
ModerationActionProcessing ModerationActionStatus = "processing"
ModerationActionSucceeded ModerationActionStatus = "succeeded"
ModerationActionSuperseded ModerationActionStatus = "superseded"
ModerationActionRetry ModerationActionStatus = "retry"
ModerationActionFailed ModerationActionStatus = "failed"
)
func (s ModerationActionStatus) Valid() bool {
switch s {
case ModerationActionPending, ModerationActionProcessing,
ModerationActionSucceeded, ModerationActionSuperseded,
ModerationActionRetry,
ModerationActionFailed:
return true
default:
return false
}
}
// ModerationSanctionFamily groups reversible actions that mutate the same
// target-scoped state. Only the latest desired action in a family may execute;
// older queued work is retained as superseded audit history.
type ModerationSanctionFamily string
const (
ModerationSanctionPeerFlags ModerationSanctionFamily = "peer_flags"
ModerationSanctionAccountFreeze ModerationSanctionFamily = "account_freeze"
)
func (k ModerationActionKind) SanctionFamily() (ModerationSanctionFamily, bool) {
switch k {
case ModerationActionMarkScam, ModerationActionMarkFake,
ModerationActionClearPeerFlags:
return ModerationSanctionPeerFlags, true
case ModerationActionFreezeAccount, ModerationActionUnfreezeAccount:
return ModerationSanctionAccountFreeze, true
default:
return "", false
}
}
type ModerationAppealStatus string
const (
ModerationAppealPending ModerationAppealStatus = "pending"
ModerationAppealGranted ModerationAppealStatus = "granted"
ModerationAppealRejected ModerationAppealStatus = "rejected"
)
func (s ModerationAppealStatus) Valid() bool {
switch s {
case ModerationAppealPending, ModerationAppealGranted, ModerationAppealRejected:
return true
default:
return false
}
}
type ModerationCase struct {
ID int64
Target Peer
Status ModerationCaseStatus
Severity ModerationSeverity
AssignedTo string
Version int64
ReportCount int
DistinctReporterCount int
FirstReportAt time.Time
LastReportAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func (c ModerationCase) Validate() error {
if c.ID <= 0 || !moderationPeerValid(c.Target) || !c.Status.Valid() ||
!c.Severity.Valid() || c.Version <= 0 || c.ReportCount <= 0 ||
c.DistinctReporterCount <= 0 ||
c.DistinctReporterCount > c.ReportCount ||
len(c.AssignedTo) > MaxModerationActorBytes ||
!utf8.ValidString(c.AssignedTo) || c.FirstReportAt.IsZero() ||
c.LastReportAt.Before(c.FirstReportAt) || c.CreatedAt.IsZero() ||
c.UpdatedAt.Before(c.CreatedAt) {
return ErrModerationCaseInvalid
}
if (c.Status == ModerationCaseInReview ||
c.Status == ModerationCaseActionPending ||
c.Status == ModerationCaseActionFailed) && c.AssignedTo == "" {
return ErrModerationCaseInvalid
}
return nil
}
type ModerationCaseFilter struct {
Statuses []ModerationCaseStatus
AssignedTo string
Target Peer
BeforeUpdate time.Time
BeforeID int64
Limit int
}
func (f ModerationCaseFilter) Validate() error {
if f.Limit <= 0 || f.Limit > MaxModerationCasePage ||
len(f.AssignedTo) > MaxModerationActorBytes ||
!utf8.ValidString(f.AssignedTo) || f.BeforeID < 0 {
return ErrModerationCaseInvalid
}
if f.Target.ID != 0 && !moderationPeerValid(f.Target) {
return ErrModerationCaseInvalid
}
if f.Target.ID == 0 && f.Target.Type != "" {
return ErrModerationCaseInvalid
}
for _, status := range f.Statuses {
if !status.Valid() {
return ErrModerationCaseInvalid
}
}
return nil
}
type ModerationCaseDetail struct {
Case ModerationCase
ReportIDs []int64
Decisions []ModerationDecision
Actions []ModerationAction
Appeals []ModerationAppeal
}
type ModerationDecision struct {
ID int64
CaseID int64
AppealID int64
Kind ModerationDecisionKind
Actor string
Reason string
CommandID string
Fingerprint [sha256.Size]byte
CreatedAt time.Time
}
type ModerationActionDraft struct {
Kind ModerationActionKind
Payload json.RawMessage
}
type ModerationDecisionRequest struct {
CaseID int64
AppealID int64
ExpectedVersion int64
Actor string
Reason string
CommandID string
Kind ModerationDecisionKind
Actions []ModerationActionDraft
Fingerprint [sha256.Size]byte
CreatedAt time.Time
}
func NewModerationDecisionRequest(request ModerationDecisionRequest) (ModerationDecisionRequest, error) {
out := request
out.Actions = append([]ModerationActionDraft(nil), request.Actions...)
for i := range out.Actions {
canonical, err := CanonicalModerationActionPayload(out.Actions[i].Payload)
if err != nil {
return ModerationDecisionRequest{}, err
}
out.Actions[i].Payload = canonical
}
fingerprint, err := moderationDecisionFingerprint(out)
if err != nil {
return ModerationDecisionRequest{}, err
}
out.Fingerprint = fingerprint
if err := out.Validate(); err != nil {
return ModerationDecisionRequest{}, err
}
return out, nil
}
func (r ModerationDecisionRequest) Validate() error {
if r.CaseID <= 0 || r.ExpectedVersion <= 0 || !r.Kind.Valid() ||
r.Actor == "" || len(r.Actor) > MaxModerationActorBytes ||
!utf8.ValidString(r.Actor) || r.CommandID == "" ||
len(r.CommandID) > MaxModerationDecisionCommandBytes ||
!utf8.ValidString(r.CommandID) ||
r.Reason == "" || !utf8.ValidString(r.Reason) ||
utf8.RuneCountInString(r.Reason) > MaxModerationDecisionTextRunes ||
len(r.Actions) > MaxModerationActionsPerCase ||
r.Fingerprint == ([sha256.Size]byte{}) || r.CreatedAt.IsZero() {
return ErrModerationCaseInvalid
}
if r.Kind == ModerationDecisionNoViolation && len(r.Actions) != 0 {
return ErrModerationActionInvalid
}
if r.Kind == ModerationDecisionViolation && len(r.Actions) == 0 {
return ErrModerationActionInvalid
}
if (r.Kind == ModerationDecisionAppealGrant ||
r.Kind == ModerationDecisionAppealDeny) != (r.AppealID > 0) {
return ErrModerationCaseInvalid
}
if r.Kind == ModerationDecisionAppealDeny && len(r.Actions) != 0 {
return ErrModerationActionInvalid
}
if (r.Kind == ModerationDecisionNoViolation ||
r.Kind == ModerationDecisionViolation) && r.AppealID != 0 {
return ErrModerationCaseInvalid
}
for i := range r.Actions {
canonical, err := CanonicalModerationActionPayload(r.Actions[i].Payload)
if !r.Actions[i].Kind.Valid() || err != nil ||
!bytes.Equal(canonical, r.Actions[i].Payload) {
return ErrModerationActionInvalid
}
}
fingerprint, err := moderationDecisionFingerprint(r)
if err != nil || fingerprint != r.Fingerprint {
return ErrModerationCaseInvalid
}
return nil
}
type ModerationAction struct {
ID int64
CaseID int64
DecisionID int64
Kind ModerationActionKind
Payload json.RawMessage
Status ModerationActionStatus
Attempts int
AvailableAt time.Time
LeaseUntil time.Time
LastError string
CommandID string
CreatedAt time.Time
UpdatedAt time.Time
}
func (a ModerationAction) Validate() error {
canonical, err := CanonicalModerationActionPayload(a.Payload)
if a.ID <= 0 || a.CaseID <= 0 || a.DecisionID <= 0 ||
!a.Kind.Valid() || !a.Status.Valid() || a.Attempts < 0 ||
a.Attempts > MaxModerationActionAttempts || a.AvailableAt.IsZero() ||
a.CommandID == "" || len(a.CommandID) > 160 ||
!utf8.ValidString(a.CommandID) || a.CreatedAt.IsZero() ||
a.UpdatedAt.Before(a.CreatedAt) || err != nil ||
!bytes.Equal(canonical, a.Payload) {
return ErrModerationActionInvalid
}
return nil
}
type ModerationAppeal struct {
ID int64
CaseID int64
AppellantUserID int64
Text string
TextHash [sha256.Size]byte
Fingerprint [sha256.Size]byte
Status ModerationAppealStatus
PreviousCaseStatus ModerationCaseStatus
Reviewer string
ReviewReason string
CreatedAt time.Time
ReviewedAt time.Time
}
// ModerationAppealLink is a hash-only bearer capability issued for the user
// targeted by a moderation case. The raw token is never persisted.
type ModerationAppealLink struct {
ID int64
CaseID int64
AppellantUserID int64
TokenHash [sha256.Size]byte
ExpiresAt time.Time
AppealID int64
CreatedAt time.Time
ConsumedAt time.Time
}
func (l ModerationAppealLink) Validate() error {
if l.ID < 0 || l.CaseID <= 0 || l.AppellantUserID <= 0 ||
l.TokenHash == ([sha256.Size]byte{}) || l.CreatedAt.IsZero() ||
!l.ExpiresAt.After(l.CreatedAt) ||
l.ExpiresAt.Sub(l.CreatedAt) > MaxModerationAppealLinkLifetime ||
l.AppealID < 0 {
return ErrModerationAppealLinkInvalid
}
if l.AppealID == 0 {
if !l.ConsumedAt.IsZero() {
return ErrModerationAppealLinkInvalid
}
} else if l.ConsumedAt.IsZero() {
return ErrModerationAppealLinkInvalid
}
return nil
}
func NewModerationAppeal(caseID, appellantUserID int64, previousStatus ModerationCaseStatus, text string, createdAt time.Time) (ModerationAppeal, error) {
appeal := ModerationAppeal{
CaseID: caseID, AppellantUserID: appellantUserID, Text: text,
TextHash: sha256.Sum256([]byte(text)), Status: ModerationAppealPending,
PreviousCaseStatus: previousStatus, CreatedAt: createdAt,
}
raw, err := json.Marshal(struct {
Version int
CaseID int64
AppellantUserID int64
PreviousStatus ModerationCaseStatus
TextHash [sha256.Size]byte
}{1, caseID, appellantUserID, previousStatus, appeal.TextHash})
if err != nil {
return ModerationAppeal{}, ErrModerationCaseInvalid
}
appeal.Fingerprint = sha256.Sum256(raw)
if err := appeal.Validate(); err != nil {
return ModerationAppeal{}, err
}
return appeal, nil
}
func (a ModerationAppeal) Validate() error {
if a.ID < 0 || a.CaseID <= 0 || a.AppellantUserID <= 0 ||
a.Text == "" || !utf8.ValidString(a.Text) ||
utf8.RuneCountInString(a.Text) > MaxModerationAppealTextRunes ||
a.TextHash != sha256.Sum256([]byte(a.Text)) ||
a.Fingerprint == ([sha256.Size]byte{}) || !a.Status.Valid() ||
(a.PreviousCaseStatus != ModerationCaseResolved &&
a.PreviousCaseStatus != ModerationCaseDismissed) ||
len(a.Reviewer) > MaxModerationActorBytes ||
!utf8.ValidString(a.Reviewer) || !utf8.ValidString(a.ReviewReason) ||
utf8.RuneCountInString(a.ReviewReason) > MaxModerationDecisionTextRunes ||
a.CreatedAt.IsZero() {
return ErrModerationCaseInvalid
}
if a.Status == ModerationAppealPending {
if a.Reviewer != "" || a.ReviewReason != "" || !a.ReviewedAt.IsZero() {
return ErrModerationCaseInvalid
}
} else if a.Reviewer == "" || a.ReviewReason == "" || a.ReviewedAt.IsZero() {
return ErrModerationCaseInvalid
}
return nil
}
func CanonicalModerationActionPayload(raw json.RawMessage) (json.RawMessage, error) {
var value any
if len(raw) == 0 {
raw = json.RawMessage(`{}`)
}
if err := json.Unmarshal(raw, &value); err != nil {
return nil, ErrModerationActionInvalid
}
if _, ok := value.(map[string]any); !ok {
return nil, ErrModerationActionInvalid
}
canonical, err := json.Marshal(value)
if err != nil || len(canonical) > MaxModerationActionPayload {
return nil, ErrModerationActionInvalid
}
return canonical, nil
}
func moderationDecisionFingerprint(request ModerationDecisionRequest) ([sha256.Size]byte, error) {
raw, err := json.Marshal(struct {
Version int
CaseID int64
AppealID int64
ExpectedVersion int64
Actor string
Reason string
CommandID string
Kind ModerationDecisionKind
Actions []ModerationActionDraft
}{
Version: 1, CaseID: request.CaseID,
AppealID: request.AppealID,
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
Reason: request.Reason, CommandID: request.CommandID,
Kind: request.Kind, Actions: request.Actions,
})
if err != nil {
return [sha256.Size]byte{}, ErrModerationCaseInvalid
}
return sha256.Sum256(raw), nil
}

View file

@ -0,0 +1,156 @@
package domain
import (
"bytes"
"crypto/sha256"
"encoding/json"
"time"
)
const MaxSponsoredImpressionLifetime = 30 * 24 * time.Hour
// SponsoredMessageImpression is the server-issued fact required before a
// random_id may enter the human moderation pipeline.
type SponsoredMessageImpression struct {
ID int64
UserID int64
RandomIDHash [sha256.Size]byte
Target Peer
AuthorUserID int64
EvidenceSchemaVersion int
Evidence json.RawMessage
EvidenceHash [sha256.Size]byte
ReportID int64
CreatedAt time.Time
ExpiresAt time.Time
}
func NewSponsoredMessageImpression(userID int64, randomID []byte, target Peer, authorUserID int64, evidence json.RawMessage, createdAt, expiresAt time.Time) (SponsoredMessageImpression, error) {
canonical, err := CanonicalModerationEvidence(evidence)
if err != nil {
return SponsoredMessageImpression{}, ErrModerationReportInvalid
}
impression := SponsoredMessageImpression{
UserID: userID, RandomIDHash: sha256.Sum256(randomID),
Target: target, AuthorUserID: authorUserID,
EvidenceSchemaVersion: 1, Evidence: canonical,
EvidenceHash: sha256.Sum256(canonical),
CreatedAt: createdAt.UTC(), ExpiresAt: expiresAt.UTC(),
}
if err := impression.Validate(); err != nil {
return SponsoredMessageImpression{}, err
}
return impression, nil
}
func (i SponsoredMessageImpression) Validate() error {
canonical, err := CanonicalModerationEvidence(i.Evidence)
if i.ID < 0 || i.UserID <= 0 ||
i.RandomIDHash == ([sha256.Size]byte{}) ||
!moderationPeerValid(i.Target) || i.AuthorUserID < 0 ||
i.EvidenceSchemaVersion <= 0 ||
i.EvidenceHash != sha256.Sum256(i.Evidence) ||
err != nil || !bytes.Equal(canonical, i.Evidence) ||
i.ReportID < 0 || i.CreatedAt.IsZero() ||
!i.ExpiresAt.After(i.CreatedAt) ||
i.ExpiresAt.Sub(i.CreatedAt) > MaxSponsoredImpressionLifetime {
return ErrModerationReportInvalid
}
return nil
}
func ValidateSponsoredModerationReport(impression SponsoredMessageImpression, report ModerationReport) error {
if err := impression.Validate(); err != nil {
return err
}
if err := report.Validate(); err != nil ||
report.ReporterUserID != impression.UserID ||
report.Source != ModerationSourceSponsored ||
report.Target != impression.Target ||
report.CreatedAt.Before(impression.CreatedAt) ||
!report.CreatedAt.Before(impression.ExpiresAt) ||
len(report.Items) != 1 || len(report.MediaHolds) != 0 {
return ErrModerationReportInvalid
}
item := report.Items[0]
if item.Kind != ModerationItemSponsored ||
item.Peer != impression.Target ||
item.ItemID != impression.ID || item.SecondaryID != 0 ||
item.AuthorUserID != impression.AuthorUserID ||
item.EvidenceSchemaVersion != impression.EvidenceSchemaVersion ||
item.EvidenceHash != impression.EvidenceHash ||
!bytes.Equal(item.Evidence, impression.Evidence) {
return ErrModerationReportInvalid
}
return nil
}
// ChannelAntiSpamDecision is immutable evidence that native anti-spam
// actually removed the referenced message. A false-positive report without
// this fact must fail closed.
type ChannelAntiSpamDecision struct {
ID int64
ChannelID int64
MessageID int
AuthorUserID int64
EvidenceSchemaVersion int
Evidence json.RawMessage
EvidenceHash [sha256.Size]byte
ReportID int64
CreatedAt time.Time
}
func NewChannelAntiSpamDecision(channelID int64, messageID int, authorUserID int64, evidence json.RawMessage, createdAt time.Time) (ChannelAntiSpamDecision, error) {
canonical, err := CanonicalModerationEvidence(evidence)
if err != nil {
return ChannelAntiSpamDecision{}, ErrModerationReportInvalid
}
decision := ChannelAntiSpamDecision{
ChannelID: channelID, MessageID: messageID,
AuthorUserID: authorUserID, EvidenceSchemaVersion: 1,
Evidence: canonical, EvidenceHash: sha256.Sum256(canonical),
CreatedAt: createdAt.UTC(),
}
if err := decision.Validate(); err != nil {
return ChannelAntiSpamDecision{}, err
}
return decision, nil
}
func (d ChannelAntiSpamDecision) Validate() error {
canonical, err := CanonicalModerationEvidence(d.Evidence)
if d.ID < 0 || d.ChannelID <= 0 || d.MessageID <= 0 ||
d.MessageID > MaxMessageBoxID || d.AuthorUserID <= 0 ||
d.EvidenceSchemaVersion <= 0 ||
d.EvidenceHash != sha256.Sum256(d.Evidence) ||
err != nil || !bytes.Equal(canonical, d.Evidence) ||
d.ReportID < 0 || d.CreatedAt.IsZero() {
return ErrModerationReportInvalid
}
return nil
}
func ValidateAntiSpamFalsePositiveReport(decision ChannelAntiSpamDecision, report ModerationReport) error {
if err := decision.Validate(); err != nil {
return err
}
target := Peer{Type: PeerTypeChannel, ID: decision.ChannelID}
if err := report.Validate(); err != nil ||
report.Source != ModerationSourceAntiSpamFalsePositive ||
report.Target != target ||
report.CreatedAt.Before(decision.CreatedAt) ||
len(report.Items) != 1 || len(report.MediaHolds) != 0 {
return ErrModerationReportInvalid
}
item := report.Items[0]
if item.Kind != ModerationItemAntiSpamDecision ||
item.Peer != target || item.ItemID != decision.ID ||
item.SecondaryID != int64(decision.MessageID) ||
item.AuthorUserID != decision.AuthorUserID ||
item.EvidenceSchemaVersion != decision.EvidenceSchemaVersion ||
item.EvidenceHash != decision.EvidenceHash ||
!bytes.Equal(item.Evidence, decision.Evidence) {
return ErrModerationReportInvalid
}
return nil
}

View file

@ -0,0 +1,87 @@
package domain
import (
"bytes"
"testing"
"time"
)
func TestNewModerationReportCanonicalizesEvidenceItemsAndHolds(t *testing.T) {
now := time.Unix(1_750_000_000, 0).UTC()
draft := ModerationReportDraft{
ReporterUserID: 101,
Source: ModerationSourceMessages,
Target: Peer{Type: PeerTypeChannel, ID: 202},
Reason: ModerationReasonSpam,
Option: "v1/spam",
Comment: "review",
CreatedAt: now,
Items: []ModerationReportItem{
{
Kind: ModerationItemStory, Peer: Peer{Type: PeerTypeChannel, ID: 202},
ItemID: 20, AuthorUserID: 303, EvidenceSchemaVersion: 1,
Evidence: []byte(`{ "z": 1, "a": {"two": 2, "one": 1} }`),
},
{
Kind: ModerationItemMessage, Peer: Peer{Type: PeerTypeChannel, ID: 202},
ItemID: 10, AuthorUserID: 303, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"spam"}`),
},
},
MediaHolds: []ModerationMediaHold{{
ItemIndex: 0, Kind: ModerationMediaPhoto, StorageKey: "photo/20",
}},
}
report, err := NewModerationReport(draft)
if err != nil {
t.Fatalf("NewModerationReport: %v", err)
}
if report.Items[0].Kind != ModerationItemMessage || report.Items[1].Kind != ModerationItemStory {
t.Fatalf("items not canonicalized: %+v", report.Items)
}
if report.MediaHolds[0].ItemIndex != 1 {
t.Fatalf("media hold item index = %d, want 1 after canonical sort", report.MediaHolds[0].ItemIndex)
}
if got, want := report.Items[1].Evidence, []byte(`{"a":{"one":1,"two":2},"z":1}`); !bytes.Equal(got, want) {
t.Fatalf("canonical evidence = %s, want %s", got, want)
}
if err := report.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
retry := draft
retry.CreatedAt = now.Add(time.Hour)
retryReport, err := NewModerationReport(retry)
if err != nil {
t.Fatalf("retry NewModerationReport: %v", err)
}
if retryReport.Fingerprint != report.Fingerprint {
t.Fatalf("retry fingerprint changed with CreatedAt")
}
retry.Items = append([]ModerationReportItem(nil), retry.Items...)
retry.Items[0].Evidence = []byte(`{"z":2}`)
changed, err := NewModerationReport(retry)
if err != nil {
t.Fatalf("changed NewModerationReport: %v", err)
}
if changed.Fingerprint == report.Fingerprint {
t.Fatalf("evidence change did not change fingerprint")
}
}
func TestModerationReportRejectsDuplicateItemIdentity(t *testing.T) {
item := ModerationReportItem{
Kind: ModerationItemMessage, Peer: Peer{Type: PeerTypeUser, ID: 2},
ItemID: 7, AuthorUserID: 2, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"bad"}`),
}
_, err := NewModerationReport(ModerationReportDraft{
ReporterUserID: 1, Source: ModerationSourceMessages,
Target: Peer{Type: PeerTypeUser, ID: 2},
Reason: ModerationReasonSpam, Option: "v1/spam",
Items: []ModerationReportItem{item, item}, CreatedAt: time.Now().UTC(),
})
if err != ErrModerationReportInvalid {
t.Fatalf("error = %v, want ErrModerationReportInvalid", err)
}
}

View file

@ -75,6 +75,10 @@ func DefaultPrivacyRules(key PrivacyKey) []PrivacyRule {
switch key {
case PrivacyKeyPhoneNumber:
return []PrivacyRule{{Kind: PrivacyRuleDisallowAll}}
case PrivacyKeyNoPaidMessages:
// This key is an allow-list of peers exempt from paid private
// messages, not the base visibility of a profile field.
return []PrivacyRule{{Kind: PrivacyRuleDisallowAll}}
case PrivacyKeyBirthday:
return []PrivacyRule{{Kind: PrivacyRuleAllowContacts}}
default:

View file

@ -353,6 +353,7 @@ type AdminStarGiftGrant struct {
CommandKey string
Date int
RecipientBlocked bool
RecipientUnsaved bool
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
@ -382,6 +383,7 @@ type StarGiftPurchaseRequest struct {
CommandKey string
Date int
RecipientBlocked bool
RecipientUnsaved bool
OriginAuthKeyID [8]byte
OriginSessionID int64
}
@ -555,15 +557,16 @@ type StarGiftValueInfo struct {
}
type StarGiftTransferRequest struct {
ActorUserID int64
Ref SavedStarGiftRef
To Peer
ChargeStars int64
FormID int64
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
ActorUserID int64
Ref SavedStarGiftRef
To Peer
ChargeStars int64
FormID int64
CommandKey string
Date int
RecipientUnsaved bool
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftTransferResult struct {
@ -582,15 +585,16 @@ type StarGiftListingRequest struct {
}
type StarGiftResalePurchaseRequest struct {
BuyerUserID int64
Slug string
To Peer
Amount StarGiftAmount
FormID int64
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
BuyerUserID int64
Slug string
To Peer
Amount StarGiftAmount
FormID int64
CommandKey string
Date int
RecipientUnsaved bool
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftOfferRequest struct {

View file

@ -36,7 +36,10 @@ const (
// UpdateEventUserEmojiStatus carries the exact immutable status snapshot.
// It consumes account pts even though updateUserEmojiStatus has no pts.
UpdateEventUserEmojiStatus UpdateEventType = "user_emoji_status"
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
// UpdateEventPrivacy carries the immutable account privacy key/rule
// snapshot committed at this pts. updatePrivacy has no wire pts.
UpdateEventPrivacy UpdateEventType = "privacy"
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
// UpdateEventPinnedMessages 映射 updatePinnedMessages私聊置顶/取消
// 置顶MessageIDs 是该 owner 自己视角的 box idBool 为 pinned
// TL 构造器自带账号 pts/pts_count不属于 LacksWirePts。
@ -88,6 +91,7 @@ type UpdateEvent struct {
Bool bool
Phone string
EmojiStatus UserEmojiStatus
Privacy PrivacyRules
Settings PeerSettings
MessageIDs []int
MaxID int
@ -140,6 +144,7 @@ func (e UpdateEvent) LacksWirePts() bool {
UpdateEventPeerStoryBlocked,
UpdateEventUserPhone,
UpdateEventUserEmojiStatus,
UpdateEventPrivacy,
UpdateEventDialogFilter,
UpdateEventDialogFilterOrder,
UpdateEventDialogFilters,

View file

@ -243,6 +243,26 @@ type UserStatus struct {
WasOnline int
}
// ApproximateUserStatus returns Telegram's coarse privacy-preserving last-seen
// buckets. Exact online/offline timestamps must never be reattached after this
// projection.
func ApproximateUserStatus(lastSeenAt, now int) UserStatus {
if lastSeenAt <= 0 || now <= 0 || lastSeenAt >= now {
return UserStatus{Kind: UserStatusRecently}
}
age := now - lastSeenAt
switch {
case age <= 3*24*60*60:
return UserStatus{Kind: UserStatusRecently}
case age <= 7*24*60*60:
return UserStatus{Kind: UserStatusLastWeek}
case age <= 30*24*60*60:
return UserStatus{Kind: UserStatusLastMonth}
default:
return UserStatus{Kind: UserStatusEmpty}
}
}
// Birthday 是用户公开生日。Day/Month 为 0 表示未设置Year 为 0 表示只填了月日不含年份。
type Birthday struct {
Day int