3601 lines
140 KiB
Go
3601 lines
140 KiB
Go
package admin
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
const (
|
|
ActionSetAccountFrozen = "account.set_frozen"
|
|
ActionGrantPremium = "account.grant_premium"
|
|
ActionSetVerified = "account.set_verified"
|
|
ActionSetUserFlags = "account.set_flags"
|
|
ActionSetSupport = "account.set_support"
|
|
ActionSetUsername = "account.set_username"
|
|
ActionSetUserColor = "account.set_color"
|
|
ActionSetUserEmojiStatus = "account.set_emoji_status"
|
|
ActionSetProfile = "account.set_profile"
|
|
ActionSetPhone = "account.set_phone"
|
|
ActionSetLoginEmail = "account.set_login_email"
|
|
ActionSetAccountAvatar = "account.set_avatar"
|
|
ActionSetAccountAvatarVideo = "account.set_avatar_video"
|
|
ActionSetChannelAvatar = "channel.set_avatar"
|
|
ActionSetChannelUsername = "channel.set_username"
|
|
ActionSetChannelSettings = "channel.set_settings"
|
|
ActionSetChannelColor = "channel.set_color"
|
|
ActionSetChannelEmojiStatus = "channel.set_emoji_status"
|
|
ActionSetChannelVerified = "channel.set_verified"
|
|
ActionSetChannelFlags = "channel.set_flags"
|
|
ActionRevokeSessions = "account.revoke_sessions"
|
|
ActionDeletePrivateMessages = "messages.delete_private_messages"
|
|
ActionDeletePrivateHistory = "messages.delete_private_history"
|
|
ActionCreateBot = "bot.create"
|
|
ActionCreateBroadcast = "broadcast.create"
|
|
ActionDeleteBot = "bot.delete"
|
|
ActionExportBotToken = "bot.export_token"
|
|
ActionSetStickerSetArchived = "stickers.set_archived"
|
|
ActionSetStickerSetSortOrder = "stickers.set_sort_order"
|
|
ActionRenameStickerSet = "stickers.rename"
|
|
ActionDeleteStickerSet = "stickers.delete"
|
|
ActionCreateStickerSet = "stickers.create"
|
|
ActionAddStickerToSet = "stickers.add_sticker"
|
|
ActionRemoveStickerFromSet = "stickers.remove_sticker"
|
|
ActionCreateGifCatalogEntry = "gif_catalog.create"
|
|
ActionSetGifCatalogEnabled = "gif_catalog.set_enabled"
|
|
ActionSetGifCatalogSortOrder = "gif_catalog.set_sort_order"
|
|
ActionSetGifCatalogCategory = "gif_catalog.set_category"
|
|
ActionAutoCategorizeGifCatalog = "gif_catalog.auto_categorize"
|
|
ActionDeleteUncategorizedGifs = "gif_catalog.delete_uncategorized"
|
|
ActionDeleteGifCatalogEntry = "gif_catalog.delete"
|
|
// Manual storage purge: admin-chosen categories + optional age cutoff,
|
|
// independent of the automatic retention sweep's config-derived
|
|
// selection. See StorageService's doc comment.
|
|
ActionManualPurgeStorage = "storage.manual_purge"
|
|
// Collectible (Fragment-style) username lifecycle.
|
|
ActionMintCollectibleUsername = "usernames.collectible.mint"
|
|
ActionTransferCollectibleUsername = "usernames.collectible.transfer"
|
|
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
|
|
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
|
|
// Official platform verification review. Claim/approve/reject act on one
|
|
// application; revoke acts on a target, because clearing a badge is not a
|
|
// decision on the application that granted it.
|
|
ActionClaimVerification = "verification.claim"
|
|
ActionApproveVerification = "verification.approve"
|
|
ActionRejectVerification = "verification.reject"
|
|
ActionRevokeVerification = "verification.revoke"
|
|
// Third-party bot verification (see botverification.go). A namespace of its own
|
|
// on purpose: these actions write the verifier catalogue and the attributed
|
|
// marks, never the platform checkmark, and the audit trail has to keep the two
|
|
// mechanisms apart at a glance.
|
|
ActionGrantBotVerifier = "botverification.grant_verifier"
|
|
ActionSetBotVerifierEnabled = "botverification.set_verifier_enabled"
|
|
ActionRevokeBotVerifier = "botverification.revoke_verifier"
|
|
ActionUpsertVerificationIcon = "botverification.upsert_icon"
|
|
ActionSetVerificationIconActive = "botverification.set_icon_active"
|
|
ActionRevokeCustomVerification = "botverification.revoke_mark"
|
|
ActionApproveBotVerification = "botverification.approve"
|
|
ActionRejectBotVerification = "botverification.reject"
|
|
ActionRevokeBotVerification = "botverification.revoke_request"
|
|
|
|
maxCommandIDLength = 128
|
|
maxActorLength = 128
|
|
maxReasonLength = 1000
|
|
maxHistoryBatches = 100
|
|
maxPremiumMonths = 120
|
|
maxFreezeAppealURLLength = 2048
|
|
)
|
|
|
|
// Stable admin error codes for the collectible-username and account-rating
|
|
// subsystems. The panel switches on the code, so a command failure and a read
|
|
// failure describe the same condition with the same token instead of leaking a
|
|
// Go error string the UI would have to pattern-match.
|
|
const (
|
|
CodeUsernameOccupied = "USERNAME_OCCUPIED"
|
|
CodeUsernameInvalid = "USERNAME_INVALID"
|
|
CodeUsernameNotCollectible = "USERNAME_NOT_COLLECTIBLE"
|
|
CodeCollectibleNotFound = "COLLECTIBLE_NOT_FOUND"
|
|
CodeCollectibleNotOwned = "COLLECTIBLE_NOT_OWNED"
|
|
CodeCollectibleBurned = "COLLECTIBLE_BURNED"
|
|
CodeCollectiblePeerLimit = "COLLECTIBLE_PEER_LIMIT"
|
|
CodeCollectibleCurrencyInvalid = "COLLECTIBLE_CURRENCY_INVALID"
|
|
CodeCollectibleStateInvalid = "COLLECTIBLE_STATE_INVALID"
|
|
// Official platform verification review. CodeVerificationConflict is the lost
|
|
// optimistic-locking race -- two reviewers deciding at once -- and is the one
|
|
// the panel must render as "reload and look again" rather than as a bad
|
|
// request.
|
|
CodeVerificationNotFound = "VERIFICATION_NOT_FOUND"
|
|
CodeVerificationConflict = "VERIFICATION_CONFLICT"
|
|
CodeVerificationStatusInvalid = "VERIFICATION_STATUS_INVALID"
|
|
CodeVerificationReasonRequired = "VERIFICATION_REASON_REQUIRED"
|
|
CodeVerificationTargetInvalid = "VERIFICATION_TARGET_INVALID"
|
|
CodeVerificationTargetOccupied = "VERIFICATION_TARGET_OCCUPIED"
|
|
CodeVerificationTargetVerified = "VERIFICATION_TARGET_ALREADY_VERIFIED"
|
|
CodeVerificationTargetNotPublic = "VERIFICATION_TARGET_NOT_PUBLIC"
|
|
CodeVerificationTargetRestricted = "VERIFICATION_TARGET_RESTRICTED"
|
|
CodeVerificationTargetSystem = "VERIFICATION_TARGET_SYSTEM"
|
|
CodeVerificationNotOwner = "VERIFICATION_NOT_OWNER"
|
|
CodeVerificationUserTargetsDisabled = "VERIFICATION_USER_TARGETS_DISABLED"
|
|
CodeVerificationInvalid = "VERIFICATION_INVALID"
|
|
)
|
|
|
|
// Stable admin error codes for third-party bot verification (see
|
|
// botverification.go). They are a separate set from the official verification
|
|
// codes above: the two mechanisms own separate tables and fail for separate
|
|
// reasons, and the panel renders them in separate sections, so one shared token
|
|
// would land a message in the wrong place.
|
|
//
|
|
// CodeCustomVerificationConflict is the lost optimistic-locking race -- two
|
|
// operators deciding at once -- and is the one the panel must render as "reload
|
|
// and look again" rather than as a bad request.
|
|
const (
|
|
CodeBotVerifierNotFound = "BOTVERIFIER_NOT_FOUND"
|
|
CodeBotVerifierForbidden = "BOTVERIFIER_FORBIDDEN"
|
|
CodeBotVerifierInvalid = "BOTVERIFIER_INVALID"
|
|
CodeBotVerifierBotNotFound = "BOTVERIFIER_BOT_NOT_FOUND"
|
|
CodeBotVerifierDescriptionForbidden = "BOTVERIFIER_DESCRIPTION_FORBIDDEN"
|
|
|
|
CodeVerificationIconNotFound = "VERIFICATION_ICON_NOT_FOUND"
|
|
CodeVerificationIconInactive = "VERIFICATION_ICON_INACTIVE"
|
|
CodeVerificationIconInvalid = "VERIFICATION_ICON_INVALID"
|
|
|
|
CodeCustomVerificationNotFound = "CUSTOM_VERIFICATION_NOT_FOUND"
|
|
CodeCustomVerificationRequestNotFound = "CUSTOM_VERIFICATION_REQUEST_NOT_FOUND"
|
|
CodeCustomVerificationRequestExists = "CUSTOM_VERIFICATION_REQUEST_EXISTS"
|
|
CodeCustomVerificationConflict = "CUSTOM_VERIFICATION_CONFLICT"
|
|
CodeCustomVerificationLimit = "CUSTOM_VERIFICATION_LIMIT"
|
|
CodeCustomVerificationStatusInvalid = "CUSTOM_VERIFICATION_STATUS_INVALID"
|
|
CodeCustomVerificationReasonRequired = "CUSTOM_VERIFICATION_REASON_REQUIRED"
|
|
CodeCustomVerificationTargetInvalid = "CUSTOM_VERIFICATION_TARGET_INVALID"
|
|
CodeCustomVerificationTargetSystem = "CUSTOM_VERIFICATION_TARGET_SYSTEM"
|
|
CodeCustomVerificationRateLimited = "CUSTOM_VERIFICATION_RATE_LIMITED"
|
|
CodeCustomVerificationInvalid = "CUSTOM_VERIFICATION_INVALID"
|
|
)
|
|
|
|
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 {
|
|
GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
|
|
SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error)
|
|
}
|
|
|
|
type accountFreezeBatchStore interface {
|
|
GetAccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error)
|
|
}
|
|
|
|
type accountFreezeNotificationStore interface {
|
|
ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error)
|
|
CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) 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)
|
|
SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error)
|
|
SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error)
|
|
UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error)
|
|
UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error)
|
|
UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error)
|
|
UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error)
|
|
// SetPhone force-sets a user's phone number (no code verification).
|
|
SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error)
|
|
}
|
|
|
|
// AccountService carries the login-email factor (account_passwords table),
|
|
// a separate concern from UsersService's users-table fields.
|
|
type AccountService interface {
|
|
// ValidLoginEmail reports whether email is an acceptable login/signup
|
|
// email address.
|
|
ValidLoginEmail(email string) bool
|
|
// SetLoginEmail force-sets a user's login/signup email, no OTP required.
|
|
SetLoginEmail(ctx context.Context, userID int64, email string) error
|
|
// ClearLoginEmail removes the login email factor entirely.
|
|
ClearLoginEmail(ctx context.Context, userID int64) error
|
|
// LoginEmail returns a user's current login/signup email, if any.
|
|
LoginEmail(ctx context.Context, userID int64) (string, bool, error)
|
|
}
|
|
|
|
// BroadcastService creates and lists system broadcast campaigns (a message
|
|
// from domain.OfficialSystemUserID to all or a hand-picked list of users).
|
|
// Both recipient enumeration and delivery happen out-of-band via a worker
|
|
// draining the durable outbox created here -- this interface only enqueues
|
|
// and reads back, so CreateBroadcast never blocks on however many
|
|
// recipients there are.
|
|
//
|
|
// For domain.BroadcastTargetAll, recipientUserIDs must be empty: the
|
|
// service snapshots the current max eligible user id itself and the worker
|
|
// enumerates it incrementally, so a huge user base never has to cross the
|
|
// admin HTTP boundary as an explicit id list. For
|
|
// domain.BroadcastTargetSelected, recipientUserIDs is the operator-picked
|
|
// list, already resolved by the caller (cmd/telesrv-admin's readstore
|
|
// proxy, the same place every other account list query already lives).
|
|
type BroadcastService interface {
|
|
Create(ctx context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error)
|
|
List(ctx context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error)
|
|
Get(ctx context.Context, id int64) (domain.Broadcast, bool, error)
|
|
}
|
|
|
|
type UserNotifier interface {
|
|
NotifyUserChanged(ctx context.Context, u domain.User) error
|
|
}
|
|
|
|
type UserModerationNotifier interface {
|
|
NotifyUserModerationFlagsChanged(ctx context.Context, u domain.User) error
|
|
}
|
|
|
|
type AccountFreezeNotifier interface {
|
|
NotifyAccountFreezeChanged(ctx context.Context, freeze domain.AccountFreeze) error
|
|
}
|
|
|
|
type ChannelsService interface {
|
|
GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error)
|
|
SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error)
|
|
SetScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error)
|
|
AdminSetSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error)
|
|
AdminSetUsername(ctx context.Context, channelID int64, username string) (domain.Channel, error)
|
|
AdminSetColor(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error)
|
|
AdminSetEmojiStatus(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error)
|
|
AdminSetPhoto(ctx context.Context, channelID int64, photo domain.Photo) (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)
|
|
}
|
|
|
|
// AvatarResolver is the same shape as internal/web's ProfilePhotoResolver, kept as its
|
|
// own local interface (rather than importing internal/web) since only this narrow slice
|
|
// is needed to serve an account's current profile photo in the admin console.
|
|
type AvatarResolver interface {
|
|
CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error)
|
|
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
|
|
// ValidateAvatarUpload is a pure check (no store writes), used by a dry-run
|
|
// preview before CreateAvatarFromBytes actually materializes the avatar.
|
|
ValidateAvatarUpload(data []byte) bool
|
|
CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error)
|
|
// CreateAvatarVideoFromBytes is CreateAvatarFromBytes's video counterpart,
|
|
// for the admin console's animated-avatar upload.
|
|
CreateAvatarVideoFromBytes(ctx context.Context, data []byte, ownerUserID int64, videoStartTs float64) (domain.Photo, error)
|
|
SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error)
|
|
// GetPhoto looks up a photo by id directly -- used to read a channel's
|
|
// current avatar, which is denormalized on the channel row as a bare
|
|
// photo_id rather than tracked through CurrentProfilePhotoKind.
|
|
GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error)
|
|
}
|
|
|
|
// StickerSetsService is the admin-console management surface over sticker/custom-emoji
|
|
// sets: no ownership check (see internal/app/files.Service.AdminSetStickerSetArchived),
|
|
// so it works for seed-imported system/regular packs as well as user-created ones.
|
|
type StickerSetsService interface {
|
|
AdminSetStickerSetArchived(ctx context.Context, setID int64, archived bool) (bool, error)
|
|
AdminSetStickerSetSortOrder(ctx context.Context, setID int64, order int) (bool, error)
|
|
AdminRenameStickerSet(ctx context.Context, setID int64, title string) (domain.StickerSet, error)
|
|
AdminDeleteStickerSet(ctx context.Context, setID int64) (domain.StickerSetKind, error)
|
|
// ValidateStickerMaterialUpload is a pure check (no store writes) so a dry-run
|
|
// preview can validate an uploaded file's shape without materializing it.
|
|
ValidateStickerMaterialUpload(fileName string, data []byte) (mimeType string, ok bool)
|
|
// ValidateAdminCreateStickerSet and ValidateAdminAddStickerToSet are pure
|
|
// checks (no store writes), used by a dry-run preview before the
|
|
// corresponding Admin* call actually mutates the pack.
|
|
ValidateAdminCreateStickerSet(ctx context.Context, title, shortName, emoji string, kind domain.StickerSetKind) error
|
|
ValidateAdminAddStickerToSet(ctx context.Context, setID int64, emoji string) error
|
|
AdminUploadStickerMaterial(ctx context.Context, fileName string, data []byte) (domain.Document, error)
|
|
AdminCreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error)
|
|
AdminAddStickerToSet(ctx context.Context, setID int64, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error)
|
|
AdminRemoveStickerFromSet(ctx context.Context, setID int64, documentID int64) (domain.StickerSet, []domain.Document, error)
|
|
}
|
|
|
|
// GifCatalogService is the admin-console management surface over the
|
|
// admin-curated GIF catalog the built-in @gif inline bot serves for the
|
|
// client's GIF picker.
|
|
type GifCatalogService interface {
|
|
// ValidateGifUpload is a pure check (no store writes) so a dry-run preview
|
|
// can validate an uploaded file's shape without materializing it.
|
|
ValidateGifUpload(fileName string, data []byte) (mimeType string, ok bool)
|
|
AdminUploadGifMaterial(ctx context.Context, fileName string, data []byte) (domain.Document, error)
|
|
AdminCreateGifCatalogEntry(ctx context.Context, title string, documentID int64) (domain.GifCatalogEntry, error)
|
|
AdminListGifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error)
|
|
AdminSetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error)
|
|
AdminSetGifCatalogSortOrder(ctx context.Context, id int64, order int) (bool, error)
|
|
AdminSetGifCatalogCategory(ctx context.Context, id int64, category string) (bool, error)
|
|
// AdminAutoCategorizeGifCatalog runs files.ClassifyGifCategory over every
|
|
// currently-uncategorized entry's title and returns how many got a
|
|
// category assigned.
|
|
AdminAutoCategorizeGifCatalog(ctx context.Context) (int, error)
|
|
// AdminDeleteUncategorizedGifs removes every catalog entry with no
|
|
// category, plus its document/blob when nothing else references it.
|
|
// Returns (catalog entries deleted, documents actually deleted).
|
|
AdminDeleteUncategorizedGifs(ctx context.Context) (int, int, error)
|
|
AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error)
|
|
}
|
|
|
|
// StorageService is the admin-console surface over manual storage purge --
|
|
// deleting media blob bytes (never the document/photo metadata row) by
|
|
// admin-chosen category and an optional age cutoff, independent of the
|
|
// automatic retention sweep's config-derived categories/age. See
|
|
// internal/app/files.Service.ManualPurge's doc comment for the exact
|
|
// deletion semantics (identical to "hard" retention mode).
|
|
type StorageService interface {
|
|
// CountManualPurgeCandidates is the dry-run preview: an exact count of
|
|
// how many documents/photos the selection would purge. before == nil
|
|
// means no age filter at all.
|
|
CountManualPurgeCandidates(ctx context.Context, categories []domain.MediaCategory, includeAvatars bool, before *time.Time) (docs int, photos int, err error)
|
|
// ManualPurge actually deletes the selected media's blob bytes, up to
|
|
// limit documents/photos per category/bucket, returning what was purged.
|
|
ManualPurge(ctx context.Context, categories []domain.MediaCategory, includeAvatars bool, before *time.Time, limit int) (purgedDocs int, purgedPhotos int, bytesReclaimed int64, err error)
|
|
}
|
|
|
|
// BotService creates bot accounts on behalf of the admin. It mirrors the
|
|
// owner-scoped /newbot flow: a bot is a users row (is_bot=true) plus a bots row
|
|
// owned by ownerUserID, and the returned token is shown once to the operator.
|
|
type BotService interface {
|
|
CreateBot(ctx context.Context, ownerUserID int64, name, username string) (domain.User, string, error)
|
|
DeleteBot(ctx context.Context, botUserID int64) (domain.User, error)
|
|
// AdminExportBotToken returns a non-system bot's current token with no
|
|
// ownership check. Used by ExportBotToken; the token never enters the
|
|
// audit/replay record (see CommandResult.transientDetails).
|
|
AdminExportBotToken(ctx context.Context, botUserID int64) (string, error)
|
|
}
|
|
|
|
// EmojiService renders custom-emoji document animations for the admin emoji
|
|
// browser (Lottie JSON, TGS transparently decompressed).
|
|
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)
|
|
}
|
|
|
|
// CollectibleUsernamesService is the operator-facing slice of the collectible
|
|
// username use cases: the mint/transfer/revoke lifecycle plus the reads the
|
|
// admin panel explains an asset with. It is deliberately narrow -- the client
|
|
// facing toggle/reorder entry points stay out of the admin surface, because the
|
|
// editable slot and the row order belong to the peer, not to the operator.
|
|
type CollectibleUsernamesService interface {
|
|
Mint(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
|
|
Transfer(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
|
|
Revoke(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
|
|
Delete(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error)
|
|
Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error)
|
|
List(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
|
Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
|
}
|
|
|
|
// collectibleUsernameByIDLookup is the optional by-identity read. Stores that
|
|
// expose it answer a detail request in one round trip; the keyset fallback in
|
|
// CollectibleUsernameByID keeps a service without it correct.
|
|
type collectibleUsernameByIDLookup interface {
|
|
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
|
}
|
|
|
|
type Dependencies struct {
|
|
Commands CommandRepository
|
|
Restrictions RestrictionStore
|
|
Auth AuthService
|
|
Revoker AuthKeyRevoker
|
|
Users UsersService
|
|
UserNotifier UserNotifier
|
|
UserModerationNotifier UserModerationNotifier
|
|
FreezeNotifier AccountFreezeNotifier
|
|
Channels ChannelsService
|
|
ChannelNotifier ChannelNotifier
|
|
Messages MessagesService
|
|
Photos AvatarResolver
|
|
StickerSets StickerSetsService
|
|
GifCatalog GifCatalogService
|
|
Storage StorageService
|
|
Bots BotService
|
|
Emoji EmojiService
|
|
Moderation ModerationService
|
|
Usernames CollectibleUsernamesService
|
|
Verification VerificationService
|
|
// BotVerification is the third-party mechanism, wired separately from
|
|
// Verification: the two never read each other's state.
|
|
BotVerification BotVerificationService
|
|
// Account carries the login-email factor -- a separate app service from
|
|
// Users, since login email lives in account_passwords, not users.
|
|
Account AccountService
|
|
// Broadcast is the system-broadcast (777000) create/list/get surface.
|
|
Broadcast BroadcastService
|
|
Now func() time.Time
|
|
}
|
|
|
|
type Service struct {
|
|
commands CommandRepository
|
|
restrictions RestrictionStore
|
|
auth AuthService
|
|
revoker AuthKeyRevoker
|
|
users UsersService
|
|
userNotifier UserNotifier
|
|
userModerationNotifier UserModerationNotifier
|
|
freezeNotifier AccountFreezeNotifier
|
|
channels ChannelsService
|
|
channelNotifier ChannelNotifier
|
|
messages MessagesService
|
|
photos AvatarResolver
|
|
stickerSets StickerSetsService
|
|
gifCatalog GifCatalogService
|
|
storage StorageService
|
|
bots BotService
|
|
emoji EmojiService
|
|
moderation ModerationService
|
|
usernames CollectibleUsernamesService
|
|
verification VerificationService
|
|
botVerification BotVerificationService
|
|
account AccountService
|
|
broadcast BroadcastService
|
|
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.UserModerationNotifier != nil {
|
|
s.userModerationNotifier = deps.UserModerationNotifier
|
|
}
|
|
if deps.FreezeNotifier != nil {
|
|
s.freezeNotifier = deps.FreezeNotifier
|
|
}
|
|
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.Photos != nil {
|
|
s.photos = deps.Photos
|
|
}
|
|
if deps.StickerSets != nil {
|
|
s.stickerSets = deps.StickerSets
|
|
}
|
|
if deps.GifCatalog != nil {
|
|
s.gifCatalog = deps.GifCatalog
|
|
}
|
|
if deps.Storage != nil {
|
|
s.storage = deps.Storage
|
|
}
|
|
if deps.Bots != nil {
|
|
s.bots = deps.Bots
|
|
}
|
|
if deps.Emoji != nil {
|
|
s.emoji = deps.Emoji
|
|
}
|
|
if deps.Moderation != nil {
|
|
s.moderation = deps.Moderation
|
|
}
|
|
if deps.Usernames != nil {
|
|
s.usernames = deps.Usernames
|
|
}
|
|
if deps.Verification != nil {
|
|
s.verification = deps.Verification
|
|
}
|
|
if deps.BotVerification != nil {
|
|
s.botVerification = deps.BotVerification
|
|
}
|
|
if deps.Account != nil {
|
|
s.account = deps.Account
|
|
}
|
|
if deps.Broadcast != nil {
|
|
s.broadcast = deps.Broadcast
|
|
}
|
|
if deps.Now != nil {
|
|
s.now = deps.Now
|
|
}
|
|
if s.now == nil {
|
|
s.now = time.Now
|
|
}
|
|
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)
|
|
}
|
|
|
|
// CollectibleUsernames is the admin listing read. The filter is passed through
|
|
// unchanged: the use-case layer owns normalisation and the page bound, so the
|
|
// admin API and the RPC edge page the registry identically.
|
|
func (s *Service) CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
|
if s == nil || s.usernames == nil {
|
|
return nil, fmt.Errorf("collectible username dependency is not configured")
|
|
}
|
|
return s.usernames.List(ctx, filter)
|
|
}
|
|
|
|
// CollectibleUsername resolves one asset by name.
|
|
func (s *Service) CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error) {
|
|
if s == nil || s.usernames == nil {
|
|
return domain.CollectibleUsername{}, fmt.Errorf("collectible username dependency is not configured")
|
|
}
|
|
return s.usernames.Collectible(ctx, username)
|
|
}
|
|
|
|
// CollectibleUsernameByID resolves one asset by identity, which is how the admin
|
|
// panel links a row to its detail view.
|
|
//
|
|
// A service exposing the direct by-identity read is used as-is. Otherwise the
|
|
// bounded keyset listing answers it: the listing is ordered by descending id, so
|
|
// the single row taken before id+1 is the asset itself whenever it exists, and
|
|
// any other id proves the asset is gone.
|
|
func (s *Service) CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error) {
|
|
if s == nil || s.usernames == nil {
|
|
return domain.CollectibleUsername{}, fmt.Errorf("collectible username dependency is not configured")
|
|
}
|
|
if id <= 0 {
|
|
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
if lookup, ok := s.usernames.(collectibleUsernameByIDLookup); ok {
|
|
return lookup.CollectibleUsernameByID(ctx, id)
|
|
}
|
|
before := int64(0)
|
|
if id < math.MaxInt64 {
|
|
before = id + 1
|
|
}
|
|
items, err := s.usernames.List(ctx, domain.CollectibleUsernameFilter{BeforeID: before, Limit: 1})
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, err
|
|
}
|
|
if len(items) == 0 || items[0].ID != id {
|
|
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
return items[0], nil
|
|
}
|
|
|
|
// CollectibleUsernameTransfers returns one asset's provenance log, newest first.
|
|
func (s *Service) CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
|
if s == nil || s.usernames == nil {
|
|
return nil, fmt.Errorf("collectible username dependency is not configured")
|
|
}
|
|
return s.usernames.Transfers(ctx, collectibleID, limit)
|
|
}
|
|
|
|
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"`
|
|
// transientDetails are returned to the initiating caller only. They are
|
|
// deliberately excluded from JSON so credentials can never enter command
|
|
// replay or audit storage.
|
|
transientDetails map[string]any
|
|
}
|
|
|
|
type SetStickerSetArchivedRequest struct {
|
|
CommandMeta
|
|
SetID int64 `json:"set_id"`
|
|
Archived bool `json:"archived"`
|
|
}
|
|
|
|
type SetStickerSetSortOrderRequest struct {
|
|
CommandMeta
|
|
SetID int64 `json:"set_id"`
|
|
SortOrder int `json:"sort_order"`
|
|
}
|
|
|
|
type RenameStickerSetRequest struct {
|
|
CommandMeta
|
|
SetID int64 `json:"set_id"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
type DeleteStickerSetRequest struct {
|
|
CommandMeta
|
|
SetID int64 `json:"set_id"`
|
|
}
|
|
|
|
type CreateStickerSetRequest struct {
|
|
CommandMeta
|
|
Title string `json:"title"`
|
|
ShortName string `json:"short_name"`
|
|
Kind string `json:"kind"`
|
|
Emoji string `json:"emoji"`
|
|
Keywords string `json:"keywords,omitempty"`
|
|
FileName string `json:"file_name"`
|
|
Data []byte `json:"-"`
|
|
}
|
|
|
|
type AddStickerToSetRequest struct {
|
|
CommandMeta
|
|
SetID int64 `json:"set_id"`
|
|
Emoji string `json:"emoji"`
|
|
Keywords string `json:"keywords,omitempty"`
|
|
FileName string `json:"file_name"`
|
|
Data []byte `json:"-"`
|
|
}
|
|
|
|
type RemoveStickerFromSetRequest struct {
|
|
CommandMeta
|
|
SetID int64 `json:"set_id"`
|
|
DocumentID int64 `json:"document_id"`
|
|
}
|
|
|
|
type CreateGifCatalogEntryRequest struct {
|
|
CommandMeta
|
|
Title string `json:"title"`
|
|
FileName string `json:"file_name"`
|
|
Data []byte `json:"-"`
|
|
ContentSHA256 string `json:"content_sha256,omitempty"`
|
|
}
|
|
|
|
type SetGifCatalogEnabledRequest struct {
|
|
CommandMeta
|
|
ID int64 `json:"id"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
type SetGifCatalogSortOrderRequest struct {
|
|
CommandMeta
|
|
ID int64 `json:"id"`
|
|
SortOrder int `json:"sort_order"`
|
|
}
|
|
|
|
type SetGifCatalogCategoryRequest struct {
|
|
CommandMeta
|
|
ID int64 `json:"id"`
|
|
Category string `json:"category"`
|
|
}
|
|
|
|
type AutoCategorizeGifCatalogRequest struct {
|
|
CommandMeta
|
|
}
|
|
|
|
type DeleteUncategorizedGifsRequest struct {
|
|
CommandMeta
|
|
}
|
|
|
|
type DeleteGifCatalogEntryRequest struct {
|
|
CommandMeta
|
|
ID int64 `json:"id"`
|
|
}
|
|
|
|
// ManualPurgeStorageRequest is the manual storage purge action's request.
|
|
// Categories are the admin UI's category keys (see manualPurgeCategoryKeys)
|
|
// -- "photo", "video", "round_video", "gif", "music", "voice", "file".
|
|
// "avatar" is not a category key; it is the separate IncludeAvatars flag,
|
|
// since Avatar is not a domain.MediaCategory (see
|
|
// files.Service.ManualPurge's doc comment). CreatedBefore is an optional
|
|
// ISO-8601/RFC3339 cutoff -- nil (the field omitted or JSON null) means no
|
|
// age filter at all: every document/photo in the selected categories is
|
|
// purged, regardless of age.
|
|
type ManualPurgeStorageRequest struct {
|
|
CommandMeta
|
|
Categories []string `json:"categories"`
|
|
IncludeAvatars bool `json:"include_avatars,omitempty"`
|
|
CreatedBefore *time.Time `json:"created_before,omitempty"`
|
|
}
|
|
|
|
type SetAccountFrozenRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
Frozen bool `json:"frozen"`
|
|
Until time.Time `json:"freeze_until,omitempty"`
|
|
AppealURL string `json:"freeze_appeal_url,omitempty"`
|
|
}
|
|
|
|
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 SetUserFlagsRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
Scam bool `json:"scam"`
|
|
Fake bool `json:"fake"`
|
|
}
|
|
|
|
type SetChannelFlagsRequest struct {
|
|
CommandMeta
|
|
ChannelID int64 `json:"channel_id"`
|
|
Scam bool `json:"scam"`
|
|
Fake bool `json:"fake"`
|
|
}
|
|
|
|
type SetSupportRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
Support bool `json:"support"`
|
|
}
|
|
|
|
type SetUsernameRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
// SetProfileRequest updates first/last name. Both are always sent (not
|
|
// pointer/omitempty): the admin form always shows and submits both fields
|
|
// together, so there is no "leave unset" case to represent here.
|
|
type SetProfileRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
}
|
|
|
|
type SetPhoneRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
Phone string `json:"phone"`
|
|
}
|
|
|
|
// SetLoginEmailRequest force-sets (or, if Email is empty, clears) a user's
|
|
// login/signup email.
|
|
type SetLoginEmailRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type SetAccountAvatarRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
FileName string `json:"file_name"`
|
|
Data []byte `json:"-"`
|
|
}
|
|
|
|
type SetAccountAvatarVideoRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
FileName string `json:"file_name"`
|
|
VideoStartTs float64 `json:"video_start_ts"`
|
|
Data []byte `json:"-"`
|
|
}
|
|
|
|
type SetChannelAvatarRequest struct {
|
|
CommandMeta
|
|
ChannelID int64 `json:"channel_id"`
|
|
FileName string `json:"file_name"`
|
|
Data []byte `json:"-"`
|
|
}
|
|
|
|
type SetChannelUsernameRequest struct {
|
|
CommandMeta
|
|
ChannelID int64 `json:"channel_id"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
type PeerColorInput struct {
|
|
ForProfile bool `json:"for_profile"`
|
|
HasColor bool `json:"has_color"`
|
|
Color int `json:"color"`
|
|
BackgroundEmojiID int64 `json:"background_emoji_id,string"`
|
|
}
|
|
|
|
type SetUserColorRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
PeerColorInput
|
|
}
|
|
|
|
type SetChannelColorRequest struct {
|
|
CommandMeta
|
|
ChannelID int64 `json:"channel_id"`
|
|
PeerColorInput
|
|
}
|
|
|
|
type EmojiStatusInput struct {
|
|
DocumentID int64 `json:"document_id,string"`
|
|
Until int `json:"until"`
|
|
}
|
|
|
|
type SetUserEmojiStatusRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
EmojiStatusInput
|
|
}
|
|
|
|
type SetChannelEmojiStatusRequest struct {
|
|
CommandMeta
|
|
ChannelID int64 `json:"channel_id"`
|
|
EmojiStatusInput
|
|
}
|
|
|
|
type SetChannelSettingsRequest struct {
|
|
CommandMeta
|
|
ChannelID int64 `json:"channel_id"`
|
|
Gigagroup *bool `json:"gigagroup,omitempty"`
|
|
AntiSpam *bool `json:"antispam,omitempty"`
|
|
ParticipantsHidden *bool `json:"participants_hidden,omitempty"`
|
|
NoForwards *bool `json:"noforwards,omitempty"`
|
|
JoinToSend *bool `json:"join_to_send,omitempty"`
|
|
JoinRequest *bool `json:"join_request,omitempty"`
|
|
SlowmodeSeconds *int `json:"slowmode_seconds,omitempty"`
|
|
}
|
|
|
|
type CreateBotRequest struct {
|
|
CommandMeta
|
|
OwnerUserID int64 `json:"owner_user_id"`
|
|
Name string `json:"name"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
type DeleteBotRequest struct {
|
|
CommandMeta
|
|
BotUserID int64 `json:"bot_user_id"`
|
|
}
|
|
|
|
// CreateBroadcastRequest's UserIDs carries the operator-picked recipient
|
|
// list for TargetMode "selected" only. For TargetMode "all", UserIDs must be
|
|
// empty: the admin service snapshots the current eligible user set itself
|
|
// and the broadcast worker enumerates it incrementally, so "every user"
|
|
// never has to cross the admin HTTP boundary as an explicit id list.
|
|
type CreateBroadcastRequest struct {
|
|
CommandMeta
|
|
Message string `json:"message"`
|
|
TargetMode string `json:"target_mode"`
|
|
UserIDs []int64 `json:"user_ids"`
|
|
}
|
|
|
|
type ExportBotTokenRequest struct {
|
|
CommandMeta
|
|
BotUserID int64 `json:"bot_user_id"`
|
|
}
|
|
|
|
// MintCollectibleUsernameRequest mints a collectible username asset. At most one
|
|
// of OwnerUserID / OwnerChannelID may be set: neither mints into the operator
|
|
// vault, one assigns the asset to that holder in the same command.
|
|
//
|
|
// Amount and CryptoAmount are minor units (nanotons for TON), so they cross the
|
|
// JSON boundary as decimal strings and stay exact. PurchaseDate is an optional
|
|
// Unix timestamp; zero is stamped with the command clock.
|
|
type MintCollectibleUsernameRequest struct {
|
|
CommandMeta
|
|
Username string `json:"username"`
|
|
OwnerUserID int64 `json:"owner_user_id,string,omitempty"`
|
|
OwnerChannelID int64 `json:"owner_channel_id,string,omitempty"`
|
|
Currency string `json:"currency"`
|
|
Amount int64 `json:"amount,string"`
|
|
CryptoCurrency string `json:"crypto_currency,omitempty"`
|
|
CryptoAmount int64 `json:"crypto_amount,string,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
PurchaseDate int64 `json:"purchase_date,omitempty"`
|
|
}
|
|
|
|
// TransferCollectibleUsernameRequest moves an asset out of the vault or between
|
|
// holders. Exactly one of ToUserID / ToChannelID identifies the new holder.
|
|
type TransferCollectibleUsernameRequest struct {
|
|
CommandMeta
|
|
Username string `json:"username"`
|
|
ToUserID int64 `json:"to_user_id,string,omitempty"`
|
|
ToChannelID int64 `json:"to_channel_id,string,omitempty"`
|
|
}
|
|
|
|
// RevokeCollectibleUsernameRequest returns an asset to the vault, or retires it
|
|
// permanently when Burn is set.
|
|
type RevokeCollectibleUsernameRequest struct {
|
|
CommandMeta
|
|
Username string `json:"username"`
|
|
ExpectedOwnerUserID int64 `json:"expected_owner_user_id,string,omitempty"`
|
|
Burn bool `json:"burn"`
|
|
}
|
|
|
|
// DeleteCollectibleUsernameRequest erases a collectible asset entirely. Unlike a
|
|
// burn, which retires the asset and keeps its provenance, this drops the record
|
|
// and frees the name for a fresh issue -- the escape hatch for a mistaken mint.
|
|
type DeleteCollectibleUsernameRequest struct {
|
|
CommandMeta
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// AccountFreeze returns the durable account-level freeze state. A missing row
|
|
// is the only non-frozen default; invalid active rows are rejected by the
|
|
// store/schema instead of normalized on read.
|
|
func (s *Service) AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
|
|
if s == nil || s.restrictions == nil || userID == 0 {
|
|
return domain.AccountFreeze{}, false, nil
|
|
}
|
|
freeze, found, err := s.restrictions.GetAccountFreeze(ctx, userID)
|
|
if err != nil || !found {
|
|
return freeze, found, err
|
|
}
|
|
if err := validateAccountFreeze(freeze); err != nil {
|
|
return domain.AccountFreeze{}, false, fmt.Errorf("invalid durable account freeze for user %d: %w", userID, err)
|
|
}
|
|
return freeze, true, nil
|
|
}
|
|
|
|
// AccountFreezes is the bounded-query projection API used by user hydration.
|
|
// Production stores use array batches; lightweight test stores keep the exact
|
|
// same semantics through the single-row fallback.
|
|
func (s *Service) AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) {
|
|
out := make(map[int64]domain.AccountFreeze)
|
|
if s == nil || s.restrictions == nil || len(userIDs) == 0 {
|
|
return out, nil
|
|
}
|
|
ids := uniqueFreezeUserIDs(userIDs)
|
|
if batch, ok := s.restrictions.(accountFreezeBatchStore); ok {
|
|
const batchSize = 1000
|
|
for start := 0; start < len(ids); start += batchSize {
|
|
end := min(start+batchSize, len(ids))
|
|
items, err := batch.GetAccountFreezes(ctx, ids[start:end])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for id, freeze := range items {
|
|
if err := validateAccountFreeze(freeze); err != nil {
|
|
return nil, fmt.Errorf("invalid durable account freeze for user %d: %w", id, err)
|
|
}
|
|
if freeze.Frozen {
|
|
out[id] = freeze
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
for _, id := range ids {
|
|
freeze, found, err := s.AccountFreeze(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if found && freeze.Frozen {
|
|
out[id] = freeze
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func uniqueFreezeUserIDs(userIDs []int64) []int64 {
|
|
out := make([]int64, 0, len(userIDs))
|
|
seen := make(map[int64]struct{}, len(userIDs))
|
|
for _, id := range userIDs {
|
|
if id == 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Service) ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error) {
|
|
store, ok := s.restrictions.(accountFreezeNotificationStore)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return store.ClaimAccountFreezeNotifications(ctx, now, limit, lease)
|
|
}
|
|
|
|
func (s *Service) CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error {
|
|
store, ok := s.restrictions.(accountFreezeNotificationStore)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return store.CompleteAccountFreezeNotification(ctx, id, version, now)
|
|
}
|
|
|
|
func validateAccountFreeze(freeze domain.AccountFreeze) error {
|
|
if !freeze.Frozen {
|
|
if !freeze.Since.IsZero() || !freeze.Until.IsZero() || freeze.AppealURL != "" {
|
|
return fmt.Errorf("inactive freeze retains client-visible state")
|
|
}
|
|
return nil
|
|
}
|
|
if freeze.Since.IsZero() || freeze.Until.IsZero() || !freeze.Until.After(freeze.Since) ||
|
|
freeze.Since.Unix() <= 0 || freeze.Until.Unix() > math.MaxInt32 {
|
|
return fmt.Errorf("active freeze has invalid since/until")
|
|
}
|
|
if len(freeze.AppealURL) > maxFreezeAppealURLLength {
|
|
return fmt.Errorf("active freeze appeal URL is too long")
|
|
}
|
|
parsed, err := url.ParseRequestURI(freeze.AppealURL)
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return fmt.Errorf("active freeze has invalid appeal URL")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) CanSendMessages(ctx context.Context, userID int64) error {
|
|
freeze, found, err := s.AccountFreeze(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if found && freeze.Frozen {
|
|
return domain.ErrUserFrozen
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) SetAccountFrozen(ctx context.Context, req SetAccountFrozenRequest) (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")
|
|
}
|
|
now := s.now().UTC()
|
|
appealURL := strings.TrimSpace(req.AppealURL)
|
|
if req.Frozen {
|
|
if req.Until.IsZero() || req.Until.Unix() > math.MaxInt32 {
|
|
return CommandResult{}, fmt.Errorf("freeze_until must be a non-zero int32 Unix timestamp")
|
|
}
|
|
if len(appealURL) > maxFreezeAppealURLLength {
|
|
return CommandResult{}, fmt.Errorf("freeze_appeal_url must be <= %d bytes", maxFreezeAppealURLLength)
|
|
}
|
|
parsed, err := url.ParseRequestURI(appealURL)
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return CommandResult{}, fmt.Errorf("freeze_appeal_url must be an absolute HTTP(S) URL")
|
|
}
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountFrozen, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
|
// Keep this time-relative check inside runCommand: a completed command ID
|
|
// must remain replayable after its deadline, while a new stale request is
|
|
// recorded as failed and cannot mutate the restriction row.
|
|
if req.Frozen && !req.Until.After(now) {
|
|
return CommandResult{}, fmt.Errorf("freeze_until must be in the future")
|
|
}
|
|
prev, found, err := s.restrictions.GetAccountFreeze(ctx, req.UserID)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
next := domain.AccountFreeze{
|
|
UserID: req.UserID,
|
|
Frozen: req.Frozen,
|
|
Reason: req.Reason,
|
|
Actor: req.Actor,
|
|
CommandID: req.CommandID,
|
|
}
|
|
if req.Frozen {
|
|
next.Since = now
|
|
if found && prev.Frozen {
|
|
next.Since = prev.Since
|
|
}
|
|
next.Until = req.Until.UTC()
|
|
next.AppealURL = appealURL
|
|
if !next.Until.After(next.Since) {
|
|
return CommandResult{}, fmt.Errorf("freeze_until must be after freeze_since")
|
|
}
|
|
}
|
|
wouldChange := !found || prev.Frozen != next.Frozen ||
|
|
!prev.Since.Equal(next.Since) || !prev.Until.Equal(next.Until) ||
|
|
prev.AppealURL != next.AppealURL
|
|
details := map[string]any{
|
|
"previous_frozen": found && prev.Frozen,
|
|
"new_frozen": req.Frozen,
|
|
"would_change": wouldChange,
|
|
}
|
|
if req.Frozen {
|
|
details["freeze_since"] = next.Since.Format(time.RFC3339)
|
|
details["freeze_until"] = next.Until.Format(time.RFC3339)
|
|
details["freeze_appeal_url"] = next.AppealURL
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.restrictions.SetAccountFreeze(ctx, next)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated_at"] = updated.UpdatedAt.UTC().Format(time.RFC3339)
|
|
details["version"] = updated.Version
|
|
if err := s.notifyAccountFreezeChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "account freeze 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
|
|
})
|
|
}
|
|
|
|
// SetUserFlags sets or clears the scam/fake moderation flags on a user (bots
|
|
// reuse the same path). Both flags are applied together from the desired state.
|
|
func (s *Service) SetUserFlags(ctx context.Context, req SetUserFlagsRequest) (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")
|
|
}
|
|
if req.Scam && req.Fake {
|
|
return CommandResult{}, domain.ErrPeerModerationFlagsInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetUserFlags, 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_scam": u.Scam, "previous_fake": u.Fake,
|
|
"new_scam": req.Scam, "new_fake": req.Fake,
|
|
"would_change": u.Scam != req.Scam || u.Fake != req.Fake,
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.users.SetScamFake(ctx, req.UserID, req.Scam, req.Fake)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated_scam"] = updated.Scam
|
|
details["updated_fake"] = updated.Fake
|
|
if err := s.notifyUserModerationFlagsChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "user flags updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetSupport sets or clears the official-support flag on a user.
|
|
func (s *Service) SetSupport(ctx context.Context, req SetSupportRequest) (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, ActionSetSupport, 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_support": u.Support, "new_support": req.Support, "would_change": u.Support != req.Support}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.users.SetSupport(ctx, req.UserID, req.Support)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated_support"] = updated.Support
|
|
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "support updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) SetUsername(ctx context.Context, req SetUsernameRequest) (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")
|
|
}
|
|
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
|
|
req.Username = username
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetUsername, 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_username": u.Username, "new_username": username}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.users.UpdateUsername(ctx, req.UserID, username)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated_username"] = updated.Username
|
|
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "username updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetProfile force-sets a user's first and last name.
|
|
func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (CommandResult, error) {
|
|
if req.UserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
|
}
|
|
if domain.IsSystemUserID(req.UserID) {
|
|
return CommandResult{}, fmt.Errorf("system user profile cannot be changed")
|
|
}
|
|
if s == nil || s.users == nil {
|
|
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
|
}
|
|
req.FirstName = strings.TrimSpace(req.FirstName)
|
|
req.LastName = strings.TrimSpace(req.LastName)
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetProfile, 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_first_name": u.FirstName,
|
|
"previous_last_name": u.LastName,
|
|
"new_first_name": req.FirstName,
|
|
"new_last_name": req.LastName,
|
|
"would_change": u.FirstName != req.FirstName || u.LastName != req.LastName,
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "profile update validated", Details: details}, nil
|
|
}
|
|
updated, err := s.users.UpdateProfile(ctx, req.UserID, domain.UserProfileUpdate{
|
|
FirstName: req.FirstName, HasFirstName: true,
|
|
LastName: req.LastName, HasLastName: true,
|
|
})
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "profile updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetPhone force-sets a user's phone number. Rejects a collision with
|
|
// another account's phone (checked by the users service before writing,
|
|
// backed by the users_phone_unique_idx constraint as well).
|
|
func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (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")
|
|
}
|
|
req.Phone = domain.NormalizePhone(req.Phone)
|
|
if !domain.ValidPhone(req.Phone) {
|
|
return CommandResult{}, domain.ErrPhoneNumberInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetPhone, 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 || domain.IsSystemUserID(u.ID) {
|
|
return CommandResult{}, domain.ErrPhoneChangeForbidden
|
|
}
|
|
details := map[string]any{
|
|
"previous_phone": u.Phone,
|
|
"new_phone": req.Phone,
|
|
"would_change": u.Phone != req.Phone,
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "phone update validated", Details: details}, nil
|
|
}
|
|
updated, err := s.users.SetPhone(ctx, req.UserID, req.Phone)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["changed"] = u.Phone != updated.Phone
|
|
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "phone updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetLoginEmail force-sets (or, if Email is empty, clears) a user's
|
|
// login/signup email. Rejects a collision with another account's login
|
|
// email (checked by the account service before writing, backed by the
|
|
// account_passwords_login_email_lower_unique_idx constraint as well).
|
|
func (s *Service) SetLoginEmail(ctx context.Context, req SetLoginEmailRequest) (CommandResult, error) {
|
|
if req.UserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
|
}
|
|
if s == nil || s.users == nil || s.account == nil {
|
|
return CommandResult{}, fmt.Errorf("admin account dependencies are not configured")
|
|
}
|
|
req.Email = strings.TrimSpace(req.Email)
|
|
if req.Email != "" && !s.account.ValidLoginEmail(req.Email) {
|
|
return CommandResult{}, domain.ErrEmailInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetLoginEmail, 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 || domain.IsSystemUserID(u.ID) {
|
|
return CommandResult{}, domain.ErrEmailInvalid
|
|
}
|
|
previous, _, err := s.account.LoginEmail(ctx, req.UserID)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details := map[string]any{
|
|
"previous_login_email": previous,
|
|
"new_login_email": req.Email,
|
|
"would_change": !strings.EqualFold(previous, req.Email),
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "login email update validated", Details: details}, nil
|
|
}
|
|
if req.Email == "" {
|
|
err = s.account.ClearLoginEmail(ctx, req.UserID)
|
|
} else {
|
|
err = s.account.SetLoginEmail(ctx, req.UserID, req.Email)
|
|
}
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
message := "login email updated"
|
|
if req.Email == "" {
|
|
message = "login email cleared"
|
|
}
|
|
return CommandResult{Message: message, Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetAccountAvatar force-sets a user's current profile photo from raw
|
|
// uploaded image bytes, reusing the same avatar rendition pipeline
|
|
// (s/a/c sizes) as photos.uploadProfilePhoto.
|
|
func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequest) (CommandResult, error) {
|
|
if req.UserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
|
}
|
|
if domain.IsSystemUserID(req.UserID) {
|
|
return CommandResult{}, fmt.Errorf("system user avatar cannot be changed")
|
|
}
|
|
if s == nil || s.users == nil || s.photos == nil {
|
|
return CommandResult{}, fmt.Errorf("admin avatar dependencies are not configured")
|
|
}
|
|
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
|
|
return CommandResult{}, domain.ErrPhotoInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatar, 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{"file_name": req.FileName, "bytes": len(req.Data), "bot": u.Bot}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "avatar update validated", Details: details}, nil
|
|
}
|
|
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, req.UserID)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(s.now().Unix())); err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["photo_id"] = strconv.FormatInt(photo.ID, 10)
|
|
if err := s.notifyUserChanged(ctx, u); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "avatar updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetAccountAvatarVideo is SetAccountAvatar's video counterpart: force-sets a
|
|
// user's current profile photo from raw uploaded animated-video bytes,
|
|
// reusing the same still-frame + s/a/c rendition pipeline as
|
|
// photos.uploadProfilePhoto's video path. Unlike SetAccountAvatar's
|
|
// ValidateAvatarUpload (an image-header decode), video bytes are only
|
|
// size-bounded here -- the admin console does not decode video containers.
|
|
func (s *Service) SetAccountAvatarVideo(ctx context.Context, req SetAccountAvatarVideoRequest) (CommandResult, error) {
|
|
if req.UserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
|
}
|
|
if domain.IsSystemUserID(req.UserID) {
|
|
return CommandResult{}, fmt.Errorf("system user avatar cannot be changed")
|
|
}
|
|
if s == nil || s.users == nil || s.photos == nil {
|
|
return CommandResult{}, fmt.Errorf("admin avatar dependencies are not configured")
|
|
}
|
|
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarVideoBytes {
|
|
return CommandResult{}, domain.ErrPhotoInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatarVideo, 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{"file_name": req.FileName, "bytes": len(req.Data), "bot": u.Bot}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "avatar video update validated", Details: details}, nil
|
|
}
|
|
photo, err := s.photos.CreateAvatarVideoFromBytes(ctx, req.Data, req.UserID, req.VideoStartTs)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(s.now().Unix())); err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["photo_id"] = strconv.FormatInt(photo.ID, 10)
|
|
if err := s.notifyUserChanged(ctx, u); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "avatar video updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetChannelAvatar force-sets a channel's avatar from raw uploaded image
|
|
// bytes, reusing the same avatar rendition pipeline (s/a/c sizes) as
|
|
// SetAccountAvatar, but attaching the resulting photo directly to the
|
|
// channel row (photo_id) through the permission-check-free admin path
|
|
// instead of profile_photos history.
|
|
func (s *Service) SetChannelAvatar(ctx context.Context, req SetChannelAvatarRequest) (CommandResult, error) {
|
|
if req.ChannelID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("channel_id is required")
|
|
}
|
|
if s == nil || s.channels == nil || s.photos == nil {
|
|
return CommandResult{}, fmt.Errorf("admin channel avatar dependencies are not configured")
|
|
}
|
|
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
|
|
return CommandResult{}, domain.ErrPhotoInvalid
|
|
}
|
|
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelAvatar, 0, target, req, func() (CommandResult, error) {
|
|
channel, err := s.channels.GetChannelByID(ctx, req.ChannelID)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
if channel.Deleted || channel.Monoforum || (!channel.Broadcast && !channel.Megagroup) {
|
|
return CommandResult{}, domain.ErrChannelInvalid
|
|
}
|
|
details := map[string]any{
|
|
"file_name": req.FileName,
|
|
"bytes": len(req.Data),
|
|
"previous_photo_id": strconv.FormatInt(channel.PhotoID, 10),
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "channel avatar update validated", Details: details}, nil
|
|
}
|
|
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, 0)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
updated, err := s.channels.AdminSetPhoto(ctx, req.ChannelID, photo)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["photo_id"] = strconv.FormatInt(photo.ID, 10)
|
|
if err := s.notifyChannelChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "avatar updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// ChannelAvatar returns a channel's current avatar bytes and detected MIME
|
|
// type. Unlike a user's profile photo (tracked via profile_photos history),
|
|
// a channel's current photo is denormalized directly on the channel row as
|
|
// photo_id, so this resolves that id through GetPhoto instead of
|
|
// CurrentProfilePhotoKind.
|
|
func (s *Service) ChannelAvatar(ctx context.Context, channelID int64) ([]byte, string, bool, error) {
|
|
if s == nil || s.photos == nil || s.channels == nil || channelID <= 0 {
|
|
return nil, "", false, nil
|
|
}
|
|
channel, err := s.channels.GetChannelByID(ctx, channelID)
|
|
if err != nil {
|
|
return nil, "", false, err
|
|
}
|
|
if channel.PhotoID == 0 {
|
|
return nil, "", false, nil
|
|
}
|
|
photo, found, err := s.photos.GetPhoto(ctx, channel.PhotoID)
|
|
if err != nil || !found {
|
|
return nil, "", found, err
|
|
}
|
|
return s.avatarBytes(ctx, photo)
|
|
}
|
|
|
|
// SetUserColor force-sets or clears a user's name/profile color.
|
|
func (s *Service) SetUserColor(ctx context.Context, req SetUserColorRequest) (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")
|
|
}
|
|
color := domain.PeerColor{HasColor: req.HasColor, Color: req.Color, BackgroundEmojiID: req.BackgroundEmojiID}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetUserColor, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"for_profile": req.ForProfile, "has_color": req.HasColor, "color": req.Color}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.users.UpdateColor(ctx, req.UserID, req.ForProfile, color)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "user color updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetUserEmojiStatus force-sets or clears (document_id=0) a user's emoji status.
|
|
func (s *Service) SetUserEmojiStatus(ctx context.Context, req SetUserEmojiStatusRequest) (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")
|
|
}
|
|
status := domain.UserEmojiStatus{DocumentID: req.DocumentID, Until: req.Until}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetUserEmojiStatus, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"document_id": strconv.FormatInt(req.DocumentID, 10), "until": req.Until}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.users.UpdateEmojiStatus(ctx, req.UserID, status)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "user emoji status updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// CreateBot provisions a new bot account owned by ownerUserID. The dry-run stage
|
|
// only validates the display name and username; the confirm stage creates the
|
|
// users+bots rows and returns the freshly minted token in the result details so
|
|
// the operator can copy it once.
|
|
func (s *Service) CreateBot(ctx context.Context, req CreateBotRequest) (CommandResult, error) {
|
|
if s == nil || s.bots == nil {
|
|
return CommandResult{}, fmt.Errorf("admin bot dependency is not configured")
|
|
}
|
|
if req.OwnerUserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("owner_user_id is required")
|
|
}
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" || len([]rune(name)) > domain.MaxBotNameLength {
|
|
return CommandResult{}, domain.ErrBotNameInvalid
|
|
}
|
|
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
|
|
if !domain.ValidBotUsername(username) {
|
|
return CommandResult{}, domain.ErrBotUsernameInvalid
|
|
}
|
|
req.Name = name
|
|
req.Username = username
|
|
return s.runCommand(ctx, req.CommandMeta, ActionCreateBot, req.OwnerUserID, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{
|
|
"owner_user_id": req.OwnerUserID,
|
|
"name": name,
|
|
"username": username,
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "bot creation validated", Details: details}, nil
|
|
}
|
|
bot, token, err := s.bots.CreateBot(ctx, req.OwnerUserID, name, username)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["bot_user_id"] = bot.ID
|
|
if err := s.notifyUserChanged(ctx, bot); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{
|
|
Message: "bot created",
|
|
Details: details,
|
|
transientDetails: map[string]any{"token": token},
|
|
}, nil
|
|
})
|
|
}
|
|
|
|
// DeleteBot permanently removes a user-created bot. The dry-run stage verifies
|
|
// the target is a non-system bot; the confirm stage tombstones the account and
|
|
// invalidates its token. System bots are rejected outright.
|
|
func (s *Service) DeleteBot(ctx context.Context, req DeleteBotRequest) (CommandResult, error) {
|
|
if s == nil || s.bots == nil {
|
|
return CommandResult{}, fmt.Errorf("admin bot dependency is not configured")
|
|
}
|
|
if req.BotUserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("bot_user_id is required")
|
|
}
|
|
if domain.IsSystemUserID(req.BotUserID) {
|
|
return CommandResult{}, fmt.Errorf("system bots cannot be deleted")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionDeleteBot, req.BotUserID, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"bot_user_id": req.BotUserID}
|
|
if s.users != nil {
|
|
u, found, err := s.users.AdminUser(ctx, req.BotUserID)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
if !found || !u.Bot {
|
|
return CommandResult{}, domain.ErrBotNotFound
|
|
}
|
|
details["username"] = u.Username
|
|
details["name"] = u.FirstName
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "bot deletion validated", Details: details}, nil
|
|
}
|
|
deleted, err := s.bots.DeleteBot(ctx, req.BotUserID)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["deleted"] = true
|
|
if err := s.notifyUserChanged(ctx, deleted); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "bot deleted", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// CreateBroadcast enqueues a system-broadcast (a message from
|
|
// domain.OfficialSystemUserID) to all users or an already-resolved
|
|
// "selected" recipient list. Both recipient enumeration (for "all") and
|
|
// delivery happen out-of-band via the broadcast worker -- the command
|
|
// completes as soon as the campaign is snapshotted, never waiting on however
|
|
// many sends that implies.
|
|
func (s *Service) CreateBroadcast(ctx context.Context, req CreateBroadcastRequest) (CommandResult, error) {
|
|
if s == nil || s.broadcast == nil {
|
|
return CommandResult{}, fmt.Errorf("admin broadcast dependency is not configured")
|
|
}
|
|
message := strings.TrimSpace(req.Message)
|
|
if message == "" {
|
|
return CommandResult{}, domain.ErrBroadcastMessageEmpty
|
|
}
|
|
if len(message) > domain.MaxBroadcastMessageBytes {
|
|
return CommandResult{}, domain.ErrBroadcastMessageTooLong
|
|
}
|
|
targetMode := domain.BroadcastTargetMode(req.TargetMode)
|
|
switch targetMode {
|
|
case domain.BroadcastTargetAll:
|
|
if len(req.UserIDs) != 0 {
|
|
return CommandResult{}, domain.ErrBroadcastInvalid
|
|
}
|
|
case domain.BroadcastTargetSelected:
|
|
if len(req.UserIDs) == 0 {
|
|
return CommandResult{}, domain.ErrBroadcastNoRecipients
|
|
}
|
|
if len(req.UserIDs) > domain.MaxBroadcastSelectedRecipients {
|
|
return CommandResult{}, domain.ErrBroadcastInvalid
|
|
}
|
|
default:
|
|
return CommandResult{}, domain.ErrBroadcastInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionCreateBroadcast, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{
|
|
"target_mode": string(targetMode),
|
|
"recipient_count": len(req.UserIDs),
|
|
"message_preview": truncateBroadcastPreview(message),
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "broadcast validated", Details: details}, nil
|
|
}
|
|
created, err := s.broadcast.Create(ctx, message, targetMode, req.UserIDs, req.CommandMeta.Actor)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["broadcast_id"] = created.ID
|
|
details["target_count"] = created.TargetCount
|
|
return CommandResult{Message: "broadcast created", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func truncateBroadcastPreview(message string) string {
|
|
const maxPreview = 120
|
|
r := []rune(message)
|
|
if len(r) <= maxPreview {
|
|
return message
|
|
}
|
|
return string(r[:maxPreview]) + "…"
|
|
}
|
|
|
|
// ExportBotToken returns a non-system bot's current token (unrotated) via the
|
|
// audited runCommand wrapper. Like CreateBot's token, it travels only in
|
|
// transientDetails -- excluded from the stored/replayed command JSON so it
|
|
// never lands in audit storage. The admin console's own UI additionally never
|
|
// renders this token on screen; it copies the response straight to the
|
|
// clipboard.
|
|
func (s *Service) ExportBotToken(ctx context.Context, req ExportBotTokenRequest) (CommandResult, error) {
|
|
if s == nil || s.bots == nil {
|
|
return CommandResult{}, fmt.Errorf("admin bot dependency is not configured")
|
|
}
|
|
if req.BotUserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("bot_user_id is required")
|
|
}
|
|
if domain.IsSystemUserID(req.BotUserID) {
|
|
return CommandResult{}, fmt.Errorf("system bots have no exportable token")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionExportBotToken, req.BotUserID, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"bot_user_id": req.BotUserID}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "token export validated", Details: details}, nil
|
|
}
|
|
token, err := s.bots.AdminExportBotToken(ctx, req.BotUserID)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
return CommandResult{
|
|
Message: "token exported",
|
|
Details: details,
|
|
transientDetails: map[string]any{"token": token},
|
|
}, nil
|
|
})
|
|
}
|
|
|
|
// MintCollectibleUsername creates a collectible username asset and optionally
|
|
// assigns it in the same command. Shape validation runs before the command is
|
|
// journalled; occupancy is checked inside it, so a dry-run reports a taken name
|
|
// without minting and a replay of a completed command stays idempotent.
|
|
func (s *Service) MintCollectibleUsername(ctx context.Context, req MintCollectibleUsernameRequest) (CommandResult, error) {
|
|
if s == nil || s.usernames == nil {
|
|
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
if !domain.ValidCollectibleUsername(req.Username) {
|
|
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
|
}
|
|
owner, err := collectibleOwnerPeer(req.OwnerUserID, req.OwnerChannelID)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
|
|
req.CryptoCurrency = strings.ToUpper(strings.TrimSpace(req.CryptoCurrency))
|
|
if err := domain.ValidateCollectibleAmounts(req.Currency, req.Amount, req.CryptoCurrency, req.CryptoAmount); err != nil {
|
|
return CommandResult{}, codedError(CodeCollectibleCurrencyInvalid, err)
|
|
}
|
|
req.URL = strings.TrimSpace(req.URL)
|
|
if len(req.URL) > domain.MaxCollectibleUsernameURLLength {
|
|
return CommandResult{}, fmt.Errorf("url must be <= %d bytes", domain.MaxCollectibleUsernameURLLength)
|
|
}
|
|
purchase, err := collectiblePurchaseDate(req.PurchaseDate, s.now)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
req.PurchaseDate = purchase.Unix()
|
|
return s.runCommand(ctx, req.CommandMeta, ActionMintCollectibleUsername, req.OwnerUserID, owner, req, func() (CommandResult, error) {
|
|
details := map[string]any{
|
|
"username": req.Username,
|
|
"owner_type": string(owner.Type),
|
|
"owner_id": strconv.FormatInt(owner.ID, 10),
|
|
"currency": req.Currency,
|
|
"amount": strconv.FormatInt(req.Amount, 10),
|
|
"crypto_currency": req.CryptoCurrency,
|
|
"crypto_amount": strconv.FormatInt(req.CryptoAmount, 10),
|
|
"purchase_date": purchase.Format(time.RFC3339),
|
|
"url": req.URL,
|
|
}
|
|
existing, err := s.usernames.Collectible(ctx, req.Username)
|
|
switch {
|
|
case err == nil:
|
|
details["existing_collectible_id"] = strconv.FormatInt(existing.ID, 10)
|
|
details["existing_status"] = string(existing.Status)
|
|
return CommandResult{Details: details}, codedError(CodeUsernameOccupied, domain.ErrUsernameOccupied)
|
|
case !errors.Is(err, domain.ErrCollectibleUsernameNotFound):
|
|
return CommandResult{Details: details}, collectibleUsernameError(err)
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "collectible username mint validated", Details: details}, nil
|
|
}
|
|
asset, created, err := s.usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
|
|
Username: req.Username,
|
|
Owner: owner,
|
|
PurchaseDate: purchase,
|
|
Currency: req.Currency,
|
|
Amount: req.Amount,
|
|
CryptoCurrency: req.CryptoCurrency,
|
|
CryptoAmount: req.CryptoAmount,
|
|
URL: req.URL,
|
|
Actor: req.Actor,
|
|
Reason: req.Reason,
|
|
CommandKey: "admin-collectible-mint:" + req.CommandID,
|
|
})
|
|
if err != nil {
|
|
return CommandResult{Details: details}, collectibleUsernameError(err)
|
|
}
|
|
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
|
|
details["status"] = string(asset.Status)
|
|
details["url"] = asset.URL
|
|
details["created"] = created
|
|
message := "collectible username minted"
|
|
if !created {
|
|
message = "collectible username mint replayed"
|
|
}
|
|
return CommandResult{Message: message, Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// TransferCollectibleUsername moves an asset to a new holder. The asset must
|
|
// exist and must not be burned; the store keeps the move atomic with the
|
|
// receiving peer's username registry row.
|
|
func (s *Service) TransferCollectibleUsername(ctx context.Context, req TransferCollectibleUsernameRequest) (CommandResult, error) {
|
|
if s == nil || s.usernames == nil {
|
|
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
if !domain.ValidCollectibleUsername(req.Username) {
|
|
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
|
}
|
|
to, err := collectibleOwnerPeer(req.ToUserID, req.ToChannelID)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
if to.Type == "" {
|
|
return CommandResult{}, fmt.Errorf("exactly one of to_user_id or to_channel_id is required")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionTransferCollectibleUsername, req.ToUserID, to, req, func() (CommandResult, error) {
|
|
details := map[string]any{
|
|
"username": req.Username,
|
|
"to_type": string(to.Type),
|
|
"to_id": strconv.FormatInt(to.ID, 10),
|
|
}
|
|
asset, err := s.usernames.Collectible(ctx, req.Username)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, collectibleUsernameError(err)
|
|
}
|
|
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
|
|
details["previous_status"] = string(asset.Status)
|
|
details["previous_owner_type"] = string(asset.Owner.Type)
|
|
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
|
|
details["transfer_count"] = asset.TransferCount
|
|
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
|
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
|
|
}
|
|
details["would_change"] = !asset.Owned() || asset.Owner != to
|
|
if req.DryRun {
|
|
return CommandResult{Message: "collectible username transfer validated", Details: details}, nil
|
|
}
|
|
updated, changed, err := s.usernames.Transfer(ctx, domain.TransferCollectibleUsernameRequest{
|
|
Username: req.Username,
|
|
To: to,
|
|
Actor: req.Actor,
|
|
Reason: req.Reason,
|
|
CommandKey: "admin-collectible-transfer:" + req.CommandID,
|
|
})
|
|
if err != nil {
|
|
return CommandResult{Details: details}, collectibleUsernameError(err)
|
|
}
|
|
details["status"] = string(updated.Status)
|
|
details["owner_type"] = string(updated.Owner.Type)
|
|
details["owner_id"] = strconv.FormatInt(updated.Owner.ID, 10)
|
|
details["changed"] = changed
|
|
message := "collectible username transferred"
|
|
if !changed {
|
|
message = "collectible username transfer was a no-op"
|
|
}
|
|
return CommandResult{Message: message, Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// RevokeCollectibleUsername returns an asset to the operator vault, or burns it
|
|
// permanently when Burn is set. Revoking an asset nobody holds is rejected:
|
|
// there is nothing to take back, and a silent no-op would read as success.
|
|
func (s *Service) RevokeCollectibleUsername(ctx context.Context, req RevokeCollectibleUsernameRequest) (CommandResult, error) {
|
|
if s == nil || s.usernames == nil {
|
|
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
if !domain.ValidCollectibleUsername(req.Username) {
|
|
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
|
}
|
|
if req.ExpectedOwnerUserID < 0 {
|
|
return CommandResult{}, fmt.Errorf("expected_owner_user_id must not be negative")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"username": req.Username, "burn": req.Burn}
|
|
asset, err := s.usernames.Collectible(ctx, req.Username)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, collectibleUsernameError(err)
|
|
}
|
|
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
|
|
details["previous_status"] = string(asset.Status)
|
|
details["previous_owner_type"] = string(asset.Owner.Type)
|
|
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
|
|
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
|
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
|
|
}
|
|
if !req.Burn && !asset.Owned() {
|
|
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
|
|
}
|
|
if req.ExpectedOwnerUserID > 0 && asset.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.ExpectedOwnerUserID}) {
|
|
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "collectible username revoke validated", Details: details}, nil
|
|
}
|
|
updated, changed, err := s.usernames.Revoke(ctx, domain.RevokeCollectibleUsernameRequest{
|
|
Username: req.Username,
|
|
Burn: req.Burn,
|
|
Actor: req.Actor,
|
|
Reason: req.Reason,
|
|
CommandKey: "admin-collectible-revoke:" + req.CommandID,
|
|
})
|
|
if err != nil {
|
|
return CommandResult{Details: details}, collectibleUsernameError(err)
|
|
}
|
|
details["status"] = string(updated.Status)
|
|
details["changed"] = changed
|
|
message := "collectible username returned to vault"
|
|
if req.Burn {
|
|
message = "collectible username burned"
|
|
}
|
|
return CommandResult{Message: message, Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// DeleteCollectibleUsername erases the asset and its provenance, releasing the
|
|
// name. Because the history disappears with the record, the command journal is
|
|
// the only remaining trace: the details below are captured before the delete so
|
|
// the entry still says what was removed and from whom.
|
|
func (s *Service) DeleteCollectibleUsername(ctx context.Context, req DeleteCollectibleUsernameRequest) (CommandResult, error) {
|
|
if s == nil || s.usernames == nil {
|
|
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
if !domain.ValidCollectibleUsername(req.Username) {
|
|
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionDeleteCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"username": req.Username}
|
|
asset, err := s.usernames.Collectible(ctx, req.Username)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, collectibleUsernameError(err)
|
|
}
|
|
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
|
|
details["previous_status"] = string(asset.Status)
|
|
details["previous_owner_type"] = string(asset.Owner.Type)
|
|
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
|
|
details["transfer_count"] = asset.TransferCount
|
|
details["currency"] = asset.Currency
|
|
details["amount"] = strconv.FormatInt(asset.Amount, 10)
|
|
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
|
// Only live assets can be deleted; burned rows are history and are
|
|
// released by re-issuing the name instead.
|
|
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "collectible username delete validated", Details: details}, nil
|
|
}
|
|
deleted, err := s.usernames.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
|
|
Username: req.Username,
|
|
Actor: req.Actor,
|
|
Reason: req.Reason,
|
|
CommandKey: "admin-collectible-delete:" + req.CommandID,
|
|
})
|
|
if err != nil {
|
|
return CommandResult{Details: details}, collectibleUsernameError(err)
|
|
}
|
|
details["deleted"] = deleted
|
|
if !deleted {
|
|
return CommandResult{Message: "collectible username already absent", Details: details}, nil
|
|
}
|
|
return CommandResult{Message: "collectible username deleted", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func collectibleOwnerPeer(userID, channelID int64) (domain.Peer, error) {
|
|
if userID < 0 || channelID < 0 {
|
|
return domain.Peer{}, fmt.Errorf("owner id must be positive")
|
|
}
|
|
if userID > 0 && channelID > 0 {
|
|
return domain.Peer{}, fmt.Errorf("at most one of user id or channel id is allowed")
|
|
}
|
|
switch {
|
|
case userID > 0:
|
|
return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, nil
|
|
case channelID > 0:
|
|
return domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, nil
|
|
default:
|
|
return domain.Peer{}, nil
|
|
}
|
|
}
|
|
|
|
// collectiblePurchaseDate resolves the optional Unix purchase timestamp. Zero
|
|
// means "now", so a mint always records a complete, reproducible provenance
|
|
// entry; the int32 bound matches the TL date field clients render.
|
|
func collectiblePurchaseDate(unix int64, now func() time.Time) (time.Time, error) {
|
|
if unix == 0 {
|
|
return now().UTC(), nil
|
|
}
|
|
if unix < 0 || unix > math.MaxInt32 {
|
|
return time.Time{}, fmt.Errorf("purchase_date must be a non-negative int32 Unix timestamp")
|
|
}
|
|
return time.Unix(unix, 0).UTC(), nil
|
|
}
|
|
|
|
func CollectibleUsernameErrorCode(err error) string {
|
|
switch {
|
|
case err == nil:
|
|
return ""
|
|
case errors.Is(err, domain.ErrUsernameOccupied):
|
|
return CodeUsernameOccupied
|
|
case errors.Is(err, domain.ErrCollectibleUsernameNotFound), errors.Is(err, domain.ErrUsernameNotOccupied):
|
|
return CodeCollectibleNotFound
|
|
case errors.Is(err, domain.ErrCollectibleUsernameBurned):
|
|
return CodeCollectibleBurned
|
|
case errors.Is(err, domain.ErrCollectibleUsernameLimit):
|
|
return CodeCollectiblePeerLimit
|
|
case errors.Is(err, domain.ErrCollectibleUsernameNotOwned):
|
|
return CodeCollectibleNotOwned
|
|
case errors.Is(err, domain.ErrCollectibleCurrencyInvalid):
|
|
return CodeCollectibleCurrencyInvalid
|
|
case errors.Is(err, domain.ErrUsernameNotCollectible), errors.Is(err, domain.ErrUsernameNotEditable):
|
|
return CodeUsernameNotCollectible
|
|
case errors.Is(err, domain.ErrUsernameInvalid):
|
|
return CodeUsernameInvalid
|
|
case errors.Is(err, domain.ErrCollectibleUsernameStateInvalid), errors.Is(err, domain.ErrUsernameOrderInvalid):
|
|
return CodeCollectibleStateInvalid
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func collectibleUsernameError(err error) error {
|
|
if code := CollectibleUsernameErrorCode(err); code != "" {
|
|
return codedError(code, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func codedError(code string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s: %w", code, err)
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
// SetChannelFlags sets or clears the scam/fake moderation flags on a channel or
|
|
// supergroup. Both flags are applied together from the desired state.
|
|
func (s *Service) SetChannelFlags(ctx context.Context, req SetChannelFlagsRequest) (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")
|
|
}
|
|
if req.Scam && req.Fake {
|
|
return CommandResult{}, domain.ErrPeerModerationFlagsInvalid
|
|
}
|
|
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelFlags, 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,
|
|
"previous_scam": ch.Scam, "previous_fake": ch.Fake,
|
|
"new_scam": req.Scam, "new_fake": req.Fake,
|
|
"would_change": ch.Scam != req.Scam || ch.Fake != req.Fake,
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.channels.SetScamFake(ctx, req.ChannelID, req.Scam, req.Fake)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated_scam"] = updated.Scam
|
|
details["updated_fake"] = updated.Fake
|
|
if err := s.notifyChannelChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "channel flags updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetChannelSettings applies an admin moderation-settings patch to a channel/supergroup.
|
|
func (s *Service) SetChannelSettings(ctx context.Context, req SetChannelSettingsRequest) (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")
|
|
}
|
|
if req.SlowmodeSeconds != nil && (*req.SlowmodeSeconds < 0 || *req.SlowmodeSeconds > 86400) {
|
|
return CommandResult{}, fmt.Errorf("slowmode_seconds must be between 0 and 86400")
|
|
}
|
|
patch := domain.ChannelAdminSettings{
|
|
Gigagroup: req.Gigagroup, AntiSpam: req.AntiSpam, ParticipantsHidden: req.ParticipantsHidden,
|
|
NoForwards: req.NoForwards, JoinToSend: req.JoinToSend, JoinRequest: req.JoinRequest, SlowmodeSeconds: req.SlowmodeSeconds,
|
|
}
|
|
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelSettings, 0, target, req, func() (CommandResult, error) {
|
|
if patch.Empty() {
|
|
return CommandResult{}, fmt.Errorf("no settings provided")
|
|
}
|
|
details := boolIntPatchDetails(patch)
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.channels.AdminSetSettings(ctx, req.ChannelID, patch)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated"] = true
|
|
if err := s.notifyChannelChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "channel settings updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetChannelUsername force-sets or clears a channel username.
|
|
func (s *Service) SetChannelUsername(ctx context.Context, req SetChannelUsernameRequest) (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")
|
|
}
|
|
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
|
|
req.Username = username
|
|
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelUsername, 0, target, req, func() (CommandResult, error) {
|
|
details := map[string]any{"new_username": username}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.channels.AdminSetUsername(ctx, req.ChannelID, username)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated_username"] = updated.Username
|
|
if err := s.notifyChannelChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "channel username updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetChannelColor force-sets or clears a channel name/profile color.
|
|
func (s *Service) SetChannelColor(ctx context.Context, req SetChannelColorRequest) (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")
|
|
}
|
|
color := domain.ChannelPeerColor{HasColor: req.HasColor, Color: req.Color, BackgroundEmojiID: req.BackgroundEmojiID}
|
|
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelColor, 0, target, req, func() (CommandResult, error) {
|
|
details := map[string]any{"for_profile": req.ForProfile, "has_color": req.HasColor, "color": req.Color}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.channels.AdminSetColor(ctx, req.ChannelID, req.ForProfile, color)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
if err := s.notifyChannelChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "channel color updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// SetChannelEmojiStatus force-sets or clears (document_id=0) a channel emoji status.
|
|
func (s *Service) SetChannelEmojiStatus(ctx context.Context, req SetChannelEmojiStatusRequest) (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")
|
|
}
|
|
status := domain.ChannelEmojiStatus{DocumentID: req.DocumentID, Until: req.Until}
|
|
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelEmojiStatus, 0, target, req, func() (CommandResult, error) {
|
|
details := map[string]any{"document_id": strconv.FormatInt(req.DocumentID, 10), "until": req.Until}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.channels.AdminSetEmojiStatus(ctx, req.ChannelID, status)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
if err := s.notifyChannelChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "channel emoji status updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func boolIntPatchDetails(p domain.ChannelAdminSettings) map[string]any {
|
|
details := map[string]any{}
|
|
if p.Gigagroup != nil {
|
|
details["gigagroup"] = *p.Gigagroup
|
|
}
|
|
if p.AntiSpam != nil {
|
|
details["antispam"] = *p.AntiSpam
|
|
}
|
|
if p.ParticipantsHidden != nil {
|
|
details["participants_hidden"] = *p.ParticipantsHidden
|
|
}
|
|
if p.NoForwards != nil {
|
|
details["noforwards"] = *p.NoForwards
|
|
}
|
|
if p.JoinToSend != nil {
|
|
details["join_to_send"] = *p.JoinToSend
|
|
}
|
|
if p.JoinRequest != nil {
|
|
details["join_request"] = *p.JoinRequest
|
|
}
|
|
if p.SlowmodeSeconds != nil {
|
|
details["slowmode_seconds"] = *p.SlowmodeSeconds
|
|
}
|
|
return details
|
|
}
|
|
|
|
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")
|
|
}
|
|
modeCount := 0
|
|
if req.Hash != 0 {
|
|
modeCount++
|
|
}
|
|
if req.KeepHash != 0 {
|
|
modeCount++
|
|
}
|
|
if req.RevokeAll {
|
|
modeCount++
|
|
}
|
|
if modeCount != 1 {
|
|
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": authorizationHashString(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 {
|
|
return CommandResult{}, fmt.Errorf("authorization hash not 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
|
|
})
|
|
}
|
|
|
|
// MaxAccountAvatarBytes bounds both reading (AccountAvatar) and writing
|
|
// (SetAccountAvatar) a user's profile photo through the admin console.
|
|
const MaxAccountAvatarBytes = 4 << 20
|
|
|
|
// MaxAccountAvatarVideoBytes bounds SetAccountAvatarVideo uploads. Video
|
|
// avatars are official Telegram's short (~a few seconds) looping clips, so
|
|
// their encoded size runs well above a static photo's while still being
|
|
// bounded -- a real duration/dimension cap would need decoding the video,
|
|
// which the admin console deliberately does not do.
|
|
const MaxAccountAvatarVideoBytes = 10 << 20
|
|
|
|
// AccountAvatar returns an account's current profile photo bytes and detected
|
|
// MIME type, mirroring internal/web's public avatar serving (same size
|
|
// selection and safe-image-type checks) so the admin console shows exactly
|
|
// what a public preview card would show for the same account.
|
|
func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, string, bool, error) {
|
|
if s == nil || s.photos == nil || userID <= 0 {
|
|
return nil, "", false, nil
|
|
}
|
|
photo, found, err := s.photos.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, domain.ProfilePhotoKindProfile)
|
|
if err != nil || !found {
|
|
return nil, "", found, err
|
|
}
|
|
return s.avatarBytes(ctx, photo)
|
|
}
|
|
|
|
func (s *Service) avatarBytes(ctx context.Context, photo domain.Photo) ([]byte, string, bool, error) {
|
|
size, inline, ok := bestAccountPhotoSize(photo.Sizes)
|
|
if !ok {
|
|
return nil, "", false, nil
|
|
}
|
|
data := inline
|
|
if len(data) == 0 {
|
|
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
|
|
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
|
|
Limit: MaxAccountAvatarBytes + 1,
|
|
})
|
|
if err != nil || !found {
|
|
return nil, "", found, err
|
|
}
|
|
if chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
|
|
return nil, "", false, nil
|
|
}
|
|
data = chunk.Bytes
|
|
}
|
|
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
|
|
return nil, "", false, nil
|
|
}
|
|
mimeType := http.DetectContentType(data)
|
|
if !safeAccountImageType(mimeType) {
|
|
return nil, "", false, nil
|
|
}
|
|
return data, mimeType, true, nil
|
|
}
|
|
|
|
func bestAccountPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, bool) {
|
|
var (
|
|
best domain.PhotoSize
|
|
bestBytes []byte
|
|
bestScore int64 = -1
|
|
)
|
|
for _, size := range sizes {
|
|
if !validAccountPhotoSizeType(size.Type) {
|
|
continue
|
|
}
|
|
var inline []byte
|
|
switch size.Kind {
|
|
case domain.PhotoSizeKindCached:
|
|
if len(size.Bytes) == 0 || len(size.Bytes) > MaxAccountAvatarBytes {
|
|
continue
|
|
}
|
|
inline = size.Bytes
|
|
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive:
|
|
// Downloadable static raster size.
|
|
default:
|
|
continue
|
|
}
|
|
score := int64(size.W) * int64(size.H)
|
|
if score <= 0 {
|
|
score = int64(size.Size)
|
|
}
|
|
if score > bestScore {
|
|
best, bestBytes, bestScore = size, inline, score
|
|
}
|
|
}
|
|
return best, bestBytes, bestScore >= 0
|
|
}
|
|
|
|
func validAccountPhotoSizeType(value string) bool {
|
|
if value == "" || len(value) > 8 {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func safeAccountImageType(value string) bool {
|
|
switch value {
|
|
case "image/jpeg", "image/png", "image/gif", "image/webp":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func dedupeCollectibleAttributeName(seen map[string]int, name string) string {
|
|
key := strings.ToLower(name)
|
|
seen[key]++
|
|
if seen[key] == 1 {
|
|
return name
|
|
}
|
|
return fmt.Sprintf("%s (%d)", name, seen[key])
|
|
}
|
|
|
|
func (s *Service) SetStickerSetArchived(ctx context.Context, req SetStickerSetArchivedRequest) (CommandResult, error) {
|
|
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetStickerSetArchived, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "archived": req.Archived}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "sticker set state change validated", Details: details}, nil
|
|
}
|
|
changed, err := s.stickerSets.AdminSetStickerSetArchived(ctx, req.SetID, req.Archived)
|
|
details["changed"] = changed
|
|
return CommandResult{Message: "sticker set state updated", Details: details}, err
|
|
})
|
|
}
|
|
|
|
func (s *Service) SetStickerSetSortOrder(ctx context.Context, req SetStickerSetSortOrderRequest) (CommandResult, error) {
|
|
if s == nil || s.stickerSets == nil || req.SetID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
|
|
return CommandResult{}, domain.ErrStickerSetInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetStickerSetSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "sort_order": req.SortOrder}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "sticker set order change validated", Details: details}, nil
|
|
}
|
|
changed, err := s.stickerSets.AdminSetStickerSetSortOrder(ctx, req.SetID, req.SortOrder)
|
|
details["changed"] = changed
|
|
return CommandResult{Message: "sticker set order updated", Details: details}, err
|
|
})
|
|
}
|
|
|
|
func (s *Service) RenameStickerSet(ctx context.Context, req RenameStickerSetRequest) (CommandResult, error) {
|
|
if s == nil || s.stickerSets == nil || req.SetID <= 0 || strings.TrimSpace(req.Title) == "" {
|
|
return CommandResult{}, domain.ErrStickerSetInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionRenameStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "title": req.Title}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "sticker set rename validated", Details: details}, nil
|
|
}
|
|
set, err := s.stickerSets.AdminRenameStickerSet(ctx, req.SetID, req.Title)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["title"] = set.Title
|
|
return CommandResult{Message: "sticker set renamed", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) DeleteStickerSet(ctx context.Context, req DeleteStickerSetRequest) (CommandResult, error) {
|
|
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
|
|
return CommandResult{}, domain.ErrStickerSetInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionDeleteStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10)}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "sticker set deletion validated", Details: details}, nil
|
|
}
|
|
kind, err := s.stickerSets.AdminDeleteStickerSet(ctx, req.SetID)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["kind"] = string(kind)
|
|
return CommandResult{Message: "sticker set deleted", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequest) (CommandResult, error) {
|
|
if s == nil || s.stickerSets == nil {
|
|
return CommandResult{}, domain.ErrStickerSetInvalid
|
|
}
|
|
if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.ShortName) == "" || strings.TrimSpace(req.Emoji) == "" {
|
|
return CommandResult{}, domain.ErrStickerSetFileInvalid
|
|
}
|
|
mimeType, ok := s.stickerSets.ValidateStickerMaterialUpload(req.FileName, req.Data)
|
|
if !ok {
|
|
return CommandResult{}, domain.ErrStickerSetFileInvalid
|
|
}
|
|
kind := domain.StickerSetKindStickers
|
|
if req.Kind == string(domain.StickerSetKindEmoji) {
|
|
kind = domain.StickerSetKindEmoji
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionCreateStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{
|
|
"title": req.Title, "short_name": req.ShortName, "kind": string(kind),
|
|
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
|
|
}
|
|
if err := s.stickerSets.ValidateAdminCreateStickerSet(ctx, req.Title, req.ShortName, req.Emoji, kind); err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "sticker pack validated", Details: details}, nil
|
|
}
|
|
doc, err := s.stickerSets.AdminUploadStickerMaterial(ctx, req.FileName, req.Data)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
set, _, err := s.stickerSets.AdminCreateStickerSet(ctx, domain.CreateStickerSetRequest{
|
|
Title: req.Title,
|
|
ShortName: req.ShortName,
|
|
Kind: kind,
|
|
Items: []domain.StickerSetItemInput{{
|
|
DocumentID: doc.ID,
|
|
DocumentAccessHash: doc.AccessHash,
|
|
Emoji: req.Emoji,
|
|
Keywords: req.Keywords,
|
|
}},
|
|
})
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["set_id"] = strconv.FormatInt(set.ID, 10)
|
|
details["short_name"] = set.ShortName
|
|
return CommandResult{Message: "sticker pack created", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetRequest) (CommandResult, error) {
|
|
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
|
|
return CommandResult{}, domain.ErrStickerSetInvalid
|
|
}
|
|
if strings.TrimSpace(req.Emoji) == "" {
|
|
return CommandResult{}, domain.ErrStickerSetEmojiInvalid
|
|
}
|
|
mimeType, ok := s.stickerSets.ValidateStickerMaterialUpload(req.FileName, req.Data)
|
|
if !ok {
|
|
return CommandResult{}, domain.ErrStickerSetFileInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionAddStickerToSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{
|
|
"set_id": strconv.FormatInt(req.SetID, 10), "emoji": req.Emoji,
|
|
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
|
|
}
|
|
// Validate the target and item before materializing a loose
|
|
// document/blob. Keeping this inside runCommand preserves replay of a
|
|
// previously completed command even if the pack has since changed.
|
|
if err := s.stickerSets.ValidateAdminAddStickerToSet(ctx, req.SetID, req.Emoji); err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "sticker upload validated", Details: details}, nil
|
|
}
|
|
doc, err := s.stickerSets.AdminUploadStickerMaterial(ctx, req.FileName, req.Data)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
set, _, err := s.stickerSets.AdminAddStickerToSet(ctx, req.SetID, domain.StickerSetItemInput{
|
|
DocumentID: doc.ID,
|
|
DocumentAccessHash: doc.AccessHash,
|
|
Emoji: req.Emoji,
|
|
Keywords: req.Keywords,
|
|
})
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["document_id"] = strconv.FormatInt(doc.ID, 10)
|
|
details["count"] = set.Count
|
|
return CommandResult{Message: "sticker added", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) RemoveStickerFromSet(ctx context.Context, req RemoveStickerFromSetRequest) (CommandResult, error) {
|
|
if s == nil || s.stickerSets == nil || req.SetID <= 0 || req.DocumentID <= 0 {
|
|
return CommandResult{}, domain.ErrStickerSetInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionRemoveStickerFromSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "document_id": strconv.FormatInt(req.DocumentID, 10)}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "sticker removal validated", Details: details}, nil
|
|
}
|
|
set, _, err := s.stickerSets.AdminRemoveStickerFromSet(ctx, req.SetID, req.DocumentID)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["count"] = set.Count
|
|
return CommandResult{Message: "sticker removed", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
// GifCatalog returns every GIF catalog entry for the admin console's
|
|
// management view.
|
|
func (s *Service) GifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error) {
|
|
if s == nil || s.gifCatalog == nil {
|
|
return nil, domain.ErrGifCatalogUnavailable
|
|
}
|
|
return s.gifCatalog.AdminListGifCatalog(ctx)
|
|
}
|
|
|
|
func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalogEntryRequest) (CommandResult, error) {
|
|
if s == nil || s.gifCatalog == nil {
|
|
return CommandResult{}, domain.ErrGifCatalogUnavailable
|
|
}
|
|
if strings.TrimSpace(req.Title) == "" {
|
|
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
|
}
|
|
mimeType, ok := s.gifCatalog.ValidateGifUpload(req.FileName, req.Data)
|
|
if !ok {
|
|
return CommandResult{}, domain.ErrGifCatalogFileInvalid
|
|
}
|
|
digest := sha256.Sum256(req.Data)
|
|
req.ContentSHA256 = hex.EncodeToString(digest[:])
|
|
return s.runCommand(ctx, req.CommandMeta, ActionCreateGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{
|
|
"title": req.Title, "file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
|
|
}
|
|
entries, err := s.gifCatalog.AdminListGifCatalog(ctx)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
if len(entries) >= domain.MaxGifCatalogEntries {
|
|
return CommandResult{Details: details}, domain.ErrGifCatalogFull
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "gif catalog entry validated", Details: details}, nil
|
|
}
|
|
doc, err := s.gifCatalog.AdminUploadGifMaterial(ctx, req.FileName, req.Data)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
entry, err := s.gifCatalog.AdminCreateGifCatalogEntry(ctx, req.Title, doc.ID)
|
|
if err != nil {
|
|
return CommandResult{Details: details}, err
|
|
}
|
|
details["id"] = strconv.FormatInt(entry.ID, 10)
|
|
details["document_id"] = strconv.FormatInt(doc.ID, 10)
|
|
return CommandResult{Message: "gif catalog entry created", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) SetGifCatalogEnabled(ctx context.Context, req SetGifCatalogEnabledRequest) (CommandResult, error) {
|
|
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
|
|
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogEnabled, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "enabled": req.Enabled}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "gif catalog entry state change validated", Details: details}, nil
|
|
}
|
|
changed, err := s.gifCatalog.AdminSetGifCatalogEnabled(ctx, req.ID, req.Enabled)
|
|
details["changed"] = changed
|
|
return CommandResult{Message: "gif catalog entry state updated", Details: details}, err
|
|
})
|
|
}
|
|
|
|
func (s *Service) SetGifCatalogSortOrder(ctx context.Context, req SetGifCatalogSortOrderRequest) (CommandResult, error) {
|
|
if s == nil || s.gifCatalog == nil || req.ID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
|
|
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "sort_order": req.SortOrder}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "gif catalog entry order change validated", Details: details}, nil
|
|
}
|
|
changed, err := s.gifCatalog.AdminSetGifCatalogSortOrder(ctx, req.ID, req.SortOrder)
|
|
details["changed"] = changed
|
|
return CommandResult{Message: "gif catalog entry order updated", Details: details}, err
|
|
})
|
|
}
|
|
|
|
func (s *Service) SetGifCatalogCategory(ctx context.Context, req SetGifCatalogCategoryRequest) (CommandResult, error) {
|
|
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
|
|
}
|
|
if !domain.ValidGifCatalogCategory(req.Category) {
|
|
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogCategory, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "category": req.Category}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "gif catalog entry category change validated", Details: details}, nil
|
|
}
|
|
changed, err := s.gifCatalog.AdminSetGifCatalogCategory(ctx, req.ID, req.Category)
|
|
details["changed"] = changed
|
|
return CommandResult{Message: "gif catalog entry category updated", Details: details}, err
|
|
})
|
|
}
|
|
|
|
func (s *Service) AutoCategorizeGifCatalog(ctx context.Context, req AutoCategorizeGifCatalogRequest) (CommandResult, error) {
|
|
if s == nil || s.gifCatalog == nil {
|
|
return CommandResult{}, fmt.Errorf("gif catalog service is not configured")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionAutoCategorizeGifCatalog, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "gif catalog auto-categorize validated", Details: details}, nil
|
|
}
|
|
count, err := s.gifCatalog.AdminAutoCategorizeGifCatalog(ctx)
|
|
details["categorized"] = count
|
|
return CommandResult{Message: "gif catalog auto-categorized", Details: details}, err
|
|
})
|
|
}
|
|
|
|
func (s *Service) DeleteUncategorizedGifs(ctx context.Context, req DeleteUncategorizedGifsRequest) (CommandResult, error) {
|
|
if s == nil || s.gifCatalog == nil {
|
|
return CommandResult{}, fmt.Errorf("gif catalog service is not configured")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionDeleteUncategorizedGifs, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{}
|
|
if req.DryRun {
|
|
// A real count, not just "validated" -- this is a bulk delete, and
|
|
// an operator confirming it deserves to know how many entries
|
|
// they're about to lose before they do.
|
|
entries, err := s.gifCatalog.AdminListGifCatalog(ctx)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
uncategorized := 0
|
|
for _, e := range entries {
|
|
if e.Category == "" {
|
|
uncategorized++
|
|
}
|
|
}
|
|
details["would_delete"] = uncategorized
|
|
return CommandResult{Message: fmt.Sprintf("would delete %d uncategorized gif(s)", uncategorized), Details: details}, nil
|
|
}
|
|
deletedEntries, deletedDocuments, err := s.gifCatalog.AdminDeleteUncategorizedGifs(ctx)
|
|
details["deleted_entries"] = deletedEntries
|
|
details["deleted_documents"] = deletedDocuments
|
|
return CommandResult{Message: "uncategorized gifs deleted", Details: details}, err
|
|
})
|
|
}
|
|
|
|
// manualPurgeCategoryKeys maps the admin UI's category keys (see
|
|
// CATEGORY_AGE_FIELDS in cmd/telesrv-admin/web/src/pages/StoragePage.tsx) to
|
|
// their domain.MediaCategory value. "avatar" is deliberately absent -- it
|
|
// maps to ManualPurgeStorageRequest.IncludeAvatars instead, since Avatar is
|
|
// not a domain.MediaCategory (see files.Service.ManualPurge's doc comment).
|
|
var manualPurgeCategoryKeys = map[string]domain.MediaCategory{
|
|
"photo": domain.MediaCategoryPhoto,
|
|
"video": domain.MediaCategoryVideo,
|
|
"round_video": domain.MediaCategoryRoundVideo,
|
|
"gif": domain.MediaCategoryGif,
|
|
"music": domain.MediaCategoryMusic,
|
|
"voice": domain.MediaCategoryVoice,
|
|
"file": domain.MediaCategoryFile,
|
|
}
|
|
|
|
// parseManualPurgeCategories resolves each admin-supplied category key
|
|
// (case-insensitive) to its domain.MediaCategory, rejecting an unknown key
|
|
// outright rather than silently ignoring it -- an operator who fat-fingers a
|
|
// category name deserves an error, not a purge that quietly did less than
|
|
// they asked for. Duplicate keys collapse to one entry.
|
|
func parseManualPurgeCategories(keys []string) ([]domain.MediaCategory, error) {
|
|
categories := make([]domain.MediaCategory, 0, len(keys))
|
|
seen := make(map[domain.MediaCategory]bool, len(keys))
|
|
for _, raw := range keys {
|
|
key := strings.ToLower(strings.TrimSpace(raw))
|
|
cat, ok := manualPurgeCategoryKeys[key]
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown storage category: %q", raw)
|
|
}
|
|
if seen[cat] {
|
|
continue
|
|
}
|
|
seen[cat] = true
|
|
categories = append(categories, cat)
|
|
}
|
|
return categories, nil
|
|
}
|
|
|
|
// manualPurgeBatchLimit bounds how many documents/photos a single
|
|
// ManualPurgeStorage confirm call purges per category/bucket. An operator
|
|
// whose dry-run count exceeds this runs the (idempotent, self-terminating --
|
|
// see files.Service.ManualPurge) confirm action again to keep purging.
|
|
const manualPurgeBatchLimit = 5000
|
|
|
|
// ManualPurgeStorage deletes media blob bytes (never the document/photo
|
|
// metadata row) by admin-chosen category and an optional "created before"
|
|
// cutoff -- the manual counterpart of the automatic hard-retention sweep.
|
|
// See StorageService and files.Service.ManualPurge's doc comments for the
|
|
// exact semantics.
|
|
func (s *Service) ManualPurgeStorage(ctx context.Context, req ManualPurgeStorageRequest) (CommandResult, error) {
|
|
if s == nil || s.storage == nil {
|
|
return CommandResult{}, fmt.Errorf("storage service is not configured")
|
|
}
|
|
categories, err := parseManualPurgeCategories(req.Categories)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
if len(categories) == 0 && !req.IncludeAvatars {
|
|
return CommandResult{}, fmt.Errorf("at least one category (or avatars) must be selected")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionManualPurgeStorage, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{}
|
|
if req.DryRun {
|
|
// A real count, not just "validated" -- this is a bulk,
|
|
// irreversible delete, and an operator confirming it deserves to
|
|
// know how much they're about to purge before they do.
|
|
docs, photos, err := s.storage.CountManualPurgeCandidates(ctx, categories, req.IncludeAvatars, req.CreatedBefore)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["would_delete_documents"] = docs
|
|
details["would_delete_photos"] = photos
|
|
return CommandResult{Message: fmt.Sprintf("would purge %d document(s) and %d photo(s)", docs, photos), Details: details}, nil
|
|
}
|
|
purgedDocs, purgedPhotos, bytesReclaimed, err := s.storage.ManualPurge(ctx, categories, req.IncludeAvatars, req.CreatedBefore, manualPurgeBatchLimit)
|
|
details["purged_documents"] = purgedDocs
|
|
details["purged_photos"] = purgedPhotos
|
|
details["bytes_reclaimed"] = bytesReclaimed
|
|
return CommandResult{Message: fmt.Sprintf("purged %d document(s) and %d photo(s)", purgedDocs, purgedPhotos), Details: details}, err
|
|
})
|
|
}
|
|
|
|
func (s *Service) DeleteGifCatalogEntry(ctx context.Context, req DeleteGifCatalogEntryRequest) (CommandResult, error) {
|
|
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
|
|
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionDeleteGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
|
details := map[string]any{"id": strconv.FormatInt(req.ID, 10)}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "gif catalog entry deletion validated", Details: details}, nil
|
|
}
|
|
changed, err := s.gifCatalog.AdminDeleteGifCatalogEntry(ctx, req.ID)
|
|
details["changed"] = changed
|
|
return CommandResult{Message: "gif catalog entry deleted", Details: details}, err
|
|
})
|
|
}
|
|
|
|
const maxStickerDocumentBytes = 8 << 20
|
|
|
|
// StickerDocumentAnimation returns a sticker/custom-emoji document's preview
|
|
// for the admin console's set-preview grid, plus the content type the caller
|
|
// should serve it as. Most packs are gzip-compressed TGS (decompressed here to
|
|
// Lottie JSON), but some (e.g. hand-uploaded packs) contain plain static
|
|
// raster stickers instead — those are returned as-is with their real image
|
|
// content type so the frontend can render an <img> instead of a Lottie player.
|
|
func (s *Service) StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error) {
|
|
if s == nil || s.photos == nil || documentID <= 0 {
|
|
return nil, "", false, nil
|
|
}
|
|
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
|
|
LocationKey: fmt.Sprintf("doc:%d", documentID),
|
|
Limit: maxStickerDocumentBytes + 1,
|
|
})
|
|
if err != nil {
|
|
return nil, "", false, err
|
|
}
|
|
if !found || chunk.Total <= 0 || chunk.Total > maxStickerDocumentBytes || int64(len(chunk.Bytes)) != chunk.Total {
|
|
return nil, "", false, nil
|
|
}
|
|
data := chunk.Bytes
|
|
if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b {
|
|
reader, err := gzip.NewReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, "", false, nil
|
|
}
|
|
defer reader.Close()
|
|
decompressed, err := io.ReadAll(io.LimitReader(reader, maxStickerDocumentBytes))
|
|
if err != nil {
|
|
return nil, "", false, nil
|
|
}
|
|
return decompressed, "application/json; charset=utf-8", true, nil
|
|
}
|
|
detected := http.DetectContentType(data)
|
|
if !isSafeStickerPreviewImageType(detected) {
|
|
return nil, "", false, nil
|
|
}
|
|
return data, detected, true, nil
|
|
}
|
|
|
|
// GifCatalogDocumentPreview returns a gif_catalog entry's document bytes for
|
|
// the admin panel's list-page preview, plus the content type to serve it as.
|
|
// Unlike StickerDocumentAnimation there is no gzip/Lottie branch to consider:
|
|
// every document AdminUploadGifMaterial creates is already a plain H.264 MP4
|
|
// (see gif_admin.go), so this only needs to fetch and sanity-check the blob.
|
|
func (s *Service) GifCatalogDocumentPreview(ctx context.Context, documentID int64) ([]byte, string, bool, error) {
|
|
if s == nil || s.photos == nil || documentID <= 0 {
|
|
return nil, "", false, nil
|
|
}
|
|
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
|
|
LocationKey: fmt.Sprintf("doc:%d", documentID),
|
|
Limit: domain.MaxGifCatalogUploadSize + 1,
|
|
})
|
|
if err != nil {
|
|
return nil, "", false, err
|
|
}
|
|
if !found || chunk.Total <= 0 || chunk.Total > domain.MaxGifCatalogUploadSize || int64(len(chunk.Bytes)) != chunk.Total {
|
|
return nil, "", false, nil
|
|
}
|
|
detected := http.DetectContentType(chunk.Bytes)
|
|
if detected != "video/mp4" && !strings.HasPrefix(detected, "video/") {
|
|
return nil, "", false, nil
|
|
}
|
|
return chunk.Bytes, detected, true, nil
|
|
}
|
|
|
|
func isSafeStickerPreviewImageType(value string) bool {
|
|
switch value {
|
|
case "image/webp", "image/png", "image/jpeg", "image/gif":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Service) EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error) {
|
|
if s == nil || s.emoji == nil || documentID <= 0 {
|
|
return nil, false, nil
|
|
}
|
|
return s.emoji.DocumentAnimationJSON(ctx, documentID)
|
|
}
|
|
|
|
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 {
|
|
if cmd.Action != action || cmd.DryRun != meta.DryRun || !sameJSON(cmd.RequestJSON, requestJSON) {
|
|
return CommandResult{CommandID: meta.CommandID, Action: action, Status: string(domain.AdminCommandFailed), Error: "COMMAND_ID_CONFLICT", Message: "command_id is already bound to a different request"}, fmt.Errorf("COMMAND_ID_CONFLICT")
|
|
}
|
|
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)
|
|
}
|
|
response := result
|
|
if len(result.transientDetails) > 0 {
|
|
response.Details = make(map[string]any, len(result.Details)+len(result.transientDetails))
|
|
for key, value := range result.Details {
|
|
response.Details[key] = value
|
|
}
|
|
for key, value := range result.transientDetails {
|
|
response.Details[key] = value
|
|
}
|
|
}
|
|
errorText := ""
|
|
if opErr != nil {
|
|
errorText = opErr.Error()
|
|
}
|
|
if _, err := s.commands.FinishCommand(ctx, meta.CommandID, status, resultJSON, errorText); err != nil {
|
|
return response, err
|
|
}
|
|
return response, opErr
|
|
}
|
|
|
|
func sameJSON(a, b []byte) bool {
|
|
var left, right any
|
|
if json.Unmarshal(a, &left) != nil || json.Unmarshal(b, &right) != nil {
|
|
return string(a) == string(b)
|
|
}
|
|
return reflect.DeepEqual(left, right)
|
|
}
|
|
|
|
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) notifyUserModerationFlagsChanged(ctx context.Context, u domain.User) error {
|
|
if s == nil || s.userModerationNotifier == nil {
|
|
return s.notifyUserChanged(ctx, u)
|
|
}
|
|
return s.userModerationNotifier.NotifyUserModerationFlagsChanged(ctx, u)
|
|
}
|
|
|
|
func (s *Service) notifyAccountFreezeChanged(ctx context.Context, freeze domain.AccountFreeze) error {
|
|
if s == nil || s.freezeNotifier == nil {
|
|
return nil
|
|
}
|
|
return s.freezeNotifier.NotifyAccountFreezeChanged(ctx, freeze)
|
|
}
|
|
|
|
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{}, fmt.Errorf("authorization hash not found")
|
|
}
|
|
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) []string {
|
|
hashes := make([]int64, 0, len(items))
|
|
for _, a := range items {
|
|
hashes = append(hashes, a.Hash)
|
|
}
|
|
sort.Slice(hashes, func(i, j int) bool { return hashes[i] < hashes[j] })
|
|
out := make([]string, 0, len(hashes))
|
|
for _, hash := range hashes {
|
|
out = append(out, authorizationHashString(hash))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func authorizationHashString(hash int64) string {
|
|
if hash == 0 {
|
|
return ""
|
|
}
|
|
return strconv.FormatInt(hash, 10)
|
|
}
|
|
|
|
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)
|
|
pts, ptsCount := item.AffectedPts()
|
|
out = append(out, map[string]any{
|
|
"user_id": item.UserID,
|
|
"message_ids": ids,
|
|
"pts": pts,
|
|
"pts_count": 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
|
|
}
|