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

@ -165,6 +165,16 @@ type EmojiService interface {
DocumentAnimationJSON(ctx context.Context, documentID int64) ([]byte, bool, error)
}
type ModerationService interface {
ListCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
Case(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
Report(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
ClaimCase(ctx context.Context, caseID, expectedVersion int64, actor string, now time.Time) (domain.ModerationCase, error)
DecideCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
SubmitAppeal(ctx context.Context, caseID, appellantUserID int64, text string, now time.Time) (domain.ModerationAppeal, bool, error)
ReviewAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
}
// GiftGranter delivers a catalog gift to a recipient peer on behalf of a sender
// without charging Stars. Implemented by the RPC router, it reuses the standard
// gift-delivery path (service message for users, saved-gift + admin log for
@ -191,6 +201,7 @@ type Dependencies struct {
OfficialGifts OfficialGiftsSource
Bots BotService
Emoji EmojiService
Moderation ModerationService
Now func() time.Time
}
@ -212,6 +223,7 @@ type Service struct {
officialGifts OfficialGiftsSource
bots BotService
emoji EmojiService
moderation ModerationService
now func() time.Time
}
@ -272,6 +284,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Emoji != nil {
s.emoji = deps.Emoji
}
if deps.Moderation != nil {
s.moderation = deps.Moderation
}
if deps.Now != nil {
s.now = deps.Now
}
@ -281,6 +296,61 @@ func (s *Service) Configure(deps Dependencies) *Service {
return s
}
func (s *Service) ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
if s == nil || s.moderation == nil {
return nil, fmt.Errorf("moderation dependency is not configured")
}
return s.moderation.ListCases(ctx, filter)
}
func (s *Service) ModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error) {
if s == nil || s.moderation == nil {
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation dependency is not configured")
}
return s.moderation.Case(ctx, caseID)
}
func (s *Service) ModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error) {
if s == nil || s.moderation == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation dependency is not configured")
}
return s.moderation.Report(ctx, reportID)
}
func (s *Service) ClaimModerationCase(ctx context.Context, caseID, expectedVersion int64, actor string) (domain.ModerationCase, error) {
if s == nil || s.moderation == nil {
return domain.ModerationCase{}, fmt.Errorf("moderation dependency is not configured")
}
return s.moderation.ClaimCase(ctx, caseID, expectedVersion, actor, s.now().UTC())
}
func (s *Service) DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
if s == nil || s.moderation == nil {
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation dependency is not configured")
}
if request.CreatedAt.IsZero() {
request.CreatedAt = s.now().UTC()
}
return s.moderation.DecideCase(ctx, request)
}
func (s *Service) SubmitModerationAppeal(ctx context.Context, caseID, appellantUserID int64, text string) (domain.ModerationAppeal, bool, error) {
if s == nil || s.moderation == nil {
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation dependency is not configured")
}
return s.moderation.SubmitAppeal(ctx, caseID, appellantUserID, text, s.now().UTC())
}
func (s *Service) ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
if s == nil || s.moderation == nil {
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation dependency is not configured")
}
if request.CreatedAt.IsZero() {
request.CreatedAt = s.now().UTC()
}
return s.moderation.ReviewAppeal(ctx, request)
}
type CommandMeta struct {
CommandID string `json:"command_id"`
Actor string `json:"actor"`

View file

@ -57,6 +57,13 @@ type Service interface {
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
ModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
ModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
ClaimModerationCase(ctx context.Context, caseID, expectedVersion int64, actor string) (domain.ModerationCase, error)
DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
SubmitModerationAppeal(ctx context.Context, caseID, appellantUserID int64, text string) (domain.ModerationAppeal, bool, error)
ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
}
func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http.Server, error) {
@ -137,6 +144,13 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
mux.HandleFunc("GET /v1/moderation/cases", s.authenticated(s.handleModerationCases))
mux.HandleFunc("GET /v1/moderation/cases/{id}", s.authenticated(s.handleModerationCase))
mux.HandleFunc("GET /v1/moderation/reports/{id}", s.authenticated(s.handleModerationReport))
mux.HandleFunc("POST /v1/moderation/cases/{id}/claim", s.authenticated(s.handleClaimModerationCase))
mux.HandleFunc("POST /v1/moderation/cases/{id}/decide", s.authenticated(s.handleDecideModerationCase))
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals", s.authenticated(s.handleSubmitModerationAppeal))
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals/{appeal_id}/review", s.authenticated(s.handleReviewModerationAppeal))
return mux
}
@ -634,6 +648,267 @@ func (s *Server) handleStarGiftCollectibleAnimation(w http.ResponseWriter, r *ht
_, _ = w.Write(raw)
}
type moderationClaimRequest struct {
ExpectedVersion int64 `json:"expected_version"`
Actor string `json:"actor"`
}
type moderationActionRequest struct {
Kind domain.ModerationActionKind `json:"kind"`
Payload json.RawMessage `json:"payload"`
}
type moderationDecisionRequest struct {
ExpectedVersion int64 `json:"expected_version"`
Actor string `json:"actor"`
Reason string `json:"reason"`
CommandID string `json:"command_id"`
Kind domain.ModerationDecisionKind `json:"kind"`
Actions []moderationActionRequest `json:"actions"`
}
type moderationAppealRequest struct {
AppellantUserID int64 `json:"appellant_user_id"`
Text string `json:"text"`
}
type moderationAppealReviewRequest struct {
ExpectedVersion int64 `json:"expected_version"`
Actor string `json:"actor"`
Reason string `json:"reason"`
CommandID string `json:"command_id"`
Granted bool `json:"granted"`
Actions []moderationActionRequest `json:"actions"`
}
func (s *Server) handleModerationCases(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
limit := 50
if raw := query.Get("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
limit = parsed
}
filter := domain.ModerationCaseFilter{
AssignedTo: query.Get("assigned_to"),
Limit: limit,
}
if raw := query.Get("statuses"); raw != "" {
for _, status := range strings.Split(raw, ",") {
if status = strings.TrimSpace(status); status != "" {
filter.Statuses = append(filter.Statuses, domain.ModerationCaseStatus(status))
}
}
}
if raw := query.Get("target_id"); raw != "" {
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid target id")
return
}
filter.Target = domain.Peer{
Type: domain.PeerType(query.Get("target_type")), ID: id,
}
}
if raw := query.Get("before_updated_at"); raw != "" {
parsed, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid before_updated_at")
return
}
filter.BeforeUpdate = parsed
filter.BeforeID, _ = strconv.ParseInt(query.Get("before_id"), 10, 64)
}
items, err := s.svc.ModerationCases(r.Context(), filter)
if err != nil {
writeModerationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"cases": items})
}
func (s *Server) handleModerationCase(w http.ResponseWriter, r *http.Request) {
caseID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
detail, found, err := s.svc.ModerationCase(r.Context(), caseID)
if err != nil {
writeModerationError(w, err)
return
}
if !found {
writeError(w, http.StatusNotFound, "moderation case not found")
return
}
writeJSON(w, http.StatusOK, detail)
}
func (s *Server) handleModerationReport(w http.ResponseWriter, r *http.Request) {
reportID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
report, found, err := s.svc.ModerationReport(r.Context(), reportID)
if err != nil {
writeModerationError(w, err)
return
}
if !found {
writeError(w, http.StatusNotFound, "moderation report not found")
return
}
writeJSON(w, http.StatusOK, report)
}
func (s *Server) handleClaimModerationCase(w http.ResponseWriter, r *http.Request) {
caseID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var request moderationClaimRequest
if !decodeJSON(w, r, &request) {
return
}
item, err := s.svc.ClaimModerationCase(
r.Context(), caseID, request.ExpectedVersion, request.Actor,
)
if err != nil {
writeModerationError(w, err)
return
}
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleDecideModerationCase(w http.ResponseWriter, r *http.Request) {
caseID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var request moderationDecisionRequest
if !decodeJSON(w, r, &request) {
return
}
detail, created, err := s.svc.DecideModerationCase(
r.Context(), moderationDecisionDomain(caseID, 0, request),
)
if err != nil {
writeModerationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"created": created, "case": detail,
})
}
func (s *Server) handleSubmitModerationAppeal(w http.ResponseWriter, r *http.Request) {
caseID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var request moderationAppealRequest
if !decodeJSON(w, r, &request) {
return
}
appeal, created, err := s.svc.SubmitModerationAppeal(
r.Context(), caseID, request.AppellantUserID, request.Text,
)
if err != nil {
writeModerationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"created": created, "appeal": appeal,
})
}
func (s *Server) handleReviewModerationAppeal(w http.ResponseWriter, r *http.Request) {
caseID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
appealID, ok := moderationPathID(w, r, "appeal_id")
if !ok {
return
}
var request moderationAppealReviewRequest
if !decodeJSON(w, r, &request) {
return
}
kind := domain.ModerationDecisionAppealDeny
if request.Granted {
kind = domain.ModerationDecisionAppealGrant
}
decision := moderationDecisionRequest{
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
Reason: request.Reason, CommandID: request.CommandID,
Kind: kind, Actions: request.Actions,
}
detail, created, err := s.svc.ReviewModerationAppeal(
r.Context(), moderationDecisionDomain(caseID, appealID, decision),
)
if err != nil {
writeModerationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"created": created, "case": detail,
})
}
func moderationDecisionDomain(caseID, appealID int64, request moderationDecisionRequest) domain.ModerationDecisionRequest {
actions := make([]domain.ModerationActionDraft, 0, len(request.Actions))
for _, action := range request.Actions {
payload := action.Payload
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
actions = append(actions, domain.ModerationActionDraft{
Kind: action.Kind, Payload: payload,
})
}
return domain.ModerationDecisionRequest{
CaseID: caseID, AppealID: appealID,
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
Reason: request.Reason, CommandID: request.CommandID,
Kind: request.Kind, Actions: actions,
}
}
func moderationPathID(w http.ResponseWriter, r *http.Request, name string) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return id, true
}
func writeModerationError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrModerationCaseNotFound),
errors.Is(err, domain.ErrModerationReportNotFound),
errors.Is(err, domain.ErrModerationEvidenceNotFound):
writeError(w, http.StatusNotFound, err.Error())
case errors.Is(err, domain.ErrModerationPermissionDenied):
writeError(w, http.StatusForbidden, err.Error())
case errors.Is(err, domain.ErrModerationCaseConflict),
errors.Is(err, domain.ErrModerationActionConflict):
writeError(w, http.StatusConflict, err.Error())
case errors.Is(err, domain.ErrModerationRateLimited):
writeError(w, http.StatusTooManyRequests, err.Error())
case errors.Is(err, domain.ErrModerationCaseInvalid),
errors.Is(err, domain.ErrModerationActionInvalid),
errors.Is(err, domain.ErrModerationReportInvalid):
writeError(w, http.StatusBadRequest, err.Error())
default:
writeError(w, http.StatusInternalServerError, err.Error())
}
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
defer r.Body.Close()
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))

View file

@ -42,6 +42,85 @@ func TestAdminAPISetAccountFrozen(t *testing.T) {
}
}
type captureModerationService struct {
fakeService
filter domain.ModerationCaseFilter
decision domain.ModerationDecisionRequest
appealReview domain.ModerationDecisionRequest
}
func (s *captureModerationService) ModerationCases(_ context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
s.filter = filter
return []domain.ModerationCase{{ID: 7}}, nil
}
func (s *captureModerationService) DecideModerationCase(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
s.decision = request
return domain.ModerationCaseDetail{Case: domain.ModerationCase{ID: request.CaseID}}, true, nil
}
func (s *captureModerationService) ReviewModerationAppeal(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
s.appealReview = request
return domain.ModerationCaseDetail{Case: domain.ModerationCase{ID: request.CaseID}}, true, nil
}
func TestAdminAPIModerationQueueDecisionAndAppealReview(t *testing.T) {
svc := &captureModerationService{}
srv := &Server{token: "secret", svc: svc}
listRequest := httptest.NewRequest(
http.MethodGet,
"/v1/moderation/cases?statuses=open,action_failed&assigned_to=alice&target_type=user&target_id=99&limit=25",
nil,
)
listRequest.Header.Set("Authorization", "Bearer secret")
list := httptest.NewRecorder()
srv.routes().ServeHTTP(list, listRequest)
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"ID":7`) {
t.Fatalf("list status=%d body=%s", list.Code, list.Body.String())
}
if len(svc.filter.Statuses) != 2 ||
svc.filter.Statuses[0] != domain.ModerationCaseOpen ||
svc.filter.Statuses[1] != domain.ModerationCaseActionFailed ||
svc.filter.AssignedTo != "alice" ||
svc.filter.Target != (domain.Peer{Type: domain.PeerTypeUser, ID: 99}) ||
svc.filter.Limit != 25 {
t.Fatalf("filter=%+v", svc.filter)
}
decisionRequest := httptest.NewRequest(
http.MethodPost, "/v1/moderation/cases/7/decide",
strings.NewReader(`{"expected_version":3,"actor":"alice","reason":"confirmed","command_id":"decision-7","kind":"violation","actions":[{"kind":"mark_scam","payload":{}}]}`),
)
decisionRequest.Header.Set("Authorization", "Bearer secret")
decision := httptest.NewRecorder()
srv.routes().ServeHTTP(decision, decisionRequest)
if decision.Code != http.StatusOK ||
!strings.Contains(decision.Body.String(), `"created":true`) ||
svc.decision.CaseID != 7 || svc.decision.ExpectedVersion != 3 ||
svc.decision.Kind != domain.ModerationDecisionViolation ||
len(svc.decision.Actions) != 1 ||
svc.decision.Actions[0].Kind != domain.ModerationActionMarkScam {
t.Fatalf("decision status=%d request=%+v body=%s",
decision.Code, svc.decision, decision.Body.String())
}
reviewRequest := httptest.NewRequest(
http.MethodPost, "/v1/moderation/cases/7/appeals/8/review",
strings.NewReader(`{"expected_version":5,"actor":"bob","reason":"appeal accepted","command_id":"appeal-8","granted":true,"actions":[{"kind":"clear_peer_flags","payload":{}}]}`),
)
reviewRequest.Header.Set("Authorization", "Bearer secret")
review := httptest.NewRecorder()
srv.routes().ServeHTTP(review, reviewRequest)
if review.Code != http.StatusOK ||
svc.appealReview.CaseID != 7 || svc.appealReview.AppealID != 8 ||
svc.appealReview.Kind != domain.ModerationDecisionAppealGrant ||
len(svc.appealReview.Actions) != 1 ||
svc.appealReview.Actions[0].Kind != domain.ModerationActionClearPeerFlags {
t.Fatalf("review status=%d request=%+v body=%s",
review.Code, svc.appealReview, review.Body.String())
}
}
func TestAdminAPISetVerified(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-verified", strings.NewReader(`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
@ -357,3 +436,31 @@ func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGift
func (fakeService) StarGiftCollectibleAnimation(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
}
func (fakeService) ModerationCases(context.Context, domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
return nil, nil
}
func (fakeService) ModerationCase(context.Context, int64) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{}, false, nil
}
func (fakeService) ModerationReport(context.Context, int64) (domain.ModerationReport, bool, error) {
return domain.ModerationReport{}, false, nil
}
func (fakeService) ClaimModerationCase(context.Context, int64, int64, string) (domain.ModerationCase, error) {
return domain.ModerationCase{}, nil
}
func (fakeService) DecideModerationCase(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{}, true, nil
}
func (fakeService) SubmitModerationAppeal(context.Context, int64, int64, string) (domain.ModerationAppeal, bool, error) {
return domain.ModerationAppeal{}, true, nil
}
func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{}, true, nil
}

View file

