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

@ -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;