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:
parent
9e45da69ef
commit
313624eab2
63 changed files with 3650 additions and 71 deletions
|
|
@ -43,6 +43,8 @@ type AccountRow struct {
|
|||
Frozen bool
|
||||
Reason string
|
||||
Verified bool
|
||||
Scam bool
|
||||
Fake bool
|
||||
PremiumUntil int64
|
||||
LastActiveAt time.Time
|
||||
DeviceCount int
|
||||
|
|
@ -53,6 +55,8 @@ type AccountDetail struct {
|
|||
About string
|
||||
LastSeenAt int64
|
||||
Verified bool
|
||||
Scam bool
|
||||
Fake bool
|
||||
Support bool
|
||||
Bot bool
|
||||
StarsBalance int64
|
||||
|
|
@ -112,10 +116,19 @@ type ChannelRow struct {
|
|||
Broadcast bool
|
||||
Megagroup bool
|
||||
Forum bool
|
||||
Monoforum bool
|
||||
Verified bool
|
||||
Deleted bool
|
||||
ParticipantsCount int
|
||||
Monoforum bool
|
||||
Verified bool
|
||||
Scam bool
|
||||
Fake bool
|
||||
Gigagroup bool
|
||||
Deleted bool
|
||||
AntiSpam bool
|
||||
ParticipantsHidden bool
|
||||
NoForwards bool
|
||||
JoinToSend bool
|
||||
JoinRequest bool
|
||||
SlowmodeSeconds int
|
||||
ParticipantsCount int
|
||||
AdminsCount int
|
||||
KickedCount int
|
||||
BannedCount int
|
||||
|
|
@ -206,7 +219,7 @@ WITH auth AS (
|
|||
GROUP BY user_id
|
||||
)
|
||||
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake,
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(a.device_count, 0)::int,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
|
||||
|
|
@ -224,7 +237,7 @@ LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
|
|||
out := make([]AccountRow, 0)
|
||||
for rows.Next() {
|
||||
var item AccountRow
|
||||
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.Scam, &item.Fake, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
|
|
@ -237,6 +250,8 @@ type BotRow struct {
|
|||
Username string
|
||||
FirstName string
|
||||
Verified bool
|
||||
Scam bool
|
||||
Fake bool
|
||||
System bool
|
||||
OwnerUserID int64
|
||||
CreatedAt time.Time
|
||||
|
|
@ -262,7 +277,7 @@ func (s *readStore) ListBots(ctx context.Context, beforeID int64, limit int) ([]
|
|||
limit = accountListMaxLimit
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.verified,
|
||||
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.verified, u.scam, u.fake,
|
||||
COALESCE(b.owner_user_id, 0), u.created_at, u.updated_at
|
||||
FROM users u
|
||||
LEFT JOIN bots b ON b.bot_user_id = u.id
|
||||
|
|
@ -277,7 +292,7 @@ LIMIT $2`, beforeID, limit+1)
|
|||
out := make([]BotRow, 0, limit+1)
|
||||
for rows.Next() {
|
||||
var item BotRow
|
||||
if err := rows.Scan(&item.ID, &item.Username, &item.FirstName, &item.Verified, &item.OwnerUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Username, &item.FirstName, &item.Verified, &item.Scam, &item.Fake, &item.OwnerUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
item.System = domain.IsSystemUserID(item.ID)
|
||||
|
|
@ -304,7 +319,7 @@ func (s *readStore) SearchBots(ctx context.Context, q string) ([]BotRow, error)
|
|||
}
|
||||
username := strings.ToLower(strings.TrimPrefix(q, "@"))
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.verified,
|
||||
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.verified, u.scam, u.fake,
|
||||
COALESCE(b.owner_user_id, 0), u.created_at, u.updated_at
|
||||
FROM users u
|
||||
LEFT JOIN bots b ON b.bot_user_id = u.id
|
||||
|
|
@ -319,7 +334,7 @@ LIMIT $3`, id, username, accountSearchLimit)
|
|||
out := make([]BotRow, 0)
|
||||
for rows.Next() {
|
||||
var item BotRow
|
||||
if err := rows.Scan(&item.ID, &item.Username, &item.FirstName, &item.Verified, &item.OwnerUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Username, &item.FirstName, &item.Verified, &item.Scam, &item.Fake, &item.OwnerUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.System = domain.IsSystemUserID(item.ID)
|
||||
|
|
@ -331,14 +346,14 @@ LIMIT $3`, id, username, accountSearchLimit)
|
|||
func (s *readStore) BotDetail(ctx context.Context, botUserID int64) (BotDetail, error) {
|
||||
var out BotDetail
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.about, u.verified,
|
||||
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.about, u.verified, u.scam, u.fake,
|
||||
COALESCE(b.owner_user_id, 0), COALESCE(b.description, ''),
|
||||
u.created_at, u.updated_at
|
||||
FROM users u
|
||||
LEFT JOIN bots b ON b.bot_user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE u.id = $1 AND u.is_bot AND u.deleted_at IS NULL`, botUserID).Scan(
|
||||
&out.Bot.ID, &out.Bot.Username, &out.Bot.FirstName, &out.About, &out.Bot.Verified,
|
||||
&out.Bot.ID, &out.Bot.Username, &out.Bot.FirstName, &out.About, &out.Bot.Verified, &out.Bot.Scam, &out.Bot.Fake,
|
||||
&out.Bot.OwnerUserID, &out.Description, &out.Bot.CreatedAt, &out.Bot.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -377,7 +392,8 @@ func (s *readStore) SearchChannels(ctx context.Context, q string) ([]ChannelRow,
|
|||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
|
||||
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
|
||||
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
|
||||
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.scam, c.fake, c.gigagroup, c.deleted,
|
||||
c.antispam, c.participants_hidden, c.noforwards, c.join_to_send, c.join_request, c.slowmode_seconds,
|
||||
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
|
||||
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at
|
||||
FROM channels c
|
||||
|
|
@ -410,7 +426,8 @@ func (s *readStore) ListChannels(ctx context.Context, beforeUpdatedUS, beforeID
|
|||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
|
||||
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
|
||||
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
|
||||
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.scam, c.fake, c.gigagroup, c.deleted,
|
||||
c.antispam, c.participants_hidden, c.noforwards, c.join_to_send, c.join_request, c.slowmode_seconds,
|
||||
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
|
||||
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at
|
||||
FROM channels c
|
||||
|
|
@ -442,7 +459,8 @@ func (s *readStore) ChannelDetail(ctx context.Context, channelID int64) (Channel
|
|||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about,
|
||||
COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username,
|
||||
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.deleted,
|
||||
c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.scam, c.fake, c.gigagroup, c.deleted,
|
||||
c.antispam, c.participants_hidden, c.noforwards, c.join_to_send, c.join_request, c.slowmode_seconds,
|
||||
c.participants_count, c.admins_count, c.kicked_count, c.banned_count,
|
||||
c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at,
|
||||
row_to_json(c)::jsonb
|
||||
|
|
@ -486,7 +504,8 @@ func scanChannelRow(row channelScanner, item *ChannelRow) error {
|
|||
func channelScanDest(item *ChannelRow) []any {
|
||||
return []any{
|
||||
&item.ID, &item.AccessHash, &item.CreatorUserID, &item.Title, &item.About, &item.Username,
|
||||
&item.Broadcast, &item.Megagroup, &item.Forum, &item.Monoforum, &item.Verified, &item.Deleted,
|
||||
&item.Broadcast, &item.Megagroup, &item.Forum, &item.Monoforum, &item.Verified, &item.Scam, &item.Fake, &item.Gigagroup, &item.Deleted,
|
||||
&item.AntiSpam, &item.ParticipantsHidden, &item.NoForwards, &item.JoinToSend, &item.JoinRequest, &item.SlowmodeSeconds,
|
||||
&item.ParticipantsCount, &item.AdminsCount, &item.KickedCount, &item.BannedCount,
|
||||
&item.TopMessageID, &item.PinnedMessageID, &item.PTS, &item.Date, &item.CreatedAt, &item.UpdatedAt,
|
||||
}
|
||||
|
|
@ -511,7 +530,7 @@ WITH auth AS (
|
|||
GROUP BY user_id
|
||||
)
|
||||
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake,
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
auth.last_active_at, auth.device_count,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
|
||||
|
|
@ -530,7 +549,7 @@ LIMIT $3`, beforeActiveUS, beforeID, limit+1)
|
|||
out := make([]AccountRow, 0, limit+1)
|
||||
for rows.Next() {
|
||||
var item AccountRow
|
||||
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.Scam, &item.Fake, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out = append(out, item)
|
||||
|
|
@ -549,7 +568,7 @@ func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDet
|
|||
var out AccountDetail
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
|
||||
u.about, u.last_seen_at, u.verified, u.support, u.is_bot,
|
||||
u.about, u.last_seen_at, u.verified, u.scam, u.fake, u.support, u.is_bot,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''),
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
COALESCE(sb.balance, 0)::bigint, COALESCE(sb.granted, false),
|
||||
|
|
@ -560,7 +579,7 @@ LEFT JOIN stars_balances sb ON sb.user_id = u.id
|
|||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE u.id = $1`, userID).Scan(
|
||||
&out.Account.ID, &out.Account.Phone, &out.Account.Username, &out.Account.FirstName, &out.Account.LastName,
|
||||
&out.Account.CreatedAt, &out.Account.UpdatedAt, &out.About, &out.LastSeenAt, &out.Verified, &out.Support, &out.Bot,
|
||||
&out.Account.CreatedAt, &out.Account.UpdatedAt, &out.About, &out.LastSeenAt, &out.Verified, &out.Scam, &out.Fake, &out.Support, &out.Bot,
|
||||
&out.Account.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.StarsBalance, &out.StarsGranted, &out.Account.Username,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -962,3 +981,90 @@ func prettyJSON(raw []byte) string {
|
|||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// EmojiRow is a custom-emoji document projection for the admin emoji browser.
|
||||
type EmojiRow struct {
|
||||
DocumentID int64 `json:"DocumentID,string"`
|
||||
Alt string
|
||||
MimeType string
|
||||
Size int64
|
||||
SetTitle string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const emojiListDefaultLimit = 60
|
||||
const emojiListMaxLimit = 200
|
||||
|
||||
func scanEmojiRows(rows pgx.Rows) ([]EmojiRow, error) {
|
||||
out := make([]EmojiRow, 0)
|
||||
for rows.Next() {
|
||||
var item EmojiRow
|
||||
if err := rows.Scan(&item.DocumentID, &item.Alt, &item.MimeType, &item.Size, &item.CreatedAt, &item.SetTitle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
const emojiSelectColumns = `d.id,
|
||||
COALESCE((SELECT a->>'alt' FROM jsonb_array_elements(d.attributes) a WHERE a->>'kind' = 'custom_emoji' LIMIT 1), ''),
|
||||
d.mime_type, d.size, d.created_at,
|
||||
COALESCE((SELECT s.title FROM sticker_sets s WHERE s.emojis AND NOT s.deleted AND s.document_ids @> to_jsonb(d.id) LIMIT 1), '')`
|
||||
|
||||
// ListEmoji pages over custom-emoji documents by descending id.
|
||||
func (s *readStore) ListEmoji(ctx context.Context, beforeID int64, limit int) ([]EmojiRow, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = emojiListDefaultLimit
|
||||
}
|
||||
if limit > emojiListMaxLimit {
|
||||
limit = emojiListMaxLimit
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+emojiSelectColumns+`
|
||||
FROM documents d
|
||||
WHERE d.attributes @> '[{"kind":"custom_emoji"}]'::jsonb
|
||||
AND ($1::bigint = 0 OR d.id < $1)
|
||||
ORDER BY d.id DESC
|
||||
LIMIT $2`, beforeID, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list emoji: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out, err := scanEmojiRows(rows)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// SearchEmoji finds custom-emoji documents by document id or emoticon substring.
|
||||
func (s *readStore) SearchEmoji(ctx context.Context, q string) ([]EmojiRow, error) {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id := int64(-1)
|
||||
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
|
||||
id = n
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+emojiSelectColumns+`
|
||||
FROM documents d
|
||||
WHERE d.attributes @> '[{"kind":"custom_emoji"}]'::jsonb
|
||||
AND (d.id = $1 OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(d.attributes) a
|
||||
WHERE a->>'kind' = 'custom_emoji' AND a->>'alt' ILIKE '%' || $2 || '%'
|
||||
))
|
||||
ORDER BY d.id DESC
|
||||
LIMIT $3`, id, q, emojiListMaxLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search emoji: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEmojiRows(rows)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue