chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

789
internal/admin/service.go Normal file
View file

@ -0,0 +1,789 @@
package admin
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"telesrv/internal/domain"
)
const (
ActionSetSendFrozen = "account.set_send_frozen"
ActionGrantPremium = "account.grant_premium"
ActionSetVerified = "account.set_verified"
ActionSetChannelVerified = "channel.set_verified"
ActionRevokeSessions = "account.revoke_sessions"
ActionDeletePrivateMessages = "messages.delete_private_messages"
ActionDeletePrivateHistory = "messages.delete_private_history"
maxCommandIDLength = 128
maxActorLength = 128
maxReasonLength = 1000
maxHistoryBatches = 100
maxPremiumMonths = 120
)
type CommandRepository interface {
BeginCommand(ctx context.Context, cmd domain.AdminCommand) (domain.AdminCommand, bool, error)
FinishCommand(ctx context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error)
}
type RestrictionStore interface {
GetSendRestriction(ctx context.Context, userID int64) (domain.AccountSendRestriction, bool, error)
SetSendRestriction(ctx context.Context, restriction domain.AccountSendRestriction) (domain.AccountSendRestriction, error)
IsSendFrozen(ctx context.Context, userID int64) (bool, error)
}
type AuthService interface {
ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error)
ResetAuthorization(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
}
type AuthKeyRevoker interface {
RevokeAuthorizationAuthKey(ctx context.Context, authKeyID [8]byte, userID int64) error
}
type UsersService interface {
AdminUser(ctx context.Context, userID int64) (domain.User, bool, error)
GrantPremium(ctx context.Context, userID int64, months int) (domain.User, error)
SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error)
}
type UserNotifier interface {
NotifyUserChanged(ctx context.Context, u domain.User) error
}
type ChannelsService interface {
GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error)
SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error)
}
type ChannelNotifier interface {
NotifyChannelChanged(ctx context.Context, ch domain.Channel) error
}
type MessagesService interface {
GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error)
DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error)
DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error)
}
type Dependencies struct {
Commands CommandRepository
Restrictions RestrictionStore
Auth AuthService
Revoker AuthKeyRevoker
Users UsersService
UserNotifier UserNotifier
Channels ChannelsService
ChannelNotifier ChannelNotifier
Messages MessagesService
Now func() time.Time
}
type Service struct {
commands CommandRepository
restrictions RestrictionStore
auth AuthService
revoker AuthKeyRevoker
users UsersService
userNotifier UserNotifier
channels ChannelsService
channelNotifier ChannelNotifier
messages MessagesService
now func() time.Time
}
func NewService(deps Dependencies) *Service {
s := &Service{now: time.Now}
return s.Configure(deps)
}
func (s *Service) Configure(deps Dependencies) *Service {
if deps.Commands != nil {
s.commands = deps.Commands
}
if deps.Restrictions != nil {
s.restrictions = deps.Restrictions
}
if deps.Auth != nil {
s.auth = deps.Auth
}
if deps.Revoker != nil {
s.revoker = deps.Revoker
}
if deps.Users != nil {
s.users = deps.Users
}
if deps.UserNotifier != nil {
s.userNotifier = deps.UserNotifier
}
if deps.Channels != nil {
s.channels = deps.Channels
}
if deps.ChannelNotifier != nil {
s.channelNotifier = deps.ChannelNotifier
}
if deps.Messages != nil {
s.messages = deps.Messages
}
if deps.Now != nil {
s.now = deps.Now
}
if s.now == nil {
s.now = time.Now
}
return s
}
type CommandMeta struct {
CommandID string `json:"command_id"`
Actor string `json:"actor"`
Reason string `json:"reason"`
DryRun bool `json:"dry_run"`
}
type CommandResult struct {
CommandID string `json:"command_id"`
Action string `json:"action"`
Status string `json:"status"`
AlreadyExecuted bool `json:"already_executed"`
DryRun bool `json:"dry_run"`
TargetUserID int64 `json:"target_user_id,omitempty"`
TargetPeer domain.Peer `json:"target_peer,omitempty"`
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
Error string `json:"error,omitempty"`
}
type SetSendFrozenRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Frozen bool `json:"frozen"`
}
type GrantPremiumRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Months int `json:"months"`
}
type SetVerifiedRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Verified bool `json:"verified"`
}
type SetChannelVerifiedRequest struct {
CommandMeta
ChannelID int64 `json:"channel_id"`
Verified bool `json:"verified"`
}
type RevokeSessionsRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Hash int64 `json:"hash,omitempty"`
KeepHash int64 `json:"keep_hash,omitempty"`
RevokeAll bool `json:"revoke_all,omitempty"`
}
type DeletePrivateMessagesRequest struct {
CommandMeta
OwnerUserID int64 `json:"owner_user_id"`
Peer domain.Peer `json:"peer"`
IDs []int `json:"ids"`
Revoke bool `json:"revoke"`
}
type DeletePrivateHistoryRequest struct {
CommandMeta
OwnerUserID int64 `json:"owner_user_id"`
Peer domain.Peer `json:"peer"`
MaxID int `json:"max_id,omitempty"`
MinDate int `json:"min_date,omitempty"`
MaxDate int `json:"max_date,omitempty"`
JustClear bool `json:"just_clear,omitempty"`
Revoke bool `json:"revoke"`
MaxBatches int `json:"max_batches,omitempty"`
}
func (s *Service) CanSendMessages(ctx context.Context, userID int64) error {
if s == nil || s.restrictions == nil || userID == 0 {
return nil
}
frozen, err := s.restrictions.IsSendFrozen(ctx, userID)
if err != nil {
return err
}
if frozen {
return domain.ErrUserSendRestricted
}
return nil
}
func (s *Service) SetSendFrozen(ctx context.Context, req SetSendFrozenRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.restrictions == nil {
return CommandResult{}, fmt.Errorf("admin restriction store is not configured")
}
return s.runCommand(ctx, req.CommandMeta, ActionSetSendFrozen, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
prev, found, err := s.restrictions.GetSendRestriction(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
details := map[string]any{
"previous_frozen": found && prev.Frozen,
"new_frozen": req.Frozen,
"would_change": !found || prev.Frozen != req.Frozen,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.restrictions.SetSendRestriction(ctx, domain.AccountSendRestriction{
UserID: req.UserID,
Frozen: req.Frozen,
Reason: req.Reason,
Actor: req.Actor,
CommandID: req.CommandID,
})
if err != nil {
return CommandResult{}, err
}
details["updated_at"] = updated.UpdatedAt.UTC().Format(time.RFC3339)
return CommandResult{Message: "send restriction updated", Details: details}, nil
})
}
func (s *Service) GrantPremium(ctx context.Context, req GrantPremiumRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if req.Months < 0 || req.Months > maxPremiumMonths {
return CommandResult{}, fmt.Errorf("months must be between 0 and %d", maxPremiumMonths)
}
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
return s.runCommand(ctx, req.CommandMeta, ActionGrantPremium, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
if u.Bot {
return CommandResult{}, domain.ErrPremiumBotUnsupported
}
details := premiumCommandDetails(u, req.Months, s.now())
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.users.GrantPremium(ctx, req.UserID, req.Months)
if err != nil {
return CommandResult{}, err
}
details["updated_premium_until"] = updated.PremiumUntil
details["updated_premium_active"] = updated.PremiumActiveAt(s.now().Unix())
if err := s.notifyUserChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
msg := "premium updated"
if req.Months == 0 {
msg = "premium cleared"
}
return CommandResult{Message: msg, Details: details}, nil
})
}
func (s *Service) SetVerified(ctx context.Context, req SetVerifiedRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
return s.runCommand(ctx, req.CommandMeta, ActionSetVerified, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
details := map[string]any{
"previous_verified": u.Verified,
"new_verified": req.Verified,
"would_change": u.Verified != req.Verified,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.users.SetVerified(ctx, req.UserID, req.Verified)
if err != nil {
return CommandResult{}, err
}
details["updated_verified"] = updated.Verified
if err := s.notifyUserChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "verified updated", Details: details}, nil
})
}
func (s *Service) SetChannelVerified(ctx context.Context, req SetChannelVerifiedRequest) (CommandResult, error) {
if req.ChannelID <= 0 {
return CommandResult{}, fmt.Errorf("channel_id is required")
}
if s == nil || s.channels == nil {
return CommandResult{}, fmt.Errorf("admin channel dependency is not configured")
}
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelVerified, 0, target, req, func() (CommandResult, error) {
ch, err := s.channels.GetChannelByID(ctx, req.ChannelID)
if err != nil {
return CommandResult{}, err
}
if ch.Monoforum || (!ch.Broadcast && !ch.Megagroup) {
return CommandResult{}, domain.ErrChannelInvalid
}
details := map[string]any{
"title": ch.Title,
"username": ch.Username,
"broadcast": ch.Broadcast,
"megagroup": ch.Megagroup,
"previous_verified": ch.Verified,
"new_verified": req.Verified,
"would_change": ch.Verified != req.Verified,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.channels.SetVerified(ctx, req.ChannelID, req.Verified)
if err != nil {
return CommandResult{}, err
}
details["updated_verified"] = updated.Verified
if err := s.notifyChannelChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "channel verified updated", Details: details}, nil
})
}
func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.auth == nil || s.revoker == nil {
return CommandResult{}, fmt.Errorf("admin auth dependencies are not configured")
}
if (req.Hash == 0 && req.KeepHash == 0 && !req.RevokeAll) || (req.Hash != 0 && (req.KeepHash != 0 || req.RevokeAll)) {
return CommandResult{}, fmt.Errorf("choose one revoke mode")
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeSessions, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
items, err := s.auth.ListAuthorizations(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
targets, keep, err := revokeTargets(items, req)
if err != nil {
return CommandResult{}, err
}
details := map[string]any{
"target_hashes": authorizationHashes(targets),
"target_count": len(targets),
"keep_hash": keep.Hash,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
var revoked []domain.Authorization
if req.Hash != 0 {
deleted, found, err := s.auth.ResetAuthorization(ctx, req.UserID, req.Hash)
if err != nil {
return CommandResult{}, err
}
if found {
revoked = append(revoked, deleted)
}
} else {
deleted, err := s.auth.ResetAuthorizations(ctx, req.UserID, keep.AuthKeyID)
if err != nil {
return CommandResult{}, err
}
revoked = append(revoked, deleted...)
}
for _, a := range revoked {
if err := s.revoker.RevokeAuthorizationAuthKey(ctx, a.AuthKeyID, req.UserID); err != nil {
return CommandResult{}, err
}
}
details["revoked_hashes"] = authorizationHashes(revoked)
details["revoked_count"] = len(revoked)
return CommandResult{Message: "sessions revoked", Details: details}, nil
})
}
func (s *Service) DeletePrivateMessages(ctx context.Context, req DeletePrivateMessagesRequest) (CommandResult, error) {
ids, err := normalizeIDs(req.IDs)
if err != nil {
return CommandResult{}, err
}
req.IDs = ids
if req.OwnerUserID <= 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID <= 0 {
return CommandResult{}, fmt.Errorf("owner_user_id and user peer are required")
}
if s == nil || s.messages == nil {
return CommandResult{}, fmt.Errorf("admin message dependency is not configured")
}
return s.runCommand(ctx, req.CommandMeta, ActionDeletePrivateMessages, req.OwnerUserID, req.Peer, req, func() (CommandResult, error) {
list, err := s.messages.GetMessages(ctx, req.OwnerUserID, req.IDs)
if err != nil {
return CommandResult{}, err
}
found, missing, err := validatePrivateMessageSelection(req.OwnerUserID, req.Peer, req.IDs, list.Messages)
if err != nil {
return CommandResult{}, err
}
details := map[string]any{
"requested_ids": req.IDs,
"found_ids": found,
"missing_ids": missing,
"revoke": req.Revoke,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
if len(missing) > 0 {
return CommandResult{}, fmt.Errorf("messages not found for owner/peer: %v", missing)
}
res, err := s.messages.DeleteMessages(ctx, req.OwnerUserID, domain.DeleteMessagesRequest{
OwnerUserID: req.OwnerUserID,
IDs: req.IDs,
Revoke: req.Revoke,
Date: int(s.now().Unix()),
})
if err != nil {
return CommandResult{}, err
}
details["deleted"] = summarizeDeleteResult(res)
details["changed"] = res.Changed()
return CommandResult{Message: "messages deleted", Details: details}, nil
})
}
func (s *Service) DeletePrivateHistory(ctx context.Context, req DeletePrivateHistoryRequest) (CommandResult, error) {
if req.OwnerUserID <= 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID <= 0 {
return CommandResult{}, fmt.Errorf("owner_user_id and user peer are required")
}
if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID || req.MinDate < 0 || req.MaxDate < 0 {
return CommandResult{}, domain.ErrMessageIDInvalid
}
if req.MaxBatches <= 0 {
req.MaxBatches = 10
}
if req.MaxBatches > maxHistoryBatches {
return CommandResult{}, fmt.Errorf("max_batches exceeds %d", maxHistoryBatches)
}
if s == nil || s.messages == nil {
return CommandResult{}, fmt.Errorf("admin message dependency is not configured")
}
return s.runCommand(ctx, req.CommandMeta, ActionDeletePrivateHistory, req.OwnerUserID, req.Peer, req, func() (CommandResult, error) {
preview, err := s.messages.GetHistory(ctx, req.OwnerUserID, domain.MessageFilter{
HasPeer: true,
Peer: req.Peer,
MaxID: req.MaxID,
Limit: 50,
})
if err != nil {
return CommandResult{}, err
}
details := map[string]any{
"preview_ids": messageIDs(preview.Messages),
"preview_count": len(preview.Messages),
"batch_limit": domain.MaxDeleteHistoryBatch,
"max_batches": req.MaxBatches,
"revoke": req.Revoke,
"just_clear": req.JustClear,
"date_range_filter": req.MinDate != 0 || req.MaxDate != 0,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
totalDeleted := 0
ownerBatches := make([]any, 0, req.MaxBatches)
offset := 0
for batch := 0; batch < req.MaxBatches; batch++ {
res, err := s.messages.DeleteHistory(ctx, req.OwnerUserID, domain.DeleteHistoryRequest{
OwnerUserID: req.OwnerUserID,
Peer: req.Peer,
MaxID: req.MaxID,
MinDate: req.MinDate,
MaxDate: req.MaxDate,
JustClear: req.JustClear,
Revoke: req.Revoke,
Date: int(s.now().Unix()),
})
if err != nil {
return CommandResult{}, err
}
self := res.Self()
totalDeleted += len(self.MessageIDs)
ownerBatches = append(ownerBatches, summarizeDeleteResult(res)...)
offset = res.Offset
if res.Offset == 0 {
break
}
}
details["deleted_count"] = totalDeleted
details["deleted"] = ownerBatches
details["has_more"] = offset != 0
msg := "history deleted"
if offset != 0 {
msg = "history partially deleted; run another command to continue"
}
return CommandResult{Message: msg, Details: details}, nil
})
}
func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action string, targetUserID int64, targetPeer domain.Peer, request any, fn func() (CommandResult, error)) (CommandResult, error) {
if s == nil || s.commands == nil {
return CommandResult{}, fmt.Errorf("admin command store is not configured")
}
meta.CommandID = strings.TrimSpace(meta.CommandID)
meta.Actor = strings.TrimSpace(meta.Actor)
meta.Reason = strings.TrimSpace(meta.Reason)
if meta.CommandID == "" || len(meta.CommandID) > maxCommandIDLength {
return CommandResult{}, fmt.Errorf("command_id is required and must be <= %d bytes", maxCommandIDLength)
}
if meta.Actor == "" || len(meta.Actor) > maxActorLength {
return CommandResult{}, fmt.Errorf("actor is required and must be <= %d bytes", maxActorLength)
}
if meta.Reason == "" || len(meta.Reason) > maxReasonLength {
return CommandResult{}, fmt.Errorf("reason is required and must be <= %d bytes", maxReasonLength)
}
requestJSON, err := json.Marshal(request)
if err != nil {
return CommandResult{}, fmt.Errorf("marshal admin request: %w", err)
}
cmd, created, err := s.commands.BeginCommand(ctx, domain.AdminCommand{
CommandID: meta.CommandID,
Actor: meta.Actor,
Action: action,
TargetUserID: targetUserID,
TargetPeer: targetPeer,
DryRun: meta.DryRun,
Reason: meta.Reason,
RequestJSON: requestJSON,
Status: domain.AdminCommandRunning,
CreatedAt: s.now(),
})
if err != nil {
return CommandResult{}, err
}
if !created {
return resultFromCommand(cmd), nil
}
result, opErr := fn()
result.CommandID = meta.CommandID
result.Action = action
result.DryRun = meta.DryRun
result.TargetUserID = targetUserID
result.TargetPeer = targetPeer
status := domain.AdminCommandCompleted
if opErr != nil {
status = domain.AdminCommandFailed
result.Status = string(status)
result.Error = opErr.Error()
if result.Message == "" {
result.Message = "command failed"
}
} else {
result.Status = string(status)
}
resultJSON, marshalErr := json.Marshal(result)
if marshalErr != nil {
return result, fmt.Errorf("marshal admin result: %w", marshalErr)
}
errorText := ""
if opErr != nil {
errorText = opErr.Error()
}
if _, err := s.commands.FinishCommand(ctx, meta.CommandID, status, resultJSON, errorText); err != nil {
return result, err
}
return result, opErr
}
func resultFromCommand(cmd domain.AdminCommand) CommandResult {
var result CommandResult
if len(cmd.ResultJSON) > 0 {
if err := json.Unmarshal(cmd.ResultJSON, &result); err == nil {
result.AlreadyExecuted = true
return result
}
}
result = CommandResult{
CommandID: cmd.CommandID,
Action: cmd.Action,
Status: string(cmd.Status),
AlreadyExecuted: true,
DryRun: cmd.DryRun,
TargetUserID: cmd.TargetUserID,
TargetPeer: cmd.TargetPeer,
Message: "command already exists",
Error: cmd.Error,
}
return result
}
func (s *Service) notifyUserChanged(ctx context.Context, u domain.User) error {
if s == nil || s.userNotifier == nil {
return nil
}
return s.userNotifier.NotifyUserChanged(ctx, u)
}
func (s *Service) notifyChannelChanged(ctx context.Context, ch domain.Channel) error {
if s == nil || s.channelNotifier == nil {
return nil
}
return s.channelNotifier.NotifyChannelChanged(ctx, ch)
}
func premiumCommandDetails(u domain.User, months int, now time.Time) map[string]any {
active := u.PremiumActiveAt(now.Unix())
base := now
if active {
base = time.Unix(int64(u.PremiumUntil), 0)
}
projected := 0
if months > 0 {
projected = int(base.AddDate(0, months, 0).Unix())
}
return map[string]any{
"previous_premium_until": u.PremiumUntil,
"previous_premium_active": active,
"months": months,
"new_premium_until": projected,
"would_change": months > 0 || u.PremiumUntil != 0,
}
}
func revokeTargets(items []domain.Authorization, req RevokeSessionsRequest) ([]domain.Authorization, domain.Authorization, error) {
if req.Hash != 0 {
for _, a := range items {
if a.Hash == req.Hash {
return []domain.Authorization{a}, domain.Authorization{}, nil
}
}
return nil, domain.Authorization{}, nil
}
var keep domain.Authorization
if req.KeepHash != 0 {
found := false
for _, a := range items {
if a.Hash == req.KeepHash {
keep = a
found = true
break
}
}
if !found {
return nil, domain.Authorization{}, fmt.Errorf("keep_hash authorization not found")
}
}
targets := make([]domain.Authorization, 0, len(items))
for _, a := range items {
if req.KeepHash != 0 && a.Hash == req.KeepHash {
continue
}
targets = append(targets, a)
}
return targets, keep, nil
}
func authorizationHashes(items []domain.Authorization) []int64 {
out := make([]int64, 0, len(items))
for _, a := range items {
out = append(out, a.Hash)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func normalizeIDs(ids []int) ([]int, error) {
if len(ids) == 0 {
return nil, domain.ErrMessageIDInvalid
}
if len(ids) > domain.MaxDeleteMessageIDs {
return nil, fmt.Errorf("too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs)
}
seen := make(map[int]struct{}, len(ids))
out := make([]int, 0, len(ids))
for _, id := range ids {
if id <= 0 || id > domain.MaxMessageBoxID {
return nil, domain.ErrMessageIDInvalid
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
sort.Ints(out)
return out, nil
}
func validatePrivateMessageSelection(ownerUserID int64, peer domain.Peer, ids []int, messages []domain.Message) ([]int, []int, error) {
foundSet := make(map[int]domain.Message, len(messages))
for _, msg := range messages {
foundSet[msg.ID] = msg
if msg.OwnerUserID != ownerUserID || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peer.ID {
return nil, nil, domain.ErrMessageIDInvalid
}
}
found := make([]int, 0, len(messages))
missing := make([]int, 0)
for _, id := range ids {
if _, ok := foundSet[id]; ok {
found = append(found, id)
continue
}
missing = append(missing, id)
}
return found, missing, nil
}
func summarizeDeleteResult(res domain.DeleteMessagesResult) []any {
out := make([]any, 0, len(res.Deleted))
for _, item := range res.Deleted {
ids := append([]int(nil), item.MessageIDs...)
sort.Ints(ids)
out = append(out, map[string]any{
"user_id": item.UserID,
"message_ids": ids,
"pts": item.Event.Pts,
"pts_count": item.Event.PtsCount,
})
}
return out
}
func messageIDs(messages []domain.Message) []int {
out := make([]int, 0, len(messages))
for _, msg := range messages {
out = append(out, msg.ID)
}
return out
}

View file

@ -0,0 +1,510 @@
package admin
import (
"context"
"errors"
"reflect"
"testing"
"time"
"telesrv/internal/domain"
)
func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
restrictions := &fakeRestrictionStore{}
svc := NewService(Dependencies{
Commands: repo,
Restrictions: restrictions,
Now: fixedNow,
})
dry, err := svc.SetSendFrozen(ctx, SetSendFrozenRequest{
CommandMeta: CommandMeta{CommandID: "dry-freeze", Actor: "ops", Reason: "test", DryRun: true},
UserID: 1001,
Frozen: true,
})
if err != nil {
t.Fatalf("dry-run freeze: %v", err)
}
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 0 {
t.Fatalf("dry-run result=%+v setCalls=%d, want completed dry-run without mutation", dry, restrictions.setCalls)
}
execReq := SetSendFrozenRequest{
CommandMeta: CommandMeta{CommandID: "exec-freeze", Actor: "ops", Reason: "incident", DryRun: false},
UserID: 1001,
Frozen: true,
}
exec, err := svc.SetSendFrozen(ctx, execReq)
if err != nil {
t.Fatalf("execute freeze: %v", err)
}
if exec.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 1 {
t.Fatalf("execute result=%+v setCalls=%d", exec, restrictions.setCalls)
}
if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("CanSendMessages err=%v, want ErrUserSendRestricted", err)
}
again, err := svc.SetSendFrozen(ctx, execReq)
if err != nil {
t.Fatalf("duplicate freeze: %v", err)
}
if !again.AlreadyExecuted || restrictions.setCalls != 1 {
t.Fatalf("duplicate result=%+v setCalls=%d, want idempotent replay", again, restrictions.setCalls)
}
}
func TestGrantPremiumDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
users := &fakeUsersService{users: map[int64]domain.User{
1001: {ID: 1001, FirstName: "Alice"},
}}
notifier := &fakeUserNotifier{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Users: users,
UserNotifier: notifier,
Now: fixedNow,
})
dry, err := svc.GrantPremium(ctx, GrantPremiumRequest{
CommandMeta: CommandMeta{CommandID: "dry-premium", Actor: "ops", Reason: "test", DryRun: true},
UserID: 1001,
Months: 3,
})
if err != nil {
t.Fatalf("dry-run premium: %v", err)
}
if !dry.DryRun || users.grantCalls != 0 || len(notifier.users) != 0 {
t.Fatalf("dry=%+v grantCalls=%d notified=%v, want no mutation", dry, users.grantCalls, notifier.users)
}
req := GrantPremiumRequest{
CommandMeta: CommandMeta{CommandID: "exec-premium", Actor: "ops", Reason: "grant"},
UserID: 1001,
Months: 2,
}
exec, err := svc.GrantPremium(ctx, req)
if err != nil {
t.Fatalf("execute premium: %v", err)
}
if exec.Status != string(domain.AdminCommandCompleted) || users.grantCalls != 1 || users.lastMonths != 2 || len(notifier.users) != 1 {
t.Fatalf("exec=%+v grantCalls=%d months=%d notified=%v", exec, users.grantCalls, users.lastMonths, notifier.users)
}
again, err := svc.GrantPremium(ctx, req)
if err != nil {
t.Fatalf("duplicate premium: %v", err)
}
if !again.AlreadyExecuted || users.grantCalls != 1 || len(notifier.users) != 1 {
t.Fatalf("again=%+v grantCalls=%d notified=%v, want idempotent replay", again, users.grantCalls, notifier.users)
}
}
func TestSetVerifiedDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
users := &fakeUsersService{users: map[int64]domain.User{
1001: {ID: 1001, FirstName: "Alice"},
}}
notifier := &fakeUserNotifier{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Users: users,
UserNotifier: notifier,
Now: fixedNow,
})
dry, err := svc.SetVerified(ctx, SetVerifiedRequest{
CommandMeta: CommandMeta{CommandID: "dry-verified", Actor: "ops", Reason: "test", DryRun: true},
UserID: 1001,
Verified: true,
})
if err != nil {
t.Fatalf("dry-run verified: %v", err)
}
if !dry.DryRun || users.verifiedCalls != 0 || len(notifier.users) != 0 {
t.Fatalf("dry=%+v verifiedCalls=%d notified=%v, want no mutation", dry, users.verifiedCalls, notifier.users)
}
req := SetVerifiedRequest{
CommandMeta: CommandMeta{CommandID: "exec-verified", Actor: "ops", Reason: "official"},
UserID: 1001,
Verified: true,
}
exec, err := svc.SetVerified(ctx, req)
if err != nil {
t.Fatalf("execute verified: %v", err)
}
if exec.Status != string(domain.AdminCommandCompleted) || users.verifiedCalls != 1 || !users.users[1001].Verified || len(notifier.users) != 1 {
t.Fatalf("exec=%+v verifiedCalls=%d user=%+v notified=%v", exec, users.verifiedCalls, users.users[1001], notifier.users)
}
again, err := svc.SetVerified(ctx, req)
if err != nil {
t.Fatalf("duplicate verified: %v", err)
}
if !again.AlreadyExecuted || users.verifiedCalls != 1 || len(notifier.users) != 1 {
t.Fatalf("again=%+v verifiedCalls=%d notified=%v, want idempotent replay", again, users.verifiedCalls, notifier.users)
}
}
func TestSetChannelVerifiedDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
channels := &fakeChannelsService{channels: map[int64]domain.Channel{
2001: {ID: 2001, CreatorUserID: 1001, Title: "Ops Channel", Username: "ops", Broadcast: true},
}}
notifier := &fakeChannelNotifier{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Channels: channels,
ChannelNotifier: notifier,
Now: fixedNow,
})
dry, err := svc.SetChannelVerified(ctx, SetChannelVerifiedRequest{
CommandMeta: CommandMeta{CommandID: "dry-channel-verified", Actor: "ops", Reason: "test", DryRun: true},
ChannelID: 2001,
Verified: true,
})
if err != nil {
t.Fatalf("dry-run channel verified: %v", err)
}
if !dry.DryRun || channels.verifiedCalls != 0 || len(notifier.channels) != 0 {
t.Fatalf("dry=%+v verifiedCalls=%d notified=%v, want no mutation", dry, channels.verifiedCalls, notifier.channels)
}
req := SetChannelVerifiedRequest{
CommandMeta: CommandMeta{CommandID: "exec-channel-verified", Actor: "ops", Reason: "official"},
ChannelID: 2001,
Verified: true,
}
exec, err := svc.SetChannelVerified(ctx, req)
if err != nil {
t.Fatalf("execute channel verified: %v", err)
}
if exec.Status != string(domain.AdminCommandCompleted) || channels.verifiedCalls != 1 || !channels.channels[2001].Verified || len(notifier.channels) != 1 {
t.Fatalf("exec=%+v verifiedCalls=%d channel=%+v notified=%v", exec, channels.verifiedCalls, channels.channels[2001], notifier.channels)
}
if exec.TargetPeer.Type != domain.PeerTypeChannel || exec.TargetPeer.ID != 2001 || exec.TargetUserID != 0 {
t.Fatalf("target user=%d peer=%+v, want channel target", exec.TargetUserID, exec.TargetPeer)
}
again, err := svc.SetChannelVerified(ctx, req)
if err != nil {
t.Fatalf("duplicate channel verified: %v", err)
}
if !again.AlreadyExecuted || channels.verifiedCalls != 1 || len(notifier.channels) != 1 {
t.Fatalf("again=%+v verifiedCalls=%d notified=%v, want idempotent replay", again, channels.verifiedCalls, notifier.channels)
}
}
func TestDeletePrivateMessagesUsesMessageServiceAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
messages := &fakeMessagesService{
byID: []domain.Message{
{OwnerUserID: 1001, ID: 11, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}},
{OwnerUserID: 1001, ID: 12, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}},
},
}
svc := NewService(Dependencies{Commands: repo, Messages: messages, Now: fixedNow})
req := DeletePrivateMessagesRequest{
CommandMeta: CommandMeta{CommandID: "delete-1", Actor: "ops", Reason: "abuse"},
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
IDs: []int{12, 11},
Revoke: true,
}
if _, err := svc.DeletePrivateMessages(ctx, req); err != nil {
t.Fatalf("delete messages: %v", err)
}
if messages.deleteCalls != 1 || !reflect.DeepEqual(messages.lastDelete.IDs, []int{11, 12}) || !messages.lastDelete.Revoke {
t.Fatalf("delete calls=%d req=%+v", messages.deleteCalls, messages.lastDelete)
}
if _, err := svc.DeletePrivateMessages(ctx, req); err != nil {
t.Fatalf("duplicate delete messages: %v", err)
}
if messages.deleteCalls != 1 {
t.Fatalf("duplicate delete calls=%d, want 1", messages.deleteCalls)
}
}
func TestDeletePrivateMessagesRejectsMissingOnExecute(t *testing.T) {
ctx := context.Background()
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Messages: &fakeMessagesService{}, Now: fixedNow})
_, err := svc.DeletePrivateMessages(ctx, DeletePrivateMessagesRequest{
CommandMeta: CommandMeta{CommandID: "delete-missing", Actor: "ops", Reason: "test"},
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
IDs: []int{99},
})
if err == nil {
t.Fatal("delete missing message err=nil, want error")
}
}
func TestRevokeSessionsSpecifiedClosesRevokedAuthKey(t *testing.T) {
ctx := context.Background()
key := [8]byte{1, 2, 3}
auth := &fakeAuthService{items: []domain.Authorization{
{AuthKeyID: key, UserID: 1001, Hash: 555},
}}
revoker := &fakeRevoker{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Auth: auth, Revoker: revoker, Now: fixedNow})
if _, err := svc.RevokeSessions(ctx, RevokeSessionsRequest{
CommandMeta: CommandMeta{CommandID: "revoke-1", Actor: "ops", Reason: "lost device"},
UserID: 1001,
Hash: 555,
}); err != nil {
t.Fatalf("revoke sessions: %v", err)
}
if auth.resetHash != 555 || len(revoker.keys) != 1 || revoker.keys[0] != key {
t.Fatalf("resetHash=%d revoked=%v", auth.resetHash, revoker.keys)
}
}
func TestDeletePrivateHistoryLoopsUntilOffsetClears(t *testing.T) {
ctx := context.Background()
messages := &fakeMessagesService{historyOffsets: []int{1, 0}}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Messages: messages, Now: fixedNow})
res, err := svc.DeletePrivateHistory(ctx, DeletePrivateHistoryRequest{
CommandMeta: CommandMeta{CommandID: "history-1", Actor: "ops", Reason: "clear"},
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
MaxBatches: 5,
})
if err != nil {
t.Fatalf("delete history: %v", err)
}
if messages.historyCalls != 2 || res.Details["has_more"] != false {
t.Fatalf("historyCalls=%d result=%+v", messages.historyCalls, res)
}
}
func fixedNow() time.Time {
return time.Unix(1_700_000_000, 0).UTC()
}
type memoryCommandRepo struct {
items map[string]domain.AdminCommand
}
func newMemoryCommandRepo() *memoryCommandRepo {
return &memoryCommandRepo{items: map[string]domain.AdminCommand{}}
}
func (m *memoryCommandRepo) BeginCommand(_ context.Context, cmd domain.AdminCommand) (domain.AdminCommand, bool, error) {
if existing, ok := m.items[cmd.CommandID]; ok {
return existing, false, nil
}
m.items[cmd.CommandID] = cmd
return cmd, true, nil
}
func (m *memoryCommandRepo) FinishCommand(_ context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) {
cmd := m.items[commandID]
cmd.Status = status
cmd.ResultJSON = resultJSON
cmd.Error = errorText
m.items[commandID] = cmd
return cmd, nil
}
type fakeRestrictionStore struct {
items map[int64]domain.AccountSendRestriction
setCalls int
}
func (f *fakeRestrictionStore) GetSendRestriction(_ context.Context, userID int64) (domain.AccountSendRestriction, bool, error) {
if f.items == nil {
return domain.AccountSendRestriction{}, false, nil
}
r, ok := f.items[userID]
return r, ok, nil
}
func (f *fakeRestrictionStore) SetSendRestriction(_ context.Context, r domain.AccountSendRestriction) (domain.AccountSendRestriction, error) {
if f.items == nil {
f.items = map[int64]domain.AccountSendRestriction{}
}
f.setCalls++
r.UpdatedAt = fixedNow()
f.items[r.UserID] = r
return r, nil
}
func (f *fakeRestrictionStore) IsSendFrozen(_ context.Context, userID int64) (bool, error) {
if f.items == nil {
return false, nil
}
return f.items[userID].Frozen, nil
}
type fakeMessagesService struct {
byID []domain.Message
deleteCalls int
lastDelete domain.DeleteMessagesRequest
historyCalls int
historyOffsets []int
}
func (f *fakeMessagesService) GetMessages(_ context.Context, _ int64, _ []int) (domain.MessageList, error) {
return domain.MessageList{Messages: f.byID}, nil
}
func (f *fakeMessagesService) GetHistory(_ context.Context, _ int64, _ domain.MessageFilter) (domain.MessageList, error) {
return domain.MessageList{Messages: []domain.Message{{ID: 1}}}, nil
}
func (f *fakeMessagesService) DeleteMessages(_ context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) {
f.deleteCalls++
f.lastDelete = req
return domain.DeleteMessagesResult{
OwnerUserID: userID,
Deleted: []domain.DeletedMessagesForUser{{
UserID: userID,
MessageIDs: req.IDs,
Event: domain.UpdateEvent{Pts: 10, PtsCount: len(req.IDs)},
}},
}, nil
}
func (f *fakeMessagesService) DeleteHistory(_ context.Context, userID int64, _ domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) {
offset := 0
if f.historyCalls < len(f.historyOffsets) {
offset = f.historyOffsets[f.historyCalls]
}
f.historyCalls++
return domain.DeleteMessagesResult{
OwnerUserID: userID,
Deleted: []domain.DeletedMessagesForUser{{
UserID: userID,
MessageIDs: []int{f.historyCalls},
Event: domain.UpdateEvent{Pts: f.historyCalls, PtsCount: 1},
}},
Offset: offset,
}, nil
}
type fakeAuthService struct {
items []domain.Authorization
resetHash int64
}
func (f *fakeAuthService) ListAuthorizations(context.Context, int64) ([]domain.Authorization, error) {
return f.items, nil
}
func (f *fakeAuthService) ResetAuthorization(_ context.Context, _ int64, hash int64) (domain.Authorization, bool, error) {
f.resetHash = hash
for _, a := range f.items {
if a.Hash == hash {
return a, true, nil
}
}
return domain.Authorization{}, false, nil
}
func (f *fakeAuthService) ResetAuthorizations(_ context.Context, _ int64, keep [8]byte) ([]domain.Authorization, error) {
out := make([]domain.Authorization, 0)
for _, a := range f.items {
if a.AuthKeyID != keep {
out = append(out, a)
}
}
return out, nil
}
type fakeRevoker struct {
keys [][8]byte
}
func (f *fakeRevoker) RevokeAuthorizationAuthKey(_ context.Context, key [8]byte, _ int64) error {
f.keys = append(f.keys, key)
return nil
}
type fakeUsersService struct {
users map[int64]domain.User
grantCalls int
lastMonths int
verifiedCalls int
}
func (f *fakeUsersService) AdminUser(_ context.Context, userID int64) (domain.User, bool, error) {
u, ok := f.users[userID]
return u, ok, nil
}
func (f *fakeUsersService) GrantPremium(_ context.Context, userID int64, months int) (domain.User, error) {
f.grantCalls++
f.lastMonths = months
u, ok := f.users[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
if u.Bot {
return domain.User{}, domain.ErrPremiumBotUnsupported
}
if months <= 0 {
u.PremiumUntil = 0
} else {
u.PremiumUntil = int(fixedNow().AddDate(0, months, 0).Unix())
}
f.users[userID] = u
return u, nil
}
func (f *fakeUsersService) SetVerified(_ context.Context, userID int64, verified bool) (domain.User, error) {
f.verifiedCalls++
u, ok := f.users[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
u.Verified = verified
f.users[userID] = u
return u, nil
}
type fakeUserNotifier struct {
users []int64
}
func (f *fakeUserNotifier) NotifyUserChanged(_ context.Context, u domain.User) error {
f.users = append(f.users, u.ID)
return nil
}
type fakeChannelsService struct {
channels map[int64]domain.Channel
verifiedCalls int
}
func (f *fakeChannelsService) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) {
ch, ok := f.channels[channelID]
if !ok {
return domain.Channel{}, domain.ErrChannelInvalid
}
return ch, nil
}
func (f *fakeChannelsService) SetVerified(_ context.Context, channelID int64, verified bool) (domain.Channel, error) {
f.verifiedCalls++
ch, ok := f.channels[channelID]
if !ok {
return domain.Channel{}, domain.ErrChannelInvalid
}
ch.Verified = verified
f.channels[channelID] = ch
return ch, nil
}
type fakeChannelNotifier struct {
channels []int64
}
func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.Channel) error {
f.channels = append(f.channels, ch.ID)
return nil
}