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

@ -91,6 +91,14 @@ TELESRV_PUBLIC_APP_LINK_BASE=
TELESRV_PUBLIC_WEB_BASE_URL=https://web.telesrv.net
TELESRV_PUBLIC_APP_NAME=telesrv
# Profile warning text injected into getFullUser/getFullChannel About for peers
# flagged SCAM/FAKE from the admin panel. Empty keeps built-in English defaults.
# Clients cannot localize server text, so set your audience language here. The
# stored bio/description is never overwritten; the warning is re-applied from the
# flag on every read and survives the owner editing their description.
TELESRV_SCAM_WARNING=
TELESRV_FAKE_WARNING=
# Admin API / Admin UI 配置
#
# TELESRV_ADMIN_API_TOKEN 是主服务 (cmd/telesrv) 暴露 Admin REST API 的鉴权 token

View file

@ -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)
}

View file

@ -55,6 +55,8 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
mux.Handle("GET /api/bots", s.requireAuthAPI(http.HandlerFunc(s.handleBotsAPI)))
mux.Handle("GET /api/bots/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleBotDetailAPI)))
mux.Handle("GET /api/emoji", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAPI)))
mux.Handle("GET /api/emoji/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAnimationAPI)))
mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI)))
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
@ -69,6 +71,16 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
mux.Handle("POST /api/actions/set-account-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserFlagsAPI)))
mux.Handle("POST /api/actions/set-channel-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelFlagsAPI)))
mux.Handle("POST /api/actions/set-support", s.requireAuthAPI(http.HandlerFunc(s.handleSetSupportAPI)))
mux.Handle("POST /api/actions/set-account-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetUsernameAPI)))
mux.Handle("POST /api/actions/set-account-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserColorAPI)))
mux.Handle("POST /api/actions/set-account-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
mux.Handle("POST /api/actions/set-channel-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI)))
mux.Handle("POST /api/actions/set-channel-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelUsernameAPI)))
mux.Handle("POST /api/actions/set-channel-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelColorAPI)))
mux.Handle("POST /api/actions/set-channel-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelEmojiStatusAPI)))
mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI)))
mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI)))
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
@ -80,6 +92,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/publish-gift-collectibles", s.requireAuthAPI(http.HandlerFunc(s.handlePublishStarGiftCollectiblesAPI)))
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
mux.Handle("POST /api/actions/give-gift", s.requireAuthAPI(http.HandlerFunc(s.handleGiveGiftAPI)))
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, http.StatusNotFound, "api route not found")
})
@ -192,6 +205,73 @@ func (s *server) handleStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"Gifts": rows})
}
func (s *server) handleEmojiAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query().Get("q")
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows := []EmojiRow{}
hasMore := false
var err error
if strings.TrimSpace(q) != "" {
rows, err = s.read.SearchEmoji(r.Context(), q)
} else {
rows, hasMore, err = s.read.ListEmoji(r.Context(), beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
if hasMore && len(rows) > 0 {
nextBeforeID = rows[len(rows)-1].DocumentID
}
writeJSON(w, http.StatusOK, map[string]any{
"query": q,
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
"listing": strings.TrimSpace(q) == "",
})
}
func (s *server) handleEmojiAnimationAPI(w http.ResponseWriter, r *http.Request) {
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || documentID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid document id")
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
fmt.Sprintf("%s/v1/emoji/%d/animation", s.cfg.AdminAPIURL, documentID), nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, (4<<20)+1))
if err != nil || len(raw) > 4<<20 {
writeAPIError(w, http.StatusBadGateway, "invalid animation response")
return
}
if resp.StatusCode != http.StatusOK {
writeAPIError(w, resp.StatusCode, string(raw))
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) handleStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {
@ -706,6 +786,254 @@ func (s *server) handleSetVerifiedAPI(w http.ResponseWriter, r *http.Request) {
writeCommandResultAPI(w, result, err)
}
type setUserFlagsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Scam bool `json:"scam"`
Fake bool `json:"fake"`
}
func (s *server) handleSetUserFlagsAPI(w http.ResponseWriter, r *http.Request) {
var body setUserFlagsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUserFlagsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-account-flags"),
UserID: body.UserID,
Scam: body.Scam,
Fake: body.Fake,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-flags", req)
writeCommandResultAPI(w, result, err)
}
type setChannelFlagsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
Scam bool `json:"scam"`
Fake bool `json:"fake"`
}
func (s *server) handleSetChannelFlagsAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelFlagsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelFlagsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-flags"),
ChannelID: body.ChannelID,
Scam: body.Scam,
Fake: body.Fake,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-flags", req)
writeCommandResultAPI(w, result, err)
}
type setSupportAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Support bool `json:"support"`
}
func (s *server) handleSetSupportAPI(w http.ResponseWriter, r *http.Request) {
var body setSupportAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetSupportRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-support"),
UserID: body.UserID,
Support: body.Support,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-support", req)
writeCommandResultAPI(w, result, err)
}
type setUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
}
func (s *server) handleSetUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body setUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-username"),
UserID: body.UserID,
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-username", req)
writeCommandResultAPI(w, result, err)
}
type setUserColorAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
ForProfile bool `json:"for_profile"`
HasColor bool `json:"has_color"`
Color int `json:"color"`
BackgroundEmojiID int64 `json:"background_emoji_id,string"`
}
func (s *server) handleSetUserColorAPI(w http.ResponseWriter, r *http.Request) {
var body setUserColorAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUserColorRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-account-color"),
UserID: body.UserID,
PeerColorInput: admin.PeerColorInput{
ForProfile: body.ForProfile, HasColor: body.HasColor, Color: body.Color, BackgroundEmojiID: body.BackgroundEmojiID,
},
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-color", req)
writeCommandResultAPI(w, result, err)
}
type setUserEmojiStatusAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
DocumentID int64 `json:"document_id,string"`
Until int `json:"until"`
}
func (s *server) handleSetUserEmojiStatusAPI(w http.ResponseWriter, r *http.Request) {
var body setUserEmojiStatusAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUserEmojiStatusRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-account-emoji-status"),
UserID: body.UserID,
EmojiStatusInput: admin.EmojiStatusInput{DocumentID: body.DocumentID, Until: body.Until},
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-emoji-status", req)
writeCommandResultAPI(w, result, err)
}
type setChannelSettingsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
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"`
}
func (s *server) handleSetChannelSettingsAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelSettingsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelSettingsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-settings"),
ChannelID: body.ChannelID,
Gigagroup: body.Gigagroup,
AntiSpam: body.AntiSpam,
ParticipantsHidden: body.ParticipantsHidden,
NoForwards: body.NoForwards,
JoinToSend: body.JoinToSend,
JoinRequest: body.JoinRequest,
SlowmodeSeconds: body.SlowmodeSeconds,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-settings", req)
writeCommandResultAPI(w, result, err)
}
type setChannelUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
Username string `json:"username"`
}
func (s *server) handleSetChannelUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-username"),
ChannelID: body.ChannelID,
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-username", req)
writeCommandResultAPI(w, result, err)
}
type setChannelColorAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
ForProfile bool `json:"for_profile"`
HasColor bool `json:"has_color"`
Color int `json:"color"`
BackgroundEmojiID int64 `json:"background_emoji_id,string"`
}
func (s *server) handleSetChannelColorAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelColorAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelColorRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-color"),
ChannelID: body.ChannelID,
PeerColorInput: admin.PeerColorInput{
ForProfile: body.ForProfile, HasColor: body.HasColor, Color: body.Color, BackgroundEmojiID: body.BackgroundEmojiID,
},
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-color", req)
writeCommandResultAPI(w, result, err)
}
type setChannelEmojiStatusAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
DocumentID int64 `json:"document_id,string"`
Until int `json:"until"`
}
func (s *server) handleSetChannelEmojiStatusAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelEmojiStatusAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelEmojiStatusRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-emoji-status"),
ChannelID: body.ChannelID,
EmojiStatusInput: admin.EmojiStatusInput{DocumentID: body.DocumentID, Until: body.Until},
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-emoji-status", req)
writeCommandResultAPI(w, result, err)
}
type setChannelVerifiedAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
@ -1029,6 +1357,46 @@ func (s *server) handleSetStarGiftSortOrderAPI(w http.ResponseWriter, r *http.Re
writeCommandResultAPI(w, result, err)
}
type giveGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SenderUserID int64 `json:"sender_user_id"`
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
GiftID int64 `json:"gift_id,string"`
HideName bool `json:"hide_name"`
Message string `json:"message"`
Upgrade bool `json:"upgrade"`
ModelAttributeID int64 `json:"model_attribute_id,string"`
PatternAttributeID int64 `json:"pattern_attribute_id,string"`
BackdropAttributeID int64 `json:"backdrop_attribute_id,string"`
Num int `json:"num"`
}
func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
var body giveGiftAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.GiveGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "give-gift"),
SenderUserID: body.SenderUserID,
UserID: body.UserID,
ChannelID: body.ChannelID,
GiftID: body.GiftID,
HideName: body.HideName,
Message: body.Message,
Upgrade: body.Upgrade,
ModelAttributeID: body.ModelAttributeID,
PatternAttributeID: body.PatternAttributeID,
BackdropAttributeID: body.BackdropAttributeID,
Num: body.Num,
}
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/give", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
commandID = strings.TrimSpace(commandID)
if confirm && strings.HasPrefix(commandID, "dry-") {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -21,8 +21,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-BB8hN3NX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BlWlOvtx.css">
<script type="module" crossorigin src="/assets/index-Bx7A77x9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-XV_IEG5m.css">
</head>
<body>
<div id="root"></div>

View file

@ -4,6 +4,7 @@ import type {
BotDetail,
BotListResponse,
ChannelDetail,
EmojiListResponse,
ChannelListResponse,
CommandResult,
GroupMessageDetail,
@ -60,6 +61,8 @@ export const api = {
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`),
bot: (id: number) => request<BotDetail>(`/api/bots/${id}`),
emoji: (params: URLSearchParams) => request<EmojiListResponse>(`/api/emoji?${params.toString()}`),
emojiAnimation: (documentID: string) => request<Record<string, unknown>>(`/api/emoji/${encodeURIComponent(documentID)}/animation`),
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),
message: (ownerUserID: number, msgID: number) => {
const params = new URLSearchParams({ owner_user_id: String(ownerUserID), msg_id: String(msgID) });

View file

@ -8,8 +8,10 @@ import {
Server,
Shield,
ShieldCheck,
Smile,
Users,
Gift
Gift,
Send
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
@ -79,6 +81,8 @@ export function Shell({
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{t("layout.bots")}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{t("layout.giveGifts")}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"

View file

@ -0,0 +1,168 @@
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
import { useState } from "react";
import { ActionButton } from "./ActionButton";
import { useI18n } from "../i18n";
import { toInt } from "../lib/format";
import type { ChannelRow } from "../types";
type IDKey = "user_id" | "channel_id";
// SupportAction toggles the official-support flag (users/bots only).
export function SupportAction({ id, support, onDone }: { id: number; support: boolean; onDone: () => void }) {
const { t } = useI18n();
return (
<ActionButton
label={support ? t("attr.clearSupport") : t("attr.setSupport")}
icon={<LifeBuoy size={15} />}
tone="neutral"
path="/api/actions/set-support"
payload={() => ({ user_id: id, support: !support })}
onDone={onDone}
/>
);
}
// UsernameAction sets or clears (empty) a username.
export function UsernameAction({ idKey, id, path, current, onDone }: {
idKey: IDKey;
id: number;
path: string;
current: string;
onDone: () => void;
}) {
const { t } = useI18n();
const [username, setUsername] = useState(current.replace(/^@/, ""));
return (
<div className="attr-block">
<label className="duration-field">
<span>{t("attr.username")}</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
</label>
<ActionButton
label={t("attr.setUsername")}
icon={<AtSign size={15} />}
tone="neutral"
path={path}
payload={() => ({ [idKey]: id, username: username.trim().replace(/^@/, "") })}
onDone={onDone}
/>
</div>
);
}
// ColorAction sets or clears a name/profile color (Layer 228 peer color).
export function ColorAction({ idKey, id, path, onDone }: {
idKey: IDKey;
id: number;
path: string;
onDone: () => void;
}) {
const { t } = useI18n();
const [forProfile, setForProfile] = useState(false);
const [hasColor, setHasColor] = useState(true);
const [color, setColor] = useState("0");
const [bgEmoji, setBgEmoji] = useState("");
return (
<div className="attr-block">
<label className="checkline"><input type="checkbox" checked={forProfile} onChange={(e) => setForProfile(e.target.checked)} /> {t("attr.forProfile")}</label>
<label className="checkline"><input type="checkbox" checked={hasColor} onChange={(e) => setHasColor(e.target.checked)} /> {t("attr.hasColor")}</label>
<label className="duration-field">
<span>{t("attr.colorIndex")}</span>
<input type="number" min="0" max="20" value={color} onChange={(e) => setColor(e.target.value)} />
</label>
<label className="duration-field">
<span>{t("attr.bgEmojiID")}</span>
<input value={bgEmoji} onChange={(e) => setBgEmoji(e.target.value)} placeholder="0" />
</label>
<ActionButton
label={t("attr.setColor")}
icon={<Palette size={15} />}
tone="neutral"
path={path}
payload={() => ({
[idKey]: id,
for_profile: forProfile,
has_color: hasColor,
color: toInt(color),
background_emoji_id: (bgEmoji.trim() || "0")
})}
onDone={onDone}
/>
</div>
);
}
// EmojiStatusAction sets (document id) or clears (empty) an emoji status.
export function EmojiStatusAction({ idKey, id, path, onDone }: {
idKey: IDKey;
id: number;
path: string;
onDone: () => void;
}) {
const { t } = useI18n();
const [documentID, setDocumentID] = useState("");
const [until, setUntil] = useState("0");
return (
<div className="attr-block">
<label className="duration-field">
<span>{t("attr.emojiDocID")}</span>
<input value={documentID} onChange={(e) => setDocumentID(e.target.value)} placeholder="0 = clear" />
</label>
<label className="duration-field">
<span>{t("attr.emojiUntil")}</span>
<input type="number" min="0" value={until} onChange={(e) => setUntil(e.target.value)} />
</label>
<ActionButton
label={t("attr.setEmojiStatus")}
icon={<Smile size={15} />}
tone="neutral"
path={path}
payload={() => ({ [idKey]: id, document_id: (documentID.trim() || "0"), until: toInt(until) })}
onDone={onDone}
/>
</div>
);
}
// ChannelSettingsAction force-applies moderation settings to a channel/supergroup.
export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow; onDone: () => void }) {
const { t } = useI18n();
const [gigagroup, setGigagroup] = useState(channel.Gigagroup);
const [antispam, setAntispam] = useState(channel.AntiSpam);
const [hidden, setHidden] = useState(channel.ParticipantsHidden);
const [noforwards, setNoforwards] = useState(channel.NoForwards);
const [joinToSend, setJoinToSend] = useState(channel.JoinToSend);
const [joinRequest, setJoinRequest] = useState(channel.JoinRequest);
const [slowmode, setSlowmode] = useState(String(channel.SlowmodeSeconds));
return (
<div className="attr-block">
<label className="checkline"><input type="checkbox" checked={gigagroup} onChange={(e) => setGigagroup(e.target.checked)} /> {t("attr.gigagroup")}</label>
<label className="checkline"><input type="checkbox" checked={antispam} onChange={(e) => setAntispam(e.target.checked)} /> {t("attr.antispam")}</label>
<label className="checkline"><input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} /> {t("attr.participantsHidden")}</label>
<label className="checkline"><input type="checkbox" checked={noforwards} onChange={(e) => setNoforwards(e.target.checked)} /> {t("attr.noforwards")}</label>
<label className="checkline"><input type="checkbox" checked={joinToSend} onChange={(e) => setJoinToSend(e.target.checked)} /> {t("attr.joinToSend")}</label>
<label className="checkline"><input type="checkbox" checked={joinRequest} onChange={(e) => setJoinRequest(e.target.checked)} /> {t("attr.joinRequest")}</label>
<label className="duration-field">
<span>{t("attr.slowmode")}</span>
<input type="number" min="0" max="86400" value={slowmode} onChange={(e) => setSlowmode(e.target.value)} />
</label>
<ActionButton
label={t("attr.applySettings")}
icon={<Settings2 size={15} />}
tone="warn"
path="/api/actions/set-channel-settings"
payload={() => ({
channel_id: channel.ID,
gigagroup,
antispam,
participants_hidden: hidden,
noforwards,
join_to_send: joinToSend,
join_request: joinRequest,
slowmode_seconds: toInt(slowmode)
})}
onDone={onDone}
/>
</div>
);
}

View file

@ -0,0 +1,59 @@
import { ShieldAlert, ShieldX } from "lucide-react";
import { useI18n } from "../i18n";
import { ActionButton } from "./ActionButton";
import { Badge } from "./ui";
// ScamFakeBadges renders the SCAM/FAKE moderation labels when set.
export function ScamFakeBadges({ scam, fake }: { scam: boolean; fake: boolean }) {
const { t } = useI18n();
if (!scam && !fake) {
return null;
}
return (
<>
{scam && <Badge tone="danger">{t("flags.scam")}</Badge>}
{fake && <Badge tone="danger">{t("flags.fake")}</Badge>}
</>
);
}
// ScamFakeActions renders the two independent toggles. The combined setter
// receives the full desired state, so each button flips one flag and keeps the
// other unchanged.
export function ScamFakeActions({
idKey,
id,
path,
scam,
fake,
onDone
}: {
idKey: "user_id" | "channel_id";
id: number;
path: string;
scam: boolean;
fake: boolean;
onDone: () => void;
}) {
const { t } = useI18n();
return (
<div className="action-stack">
<ActionButton
label={scam ? t("flags.clearScam") : t("flags.setScam")}
icon={<ShieldAlert size={15} />}
tone="danger"
path={path}
payload={() => ({ [idKey]: id, scam: !scam, fake })}
onDone={onDone}
/>
<ActionButton
label={fake ? t("flags.clearFake") : t("flags.setFake")}
icon={<ShieldX size={15} />}
tone="danger"
path={path}
payload={() => ({ [idKey]: id, scam, fake: !fake })}
onDone={onDone}
/>
</div>
);
}

View file

@ -63,6 +63,8 @@ const translations: Record<Language, Record<string, string>> = {
"route.messagesSubtitle": "Console / Messages",
"route.gifts": "Star Gifts",
"route.giftsSubtitle": "Console / Star Gifts",
"route.giveGifts": "Give Gifts",
"route.giveGiftsSubtitle": "Console / Give Gifts",
"layout.navigation": "Navigation",
"layout.primaryNav": "Primary navigation",
"layout.dashboard": "Overview",
@ -70,6 +72,7 @@ const translations: Record<Language, Record<string, string>> = {
"layout.channels": "Supergroups / Channels",
"layout.messages": "Messages",
"layout.gifts": "Star Gifts",
"layout.giveGifts": "Give Gifts",
"layout.privateMessages": "Private",
"layout.groupMessages": "Groups",
"layout.runtime": "Runtime",
@ -220,6 +223,45 @@ const translations: Record<Language, Record<string, string>> = {
"bots.delete": "Delete bot",
"bots.deleteHint": "Permanently deletes this user-created bot and invalidates its token. This cannot be undone.",
"bots.systemHint": "System bots are built in and cannot be deleted.",
"flags.scam": "SCAM",
"flags.fake": "FAKE",
"flags.setScam": "Mark as SCAM",
"flags.clearScam": "Clear SCAM",
"flags.setFake": "Mark as FAKE",
"flags.clearFake": "Clear FAKE",
"attr.attributes": "Attributes",
"attr.settings": "Settings",
"attr.username": "Username",
"attr.setUsername": "Set username",
"attr.setSupport": "Mark as support",
"attr.clearSupport": "Clear support",
"attr.forProfile": "Profile color",
"attr.hasColor": "Enable color",
"attr.colorIndex": "Color index",
"attr.bgEmojiID": "Background emoji ID",
"attr.setColor": "Set color",
"attr.emojiDocID": "Emoji document ID",
"attr.emojiUntil": "Until (unix, 0 = permanent)",
"attr.setEmojiStatus": "Set emoji status",
"attr.gigagroup": "Gigagroup",
"attr.antispam": "Aggressive anti-spam",
"attr.participantsHidden": "Hide members",
"attr.noforwards": "Restrict forwarding",
"attr.joinToSend": "Join to send messages",
"attr.joinRequest": "Join by request",
"attr.slowmode": "Slowmode (seconds)",
"attr.applySettings": "Apply settings",
"route.emoji": "Emoji",
"route.emojiSubtitle": "Console / Emoji",
"layout.emoji": "Emoji",
"emoji.pageTitle": "Custom Emoji",
"emoji.queryResults": "Search results",
"emoji.recent": "Custom emoji catalog",
"emoji.currentPage": "Emoji on page",
"emoji.searchPlaceholder": "Document ID or emoji",
"emoji.copyID": "Copy document ID",
"emoji.noSet": "No set",
"emoji.hint": "Document IDs here can be pasted into the Emoji status field on account, bot and channel profiles.",
"messages.privateTitle": "Private Messages",
"messages.privateEyebrow": "Private message boxes",
"messages.groupTitle": "Group Messages",
@ -285,6 +327,37 @@ const translations: Record<Language, Record<string, string>> = {
"messages.pinned": "Pinned",
"messages.channelPost": "Channel post",
"gifts.pageTitle": "Star Gift Catalog",
"giveGift.action": "Give",
"giveGift.eyebrow": "Grant a gift · no charge",
"giveGift.title": "Give gift",
"giveGift.recipientKind": "Recipient type",
"giveGift.recipientUser": "User",
"giveGift.recipientChannel": "Channel",
"giveGift.pickUser": "Recipient user",
"giveGift.pickChannel": "Recipient channel",
"giveGift.recipientRequired": "Select a recipient first",
"giveGift.sender": "Sender account ID",
"giveGift.senderHint": "Defaults to the system account 777000 (Telesrv).",
"giveGift.message": "Attached message (optional)",
"giveGift.messagePlaceholder": "Shown with the gift",
"giveGift.hideName": "Hide sender name from recipient",
"giveGift.upgrade": "Deliver as upgraded collectible",
"giveGift.upgradeNote": "The gift is minted as a unique collectible. Pick specific attributes and a number below, or leave them on Random to draw from the published pool. Requires a published collectible upgrade with remaining supply.",
"giveGift.model": "Model",
"giveGift.pattern": "Pattern",
"giveGift.backdrop": "Backdrop",
"giveGift.number": "Number",
"giveGift.numberAuto": "Auto",
"giveGift.random": "Random",
"giveGift.confirm": "Give gift",
"giveGifts.pageTitle": "Give Gifts",
"giveGifts.eyebrow": "Grant catalog gifts to any user or channel",
"giveGifts.available": "Available gifts",
"giveGifts.sender": "Default sender",
"giveGifts.searchPlaceholder": "Search by title or gift ID",
"giveGifts.hint": "Pick a gift to grant. Delivery is free of charge and sent from the system account 777000 (Telesrv) by default.",
"giveGifts.pickGift": "Select a gift",
"giveGifts.selectPrompt": "Select a gift from the list to start.",
"gifts.eyebrow": "Catalog, immutable revisions and animation assets",
"gifts.total": "Catalog entries",
"gifts.enabled": "Enabled",
@ -474,6 +547,8 @@ const translations: Record<Language, Record<string, string>> = {
"route.messagesSubtitle": "控制台 / 消息",
"route.gifts": "星星礼物",
"route.giftsSubtitle": "控制台 / 星星礼物",
"route.giveGifts": "赠送礼物",
"route.giveGiftsSubtitle": "控制台 / 赠送礼物",
"layout.navigation": "导航",
"layout.primaryNav": "主导航",
"layout.dashboard": "总览",
@ -481,6 +556,7 @@ const translations: Record<Language, Record<string, string>> = {
"layout.channels": "超级群/频道",
"layout.messages": "消息",
"layout.gifts": "礼物目录",
"layout.giveGifts": "赠送礼物",
"layout.privateMessages": "私聊",
"layout.groupMessages": "群聊",
"layout.runtime": "运行状态",
@ -631,6 +707,45 @@ const translations: Record<Language, Record<string, string>> = {
"bots.delete": "删除机器人",
"bots.deleteHint": "永久删除该用户创建的机器人并使其 token 失效。此操作不可撤销。",
"bots.systemHint": "系统内置机器人不可删除。",
"flags.scam": "SCAM",
"flags.fake": "FAKE",
"flags.setScam": "标记为 SCAM",
"flags.clearScam": "移除 SCAM",
"flags.setFake": "标记为 FAKE",
"flags.clearFake": "移除 FAKE",
"attr.attributes": "属性",
"attr.settings": "设置",
"attr.username": "用户名",
"attr.setUsername": "设置用户名",
"attr.setSupport": "标记为客服",
"attr.clearSupport": "取消客服",
"attr.forProfile": "资料颜色",
"attr.hasColor": "启用颜色",
"attr.colorIndex": "颜色编号",
"attr.bgEmojiID": "背景 emoji ID",
"attr.setColor": "设置颜色",
"attr.emojiDocID": "Emoji 文档 ID",
"attr.emojiUntil": "有效期 (unix, 0 = 永久)",
"attr.setEmojiStatus": "设置 emoji 状态",
"attr.gigagroup": "广播群 (gigagroup)",
"attr.antispam": "激进反垃圾",
"attr.participantsHidden": "隐藏成员",
"attr.noforwards": "禁止转发",
"attr.joinToSend": "先加入才能发言",
"attr.joinRequest": "加入需审批",
"attr.slowmode": "慢速模式 (秒)",
"attr.applySettings": "应用设置",
"route.emoji": "Emoji",
"route.emojiSubtitle": "控制台 / Emoji",
"layout.emoji": "Emoji",
"emoji.pageTitle": "自定义 Emoji",
"emoji.queryResults": "查询结果",
"emoji.recent": "自定义 Emoji 目录",
"emoji.currentPage": "当前页 Emoji",
"emoji.searchPlaceholder": "文档 ID 或表情",
"emoji.copyID": "复制文档 ID",
"emoji.noSet": "无所属集合",
"emoji.hint": "这里的文档 ID 可直接填入账号、机器人和频道资料的 Emoji 状态字段。",
"messages.privateTitle": "私聊消息",
"messages.privateEyebrow": "私聊消息盒",
"messages.groupTitle": "群聊消息",
@ -696,6 +811,37 @@ const translations: Record<Language, Record<string, string>> = {
"messages.pinned": "置顶",
"messages.channelPost": "频道帖子",
"gifts.pageTitle": "星星礼物目录",
"giveGift.action": "赠送",
"giveGift.eyebrow": "发放礼物 · 免费",
"giveGift.title": "赠送礼物",
"giveGift.recipientKind": "接收方类型",
"giveGift.recipientUser": "用户",
"giveGift.recipientChannel": "频道",
"giveGift.pickUser": "接收用户",
"giveGift.pickChannel": "接收频道",
"giveGift.recipientRequired": "请先选择接收方",
"giveGift.sender": "发送方账号 ID",
"giveGift.senderHint": "默认使用系统账号 777000Telesrv。",
"giveGift.message": "附加留言(可选)",
"giveGift.messagePlaceholder": "随礼物一起显示",
"giveGift.hideName": "对接收方隐藏发送方名称",
"giveGift.upgrade": "作为升级收藏品发放",
"giveGift.upgradeNote": "礼物将铸造为唯一收藏品。可在下方指定具体属性和编号,或保持“随机”从已发布的属性池中抽取。需要存在有剩余供应量的已发布收藏品升级。",
"giveGift.model": "模型",
"giveGift.pattern": "图案",
"giveGift.backdrop": "背景",
"giveGift.number": "编号",
"giveGift.numberAuto": "自动",
"giveGift.random": "随机",
"giveGift.confirm": "赠送礼物",
"giveGifts.pageTitle": "赠送礼物",
"giveGifts.eyebrow": "向任意用户或频道发放目录礼物",
"giveGifts.available": "可用礼物",
"giveGifts.sender": "默认发送方",
"giveGifts.searchPlaceholder": "按标题或礼物 ID 搜索",
"giveGifts.hint": "选择要发放的礼物。发放免费,默认由系统账号 777000Telesrv发送。",
"giveGifts.pickGift": "选择礼物",
"giveGifts.selectPrompt": "从列表中选择一个礼物开始。",
"gifts.eyebrow": "目录、不可变版本与动画资源",
"gifts.total": "目录条目",
"gifts.enabled": "已启用",
@ -885,6 +1031,8 @@ const translations: Record<Language, Record<string, string>> = {
"route.messagesSubtitle": "Консоль / Сообщения",
"route.gifts": "Звёздные подарки",
"route.giftsSubtitle": "Консоль / Звёздные подарки",
"route.giveGifts": "Выдача подарков",
"route.giveGiftsSubtitle": "Консоль / Выдача подарков",
"layout.navigation": "Навигация",
"layout.primaryNav": "Основное меню",
"layout.dashboard": "Обзор",
@ -892,6 +1040,7 @@ const translations: Record<Language, Record<string, string>> = {
"layout.channels": "Супергруппы / Каналы",
"layout.messages": "Сообщения",
"layout.gifts": "Звёздные подарки",
"layout.giveGifts": "Выдача подарков",
"layout.privateMessages": "Личные",
"layout.groupMessages": "Группы",
"layout.runtime": "Среда выполнения",
@ -1042,6 +1191,45 @@ const translations: Record<Language, Record<string, string>> = {
"bots.delete": "Удалить бота",
"bots.deleteHint": "Безвозвратно удаляет созданного пользователем бота и аннулирует его токен. Действие необратимо.",
"bots.systemHint": "Системные боты встроены и не могут быть удалены.",
"flags.scam": "SCAM",
"flags.fake": "FAKE",
"flags.setScam": "Пометить как SCAM",
"flags.clearScam": "Снять метку SCAM",
"flags.setFake": "Пометить как FAKE",
"flags.clearFake": "Снять метку FAKE",
"attr.attributes": "Атрибуты",
"attr.settings": "Настройки",
"attr.username": "Имя пользователя",
"attr.setUsername": "Задать имя пользователя",
"attr.setSupport": "Пометить как support",
"attr.clearSupport": "Снять support",
"attr.forProfile": "Цвет профиля",
"attr.hasColor": "Включить цвет",
"attr.colorIndex": "Индекс цвета",
"attr.bgEmojiID": "ID фонового эмодзи",
"attr.setColor": "Задать цвет",
"attr.emojiDocID": "ID документа эмодзи",
"attr.emojiUntil": "До (unix, 0 = бессрочно)",
"attr.setEmojiStatus": "Задать emoji-статус",
"attr.gigagroup": "Гигагруппа",
"attr.antispam": "Агрессивный антиспам",
"attr.participantsHidden": "Скрыть участников",
"attr.noforwards": "Запретить пересылку",
"attr.joinToSend": "Вступление для отправки",
"attr.joinRequest": "Вступление по заявке",
"attr.slowmode": "Медленный режим (сек)",
"attr.applySettings": "Применить настройки",
"route.emoji": "Emoji",
"route.emojiSubtitle": "Консоль / Emoji",
"layout.emoji": "Emoji",
"emoji.pageTitle": "Кастом-эмодзи",
"emoji.queryResults": "Результаты поиска",
"emoji.recent": "Каталог кастом-эмодзи",
"emoji.currentPage": "Эмодзи на странице",
"emoji.searchPlaceholder": "ID документа или эмодзи",
"emoji.copyID": "Скопировать ID документа",
"emoji.noSet": "Без набора",
"emoji.hint": "ID документов отсюда можно вставлять в поле Emoji-статуса в профилях аккаунтов, ботов и каналов.",
"messages.privateTitle": "Личные сообщения",
"messages.privateEyebrow": "Личные ящики сообщений",
"messages.groupTitle": "Групповые сообщения",
@ -1107,6 +1295,37 @@ const translations: Record<Language, Record<string, string>> = {
"messages.pinned": "Закреплено",
"messages.channelPost": "Пост в канале",
"gifts.pageTitle": "Каталог звёздных подарков",
"giveGift.action": "Выдать",
"giveGift.eyebrow": "Выдача подарка · без списания",
"giveGift.title": "Выдать подарок",
"giveGift.recipientKind": "Тип получателя",
"giveGift.recipientUser": "Пользователь",
"giveGift.recipientChannel": "Канал",
"giveGift.pickUser": "Получатель (пользователь)",
"giveGift.pickChannel": "Получатель (канал)",
"giveGift.recipientRequired": "Сначала выберите получателя",
"giveGift.sender": "ID аккаунта-отправителя",
"giveGift.senderHint": "По умолчанию системный аккаунт 777000 (Telesrv).",
"giveGift.message": "Сообщение к подарку (необязательно)",
"giveGift.messagePlaceholder": "Показывается вместе с подарком",
"giveGift.hideName": "Скрыть имя отправителя от получателя",
"giveGift.upgrade": "Выдать как улучшенный коллекционный",
"giveGift.upgradeNote": "Подарок будет отчеканен как уникальный коллекционный. Ниже можно выбрать конкретные атрибуты и номер или оставить «Случайно» для выбора из опубликованного пула. Требуется опубликованное коллекционное улучшение с остатком тиража.",
"giveGift.model": "Модель",
"giveGift.pattern": "Узор",
"giveGift.backdrop": "Фон",
"giveGift.number": "Номер",
"giveGift.numberAuto": "Авто",
"giveGift.random": "Случайно",
"giveGift.confirm": "Выдать подарок",
"giveGifts.pageTitle": "Выдача подарков",
"giveGifts.eyebrow": "Выдача каталожных подарков любому пользователю или каналу",
"giveGifts.available": "Доступно подарков",
"giveGifts.sender": "Отправитель по умолчанию",
"giveGifts.searchPlaceholder": "Поиск по названию или ID подарка",
"giveGifts.hint": "Выберите подарок для выдачи. Выдача бесплатна и по умолчанию отправляется от системного аккаунта 777000 (Telesrv).",
"giveGifts.pickGift": "Выберите подарок",
"giveGifts.selectPrompt": "Выберите подарок из списка, чтобы начать.",
"gifts.eyebrow": "Каталог, неизменяемые версии и файлы анимаций",
"gifts.total": "Подарков в каталоге",
"gifts.enabled": "Включено",

View file

@ -4,6 +4,8 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing";
@ -67,6 +69,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>}
{detail.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
<ScamFakeBadges scam={detail.Scam} fake={detail.Fake} />
{account.Frozen ? <Badge tone="danger">{t("account.accountFrozen")}</Badge> : <Badge>{t("account.accountActive")}</Badge>}
</div>
</section>
@ -194,6 +197,12 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load}
/>
</div>
<ScamFakeActions idKey="user_id" id={account.ID} path="/api/actions/set-account-flags" scam={detail.Scam} fake={detail.Fake} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div>
<SupportAction id={account.ID} support={detail.Support} onDone={load} />
<UsernameAction idKey="user_id" id={account.ID} path="/api/actions/set-account-username" current={account.Username} onDone={load} />
<ColorAction idKey="user_id" id={account.ID} path="/api/actions/set-account-color" onDone={load} />
<EmojiStatusAction idKey="user_id" id={account.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
</section>
}
/>

View file

@ -2,6 +2,7 @@ import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics";
@ -111,7 +112,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{t("common.none")}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{row.Frozen ? <Badge tone="danger">{t("account.frozen")}</Badge> : <Badge>{t("common.normal")}</Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>

View file

@ -3,6 +3,8 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { useI18n } from "../i18n";
import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
@ -55,6 +57,7 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
<div className="entity-badges">
<Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? t("bots.system") : t("bots.user")}</Badge>
{bot.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
<ScamFakeBadges scam={bot.Scam} fake={bot.Fake} />
</div>
</section>
<div className="summary-grid">
@ -85,6 +88,11 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
onDone={load}
/>
</div>
<ScamFakeActions idKey="user_id" id={bot.ID} path="/api/actions/set-account-flags" scam={bot.Scam} fake={bot.Fake} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div>
<UsernameAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-username" current={bot.Username} onDone={load} />
<ColorAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-color" onDone={load} />
<EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
{bot.System ? (
<p className="bot-create-note">{t("bots.systemHint")}</p>
) : (

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { displayUsername, formatDate, toInt } from "../lib/format";
import type { Navigate } from "../routing";
@ -152,7 +153,7 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<td>{displayUsername(row.Username) || "-"}</td>
<td>{row.FirstName || "-"}</td>
<td className="mono">{row.OwnerUserID > 0 ? row.OwnerUserID : "-"}</td>
<td>{row.Verified ? <Badge tone="good"><BadgeCheck size={12} /> {t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good"><BadgeCheck size={12} /> {t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{row.System ? <Badge tone="warn">{t("bots.system")}</Badge> : <Badge>{t("bots.user")}</Badge>}</td>
<td>{formatDate(row.CreatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {t("common.detail")} <ChevronRight size={14} /></button></td>

View file

@ -4,6 +4,8 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ChannelSettingsAction, ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types";
@ -51,6 +53,7 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
<div className="entity-badges">
<Badge>{channelKind(ch, t)}</Badge>
{ch.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
<ScamFakeBadges scam={ch.Scam} fake={ch.Fake} />
{ch.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.valid")}</Badge>}
</div>
</section>
@ -86,6 +89,13 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
payload={() => ({ channel_id: ch.ID, verified: !ch.Verified })}
onDone={load}
/>
<ScamFakeActions idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-flags" scam={ch.Scam} fake={ch.Fake} onDone={load} />
<div className="dock-title">{t("attr.settings")}</div>
<ChannelSettingsAction channel={ch} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div>
<UsernameAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-username" current={ch.Username} onDone={load} />
<ColorAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-color" onDone={load} />
<EmojiStatusAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-emoji-status" onDone={load} />
</section>
}
/>

View file

@ -2,6 +2,7 @@ import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { channelKind, displayUsername, formatDate } from "../lib/format";
import { channelMetrics } from "../lib/metrics";
@ -110,7 +111,7 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<td>{row.ParticipantsCount}</td>
<td>{row.AdminsCount}</td>
<td>{row.PTS}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
</tr>

View file

@ -0,0 +1,158 @@
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import type { EmojiListResponse, EmojiRow } from "../types";
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function isAnimated(mime: string): boolean {
const m = mime.toLowerCase();
return m.includes("tgsticker") || m.includes("lottie") || m.includes("json");
}
function EmojiPreview({ row }: { row: EmojiRow }) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [failed, setFailed] = useState(!isAnimated(row.MimeType));
useEffect(() => {
if (!isAnimated(row.MimeType)) {
setFailed(true);
return;
}
let cancelled = false;
api.emojiAnimation(row.DocumentID).then((data) => {
if (cancelled || !host.current) return;
animation.current?.destroy();
animation.current = lottie.loadAnimation({
container: host.current,
renderer: "canvas",
loop: true,
autoplay: true,
animationData: structuredClone(data)
});
}).catch(() => setFailed(true));
return () => {
cancelled = true;
animation.current?.destroy();
animation.current = null;
};
}, [row.DocumentID, row.MimeType]);
if (failed) {
return <div className="emoji-glyph">{row.Alt || "🙂"}</div>;
}
return <div className="emoji-anim" ref={host} />;
}
function EmojiCard({ row }: { row: EmojiRow }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(row.DocumentID);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
// Clipboard is best-effort.
}
}
return (
<div className="emoji-card">
<div className="emoji-preview"><EmojiPreview row={row} /></div>
<div className="emoji-meta">
<span className="emoji-alt">{row.Alt || "—"}</span>
<button className="emoji-id" type="button" onClick={copy} title={t("emoji.copyID")}>
<span className="mono">{row.DocumentID}</span>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
<span className="emoji-sub">{row.SetTitle || t("emoji.noSet")} · {formatBytes(row.Size)}</span>
</div>
</div>
);
}
export function EmojiPage() {
const { t } = useI18n();
const [q, setQ] = useState("");
const [data, setData] = useState<EmojiListResponse | null>(null);
const [cursor, setCursor] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams();
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor));
}
try {
const result = await api.emoji(params);
setData(result);
setCursor(result.next_before_id);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const rows = data?.rows ?? [];
return (
<PageFrame
title={t("emoji.pageTitle")}
eyebrow={data?.listing === false ? t("emoji.queryResults") : t("emoji.recent")}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("emoji.currentPage")} value={String(rows.length)} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("emoji.searchPlaceholder")} />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> {t("messages.nextPage")}
</button>
)}
</form>
</QueryPanel>
<p className="about-text">{t("emoji.hint")}</p>
{rows.length === 0 ? (
<div className="empty-panel">{t("common.noResults")}</div>
) : (
<div className="emoji-grid">
{rows.map((row) => <EmojiCard key={row.DocumentID} row={row} />)}
</div>
)}
</PageFrame>
);
}

View file

@ -23,7 +23,7 @@ function formatBytes(value: number | string) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) {
export function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [playing, setPlaying] = useState(true);

View file

@ -0,0 +1,229 @@
import { CheckCircle2, CircleAlert, Gift, Loader2, Play, User, Users } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
import { Alert, JsonBlock } from "../components/ui";
import { useI18n } from "../i18n";
import type { AccountRow, ChannelRow, CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
const SYSTEM_SENDER = "777000";
type RecipientKind = "user" | "channel";
function attrLabel(attr: StarGiftCollectibleAttributeRow): string {
const rarity = attr.rarity_permille > 0 ? ` · ${(attr.rarity_permille / 10).toFixed(1)}%` : "";
return `${attr.name || `#${attr.id}`}${rarity}`;
}
export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: () => void }) {
const { t } = useI18n();
const [kind, setKind] = useState<RecipientKind>("user");
const [user, setUser] = useState<AccountRow | null>(null);
const [channel, setChannel] = useState<ChannelRow | null>(null);
const [sender, setSender] = useState(SYSTEM_SENDER);
const [message, setMessage] = useState("");
const [hideName, setHideName] = useState(false);
const [upgrade, setUpgrade] = useState(false);
const [preview, setPreview] = useState<StarGiftCollectiblePreview | null>(null);
const [previewError, setPreviewError] = useState("");
const [modelID, setModelID] = useState("0");
const [patternID, setPatternID] = useState("0");
const [backdropID, setBackdropID] = useState("0");
const [num, setNum] = useState("");
const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const recipientID = kind === "user" ? user?.ID ?? 0 : channel?.ID ?? 0;
const upgradable = kind === "user" && upgrade;
// Reset the collectible selection whenever the chosen gift changes; the
// recipient/sender/message are intentionally preserved for fast re-issuing.
useEffect(() => {
setUpgrade(false);
setPreview(null);
setPreviewError("");
setModelID("0");
setPatternID("0");
setBackdropID("0");
setNum("");
setResult(null);
setError("");
}, [gift.GiftID]);
useEffect(() => {
if (!upgradable || preview) return;
let cancelled = false;
setPreviewError("");
api.giftCollectibles(gift.GiftID)
.then((data) => { if (!cancelled) setPreview(data); })
.catch((err) => { if (!cancelled) setPreviewError(errorMessage(err)); });
return () => { cancelled = true; };
}, [upgradable, preview, gift.GiftID]);
function buildPayload(confirm: boolean): Record<string, unknown> {
const senderID = Number.parseInt(sender.trim() || SYSTEM_SENDER, 10);
const parsedNum = Number.parseInt(num.trim(), 10);
return {
gift_id: gift.GiftID,
sender_user_id: Number.isFinite(senderID) ? senderID : 0,
user_id: kind === "user" ? recipientID : 0,
channel_id: kind === "channel" ? recipientID : 0,
hide_name: hideName,
message: message.trim(),
upgrade: upgradable,
model_attribute_id: upgradable ? modelID : "0",
pattern_attribute_id: upgradable ? patternID : "0",
backdrop_attribute_id: upgradable ? backdropID : "0",
num: upgradable && Number.isFinite(parsedNum) && parsedNum > 0 ? parsedNum : 0,
reason: reason.trim(),
confirm
};
}
const previewPayload = useMemo(() => buildPayload(false), [gift.GiftID, kind, recipientID, sender, message, hideName, upgrade, modelID, patternID, backdropID, num, reason]);
const canConfirm = result?.dry_run && !result.error;
async function run(confirm: boolean) {
if (recipientID <= 0) {
setError(t("giveGift.recipientRequired"));
return;
}
if (!reason.trim()) {
setError(t("action.reasonRequired"));
return;
}
setBusy(true);
setError("");
try {
const commandResult = await api.action("/api/actions/give-gift", buildPayload(confirm));
setResult(commandResult);
if (confirm && !commandResult.error) {
onDone?.();
}
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="give-gift-form">
<div className="give-gift-summary">
<Gift size={16} />
<div>
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
<span className="mono">#{gift.GiftID} · {gift.Stars}</span>
</div>
</div>
<div className="give-gift-tabs" role="group" aria-label={t("giveGift.recipientKind")}>
<button type="button" className={`btn ${kind === "user" ? "primary" : ""}`} onClick={() => { setKind("user"); setResult(null); }}>
<User size={15} /> {t("giveGift.recipientUser")}
</button>
<button type="button" className={`btn ${kind === "channel" ? "primary" : ""}`} onClick={() => { setKind("channel"); setUpgrade(false); setResult(null); }}>
<Users size={15} /> {t("giveGift.recipientChannel")}
</button>
</div>
{kind === "user"
? <UserPicker label={t("giveGift.pickUser")} value={user} onChange={(row) => { setUser(row); setResult(null); }} />
: <ChannelPicker label={t("giveGift.pickChannel")} value={channel} onChange={(row) => { setChannel(row); setResult(null); }} />}
<label className="form-field">
<span>{t("giveGift.sender")}</span>
<input value={sender} inputMode="numeric" onChange={(event) => { setSender(event.target.value.replace(/[^0-9]/g, "")); setResult(null); }} placeholder={SYSTEM_SENDER} />
<small className="field-hint">{t("giveGift.senderHint")}</small>
</label>
<label className="form-field">
<span>{t("giveGift.message")}</span>
<textarea value={message} rows={2} maxLength={255} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={t("giveGift.messagePlaceholder")} />
</label>
<label className="gift-switch">
<input type="checkbox" checked={hideName} onChange={(event) => { setHideName(event.target.checked); setResult(null); }} />
<span className="gift-switch-track" aria-hidden="true"><span /></span>
<span>{t("giveGift.hideName")}</span>
</label>
{kind === "user" && (
<>
<label className="gift-switch">
<input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); setNum(""); } setResult(null); }} />
<span className="gift-switch-track" aria-hidden="true"><span /></span>
<span>{t("giveGift.upgrade")}</span>
</label>
{upgrade && <p className="give-gift-upgrade-note">{t("giveGift.upgradeNote")}</p>}
{upgrade && previewError && <Alert>{previewError}</Alert>}
{upgrade && preview && (
<div className="gift-fields-grid give-gift-attrs">
<label>
<span>{t("giveGift.model")}</span>
<select value={modelID} onChange={(event) => { setModelID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
{(preview.models ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
<label>
<span>{t("giveGift.pattern")}</span>
<select value={patternID} onChange={(event) => { setPatternID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
{(preview.patterns ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
<label>
<span>{t("giveGift.backdrop")}</span>
<select value={backdropID} onChange={(event) => { setBackdropID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
<label>
<span>{t("giveGift.number")}</span>
<input type="number" min="1" max={preview.supply_total ?? undefined} value={num} placeholder={t("giveGift.numberAuto")} onChange={(event) => { setNum(event.target.value.replace(/[^0-9]/g, "")); setResult(null); }} />
</label>
</div>
)}
</>
)}
<label className="form-field">
<span>{t("action.reason")}</span>
<textarea value={reason} rows={2} onChange={(event) => setReason(event.target.value)} placeholder={t("action.reasonPlaceholder")} />
</label>
<div className="command-preview">
<div className="preview-head">{t("action.requestPreview")}</div>
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
</div>
{error && <Alert>{error}</Alert>}
{result && (
<div className="result-box">
<div className="result-title">
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
<strong>{result.message || result.error || t("action.result")}</strong>
</div>
<div className="result-line"><span>{t("action.commandID")}</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span>{t("action.status")}</span><strong>{result.status}</strong></div>
<div className="result-line"><span>{t("action.dryRun")}</span><strong>{result.dry_run ? t("common.yes") : t("common.no")}</strong></div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div>
)}
<div className="give-gift-form-actions">
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{result ? t("action.runAgain") : t("action.runDry")}
</button>
<button className="btn primary icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<Gift size={15} />
{t("giveGift.confirm")}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,83 @@
import { Gift, RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, PageFrame } from "../components/ui";
import { useI18n } from "../i18n";
import type { StarGiftRow } from "../types";
import { LottiePreview } from "./GiftsPage";
import { GiveGiftForm } from "./GiveGiftForm";
export function GiveGiftsPage() {
const { t } = useI18n();
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<StarGiftRow | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function load() {
setBusy(true);
setError("");
try {
const rows = (await api.gifts()).Gifts ?? [];
setGifts(rows);
setSelected((current) => current ?? rows[0] ?? null);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => { void load(); }, []);
const visible = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return gifts;
return gifts.filter((gift) =>
String(gift.GiftID).includes(normalized) || gift.Title.toLowerCase().includes(normalized)
);
}, [gifts, query]);
return (
<PageFrame title={t("giveGifts.pageTitle")} eyebrow={t("giveGifts.eyebrow")} actions={
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button>
}>
{error && <Alert>{error}</Alert>}
<p className="give-gift-upgrade-note">{t("giveGifts.hint")}</p>
<div className="give-gift-layout">
<section className="give-gift-picker">
<div className="give-gift-picker-head">
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("giveGifts.searchPlaceholder")} /></label>
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visible.length, total: gifts.length })}</span>
</div>
<div className="give-gift-picker-list" role="listbox" aria-label={t("giveGifts.pickGift")}>
{visible.map((gift) => {
const active = selected?.GiftID === gift.GiftID;
return (
<button key={gift.GiftID} type="button" role="option" aria-selected={active}
className={`give-gift-option ${active ? "selected" : ""} ${gift.Enabled ? "" : "gift-row-disabled"}`}
onClick={() => setSelected(gift)}>
<LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact />
<span className="give-gift-option-info">
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
<span className="mono">#{gift.GiftID}</span>
<span className="give-gift-option-meta">
{gift.Enabled ? <Badge> {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
</span>
</span>
</button>
);
})}
{visible.length === 0 && !busy && <div className="official-gift-empty">{t("common.noResults")}</div>}
</div>
</section>
<section className="give-gift-panel">
{selected
? <GiveGiftForm key={selected.GiftID} gift={selected} onDone={() => void load()} />
: <div className="give-gift-empty-panel"><Gift size={26} /><p>{t("giveGifts.selectPrompt")}</p></div>}
</section>
</div>
</PageFrame>
);
}

View file

@ -5,12 +5,14 @@ import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage";
import { BotsPage } from "./BotsPage";
import { EmojiPage } from "./EmojiPage";
import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { GiftsPage } from "./GiftsPage";
import { GiveGiftsPage } from "./GiveGiftsPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
@ -33,10 +35,16 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
}
if (route.path === "/bots") {
return <BotsPage navigate={navigate} />;
}
if (route.path === "/emoji") {
return <EmojiPage />;
}
if (route.path === "/gifts") {
return <GiftsPage />;
}
if (route.path === "/give-gifts") {
return <GiveGiftsPage />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage

View file

@ -20,7 +20,9 @@ export function routeTitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accounts");
if (pathname.startsWith("/channels")) return t("route.channels");
if (pathname.startsWith("/bots")) return t("route.bots");
if (pathname.startsWith("/emoji")) return t("route.emoji");
if (pathname.startsWith("/messages")) return t("route.messages");
if (pathname.startsWith("/give-gifts")) return t("route.giveGifts");
if (pathname.startsWith("/gifts")) return t("route.gifts");
return t("route.dashboard");
}
@ -29,7 +31,9 @@ export function routeSubtitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
if (pathname.startsWith("/bots")) return t("route.botsSubtitle");
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
if (pathname.startsWith("/give-gifts")) return t("route.giveGiftsSubtitle");
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
return t("route.dashboardSubtitle");
}

View file

@ -280,6 +280,69 @@
.gift-import-modal { width: min(860px, 100%); }
.gift-import-modal-body { gap: 14px; }
.gift-source-tabs { display: flex; gap: 8px; }
/* Give-gift flow */
.give-gift-summary {
display: flex; align-items: center; gap: 11px; padding: 11px 13px;
background: var(--panel-subtle); border: 1px solid var(--line-strong); border-radius: 12px; color: var(--text-soft);
}
.give-gift-summary > svg { flex: 0 0 auto; color: var(--brand); }
.give-gift-summary strong { display: block; font-size: 13px; color: var(--text); }
.give-gift-summary .mono { font-size: 11px; color: var(--muted); }
.give-gift-tabs { display: flex; width: 100%; gap: 4px; padding: 4px; background: var(--panel-subtle); border: 1px solid var(--line-strong); border-radius: 12px; }
.give-gift-tabs .btn { flex: 1 1 0; justify-content: center; min-height: 36px; border: 1px solid transparent; background: transparent; box-shadow: none; color: var(--text-soft); border-radius: 9px; transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease; }
.give-gift-tabs .btn:not(.primary):hover { color: var(--brand); background: var(--brand-tint); }
.give-gift-tabs .btn.primary { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: var(--shadow-brand); }
.give-gift-upgrade-note { margin: 0; padding: 9px 12px; background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: 10px; color: var(--text-soft); font-size: 11px; font-weight: 650; line-height: 1.45; }
/* Collectible attribute pickers reuse .gift-fields-grid but need equal columns
and site-styled selects rather than the import modal's fixed template. */
.give-gift-attrs { grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: end; }
.give-gift-attrs select,
.give-gift-attrs input {
width: 100%; min-width: 0; height: 38px; padding: 0 32px 0 10px;
color: var(--text); background-color: var(--input-bg); border: 1px solid var(--line); border-radius: var(--radius-sm);
font: inherit; font-size: 12px; font-weight: 600;
appearance: none; -webkit-appearance: none; -moz-appearance: none; cursor: pointer;
}
.give-gift-attrs input { padding-right: 10px; cursor: text; text-overflow: ellipsis; }
.give-gift-attrs select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239aa4b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 11px center;
}
.give-gift-attrs select:focus,
.give-gift-attrs input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--focus); outline: none; }
/* Give Gifts page: two-panel picker + form */
.give-gift-layout { display: grid; grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); gap: 16px; align-items: start; }
.give-gift-picker { display: grid; gap: 10px; align-content: start; }
.give-gift-picker-head { display: flex; align-items: center; gap: 12px; }
.give-gift-picker-head .searchbox { flex: 1 1 auto; }
.give-gift-picker-list {
display: grid; gap: 8px; max-height: 640px; padding: 8px; overflow: auto;
background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-lg); scrollbar-gutter: stable;
}
.give-gift-option {
display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 11px; align-items: center; min-width: 0;
padding: 9px; text-align: left; color: var(--text); background: var(--panel);
border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer; box-shadow: var(--shadow-sm);
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.give-gift-option:hover { border-color: var(--brand-tint-border); box-shadow: var(--shadow); transform: translateY(-1px); }
.give-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px var(--focus), var(--shadow); }
.give-gift-option .gift-animation-shell.compact { pointer-events: none; }
.give-gift-option-info { display: grid; gap: 3px; min-width: 0; }
.give-gift-option-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.give-gift-option-info .mono { color: var(--muted); font-size: 10px; }
.give-gift-option-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
.give-gift-panel {
display: grid; gap: 12px; padding: 16px; min-width: 0;
background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius-lg);
}
.give-gift-form { display: grid; gap: 12px; min-width: 0; }
.give-gift-form-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; padding-top: 4px; }
.give-gift-empty-panel { display: grid; gap: 10px; place-items: center; padding: 48px 20px; color: var(--muted); text-align: center; }
.give-gift-empty-panel svg { color: var(--brand); opacity: .8; }
.official-gift-picker { display: grid; min-width: 0; gap: 12px; }
.official-gift-tools { display: flex; align-items: center; gap: 12px; }
.official-gift-tools .searchbox { width: 100%; }
@ -484,6 +547,8 @@
@media (max-width: 900px) {
.gift-fields-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.give-gift-layout { grid-template-columns: 1fr; }
.give-gift-picker-list { max-height: 320px; }
.collectible-row.animated,
.collectible-row.backdrop { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.collectible-inline-preview,
@ -509,3 +574,105 @@
.collectible-row.backdrop { grid-template-columns: 1fr; }
.collectible-active-grid { grid-template-columns: 1fr 1fr; }
}
.attr-block {
display: grid;
gap: 8px;
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.attr-block .duration-field input {
width: 100%;
}
.attr-block .btn {
width: 100%;
justify-content: center;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.emoji-card {
display: grid;
gap: 8px;
padding: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.emoji-preview {
display: grid;
place-items: center;
height: 88px;
background: var(--surface-soft);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.emoji-anim {
width: 80px;
height: 80px;
}
.emoji-anim canvas {
width: 100% !important;
height: 100% !important;
}
.emoji-glyph {
font-size: 46px;
line-height: 1;
}
.emoji-meta {
display: grid;
gap: 4px;
min-width: 0;
}
.emoji-alt {
font-size: 18px;
line-height: 1.2;
}
.emoji-id {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px;
color: var(--text);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 11px;
}
.emoji-id .mono {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emoji-id:hover {
border-color: var(--brand-tint-border);
color: var(--brand);
}
.emoji-sub {
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -9,6 +9,8 @@ export type AccountRow = {
Frozen: boolean;
Reason: string;
Verified: boolean;
Scam: boolean;
Fake: boolean;
PremiumUntil: number;
LastActiveAt: string;
DeviceCount: number;
@ -58,6 +60,8 @@ export type AccountDetail = {
About: string;
LastSeenAt: number;
Verified: boolean;
Scam: boolean;
Fake: boolean;
Support: boolean;
Bot: boolean;
StarsBalance: number;
@ -80,7 +84,16 @@ export type ChannelRow = {
Forum: boolean;
Monoforum: boolean;
Verified: boolean;
Scam: boolean;
Fake: boolean;
Gigagroup: boolean;
Deleted: boolean;
AntiSpam: boolean;
ParticipantsHidden: boolean;
NoForwards: boolean;
JoinToSend: boolean;
JoinRequest: boolean;
SlowmodeSeconds: number;
ParticipantsCount: number;
AdminsCount: number;
KickedCount: number;
@ -104,6 +117,8 @@ export type BotRow = {
Username: string;
FirstName: string;
Verified: boolean;
Scam: boolean;
Fake: boolean;
System: boolean;
OwnerUserID: number;
CreatedAt: string;
@ -313,6 +328,23 @@ export type BotListResponse = {
listing: boolean;
};
export type EmojiRow = {
DocumentID: string;
Alt: string;
MimeType: string;
Size: number;
SetTitle: string;
CreatedAt: string;
};
export type EmojiListResponse = {
query: string;
rows: EmojiRow[];
has_more: boolean;
next_before_id: number;
listing: boolean;
};
export type MessageListResponse = {
owner_user_id: number;
peer_id: number;

View file

@ -820,6 +820,7 @@ func run(logger *zap.Logger) error {
Sender: loginEmailSender,
}))
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
rpc.SetModerationWarnings(cfg.ScamWarning, cfg.FakeWarning)
router := rpc.New(rpc.Config{
DC: cfg.DC,
IP: cfg.AdvertiseIP,
@ -921,7 +922,9 @@ func run(logger *zap.Logger) error {
ChannelNotifier: router,
Messages: messagesService,
Gifts: giftsService,
GiftGranter: router,
Bots: botsService,
Emoji: filesService,
})
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
// router 创建后注入。

View file

@ -0,0 +1,7 @@
ALTER TABLE public.channels
DROP COLUMN IF EXISTS scam,
DROP COLUMN IF EXISTS fake;
ALTER TABLE public.users
DROP COLUMN IF EXISTS scam,
DROP COLUMN IF EXISTS fake;

View file

@ -0,0 +1,9 @@
-- SCAM / FAKE moderation flags for users (incl. bots) and channels.
-- Mirrors the Layer 228 user.scam/user.fake and channel.scam/channel.fake TL flags.
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS fake boolean DEFAULT false NOT NULL;
ALTER TABLE public.channels
ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS fake boolean DEFAULT false NOT NULL;

View file

@ -0,0 +1,2 @@
ALTER TABLE public.channels
DROP COLUMN IF EXISTS gigagroup;

View file

@ -0,0 +1,3 @@
-- gigagroup flag for supergroups (Layer 228 channel.gigagroup).
ALTER TABLE public.channels
ADD COLUMN IF NOT EXISTS gigagroup boolean DEFAULT false NOT NULL;

View file

@ -63,6 +63,8 @@ This document describes every setting loaded by `internal/config`. Defaults and
| `TELESRV_PUBLIC_APP_LINK_BASE` | nullable custom URL base / empty | Optional host-based root for multi-server clients, for example `owpg://example.com`. When set, links use `owpg://example.com/oauth`, `owpg://example.com/<username>`, and equivalent route paths. Only exact `<custom-scheme>://<host>` values are accepted; ports, paths, queries, and fragments are rejected. `TELESRV_PUBLIC_APP_SCHEME` remains an accepted legacy input. |
| `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | Web-client root used by public username pages. Same URL validation as `TELESRV_PUBLIC_BASE_URL`. |
| `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | Public landing-page product name; trimmed, non-empty, no control characters, maximum 64 Unicode characters. |
| `TELESRV_SCAM_WARNING` | string / empty | Overrides the profile warning injected into `getFullUser`/`getFullChannel` About for SCAM-flagged peers. Empty keeps the built-in per-peer-type English default. Non-destructive: the stored bio/description is never overwritten and the warning is re-applied from the flag on every read. Clients cannot localize server-provided text. |
| `TELESRV_FAKE_WARNING` | string / empty | Same as `TELESRV_SCAM_WARNING`, for FAKE-flagged peers. |
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Read-only username/avatar/sticker/emoji/chatlist/collectible-gift landing-page listener. Empty disables it. Production should bind loopback behind exact nginx routes. `.env.example` enables `127.0.0.1:2401` for development. |
| `TELESRV_TELEGRAM_LOGIN_ENABLE` | bool / `false` | Mount the self-hosted Telegram Login/OIDC provider on `TELESRV_PUBLIC_LINK_WEB_ADDR`. Enabling it requires that listener and all key files below. |
| `TELESRV_TELEGRAM_LOGIN_ISSUER` | absolute origin URL / `TELESRV_PUBLIC_BASE_URL` | Exact public issuer used in discovery and tokens. HTTPS is required by default; paths, credentials, query, and fragment are rejected. The next setting permits any HTTP host/IP. |

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)