@ -1016,6 +1016,41 @@ func (s *Service) GetAccountSettings(ctx context.Context, userID int64) (domain.
return settings, nil
}
// GetAccountSettingsBatch is the bounded cold loader behind the RPC read
// model. Missing rows are returned as explicit defaults so they are negative
// cached instead of being queried again.
func (s *Service) GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
out := make(map[int64]domain.AccountSettings, len(userIDs))
for _, userID := range userIDs {
if userID > 0 {
out[userID] = domain.DefaultAccountSettings()
}
}
if s == nil || s.settings == nil || len(out) == 0 {
return out, nil
}
if batch, ok := s.settings.(store.AccountSettingsBatchStore); ok {
loaded, err := batch.GetAccountSettingsBatch(ctx, userIDs)
if err != nil {
return nil, err
}
for userID, settings := range loaded {
out[userID] = settings
}
return out, nil
}
for userID := range out {
settings, found, err := s.settings.GetAccountSettings(ctx, userID)
if err != nil {
return nil, err
}
if found {
out[userID] = settings
}
}
return out, nil
}
// SetGlobalPrivacy 持久化账号全局隐私开关,返回合并后的完整设置。
func (s *Service) SetGlobalPrivacy(ctx context.Context, userID int64, privacy domain.GlobalPrivacy) (domain.AccountSettings, error) {
settings, err := s.GetAccountSettings(ctx, userID)

View file

@ -0,0 +1,53 @@
package authdiagnostics
import (
"context"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type Service struct {
codes store.CodeStore
reports store.AuthDeliveryReportStore
}
func NewService(codes store.CodeStore, reports store.AuthDeliveryReportStore) *Service {
return &Service{codes: codes, reports: reports}
}
func (s *Service) ReportMissingCode(ctx context.Context, req domain.AuthMissingCodeReportRequest) (domain.AuthDeliveryReport, bool, error) {
phone := domain.NormalizePhone(req.Phone)
if s == nil || s.codes == nil || s.reports == nil ||
!domain.ValidPhone(phone) || req.PhoneCodeHash == "" {
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
}
record, found, err := s.codes.Get(ctx, req.PhoneCodeHash)
if err != nil {
return domain.AuthDeliveryReport{}, false, err
}
if !found {
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeExpired
}
if record.Version != store.PhoneCodeVersionCurrent || record.Purpose != "" ||
record.Phone != phone || !store.LoginCodeChannelVerifiable(record.Channel) {
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
}
var channel domain.AuthCodeDeliveryKind
switch record.Channel {
case store.PhoneCodeChannelPhone:
channel = domain.AuthCodeDeliveryPhone
case store.PhoneCodeChannelSMS:
channel = domain.AuthCodeDeliverySMS
default:
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
}
report, err := domain.NewAuthDeliveryReport(
req.AuthKeyID, req.SessionID, req.ClientType, phone, req.PhoneCodeHash,
record.IssuedUserID, record.DeliveryID, channel, req.MNC, req.CreatedAt,
)
if err != nil {
return domain.AuthDeliveryReport{}, false, err
}
return s.reports.CreateAuthDeliveryReport(ctx, report)
}

View file

@ -0,0 +1,81 @@
package authdiagnostics
import (
"context"
"crypto/sha256"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
func TestReportMissingCodeValidatesLiveDeliveryAndStoresOnlyHashes(t *testing.T) {
ctx := context.Background()
codes := memory.NewCodeStore()
reports := memory.NewAuthDeliveryReportStore()
const (
phone = "15550001234"
codeHash = "login-code-hash"
)
if err := codes.Set(ctx, codeHash, store.PhoneCode{
Version: store.PhoneCodeVersionCurrent, Phone: phone, Code: "12345",
DeliveryID: "delivery-1", Channel: store.PhoneCodeChannelSMS,
IssuedUserID: 42,
}, time.Hour); err != nil {
t.Fatal(err)
}
service := NewService(codes, reports)
now := time.Now().UTC()
req := domain.AuthMissingCodeReportRequest{
AuthKeyID: [8]byte{1, 2, 3}, SessionID: 99, ClientType: "tdesktop",
Phone: "+1 (555) 000-1234", PhoneCodeHash: codeHash, MNC: "46000",
CreatedAt: now,
}
first, created, err := service.ReportMissingCode(ctx, req)
if err != nil || !created {
t.Fatalf("first report created=%v err=%v", created, err)
}
second, created, err := service.ReportMissingCode(ctx, req)
if err != nil || created || second.ID != first.ID {
t.Fatalf("retry report=%+v created=%v err=%v", second, created, err)
}
stored := reports.Reports()
if len(stored) != 1 {
t.Fatalf("stored reports=%d, want 1", len(stored))
}
if stored[0].PhoneHash != sha256.Sum256([]byte(phone)) ||
stored[0].CodeHash != sha256.Sum256([]byte(codeHash)) {
t.Fatalf("stored hashes do not match normalized delivery identity: %+v", stored[0])
}
if stored[0].DeliveryID != "delivery-1" || stored[0].IssuedUserID != 42 ||
stored[0].Channel != domain.AuthCodeDeliverySMS {
t.Fatalf("stored delivery metadata=%+v", stored[0])
}
}
func TestReportMissingCodeRejectsUnknownOrMismatchedLoginState(t *testing.T) {
ctx := context.Background()
codes := memory.NewCodeStore()
service := NewService(codes, memory.NewAuthDeliveryReportStore())
now := time.Now().UTC()
base := domain.AuthMissingCodeReportRequest{
AuthKeyID: [8]byte{1}, SessionID: 10, Phone: "15550002222",
PhoneCodeHash: "missing", CreatedAt: now,
}
if _, _, err := service.ReportMissingCode(ctx, base); !errors.Is(err, domain.ErrPhoneCodeExpired) {
t.Fatalf("missing hash err=%v, want phone-code expired", err)
}
if err := codes.Set(ctx, "current", store.PhoneCode{
Version: store.PhoneCodeVersionCurrent, Phone: "15550003333",
Channel: store.PhoneCodeChannelPhone,
}, time.Hour); err != nil {
t.Fatal(err)
}
base.PhoneCodeHash = "current"
if _, _, err := service.ReportMissingCode(ctx, base); !errors.Is(err, domain.ErrPhoneCodeInvalid) {
t.Fatalf("mismatched phone err=%v, want phone-code invalid", err)
}
}

View file

@ -1031,6 +1031,29 @@ func (s *Service) ListMessageReactions(ctx context.Context, userID int64, req do
return s.channels.ListChannelMessageReactions(ctx, req)
}
type messageReactionLookupStore interface {
FindChannelMessageReaction(ctx context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
}
func (s *Service) FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 ||
req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID ||
req.ReactorUserID == 0 {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
if req.ViewerUserID == 0 {
req.ViewerUserID = userID
}
if req.ViewerUserID != userID {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
lookup, ok := s.channels.(messageReactionLookupStore)
if !ok {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
return lookup.FindChannelMessageReaction(ctx, req)
}
type messageReactionUsageStore interface {
RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error
}
@ -1445,6 +1468,30 @@ func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.D
return s.channels.DeleteChannelMessages(ctx, req)
}
type moderationChannelMessageStore interface {
ModerationDeleteChannelMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error)
}
// ModerationDeleteMessages is the explicit server-authority deletion path used
// only by the durable moderation action worker. It never accepts a client
// identity and therefore cannot be reached by ordinary RPC permission checks.
func (s *Service) ModerationDeleteMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error) {
if s == nil || s.channels == nil || channelID <= 0 ||
len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
for _, id := range ids {
if id <= 0 || id > domain.MaxMessageBoxID {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
}
store, ok := s.channels.(moderationChannelMessageStore)
if !ok {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
return store.ModerationDeleteChannelMessages(ctx, channelID, append([]int(nil), ids...), date)
}
// DeleteHistory clears the current user's history view or deletes a bounded channel history page for everyone.
func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
if s == nil || s.channels == nil || userID == 0 {

View file

@ -0,0 +1,31 @@
package clienttelemetry
import (
"context"
"fmt"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type Service struct {
store store.ClientTelemetryStore
}
func NewService(telemetryStore store.ClientTelemetryStore) *Service {
return &Service{store: telemetryStore}
}
func (s *Service) Record(ctx context.Context, userID int64, kind domain.ClientTelemetryKind, peer domain.Peer, subjectIDs []int64, payload any, createdAt time.Time) (domain.ClientTelemetryEvent, bool, error) {
if s == nil || s.store == nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("client telemetry store is not configured")
}
event, err := domain.NewClientTelemetryEvent(
userID, kind, peer, subjectIDs, payload, createdAt,
)
if err != nil {
return domain.ClientTelemetryEvent{}, false, err
}
return s.store.CreateClientTelemetry(ctx, event)
}

View file

@ -149,7 +149,9 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
return s.projectContact(ctx, userID, contact)
}
// AcceptContact shares the current user's phone/profile with an existing one-way contact.
// AcceptContact creates the reciprocal contact for an existing one-way contact.
// Phone visibility remains governed exclusively by account privacy rules; this
// RPC has no protocol flag authorizing a hidden phone-number exception.
func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64) (domain.Contact, error) {
if s == nil || s.contacts == nil || s.users == nil || userID == 0 || contactUserID == 0 || contactUserID == userID {
return domain.Contact{}, ErrContactIDInvalid
@ -188,11 +190,6 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64
return domain.Contact{}, err
}
s.InvalidateViewers(userID, contactUserID)
if s.privacy != nil {
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, contactUserID); err != nil {
return domain.Contact{}, err
}
}
contact, found, err := s.contacts.Get(ctx, userID, target.ID)
if err != nil {
return domain.Contact{}, err

View file

@ -67,6 +67,19 @@ type LoginCodeDeliveryRetentionStore interface {
DeleteExpiredLoginCodeDeliveries(ctx context.Context, expiredBefore time.Time, limit int) (int, error)
}
type ClientTelemetryRetentionStore interface {
DeleteExpiredClientTelemetry(ctx context.Context, olderThan time.Time, limit int) (int, error)
}
type AuthDeliveryReportRetentionStore interface {
DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error)
}
type ModerationRetentionStore interface {
DeleteExpiredSponsoredMessageImpressions(ctx context.Context, olderThan time.Time, limit int) (int, error)
DeleteExpiredModerationAppealLinks(ctx context.Context, olderThan time.Time, limit int) (int, error)
}
// botAPIConfirmedGrace 是已确认 Bot API update 行的删除宽限:确认水位之下的行不会再被
// getUpdates 读取fromID 恒 > confirmed宽限仅防御 offset 回拨调试;回收目标是清堆积。
const botAPIConfirmedGrace = 15 * time.Minute
@ -92,24 +105,29 @@ const (
// 前缀;落后或缺 state 的任一设备都会把 floor 压回 0。客户端偶然带回已确认前的旧 pts 时,
// updates 服务通过普通 differenceSlice checkpoint 推进,不发送 differenceTooLong。
type RetentionWorker struct {
outbox DispatchOutboxRetentionStore
tempKeys TempAuthKeyRetentionStore // 可为 nil不回收 temp key 绑定)
authKeySessionLayers AuthKeySessionLayerRetentionStore
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil不回收 Bot API 队列)
userUpdates UserUpdateEventRetentionStore
channelUpdates ChannelUpdateEventRetentionStore
loginCodeDeliveries LoginCodeDeliveryRetentionStore
orphanAuthKeys OrphanAuthKeyRetentionStore
activeAuthKeys ActiveRawAuthKeyProvider
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
logger *zap.Logger
retention time.Duration
botAPIRetention time.Duration
orphanRetention time.Duration
outboxPoisonRetention time.Duration
outboxPoisonInterval time.Duration
interval time.Duration
batch int
outbox DispatchOutboxRetentionStore
tempKeys TempAuthKeyRetentionStore // 可为 nil不回收 temp key 绑定)
authKeySessionLayers AuthKeySessionLayerRetentionStore
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil不回收 Bot API 队列)
userUpdates UserUpdateEventRetentionStore
channelUpdates ChannelUpdateEventRetentionStore
loginCodeDeliveries LoginCodeDeliveryRetentionStore
clientTelemetry ClientTelemetryRetentionStore
authDeliveryReports AuthDeliveryReportRetentionStore
moderation ModerationRetentionStore
orphanAuthKeys OrphanAuthKeyRetentionStore
activeAuthKeys ActiveRawAuthKeyProvider
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
logger *zap.Logger
retention time.Duration
botAPIRetention time.Duration
orphanRetention time.Duration
clientTelemetryRetention time.Duration
authDeliveryReportRetention time.Duration
outboxPoisonRetention time.Duration
outboxPoisonInterval time.Duration
interval time.Duration
batch int
}
func NewRetentionWorker(outbox DispatchOutboxRetentionStore, tempKeys TempAuthKeyRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker {
@ -191,6 +209,29 @@ func (w *RetentionWorker) WithAuthKeySessionLayerRetention(store AuthKeySessionL
return w
}
func (w *RetentionWorker) WithClientTelemetryRetention(store ClientTelemetryRetentionStore, retention time.Duration) *RetentionWorker {
if retention <= 0 {
retention = 30 * 24 * time.Hour
}
w.clientTelemetry = store
w.clientTelemetryRetention = retention
return w
}
func (w *RetentionWorker) WithAuthDeliveryReportRetention(store AuthDeliveryReportRetentionStore, retention time.Duration) *RetentionWorker {
if retention <= 0 {
retention = 30 * 24 * time.Hour
}
w.authDeliveryReports = store
w.authDeliveryReportRetention = retention
return w
}
func (w *RetentionWorker) WithModerationRetention(store ModerationRetentionStore) *RetentionWorker {
w.moderation = store
return w
}
// WithOrphanAuthKeyRetention 启用未授权握手 key 的有界回收。active 必须提供 raw key
// 不能提供 temp→perm business key否则未登录或 PFS 连接会被误判为 orphan。
func (w *RetentionWorker) WithOrphanAuthKeyRetention(store OrphanAuthKeyRetentionStore, active ActiveRawAuthKeyProvider, retention time.Duration) *RetentionWorker {
@ -274,6 +315,45 @@ func (w *RetentionWorker) runRetentionOnce(ctx context.Context) {
w.logger.Info("回收过期 login-code delivery 回执完成", zap.Int("deleted", deleted))
}
}
if w.clientTelemetry != nil {
deleted, err := w.clientTelemetry.DeleteExpiredClientTelemetry(
ctx, time.Now().Add(-w.clientTelemetryRetention), w.batch,
)
if err != nil {
w.logger.Warn("回收过期客户端 telemetry 失败", zap.Error(err))
} else if deleted > 0 {
w.logger.Info("回收过期客户端 telemetry 完成", zap.Int("deleted", deleted))
}
}
if w.authDeliveryReports != nil {
deleted, err := w.authDeliveryReports.DeleteExpiredAuthDeliveryReports(
ctx, time.Now().Add(-w.authDeliveryReportRetention), w.batch,
)
if err != nil {
w.logger.Warn("回收过期验证码投递诊断失败", zap.Error(err))
} else if deleted > 0 {
w.logger.Info("回收过期验证码投递诊断完成", zap.Int("deleted", deleted))
}
}
if w.moderation != nil {
now := time.Now()
impressions, err := w.moderation.DeleteExpiredSponsoredMessageImpressions(
ctx, now, w.batch,
)
if err != nil {
w.logger.Warn("回收过期 sponsored impression 失败", zap.Error(err))
} else if impressions > 0 {
w.logger.Info("回收过期 sponsored impression 完成", zap.Int("deleted", impressions))
}
links, err := w.moderation.DeleteExpiredModerationAppealLinks(
ctx, now, w.batch,
)
if err != nil {
w.logger.Warn("回收过期审核申诉链接失败", zap.Error(err))
} else if links > 0 {
w.logger.Info("回收过期审核申诉链接完成", zap.Int("deleted", links))
}
}
if w.tempKeys != nil {
expiredBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix()
tempDeleted, err := w.tempKeys.DeleteExpired(ctx, expiredBefore, w.batch)

View file

@ -131,6 +131,86 @@ func TestRetentionWorkerReclaimsExpiredLoginCodeDeliveryReceipts(t *testing.T) {
}
}
type fakeReportRetention struct {
telemetryBefore time.Time
authBefore time.Time
sponsoredBefore time.Time
appealBefore time.Time
telemetryCalls int
authCalls int
sponsoredCalls int
appealCalls int
limit int
}
func (f *fakeReportRetention) DeleteExpiredClientTelemetry(_ context.Context, before time.Time, limit int) (int, error) {
f.telemetryCalls++
f.telemetryBefore = before
f.limit = limit
return 1, nil
}
func (f *fakeReportRetention) DeleteExpiredAuthDeliveryReports(_ context.Context, before time.Time, limit int) (int, error) {
f.authCalls++
f.authBefore = before
f.limit = limit
return 1, nil
}
func (f *fakeReportRetention) DeleteExpiredSponsoredMessageImpressions(_ context.Context, before time.Time, limit int) (int, error) {
f.sponsoredCalls++
f.sponsoredBefore = before
f.limit = limit
return 1, nil
}
func (f *fakeReportRetention) DeleteExpiredModerationAppealLinks(_ context.Context, before time.Time, limit int) (int, error) {
f.appealCalls++
f.appealBefore = before
f.limit = limit
return 1, nil
}
func TestRetentionWorkerSeparatesTelemetryDiagnosticsAndModerationCapabilities(t *testing.T) {
const (
telemetryTTL = 7 * 24 * time.Hour
authTTL = 14 * 24 * time.Hour
batch = 47
)
store := &fakeReportRetention{}
w := NewRetentionWorker(
&fakeOutboxRetention{}, nil, zap.NewNop(),
168*time.Hour, time.Hour, batch,
).WithClientTelemetryRetention(store, telemetryTTL).
WithAuthDeliveryReportRetention(store, authTTL).
WithModerationRetention(store)
before := time.Now()
w.runRetentionOnce(context.Background())
after := time.Now()
if store.telemetryCalls != 1 || store.authCalls != 1 ||
store.sponsoredCalls != 1 || store.appealCalls != 1 ||
store.limit != batch {
t.Fatalf("calls telemetry/auth/sponsored/appeal=%d/%d/%d/%d limit=%d",
store.telemetryCalls, store.authCalls,
store.sponsoredCalls, store.appealCalls, store.limit)
}
if store.telemetryBefore.Before(before.Add(-telemetryTTL)) ||
store.telemetryBefore.After(after.Add(-telemetryTTL)) {
t.Fatalf("telemetry boundary=%v", store.telemetryBefore)
}
if store.authBefore.Before(before.Add(-authTTL)) ||
store.authBefore.After(after.Add(-authTTL)) {
t.Fatalf("auth boundary=%v", store.authBefore)
}
if store.sponsoredBefore.Before(before) ||
store.sponsoredBefore.After(after) ||
store.appealBefore.Before(before) ||
store.appealBefore.After(after) {
t.Fatalf("moderation capability boundaries sponsored=%v appeal=%v",
store.sponsoredBefore, store.appealBefore)
}
}
func (f *fakeBotAPIRetention) DeleteDeliveredOrExpired(_ context.Context, confirmedGrace, maxAge time.Duration, limit int) (int, error) {
f.calls++
f.confirmedGrace = confirmedGrace

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

View file

@ -87,6 +87,34 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P
return nil
}
func (c *CachedPrivacyStore) SetPrivacyRulesWithUpdate(
ctx context.Context,
rules domain.PrivacyRules,
event domain.UpdateEvent,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (domain.UpdateEvent, error) {
writer, ok := c.inner.(store.PrivacyUpdateStore)
if !ok {
return domain.UpdateEvent{}, domain.ErrPrivacyRuleInvalid
}
recorded, err := writer.SetPrivacyRulesWithUpdate(ctx, rules, event, excludeAuthKeyID, excludeSessionID)
if err != nil {
return domain.UpdateEvent{}, err
}
c.InvalidateOwners(rules.OwnerUserID)
_ = c.WarmOwners(ctx, rules.OwnerUserID)
return recorded, nil
}
func (c *CachedPrivacyStore) SupportsDurablePrivacyUpdates() bool {
if c == nil {
return false
}
capability, ok := c.inner.(interface{ SupportsDurablePrivacyUpdates() bool })
return ok && capability.SupportsDurablePrivacyUpdates()
}
// WarmOwners 在低频写/变更通知路径一次性装入 owner 的完整规则集。调用方必须先
// InvalidateOwnersepoch 保证预热期间若又发生失效,不会把旧快照写回。
func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error {
@ -185,6 +213,40 @@ func (c *CachedPrivacyStore) FlushReadModelCache() {
c.cache.Flush()
}
// InvalidateOwners lets Service be registered as the single privacy read-model
// cache group: rule snapshots and relationship facts then share one invalidation
// lifecycle.
func (s *Service) InvalidateOwners(ids ...int64) {
if s == nil || s.rules == nil {
return
}
if cache, ok := s.rules.(interface{ InvalidateOwners(...int64) }); ok {
cache.InvalidateOwners(ids...)
}
}
func (s *Service) WarmOwners(ctx context.Context, ids ...int64) error {
if s == nil || s.rules == nil {
return nil
}
if cache, ok := s.rules.(interface {
WarmOwners(context.Context, ...int64) error
}); ok {
return cache.WarmOwners(ctx, ids...)
}
return nil
}
func (s *Service) FlushReadModelCache() {
if s == nil {
return
}
if cache, ok := s.rules.(interface{ FlushReadModelCache() }); ok {
cache.FlushReadModelCache()
}
s.flushFactCaches()
}
// buildPrivacyRulesByOwner 把扁平规则按 owner 归组;每个 owner 都建一个条目(无规则即空 map),
// 这样「查过且无规则」的 owner 也被负缓存,不会反复打后端。
func buildPrivacyRulesByOwner(list []domain.PrivacyRules, owners []int64) map[int64]privacyRulesMap {

View file

@ -0,0 +1,245 @@
package privacy
import (
"context"
"strconv"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
)
const (
defaultPrivacyViewerFactsTTL = 10 * time.Minute
defaultPrivacyMembershipTTL = 24 * time.Hour
privacyViewerFactsMaxEntries = 8192
privacyMembershipMaxEntries = 65536
)
// baseUserProvider returns viewer-independent user facts through the users read
// model. Implementations must batch cold misses rather than issue one query per
// user.
type baseUserProvider interface {
PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error)
}
// channelMembershipProvider is the cold loader behind the bounded membership
// read model. Privacy evaluation never calls it for a warm (chat,user) pair.
type channelMembershipProvider interface {
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
}
type viewerFacts struct {
Found bool
Bot bool
PremiumUntil int64
}
type membershipKey struct {
ChatID int64
UserID int64
}
type evaluationNeeds struct {
viewerBase bool
chatIDs []int64
}
func newViewerFactsCache() *readmodelcache.Cache[int64, viewerFacts] {
return readmodelcache.New[int64, viewerFacts](readmodelcache.Config[int64, viewerFacts]{
MaxEntries: privacyViewerFactsMaxEntries,
TTL: defaultPrivacyViewerFactsTTL,
})
}
func newMembershipCache() *readmodelcache.Cache[membershipKey, bool] {
return readmodelcache.New[membershipKey, bool](readmodelcache.Config[membershipKey, bool]{
MaxEntries: privacyMembershipMaxEntries,
TTL: defaultPrivacyMembershipTTL,
KeyString: func(key membershipKey) string {
return strconv.FormatInt(key.ChatID, 10) + ":" + strconv.FormatInt(key.UserID, 10)
},
})
}
func needsForRules(rules domain.PrivacyRules) evaluationNeeds {
var needs evaluationNeeds
seenChats := make(map[int64]struct{})
for _, rule := range rules.Rules {
switch rule.Kind {
case domain.PrivacyRuleAllowPremium,
domain.PrivacyRuleAllowBots,
domain.PrivacyRuleDisallowBots:
needs.viewerBase = true
case domain.PrivacyRuleAllowChatParticipants,
domain.PrivacyRuleDisallowChatParticipants:
for _, chatID := range rule.ChatIDs {
if chatID <= 0 {
continue
}
if _, ok := seenChats[chatID]; ok {
continue
}
seenChats[chatID] = struct{}{}
needs.chatIDs = append(needs.chatIDs, chatID)
}
}
}
return needs
}
func mergeNeeds(dst *evaluationNeeds, src evaluationNeeds) {
if src.viewerBase {
dst.viewerBase = true
}
if len(src.chatIDs) == 0 {
return
}
seen := make(map[int64]struct{}, len(dst.chatIDs)+len(src.chatIDs))
for _, id := range dst.chatIDs {
seen[id] = struct{}{}
}
for _, id := range src.chatIDs {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
dst.chatIDs = append(dst.chatIDs, id)
}
}
func (s *Service) loadViewerFacts(ctx context.Context, viewerUserIDs []int64) (map[int64]viewerFacts, error) {
ids := dedupNonZero(viewerUserIDs)
if len(ids) == 0 {
return map[int64]viewerFacts{}, nil
}
loadMissing := func(ctx context.Context, missing []int64) (map[int64]viewerFacts, error) {
out := make(map[int64]viewerFacts, len(missing))
for _, id := range missing {
out[id] = viewerFacts{} // negative cache: user was not found.
}
if s == nil || s.baseUsers == nil {
return out, nil
}
users, err := s.baseUsers.PrivacyBaseUsers(ctx, missing)
if err != nil {
return nil, err
}
for _, user := range users {
if user.ID == 0 {
continue
}
out[user.ID] = viewerFacts{
Found: true,
Bot: user.Bot,
PremiumUntil: int64(user.PremiumUntil),
}
}
return out, nil
}
if s == nil || s.viewerFacts == nil {
return loadMissing(ctx, ids)
}
return s.viewerFacts.GetOrLoadBatch(ctx, ids,
func(int64) (int64, bool) { return 0, true },
loadMissing,
)
}
func (s *Service) loadMembershipFacts(ctx context.Context, chatIDs, viewerUserIDs []int64) (map[membershipKey]bool, error) {
chats := dedupNonZero(chatIDs)
viewers := dedupNonZero(viewerUserIDs)
if len(chats) == 0 || len(viewers) == 0 {
return map[membershipKey]bool{}, nil
}
keys := make([]membershipKey, 0, len(chats)*len(viewers))
for _, chatID := range chats {
for _, viewerID := range viewers {
keys = append(keys, membershipKey{ChatID: chatID, UserID: viewerID})
}
}
loadMissing := func(ctx context.Context, missing []membershipKey) (map[membershipKey]bool, error) {
out := make(map[membershipKey]bool, len(missing))
byChat := make(map[int64][]int64)
for _, key := range missing {
out[key] = false // negative cache: not an active member.
byChat[key.ChatID] = append(byChat[key.ChatID], key.UserID)
}
if s == nil || s.memberships == nil {
return out, nil
}
for chatID, userIDs := range byChat {
active, err := s.memberships.FilterActiveChannelMemberIDs(ctx, chatID, userIDs)
if err != nil {
return nil, err
}
for _, userID := range active {
out[membershipKey{ChatID: chatID, UserID: userID}] = true
}
}
return out, nil
}
if s == nil || s.membershipFacts == nil {
return loadMissing(ctx, keys)
}
return s.membershipFacts.GetOrLoadBatch(ctx, keys,
func(membershipKey) (int64, bool) { return 0, true },
loadMissing,
)
}
func applyViewerFacts(ctx *domain.PrivacyContext, facts viewerFacts, now int64) {
if ctx == nil || !facts.Found {
return
}
ctx.ViewerIsBot = facts.Bot
ctx.ViewerIsPremium = !facts.Bot && facts.PremiumUntil > now
}
func applyMembershipFacts(ctx *domain.PrivacyContext, chatIDs []int64, facts map[membershipKey]bool) {
if ctx == nil || len(chatIDs) == 0 {
return
}
for _, chatID := range chatIDs {
if facts[membershipKey{ChatID: chatID, UserID: ctx.ViewerUserID}] {
ctx.SharedChatIDs = append(ctx.SharedChatIDs, chatID)
}
}
}
// InvalidateViewerFacts invalidates bot/premium facts after a user-base change.
func (s *Service) InvalidateViewerFacts(userIDs ...int64) {
if s == nil || s.viewerFacts == nil {
return
}
s.viewerFacts.Invalidate(dedupNonZero(userIDs)...)
}
// InvalidateMembership invalidates one membership pair after a channel-member change.
func (s *Service) InvalidateMembership(channelID, userID int64) {
if s == nil || s.membershipFacts == nil || channelID == 0 || userID == 0 {
return
}
s.membershipFacts.Invalidate(membershipKey{ChatID: channelID, UserID: userID})
}
// InvalidateChannelMemberships invalidates all cached pairs for a changed/deleted channel.
func (s *Service) InvalidateChannelMemberships(channelID int64) {
if s == nil || s.membershipFacts == nil || channelID == 0 {
return
}
s.membershipFacts.InvalidateWhere(func(key membershipKey) bool { return key.ChatID == channelID })
}
func (s *Service) flushFactCaches() {
if s == nil {
return
}
if s.viewerFacts != nil {
s.viewerFacts.Flush()
}
if s.membershipFacts != nil {
s.membershipFacts.Flush()
}
}

View file

@ -3,21 +3,49 @@ package privacy
import (
"context"
"slices"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const maxPrivacyRules = 100
const (
maxPrivacyRules = 100
maxPrivacyRuleIDs = 5000
)
// Service owns account privacy rules and viewer-specific evaluation.
type Service struct {
rules store.PrivacyStore
contacts store.ContactStore
rules store.PrivacyStore
contacts store.ContactStore
baseUsers baseUserProvider
memberships channelMembershipProvider
viewerFacts *readmodelcache.Cache[int64, viewerFacts]
membershipFacts *readmodelcache.Cache[membershipKey, bool]
now func() time.Time
}
func NewService(rules store.PrivacyStore, contacts store.ContactStore) *Service {
return &Service{rules: rules, contacts: contacts}
return &Service{
rules: rules,
contacts: contacts,
viewerFacts: newViewerFactsCache(),
membershipFacts: newMembershipCache(),
now: time.Now,
}
}
// ConfigureReadModels wires the cold loaders behind the bounded in-memory
// privacy fact caches. It is called after users/channels services are built to
// avoid a package dependency cycle.
func (s *Service) ConfigureReadModels(users baseUserProvider, memberships channelMembershipProvider) *Service {
if s == nil {
return s
}
s.baseUsers = users
s.memberships = memberships
return s
}
func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error) {
@ -43,6 +71,52 @@ func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.Pr
}
func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
out, err := normalizedRules(ownerUserID, key, rules)
if err != nil {
return domain.PrivacyRules{}, err
}
if s != nil && s.rules != nil {
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
return domain.PrivacyRules{}, err
}
}
return out, nil
}
// SetRulesWithUpdate uses the production atomic write boundary when available.
// durable=false means no write was attempted; the RPC layer may then use the
// ordinary SetRules + Updates.RecordPrivacy fallback used by memory tests.
func (s *Service) SetRulesWithUpdate(
ctx context.Context,
ownerUserID int64,
key domain.PrivacyKey,
rules []domain.PrivacyRule,
date int,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (domain.PrivacyRules, domain.UpdateEvent, bool, error) {
out, err := normalizedRules(ownerUserID, key, rules)
if err != nil {
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
}
capability, ok := s.rules.(interface{ SupportsDurablePrivacyUpdates() bool })
if !ok || !capability.SupportsDurablePrivacyUpdates() {
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, nil
}
writer := s.rules.(store.PrivacyUpdateStore)
event, err := writer.SetPrivacyRulesWithUpdate(ctx, out, domain.UpdateEvent{
Type: domain.UpdateEventPrivacy,
Date: date,
Privacy: cloneRules(out),
PtsCount: 1,
}, excludeAuthKeyID, excludeSessionID)
if err != nil {
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
}
return out, event, true, nil
}
func normalizedRules(ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
if !ValidKey(key) {
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
}
@ -52,13 +126,7 @@ func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.Pr
if err := validateRules(rules); err != nil {
return domain.PrivacyRules{}, err
}
out := domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}
if s != nil && s.rules != nil {
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
return domain.PrivacyRules{}, err
}
}
return out, nil
return domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}, nil
}
func (s *Service) AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) {
@ -96,17 +164,33 @@ func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, k
if err != nil {
return false, err
}
needs := needsForRules(rules)
evalCtx := domain.PrivacyContext{
OwnerUserID: ownerUserID,
ViewerUserID: viewerUserID,
}
if s != nil && s.contacts != nil {
if _, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
if contact, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
return false, err
} else if found {
evalCtx.ViewerIsContact = true
evalCtx.ViewerCloseFriend = contact.CloseFriend
}
}
if needs.viewerBase {
facts, err := s.loadViewerFacts(ctx, []int64{viewerUserID})
if err != nil {
return false, err
}
applyViewerFacts(&evalCtx, facts[viewerUserID], s.now().Unix())
}
if len(needs.chatIDs) > 0 {
facts, err := s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
if err != nil {
return false, err
}
applyMembershipFacts(&evalCtx, needs.chatIDs, facts)
}
return Evaluate(rules, evalCtx), nil
}
@ -183,6 +267,16 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
}
}
var needs evaluationNeeds
for _, owner := range owners {
for _, key := range keys {
rules, ok := rulesByOwner[owner][key]
if !ok {
rules = defaultRules(owner, key)
}
mergeNeeds(&needs, needsForRules(rules))
}
}
// 批量取「viewer 是否在 owner 的联系人里」owner→viewer 方向,对应 CanSee 的
// contacts.Get(owner, viewer))。
var reverse map[int64]domain.Contact
@ -193,25 +287,82 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
return nil, err
}
}
var baseFacts map[int64]viewerFacts
if needs.viewerBase {
var err error
baseFacts, err = s.loadViewerFacts(ctx, []int64{viewerUserID})
if err != nil {
return nil, err
}
}
var membershipFacts map[membershipKey]bool
if len(needs.chatIDs) > 0 {
var err error
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
if err != nil {
return nil, err
}
}
now := s.now().Unix()
for _, owner := range owners {
_, isContact := reverse[owner]
contact, isContact := reverse[owner]
m := make(map[domain.PrivacyKey]bool, len(keys))
for _, k := range keys {
rules, ok := rulesByOwner[owner][k]
if !ok {
rules = defaultRules(owner, k)
}
m[k] = Evaluate(rules, domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewerUserID,
ViewerIsContact: isContact,
})
evalCtx := domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewerUserID,
ViewerIsContact: isContact,
ViewerCloseFriend: isContact && contact.CloseFriend,
}
applyViewerFacts(&evalCtx, baseFacts[viewerUserID], now)
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
m[k] = Evaluate(rules, evalCtx)
}
out[owner] = m
}
return out, nil
}
// CanContactForFreeBatch evaluates the complete exception predicate for
// per-user contact requirements. Contacts are always free because the global
// setting is explicitly "noncontact peers"; privacyKeyNoPaidMessages adds
// exceptions beyond that relationship. Both facts come from the in-memory
// privacy/contact read models after their bounded cold loads.
func (s *Service) CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) (map[int64]bool, error) {
owners := dedupNonZero(ownerUserIDs)
out := make(map[int64]bool, len(owners))
if viewerUserID == 0 || len(owners) == 0 {
return out, nil
}
visibility, err := s.CanSeeBatch(
ctx,
owners,
viewerUserID,
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
)
if err != nil {
return nil, err
}
var contacts map[int64]domain.Contact
if s != nil && s.contacts != nil {
contacts, err = s.contacts.GetReverseContacts(ctx, viewerUserID, owners)
if err != nil {
return nil, err
}
}
for _, ownerUserID := range owners {
_, isContact := contacts[ownerUserID]
out[ownerUserID] = ownerUserID == viewerUserID ||
isContact ||
visibility[ownerUserID][domain.PrivacyKeyNoPaidMessages]
}
return out, nil
}
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
// 调 CanSee但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
@ -249,6 +400,33 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
}
}
var needs evaluationNeeds
for _, owner := range owners {
for _, key := range keys {
rules, ok := rulesByOwner[owner][key]
if !ok {
rules = defaultRules(owner, key)
}
mergeNeeds(&needs, needsForRules(rules))
}
}
var baseFacts map[int64]viewerFacts
if needs.viewerBase {
var err error
baseFacts, err = s.loadViewerFacts(ctx, viewers)
if err != nil {
return nil, err
}
}
var membershipFacts map[membershipKey]bool
if len(needs.chatIDs) > 0 {
var err error
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, viewers)
if err != nil {
return nil, err
}
}
now := s.now().Unix()
for _, owner := range owners {
// owner 的联系人中哪些是本批 viewer= privacy 的 ViewerIsContact对应 contacts.Get(owner,viewer))。
var ownerContacts map[int64]domain.Contact
@ -269,17 +447,21 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
perViewer[viewer] = m
continue
}
_, isContact := ownerContacts[viewer]
contact, isContact := ownerContacts[viewer]
for _, k := range keys {
rules, ok := rulesByOwner[owner][k]
if !ok {
rules = defaultRules(owner, k)
}
m[k] = Evaluate(rules, domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewer,
ViewerIsContact: isContact,
})
evalCtx := domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewer,
ViewerIsContact: isContact,
ViewerCloseFriend: isContact && contact.CloseFriend,
}
applyViewerFacts(&evalCtx, baseFacts[viewer], now)
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
m[k] = Evaluate(rules, evalCtx)
}
perViewer[viewer] = m
}
@ -370,6 +552,7 @@ func validateRules(rules []domain.PrivacyRule) error {
if len(rules) > maxPrivacyRules {
return domain.ErrPrivacyRuleInvalid
}
totalIDs := 0
for _, rule := range rules {
switch rule.Kind {
case domain.PrivacyRuleAllowContacts,
@ -387,6 +570,20 @@ func validateRules(rules []domain.PrivacyRule) error {
default:
return domain.ErrPrivacyRuleInvalid
}
totalIDs += len(rule.UserIDs) + len(rule.ChatIDs)
if totalIDs > maxPrivacyRuleIDs {
return domain.ErrPrivacyRuleInvalid
}
for _, id := range rule.UserIDs {
if id <= 0 {
return domain.ErrPrivacyRuleInvalid
}
}
for _, id := range rule.ChatIDs {
if id <= 0 {
return domain.ErrPrivacyRuleInvalid
}
}
}
return nil
}

View file

@ -607,6 +607,19 @@ func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]b
}, true, excludeSessionID)
}
// RecordPrivacy durably synchronizes the exact immutable privacy snapshot to
// the account's other sessions and offline difference stream.
func (s *Service) RecordPrivacy(ctx context.Context, stateAuthKeyID [8]byte, userID int64, rules domain.PrivacyRules, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
if rules.OwnerUserID != userID || rules.Key == "" || len(rules.Rules) == 0 {
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrPrivacyRuleInvalid
}
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventPrivacy,
Privacy: rules,
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对
// 状态,重放时按 peer 重载当前值。updateDraftMessage 无 pts 字段,走 LacksWirePts
// aux 簿记topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。

View file

@ -2,6 +2,7 @@ package userprojection
import (
"context"
"time"
"golang.org/x/sync/errgroup"
@ -660,10 +661,8 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in
return domain.User{}, err
}
if !statusAllowed {
user.Status = domain.ApproximateUserStatus(user.LastSeenAt, int(time.Now().Unix()))
user.LastSeenAt = 0
if user.Status.Kind == domain.UserStatusOnline || user.Status.Kind == domain.UserStatusOffline {
user.Status = domain.UserStatus{Kind: domain.UserStatusRecently}
}
}
if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 {
ref.Personal = true

View file

@ -138,6 +138,14 @@ func (s *Service) AdminUser(ctx context.Context, userID int64) (domain.User, boo
return s.loadBaseUserByID(ctx, userID)
}
// PrivacyBaseUsers returns viewer-independent bot/premium facts through the
// shared base-user read model. Privacy uses this as a batched cold loader behind
// its bounded process cache; no viewer projection is performed, avoiding a
// privacy -> users -> privacy recursion.
func (s *Service) PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error) {
return s.loadBaseUsersByIDs(ctx, userIDs)
}
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
if currentUserID == 0 {

View file

@ -90,14 +90,9 @@ type Config struct {
PublicWebBaseURL string
// PublicAppName 是公开落地页展示的产品名,不参与协议路由。
PublicAppName string
// ScamWarning / FakeWarning override the profile warning text injected into
// getFullUser/getFullChannel About for scam/fake peers. Empty keeps the
// built-in per-peer-type English defaults. Clients cannot localize
// server-provided text, so operators set these to their audience language.
ScamWarning string
FakeWarning string
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
// 生产应只监听 loopback并由 nginx 将 /<username>、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。
// 生产应只监听 loopback并由 nginx 将 /<username>、/addstickers/、/addemoji/、
// /addlist/ 与 hash-only /appeal/ 路由反代到该地址。
PublicLinkWebAddr string
// TelegramLoginEnabled mounts the self-hosted Telegram Login/OIDC provider
// on PublicLinkWebAddr. Secrets are file-backed so they are not exposed in
@ -504,8 +499,6 @@ func Load() (Config, error) {
PublicAppLinkBase: publicAppLinkBase,
PublicWebBaseURL: publicWebBaseURL,
PublicAppName: publicAppName,
ScamWarning: envAllowEmptyOr("TELESRV_SCAM_WARNING", ""),
FakeWarning: envAllowEmptyOr("TELESRV_FAKE_WARNING", ""),
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false),
TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"),

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

View file

@ -16,6 +16,12 @@ import (
// registerAccount 注册 account.* RPC handler。
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
registerRPC[*tg.AccountReportPeerRequest](d, tlprofile.SemanticMethodAccountReportPeer, func(ctx context.Context, req *tg.AccountReportPeerRequest) (any, error) {
return r.onAccountReportPeer(ctx, req)
})
registerRPC[*tg.AccountReportProfilePhotoRequest](d, tlprofile.SemanticMethodAccountReportProfilePhoto, func(ctx context.Context, req *tg.AccountReportProfilePhotoRequest) (any, error) {
return r.onAccountReportProfilePhoto(ctx, req)
})
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
return r.onAccountDeleteAccount(ctx, req)
})
@ -867,7 +873,27 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
if r.deps.Privacy == nil {
return &tg.AccountPrivacyRules{Rules: tgPrivacyRules(rules), Users: []tg.UserClass{}, Chats: []tg.ChatClass{}}, nil
}
saved, err := r.deps.Privacy.SetRules(ctx, userID, domainKey, rules)
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
var (
saved domain.PrivacyRules
event domain.UpdateEvent
durableWrite bool
)
if durable, ok := r.deps.Privacy.(PrivacyDurableService); ok {
saved, event, durableWrite, err = durable.SetRulesWithUpdate(
ctx,
userID,
domainKey,
rules,
int(r.clock.Now().Unix()),
rawAuthKeyIDForOrigin(ctx),
sessionID,
)
}
if err == nil && !durableWrite {
saved, err = r.deps.Privacy.SetRules(ctx, userID, domainKey, rules)
}
if err != nil {
return nil, privacyErr(err)
}
@ -876,14 +902,36 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
return nil, err
}
r.invalidateRPCProjectionForUser(userID)
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdatePrivacy{
Key: tgPrivacyKey(saved.Key),
Rules: tgPrivacyRules(saved.Rules),
}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
})
if durableWrite {
if sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
} else if updates, ok := r.deps.Updates.(PrivacyUpdatesService); ok {
event, _, recordErr := updates.RecordPrivacy(
ctx, authKeyID, userID, saved, rawAuthKeyIDForOrigin(ctx), sessionID,
)
if recordErr != nil {
return nil, internalErr()
}
if sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
} else {
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdatePrivacy{
Key: tgPrivacyKey(saved.Key),
Rules: tgPrivacyRules(saved.Rules),
}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
})
}
if domainKey == domain.PrivacyKeyStatusTimestamp {
r.pushStatusPrivacyRefresh(ctx, userID)
}
return out, nil
}
@ -908,10 +956,11 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
return false, tgerr400("TTL_DAYS_INVALID")
}
if svc, ok := r.accountSettingsSvc(); ok {
if _, err := svc.SetAccountTTL(ctx, userID, ttl.Days); err != nil {
saved, err := svc.SetAccountTTL(ctx, userID, ttl.Days)
if err != nil {
return false, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}
@ -938,7 +987,7 @@ func (r *Router) onAccountSetGlobalPrivacySettings(ctx context.Context, settings
if err != nil {
return nil, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
return tgGlobalPrivacySettings(saved.GlobalPrivacy), nil
}
return &settings, nil
@ -965,10 +1014,11 @@ func (r *Router) onAccountSetContentSettings(ctx context.Context, req *tg.Accoun
return false, inputRequestInvalidErr()
}
if svc, ok := r.accountSettingsSvc(); ok {
if _, err := svc.SetSensitiveContent(ctx, userID, req.SensitiveEnabled); err != nil {
saved, err := svc.SetSensitiveContent(ctx, userID, req.SensitiveEnabled)
if err != nil {
return false, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}
@ -991,10 +1041,11 @@ func (r *Router) onAccountSetContactSignUpNotification(ctx context.Context, sile
return false, internalErr()
}
if svc, ok := r.accountSettingsSvc(); ok {
if _, err := svc.SetContactSignUpSilent(ctx, userID, silent); err != nil {
saved, err := svc.SetContactSignUpSilent(ctx, userID, silent)
if err != nil {
return false, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}

View file

@ -0,0 +1,108 @@
package rpc
import (
"context"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
)
func (r *Router) onAccountReportPeer(ctx context.Context, req *tg.AccountReportPeerRequest) (bool, error) {
if req == nil || req.Reason == nil {
return false, inputRequestInvalidErr()
}
if !utf8.ValidString(req.Message) || utf8.RuneCountInString(req.Message) > domain.MaxModerationCommentRunes {
return false, limitInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
reason, ok := moderationReasonFromReportReason(req.Reason)
if !ok {
return false, tgerr.New(400, "REASON_INVALID")
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportPeer(
ctx, userID, domain.ModerationSourceAccountPeer, target,
reason, string(reason), req.Message, r.clock.Now(),
); err != nil {
return false, moderationReportError(err)
}
return true, nil
}
func (r *Router) onAccountReportProfilePhoto(ctx context.Context, req *tg.AccountReportProfilePhotoRequest) (bool, error) {
if req == nil || req.Reason == nil {
return false, inputRequestInvalidErr()
}
if !utf8.ValidString(req.Message) || utf8.RuneCountInString(req.Message) > domain.MaxModerationCommentRunes {
return false, limitInvalidErr()
}
photo, ok := req.PhotoID.(*tg.InputPhoto)
if !ok || photo == nil || photo.ID <= 0 {
return false, photoInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
reason, ok := moderationReasonFromReportReason(req.Reason)
if !ok {
return false, tgerr.New(400, "REASON_INVALID")
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportProfilePhoto(ctx, domain.ModerationProfilePhotoReportRequest{
ReporterUserID: userID, Target: target, PhotoID: photo.ID,
AccessHash: photo.AccessHash, FileReference: append([]byte(nil), photo.FileReference...),
Reason: reason, Comment: req.Message, CreatedAt: r.clock.Now(),
}); err != nil {
if err == domain.ErrModerationEvidenceNotFound {
return false, photoInvalidErr()
}
return false, moderationReportError(err)
}
return true, nil
}
func moderationReasonFromReportReason(reason tg.ReportReasonClass) (domain.ModerationReason, bool) {
switch reason.(type) {
case *tg.InputReportReasonSpam:
return domain.ModerationReasonSpam, true
case *tg.InputReportReasonViolence:
return domain.ModerationReasonViolence, true
case *tg.InputReportReasonPornography:
return domain.ModerationReasonPornography, true
case *tg.InputReportReasonChildAbuse:
return domain.ModerationReasonChildAbuse, true
case *tg.InputReportReasonOther:
return domain.ModerationReasonOther, true
case *tg.InputReportReasonCopyright:
return domain.ModerationReasonCopyright, true
case *tg.InputReportReasonGeoIrrelevant:
return domain.ModerationReasonGeoIrrelevant, true
case *tg.InputReportReasonFake:
return domain.ModerationReasonFake, true
case *tg.InputReportReasonIllegalDrugs:
return domain.ModerationReasonIllegalDrugs, true
case *tg.InputReportReasonPersonalDetails:
return domain.ModerationReasonPersonalDetails, true
default:
return "", false
}
}

View file

@ -0,0 +1,71 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appmoderation "telesrv/internal/app/moderation"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestAccountReportPeerPersistsImmutableSnapshot(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
reporter, err := users.Create(ctx, domain.User{
AccessHash: 101, Phone: "15550005001", FirstName: "Reporter",
})
if err != nil {
t.Fatal(err)
}
target, err := users.Create(ctx, domain.User{
AccessHash: 202, Phone: "15550005002", FirstName: "Target",
Username: "reported_target", About: "original bio",
})
if err != nil {
t.Fatal(err)
}
userService := appusers.NewService(users)
reports := memory.NewModerationReportStore()
router := New(Config{}, Deps{
Users: userService,
Moderation: appmoderation.NewService(
reports, appmoderation.WithPeerReaders(userService, nil),
),
}, zaptest.NewLogger(t), clock.System)
ok, err := router.onAccountReportPeer(
WithUserID(ctx, reporter.ID),
&tg.AccountReportPeerRequest{
Peer: &tg.InputPeerUser{
UserID: target.ID, AccessHash: target.AccessHash,
},
Reason: &tg.InputReportReasonFake{},
Message: "This profile impersonates someone.",
},
)
if err != nil || !ok {
t.Fatalf("report peer ok=%v err=%v", ok, err)
}
stored := reports.Reports()
if len(stored) != 1 ||
stored[0].ReporterUserID != reporter.ID ||
stored[0].Target != (domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}) ||
stored[0].Reason != domain.ModerationReasonFake ||
len(stored[0].Items) != 1 ||
stored[0].Items[0].Kind != domain.ModerationItemPeer {
t.Fatalf("stored report=%+v", stored)
}
if _, err := users.UpdateProfile(ctx, target.ID, "Changed", "", "changed later"); err != nil {
t.Fatal(err)
}
again, found, err := reports.GetModerationReport(ctx, stored[0].ID)
if err != nil || !found ||
string(again.Items[0].Evidence) != string(stored[0].Items[0].Evidence) {
t.Fatalf("immutable snapshot=%+v found=%v err=%v", again, found, err)
}
}

View file

@ -45,6 +45,46 @@ func (c *accountSettingsCache) Delete(userID int64) {
c.cache.Invalidate(userID)
}
func (c *accountSettingsCache) Store(userID int64, settings domain.AccountSettings) {
if c == nil || userID == 0 {
return
}
c.cache.Store(userID, settings)
}
type accountSettingsBatchReader interface {
GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error)
}
func (c *accountSettingsCache) getOrLoadBatch(
ctx context.Context,
userIDs []int64,
svc accountSettingsService,
) (map[int64]domain.AccountSettings, error) {
if len(userIDs) == 0 {
return map[int64]domain.AccountSettings{}, nil
}
return c.cache.GetOrLoadBatch(
ctx,
userIDs,
func(int64) (int64, bool) { return 0, true },
func(ctx context.Context, missing []int64) (map[int64]domain.AccountSettings, error) {
if batch, ok := svc.(accountSettingsBatchReader); ok {
return batch.GetAccountSettingsBatch(ctx, missing)
}
out := make(map[int64]domain.AccountSettings, len(missing))
for _, userID := range missing {
settings, err := svc.GetAccountSettings(ctx, userID)
if err != nil {
return nil, err
}
out[userID] = settings
}
return out, nil
},
)
}
// cachedAccountSettings 取(缓存的)账号单例设置;服务未接通返回默认。
func (r *Router) cachedAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) {
svc, ok := r.accountSettingsSvc()

View file

@ -64,6 +64,9 @@ func (r *Router) registerAuth(d *tlprofile.Dispatcher) {
registerRPC[*tg.AuthSendCodeRequest](d, tlprofile.SemanticMethodAuthSendCode, func(ctx context.Context, layerRequest *tg.AuthSendCodeRequest) (any, error) {
return r.onAuthSendCode(ctx, layerRequest)
})
registerRPC[*tg.AuthReportMissingCodeRequest](d, tlprofile.SemanticMethodAuthReportMissingCode, func(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (any, error) {
return r.onAuthReportMissingCode(ctx, req)
})
registerRPC[*tg.AuthResendCodeRequest](d, tlprofile.SemanticMethodAuthResendCode, func(ctx context.Context, layerRequest *tg.AuthResendCodeRequest) (any, error) {
return r.onAuthResendCode(ctx, layerRequest)
})
@ -376,6 +379,36 @@ func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest
return r.tgSentCodeForHash(ctx, hash)
}
func (r *Router) onAuthReportMissingCode(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (bool, error) {
if req == nil || r.deps.AuthDeliveryReports == nil {
return false, inputRequestInvalidErr()
}
authKeyID, authKeyOK := AuthKeyIDFrom(ctx)
sessionID, sessionOK := SessionIDFrom(ctx)
if !authKeyOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 {
return false, internalErr()
}
clientType := string(ClientTypeFrom(ctx))
if _, _, err := r.deps.AuthDeliveryReports.ReportMissingCode(ctx, domain.AuthMissingCodeReportRequest{
AuthKeyID: authKeyID, SessionID: sessionID, ClientType: clientType,
Phone: req.PhoneNumber, PhoneCodeHash: req.PhoneCodeHash,
MNC: req.Mnc, CreatedAt: r.clock.Now(),
}); err != nil {
switch {
case errors.Is(err, domain.ErrPhoneCodeExpired):
return false, phoneCodeExpiredErr()
case errors.Is(err, domain.ErrPhoneCodeInvalid),
errors.Is(err, domain.ErrAuthDeliveryReportInvalid):
return false, phoneCodeInvalidErr()
case errors.Is(err, domain.ErrAuthDeliveryRateLimited):
return false, floodWaitErr(60)
default:
return false, internalErr()
}
}
return true, nil
}
func tgSentCode(hash string) tg.AuthSentCodeClass {
return tgSentCodeWithLength(hash, devCodeLength)
}

View file

@ -29,6 +29,10 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
return nil, err
}
memberIDs = createChatInviteMemberIDs(memberIDs, userID)
memberIDs, missingInvitees, err := r.filterChatInvitePrivacy(ctx, userID, memberIDs)
if err != nil {
return nil, internalErr()
}
date := int(r.clock.Now().Unix())
r.log.Debug("messages.createChat resolved users",
zap.Int("input_users", len(req.Users)),
@ -84,7 +88,7 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, inviteRes, cache)
})
}
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: missingInvitees}, nil
}
func (r *Router) onMessagesMigrateChat(ctx context.Context, chatID int64) (tg.UpdatesClass, error) {

View file

@ -311,7 +311,27 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
if err != nil {
return nil, err
}
res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, int(r.clock.Now().Unix()))
// Authorize before evaluating target privacy, otherwise a non-admin could
// probe whether a target permits invites.
view, err := r.deps.Channels.ResolveChannel(ctx, userID, channelID)
if err != nil {
return nil, channelInviteErr(err)
}
if !view.Self.CanInviteUsers(view.Channel) {
return nil, channelInviteErr(domain.ErrChannelAdminRequired)
}
userIDs, missingInvitees, err := r.filterChatInvitePrivacy(ctx, userID, userIDs)
if err != nil {
return nil, internalErr()
}
date := int(r.clock.Now().Unix())
if len(userIDs) == 0 {
return &tg.MessagesInvitedUsers{
Updates: emptyInvitedUsersUpdates(date),
MissingInvitees: missingInvitees,
}, nil
}
res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, date)
if err != nil {
return nil, channelInviteErr(err)
}
@ -322,7 +342,7 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, cache)
})
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: missingInvitees}, nil
}
func (r *Router) onChannelsJoinChannel(ctx context.Context, input tg.InputChannelClass) (tg.MessagesChatInviteJoinResultClass, error) {

View file

@ -350,6 +350,24 @@ func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsD
return &tg.MessagesAffectedMessages{Pts: res.Channel.Pts, PtsCount: 0}, nil
}
// NotifyModerationChannelDeletion performs the online accelerator for a
// server-authority deletion already committed by the moderation action worker.
// Durable channel update events remain the offline recovery source.
func (r *Router) NotifyModerationChannelDeletion(ctx context.Context, res domain.DeleteChannelMessagesResult) {
if r == nil || res.Event.Pts == 0 {
return
}
r.enqueueChannelFanout(ctx, channelFanoutMembers, domain.OfficialSystemUserID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event)
})
for _, cascade := range res.DiscussionDeletes {
cascade := cascade
r.enqueueChannelFanout(ctx, channelFanoutMembers, domain.OfficialSystemUserID, cascade.Channel.ID, cascade.Event.Pts, cascade.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelDeleteMessagesUpdates(viewerUserID, cascade.Channel, cascade.Event)
})
}
}
func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDeleteHistoryRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil

