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{

View file

@ -29,9 +29,19 @@ type Service interface {
GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error)
GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error)
SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error)
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error)
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
CreateBot(ctx context.Context, req admin.CreateBotRequest) (admin.CommandResult, error)
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error)
SetSupport(ctx context.Context, req admin.SetSupportRequest) (admin.CommandResult, error)
SetUsername(ctx context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error)
SetUserColor(ctx context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error)
SetUserEmojiStatus(ctx context.Context, req admin.SetUserEmojiStatusRequest) (admin.CommandResult, error)
SetChannelSettings(ctx context.Context, req admin.SetChannelSettingsRequest) (admin.CommandResult, error)
SetChannelUsername(ctx context.Context, req admin.SetChannelUsernameRequest) (admin.CommandResult, error)
SetChannelColor(ctx context.Context, req admin.SetChannelColorRequest) (admin.CommandResult, error)
SetChannelEmojiStatus(ctx context.Context, req admin.SetChannelEmojiStatusRequest) (admin.CommandResult, error)
RevokeSessions(ctx context.Context, req admin.RevokeSessionsRequest) (admin.CommandResult, error)
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error)
@ -42,7 +52,9 @@ type Service interface {
PublishStarGiftCollectibles(ctx context.Context, req admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error)
SetStarGiftEnabled(ctx context.Context, req admin.SetStarGiftEnabledRequest) (admin.CommandResult, error)
SetStarGiftSortOrder(ctx context.Context, req admin.SetStarGiftSortOrderRequest) (admin.CommandResult, error)
GiveGift(ctx context.Context, req admin.GiveGiftRequest) (admin.CommandResult, error)
StarGiftAnimation(ctx context.Context, giftID int64) ([]byte, bool, error)
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
}
@ -97,8 +109,18 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium))
mux.HandleFunc("POST /v1/accounts/grant-stars", s.authenticated(s.handleGrantStars))
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
mux.HandleFunc("POST /v1/accounts/set-flags", s.authenticated(s.handleSetUserFlags))
mux.HandleFunc("POST /v1/accounts/set-support", s.authenticated(s.handleSetSupport))
mux.HandleFunc("POST /v1/accounts/set-username", s.authenticated(s.handleSetUsername))
mux.HandleFunc("POST /v1/accounts/set-color", s.authenticated(s.handleSetUserColor))
mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus))
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified))
mux.HandleFunc("POST /v1/channels/set-flags", s.authenticated(s.handleSetChannelFlags))
mux.HandleFunc("POST /v1/channels/set-settings", s.authenticated(s.handleSetChannelSettings))
mux.HandleFunc("POST /v1/channels/set-username", s.authenticated(s.handleSetChannelUsername))
mux.HandleFunc("POST /v1/channels/set-color", s.authenticated(s.handleSetChannelColor))
mux.HandleFunc("POST /v1/channels/set-emoji-status", s.authenticated(s.handleSetChannelEmojiStatus))
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot))
mux.HandleFunc("POST /v1/bots/delete", s.authenticated(s.handleDeleteBot))
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
@ -110,7 +132,9 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/gifts/{id}/collectibles/publish", s.authenticated(s.handlePublishStarGiftCollectibles))
mux.HandleFunc("POST /v1/gifts/set-enabled", s.authenticated(s.handleSetStarGiftEnabled))
mux.HandleFunc("POST /v1/gifts/set-sort-order", s.authenticated(s.handleSetStarGiftSortOrder))
mux.HandleFunc("POST /v1/gifts/give", s.authenticated(s.handleGiveGift))
mux.HandleFunc("GET /v1/gifts/{id}/animation", s.authenticated(s.handleStarGiftAnimation))
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
return mux
@ -172,6 +196,96 @@ func (s *Server) handleSetChannelVerified(w http.ResponseWriter, r *http.Request
writeCommandResult(w, result, err)
}
func (s *Server) handleSetUserFlags(w http.ResponseWriter, r *http.Request) {
var req admin.SetUserFlagsRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetUserFlags(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetChannelFlags(w http.ResponseWriter, r *http.Request) {
var req admin.SetChannelFlagsRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetChannelFlags(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetSupport(w http.ResponseWriter, r *http.Request) {
var req admin.SetSupportRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetSupport(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetUsername(w http.ResponseWriter, r *http.Request) {
var req admin.SetUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetUserColor(w http.ResponseWriter, r *http.Request) {
var req admin.SetUserColorRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetUserColor(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetUserEmojiStatus(w http.ResponseWriter, r *http.Request) {
var req admin.SetUserEmojiStatusRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetUserEmojiStatus(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetChannelSettings(w http.ResponseWriter, r *http.Request) {
var req admin.SetChannelSettingsRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetChannelSettings(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetChannelUsername(w http.ResponseWriter, r *http.Request) {
var req admin.SetChannelUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetChannelUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetChannelColor(w http.ResponseWriter, r *http.Request) {
var req admin.SetChannelColorRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetChannelColor(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetChannelEmojiStatus(w http.ResponseWriter, r *http.Request) {
var req admin.SetChannelEmojiStatusRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetChannelEmojiStatus(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleCreateBot(w http.ResponseWriter, r *http.Request) {
var req admin.CreateBotRequest
if !decodeJSON(w, r, &req) {
@ -389,6 +503,15 @@ func (s *Server) handleSetStarGiftSortOrder(w http.ResponseWriter, r *http.Reque
writeCommandResult(w, result, err)
}
func (s *Server) handleGiveGift(w http.ResponseWriter, r *http.Request) {
var req admin.GiveGiftRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.GiveGift(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleStarGiftAnimation(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {
@ -410,6 +533,27 @@ func (s *Server) handleStarGiftAnimation(w http.ResponseWriter, r *http.Request)
_, _ = w.Write(raw)
}
func (s *Server) handleEmojiAnimation(w http.ResponseWriter, r *http.Request) {
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || documentID <= 0 {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
raw, found, err := s.svc.EmojiAnimation(r.Context(), documentID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeError(w, http.StatusNotFound, "emoji animation not found")
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "private, max-age=60")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func (s *Server) handleStarGiftCollectibles(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {

View file

@ -258,6 +258,50 @@ func (fakeService) DeleteBot(_ context.Context, req admin.DeleteBotRequest) (adm
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetChannelFlags(_ context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetSupport(_ context.Context, req admin.SetSupportRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) GiveGift(_ context.Context, req admin.GiveGiftRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetUsername(_ context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetUserColor(_ context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetUserEmojiStatus(_ context.Context, req admin.SetUserEmojiStatusRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetChannelSettings(_ context.Context, req admin.SetChannelSettingsRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetChannelUsername(_ context.Context, req admin.SetChannelUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetChannelColor(_ context.Context, req admin.SetChannelColorRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetChannelEmojiStatus(_ context.Context, req admin.SetChannelEmojiStatusRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeSessions(context.Context, admin.RevokeSessionsRequest) (admin.CommandResult, error) {
return admin.CommandResult{}, nil
}
@ -302,6 +346,10 @@ func (fakeService) StarGiftAnimation(context.Context, int64) ([]byte, bool, erro
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
}
func (fakeService) EmojiAnimation(context.Context, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7","w":100,"h":100}`), true, nil
}
func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
return domain.StarGiftUpgradePreview{}, false, nil
}

View file

@ -500,6 +500,46 @@ func (s *Service) SetVerified(ctx context.Context, channelID int64, verified boo
return s.channels.SetChannelVerified(ctx, channelID, verified)
}
// SetScamFake sets or clears the channel/supergroup scam and fake flags through the internal admin path.
func (s *Service) SetScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelScamFake(ctx, channelID, scam, fake)
}
// AdminSetSettings applies a moderation-settings patch through the admin path (no permission checks).
func (s *Service) AdminSetSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelAdminSettings(ctx, channelID, patch)
}
// AdminSetUsername force-sets or clears a channel username through the admin path.
func (s *Service) AdminSetUsername(ctx context.Context, channelID int64, username string) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelUsernameAdmin(ctx, channelID, username)
}
// AdminSetColor force-sets a channel name/profile color through the admin path.
func (s *Service) AdminSetColor(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelColorAdmin(ctx, channelID, forProfile, color)
}
// AdminSetEmojiStatus force-sets or clears a channel emoji status through the admin path.
func (s *Service) AdminSetEmojiStatus(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelEmojiStatusAdmin(ctx, channelID, status)
}
// ListAdminedPublicChannels returns public channels/supergroups administered by user.
func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 {

View file

@ -0,0 +1,79 @@
package files
import (
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"telesrv/internal/domain"
)
const maxEmojiAnimationBytes = 2 << 20
// DocumentAnimationJSON returns the Lottie JSON for an animated custom-emoji
// document, decompressing TGS (gzip) transparently. Non-emoji documents and
// documents without a stored blob return found=false. It backs the admin emoji
// browser preview and reuses the existing file-blob storage (doc:<id> key).
func (s *Service) DocumentAnimationJSON(ctx context.Context, documentID int64) ([]byte, bool, error) {
if s == nil || s.media == nil || s.blobs == nil || documentID <= 0 {
return nil, false, nil
}
doc, found, err := s.GetDocument(ctx, documentID)
if err != nil {
return nil, false, err
}
if !found || !documentIsCustomEmoji(doc) {
return nil, false, nil
}
blob, found, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", documentID))
if err != nil {
return nil, false, err
}
if !found || blob.Size <= 0 || blob.Size > maxEmojiAnimationBytes {
return nil, false, nil
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil {
return nil, false, err
}
if int64(len(data)) != total {
return nil, false, nil
}
out, err := gunzipIfNeeded(data)
if err != nil {
return nil, false, err
}
return out, true, nil
}
func documentIsCustomEmoji(doc domain.Document) bool {
for _, a := range doc.Attributes {
if a.Kind == domain.DocAttrCustomEmoji {
return true
}
}
return false
}
// gunzipIfNeeded transparently decompresses TGS (gzip-wrapped Lottie); raw JSON
// (non-gzip) is returned unchanged.
func gunzipIfNeeded(data []byte) ([]byte, error) {
if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b {
return data, nil
}
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("open tgs gzip: %w", err)
}
defer gz.Close()
out, err := io.ReadAll(io.LimitReader(gz, maxEmojiAnimationBytes+1))
if err != nil {
return nil, fmt.Errorf("decompress tgs: %w", err)
}
if len(out) > maxEmojiAnimationBytes {
return nil, fmt.Errorf("decompressed tgs too large")
}
return out, nil
}

View file

@ -365,6 +365,53 @@ func (s *Service) SetVerified(ctx context.Context, userID int64, verified bool)
return updated, nil
}
// SetScamFake 设置/取消用户的 scam 与 fake 标记bot 复用同一路径。scam/fake
// 是账号基础事实,所有 user 投影统一消费;写后刷新基础缓存以便投影即时可见。
func (s *Service) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) {
if userID == 0 {
return domain.User{}, ErrNotAuthorized
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, err
}
if !found {
return domain.User{}, domain.ErrUserNotFound
}
if u.Scam == scam && u.Fake == fake {
return u, nil
}
updated, err := s.users.SetScamFake(ctx, userID, scam, fake)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, updated)
return updated, nil
}
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。写后刷新基础缓存。
func (s *Service) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) {
if userID == 0 {
return domain.User{}, ErrNotAuthorized
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, err
}
if !found {
return domain.User{}, domain.ErrUserNotFound
}
if u.Support == support {
return u, nil
}
updated, err := s.users.SetSupport(ctx, userID, support)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, updated)
return updated, nil
}
// SweepExpiredPremium 清理到期会员store 把过期行清 NULL并失效用户缓存
// 返回清理后的用户,供 RPC 层向本人在线 session 推 updateUser。premium 下发
// 正确性由读取路径即时派生保证,这里只做收尾与通知。

View file

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

View file

@ -415,6 +415,9 @@ type Channel struct {
About string
Username string
Verified bool
Scam bool
Fake bool
Gigagroup bool
Broadcast bool
Megagroup bool
Forum bool
@ -1443,6 +1446,25 @@ type UpdateChannelUsernameRequest struct {
Username string
}
// ChannelAdminSettings is an admin-direct patch of channel moderation settings.
// nil fields are left unchanged; set fields are applied verbatim (no membership
// or permission checks — this is the operator/admin path).
type ChannelAdminSettings struct {
Gigagroup *bool
AntiSpam *bool
ParticipantsHidden *bool
NoForwards *bool
JoinToSend *bool
JoinRequest *bool
SlowmodeSeconds *int
}
// Empty reports whether the patch changes nothing.
func (p ChannelAdminSettings) Empty() bool {
return p.Gigagroup == nil && p.AntiSpam == nil && p.ParticipantsHidden == nil &&
p.NoForwards == nil && p.JoinToSend == nil && p.JoinRequest == nil && p.SlowmodeSeconds == nil
}
// SetChannelPhotoResult describes a channel avatar mutation and its durable
// service message.
type SetChannelPhotoResult struct {

View file

@ -327,6 +327,33 @@ type StarGiftUpgradeRequest struct {
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
// Admin-controlled minting overrides. When non-zero these pin the specific
// collectible attributes / number instead of the random pool draw and the
// sequential issued+1 number. They are only honoured on the admin grant path;
// the DB FK (attribute must belong to the revision) and UNIQUE(gift_id,num)
// constraints remain the source of truth.
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
Num int
}
// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to
// Recipient from Sender (0 => official system account 777000) at no charge.
// When Upgrade is set the gift is minted as a collectible; the optional
// attribute IDs / Num pin specific collectible facts (0 => random/auto).
type AdminStarGiftGrant struct {
SenderID int64
Recipient Peer
GiftID int64
HideName bool
Message string
Upgrade bool
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
Num int
}
type StarGiftPurchaseRequest struct {
@ -900,6 +927,7 @@ var (
ErrStarGiftAlreadyUpgraded = errors.New("stargift: already upgraded")
ErrStarGiftCollectibleSoldOut = errors.New("stargift: collectible supply exhausted")
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
ErrStarGiftCollectibleNumberTaken = errors.New("stargift: collectible number already taken")
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
ErrStarGiftCollectionsFull = errors.New("stargift: collections full")
ErrStarGiftUnavailable = errors.New("stargift: unavailable")

View file

@ -100,6 +100,8 @@ type User struct {
Username string
CountryCode string
Verified bool
Scam bool
Fake bool
Support bool
Contact bool
Mutual bool

View file

@ -450,6 +450,9 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
out := &tg.Channel{
Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0,
Verified: ch.Verified,
Scam: ch.Scam,
Fake: ch.Fake,
Gigagroup: ch.Gigagroup,
Broadcast: ch.Broadcast,
Megagroup: ch.Megagroup,
Forum: ch.Forum,
@ -540,6 +543,16 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
return out
}
// channelAboutWithModerationWarning decorates the projected channel/supergroup
// About with the scam/fake warning when set (group vs channel wording).
func channelAboutWithModerationWarning(ch domain.Channel) string {
scamText, fakeText := defaultScamWarningChannel, defaultFakeWarningChannel
if ch.Megagroup && !ch.Broadcast {
scamText, fakeText = defaultScamWarningGroup, defaultFakeWarningGroup
}
return aboutWithModerationWarning(ch.About, scamText, fakeText, ch.Scam, ch.Fake)
}
func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.ChannelFull {
ch := view.Channel
full := &tg.ChannelFull{
@ -550,7 +563,7 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
CanSetUsername: view.Self.Role == domain.ChannelRoleCreator,
CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator,
ID: ch.ID,
About: ch.About,
About: channelAboutWithModerationWarning(ch),
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
UnreadCount: view.Dialog.UnreadCount,

View file

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

View file

@ -52,6 +52,8 @@ func tgUser(u domain.User) *tg.User {
Username: u.Username,
Phone: u.Phone,
Verified: u.Verified,
Scam: u.Scam,
Fake: u.Fake,
Support: u.Support,
Contact: u.Contact,
MutualContact: u.Mutual,

View file

@ -269,9 +269,9 @@ func (r *Router) sendStarGiftMemoryPurchase(ctx context.Context, userID int64, p
var updates *tg.Updates
switch peer.Type {
case domain.PeerTypeUser:
updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
_, updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
case domain.PeerTypeChannel:
updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
_, updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage, upgradeStars)
default:
err = domain.ErrStarGiftInvalid
}
@ -363,19 +363,19 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil
}
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) {
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SavedStarGiftRef, *tg.Updates, error) {
prepaidUpgradeHash := ""
if prepaidUpgradeStars == 0 && gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return nil, err
return domain.SavedStarGiftRef{}, nil, err
}
prepaidUpgradeHash = base64.RawURLEncoding.EncodeToString(token[:])
}
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message, prepaidUpgradeStars, prepaidUpgradeHash)
if err != nil {
return nil, err
return domain.SavedStarGiftRef{}, nil, err
}
// 3. 记账收礼人收到一份礼物实例msg_id = 收礼人侧消息 id
if _, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
@ -392,17 +392,18 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
PrepaidUpgradeHash: prepaidUpgradeHash,
Message: message,
}); err != nil {
return nil, err
return domain.SavedStarGiftRef{}, nil, err
}
// 收礼人 stargifts_count 变化 → 失效其 userFull 投影,资料页 Gifts 区段才会出现。
r.invalidateRPCProjectionForUser(recipientID)
ref := domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}, MsgID: send.RecipientMessage.ID}
users := r.usersForMessageUpdate(ctx, senderID, send.SenderMessage)
chats := r.chatsForMessageUpdate(ctx, senderID, send.SenderMessage)
return tgPrivateMessageUpdates(send.SenderEvent, send.SenderMessage, 0, false, users, chats), nil
return ref, tgPrivateMessageUpdates(send.SenderEvent, send.SenderMessage, 0, false, users, chats), nil
}
func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (*tg.Updates, error) {
func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SavedStarGiftRef, *tg.Updates, error) {
now := int(r.clock.Now().Unix())
sticker := gift.Sticker
action := domain.ChannelMessageAction{
@ -438,7 +439,7 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
Message: message,
})
if err != nil {
return nil, err
return domain.SavedStarGiftRef{}, nil, err
}
action.StarGift.PeerChannelID = channelID
action.StarGift.SavedID = savedID
@ -451,7 +452,8 @@ func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID
)
}
r.invalidateRPCProjectionForChannel(channelID)
return nil, nil
ref := domain.SavedStarGiftRef{Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, SavedID: savedID}
return ref, nil, nil
}
// deliverStarGift 经 SendPrivateText 把 messageActionStarGift 服务消息投递到收礼人私聊。

View file

@ -0,0 +1,99 @@
package rpc
import (
"context"
"fmt"
"telesrv/internal/domain"
)
// AdminGrantStarGift delivers a catalog gift to a recipient peer on behalf of
// grant.SenderID without charging any Stars. It powers the admin console "Give
// gift" action: the gift is loaded from the catalog and delivered through the
// exact same path a paid send uses (messageActionStarGift service message for
// users, saved-gift + admin log for channels), only the Stars debit is skipped.
//
// When SenderID is zero the official system account (777000, the telesrv
// service account) is used as the sender. When Upgrade is true the granted gift
// is immediately upgraded to a genuine collectible (unique) gift. The optional
// ModelAttributeID / PatternAttributeID / BackdropAttributeID / Num pin specific
// collectible facts (0 => random model/pattern/backdrop, auto sequential
// number); the DB constraints remain the source of truth. Upgraded delivery is
// supported for user recipients only.
func (r *Router) AdminGrantStarGift(ctx context.Context, grant domain.AdminStarGiftGrant) error {
senderID := grant.SenderID
if senderID <= 0 {
senderID = domain.OfficialSystemUserID
}
if grant.GiftID <= 0 {
return fmt.Errorf("gift_id is required")
}
if grant.Recipient.ID <= 0 {
return fmt.Errorf("recipient is required")
}
if r.deps.Gifts == nil {
return fmt.Errorf("gifts dependency is not configured")
}
gift, ok, err := r.deps.Gifts.GiftByID(ctx, grant.GiftID)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("gift %d not found", grant.GiftID)
}
if grant.Upgrade {
return r.adminGrantUpgradedStarGift(ctx, senderID, gift, grant)
}
switch grant.Recipient.Type {
case domain.PeerTypeUser:
_, _, err = r.sendStarGiftToUser(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, 0)
return err
case domain.PeerTypeChannel:
_, _, err = r.sendStarGiftToChannel(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, 0)
return err
default:
return fmt.Errorf("unsupported recipient peer type %q", grant.Recipient.Type)
}
}
// adminGrantUpgradedStarGift grants a base gift carrying a prepaid upgrade
// entitlement and then mints the collectible via the standard zero-charge
// prepaid upgrade path, so the recipient ends up owning a real unique gift with
// the requested (or random) attributes and number.
func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64, gift domain.StarGift, grant domain.AdminStarGiftGrant) error {
if grant.Recipient.Type != domain.PeerTypeUser {
return fmt.Errorf("upgraded gift delivery is supported for user recipients only")
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, gift.ID)
if err != nil {
return err
}
if !found || preview.UpgradeStars <= 0 {
return fmt.Errorf("gift %d has no published collectible upgrade", gift.ID)
}
if preview.Issued >= preview.SupplyTotal {
return fmt.Errorf("gift %d collectible supply is exhausted", gift.ID)
}
// Grant the base gift with a prepaid-upgrade entitlement so the upgrade
// below runs on the zero-charge RequirePrepaid path.
ref, _, err := r.sendStarGiftToUser(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, preview.UpgradeStars)
if err != nil {
return err
}
commandKey := fmt.Sprintf("admin-grant-upgrade:%d:%d:%d", grant.Recipient.ID, gift.ID, ref.MsgID)
if _, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
UserID: grant.Recipient.ID,
Ref: ref,
RequirePrepaid: true,
CommandKey: commandKey,
Date: int(r.clock.Now().Unix()),
ModelAttributeID: grant.ModelAttributeID,
PatternAttributeID: grant.PatternAttributeID,
BackdropAttributeID: grant.BackdropAttributeID,
Num: grant.Num,
}); err != nil {
return err
}
r.invalidateStarGiftOwnerProjection(grant.Recipient)
return nil
}

View file

@ -247,6 +247,11 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
about = ""
}
}
// Surface the scam/fake warning to other viewers (never to the account
// itself), non-destructively over the projected About.
if u.ID != currentUserID {
about = aboutWithModerationWarning(about, defaultScamWarningUser, defaultFakeWarningUser, u.Scam, u.Fake)
}
full := tg.UserFull{
ID: u.ID,
About: about,

View file

@ -35,6 +35,11 @@ type ChannelStore interface {
CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error)
UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error)
SetChannelVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error)
SetChannelScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error)
SetChannelAdminSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error)
SetChannelUsernameAdmin(ctx context.Context, channelID int64, username string) (domain.Channel, error)
SetChannelColorAdmin(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error)
SetChannelEmojiStatusAdmin(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error)
ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)

View file

@ -197,6 +197,110 @@ func (s *ChannelStore) SetChannelVerified(_ context.Context, channelID int64, ve
return cloneChannel(channel), nil
}
func (s *ChannelStore) SetChannelScamFake(_ context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, ok := s.channels[channelID]
if !ok || channel.Deleted {
return domain.Channel{}, domain.ErrChannelInvalid
}
channel.Scam = scam
channel.Fake = fake
s.channels[channelID] = channel
return cloneChannel(channel), nil
}
func (s *ChannelStore) SetChannelAdminSettings(_ context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, ok := s.channels[channelID]
if !ok || channel.Deleted {
return domain.Channel{}, domain.ErrChannelInvalid
}
if patch.Gigagroup != nil {
channel.Gigagroup = *patch.Gigagroup
}
if patch.AntiSpam != nil {
channel.AntiSpam = *patch.AntiSpam
}
if patch.ParticipantsHidden != nil {
channel.ParticipantsHidden = *patch.ParticipantsHidden
}
if patch.NoForwards != nil {
channel.NoForwards = *patch.NoForwards
}
if patch.JoinToSend != nil {
channel.JoinToSend = *patch.JoinToSend
}
if patch.JoinRequest != nil {
channel.JoinRequest = *patch.JoinRequest
}
if patch.SlowmodeSeconds != nil {
channel.SlowmodeSeconds = *patch.SlowmodeSeconds
}
s.channels[channelID] = channel
return cloneChannel(channel), nil
}
func (s *ChannelStore) SetChannelUsernameAdmin(_ context.Context, channelID int64, username string) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
s.mu.Lock()
defer s.mu.Unlock()
channel, ok := s.channels[channelID]
if !ok || channel.Deleted {
return domain.Channel{}, domain.ErrChannelInvalid
}
channel.Username = username
s.channels[channelID] = channel
return cloneChannel(channel), nil
}
func (s *ChannelStore) SetChannelColorAdmin(_ context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, ok := s.channels[channelID]
if !ok || channel.Deleted {
return domain.Channel{}, domain.ErrChannelInvalid
}
if forProfile {
channel.ProfileColor = color
} else {
channel.Color = color
}
s.channels[channelID] = channel
return cloneChannel(channel), nil
}
func (s *ChannelStore) SetChannelEmojiStatusAdmin(_ context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if status.DocumentID == 0 {
status.Until = 0
}
s.mu.Lock()
defer s.mu.Unlock()
channel, ok := s.channels[channelID]
if !ok || channel.Deleted {
return domain.Channel{}, domain.ErrChannelInvalid
}
channel.EmojiStatus = status
s.channels[channelID] = channel
return cloneChannel(channel), nil
}
func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
_ = viewerUserID // zero is the anonymous public-web view; no membership state is projected.
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))

View file

@ -283,6 +283,33 @@ func (s *UserStore) SetVerified(_ context.Context, userID int64, verified bool)
return u, nil
}
// SetSupport 设置/取消用户的 support 标记(与 postgres 语义一致)。
func (s *UserStore) SetSupport(_ context.Context, userID int64, support bool) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[userID]
if !ok || u.Deleted {
return domain.User{}, domain.ErrUserNotFound
}
u.Support = support
s.byID[userID] = u
return u, nil
}
// SetScamFake 设置/取消用户的 scam 与 fake 标记(与 postgres 语义一致)。
func (s *UserStore) SetScamFake(_ context.Context, userID int64, scam, fake bool) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[userID]
if !ok || u.Deleted {
return domain.User{}, domain.ErrUserNotFound
}
u.Scam = scam
u.Fake = fake
s.byID[userID] = u
return u, nil
}
// SweepExpiredPremium 清空到期会员行并返回清理后的用户(与 postgres 语义一致)。
func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int) ([]domain.User, error) {
if limit <= 0 {

View file

@ -496,7 +496,7 @@ func scanChannel(row rowScanner) (domain.Channel, error) {
func channelScanDest(ch *domain.Channel, rights, reactionPolicy *string, wallpaper **string) []any {
return []any{
&ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Verified,
&ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Verified, &ch.Scam, &ch.Fake, &ch.Gigagroup,
&ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.HasLink, &ch.LinkedChatID, &ch.LinkedCommunityID, &ch.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights,
reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until,
wallpaper, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID,

View file

@ -284,6 +284,177 @@ func (s *ChannelStore) SetChannelVerified(ctx context.Context, channelID int64,
return channel, nil
}
// SetChannelScamFake 设置/取消频道的 scam 与 fake 标记。
func (s *ChannelStore) SetChannelScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
channel, err := s.channelByID(ctx, s.db, channelID)
if err != nil {
return domain.Channel{}, err
}
if channel.Scam == scam && channel.Fake == fake {
return channel, nil
}
if _, err := s.db.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil {
return domain.Channel{}, fmt.Errorf("set channel scam/fake: %w", err)
}
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
channel.Scam = scam
channel.Fake = fake
return channel, nil
}
// SetChannelAdminSettings applies an admin-direct moderation-settings patch
// (no membership/permission checks). nil fields are left unchanged.
func (s *ChannelStore) SetChannelAdminSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if patch.Empty() {
return s.channelByID(ctx, s.db, channelID)
}
sets := make([]string, 0, 7)
args := []any{channelID}
idx := 2
add := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, val)
idx++
}
if patch.Gigagroup != nil {
add("gigagroup", *patch.Gigagroup)
}
if patch.AntiSpam != nil {
add("antispam", *patch.AntiSpam)
}
if patch.ParticipantsHidden != nil {
add("participants_hidden", *patch.ParticipantsHidden)
}
if patch.NoForwards != nil {
add("noforwards", *patch.NoForwards)
}
if patch.JoinToSend != nil {
add("join_to_send", *patch.JoinToSend)
}
if patch.JoinRequest != nil {
add("join_request", *patch.JoinRequest)
}
if patch.SlowmodeSeconds != nil {
add("slowmode_seconds", *patch.SlowmodeSeconds)
}
query := "UPDATE channels SET " + strings.Join(sets, ", ") + ", updated_at = now() WHERE id = $1 AND NOT deleted"
if _, err := s.db.Exec(ctx, query, args...); err != nil {
return domain.Channel{}, fmt.Errorf("set channel admin settings: %w", err)
}
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
return s.channelByID(ctx, s.db, channelID)
}
// SetChannelUsernameAdmin force-sets or clears (empty) a channel username with
// no permission checks. Username uniqueness is still enforced by peer_usernames.
func (s *ChannelStore) SetChannelUsernameAdmin(ctx context.Context, channelID int64, username string) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.Channel{}, fmt.Errorf("set channel username: db does not support transactions")
}
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
usernameLower := strings.ToLower(username)
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.Channel{}, fmt.Errorf("begin set channel username: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
channel, err := s.channelByID(ctx, tx, channelID)
if err != nil {
return domain.Channel{}, err
}
if strings.EqualFold(channel.Username, username) {
return channel, nil
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, usernameLower); err != nil {
return domain.Channel{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, channelID, username); err != nil {
return domain.Channel{}, fmt.Errorf("set channel username: %w", err)
}
if err := markUserChannelMemberIndexPublicTx(ctx, tx, channelID, username != ""); err != nil {
return domain.Channel{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Channel{}, fmt.Errorf("commit set channel username: %w", err)
}
committed = true
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
channel.Username = username
return channel, nil
}
// SetChannelColorAdmin force-sets a channel name/profile color (no permission checks).
func (s *ChannelStore) SetChannelColorAdmin(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
channel, err := s.channelByID(ctx, s.db, channelID)
if err != nil {
return domain.Channel{}, err
}
if forProfile {
if _, err := s.db.Exec(ctx, `UPDATE channels SET profile_color_set = $2, profile_color = $3, profile_color_background_emoji_id = $4, updated_at = now() WHERE id = $1`,
channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil {
return domain.Channel{}, fmt.Errorf("set channel profile color: %w", err)
}
channel.ProfileColor = color
} else {
if _, err := s.db.Exec(ctx, `UPDATE channels SET color_set = $2, color = $3, color_background_emoji_id = $4, updated_at = now() WHERE id = $1`,
channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil {
return domain.Channel{}, fmt.Errorf("set channel color: %w", err)
}
channel.Color = color
}
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
return channel, nil
}
// SetChannelEmojiStatusAdmin force-sets or clears a channel emoji status (no permission checks).
func (s *ChannelStore) SetChannelEmojiStatusAdmin(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if status.DocumentID == 0 {
status.Until = 0
}
channel, err := s.channelByID(ctx, s.db, channelID)
if err != nil {
return domain.Channel{}, err
}
if _, err := s.db.Exec(ctx, `UPDATE channels SET emoji_status_document_id = $2, emoji_status_until = $3, updated_at = now() WHERE id = $1`,
channelID, status.DocumentID, status.Until); err != nil {
return domain.Channel{}, fmt.Errorf("set channel emoji status: %w", err)
}
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
channel.EmojiStatus = status
return channel, nil
}
func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
_ = viewerUserID // zero is the anonymous public-web view; this query is viewer-independent.
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))

View file

@ -113,7 +113,7 @@ func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore
return s
}
const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.verified,
const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.verified, c.scam, c.fake, c.gigagroup,
c.broadcast, c.megagroup, c.forum, c.forum_tabs, c.autotranslation, c.restricted_sponsored, c.broadcast_messages_allowed, c.send_paid_messages_stars, c.noforwards, c.join_to_send, c.join_request, c.signatures, c.pre_history_hidden, c.participants_hidden, c.antispam,
EXISTS (SELECT 1 FROM channel_invites ci WHERE ci.channel_id = c.id AND NOT ci.revoked) AS has_link,
c.linked_chat_id, c.linked_community_id, c.monoforum, c.linked_monoforum_id, c.slowmode_seconds, c.boosts_unrestrict, c.default_banned_rights::text,

View file

@ -153,6 +153,21 @@ SET verified = sqlc.arg(verified)::boolean,
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: SetUserScamFake :one
UPDATE users
SET scam = sqlc.arg(scam)::boolean,
fake = sqlc.arg(fake)::boolean,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: SetUserSupport :one
UPDATE users
SET support = sqlc.arg(support)::boolean,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: SweepExpiredPremium :many
UPDATE users
SET premium_expires_at = NULL,

View file

@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error {
const insertBotUser = `-- name: InsertBotUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version)
VALUES ($1, '', $2, '', $3, '', TRUE, 1)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type InsertBotUserParams struct {
@ -216,6 +216,8 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}

View file

@ -2283,6 +2283,8 @@ type User struct {
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
LinkedCommunityID int64
Scam bool
Fake bool
}
type UserBusinessProfile struct {

View file

@ -14,7 +14,7 @@ import (
const createUser = `-- name: CreateUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type CreateUserParams struct {
@ -75,12 +75,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id FROM users WHERE id = $1
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
@ -123,12 +125,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
const getUserByPhone = `-- name: GetUserByPhone :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id FROM users WHERE phone = $1 AND deleted_at IS NULL
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake FROM users WHERE phone = $1 AND deleted_at IS NULL
`
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
@ -171,12 +175,14 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
`
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
@ -219,12 +225,14 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
const getUsersByIDs = `-- name: GetUsersByIDs :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
FROM users
WHERE id = ANY($1::bigint[])
ORDER BY id
@ -276,6 +284,8 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
); err != nil {
return nil, err
}
@ -288,7 +298,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
}
const getUsersByPhones = `-- name: GetUsersByPhones :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
FROM users
WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
ORDER BY id
@ -340,6 +350,8 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
); err != nil {
return nil, err
}
@ -535,7 +547,7 @@ UPDATE users
SET premium_expires_at = $1::timestamptz,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type SetUserPremiumUntilParams struct {
@ -583,6 +595,128 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
const setUserScamFake = `-- name: SetUserScamFake :one
UPDATE users
SET scam = $1::boolean,
fake = $2::boolean,
updated_at = now()
WHERE id = $3::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type SetUserScamFakeParams struct {
Scam bool
Fake bool
ID int64
}
func (q *Queries) SetUserScamFake(ctx context.Context, arg SetUserScamFakeParams) (User, error) {
row := q.db.QueryRow(ctx, setUserScamFake, arg.Scam, arg.Fake, arg.ID)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
&i.DefaultHistoryTtlPeriod,
&i.IsBot,
&i.BotInfoVersion,
&i.PremiumExpiresAt,
&i.EmojiStatusDocumentID,
&i.EmojiStatusUntil,
&i.ColorSet,
&i.Color,
&i.ColorBackgroundEmojiID,
&i.ProfileColorSet,
&i.ProfileColor,
&i.ProfileColorBackgroundEmojiID,
&i.BirthdayDay,
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
const setUserSupport = `-- name: SetUserSupport :one
UPDATE users
SET support = $1::boolean,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type SetUserSupportParams struct {
Support bool
ID int64
}
func (q *Queries) SetUserSupport(ctx context.Context, arg SetUserSupportParams) (User, error) {
row := q.db.QueryRow(ctx, setUserSupport, arg.Support, arg.ID)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
&i.DefaultHistoryTtlPeriod,
&i.IsBot,
&i.BotInfoVersion,
&i.PremiumExpiresAt,
&i.EmojiStatusDocumentID,
&i.EmojiStatusUntil,
&i.ColorSet,
&i.Color,
&i.ColorBackgroundEmojiID,
&i.ProfileColorSet,
&i.ProfileColor,
&i.ProfileColorBackgroundEmojiID,
&i.BirthdayDay,
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -592,7 +726,7 @@ UPDATE users
SET verified = $1::boolean,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type SetUserVerifiedParams struct {
@ -640,6 +774,8 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -656,7 +792,7 @@ WHERE id IN (
ORDER BY premium_expires_at
LIMIT $2::int
)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type SweepExpiredPremiumParams struct {
@ -710,6 +846,8 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
); err != nil {
return nil, err
}
@ -728,7 +866,7 @@ SET birthday_day = $1::int,
birthday_year = $3::int,
updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserBirthdayParams struct {
@ -783,6 +921,8 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -794,7 +934,7 @@ SET color_set = $1::boolean,
color_background_emoji_id = $3::bigint,
updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserColorParams struct {
@ -849,6 +989,8 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -861,7 +1003,7 @@ SET emoji_status_document_id = $1::bigint,
emoji_status_collectible = $4::jsonb,
updated_at = now()
WHERE id = $5::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserEmojiStatusParams struct {
@ -918,6 +1060,8 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -944,7 +1088,7 @@ UPDATE users
SET personal_channel_id = $1::bigint,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserPersonalChannelParams struct {
@ -992,6 +1136,8 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -1001,7 +1147,7 @@ UPDATE users
SET phone = $1::text,
updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserPhoneParams struct {
@ -1049,6 +1195,8 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -1060,7 +1208,7 @@ SET first_name = $2,
about = $4,
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserProfileParams struct {
@ -1115,6 +1263,8 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -1126,7 +1276,7 @@ SET profile_color_set = $1::boolean,
profile_color_background_emoji_id = $3::bigint,
updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserProfileColorParams struct {
@ -1181,6 +1331,8 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}
@ -1190,7 +1342,7 @@ UPDATE users
SET username = $2,
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id, scam, fake
`
type UpdateUserUsernameParams struct {
@ -1238,6 +1390,8 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LinkedCommunityID,
&i.Scam,
&i.Fake,
)
return i, err
}

View file

@ -150,20 +150,33 @@ WHERE collectible_revision_id=$1 AND crafted
if err != nil {
return err
}
modelID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID)
modelID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID, req.ModelAttributeID)
if err != nil {
return err
}
patternID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID)
patternID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID, req.PatternAttributeID)
if err != nil {
return err
}
backdropID, err := chooseCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID)
backdropID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID, req.BackdropAttributeID)
if err != nil {
return err
}
num := revision.Issued + 1
if req.Num > 0 {
if req.Num > revision.SupplyTotal {
return domain.ErrStarGiftCollectibleInvalid
}
var numTaken bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM unique_star_gifts WHERE gift_id=$1 AND num=$2)`, locked.GiftID, req.Num).Scan(&numTaken); err != nil {
return fmt.Errorf("check collectible number availability: %w", err)
}
if numTaken {
return domain.ErrStarGiftCollectibleNumberTaken
}
num = req.Num
}
var uniqueID int64
if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil {
return fmt.Errorf("allocate unique star gift id: %w", err)
@ -666,6 +679,30 @@ func debitStarGiftUpgrade(ctx context.Context, tx pgx.Tx, userID, amount int64,
return result, nil
}
// resolveCollectibleAttribute returns explicitID when it names a renderable
// attribute belonging to revisionID (admin-pinned choice), otherwise it falls
// back to the weighted random draw. Models excluded from the random pool
// (crafted) are also rejected for explicit selection to preserve invariants.
func resolveCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID, explicitID int64) (int64, error) {
if explicitID <= 0 {
return chooseCollectibleAttribute(ctx, tx, table, revisionID)
}
extra := ""
if table == "star_gift_collectible_models" {
extra = " AND NOT crafted"
}
var ok bool
if err := tx.QueryRow(ctx, fmt.Sprintf(`SELECT EXISTS (SELECT 1 FROM %s
WHERE id=$1 AND collectible_revision_id=$2 AND rarity_kind='permille' AND rarity_permille > 0%s)`, table, extra),
explicitID, revisionID).Scan(&ok); err != nil {
return 0, fmt.Errorf("validate collectible attribute: %w", err)
}
if !ok {
return 0, domain.ErrStarGiftCollectibleInvalid
}
return explicitID, nil
}
func chooseCollectibleAttribute(ctx context.Context, tx pgx.Tx, table string, revisionID int64) (int64, error) {
extra := ""
if table == "star_gift_collectible_models" {

View file

@ -321,6 +321,37 @@ func (s *UserStore) SetVerified(ctx context.Context, userID int64, verified bool
return userFromModel(row), nil
}
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。
func (s *UserStore) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) {
row, err := s.q.SetUserSupport(ctx, sqlcgen.SetUserSupportParams{
ID: userID,
Support: support,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
return domain.User{}, fmt.Errorf("set user support: %w", err)
}
return userFromModel(row), nil
}
// SetScamFake 设置/取消用户的 scam 与 fake 标记bot 复用同一路径)。
func (s *UserStore) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) {
row, err := s.q.SetUserScamFake(ctx, sqlcgen.SetUserScamFakeParams{
ID: userID,
Scam: scam,
Fake: fake,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
return domain.User{}, fmt.Errorf("set user scam/fake: %w", err)
}
return userFromModel(row), nil
}
// SweepExpiredPremium 清空到期会员行并返回清理后的用户。
func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) {
if limit <= 0 {
@ -547,6 +578,8 @@ func userFromModel(r sqlcgen.User) domain.User {
Username: r.Username,
CountryCode: r.CountryCode,
Verified: r.Verified,
Scam: r.Scam,
Fake: r.Fake,
Support: r.Support,
Bot: r.IsBot,
BotInfoVersion: int(r.BotInfoVersion),

View file

@ -24,6 +24,10 @@ type UserStore interface {
SetPremiumUntil(ctx context.Context, userID int64, until int) (domain.User, error)
// SetVerified 设置/取消用户认证标记。认证是用户基础事实,读取投影统一下发。
SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error)
// SetScamFake 设置/取消用户的 scam 与 fake 标记bot 复用同一路径)。
SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error)
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。
SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error)
// SweepExpiredPremium 把到期premium_expires_at <= now的会员行清空并
// 返回清理后的用户(供推送 updateUser单次最多处理 limit 行。
SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error)