admin: gift granting, collectible attribute/number control, and Layer 228 moderation tools

Admin console additions (Layer 228):
- Give Gifts: dedicated tab with sorted Lottie/TGS gift picker + inline form; grant any catalog gift to a user/channel from 777000 (no charge)
- Upgraded/collectible delivery: mint a unique gift with admin-selected model/pattern/backdrop and custom number, or random/auto (DB FK + UNIQUE(gift_id,num) enforce invariants)
- SCAM/FAKE flags for users/channels (migration 0136) with configurable profile warning (TELESRV_SCAM_WARNING/TELESRV_FAKE_WARNING)
- Support toggle, force channel settings incl. gigagroup (migration 0137), username management, cosmetic color/emoji-status
- Emoji admin tab (custom emoji list + document IDs + Lottie/TGS preview)
- Bot management; soft UI / dark theme
Wired through Router -> admin.Service -> adminapi -> BFF -> React panel (en/zh/ru).
This commit is contained in:
epilepticseizureee 2026-07-23 04:00:29 +03:00
parent 9e45da69ef
commit 313624eab2
63 changed files with 3650 additions and 71 deletions

View file

@ -23,7 +23,17 @@ const (
ActionGrantPremium = "account.grant_premium"
ActionGrantStars = "account.grant_stars"
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"
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"
@ -32,6 +42,7 @@ const (
ActionPublishGiftCollectibles = "gifts.collectibles.publish"
ActionSetStarGiftEnabled = "gifts.set_enabled"
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
ActionGiveGift = "gifts.give"
ActionCreateBot = "bot.create"
ActionDeleteBot = "bot.delete"
@ -77,6 +88,11 @@ 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)
}
type StarsService interface {
@ -98,6 +114,11 @@ type AccountFreezeNotifier interface {
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)
}
type ChannelNotifier interface {
@ -112,6 +133,7 @@ type MessagesService interface {
}
type GiftsService interface {
GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error)
PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error)
CreateCatalogRevision(ctx context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error)
@ -137,6 +159,20 @@ type BotService interface {
DeleteBot(ctx context.Context, botUserID int64) (domain.User, 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)
}
// GiftGranter delivers a catalog gift to a recipient peer on behalf of a sender
// without charging Stars. Implemented by the RPC router, it reuses the standard
// gift-delivery path (service message for users, saved-gift + admin log for
// channels) so granted gifts are indistinguishable from paid ones.
type GiftGranter interface {
AdminGrantStarGift(ctx context.Context, grant domain.AdminStarGiftGrant) error
}
type Dependencies struct {
Commands CommandRepository
Restrictions RestrictionStore
@ -151,8 +187,10 @@ type Dependencies struct {
ChannelNotifier ChannelNotifier
Messages MessagesService
Gifts GiftsService
GiftGranter GiftGranter
OfficialGifts OfficialGiftsSource
Bots BotService
Emoji EmojiService
Now func() time.Time
}
@ -170,8 +208,10 @@ type Service struct {
channelNotifier ChannelNotifier
messages MessagesService
gifts GiftsService
giftGranter GiftGranter
officialGifts OfficialGiftsSource
bots BotService
emoji EmojiService
now func() time.Time
}
@ -220,12 +260,18 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Gifts != nil {
s.gifts = deps.Gifts
}
if deps.GiftGranter != nil {
s.giftGranter = deps.GiftGranter
}
if deps.OfficialGifts != nil {
s.officialGifts = deps.OfficialGifts
}
if deps.Bots != nil {
s.bots = deps.Bots
}
if deps.Emoji != nil {
s.emoji = deps.Emoji
}
if deps.Now != nil {
s.now = deps.Now
}
@ -297,6 +343,24 @@ type SetStarGiftSortOrderRequest struct {
SortOrder int `json:"sort_order"`
}
// GiveGiftRequest grants a catalog gift to a recipient (user or channel) from a
// sender account (defaults to the official system account 777000) at no charge.
// Exactly one of UserID / ChannelID identifies the recipient.
type GiveGiftRequest struct {
CommandMeta
SenderUserID int64 `json:"sender_user_id"`
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
GiftID int64 `json:"gift_id"`
HideName bool `json:"hide_name"`
Message string `json:"message"`
Upgrade bool `json:"upgrade"`
ModelAttributeID int64 `json:"model_attribute_id"`
PatternAttributeID int64 `json:"pattern_attribute_id"`
BackdropAttributeID int64 `json:"backdrop_attribute_id"`
Num int `json:"num"`
}
type StarGiftCollectibleAnimationUpload struct {
Name string `json:"name"`
RarityPermille int `json:"rarity_permille"`
@ -361,6 +425,86 @@ type SetChannelVerifiedRequest struct {
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"`
}
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"`
@ -718,6 +862,285 @@ func (s *Service) SetVerified(ctx context.Context, req SetVerifiedRequest) (Comm
})
}
// 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")
}
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.notifyUserChanged(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 collectibleAttrPresent(attrs []domain.StarGiftCollectibleAttribute, id int64) bool {
for _, attr := range attrs {
if attr.ID == id {
return true
}
}
return false
}
// GiveGift grants a catalog gift to a recipient (user or channel) from a sender
// account (defaults to the official system account 777000) without charging any
// Stars. Delivery reuses the standard gift path via the GiftGranter dependency.
func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandResult, error) {
if req.GiftID <= 0 {
return CommandResult{}, fmt.Errorf("gift_id is required")
}
if (req.UserID > 0) == (req.ChannelID > 0) {
return CommandResult{}, fmt.Errorf("exactly one of user_id or channel_id is required")
}
if s == nil || s.giftGranter == nil {
return CommandResult{}, fmt.Errorf("gift granter dependency is not configured")
}
sender := req.SenderUserID
if sender <= 0 {
sender = domain.OfficialSystemUserID
}
var recipient domain.Peer
if req.ChannelID > 0 {
recipient = domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
} else {
recipient = domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}
}
if req.Upgrade && recipient.Type != domain.PeerTypeUser {
return CommandResult{}, fmt.Errorf("upgraded gift delivery is supported for user recipients only")
}
if !req.Upgrade && (req.ModelAttributeID > 0 || req.PatternAttributeID > 0 || req.BackdropAttributeID > 0 || req.Num > 0) {
return CommandResult{}, fmt.Errorf("collectible attributes and number require upgrade")
}
return s.runCommand(ctx, req.CommandMeta, ActionGiveGift, req.UserID, recipient, req, func() (CommandResult, error) {
details := map[string]any{
"sender_user_id": sender,
"gift_id": req.GiftID,
"recipient_type": string(recipient.Type),
"recipient_id": recipient.ID,
"hide_name": req.HideName,
"upgrade": req.Upgrade,
}
if strings.TrimSpace(req.Message) != "" {
details["message"] = strings.TrimSpace(req.Message)
}
if s.gifts != nil {
gift, found, err := s.gifts.GiftByID(ctx, req.GiftID)
if err != nil {
return CommandResult{}, err
}
if !found {
return CommandResult{}, fmt.Errorf("gift %d not found", req.GiftID)
}
details["gift_title"] = gift.Title
details["gift_stars"] = gift.Stars
if req.Upgrade {
preview, ok, err := s.gifts.CollectiblePreview(ctx, req.GiftID)
if err != nil {
return CommandResult{}, err
}
if !ok || preview.UpgradeStars <= 0 {
return CommandResult{}, fmt.Errorf("gift %d has no published collectible upgrade", req.GiftID)
}
if preview.Issued >= preview.SupplyTotal {
return CommandResult{}, fmt.Errorf("gift %d collectible supply is exhausted", req.GiftID)
}
if req.ModelAttributeID > 0 && !collectibleAttrPresent(preview.Models, req.ModelAttributeID) {
return CommandResult{}, fmt.Errorf("model attribute %d is not part of gift %d", req.ModelAttributeID, req.GiftID)
}
if req.PatternAttributeID > 0 && !collectibleAttrPresent(preview.Patterns, req.PatternAttributeID) {
return CommandResult{}, fmt.Errorf("pattern attribute %d is not part of gift %d", req.PatternAttributeID, req.GiftID)
}
if req.BackdropAttributeID > 0 && !collectibleAttrPresent(preview.Backdrops, req.BackdropAttributeID) {
return CommandResult{}, fmt.Errorf("backdrop attribute %d is not part of gift %d", req.BackdropAttributeID, req.GiftID)
}
if req.Num > 0 && req.Num > preview.SupplyTotal {
return CommandResult{}, fmt.Errorf("number %d exceeds collectible supply %d", req.Num, preview.SupplyTotal)
}
details["collectible_supply_total"] = preview.SupplyTotal
details["collectible_issued"] = preview.Issued
if req.ModelAttributeID > 0 {
details["model_attribute_id"] = req.ModelAttributeID
}
if req.PatternAttributeID > 0 {
details["pattern_attribute_id"] = req.PatternAttributeID
}
if req.BackdropAttributeID > 0 {
details["backdrop_attribute_id"] = req.BackdropAttributeID
}
if req.Num > 0 {
details["num"] = req.Num
}
}
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
if err := s.giftGranter.AdminGrantStarGift(ctx, domain.AdminStarGiftGrant{
SenderID: sender,
Recipient: recipient,
GiftID: req.GiftID,
HideName: req.HideName,
Message: strings.TrimSpace(req.Message),
Upgrade: req.Upgrade,
ModelAttributeID: req.ModelAttributeID,
PatternAttributeID: req.PatternAttributeID,
BackdropAttributeID: req.BackdropAttributeID,
Num: req.Num,
}); err != nil {
return CommandResult{}, err
}
msg := "gift granted"
if req.Upgrade {
msg = "collectible gift granted"
}
return CommandResult{Message: msg, Details: details}, nil
})
}
// SetUsername force-sets or clears (empty) a user/bot username. Format and
// availability are validated by the users service.
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
})
}
// 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
@ -845,6 +1268,188 @@ func (s *Service) SetChannelVerified(ctx context.Context, req SetChannelVerified
})
}
// 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")
}
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")
@ -1403,6 +2008,14 @@ func (s *Service) StarGiftAnimation(ctx context.Context, giftID int64) ([]byte,
return s.gifts.AnimationJSON(ctx, giftID)
}
// EmojiAnimation returns the Lottie JSON for a custom-emoji document (admin emoji browser preview).
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) StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
if s == nil || s.gifts == nil || giftID <= 0 {
return domain.StarGiftUpgradePreview{}, false, nil

View file

@ -680,6 +680,60 @@ func (f *fakeUsersService) SetVerified(_ context.Context, userID int64, verified
return u, nil
}
func (f *fakeUsersService) SetScamFake(_ context.Context, userID int64, scam, fake bool) (domain.User, error) {
u, ok := f.users[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
u.Scam = scam
u.Fake = fake
f.users[userID] = u
return u, nil
}
func (f *fakeUsersService) SetSupport(_ context.Context, userID int64, support bool) (domain.User, error) {
u, ok := f.users[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
u.Support = support
f.users[userID] = u
return u, nil
}
func (f *fakeUsersService) UpdateUsername(_ context.Context, userID int64, username string) (domain.User, error) {
u, ok := f.users[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
u.Username = username
f.users[userID] = u
return u, nil
}
func (f *fakeUsersService) UpdateColor(_ context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) {
u, ok := f.users[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
if forProfile {
u.ProfileColor = color
} else {
u.Color = color
}
f.users[userID] = u
return u, nil
}
func (f *fakeUsersService) UpdateEmojiStatus(_ context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
u, ok := f.users[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
f.users[userID] = u
return u, nil
}
type fakeStarsService struct {
balances map[int64]domain.StarsBalance
creditCalls int
@ -754,6 +808,66 @@ func (f *fakeChannelsService) SetVerified(_ context.Context, channelID int64, ve
return ch, nil
}
func (f *fakeChannelsService) SetScamFake(_ context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
ch, ok := f.channels[channelID]
if !ok {
return domain.Channel{}, domain.ErrChannelInvalid
}
ch.Scam = scam
ch.Fake = fake
f.channels[channelID] = ch
return ch, nil
}
func (f *fakeChannelsService) AdminSetSettings(_ context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
ch, ok := f.channels[channelID]
if !ok {
return domain.Channel{}, domain.ErrChannelInvalid
}
if patch.Gigagroup != nil {
ch.Gigagroup = *patch.Gigagroup
}
if patch.SlowmodeSeconds != nil {
ch.SlowmodeSeconds = *patch.SlowmodeSeconds
}
f.channels[channelID] = ch
return ch, nil
}
func (f *fakeChannelsService) AdminSetUsername(_ context.Context, channelID int64, username string) (domain.Channel, error) {
ch, ok := f.channels[channelID]
if !ok {
return domain.Channel{}, domain.ErrChannelInvalid
}
ch.Username = username
f.channels[channelID] = ch
return ch, nil
}
func (f *fakeChannelsService) AdminSetColor(_ context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
ch, ok := f.channels[channelID]
if !ok {
return domain.Channel{}, domain.ErrChannelInvalid
}
if forProfile {
ch.ProfileColor = color
} else {
ch.Color = color
}
f.channels[channelID] = ch
return ch, nil
}
func (f *fakeChannelsService) AdminSetEmojiStatus(_ context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
ch, ok := f.channels[channelID]
if !ok {
return domain.Channel{}, domain.ErrChannelInvalid
}
ch.EmojiStatus = status
f.channels[channelID] = ch
return ch, nil
}
type fakeChannelNotifier struct {
channels []int64
}
@ -1021,6 +1135,12 @@ type fakeGiftsService struct {
lastBundle domain.StarGiftCatalogBundleWrite
}
func (f *fakeGiftsService) GiftByID(_ context.Context, id int64) (domain.StarGift, bool, error) {
if id <= 0 {
return domain.StarGift{}, false, nil
}
return domain.StarGift{ID: id, Stars: 50, Title: "Test Gift"}, true, nil
}
func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
sum := sha256.Sum256(data)
return domain.StarGiftAnimation{