View file

@ -3,7 +3,10 @@ package rpc
import (
"github.com/iamxvbaba/td/tg"
"strings"
apptelemetry "telesrv/internal/app/clienttelemetry"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
"time"
)
@ -11,6 +14,14 @@ import (
func TestTDesktopPassiveChannelStubs(t *testing.T) {
f := newRPCChannelFixture(t)
r := f.router
moderationReports := memory.NewModerationReportStore()
telemetryEvents := memory.NewClientTelemetryStore()
r.deps.ClientTelemetry = apptelemetry.NewService(telemetryEvents)
r.deps.Moderation = appmoderation.NewService(
moderationReports,
appmoderation.WithMessageReaders(nil, r.deps.Channels),
appmoderation.WithPeerReaders(r.deps.Users, r.deps.Channels),
)
owner := f.user(41, "15550002101", "Owner")
friend := f.user(42, "15550002102", "Friend")
invited := f.user(43, "15550002103", "Invited")
@ -470,6 +481,9 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if _, ok := reported.(*tg.ReportResultReported); !ok {
t.Fatalf("messages.report spam = %#v, want reported", reported)
}
if got := moderationReports.Reports(); len(got) != 2 {
t.Fatalf("moderation reports = %+v, want peer-spam and message reports", got)
}
if _, err := r.onMessagesReport(ownerCtx, &tg.MessagesReportRequest{
Peer: inputPeerChannel(channel),
ID: []int{1},
@ -477,13 +491,6 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
}); err == nil || !strings.Contains(err.Error(), "OPTION_INVALID") {
t.Fatalf("messages.report invalid option err = %v, want OPTION_INVALID", err)
}
if ok, err := r.onMessagesReportReaction(ownerCtx, &tg.MessagesReportReactionRequest{
Peer: inputPeerChannel(channel),
ID: 1,
ReactionPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
}); err != nil || !ok {
t.Fatalf("messages.reportReaction = ok %v err %v, want true nil", ok, err)
}
if ok, err := r.onMessagesReportMessagesDelivery(ownerCtx, &tg.MessagesReportMessagesDeliveryRequest{
Peer: inputPeerChannel(channel),
ID: []int{1},
@ -503,11 +510,45 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
}); err != nil || !ok {
t.Fatalf("messages.reportReadMetrics = ok %v err %v, want true nil", ok, err)
}
if events := telemetryEvents.Events(); len(events) != 2 ||
events[0].Kind != domain.ClientTelemetryMessageDelivery ||
events[1].Kind != domain.ClientTelemetryReadMetrics ||
len(moderationReports.Reports()) != 2 {
t.Fatalf("telemetry=%+v moderation=%+v, want separate durable streams",
events, moderationReports.Reports())
}
if ok, err := r.onMessagesReportMusicListen(ownerCtx, &tg.MessagesReportMusicListenRequest{
ID: &tg.InputDocument{ID: 1, AccessHash: 2},
ListenedDuration: 1,
}); err != nil || !ok {
t.Fatalf("messages.reportMusicListen = ok %v err %v, want true nil", ok, err)
}); err == nil || ok || !strings.Contains(err.Error(), "DOCUMENT_INVALID") {
t.Fatalf("messages.reportMusicListen = ok %v err %v, want DOCUMENT_INVALID", ok, err)
}
if _, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
RandomID: []byte("unseen-ad"),
}); err == nil || !strings.Contains(err.Error(), "RANDOM_ID_INVALID") {
t.Fatalf("unseen sponsored report err=%v, want RANDOM_ID_INVALID", err)
}
impression, err := domain.NewSponsoredMessageImpression(
owner.ID, []byte("ad"),
domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID},
0, []byte(`{"schema_version":1,"text":"test sponsored message"}`),
time.Now().UTC(), time.Now().UTC().Add(time.Hour),
)
if err != nil {
t.Fatal(err)
}
if _, _, err := moderationReports.CreateSponsoredMessageImpression(ownerCtx, impression); err != nil {
t.Fatal(err)
}
sponsoredOptions, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
RandomID: []byte("ad"),
})
if err != nil {
t.Fatalf("messages.reportSponsoredMessage options: %v", err)
}
if choices, ok := sponsoredOptions.(*tg.ChannelsSponsoredMessageReportResultChooseOption); !ok ||
len(choices.Options) == 0 {
t.Fatalf("sponsored options=%#v, want chooseOption", sponsoredOptions)
}
sponsoredReport, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
RandomID: []byte("ad"),
@ -577,6 +618,13 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if _, err := r.onMessagesSendReaction(friendCtx, friendReactionReq); err != nil {
t.Fatalf("messages.sendReaction by friend: %v", err)
}
if ok, err := r.onMessagesReportReaction(ownerCtx, &tg.MessagesReportReactionRequest{
Peer: inputPeerChannel(channel),
ID: viewedID,
ReactionPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
}); err != nil || !ok {
t.Fatalf("messages.reportReaction = ok %v err %v, want true nil", ok, err)
}
unreadReactions, err := r.onMessagesGetUnreadReactions(ownerCtx, &tg.MessagesGetUnreadReactionsRequest{
Peer: inputPeerChannel(channel),
Limit: 10,

View file

@ -206,9 +206,21 @@ func (r *Router) onChannelsReportAntiSpamFalsePositive(ctx context.Context, req
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return false, messageIDInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
userID, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if !view.Channel.Megagroup || view.Channel.Broadcast {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportAntiSpamFalsePositive(
ctx, userID, view.Channel.ID, req.MsgID, r.clock.Now(),
); err != nil {
return false, moderationReportError(err)
}
return true, nil
}

View file

@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
@ -12,6 +13,7 @@ import (
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/app/readmodel"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
@ -598,9 +600,17 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002301", FirstName: "Owner"})
requester, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002302", FirstName: "Requester"})
channelStore := memory.NewChannelStore()
moderationStore := memory.NewModerationReportStore()
userService := appusers.NewService(userStore)
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Users: userService,
Channels: channelService,
Moderation: appmoderation.NewService(
moderationStore,
appmoderation.WithMessageReaders(nil, channelService),
appmoderation.WithPeerReaders(userService, channelService),
),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Public Team",
@ -621,6 +631,23 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
t.Fatalf("send seed message: %v", err)
}
msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID
if ok, err := r.onChannelsReportAntiSpamFalsePositive(
WithUserID(ctx, owner.ID),
&tg.ChannelsReportAntiSpamFalsePositiveRequest{Channel: input, MsgID: msgID},
); err == nil || ok || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("report anti-spam without native decision = ok %v err %v, want MESSAGE_ID_INVALID", ok, err)
}
antiSpamDecision, err := domain.NewChannelAntiSpamDecision(
channel.ID, msgID, owner.ID,
[]byte(`{"schema_version":1,"source":"native_antispam"}`),
time.Now().UTC(),
)
if err != nil {
t.Fatal(err)
}
if _, _, err := moderationStore.CreateChannelAntiSpamDecision(ctx, antiSpamDecision); err != nil {
t.Fatal(err)
}
okUsername, err := r.onChannelsCheckUsername(WithUserID(ctx, owner.ID), &tg.ChannelsCheckUsernameRequest{
Channel: input,

View file

@ -18,6 +18,12 @@ func validateEmptyChannelStickerSet(stickerset tg.InputStickerSetClass) error {
}
func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsReportSpamRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
if len(req.ID) == 0 {
return false, tgerr.New(400, "MESSAGE_ID_REQUIRED")
}
if len(req.ID) > maxChannelReportMessageIDs {
return false, limitInvalidErr()
}
@ -26,12 +32,30 @@ func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsRepor
return false, messageIDInvalidErr()
}
}
if _, _, err := r.channelView(ctx, req.Channel); err != nil {
userID, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if peer, ok := r.domainPeerFromInputPeer(0, req.Participant); !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
if !view.Channel.Megagroup {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Participant)
if err != nil {
return false, err
}
if peer.Type != domain.PeerTypeUser || peer.ID == 0 {
return false, peerIDInvalidErr()
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportChannelSpam(ctx, domain.ModerationChannelSpamReportRequest{
ReporterUserID: userID, ChannelID: view.Channel.ID,
ParticipantUserID: peer.ID, MessageIDs: req.ID,
CreatedAt: r.clock.Now(),
}); err != nil {
return false, moderationReportError(err)
}
return true, nil
}

View file

@ -540,6 +540,7 @@ func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus,
}
}
}
statusVisible := r.statusTimestampVisibleToViewer(ctx, contactUserIDs, userID)
seen = make(map[int64]struct{}, len(list.Contacts))
for _, contact := range list.Contacts {
id := contact.User.ID
@ -555,9 +556,19 @@ func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus,
u.LastSeenAt = current.LastSeenAt
u.Status = current.Status
}
status := u.Status
if statusVisible[id] {
status = r.userPresenceStatusForUser(u)
} else {
switch status.Kind {
case domain.UserStatusRecently, domain.UserStatusLastWeek, domain.UserStatusLastMonth, domain.UserStatusEmpty:
default:
status = domain.ApproximateUserStatus(u.LastSeenAt, int(r.clock.Now().Unix()))
}
}
out = append(out, tg.ContactStatus{
UserID: id,
Status: tgUserStatus(r.userPresenceStatusForUser(u)),
Status: tgUserStatus(status),
})
}
return out, nil

View file

@ -543,16 +543,6 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
return out
}
// channelAboutWithModerationWarning decorates the projected channel/supergroup
// About with the scam/fake warning when set (group vs channel wording).
func channelAboutWithModerationWarning(ch domain.Channel) string {
scamText, fakeText := defaultScamWarningChannel, defaultFakeWarningChannel
if ch.Megagroup && !ch.Broadcast {
scamText, fakeText = defaultScamWarningGroup, defaultFakeWarningGroup
}
return aboutWithModerationWarning(ch.About, scamText, fakeText, ch.Scam, ch.Fake)
}
func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.ChannelFull {
ch := view.Channel
full := &tg.ChannelFull{
@ -563,13 +553,15 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
CanSetUsername: view.Self.Role == domain.ChannelRoleCreator,
CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator,
ID: ch.ID,
About: channelAboutWithModerationWarning(ch),
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
UnreadCount: view.Dialog.UnreadCount,
ChatPhoto: tgChannelChatPhotoFull(ch),
NotifySettings: *tdesktop.NotifySettings(),
Pts: ch.Pts,
// Official clients render localized warnings from scam/fake flags.
// About remains the owner's unmodified description.
About: ch.About,
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
UnreadCount: view.Dialog.UnreadCount,
ChatPhoto: tgChannelChatPhotoFull(ch),
NotifySettings: *tdesktop.NotifySettings(),
Pts: ch.Pts,
}
if ch.ParticipantsCount > 0 {
full.SetParticipantsCount(ch.ParticipantsCount)

View file

@ -1,81 +0,0 @@
package rpc
import (
"strings"
"sync/atomic"
)
// Scam/fake profile warnings surfaced in the full-profile About text.
//
// Telegram Desktop only ships the SCAM/FAKE badge strings and renders no
// warning paragraph, while iOS/Android show a localized warning. To make the
// warning visible on every client, the server injects it into the projected
// getFullUser/getFullChannel About field. Injection is non-destructive: the
// stored bio/description is never overwritten, only the response is decorated,
// so clearing the flag restores the original text and the warning survives the
// owner editing their bio/description (it is re-applied from the flag on every
// read).
//
// The text is server-provided (clients cannot localize it). Operators override
// it via TELESRV_SCAM_WARNING / TELESRV_FAKE_WARNING; when unset the built-in
// per-peer-type English defaults are used. scam takes precedence over fake.
const (
defaultScamWarningUser = "\u26A0\uFE0F Warning: Many users reported this account as a scam. Please be careful, especially if it asks you for money."
defaultFakeWarningUser = "\u26A0\uFE0F Warning: Many users reported that this account impersonates a famous person or organization."
defaultScamWarningChannel = "\u26A0\uFE0F Warning: Many users reported this channel as a scam. Please be careful, especially if it asks you for money."
defaultFakeWarningChannel = "\u26A0\uFE0F Warning: Many users reported that this channel impersonates a famous person or organization."
defaultScamWarningGroup = "\u26A0\uFE0F Warning: Many users reported this group as a scam. Please be careful, especially if it asks you for money."
defaultFakeWarningGroup = "\u26A0\uFE0F Warning: Many users reported that this group impersonates a famous person or organization."
)
// moderationWarningOverrides holds the operator-configured texts. They are set
// once at startup (SetModerationWarnings) before any request is served, and
// read on the hot path; atomic.Pointer keeps that race-free without locking.
var moderationWarningOverrides atomic.Pointer[moderationWarningConfig]
type moderationWarningConfig struct {
scam string
fake string
}
// SetModerationWarnings installs operator overrides for the scam/fake profile
// warnings. Empty strings keep the built-in per-peer-type defaults. A single
// override applies to every peer type (user/channel/group).
func SetModerationWarnings(scam, fake string) {
moderationWarningOverrides.Store(&moderationWarningConfig{
scam: strings.TrimSpace(scam),
fake: strings.TrimSpace(fake),
})
}
func moderationOverride() moderationWarningConfig {
if cfg := moderationWarningOverrides.Load(); cfg != nil {
return *cfg
}
return moderationWarningConfig{}
}
// aboutWithModerationWarning prepends the scam/fake warning to a profile About.
// It returns about unchanged when neither flag is set. The operator override
// wins over the per-type default; scam wins over fake when both are set.
func aboutWithModerationWarning(about, scamDefault, fakeDefault string, scam, fake bool) string {
override := moderationOverride()
warning := ""
switch {
case scam:
if warning = override.scam; warning == "" {
warning = scamDefault
}
case fake:
if warning = override.fake; warning == "" {
warning = fakeDefault
}
}
if warning == "" {
return about
}
if about = strings.TrimSpace(about); about == "" {
return warning
}
return warning + "\n\n" + about
}

View file

@ -242,6 +242,14 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
return nil
}
return &tg.UpdateUserEmojiStatus{UserID: event.UserID, EmojiStatus: tgUserEmojiStatusValue(event.EmojiStatus)}
case domain.UpdateEventPrivacy:
if event.Privacy.OwnerUserID == 0 || event.Privacy.Key == "" || len(event.Privacy.Rules) == 0 {
return nil
}
return &tg.UpdatePrivacy{
Key: tgPrivacyKey(event.Privacy.Key),
Rules: tgPrivacyRules(event.Privacy.Rules),
}
case domain.UpdateEventChannelState:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil

View file

@ -21,6 +21,8 @@ func tgSelfUser(u domain.User) *tg.User {
Phone: u.Phone,
Self: true,
Verified: u.Verified,
Scam: u.Scam,
Fake: u.Fake,
Support: u.Support,
Contact: u.Contact,
MutualContact: u.Mutual,

View file

@ -47,6 +47,14 @@ type AuthService interface {
ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
}
type AuthDeliveryReportService interface {
ReportMissingCode(ctx context.Context, req domain.AuthMissingCodeReportRequest) (domain.AuthDeliveryReport, bool, error)
}
type ClientTelemetryService interface {
Record(ctx context.Context, userID int64, kind domain.ClientTelemetryKind, peer domain.Peer, subjectIDs []int64, payload any, createdAt time.Time) (domain.ClientTelemetryEvent, bool, error)
}
// SessionBinder 抽象登录后 session 与 user 的在线绑定。
//
// MTProto session 的完整身份是 raw auth_key_id + session_id。所有定位单个 session
@ -470,6 +478,26 @@ type UserEmojiStatusUpdatesService interface {
RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
}
// PrivacyUpdatesService is the fallback durable extension for stores that do
// not support the atomic privacy+event write boundary (mainly memory tests).
type PrivacyUpdatesService interface {
RecordPrivacy(ctx context.Context, stateAuthKeyID [8]byte, userID int64, rules domain.PrivacyRules, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
}
// PrivacyDurableService is implemented by the production privacy service. Its
// successful path commits rules+pts+event+dispatch in one transaction.
type PrivacyDurableService interface {
SetRulesWithUpdate(
ctx context.Context,
ownerUserID int64,
key domain.PrivacyKey,
rules []domain.PrivacyRule,
date int,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (saved domain.PrivacyRules, event domain.UpdateEvent, durable bool, err error)
}
// ContactsService 抽象通讯录查询。
type ContactsService interface {
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error)
@ -669,6 +697,7 @@ type ChannelsService interface {
VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
CloseMessagePoll(ctx context.Context, userID int64, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
ListMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error)
FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
TopReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
ClearRecentReactions(ctx context.Context, userID int64) error
@ -875,9 +904,28 @@ type EphemeralService interface {
ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error)
}
// ModerationService accepts only final report choices. Implementations must
// validate and snapshot referenced evidence, then durably commit the immutable
// submission before returning success.
type ModerationService interface {
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)
ReportMessages(ctx context.Context, req domain.ModerationMessageReportRequest) (domain.ModerationReport, bool, error)
ReportProfilePhoto(ctx context.Context, req domain.ModerationProfilePhotoReportRequest) (domain.ModerationReport, bool, error)
ReportChannelSpam(ctx context.Context, req domain.ModerationChannelSpamReportRequest) (domain.ModerationReport, bool, error)
ReportReaction(ctx context.Context, req domain.ModerationReactionReportRequest) (domain.ModerationReport, bool, error)
ReportEncryptedSpam(ctx context.Context, reporterUserID int64, chat domain.SecretChat, createdAt time.Time) (domain.ModerationReport, bool, error)
ReportStories(ctx context.Context, req domain.ModerationStoryReportRequest) (domain.ModerationReport, bool, error)
ReportEphemeral(ctx context.Context, reporterUserID int64, target domain.EphemeralMessage, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error)
SponsoredImpression(ctx context.Context, userID int64, randomID []byte, now time.Time) (domain.SponsoredMessageImpression, error)
ReportSponsored(ctx context.Context, userID int64, randomID []byte, reason domain.ModerationReason, option string, now time.Time) (domain.ModerationReport, bool, error)
ReportAntiSpamFalsePositive(ctx context.Context, reporterUserID, channelID int64, messageID int, now time.Time) (domain.ModerationReport, bool, error)
}
// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件auth.go / users.go / updates.go
type Deps struct {
Auth AuthService
Auth AuthService
AuthDeliveryReports AuthDeliveryReportService
ClientTelemetry ClientTelemetryService
// AuthKeySessionLayers is the protocol-only durable ordering boundary for
// explicit invokeWithLayer evidence. Production must wire the same auth-key
// store used by the MTProto edge; nil is reserved for isolated router tests.
@ -889,7 +937,7 @@ type Deps struct {
AICompose AIComposeService
Ephemeral EphemeralService
EphemeralPush store.EphemeralPushBroker
EphemeralReports store.EphemeralReportStore
Moderation ModerationService
Users UsersService
TelegramLogin TelegramLoginService
Updates UpdatesService

View file

@ -93,12 +93,24 @@ func (r *Router) onMessagesReceivedQueue(ctx context.Context, maxQts int) ([]int
return []int64{}, nil
}
// onMessagesReportEncryptedSpam 纯记录:服务端不自动 discard、不拉黑、不产生 update
// discard/block 由客户端独立 RPC 完成。P1 接受并回 true。
func (r *Router) onMessagesReportEncryptedSpam(ctx context.Context, _ tg.InputEncryptedChat) (bool, error) {
if _, err := r.secretChatRequireUser(ctx); err != nil {
// onMessagesReportEncryptedSpam persists an immutable chat-metadata snapshot;
// the server remains unable to inspect encrypted message plaintext. Reporting
// does not discard or block the chat and emits no update.
func (r *Router) onMessagesReportEncryptedSpam(ctx context.Context, peer tg.InputEncryptedChat) (bool, error) {
if r.deps.SecretChats == nil || r.deps.Moderation == nil {
return false, notImplementedErr()
}
userID, err := r.secretChatRequireUser(ctx)
if err != nil {
return false, err
}
chat, _, _, err := r.resolveSecretChatPeer(ctx, userID, peer)
if err != nil {
return false, err
}
if _, _, err := r.deps.Moderation.ReportEncryptedSpam(ctx, userID, chat, r.clock.Now()); err != nil {
return false, moderationReportError(err)
}
return true, nil
}

View file

@ -6,8 +6,10 @@ import (
"github.com/iamxvbaba/td/tg"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/postresponse"
"telesrv/internal/store/memory"
)
// acceptChat 跑完 request→accept返回 normal 态密聊 id 与 participant 视角 access_hash。
@ -46,6 +48,33 @@ func encNewMessagePayload(t *testing.T, rec phonePushRecord) *tg.UpdateNewEncryp
return upd
}
func TestReportEncryptedSpamPersistsMetadataOnly(t *testing.T) {
f := newEncryptedFixture(t)
chatID, participantAccessHash := f.acceptChat(t)
reports := memory.NewModerationReportStore()
f.router.deps.Moderation = appmoderation.NewService(reports)
ok, err := f.router.onMessagesReportEncryptedSpam(
f.participantCtx(),
tg.InputEncryptedChat{ChatID: chatID, AccessHash: participantAccessHash},
)
if err != nil || !ok {
t.Fatalf("report encrypted spam ok=%v err=%v", ok, err)
}
stored := reports.Reports()
if len(stored) != 1 ||
stored[0].Source != domain.ModerationSourceEncryptedSpam ||
stored[0].ReporterUserID != f.participant.ID ||
stored[0].Target != (domain.Peer{Type: domain.PeerTypeUser, ID: f.admin.ID}) ||
len(stored[0].Items) != 1 ||
stored[0].Items[0].Kind != domain.ModerationItemEncryptedChat {
t.Fatalf("stored encrypted report=%+v", stored)
}
if string(stored[0].Items[0].Evidence) == "" ||
string(stored[0].Items[0].Evidence) == "plaintext" {
t.Fatalf("encrypted metadata evidence=%s", stored[0].Items[0].Evidence)
}
}
func TestSendEncryptedRPCFlow(t *testing.T) {
f := newEncryptedFixture(t)
chatID, _ := f.acceptChat(t)

View file

@ -245,13 +245,18 @@ func (r *Router) onEphemeralReportMessage(ctx context.Context, request *tg.Ephem
if _, final := result.(*tg.ReportResultReported); !final {
return result, nil
}
if r.deps.EphemeralReports == nil {
reason, ok := moderationReasonForReportOption(string(request.Option))
if !ok {
return nil, tgerr.New(400, "OPTION_INVALID")
}
if r.deps.Moderation == nil {
return nil, internalErr()
}
report := domain.NewEphemeralAbuseReport(userID, string(request.Option), request.Message, target, r.clock.Now())
if _, err := r.deps.EphemeralReports.CreateEphemeralReport(ctx, report); err != nil {
if _, _, err := r.deps.Moderation.ReportEphemeral(
ctx, userID, target, reason, string(request.Option), request.Message, r.clock.Now(),
); err != nil {
r.log.Warn("persist ephemeral abuse report", zap.Int64("reporter_user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("ephemeral_message_id", request.ID), zap.Error(err))
return nil, internalErr()
return nil, moderationReportError(err)
}
return result, nil
}

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"strings"
"testing"
"time"
@ -9,6 +10,7 @@ import (
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
@ -49,13 +51,14 @@ func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKey, SessionID: 99},
PayloadHash: [32]byte{9}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
reports := memory.NewEphemeralReportStore()
reports := memory.NewModerationReportStore()
moderation := appmoderation.NewService(reports)
ephemeral := &ephemeralReportService{target: target}
channels := &ephemeralReportChannels{view: domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 42, Megagroup: true},
Self: domain.ChannelMember{ChannelID: channelID, UserID: userID, Status: domain.ChannelMemberActive},
}}
router := New(Config{}, Deps{Ephemeral: ephemeral, EphemeralReports: reports, Channels: channels}, zaptest.NewLogger(t), clock.System)
router := New(Config{}, Deps{Ephemeral: ephemeral, Moderation: moderation, Channels: channels}, zaptest.NewLogger(t), clock.System)
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKey), 99)
request := &tg.EphemeralReportMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: 42}, ID: target.ID,
@ -87,7 +90,9 @@ func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
}
}
stored := reports.Reports()
if len(stored) != 1 || stored[0].Evidence.Content.Message != "abuse" || stored[0].Comment != "evidence comment" {
if len(stored) != 1 || stored[0].Source != domain.ModerationSourceEphemeral ||
stored[0].Comment != "evidence comment" || len(stored[0].Items) != 1 ||
!strings.Contains(string(stored[0].Items[0].Evidence), `"Message":"abuse"`) {
t.Fatalf("reports=%+v", stored)
}
if ephemeral.calls != 4 {

View file

@ -382,6 +382,9 @@ func callProtocolFlagsInvalidErr() error {
func userIsBlockedErr() error { return tgerr.New(400, "USER_IS_BLOCKED") }
func userPrivacyRestrictedErr() error { return tgerr.New(403, "USER_PRIVACY_RESTRICTED") }
func chatSendVoicesForbiddenErr() error {
return tgerr.New(403, "CHAT_SEND_VOICES_FORBIDDEN")
}
// signalingDataInvalidErr 表示 phone.sendSignalingData 载荷超限或非法。
func signalingDataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }

View file

@ -18,7 +18,7 @@ const (
maxReactionVector = 16
maxReactionListOffset = 128
maxReportOptionLength = 32
maxReportCommentLength = 1024
maxReportCommentLength = domain.MaxModerationCommentRunes
maxReportRandomIDLength = 128
maxReadMetrics = 100
maxBusinessConnIDLength = 128

View file

@ -14,24 +14,52 @@ func reportResultForOption(option string) (tg.ReportResultClass, error) {
return &tg.ReportResultChooseOption{
Title: "Report",
Options: []tg.MessageReportOption{
{Text: "Spam", Option: []byte("spam")},
{Text: "Scam or spam", Option: []byte("spam")},
{Text: "Violence", Option: []byte("violence")},
{Text: "Illegal goods", Option: []byte("illegal_goods")},
{Text: "Pornography", Option: []byte("pornography")},
{Text: "Child abuse", Option: []byte("child_abuse")},
{Text: "Personal data", Option: []byte("personal_data")},
{Text: "Illegal drugs", Option: []byte("illegal_drugs")},
{Text: "Personal details", Option: []byte("personal_details")},
{Text: "Copyright", Option: []byte("copyright")},
{Text: "Fake or impersonation", Option: []byte("fake")},
{Text: "Other", Option: []byte("other")},
},
}, nil
case "other":
return &tg.ReportResultAddComment{Optional: false, Option: []byte("other:comment")}, nil
case "spam", "violence", "illegal_goods", "child_abuse", "personal_data", "copyright", "other:comment":
case "spam", "violence", "pornography", "child_abuse", "illegal_drugs",
"personal_details", "copyright", "fake", "other:comment":
return &tg.ReportResultReported{}, nil
default:
return nil, tgerr.New(400, "OPTION_INVALID")
}
}
func moderationReasonForReportOption(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:comment":
return domain.ModerationReasonOther, true
default:
return "", false
}
}
func (r *Router) inputPeerForDomainPeer(ctx context.Context, currentUserID int64, peer domain.Peer) tg.InputPeerClass {
switch peer.Type {
case domain.PeerTypeUser:

View file

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

View file

@ -0,0 +1,71 @@
package rpc
import (
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func TestModerationFlagsProjectToSelfUserAndWire(t *testing.T) {
for _, test := range []struct {
name string
user domain.User
scam bool
fake bool
}{
{name: "scam", user: domain.User{ID: 1, Scam: true}, scam: true},
{name: "fake", user: domain.User{ID: 2, Fake: true}, fake: true},
} {
t.Run(test.name, func(t *testing.T) {
projected := tgSelfUser(test.user)
if projected.Scam != test.scam || projected.Fake != test.fake ||
!projected.Self {
t.Fatalf("projected self=%+v", projected)
}
var wire bin.Buffer
if err := projected.Encode(&wire); err != nil {
t.Fatal(err)
}
var decoded tg.User
input := bin.Buffer{Buf: append([]byte(nil), wire.Buf...)}
if err := decoded.Decode(&input); err != nil {
t.Fatal(err)
}
if decoded.Scam != test.scam || decoded.Fake != test.fake ||
!decoded.Self {
t.Fatalf("decoded self=%+v", decoded)
}
})
}
}
func TestModerationFlagsProjectToChannelWithoutMutatingAbout(t *testing.T) {
channel := domain.Channel{
ID: 10, AccessHash: 20, CreatorUserID: 1,
Title: "Reported channel", About: "Owner description",
Broadcast: true, Scam: true,
}
projected := tgChannel(2, channel, nil)
if !projected.Scam || projected.Fake {
t.Fatalf("projected channel=%+v", projected)
}
var wire bin.Buffer
if err := projected.Encode(&wire); err != nil {
t.Fatal(err)
}
var decoded tg.Channel
input := bin.Buffer{Buf: append([]byte(nil), wire.Buf...)}
if err := decoded.Decode(&input); err != nil {
t.Fatal(err)
}
if !decoded.Scam || decoded.Fake {
t.Fatalf("decoded channel=%+v", decoded)
}
full := tgChannelFull(domain.ChannelView{Channel: channel})
if full.About != channel.About {
t.Fatalf("about=%q, want unmodified %q", full.About, channel.About)
}
}

View file

@ -38,10 +38,15 @@ func (r *Router) sendStarGiftTransferForm(ctx context.Context, userID, formID in
return nil, starsErr(err)
}
}
recipientUnsaved, err := r.starGiftRecipientUnsaved(ctx, userID, to)
if err != nil {
return nil, err
}
result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID,
Ref: domain.SavedStarGiftRef{Owner: target.Owner, MsgID: target.MsgID, SavedID: target.SavedID}, To: to,
ChargeStars: target.TransferStars, FormID: formID, CommandKey: fmt.Sprintf("paid-transfer:%d:%d", target.ID, formID),
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
Date: int(r.clock.Now().Unix()), RecipientUnsaved: recipientUnsaved,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
if err != nil {
return nil, starGiftLifecycleErr(err)
}
@ -105,9 +110,14 @@ func (r *Router) sendStarGiftResaleForm(ctx context.Context, userID, formID int6
return nil, starsErr(err)
}
}
recipientUnsaved, err := r.starGiftRecipientUnsaved(ctx, userID, to)
if err != nil {
return nil, err
}
result, err := r.deps.Gifts.PurchaseResale(ctx, domain.StarGiftResalePurchaseRequest{BuyerUserID: userID,
Slug: gift.Slug, To: to, Amount: amount, FormID: formID, CommandKey: fmt.Sprintf("resale:%d:%d", gift.ID, formID),
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
Date: int(r.clock.Now().Unix()), RecipientUnsaved: recipientUnsaved,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
if err != nil {
return nil, starGiftLifecycleErr(err)
}
@ -566,10 +576,15 @@ func (r *Router) onPaymentsTransferStarGift(ctx context.Context, req *tg.Payment
if err != nil {
return nil, err
}
recipientUnsaved, err := r.starGiftRecipientUnsaved(ctx, userID, to)
if err != nil {
return nil, err
}
now := int(r.clock.Now().Unix())
result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID, Ref: ref, To: to,
CommandKey: fmt.Sprintf("free:%s:%d:%s:%s:%d", ref.Owner.Type, ref.Owner.ID, starGiftRefValue(ref), to.Type, to.ID),
Date: now, OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
Date: now, RecipientUnsaved: recipientUnsaved,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
if err != nil {
return nil, starGiftLifecycleErr(err)
}

View file

@ -230,11 +230,16 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
ChargeStars: gift.Stars + upgradeStars, FormID: req.FormID, CommandKey: fmt.Sprintf("purchase:%d", req.FormID), Date: now,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}
recipientBlocked := false
recipientUnsaved := false
if peer.Type == domain.PeerTypeUser {
recipientBlocked, err = r.peerBlocksUser(ctx, userID, peer.ID)
if err != nil {
return nil, internalErr()
}
recipientUnsaved, err = r.starGiftRecipientUnsaved(ctx, userID, peer)
if err != nil {
return nil, err
}
}
if capability, ok := r.deps.Gifts.(interface{ AtomicPurchaseConfigured() bool }); ok && !capability.AtomicPurchaseConfigured() {
if err := r.deps.Gifts.ValidatePurchaseForm(ctx, purchaseReq); err != nil {
@ -249,6 +254,7 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
return nil, starsErr(err)
}
purchaseReq.RecipientBlocked = recipientBlocked
purchaseReq.RecipientUnsaved = recipientUnsaved
result, err := r.deps.Gifts.Purchase(ctx, purchaseReq)
if err != nil {
return nil, starGiftLifecycleErr(err)
@ -364,6 +370,10 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
}
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SavedStarGiftRef, *tg.Updates, error) {
unsaved, err := r.starGiftRecipientUnsaved(ctx, senderID, domain.Peer{Type: domain.PeerTypeUser, ID: recipientID})
if err != nil {
return domain.SavedStarGiftRef{}, nil, err
}
prepaidUpgradeHash := ""
if prepaidUpgradeStars == 0 && gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal {
var token [32]byte
@ -386,7 +396,7 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
MsgID: send.RecipientMessage.ID,
Date: send.RecipientMessage.Date,
NameHidden: hideName,
Unsaved: false,
Unsaved: unsaved,
ConvertStars: gift.ConvertStars,
PrepaidUpgradeStars: prepaidUpgradeStars,
PrepaidUpgradeHash: prepaidUpgradeHash,

View file

@ -90,6 +90,10 @@ func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64,
grant.SenderID = senderID
grant.Date = int(r.clock.Now().Unix())
grant.RecipientBlocked = recipientBlocked
grant.RecipientUnsaved, err = r.starGiftRecipientUnsaved(ctx, senderID, grant.Recipient)
if err != nil {
return err
}
if _, err := granter.GrantUnique(ctx, grant); err != nil {
return err
}

View file

@ -306,6 +306,17 @@ func (r *Router) userPresenceStatusForUser(u domain.User) domain.UserStatus {
if userID == 0 {
return domain.UserStatus{Kind: domain.UserStatusRecently}
}
// The app projector marks a privacy-hidden exact timestamp with a coarse
// status and clears LastSeenAt. Never overlay the process presence tracker
// after that boundary, or every users/dialogs/history response would undo
// StatusTimestamp privacy.
switch u.Status.Kind {
case domain.UserStatusRecently,
domain.UserStatusLastWeek,
domain.UserStatusLastMonth,
domain.UserStatusEmpty:
return u.Status
}
now := int(r.clock.Now().Unix())
if status, ok := r.presence.statusFor(userID, now); ok {
return status
@ -676,10 +687,15 @@ func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain
// contacts + dialog 对端 ∩ 在线」一次性算出onlineRelevantPeerIDs2 次查询 + 内存过滤),
// 而非遍历全部在线候选逐个 GetPeerDialogs旧 onlinePrivateDialogPeerIDs 的 O(在线数) N+1
// 断连/sweeper 风暴下 O(M×512) 串行 PG。私聊 dialog 双向建行,两种算法得到同一集合。
for _, recipientID := range r.onlineRelevantPeerIDs(ctx, userID) {
recipients := r.onlineRelevantPeerIDs(ctx, userID)
visible := r.statusTimestampVisibleToViewers(ctx, userID, recipients)
for _, recipientID := range recipients {
if recipientID == userID {
continue
}
if !visible[recipientID] {
continue
}
r.pushUserMessageTransient(ctx, recipientID, "push user status", update)
}
}
@ -690,7 +706,11 @@ func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, use
return
}
updates := make([]tg.UpdateClass, 0, len(peerIDs))
visible := r.statusTimestampVisibleToViewer(ctx, peerIDs, userID)
for _, peerID := range peerIDs {
if !visible[peerID] {
continue
}
status := r.userPresenceStatus(peerID)
if status.Kind != domain.UserStatusOnline {
continue
@ -710,6 +730,99 @@ func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, use
})
}
type batchPrivacyEvaluator interface {
CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64, keys []domain.PrivacyKey) (map[int64]map[domain.PrivacyKey]bool, error)
}
type matrixPrivacyEvaluator interface {
CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs []int64, keys []domain.PrivacyKey) (map[int64]map[int64]map[domain.PrivacyKey]bool, error)
}
func (r *Router) statusTimestampVisibleToViewer(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) map[int64]bool {
out := make(map[int64]bool, len(ownerUserIDs))
if r.deps.Privacy == nil {
for _, ownerID := range ownerUserIDs {
out[ownerID] = true
}
return out
}
if batch, ok := r.deps.Privacy.(batchPrivacyEvaluator); ok {
visibility, err := batch.CanSeeBatch(ctx, ownerUserIDs, viewerUserID, []domain.PrivacyKey{domain.PrivacyKeyStatusTimestamp})
if err != nil {
r.log.Warn("evaluate status privacy batch", zap.Int64("viewer_user_id", viewerUserID), zap.Error(err))
return out
}
for _, ownerID := range ownerUserIDs {
out[ownerID] = visibility[ownerID][domain.PrivacyKeyStatusTimestamp]
}
return out
}
for _, ownerID := range ownerUserIDs {
allowed, err := r.deps.Privacy.CanSee(ctx, ownerID, viewerUserID, domain.PrivacyKeyStatusTimestamp)
if err == nil {
out[ownerID] = allowed
}
}
return out
}
func (r *Router) statusTimestampVisibleToViewers(ctx context.Context, ownerUserID int64, viewerUserIDs []int64) map[int64]bool {
out := make(map[int64]bool, len(viewerUserIDs))
if r.deps.Privacy == nil {
for _, viewerID := range viewerUserIDs {
out[viewerID] = true
}
return out
}
if matrix, ok := r.deps.Privacy.(matrixPrivacyEvaluator); ok {
visibility, err := matrix.CanSeeMatrix(ctx, []int64{ownerUserID}, viewerUserIDs, []domain.PrivacyKey{domain.PrivacyKeyStatusTimestamp})
if err != nil {
r.log.Warn("evaluate status privacy matrix", zap.Int64("owner_user_id", ownerUserID), zap.Error(err))
return out
}
for _, viewerID := range viewerUserIDs {
out[viewerID] = visibility[ownerUserID][viewerID][domain.PrivacyKeyStatusTimestamp]
}
return out
}
for _, viewerID := range viewerUserIDs {
allowed, err := r.deps.Privacy.CanSee(ctx, ownerUserID, viewerID, domain.PrivacyKeyStatusTimestamp)
if err == nil {
out[viewerID] = allowed
}
}
return out
}
// pushStatusPrivacyRefresh immediately replaces stale exact statuses on other
// online accounts after StatusTimestamp changes. Allowed viewers receive the
// live value; denied viewers receive only a coarse bucket.
func (r *Router) pushStatusPrivacyRefresh(ctx context.Context, ownerUserID int64) {
recipients := r.onlineRelevantPeerIDs(ctx, ownerUserID)
visible := r.statusTimestampVisibleToViewers(ctx, ownerUserID, recipients)
exact := r.userPresenceStatus(ownerUserID)
if r.deps.Users != nil {
if owner, found, err := r.deps.Users.ByID(ctx, ownerUserID, ownerUserID); err == nil && found {
exact = r.userPresenceStatusForUser(owner)
}
}
coarse := domain.ApproximateUserStatus(exact.WasOnline, int(r.clock.Now().Unix()))
for _, recipientID := range recipients {
if recipientID == 0 || recipientID == ownerUserID {
continue
}
status := coarse
if visible[recipientID] {
status = exact
}
r.pushUserMessageTransient(ctx, recipientID, "push status privacy refresh", &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUserStatus{UserID: ownerUserID, Status: tgUserStatus(status)}},
Date: int(r.clock.Now().Unix()),
Seq: 0,
})
}
}
// presenceCandidateCacheTTL 是 presence fan-out 候选集(联系人 私聊对端)的缓存有效期;
// lastSeenPersistDebounce 是在线续期写 last_seen 的去抖窗口。
const (

View file

@ -0,0 +1,28 @@
package rpc
import (
"context"
"telesrv/internal/domain"
)
// starGiftRecipientUnsaved evaluates privacyKeyStarGiftsAutoSave at the
// ownership-write boundary. The rule does not reject the gift: it decides
// whether an incoming user gift is displayed immediately (unsaved=false) or
// waits for the recipient's approval (unsaved=true).
func (r *Router) starGiftRecipientUnsaved(ctx context.Context, senderUserID int64, recipient domain.Peer) (bool, error) {
if recipient.Type != domain.PeerTypeUser || recipient.ID == 0 ||
senderUserID == 0 || senderUserID == recipient.ID || r.deps.Privacy == nil {
return false, nil
}
allowed, err := r.deps.Privacy.CanSee(
ctx,
recipient.ID,
senderUserID,
domain.PrivacyKeyStarGiftsAutoSave,
)
if err != nil {
return false, internalErr()
}
return !allowed, nil
}

View file

@ -0,0 +1,59 @@
package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
// filterChatInvitePrivacy evaluates the whole target vector through the
// privacy read model before any membership write. The returned slices preserve
// request order.
func (r *Router) filterChatInvitePrivacy(ctx context.Context, inviterUserID int64, targetUserIDs []int64) ([]int64, []tg.MissingInvitee, error) {
if len(targetUserIDs) == 0 {
return nil, nil, nil
}
if r.deps.Privacy == nil {
return append([]int64(nil), targetUserIDs...), nil, nil
}
visible := make(map[int64]bool, len(targetUserIDs))
if batch, ok := r.deps.Privacy.(batchPrivacyEvaluator); ok {
matrix, err := batch.CanSeeBatch(ctx, targetUserIDs, inviterUserID, []domain.PrivacyKey{domain.PrivacyKeyChatInvite})
if err != nil {
return nil, nil, err
}
for _, targetUserID := range targetUserIDs {
visible[targetUserID] = matrix[targetUserID][domain.PrivacyKeyChatInvite]
}
} else {
for _, targetUserID := range targetUserIDs {
allowed, err := r.deps.Privacy.CanSee(ctx, targetUserID, inviterUserID, domain.PrivacyKeyChatInvite)
if err != nil {
return nil, nil, err
}
visible[targetUserID] = allowed
}
}
allowed := make([]int64, 0, len(targetUserIDs))
missing := make([]tg.MissingInvitee, 0)
for _, targetUserID := range targetUserIDs {
if visible[targetUserID] {
allowed = append(allowed, targetUserID)
continue
}
missing = append(missing, tg.MissingInvitee{UserID: targetUserID})
}
return allowed, missing, nil
}
func emptyInvitedUsersUpdates(date int) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: date,
Seq: 0,
}
}

View file

@ -0,0 +1,83 @@
package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) ensureVoiceMessagesAllowed(ctx context.Context, senderUserID int64, peer domain.Peer, voiceOrRound bool) error {
if !voiceOrRound || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == senderUserID || r.deps.Privacy == nil {
return nil
}
allowed, err := r.deps.Privacy.CanSee(ctx, peer.ID, senderUserID, domain.PrivacyKeyVoiceMessages)
if err != nil {
return internalErr()
}
if !allowed {
return chatSendVoicesForbiddenErr()
}
return nil
}
// preflightVoiceOrRound inspects upload attributes and referenced documents
// before resolveInputMedia materializes any uploaded blob. Referenced document
// misses are loaded in one bounded read-model batch.
func (r *Router) preflightVoiceOrRound(ctx context.Context, inputs []tg.InputMediaClass) (bool, error) {
documentIDs := make([]int64, 0)
seen := make(map[int64]struct{})
for _, input := range inputs {
switch media := input.(type) {
case *tg.InputMediaUploadedDocument:
if documentAttributesVoiceOrRound(domainDocumentAttributes(media.Attributes)) {
return true, nil
}
case *tg.InputMediaDocument:
ids, ok := inputDocumentCandidateIDs(media.ID)
if !ok {
continue // resolveInputMedia retains MEDIA_INVALID precedence.
}
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
documentIDs = append(documentIDs, id)
}
}
}
if len(documentIDs) == 0 || r.deps.Files == nil {
return false, nil
}
documents, err := r.deps.Files.GetDocuments(ctx, documentIDs)
if err != nil {
return false, internalErr()
}
for _, document := range documents {
if documentAttributesVoiceOrRound(document.Attributes) {
return true, nil
}
}
return false, nil
}
func documentAttributesVoiceOrRound(attributes []domain.DocumentAttribute) bool {
for _, attribute := range attributes {
switch attribute.Kind {
case domain.DocAttrAudio:
if attribute.Voice {
return true
}
case domain.DocAttrVideo:
if attribute.RoundMessage {
return true
}
}
}
return false
}

View file

@ -127,6 +127,9 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
if r.deps.Messages == nil {
return nil, false, peerIDInvalidErr()
}
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, p.media != nil && p.media.HasUnreadPayload()); err != nil {
return nil, false, err
}
if r.deps.Users != nil && peer.ID != userID {
if _, found, err := r.deps.Users.ByID(ctx, userID, peer.ID); err != nil {
return nil, false, internalErr()
@ -375,6 +378,13 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
if err != nil {
return nil, err
}
voiceOrRound, err := r.preflightVoiceOrRound(ctx, []tg.InputMediaClass{req.Media})
if err != nil {
return nil, err
}
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, voiceOrRound); err != nil {
return nil, err
}
media, err := r.resolveInputMedia(ctx, userID, req.Media)
if err != nil {
return nil, err
@ -501,6 +511,19 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
if err != nil {
return nil, err
}
pendingMedia := make([]tg.InputMediaClass, 0, absentCount)
for i, item := range req.MultiMedia {
if !replays[i].found {
pendingMedia = append(pendingMedia, item.Media)
}
}
voiceOrRound, err := r.preflightVoiceOrRound(ctx, pendingMedia)
if err != nil {
return nil, err
}
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, voiceOrRound); err != nil {
return nil, err
}
// 必须在 resolveInputMedia 或发送任何 item 之前原子预留:首次请求若在第 N 条
// 失败,客户端只重试失败子集时仍从已绑定 random_id 恢复整包 grouped_id。

View file

@ -11,6 +11,7 @@ import (
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/compat/tdesktop"
@ -591,17 +592,33 @@ func (r *Router) onStoriesReport(ctx context.Context, req *tg.StoriesReportReque
if err != nil {
return nil, err
}
if r.deps.Stories != nil && userID != 0 {
ids := uniqueStoryIDs(req.ID)
list, err := r.deps.Stories.GetStoriesByID(ctx, userID, peer, ids, int(r.clock.Now().Unix()))
if err != nil {
return nil, storyErr(err)
}
if len(list.Stories) != len(ids) {
result, err := reportResultForOption(string(req.Option))
if err != nil {
return nil, err
}
if _, final := result.(*tg.ReportResultReported); !final {
// Option discovery is non-mutating, but still follows the peer/access
// validation above so malformed targets cannot bypass normal errors.
return result, nil
}
reason, ok := moderationReasonForReportOption(string(req.Option))
if !ok {
return nil, tgerr.New(400, "OPTION_INVALID")
}
if r.deps.Moderation == nil {
return nil, internalErr()
}
if _, _, err := r.deps.Moderation.ReportStories(ctx, domain.ModerationStoryReportRequest{
ReporterUserID: userID, Target: peer, StoryIDs: uniqueStoryIDs(req.ID),
Reason: reason, Option: string(req.Option), Comment: req.Message,
CreatedAt: r.clock.Now(),
}); err != nil {
if errors.Is(err, domain.ErrModerationEvidenceNotFound) {
return nil, storyIDInvalidErr()
}
return nil, moderationReportError(err)
}
return reportResultForOption(string(req.Option))
return result, nil
}
func validateStoriesReportRequest(req *tg.StoriesReportRequest) error {

View file

@ -14,6 +14,7 @@ import (
appchannels "telesrv/internal/app/channels"
appcontacts "telesrv/internal/app/contacts"
appmoderation "telesrv/internal/app/moderation"
appprivacy "telesrv/internal/app/privacy"
appstories "telesrv/internal/app/stories"
appupdates "telesrv/internal/app/updates"
@ -5866,6 +5867,7 @@ func TestStoriesGetStoriesArchiveReturnsOwnerExpiredStories(t *testing.T) {
func TestStoriesLongtailCompatHandlers(t *testing.T) {
ctx := context.Background()
storyStore := memory.NewStoryStore()
reportStore := memory.NewModerationReportStore()
ownerID := int64(9311)
owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
@ -5877,8 +5879,10 @@ func TestStoriesLongtailCompatHandlers(t *testing.T) {
}}); err != nil {
t.Fatalf("upsert story: %v", err)
}
storyService := appstories.NewService(storyStore)
r := New(Config{}, Deps{
Stories: appstories.NewService(storyStore),
Stories: storyService,
Moderation: appmoderation.NewService(reportStore, appmoderation.WithStoryReader(storyService)),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
reqCtx := WithUserID(ctx, ownerID)
@ -6034,6 +6038,9 @@ func TestStoriesLongtailCompatHandlers(t *testing.T) {
if _, ok := reported.(*tg.ReportResultReported); !ok {
t.Fatalf("report spam = %T %+v, want reported", reported, reported)
}
if got := reportStore.Reports(); len(got) != 1 || got[0].Source != domain.ModerationSourceStory {
t.Fatalf("story moderation reports = %+v, want one persisted story report", got)
}
if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{
Peer: &tg.InputPeerSelf{},
ID: []int{99},

View file

@ -247,11 +247,6 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
about = ""
}
}
// Surface the scam/fake warning to other viewers (never to the account
// itself), non-destructively over the projected About.
if u.ID != currentUserID {
about = aboutWithModerationWarning(about, defaultScamWarningUser, defaultFakeWarningUser, u.Scam, u.Fake)
}
full := tg.UserFull{
ID: u.ID,
About: about,
@ -261,7 +256,7 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
// 通话入口:客户端不见 phone_calls_available=true 不显示通话按钮P1 前置项)。
// phone_calls_private 标记对端禁 P2Pp2p_allowed 真值在通话确认时另行计算)。
if !u.Bot && u.ID != currentUserID {
callsAllowed, p2pAllowed := true, true
callsAllowed, p2pAllowed, voiceAllowed := true, true, true
if r.deps.Privacy != nil {
allowed, err := r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyPhoneCall)
if err != nil {
@ -273,10 +268,16 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
return tg.UserFull{}, internalErr()
}
p2pAllowed = allowed
allowed, err = r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyVoiceMessages)
if err != nil {
return tg.UserFull{}, internalErr()
}
voiceAllowed = allowed
}
full.PhoneCallsAvailable = callsAllowed
full.VideoCallsAvailable = callsAllowed
full.PhoneCallsPrivate = !p2pAllowed
full.VoiceMessagesForbidden = !voiceAllowed
}
if u.Bot {
full.SetBotInfo(r.tgBotInfo(ctx, u))
@ -446,13 +447,97 @@ func (r *Router) onUsersGetRequirementsToContact(ctx context.Context, ids []tg.I
if len(ids) > maxRequirementsToContactUsers {
return nil, limitInvalidErr()
}
out := make([]tg.RequirementToContactClass, 0, len(ids))
for range ids {
out = append(out, &tg.RequirementToContactEmpty{})
currentUserID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !authorized || r.deps.Users == nil {
out := make([]tg.RequirementToContactClass, len(ids))
for i := range out {
out[i] = &tg.RequirementToContactEmpty{}
}
return out, nil
}
type requirementInput struct {
userID int64
accessHash int64
self bool
}
inputs := make([]requirementInput, len(ids))
targetIDs := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for i, input := range ids {
switch value := input.(type) {
case *tg.InputUserSelf:
inputs[i] = requirementInput{userID: currentUserID, self: true}
case *tg.InputUser:
if value == nil || value.UserID == 0 {
continue
}
inputs[i] = requirementInput{userID: value.UserID, accessHash: value.AccessHash}
if _, ok := seen[value.UserID]; !ok {
seen[value.UserID] = struct{}{}
targetIDs = append(targetIDs, value.UserID)
}
}
}
usersByID := make(map[int64]domain.User, len(targetIDs))
if len(targetIDs) > 0 {
list, err := r.deps.Users.ByIDs(ctx, currentUserID, targetIDs)
if err != nil && !errors.Is(err, users.ErrNotAuthorized) {
return nil, internalErr()
}
for _, user := range list {
usersByID[user.ID] = user
}
}
validTargetIDs := make([]int64, 0, len(targetIDs))
for i, input := range inputs {
if input.self {
continue
}
user, ok := usersByID[input.userID]
if !ok || input.accessHash != 0 && input.accessHash != user.AccessHash {
inputs[i].userID = 0
continue
}
validTargetIDs = append(validTargetIDs, user.ID)
}
settingsByUser := make(map[int64]domain.AccountSettings, len(validTargetIDs))
if svc, ok := r.accountSettingsSvc(); ok {
settingsByUser, err = r.accountSettings.getOrLoadBatch(ctx, validTargetIDs, svc)
if err != nil {
return nil, internalErr()
}
}
freeByUser := make(map[int64]bool, len(validTargetIDs))
if evaluator, ok := r.deps.Privacy.(contactRequirementPrivacyEvaluator); ok {
freeByUser, err = evaluator.CanContactForFreeBatch(ctx, validTargetIDs, currentUserID)
if err != nil {
return nil, internalErr()
}
}
out := make([]tg.RequirementToContactClass, len(inputs))
for i, input := range inputs {
out[i] = &tg.RequirementToContactEmpty{}
if input.userID == 0 || input.self || input.userID == currentUserID || freeByUser[input.userID] {
continue
}
global := settingsByUser[input.userID].GlobalPrivacy
switch {
case global.NoncontactPeersPaidStars > 0:
out[i] = &tg.RequirementToContactPaidMessages{StarsAmount: global.NoncontactPeersPaidStars}
case global.NewNoncontactPeersRequirePremium:
out[i] = &tg.RequirementToContactPremium{}
}
}
return out, nil
}
type contactRequirementPrivacyEvaluator interface {
CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) (map[int64]bool, error)
}
func (r *Router) onUsersGetSavedMusic(ctx context.Context, req *tg.UsersGetSavedMusicRequest) (tg.UsersSavedMusicClass, error) {
if req == nil || req.Offset < 0 || req.Limit < 0 || req.Limit > maxSavedMusicLimit {
return nil, limitInvalidErr()

View file

@ -26,6 +26,13 @@ type AccountSettingsStore interface {
SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error
}
// AccountSettingsBatchStore is the cold-load boundary for the per-user account
// settings read model. Production stores implement it so a 100-user
// users.getRequirementsToContact request never becomes 100 SQL queries.
type AccountSettingsBatchStore interface {
GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error)
}
// NotifySettingsStore persists per-scope notification settings (specific peer /
// forum topic / the three category defaults: users, chats, broadcasts).
type NotifySettingsStore interface {

View file

@ -0,0 +1,13 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
type AuthDeliveryReportStore interface {
CreateAuthDeliveryReport(ctx context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error)
DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error)
}

View file

@ -0,0 +1,13 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
type ClientTelemetryStore interface {
CreateClientTelemetry(ctx context.Context, event domain.ClientTelemetryEvent) (domain.ClientTelemetryEvent, bool, error)
DeleteExpiredClientTelemetry(ctx context.Context, olderThan time.Time, limit int) (int, error)
}

View file

@ -0,0 +1,83 @@
package memory
import (
"context"
"sync"
"time"
"telesrv/internal/domain"
)
type AuthDeliveryReportStore struct {
mu sync.Mutex
nextID int64
byFingerprint map[[32]byte]domain.AuthDeliveryReport
}
func NewAuthDeliveryReportStore() *AuthDeliveryReportStore {
return &AuthDeliveryReportStore{
nextID: 1, byFingerprint: make(map[[32]byte]domain.AuthDeliveryReport),
}
}
func (s *AuthDeliveryReportStore) CreateAuthDeliveryReport(_ context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error) {
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.byFingerprint[report.Fingerprint]; ok {
return existing, false, nil
}
var hourly, phoneDaily int
hourAgo := report.CreatedAt.Add(-time.Hour)
dayAgo := report.CreatedAt.Add(-24 * time.Hour)
for _, existing := range s.byFingerprint {
if existing.CreatedAt.After(report.CreatedAt) {
continue
}
if existing.AuthKeyID == report.AuthKeyID && !existing.CreatedAt.Before(hourAgo) {
hourly++
}
if existing.PhoneHash == report.PhoneHash && !existing.CreatedAt.Before(dayAgo) {
phoneDaily++
}
}
if hourly >= domain.MaxAuthDeliveryReportsPerHour ||
phoneDaily >= domain.MaxAuthDeliveryReportsPerPhoneDay {
return domain.AuthDeliveryReport{}, false, domain.ErrAuthDeliveryRateLimited
}
report.ID = s.nextID
s.nextID++
s.byFingerprint[report.Fingerprint] = report
return report, true, nil
}
func (s *AuthDeliveryReportStore) Reports() []domain.AuthDeliveryReport {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.AuthDeliveryReport, 0, len(s.byFingerprint))
for _, report := range s.byFingerprint {
out = append(out, report)
}
return out
}
func (s *AuthDeliveryReportStore) DeleteExpiredAuthDeliveryReports(_ context.Context, olderThan time.Time, limit int) (int, error) {
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrAuthDeliveryReportInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
deleted := 0
for fingerprint, report := range s.byFingerprint {
if deleted >= limit {
break
}
if report.CreatedAt.Before(olderThan) {
delete(s.byFingerprint, fingerprint)
deleted++
}
}
return deleted, nil
}

View file

@ -479,6 +479,39 @@ func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain
}, nil
}
func (s *ChannelStore) FindChannelMessageReaction(_ context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID == 0 {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
s.mu.RLock()
defer s.mu.RUnlock()
channel, member, err := s.channelAndMemberLocked(req.ViewerUserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
message, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
if !ok || message.Deleted || message.ID <= member.AvailableMinID {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid
}
rows := cloneChannelPeerReactions(s.reactions[req.ChannelID][req.MessageID][req.ReactorUserID])
if len(rows) == 0 {
return domain.ChannelMessageReactionLookup{
Channel: cloneChannel(channel), Message: cloneChannelMessage(message),
}, false, nil
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].ChosenOrder != rows[j].ChosenOrder {
return rows[i].ChosenOrder < rows[j].ChosenOrder
}
return messageReactionKey(rows[i].Reaction) < messageReactionKey(rows[j].Reaction)
})
return domain.ChannelMessageReactionLookup{
Channel: cloneChannel(channel), Message: cloneChannelMessage(message),
Reactions: rows,
}, true, nil
}
func (s *ChannelStore) RecordMessageReactionUse(_ context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error {
if userID == 0 || len(reactions) == 0 {
return nil

View file

@ -0,0 +1,99 @@
package memory
import (
"context"
"sort"
"sync"
"time"
"telesrv/internal/domain"
)
type ClientTelemetryStore struct {
mu sync.Mutex
nextID int64
byID map[int64]domain.ClientTelemetryEvent
byFingerprint map[[32]byte]int64
}
func NewClientTelemetryStore() *ClientTelemetryStore {
return &ClientTelemetryStore{
nextID: 1, byID: make(map[int64]domain.ClientTelemetryEvent),
byFingerprint: make(map[[32]byte]int64),
}
}
func (s *ClientTelemetryStore) CreateClientTelemetry(_ context.Context, event domain.ClientTelemetryEvent) (domain.ClientTelemetryEvent, bool, error) {
if err := event.Validate(); err != nil || event.ID != 0 {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if id, ok := s.byFingerprint[event.Fingerprint]; ok {
return cloneClientTelemetry(s.byID[id]), false, nil
}
var hourly, daily int
for _, existing := range s.byID {
if existing.UserID != event.UserID ||
existing.CreatedAt.After(event.CreatedAt) {
continue
}
if !existing.CreatedAt.Before(event.CreatedAt.Add(-24 * time.Hour)) {
daily++
}
if !existing.CreatedAt.Before(event.CreatedAt.Add(-time.Hour)) {
hourly++
}
}
if hourly >= domain.MaxClientTelemetryEventsPerHour ||
daily >= domain.MaxClientTelemetryEventsPerDay {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryRateLimited
}
event.ID = s.nextID
s.nextID++
event = cloneClientTelemetry(event)
s.byID[event.ID] = event
s.byFingerprint[event.Fingerprint] = event.ID
return cloneClientTelemetry(event), true, nil
}
func (s *ClientTelemetryStore) DeleteExpiredClientTelemetry(_ context.Context, olderThan time.Time, limit int) (int, error) {
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrClientTelemetryInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
ids := make([]int64, 0)
for id, event := range s.byID {
if event.CreatedAt.Before(olderThan) {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
if len(ids) > limit {
ids = ids[:limit]
}
for _, id := range ids {
event := s.byID[id]
delete(s.byFingerprint, event.Fingerprint)
delete(s.byID, id)
}
return len(ids), nil
}
func (s *ClientTelemetryStore) Events() []domain.ClientTelemetryEvent {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.ClientTelemetryEvent, 0, len(s.byID))
for _, event := range s.byID {
out = append(out, cloneClientTelemetry(event))
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func cloneClientTelemetry(event domain.ClientTelemetryEvent) domain.ClientTelemetryEvent {
event.SubjectIDs = append([]int64(nil), event.SubjectIDs...)
event.Payload = append([]byte(nil), event.Payload...)
return event
}

View file

@ -0,0 +1,62 @@
package memory
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
func TestClientTelemetryStoreIdempotencyRateLimitAndRetention(t *testing.T) {
ctx := context.Background()
store := NewClientTelemetryStore()
now := time.Unix(1_750_000_000, 0).UTC()
newEvent := func(subject int64, at time.Time) domain.ClientTelemetryEvent {
event, err := domain.NewClientTelemetryEvent(
71, domain.ClientTelemetryMessageDelivery,
domain.Peer{Type: domain.PeerTypeUser, ID: 72},
[]int64{subject}, map[string]any{"push": true}, at,
)
if err != nil {
t.Fatal(err)
}
return event
}
first := newEvent(1, now)
stored, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || !created || stored.ID <= 0 {
t.Fatalf("first=%+v created=%v err=%v", stored, created, err)
}
retry, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || created || retry.ID != stored.ID {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
for i := 1; i < domain.MaxClientTelemetryEventsPerHour; i++ {
if _, created, err := store.CreateClientTelemetry(
ctx, newEvent(int64(i+1), now),
); err != nil || !created {
t.Fatalf("create %d created=%v err=%v", i, created, err)
}
}
if got, created, err := store.CreateClientTelemetry(ctx, first); err != nil ||
created || got.ID != stored.ID {
t.Fatalf("retry at limit got=%+v created=%v err=%v", got, created, err)
}
if _, _, err := store.CreateClientTelemetry(
ctx, newEvent(domain.MaxClientTelemetryEventsPerHour+1, now),
); !errors.Is(err, domain.ErrClientTelemetryRateLimited) {
t.Fatalf("overflow err=%v", err)
}
deleted, err := store.DeleteExpiredClientTelemetry(
ctx, now.Add(time.Second), domain.MaxClientTelemetryEventsPerHour+1,
)
if err != nil || deleted != domain.MaxClientTelemetryEventsPerHour {
t.Fatalf("deleted=%d err=%v", deleted, err)
}
recreated, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || !created || recreated.ID == stored.ID {
t.Fatalf("recreated=%+v created=%v err=%v", recreated, created, err)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,221 @@
package memory
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestModerationCaseLifecycleAndNewReportsDuringAction(t *testing.T) {
ctx := context.Background()
now := time.Now().UTC()
target := domain.Peer{Type: domain.PeerTypeUser, ID: 900}
store := NewModerationReportStore()
create := func(reporter int64, option string, at time.Time) domain.ModerationReport {
report, err := domain.NewModerationReport(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,
})
if err != nil {
t.Fatal(err)
}
stored, created, err := store.CreateModerationReport(ctx, report)
if err != nil || !created {
t.Fatalf("create report created=%v err=%v", created, err)
}
return stored
}
create(101, "fake", now)
create(102, "fake:impersonation", now.Add(time.Second))
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
item := cases[0]
if item.ReportCount != 2 || item.DistinctReporterCount != 2 ||
item.Version != 2 || item.Severity != domain.ModerationSeverityMedium {
t.Fatalf("case aggregate=%+v", item)
}
claimed, err := store.ClaimModerationCase(ctx, item.ID, item.Version, "reviewer", now.Add(2*time.Second))
if err != nil || claimed.Status != domain.ModerationCaseInReview {
t.Fatalf("claim=%+v err=%v", claimed, err)
}
decision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: item.ID, ExpectedVersion: claimed.Version, Actor: "reviewer",
Reason: "confirmed impersonation", CommandID: "decision-1",
Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(3 * time.Second),
})
if err != nil {
t.Fatal(err)
}
detail, created, err := store.DecideModerationCase(ctx, decision)
if err != nil || !created ||
detail.Case.Status != domain.ModerationCaseActionPending ||
len(detail.Actions) != 1 {
t.Fatalf("decision detail=%+v created=%v err=%v", detail, created, err)
}
if _, created, err := store.DecideModerationCase(ctx, decision); err != nil || created {
t.Fatalf("decision retry created=%v err=%v", created, err)
}
// Once a decision is durable, later reports open a new case instead of
// mutating the evidence set under the pending action.
create(103, "fake:new-evidence", now.Add(4*time.Second))
cases, err = store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 2 {
t.Fatalf("cases after new evidence=%+v err=%v", cases, err)
}
actions, err := store.ClaimModerationActions(ctx, now.Add(5*time.Second), 10, time.Minute)
if err != nil || len(actions) != 1 {
t.Fatalf("claimed actions=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "",
time.Time{}, now.Add(6*time.Second),
); err != nil {
t.Fatal(err)
}
resolved, found, err := store.GetModerationCase(ctx, item.ID)
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved {
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
}
appeal, err := domain.NewModerationAppeal(
item.ID, target.ID, domain.ModerationCaseResolved,
"This is a mistake.", now.Add(7*time.Second),
)
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationAppeal(ctx, appeal); err != nil || !created {
t.Fatalf("appeal created=%v err=%v", created, err)
}
appealed, _, _ := store.GetModerationCase(ctx, item.ID)
if appealed.Case.Status != domain.ModerationCaseAppealReview ||
len(appealed.Appeals) != 1 {
t.Fatalf("appealed detail=%+v", appealed)
}
}
func TestModerationActionFailedCanBeRedrivenByNewDecision(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_750_000_000, 0).UTC()
target := domain.Peer{Type: domain.PeerTypeUser, ID: 902}
store := NewModerationReportStore()
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 901, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationReport(ctx, report); err != nil || !created {
t.Fatalf("create report created=%v err=%v", created, err)
}
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
claimed, err := store.ClaimModerationCase(
ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second),
)
if err != nil {
t.Fatal(err)
}
firstDecision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "reviewer", Reason: "first command kept failing",
CommandID: "redrive-first", Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkScam, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(2 * time.Second),
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.DecideModerationCase(ctx, firstDecision); err != nil || !created {
t.Fatalf("first decision created=%v err=%v", created, err)
}
for attempt := 1; attempt <= domain.MaxModerationActionAttempts; attempt++ {
at := now.Add(time.Duration(attempt+2) * time.Second)
actions, err := store.ClaimModerationActions(ctx, at, 10, time.Second)
if err != nil || len(actions) != 1 {
t.Fatalf("attempt %d actions=%+v err=%v", attempt, actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, false, "downstream unavailable",
at.Add(time.Millisecond), at,
); err != nil {
t.Fatalf("attempt %d: %v", attempt, err)
}
}
failed, found, err := store.GetModerationCase(ctx, claimed.ID)
if err != nil || !found || failed.Case.Status != domain.ModerationCaseActionFailed ||
len(failed.Actions) != 1 ||
failed.Actions[0].Status != domain.ModerationActionFailed {
t.Fatalf("failed=%+v found=%v err=%v", failed, found, err)
}
redrive, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: failed.Case.Version,
Actor: "reviewer", Reason: "redrive after dependency recovery",
CommandID: "redrive-second", Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkScam, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(time.Minute),
})
if err != nil {
t.Fatal(err)
}
pending, created, err := store.DecideModerationCase(ctx, redrive)
if err != nil || !created ||
pending.Case.Status != domain.ModerationCaseActionPending ||
len(pending.Actions) != 2 {
t.Fatalf("pending=%+v created=%v err=%v", pending, created, err)
}
actions, err := store.ClaimModerationActions(ctx, now.Add(2*time.Minute), 10, time.Second)
if err != nil || len(actions) != 1 || actions[0].DecisionID == failed.Actions[0].DecisionID {
t.Fatalf("redrive actions=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "",
time.Time{}, now.Add(2*time.Minute+time.Second),
); err != nil {
t.Fatal(err)
}
resolved, found, err := store.GetModerationCase(ctx, claimed.ID)
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved {
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
}
var failedCount, succeededCount int
for _, action := range resolved.Actions {
switch action.Status {
case domain.ModerationActionFailed:
failedCount++
case domain.ModerationActionSucceeded:
succeededCount++
}
}
if failedCount != 1 || succeededCount != 1 {
t.Fatalf("action history failed=%d succeeded=%d", failedCount, succeededCount)
}
}

View file

@ -0,0 +1,92 @@
package memory
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestModerationReportStoreIdempotencyAndCopyIsolation(t *testing.T) {
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 11, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 22},
Reason: domain.ModerationReasonSpam, Option: "v1/spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 22},
ItemID: 5, AuthorUserID: 22, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"spam"}`),
}},
CreatedAt: time.Now().UTC(),
})
if err != nil {
t.Fatal(err)
}
store := NewModerationReportStore()
first, created, err := store.CreateModerationReport(context.Background(), report)
if err != nil || !created || first.ID <= 0 {
t.Fatalf("first = %+v created=%v err=%v", first, created, err)
}
first.Items[0].Evidence[0] = '['
retry, created, err := store.CreateModerationReport(context.Background(), report)
if err != nil || created || retry.ID != first.ID {
t.Fatalf("retry = %+v created=%v err=%v", retry, created, err)
}
if retry.Items[0].Evidence[0] != '{' {
t.Fatalf("caller mutation changed stored evidence: %s", retry.Items[0].Evidence)
}
}
func TestModerationReportStoreRateLimitDoesNotChargeIdempotentRetry(t *testing.T) {
ctx := context.Background()
store := NewModerationReportStore()
now := time.Unix(1_750_000_000, 0).UTC()
var first domain.ModerationReport
for i := 0; i < domain.MaxModerationReportsPerHour; i++ {
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 71, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
ItemID: int64(i + 1), AuthorUserID: 72,
EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"spam"}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationReport(ctx, report); err != nil || !created {
t.Fatalf("create %d: created=%v err=%v", i, created, err)
}
if i == 0 {
first = report
}
}
if got, created, err := store.CreateModerationReport(ctx, first); err != nil || created || got.ID == 0 {
t.Fatalf("retry after limit: got=%+v created=%v err=%v", got, created, err)
}
overflow, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 71, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
ItemID: 999, AuthorUserID: 72, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"overflow"}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateModerationReport(ctx, overflow); err != domain.ErrModerationRateLimited {
t.Fatalf("overflow err=%v, want ErrModerationRateLimited", err)
}
}

View file

@ -160,6 +160,18 @@ func (s *PasswordStore) GetAccountSettings(_ context.Context, userID int64) (dom
return settings, ok, nil // AccountSettings 全是值类型,无需深拷贝
}
func (s *PasswordStore) GetAccountSettingsBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
out := make(map[int64]domain.AccountSettings, len(userIDs))
s.mu.RLock()
for _, userID := range userIDs {
if settings, ok := s.accountSettings[userID]; ok {
out[userID] = settings
}
}
s.mu.RUnlock()
return out, nil
}
func (s *PasswordStore) SaveAccountSettings(_ context.Context, userID int64, settings domain.AccountSettings) error {
s.mu.Lock()
s.accountSettings[userID] = settings

View file

@ -105,9 +105,20 @@ func cloneUpdateEvent(event domain.UpdateEvent) domain.UpdateEvent {
event.Reaction = cloneUpdateReaction(event.Reaction)
event.QuickReplies = cloneUpdateQuickReplies(event.QuickReplies)
event.QuickReplyMessage = cloneUpdateQuickReplyMessage(event.QuickReplyMessage)
event.Privacy.Rules = clonePrivacyRuleSlice(event.Privacy.Rules)
return event
}
func clonePrivacyRuleSlice(in []domain.PrivacyRule) []domain.PrivacyRule {
out := make([]domain.PrivacyRule, len(in))
for i := range in {
out[i] = in[i]
out[i].UserIDs = append([]int64(nil), in[i].UserIDs...)
out[i].ChatIDs = append([]int64(nil), in[i].ChatIDs...)
}
return out
}
func cloneUpdateStory(story domain.Story) domain.Story {
story.Entities = append([]domain.MessageEntity(nil), story.Entities...)
story.Views.Reactions = append([]domain.ChannelMessageReactionCount(nil), story.Views.Reactions...)

View file

@ -0,0 +1,64 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
// ModerationReportStore atomically persists an immutable report, all evidence
// items and media holds. A retry with the same fingerprint returns the original
// report and created=false; implementations must never create partial items.
type ModerationReportStore interface {
CreateModerationReport(ctx context.Context, report domain.ModerationReport) (stored domain.ModerationReport, created bool, err error)
GetModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
}
type ModerationEvidenceRegistryStore interface {
CreateSponsoredMessageImpression(ctx context.Context, impression domain.SponsoredMessageImpression) (domain.SponsoredMessageImpression, bool, error)
GetSponsoredMessageImpression(ctx context.Context, userID int64, randomIDHash [32]byte, now time.Time) (domain.SponsoredMessageImpression, bool, error)
CreateSponsoredModerationReport(ctx context.Context, impressionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error)
CreateChannelAntiSpamDecision(ctx context.Context, decision domain.ChannelAntiSpamDecision) (domain.ChannelAntiSpamDecision, bool, error)
GetChannelAntiSpamDecision(ctx context.Context, channelID int64, messageID int) (domain.ChannelAntiSpamDecision, bool, error)
CreateAntiSpamFalsePositiveReport(ctx context.Context, decisionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error)
DeleteExpiredSponsoredMessageImpressions(ctx context.Context, olderThan time.Time, limit int) (int, error)
}
// LegacyEphemeralReport is an immutable row produced before ephemeral reports
// joined the unified moderation pipeline.
type LegacyEphemeralReport struct {
ID int64
Report domain.EphemeralAbuseReport
}
// LegacyEphemeralReportReader exposes only still-unmapped rows. It exists for
// the startup migration and must not be injected into RPC handlers.
type LegacyEphemeralReportReader interface {
ListUnmigratedEphemeralReports(ctx context.Context, limit int) ([]LegacyEphemeralReport, error)
}
// LegacyEphemeralReportImporter atomically inserts the unified report and its
// provenance mapping. Historical imports bypass submission rate limits but
// retain normal report validation and fingerprint idempotency.
type LegacyEphemeralReportImporter interface {
ImportLegacyEphemeralReport(ctx context.Context, legacyReportID int64, report domain.ModerationReport) (stored domain.ModerationReport, created bool, err error)
}
type ModerationCaseStore interface {
ListModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
GetModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
ClaimModerationCase(ctx context.Context, caseID, expectedVersion int64, actor string, now time.Time) (domain.ModerationCase, error)
DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
CreateModerationAppeal(ctx context.Context, appeal domain.ModerationAppeal) (domain.ModerationAppeal, bool, error)
GetModerationAppeal(ctx context.Context, appealID int64) (domain.ModerationAppeal, bool, error)
IssueModerationAppealLink(ctx context.Context, link domain.ModerationAppealLink) (domain.ModerationAppealLink, error)
GetModerationAppealLink(ctx context.Context, tokenHash [32]byte, now time.Time) (domain.ModerationAppealLink, bool, error)
SubmitModerationAppealByLink(ctx context.Context, tokenHash [32]byte, text string, now time.Time) (domain.ModerationAppeal, bool, error)
DeleteExpiredModerationAppealLinks(ctx context.Context, olderThan time.Time, limit int) (int, error)
ClaimModerationActions(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.ModerationAction, error)
IsModerationActionCurrent(ctx context.Context, action domain.ModerationAction) (bool, error)
SupersedeModerationAction(ctx context.Context, actionID int64, expectedAttempts int, now time.Time) error
CompleteModerationAction(ctx context.Context, actionID int64, expectedAttempts int, succeeded bool, errorText string, retryAt, now time.Time) error
}

View file

@ -261,6 +261,41 @@ WHERE user_id = $1`, userID)
return settings, true, nil
}
func (s *PasswordStore) GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
out := make(map[int64]domain.AccountSettings, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
FROM account_settings
WHERE user_id = ANY($1::bigint[])`, userIDs)
if err != nil {
return nil, fmt.Errorf("get account settings batch: %w", err)
}
defer rows.Close()
for rows.Next() {
var userID int64
settings := domain.DefaultAccountSettings()
gp := &settings.GlobalPrivacy
if err := rows.Scan(
&userID,
&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
&gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
); err != nil {
return nil, fmt.Errorf("scan account settings batch: %w", err)
}
out[userID] = settings
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account settings batch: %w", err)
}
return out, nil
}
func (s *PasswordStore) SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error {
gp := settings.GlobalPrivacy
if _, err := s.db.Exec(ctx, `

View file

@ -0,0 +1,152 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type AuthDeliveryReportStore struct {
db sqlcgen.DBTX
}
func NewAuthDeliveryReportStore(db sqlcgen.DBTX) *AuthDeliveryReportStore {
return &AuthDeliveryReportStore{db: db}
}
func (s *AuthDeliveryReportStore) CreateAuthDeliveryReport(ctx context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error) {
if s == nil || s.db == nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("auth delivery report store is not configured")
}
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, err
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("auth delivery report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("begin auth delivery report: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('auth-delivery:' || encode($1::bytea, 'hex'), 0)
)`, report.AuthKeyID[:]); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("lock auth delivery reporter: %w", err)
}
existing, found, err := getAuthDeliveryReportByFingerprint(ctx, tx, report.AuthKeyID, report.Fingerprint)
if err != nil {
return domain.AuthDeliveryReport{}, false, err
}
if found {
return existing, false, nil
}
var hourly, phoneDaily int
if err := tx.QueryRow(ctx, `
SELECT
count(*) FILTER (
WHERE auth_key_id = $1 AND created_at >= $3::timestamptz - interval '1 hour'
),
count(*) FILTER (
WHERE phone_hash = $2 AND created_at >= $3::timestamptz - interval '24 hours'
)
FROM auth_delivery_reports
WHERE created_at <= $3::timestamptz
AND (auth_key_id = $1 OR phone_hash = $2)`,
report.AuthKeyID[:], report.PhoneHash[:], report.CreatedAt,
).Scan(&hourly, &phoneDaily); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("count auth delivery reports: %w", err)
}
if hourly >= domain.MaxAuthDeliveryReportsPerHour ||
phoneDaily >= domain.MaxAuthDeliveryReportsPerPhoneDay {
return domain.AuthDeliveryReport{}, false, domain.ErrAuthDeliveryRateLimited
}
err = tx.QueryRow(ctx, `
INSERT INTO auth_delivery_reports (
auth_key_id, session_id, client_type, phone_hash, code_hash,
issued_user_id, delivery_id, channel, mnc, fingerprint, created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING id`,
report.AuthKeyID[:], report.SessionID, report.ClientType,
report.PhoneHash[:], report.CodeHash[:], report.IssuedUserID,
report.DeliveryID, string(report.Channel), report.MNC,
report.Fingerprint[:], report.CreatedAt,
).Scan(&report.ID)
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("insert auth delivery report: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("commit auth delivery report: %w", err)
}
return report, true, nil
}
func getAuthDeliveryReportByFingerprint(ctx context.Context, db sqlcgen.DBTX, authKeyID [8]byte, fingerprint [32]byte) (domain.AuthDeliveryReport, bool, error) {
var report domain.AuthDeliveryReport
var storedAuthKey, phoneHash, codeHash, storedFingerprint []byte
var channel string
err := db.QueryRow(ctx, `
SELECT id, auth_key_id, session_id, client_type, phone_hash, code_hash,
issued_user_id, delivery_id, channel, mnc, fingerprint, created_at
FROM auth_delivery_reports
WHERE auth_key_id = $1 AND fingerprint = $2`,
authKeyID[:], fingerprint[:],
).Scan(
&report.ID, &storedAuthKey, &report.SessionID, &report.ClientType,
&phoneHash, &codeHash, &report.IssuedUserID, &report.DeliveryID,
&channel, &report.MNC, &storedFingerprint, &report.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AuthDeliveryReport{}, false, nil
}
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("get auth delivery report: %w", err)
}
if len(storedAuthKey) != len(report.AuthKeyID) ||
len(phoneHash) != len(report.PhoneHash) ||
len(codeHash) != len(report.CodeHash) ||
len(storedFingerprint) != len(report.Fingerprint) {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("get auth delivery report: invalid hash shape")
}
copy(report.AuthKeyID[:], storedAuthKey)
copy(report.PhoneHash[:], phoneHash)
copy(report.CodeHash[:], codeHash)
copy(report.Fingerprint[:], storedFingerprint)
report.Channel = domain.AuthCodeDeliveryKind(channel)
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("validate auth delivery report: %w", err)
}
return report, true, nil
}
func (s *AuthDeliveryReportStore) DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("auth delivery report store is not configured")
}
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrAuthDeliveryReportInvalid
}
tag, err := s.db.Exec(ctx, `
WITH doomed AS (
SELECT id
FROM auth_delivery_reports
WHERE created_at < $1
ORDER BY created_at, id
LIMIT $2
)
DELETE FROM auth_delivery_reports r
USING doomed d
WHERE r.id = d.id`, olderThan, limit)
if err != nil {
return 0, fmt.Errorf("delete expired auth delivery reports: %w", err)
}
return int(tag.RowsAffected()), nil
}

View file

@ -0,0 +1,53 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestAuthDeliveryReportPostgresIsIdempotentAndRetainedSeparately(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
createdAt := time.Unix(123_456, 0).UTC()
report, err := domain.NewAuthDeliveryReport(
[8]byte{1, 2, 3, 4, 5, 6, 7, byte(time.Now().UnixNano())},
time.Now().UnixNano(), "tdesktop", "15550004444",
"phone-code-hash", 77, "delivery-77",
domain.AuthCodeDeliverySMS, "46000", createdAt,
)
if err != nil {
t.Fatal(err)
}
store := NewAuthDeliveryReportStore(pool)
stored, created, err := store.CreateAuthDeliveryReport(ctx, report)
if err != nil || !created || stored.ID <= 0 {
t.Fatalf("stored=%+v created=%v err=%v", stored, created, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_delivery_reports WHERE id = $1", stored.ID)
})
retry, created, err := store.CreateAuthDeliveryReport(ctx, report)
if err != nil || created || retry.ID != stored.ID ||
retry.PhoneHash != stored.PhoneHash || retry.CodeHash != stored.CodeHash {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
deleted, err := store.DeleteExpiredAuthDeliveryReports(
ctx, createdAt.Add(time.Second), 10,
)
if err != nil || deleted < 1 {
t.Fatalf("deleted=%d err=%v", deleted, err)
}
var moderationRows int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM moderation_reports
WHERE reporter_user_id = $1`, report.IssuedUserID).Scan(&moderationRows); err != nil {
t.Fatal(err)
}
if moderationRows != 0 {
t.Fatalf("auth diagnostic leaked into moderation reports: %d", moderationRows)
}
}

View file

@ -60,6 +60,68 @@ func (s *ChannelStore) DeleteChannelMessages(ctx context.Context, req domain.Del
return domain.DeleteChannelMessagesResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, DiscussionDeletes: cascades}, nil
}
func (s *ChannelStore) ModerationDeleteChannelMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error) {
if channelID <= 0 || len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
if date == 0 {
date = nowUnix()
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("moderation delete channel messages: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("begin moderation delete channel messages: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
channel, err := getChannelByID(ctx, tx, channelID)
if err != nil || channel.Deleted {
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
refs, err := s.discussionRefsForMessages(ctx, tx, channel.ID, ids)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
systemMember := domain.ChannelMember{
ChannelID: channel.ID, UserID: domain.OfficialSystemUserID,
Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive,
}
deleted, event, channel, err := s.deleteChannelMessagesTx(
ctx, tx, channel, systemMember, ids, domain.OfficialSystemUserID, date,
)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
cascades, err := s.cascadeDeleteDiscussionRootsTx(
ctx, tx, refs, deleted, domain.OfficialSystemUserID, date,
)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("commit moderation delete channel messages: %w", err)
}
committed = true
recipients, _ := s.ListActiveChannelMemberIDs(ctx, 0, channel.ID, 0)
for i := range cascades {
cascades[i].Recipients, _ = s.ListActiveChannelMemberIDs(ctx, 0, cascades[i].Channel.ID, 0)
}
return domain.DeleteChannelMessagesResult{
Channel: channel, Event: event, DeletedIDs: deleted,
Recipients: recipients, DiscussionDeletes: cascades,
}, nil
}
// discussionRefsForMessages 取待删消息携带的讨论组转发根引用。
func (s *ChannelStore) discussionRefsForMessages(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) (map[int]domain.ChannelDiscussionRef, error) {
id32, _, err := validUniqueChannelMessageIDs(ids)

View file

@ -612,3 +612,48 @@ LIMIT $`+fmt.Sprint(len(args)), args...)
NextOffset: next,
}, nil
}
func (s *ChannelStore) FindChannelMessageReaction(ctx context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID == 0 {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
channel, member, err := s.getChannelForMember(ctx, s.db, req.ViewerUserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
message, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
if message.Deleted || message.ID <= member.AvailableMinID {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, message_id, reacted_user_id, sender_user_id,
reaction_type, reaction_value, big, unread, chosen_order, reaction_date
FROM channel_message_reactions
WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3
ORDER BY chosen_order, reaction_type, reaction_value
LIMIT $4`,
req.ChannelID, req.MessageID, req.ReactorUserID,
domain.MaxChannelMessageReactionsPerUser)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, fmt.Errorf("find channel message reaction: %w", err)
}
defer rows.Close()
reactions := make([]domain.ChannelMessagePeerReaction, 0, domain.MaxChannelMessageReactionsPerUser)
for rows.Next() {
reaction, err := scanChannelMessagePeerReaction(rows, req.ViewerUserID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
reactions = append(reactions, reaction)
}
if err := rows.Err(); err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
return domain.ChannelMessageReactionLookup{
Channel: channel, Message: message, Reactions: reactions,
}, len(reactions) > 0, nil
}

View file

@ -0,0 +1,149 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type ClientTelemetryStore struct {
db sqlcgen.DBTX
}
func NewClientTelemetryStore(db sqlcgen.DBTX) *ClientTelemetryStore {
return &ClientTelemetryStore{db: db}
}
func (s *ClientTelemetryStore) CreateClientTelemetry(ctx context.Context, event domain.ClientTelemetryEvent) (domain.ClientTelemetryEvent, bool, error) {
if s == nil || s.db == nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("client telemetry store is not configured")
}
if err := event.Validate(); err != nil || event.ID != 0 {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("client telemetry store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("begin client telemetry: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('client-telemetry:' || $1::bigint::text, 0)
)`, event.UserID); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("lock client telemetry user: %w", err)
}
existing, found, err := getClientTelemetryByFingerprint(
ctx, tx, event.UserID, event.Fingerprint,
)
if err != nil {
return domain.ClientTelemetryEvent{}, false, err
}
if found {
return existing, false, nil
}
var hourly, daily int
if err := tx.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '1 hour'),
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '24 hours')
FROM client_telemetry_events
WHERE user_id = $1 AND created_at <= $2::timestamptz`,
event.UserID, event.CreatedAt,
).Scan(&hourly, &daily); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("count client telemetry: %w", err)
}
if hourly >= domain.MaxClientTelemetryEventsPerHour ||
daily >= domain.MaxClientTelemetryEventsPerDay {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryRateLimited
}
if err := tx.QueryRow(ctx, `
INSERT INTO client_telemetry_events (
user_id, kind, peer_type, peer_id, subject_ids, payload,
fingerprint, created_at
) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8)
RETURNING id`,
event.UserID, string(event.Kind), string(event.Peer.Type),
event.Peer.ID, event.SubjectIDs, []byte(event.Payload),
event.Fingerprint[:], event.CreatedAt,
).Scan(&event.ID); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("insert client telemetry: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("commit client telemetry: %w", err)
}
return event, true, nil
}
func (s *ClientTelemetryStore) DeleteExpiredClientTelemetry(ctx context.Context, olderThan time.Time, limit int) (int, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("client telemetry store is not configured")
}
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrClientTelemetryInvalid
}
tag, err := s.db.Exec(ctx, `
WITH doomed AS (
SELECT id
FROM client_telemetry_events
WHERE created_at < $1
ORDER BY created_at, id
LIMIT $2
)
DELETE FROM client_telemetry_events e
USING doomed d
WHERE e.id = d.id`, olderThan, limit)
if err != nil {
return 0, fmt.Errorf("delete expired client telemetry: %w", err)
}
return int(tag.RowsAffected()), nil
}
func getClientTelemetryByFingerprint(ctx context.Context, db sqlcgen.DBTX, userID int64, fingerprint [32]byte) (domain.ClientTelemetryEvent, bool, error) {
var event domain.ClientTelemetryEvent
var kind, peerType string
var payload, storedFingerprint []byte
if err := db.QueryRow(ctx, `
SELECT id, user_id, kind, peer_type, peer_id, subject_ids, payload,
fingerprint, created_at
FROM client_telemetry_events
WHERE user_id = $1 AND fingerprint = $2`,
userID, fingerprint[:],
).Scan(
&event.ID, &event.UserID, &kind, &peerType, &event.Peer.ID,
&event.SubjectIDs, &payload, &storedFingerprint, &event.CreatedAt,
); errors.Is(err, pgx.ErrNoRows) {
return domain.ClientTelemetryEvent{}, false, nil
} else if err != nil {
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("get client telemetry: %w", err)
}
event.Kind = domain.ClientTelemetryKind(kind)
event.Peer.Type = domain.PeerType(peerType)
var canonicalPayload map[string]any
if err := json.Unmarshal(payload, &canonicalPayload); err != nil || canonicalPayload == nil {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
canonicalRaw, marshalErr := json.Marshal(canonicalPayload)
if marshalErr != nil {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
event.Payload = canonicalRaw
if len(storedFingerprint) != len(event.Fingerprint) {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
copy(event.Fingerprint[:], storedFingerprint)
if err := event.Validate(); err != nil {
return domain.ClientTelemetryEvent{}, false, err
}
return event, true, nil
}

Some files were not shown because too many files have changed in this diff Show more