Merge pull request #13 from epilepticseizureee/feature/admin-soft-ui-dark-theme
Admin panel: Layer 228 moderation, gift granting, bot management, UI updates, QA fixes, and maintainer-reviewed correctness fixes.`n`nMerged with commit history preserved so the original contributor remains attributed.
This commit is contained in:
commit
b2ae0f64b0
85 changed files with 6047 additions and 300 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -232,6 +245,140 @@ LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type BotRow struct {
|
||||
ID int64
|
||||
Username string
|
||||
FirstName string
|
||||
Verified bool
|
||||
Scam bool
|
||||
Fake bool
|
||||
System bool
|
||||
OwnerUserID int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type BotDetail struct {
|
||||
Bot BotRow
|
||||
About string
|
||||
Description string
|
||||
OwnerUsername string
|
||||
AuditLogs []AuditLogRow
|
||||
}
|
||||
|
||||
// ListBots pages over live bot accounts (users.is_bot, not tombstoned) by
|
||||
// descending id. Bots are excluded from ListAccounts, so this is the dedicated
|
||||
// projection for them.
|
||||
func (s *readStore) ListBots(ctx context.Context, beforeID int64, limit int) ([]BotRow, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = accountListDefaultLimit
|
||||
}
|
||||
if limit > accountListMaxLimit {
|
||||
limit = accountListMaxLimit
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
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
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE u.is_bot AND u.deleted_at IS NULL AND ($1::bigint = 0 OR u.id < $1)
|
||||
ORDER BY u.id DESC
|
||||
LIMIT $2`, beforeID, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list bots: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
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.Scam, &item.Fake, &item.OwnerUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
item.System = domain.IsSystemUserID(item.ID)
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
func (s *readStore) SearchBots(ctx context.Context, q string) ([]BotRow, 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
|
||||
}
|
||||
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, 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
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE u.is_bot AND u.deleted_at IS NULL AND (u.id = $1 OR lower(u.username) = $2 OR p.username_lower = $2)
|
||||
ORDER BY u.id DESC
|
||||
LIMIT $3`, id, username, accountSearchLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search bots: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]BotRow, 0)
|
||||
for rows.Next() {
|
||||
var item BotRow
|
||||
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)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
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, 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.Scam, &out.Bot.Fake,
|
||||
&out.Bot.OwnerUserID, &out.Description, &out.Bot.CreatedAt, &out.Bot.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("get bot: %w", err)
|
||||
}
|
||||
out.Bot.System = domain.IsSystemUserID(out.Bot.ID)
|
||||
if out.Bot.OwnerUserID > 0 {
|
||||
var ownerUsername string
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(NULLIF(u.username, ''), p.username_lower, '')
|
||||
FROM users u
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE u.id = $1`, out.Bot.OwnerUserID).Scan(&ownerUsername); err != nil && err != pgx.ErrNoRows {
|
||||
return out, fmt.Errorf("get bot owner: %w", err)
|
||||
} else {
|
||||
out.OwnerUsername = ownerUsername
|
||||
}
|
||||
}
|
||||
out.AuditLogs, err = s.auditLogs(ctx, botUserID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *readStore) SearchChannels(ctx context.Context, q string) ([]ChannelRow, error) {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
|
|
@ -245,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
|
||||
|
|
@ -278,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
|
||||
|
|
@ -310,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
|
||||
|
|
@ -354,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,
|
||||
}
|
||||
|
|
@ -379,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
|
||||
|
|
@ -398,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)
|
||||
|
|
@ -417,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),
|
||||
|
|
@ -428,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 {
|
||||
|
|
@ -830,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
|
||||
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
||||
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)))
|
||||
|
|
@ -67,6 +71,18 @@ 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)))
|
||||
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
|
||||
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
|
||||
|
|
@ -76,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")
|
||||
})
|
||||
|
|
@ -188,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 {
|
||||
|
|
@ -346,6 +430,108 @@ func (s *server) handleAccountDetailAPI(w http.ResponseWriter, r *http.Request)
|
|||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (s *server) handleBotsAPI(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 := []BotRow{}
|
||||
hasMore := false
|
||||
var err error
|
||||
if strings.TrimSpace(q) != "" {
|
||||
rows, err = s.read.SearchBots(r.Context(), q)
|
||||
} else {
|
||||
rows, hasMore, err = s.read.ListBots(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].ID
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = accountListDefaultLimit
|
||||
}
|
||||
if limit > accountListMaxLimit {
|
||||
limit = accountListMaxLimit
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"query": q,
|
||||
"limit": limit,
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
"listing": strings.TrimSpace(q) == "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleBotDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
botID, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || botID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.BotDetail(r.Context(), botID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
type createBotAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (s *server) handleCreateBotAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body createBotAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.CreateBotRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-bot"),
|
||||
OwnerUserID: body.OwnerUserID,
|
||||
Name: body.Name,
|
||||
Username: body.Username,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/bots/create", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type deleteBotAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
BotUserID int64 `json:"bot_user_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleDeleteBotAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body deleteBotAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.DeleteBotRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-bot"),
|
||||
BotUserID: body.BotUserID,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/bots/delete", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) handleChannelsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
|
|
@ -600,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"`
|
||||
|
|
@ -923,6 +1357,44 @@ 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"`
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
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-") {
|
||||
|
|
|
|||
1
cmd/telesrv-admin/web/dist/assets/index-BwxAoLbQ.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-BwxAoLbQ.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-CsfWywUl.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-CsfWywUl.js
vendored
Normal file
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
21
cmd/telesrv-admin/web/dist/index.html
vendored
21
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -4,8 +4,25 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>telesrv admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-Duge82ST.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DHdrFM5j.css">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("telesrv.admin.theme");
|
||||
var theme =
|
||||
stored === "light" || stored === "dark"
|
||||
? stored
|
||||
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CsfWywUl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,23 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>telesrv admin</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("telesrv.admin.theme");
|
||||
var theme =
|
||||
stored === "light" || stored === "dark"
|
||||
? stored
|
||||
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import type {
|
||||
AccountDetail,
|
||||
AccountListResponse,
|
||||
BotDetail,
|
||||
BotListResponse,
|
||||
ChannelDetail,
|
||||
EmojiListResponse,
|
||||
ChannelListResponse,
|
||||
CommandResult,
|
||||
GroupMessageDetail,
|
||||
|
|
@ -56,6 +59,10 @@ export const api = {
|
|||
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
|
||||
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
|
||||
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) });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Database,
|
||||
LayoutDashboard,
|
||||
|
|
@ -7,13 +8,16 @@ import {
|
|||
Server,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Smile,
|
||||
Users,
|
||||
Gift
|
||||
Gift,
|
||||
Send
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api";
|
||||
import { LanguageSwitch, useI18n } from "../i18n";
|
||||
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
|
||||
import { ThemeSwitch } from "../theme";
|
||||
import { AppLink } from "./AppLink";
|
||||
|
||||
export function BootScreen() {
|
||||
|
|
@ -75,7 +79,10 @@ export function Shell({
|
|||
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{t("layout.dashboard")}</NavLink>
|
||||
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
|
||||
<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"
|
||||
|
|
@ -123,6 +130,7 @@ export function Shell({
|
|||
<h1>{routeTitle(route.path, t)}</h1>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<ThemeSwitch />
|
||||
<LanguageSwitch />
|
||||
<span className="actor-pill">{t("layout.actor", { actor })}</span>
|
||||
<button className="btn ghost icon-text" type="button" onClick={logout} title={t("layout.logout")}>
|
||||
|
|
|
|||
57
cmd/telesrv-admin/web/src/components/StaticLottie.tsx
Normal file
57
cmd/telesrv-admin/web/src/components/StaticLottie.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import lottie from "lottie-web/build/player/lottie_light_canvas";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// StaticLottie renders a single (first) frame of a Lottie/TGS animation instead
|
||||
// of looping it, so a grid of many stickers/emoji does not keep the canvas
|
||||
// rendering and pinning the CPU. It plays only while hovered, then resets to the
|
||||
// static frame. Use it for list/grid previews; keep the looping player for
|
||||
// single, focused previews.
|
||||
export function StaticLottie({
|
||||
loader,
|
||||
cacheKey,
|
||||
className,
|
||||
playOnHover = true,
|
||||
onError
|
||||
}: {
|
||||
loader: () => Promise<Record<string, unknown>>;
|
||||
cacheKey: string;
|
||||
className?: string;
|
||||
playOnHover?: boolean;
|
||||
onError?: () => void;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loader()
|
||||
.then((data) => {
|
||||
if (cancelled || !host.current) return;
|
||||
animation.current?.destroy();
|
||||
animation.current = lottie.loadAnimation({
|
||||
container: host.current,
|
||||
renderer: "canvas",
|
||||
loop: true,
|
||||
autoplay: false,
|
||||
animationData: structuredClone(data)
|
||||
});
|
||||
animation.current.goToAndStop(0, true);
|
||||
})
|
||||
.catch(() => onError?.());
|
||||
return () => {
|
||||
cancelled = true;
|
||||
animation.current?.destroy();
|
||||
animation.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cacheKey]);
|
||||
|
||||
function play() {
|
||||
if (playOnHover) animation.current?.play();
|
||||
}
|
||||
function reset() {
|
||||
if (playOnHover) animation.current?.goToAndStop(0, true);
|
||||
}
|
||||
|
||||
return <div className={className} ref={host} onMouseEnter={play} onMouseLeave={reset} />;
|
||||
}
|
||||
186
cmd/telesrv-admin/web/src/components/attributes.tsx
Normal file
186
cmd/telesrv-admin/web/src/components/attributes.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
|
||||
import { useEffect, 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));
|
||||
|
||||
// Re-sync the toggles with the persisted state whenever the channel reloads
|
||||
// (e.g. after applying a change), so previously-applied settings stay checked.
|
||||
useEffect(() => {
|
||||
setGigagroup(channel.Gigagroup);
|
||||
setAntispam(channel.AntiSpam);
|
||||
setHidden(channel.ParticipantsHidden);
|
||||
setNoforwards(channel.NoForwards);
|
||||
setJoinToSend(channel.JoinToSend);
|
||||
setJoinRequest(channel.JoinRequest);
|
||||
setSlowmode(String(channel.SlowmodeSeconds));
|
||||
}, [channel]);
|
||||
|
||||
// Send only the fields the admin actually changed. The backend applies a
|
||||
// partial patch (nil = leave unchanged), so an unrelated setting is never
|
||||
// reset when another one is applied.
|
||||
function buildPatch() {
|
||||
const patch: Record<string, unknown> = { channel_id: channel.ID };
|
||||
if (gigagroup !== channel.Gigagroup) patch.gigagroup = gigagroup;
|
||||
if (antispam !== channel.AntiSpam) patch.antispam = antispam;
|
||||
if (hidden !== channel.ParticipantsHidden) patch.participants_hidden = hidden;
|
||||
if (noforwards !== channel.NoForwards) patch.noforwards = noforwards;
|
||||
if (joinToSend !== channel.JoinToSend) patch.join_to_send = joinToSend;
|
||||
if (joinRequest !== channel.JoinRequest) patch.join_request = joinRequest;
|
||||
if (toInt(slowmode) !== channel.SlowmodeSeconds) patch.slowmode_seconds = toInt(slowmode);
|
||||
return patch;
|
||||
}
|
||||
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={buildPatch}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
cmd/telesrv-admin/web/src/components/flags.tsx
Normal file
59
cmd/telesrv-admin/web/src/components/flags.tsx
Normal 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 toggles. scam and fake are mutually exclusive
|
||||
// (a peer is never both in Telegram), so enabling one clears the other; the
|
||||
// combined setter always receives the full desired state.
|
||||
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: !scam ? false : fake })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
<ActionButton
|
||||
label={fake ? t("flags.clearFake") : t("flags.setFake")}
|
||||
icon={<ShieldX size={15} />}
|
||||
tone="danger"
|
||||
path={path}
|
||||
payload={() => ({ [idKey]: id, fake: !fake, scam: !fake ? false : scam })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
@ -84,6 +87,8 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"language.en": "EN",
|
||||
"language.zh": "中文",
|
||||
"language.ru": "RU",
|
||||
"theme.switchToDark": "Switch to dark theme",
|
||||
"theme.switchToLight": "Switch to light theme",
|
||||
"login.heading": "Operations Admin",
|
||||
"login.body": "Enter credentials to open the console.",
|
||||
"login.secret": "Admin password or token",
|
||||
|
|
@ -181,6 +186,82 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"channel.kind.forum": "Supergroup / Forum",
|
||||
"channel.kind.megagroup": "Supergroup",
|
||||
"channel.kind.generic": "Channel / Group",
|
||||
"route.bots": "Bots",
|
||||
"route.botsSubtitle": "Console / Bots",
|
||||
"layout.bots": "Bots",
|
||||
"bots.pageTitle": "Bots",
|
||||
"bots.queryResults": "Search results",
|
||||
"bots.recent": "Recently created bots",
|
||||
"bots.currentPage": "Bots on page",
|
||||
"bots.banned": "Banned",
|
||||
"bots.active": "Active",
|
||||
"bots.createTitle": "Create a system bot",
|
||||
"bots.createHint": "Provision a bot account owned by the given user. The token is shown once after confirmation.",
|
||||
"bots.ownerUserID": "Owner user ID",
|
||||
"bots.name": "Display name",
|
||||
"bots.namePlaceholder": "e.g. Service Bot",
|
||||
"bots.username": "Username",
|
||||
"bots.usernameHint": "Username must be 5-32 characters and end with 'bot'.",
|
||||
"bots.create": "Create bot",
|
||||
"bots.searchPlaceholder": "Bot ID / username",
|
||||
"bots.botID": "Bot ID",
|
||||
"bots.owner": "Owner",
|
||||
"bots.status": "Status",
|
||||
"bots.detailTitle": "Bot #{id}",
|
||||
"bots.profile": "Bot Profile",
|
||||
"bots.loadingDetail": "Loading bot detail",
|
||||
"bots.unnamed": "Unnamed bot",
|
||||
"bots.restriction": "Restriction",
|
||||
"bots.actionDock": "Bot Actions",
|
||||
"bots.banUntil": "Ban until",
|
||||
"bots.ban": "Ban bot",
|
||||
"bots.updateBan": "Update ban",
|
||||
"bots.unban": "Unban bot",
|
||||
"bots.type": "Type",
|
||||
"bots.system": "System",
|
||||
"bots.user": "User",
|
||||
"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",
|
||||
|
|
@ -246,6 +327,35 @@ 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": "Gifts are always sent from 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 below, or leave them on Random to draw from the published pool. The collectible number is assigned automatically. Requires a published collectible upgrade with remaining supply.",
|
||||
"giveGift.model": "Model",
|
||||
"giveGift.pattern": "Pattern",
|
||||
"giveGift.backdrop": "Backdrop",
|
||||
"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",
|
||||
|
|
@ -435,6 +545,8 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"route.messagesSubtitle": "控制台 / 消息",
|
||||
"route.gifts": "星星礼物",
|
||||
"route.giftsSubtitle": "控制台 / 星星礼物",
|
||||
"route.giveGifts": "赠送礼物",
|
||||
"route.giveGiftsSubtitle": "控制台 / 赠送礼物",
|
||||
"layout.navigation": "导航",
|
||||
"layout.primaryNav": "主导航",
|
||||
"layout.dashboard": "总览",
|
||||
|
|
@ -442,6 +554,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"layout.channels": "超级群/频道",
|
||||
"layout.messages": "消息",
|
||||
"layout.gifts": "礼物目录",
|
||||
"layout.giveGifts": "赠送礼物",
|
||||
"layout.privateMessages": "私聊",
|
||||
"layout.groupMessages": "群聊",
|
||||
"layout.runtime": "运行状态",
|
||||
|
|
@ -456,6 +569,8 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"language.en": "EN",
|
||||
"language.zh": "中文",
|
||||
"language.ru": "RU",
|
||||
"theme.switchToDark": "切换到深色主题",
|
||||
"theme.switchToLight": "切换到浅色主题",
|
||||
"login.heading": "运维后台",
|
||||
"login.body": "输入凭据后进入控制台。",
|
||||
"login.secret": "管理员密码或 token",
|
||||
|
|
@ -553,6 +668,82 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"channel.kind.forum": "超级群/论坛",
|
||||
"channel.kind.megagroup": "超级群",
|
||||
"channel.kind.generic": "频道/群",
|
||||
"route.bots": "机器人",
|
||||
"route.botsSubtitle": "控制台 / 机器人",
|
||||
"layout.bots": "机器人",
|
||||
"bots.pageTitle": "机器人",
|
||||
"bots.queryResults": "查询结果",
|
||||
"bots.recent": "最近创建的机器人",
|
||||
"bots.currentPage": "当前页机器人",
|
||||
"bots.banned": "已封禁",
|
||||
"bots.active": "正常",
|
||||
"bots.createTitle": "创建系统机器人",
|
||||
"bots.createHint": "为指定用户创建机器人账号。确认后 token 只显示一次。",
|
||||
"bots.ownerUserID": "所属用户 ID",
|
||||
"bots.name": "显示名称",
|
||||
"bots.namePlaceholder": "例如:服务机器人",
|
||||
"bots.username": "用户名",
|
||||
"bots.usernameHint": "用户名需 5-32 个字符,且以 bot 结尾。",
|
||||
"bots.create": "创建机器人",
|
||||
"bots.searchPlaceholder": "机器人 ID / 用户名",
|
||||
"bots.botID": "机器人 ID",
|
||||
"bots.owner": "所属用户",
|
||||
"bots.status": "状态",
|
||||
"bots.detailTitle": "机器人 #{id}",
|
||||
"bots.profile": "机器人档案",
|
||||
"bots.loadingDetail": "加载机器人详情",
|
||||
"bots.unnamed": "未命名机器人",
|
||||
"bots.restriction": "限制状态",
|
||||
"bots.actionDock": "机器人操作",
|
||||
"bots.banUntil": "封禁至",
|
||||
"bots.ban": "封禁机器人",
|
||||
"bots.updateBan": "更新封禁",
|
||||
"bots.unban": "解封机器人",
|
||||
"bots.type": "类型",
|
||||
"bots.system": "系统",
|
||||
"bots.user": "用户",
|
||||
"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": "群聊消息",
|
||||
|
|
@ -618,6 +809,35 @@ 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.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": "已启用",
|
||||
|
|
@ -772,7 +992,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"common.group": "Группа",
|
||||
"common.id": "ID",
|
||||
"common.limit": "Лимит",
|
||||
"common.loading": "Загрузка...",
|
||||
"common.loading": "Загрузка…",
|
||||
"common.member": "Участник",
|
||||
"common.members": "Участники",
|
||||
"common.messageId": "ID сообщения",
|
||||
|
|
@ -794,7 +1014,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"common.updatedAt": "Обновлено",
|
||||
"common.username": "Имя пользователя",
|
||||
"common.valid": "Действителен",
|
||||
"common.verified": "Подтвержден",
|
||||
"common.verified": "Подтверждён",
|
||||
"common.views": "Просмотры",
|
||||
"common.yes": "Да",
|
||||
"route.accounts": "Аккаунты",
|
||||
|
|
@ -805,15 +1025,18 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"route.dashboardSubtitle": "Консоль / Обзор",
|
||||
"route.messages": "Аудит сообщений",
|
||||
"route.messagesSubtitle": "Консоль / Сообщения",
|
||||
"route.gifts": "Звездные подарки",
|
||||
"route.giftsSubtitle": "Консоль / Звездные подарки",
|
||||
"route.gifts": "Звёздные подарки",
|
||||
"route.giftsSubtitle": "Консоль / Звёздные подарки",
|
||||
"route.giveGifts": "Выдача подарков",
|
||||
"route.giveGiftsSubtitle": "Консоль / Выдача подарков",
|
||||
"layout.navigation": "Навигация",
|
||||
"layout.primaryNav": "Основное меню",
|
||||
"layout.dashboard": "Обзор",
|
||||
"layout.accounts": "Аккаунты",
|
||||
"layout.channels": "Супергруппы / Каналы",
|
||||
"layout.messages": "Сообщения",
|
||||
"layout.gifts": "Звездные подарки",
|
||||
"layout.gifts": "Звёздные подарки",
|
||||
"layout.giveGifts": "Выдача подарков",
|
||||
"layout.privateMessages": "Личные",
|
||||
"layout.groupMessages": "Группы",
|
||||
"layout.runtime": "Среда выполнения",
|
||||
|
|
@ -823,16 +1046,18 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"layout.readOnly": "Только чтение",
|
||||
"layout.writeOps": "Операции записи",
|
||||
"layout.dryRun": "Тестовый запуск",
|
||||
"layout.actor": "Вход выполнен как: {actor}",
|
||||
"layout.actor": "Вы вошли как: {actor}",
|
||||
"layout.logout": "Выйти",
|
||||
"language.en": "EN",
|
||||
"language.zh": "中文",
|
||||
"language.ru": "RU",
|
||||
"theme.switchToDark": "Тёмная тема",
|
||||
"theme.switchToLight": "Светлая тема",
|
||||
"login.heading": "Панель администратора",
|
||||
"login.body": "Введите учетные данные для входа в консоль.",
|
||||
"login.body": "Введите учётные данные для входа в консоль.",
|
||||
"login.secret": "Пароль или токен администратора",
|
||||
"login.submit": "Войти",
|
||||
"login.submitting": "Вход...",
|
||||
"login.submitting": "Вход…",
|
||||
"dashboard.eyebrow": "Состояние системы",
|
||||
"dashboard.title": "Обзор консоли",
|
||||
"dashboard.readPath": "Путь чтения",
|
||||
|
|
@ -840,8 +1065,8 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"dashboard.writePath": "Путь записи",
|
||||
"dashboard.executionPolicy": "Политика выполнения",
|
||||
"dashboard.dryRunFirst": "Сначала тестовый запуск",
|
||||
"dashboard.accountsText": "Статус аккаунтов, премиум, верификация, сессии.",
|
||||
"dashboard.channelsText": "Публичные каналы и группы, количество участников, статус верификации.",
|
||||
"dashboard.accountsText": "Статус аккаунтов, Premium, подтверждение, сессии.",
|
||||
"dashboard.channelsText": "Публичные каналы и группы, число участников, статус подтверждения.",
|
||||
"dashboard.messagesText": "Ящики сообщений, обновления, состояние исходящих.",
|
||||
"dashboard.strip.dryRun": "Все опасные действия начинаются с тестового запуска",
|
||||
"dashboard.strip.token": "Браузер никогда не сохраняет внутренние токены",
|
||||
|
|
@ -858,10 +1083,10 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"account.userID": "ID пользователя",
|
||||
"account.phone": "Телефон",
|
||||
"account.lastActive": "Последняя активность",
|
||||
"account.notVerified": "Не подтвержден",
|
||||
"account.notVerified": "Не подтверждён",
|
||||
"account.notPremium": "Без Premium",
|
||||
"account.premiumUntil": "Premium истекает",
|
||||
"account.starsBalance": "Баланс Звезд",
|
||||
"account.starsBalance": "Баланс Звёзд",
|
||||
"account.startingGrantApplied": "стартовый бонус начислен",
|
||||
"account.startingGrantPending": "ожидает стартового бонуса",
|
||||
"account.activeSessions": "Авторизованные устройства",
|
||||
|
|
@ -894,17 +1119,17 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"account.premiumMonthsAria": "Указать срок действия Premium в месяцах",
|
||||
"account.setPremium": "Выдать Premium",
|
||||
"account.clearPremium": "Снять Premium",
|
||||
"account.starsAmount": "Количество звёзд",
|
||||
"account.starsAmountAria": "Указать количество начисляемых звёзд",
|
||||
"account.grantStars": "Начислить звёзды",
|
||||
"account.starsAmount": "Количество Звёзд",
|
||||
"account.starsAmountAria": "Указать количество начисляемых Звёзд",
|
||||
"account.grantStars": "Начислить Звёзды",
|
||||
"account.setVerified": "Подтвердить аккаунт",
|
||||
"account.clearVerified": "Снять подтверждение",
|
||||
"channel.pageTitle": "Супергруппы и каналы",
|
||||
"channel.recentUpdated": "Недавно обновленные",
|
||||
"channel.recentUpdated": "Недавно обновлённые",
|
||||
"channel.currentPage": "Объекты на странице",
|
||||
"channel.megagroups": "Супергруппы",
|
||||
"channel.broadcasts": "Каналы",
|
||||
"channel.verifiedCount": "Подтверждено",
|
||||
"channel.verifiedCount": "Подтверждённые",
|
||||
"channel.searchPlaceholder": "ID канала / имя пользователя / название",
|
||||
"channel.channelID": "ID канала",
|
||||
"channel.kind": "Тип",
|
||||
|
|
@ -925,6 +1150,82 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"channel.kind.forum": "Супергруппа / Форум",
|
||||
"channel.kind.megagroup": "Супергруппа",
|
||||
"channel.kind.generic": "Канал / Группа",
|
||||
"route.bots": "Боты",
|
||||
"route.botsSubtitle": "Консоль / Боты",
|
||||
"layout.bots": "Боты",
|
||||
"bots.pageTitle": "Боты",
|
||||
"bots.queryResults": "Результаты поиска",
|
||||
"bots.recent": "Недавно созданные боты",
|
||||
"bots.currentPage": "Боты на странице",
|
||||
"bots.banned": "Забанен",
|
||||
"bots.active": "Активен",
|
||||
"bots.createTitle": "Создать системного бота",
|
||||
"bots.createHint": "Создаёт бота, принадлежащего указанному пользователю. Токен показывается один раз после подтверждения.",
|
||||
"bots.ownerUserID": "ID владельца",
|
||||
"bots.name": "Отображаемое имя",
|
||||
"bots.namePlaceholder": "например, Service Bot",
|
||||
"bots.username": "Имя пользователя",
|
||||
"bots.usernameHint": "Имя пользователя: 5–32 символа, обязательно оканчивается на «bot».",
|
||||
"bots.create": "Создать бота",
|
||||
"bots.searchPlaceholder": "ID бота / имя пользователя",
|
||||
"bots.botID": "ID бота",
|
||||
"bots.owner": "Владелец",
|
||||
"bots.status": "Статус",
|
||||
"bots.detailTitle": "Бот #{id}",
|
||||
"bots.profile": "Профиль бота",
|
||||
"bots.loadingDetail": "Загрузка данных бота",
|
||||
"bots.unnamed": "Без имени",
|
||||
"bots.restriction": "Ограничение",
|
||||
"bots.actionDock": "Действия с ботом",
|
||||
"bots.banUntil": "Забанить до",
|
||||
"bots.ban": "Забанить бота",
|
||||
"bots.updateBan": "Обновить бан",
|
||||
"bots.unban": "Разбанить бота",
|
||||
"bots.type": "Тип",
|
||||
"bots.system": "Системный",
|
||||
"bots.user": "Пользовательский",
|
||||
"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": "Групповые сообщения",
|
||||
|
|
@ -943,7 +1244,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"messages.outgoing": "Исходящее",
|
||||
"messages.incoming": "Входящее",
|
||||
"messages.ownerPeer": "Владелец / Собеседник",
|
||||
"messages.deleteSelected": "Указать и удалить выбранные сообщения",
|
||||
"messages.deleteSelected": "Удалить выбранные сообщения",
|
||||
"messages.idsPlaceholder": "ID сообщений через запятую",
|
||||
"messages.revoke": "Удалить для обеих сторон",
|
||||
"messages.previewDelete": "Тестовое удаление",
|
||||
|
|
@ -989,7 +1290,36 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"messages.channelGroup": "Канал / Группа",
|
||||
"messages.pinned": "Закреплено",
|
||||
"messages.channelPost": "Пост в канале",
|
||||
"gifts.pageTitle": "Каталог звездных подарков",
|
||||
"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.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": "Включено",
|
||||
|
|
@ -1000,7 +1330,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"gifts.listSummary": "Показано {shown} из {total}",
|
||||
"gifts.idRevision": "ID / Версия",
|
||||
"gifts.price": "Цена / Конвертация",
|
||||
"gifts.importTitle": "Импорт звездного подарка",
|
||||
"gifts.importTitle": "Импорт звёздного подарка",
|
||||
"gifts.importEyebrow": "Управление каталогом подарков",
|
||||
"gifts.newRevision": "Создать версию для подарка #{id}",
|
||||
"gifts.importHint": "Загрузите файл TGS или обычный Lottie JSON. Lottie нормализуется и сжимается в формат TGS.",
|
||||
|
|
@ -1031,8 +1361,8 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"gifts.changeFile": "Изменить файл",
|
||||
"gifts.title": "Отображаемое название",
|
||||
"gifts.titlePlaceholder": "например, Праздничная звезда",
|
||||
"gifts.stars": "Цена в Звездах",
|
||||
"gifts.convertStars": "Звезд при конвертации",
|
||||
"gifts.stars": "Цена в Звёздах",
|
||||
"gifts.convertStars": "Звёзд при конвертации",
|
||||
"gifts.sortOrder": "Порядок сортировки",
|
||||
"gifts.reason": "Причина для аудита",
|
||||
"gifts.reasonPlaceholder": "Кратко опишите причину импорта этого подарка",
|
||||
|
|
@ -1047,7 +1377,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"gifts.replace": "Новая версия",
|
||||
"gifts.disable": "Отключить",
|
||||
"gifts.enable": "Включить",
|
||||
"gifts.empty": "Звездные подарки еще не импортированы.",
|
||||
"gifts.empty": "Звёздные подарки ещё не импортированы.",
|
||||
"gifts.emptyHint": "Импортируйте первую анимацию, чтобы начать наполнение каталога.",
|
||||
"gifts.validationReady": "Проверка пройдена",
|
||||
"gifts.validationHint": "Проверьте нормализованные метаданные и подтвердите импорт.",
|
||||
|
|
@ -1061,7 +1391,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"collectibles.noPoolHint": "Опубликуйте модели, узоры и фоны для активации улучшений.",
|
||||
"collectibles.publishNew": "Опубликовать новую неизменяемую версию",
|
||||
"collectibles.immutableHint": "Тестовый запуск проверяет каждый файл и структуру набора атрибутов перед активацией версии.",
|
||||
"collectibles.upgradeStars": "Цена улучшения в Звездах",
|
||||
"collectibles.upgradeStars": "Цена улучшения в Звёздах",
|
||||
"collectibles.supply": "Уникальный тираж",
|
||||
"collectibles.slug": "Публичный префикс ссылки (slug)",
|
||||
"collectibles.models": "Модели",
|
||||
|
|
@ -1071,7 +1401,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"collectibles.pattern": "Узор",
|
||||
"collectibles.backdrop": "Фон",
|
||||
"collectibles.rarity": "Редкость ‰",
|
||||
"collectibles.rarityHint": "В каждой категории должно быть не менее двух атрибутов. При добавлении и удалении веса permille перераспределяются до 1000.",
|
||||
"collectibles.rarityHint": "В каждой категории должно быть не менее двух атрибутов. Значения в промилле задают относительные веса обычного улучшения; при добавлении или удалении они перераспределяются до суммы 1000.",
|
||||
"collectibles.minimumAttributes": "Модели, узоры и фоны должны содержать не менее двух атрибутов в каждой категории.",
|
||||
"collectibles.duplicateBackdropID": "ID фонов в одном наборе должны быть уникальными.",
|
||||
"collectibles.colorHint": "Цвета сохраняются как 24-битные RGB-значения.",
|
||||
|
|
@ -1093,10 +1423,10 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"auth.lastActive": "Последняя активность",
|
||||
"auth.revokeCurrent": "Отозвать текущую",
|
||||
"auth.keepCurrent": "Оставить текущую",
|
||||
"auth.revokeAll": "Разлогинить все устройства",
|
||||
"auth.revokeAll": "Отозвать все устройства",
|
||||
"picker.userPlaceholder": "Поиск по user_id / телефону / имени пользователя",
|
||||
"picker.channelPlaceholder": "Поиск по channel_id / имени пользователя / названию",
|
||||
"picker.verified": "Подтвержденные",
|
||||
"picker.verified": "Подтверждённые",
|
||||
"picker.regular": "Обычные",
|
||||
"action.reasonRequired": "Пожалуйста, укажите причину операции",
|
||||
"action.flow": "Процесс выполнения",
|
||||
|
|
@ -1111,7 +1441,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"action.commandID": "ID команды",
|
||||
"action.status": "Статус",
|
||||
"action.dryRun": "Тестовый запуск",
|
||||
"action.runAgain": "Запустить тестовый запуск снова",
|
||||
"action.runAgain": "Повторить тестовый запуск",
|
||||
"action.runDry": "Сначала выполните тестовый запуск",
|
||||
"action.confirm": "Подтвердить выполнение",
|
||||
"audit.id": "ID",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@ import React from "react";
|
|||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { ThemeProvider } from "./theme";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
116
cmd/telesrv-admin/web/src/pages/BotDetailPage.tsx
Normal file
116
cmd/telesrv-admin/web/src/pages/BotDetailPage.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { ArrowLeft, BadgeCheck, Trash2 } from "lucide-react";
|
||||
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";
|
||||
import type { BotDetail } from "../types";
|
||||
|
||||
export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [detail, setDetail] = useState<BotDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.bot(id));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
if (error) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={busy ? t("bots.loadingDetail") : t("account.waitingData")} />;
|
||||
}
|
||||
|
||||
const bot = detail.Bot;
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("bots.detailTitle", { id: bot.ID })}
|
||||
eyebrow={t("bots.profile")}
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
|
||||
>
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{bot.FirstName || t("bots.unnamed")}</div>
|
||||
<div className="entity-subtitle">{displayUsername(bot.Username) || t("account.noUsername")}</div>
|
||||
</div>
|
||||
<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">
|
||||
<Summary label={t("bots.botID")} value={String(bot.ID)} mono />
|
||||
<Summary label={t("bots.owner")} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : t("common.none")} />
|
||||
<Summary label={t("bots.type")} value={bot.System ? t("bots.system") : t("bots.user")} />
|
||||
<Summary label={t("common.updatedAt")} value={formatDate(bot.UpdatedAt) || "-"} />
|
||||
<Summary label={t("account.createdAt")} value={formatDate(bot.CreatedAt) || "-"} />
|
||||
</div>
|
||||
{detail.About && <p className="about-text">{detail.About}</p>}
|
||||
{detail.Description && detail.Description.trim() !== detail.About.trim() && <p className="about-text">{detail.Description}</p>}
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
|
||||
<AuditTable rows={detail.AuditLogs} />
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{t("bots.actionDock")}</div>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={bot.Verified ? t("account.clearVerified") : t("account.setVerified")}
|
||||
icon={<BadgeCheck size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/set-verified"
|
||||
payload={() => ({ user_id: bot.ID, verified: !bot.Verified })}
|
||||
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>
|
||||
) : (
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label={t("bots.delete")}
|
||||
icon={<Trash2 size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/delete-bot"
|
||||
payload={() => ({ bot_user_id: bot.ID })}
|
||||
onDone={() => navigate("/bots")}
|
||||
/>
|
||||
<p className="bot-create-note">{t("bots.deleteHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
168
cmd/telesrv-admin/web/src/pages/BotsPage.tsx
Normal file
168
cmd/telesrv-admin/web/src/pages/BotsPage.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { BadgeCheck, Bot, ChevronRight, Loader2, Plus, RefreshCw, Search } from "lucide-react";
|
||||
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";
|
||||
import type { BotListResponse } from "../types";
|
||||
|
||||
export function BotsPage({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [data, setData] = useState<BotListResponse | null>(null);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [ownerID, setOwnerID] = useState("");
|
||||
const [botName, setBotName] = useState("");
|
||||
const [botUsername, setBotUsername] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (q.trim()) {
|
||||
params.set("q", q.trim());
|
||||
} else if (next) {
|
||||
params.set("before_id", String(cursor));
|
||||
}
|
||||
try {
|
||||
const result = await api.bots(params);
|
||||
setData(result);
|
||||
setCursor(result.next_before_id);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
const verified = rows.filter((row) => row.Verified).length;
|
||||
const systemCount = rows.filter((row) => row.System).length;
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("bots.pageTitle")}
|
||||
eyebrow={data?.listing === false ? t("bots.queryResults") : t("bots.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("bots.currentPage")} value={String(rows.length)} />
|
||||
<Metric label={t("common.verified")} value={String(verified)} tone="good" />
|
||||
<Metric label={t("bots.system")} value={String(systemCount)} />
|
||||
</div>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>{t("bots.createTitle")}</h2>
|
||||
<p>{t("bots.createHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{t("bots.ownerUserID")}</span>
|
||||
<input
|
||||
value={ownerID}
|
||||
onChange={(event) => setOwnerID(event.target.value)}
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("bots.name")}</span>
|
||||
<input value={botName} onChange={(event) => setBotName(event.target.value)} placeholder={t("bots.namePlaceholder")} maxLength={64} />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("bots.username")}</span>
|
||||
<input value={botUsername} onChange={(event) => setBotUsername(event.target.value)} placeholder="my_service_bot" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{t("bots.usernameHint")}</span>
|
||||
<ActionButton
|
||||
label={t("bots.create")}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/create-bot"
|
||||
payload={() => ({
|
||||
owner_user_id: toInt(ownerID),
|
||||
name: botName.trim(),
|
||||
username: botUsername.trim().replace(/^@/, "")
|
||||
})}
|
||||
onDone={() => load(false)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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("bots.searchPlaceholder")} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("common.limit")}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
|
||||
</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>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("bots.botID")}</th>
|
||||
<th>{t("common.username")}</th>
|
||||
<th>{t("common.name")}</th>
|
||||
<th>{t("bots.owner")}</th>
|
||||
<th>{t("common.verified")}</th>
|
||||
<th>{t("bots.type")}</th>
|
||||
<th>{t("account.createdAt")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<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>} <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>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
145
cmd/telesrv-admin/web/src/pages/EmojiPage.tsx
Normal file
145
cmd/telesrv-admin/web/src/pages/EmojiPage.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { StaticLottie } from "../components/StaticLottie";
|
||||
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 [failed, setFailed] = useState(!isAnimated(row.MimeType));
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(!isAnimated(row.MimeType));
|
||||
}, [row.DocumentID, row.MimeType]);
|
||||
|
||||
if (failed) {
|
||||
return <div className="emoji-glyph">{row.Alt || "🙂"}</div>;
|
||||
}
|
||||
// Render a static first frame (plays only on hover) so a full grid of emoji
|
||||
// does not keep every Lottie canvas animating and lag the page.
|
||||
return (
|
||||
<StaticLottie
|
||||
className="emoji-anim"
|
||||
cacheKey={row.DocumentID}
|
||||
loader={() => api.emojiAnimation(row.DocumentID)}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
220
cmd/telesrv-admin/web/src/pages/GiveGiftForm.tsx
Normal file
220
cmd/telesrv-admin/web/src/pages/GiveGiftForm.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
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 [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 [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");
|
||||
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> {
|
||||
return {
|
||||
gift_id: gift.GiftID,
|
||||
// Gifts are always sent from the official system account (777000).
|
||||
sender_user_id: Number(SYSTEM_SENDER),
|
||||
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",
|
||||
reason: reason.trim(),
|
||||
confirm
|
||||
};
|
||||
}
|
||||
|
||||
const previewPayload = useMemo(() => buildPayload(false), [gift.GiftID, kind, recipientID, message, hideName, upgrade, modelID, patternID, backdropID, 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={SYSTEM_SENDER} disabled readOnly />
|
||||
<small className="field-hint">{t("giveGift.senderHint")}</small>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>{t("giveGift.message")}</span>
|
||||
<textarea value={message} rows={2} maxLength={128} 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"); } 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
83
cmd/telesrv-admin/web/src/pages/GiveGiftsPage.tsx
Normal file
83
cmd/telesrv-admin/web/src/pages/GiveGiftsPage.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Gift, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { StaticLottie } from "../components/StaticLottie";
|
||||
import { Alert, Badge, PageFrame } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { StarGiftRow } from "../types";
|
||||
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)}>
|
||||
<StaticLottie className="give-gift-thumb" cacheKey={`${gift.GiftID}:${gift.Revision}`} loader={() => api.giftAnimation(gift.GiftID)} />
|
||||
<span className="give-gift-option-info">
|
||||
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
|
||||
<span className="mono">#{gift.GiftID}</span>
|
||||
</span>
|
||||
<span className="give-gift-option-price">
|
||||
{gift.Enabled ? <Badge>⭐ {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { Alert } from "../components/ui";
|
||||
import { LanguageSwitch, useI18n } from "../i18n";
|
||||
import { ThemeSwitch } from "../theme";
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -36,6 +37,7 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
|
|||
</span>
|
||||
</div>
|
||||
<div className="login-head-actions">
|
||||
<ThemeSwitch />
|
||||
<LanguageSwitch />
|
||||
<span className="login-chip">{t("app.localAccess")}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,31 +3,48 @@ import { AccountDetailPage } from "./AccountDetailPage";
|
|||
import { AccountsPage } from "./AccountsPage";
|
||||
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];
|
||||
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
|
||||
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
|
||||
if (accountID) {
|
||||
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
|
||||
}
|
||||
if (channelID) {
|
||||
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
|
||||
}
|
||||
if (botID) {
|
||||
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/accounts") {
|
||||
return <AccountsPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/channels") {
|
||||
return <ChannelsPage navigate={navigate} />;
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ export function currentRoute(): RouteState {
|
|||
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");
|
||||
}
|
||||
|
|
@ -27,7 +30,10 @@ export function routeTitle(pathname: string, t: TFunction): string {
|
|||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,155 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f3f5f7;
|
||||
|
||||
/* Surfaces */
|
||||
--bg: #eef1f5;
|
||||
--bg-accent: #e7ecf1;
|
||||
--panel: #ffffff;
|
||||
--panel-subtle: #f8fafb;
|
||||
--panel-strong: #eef2f5;
|
||||
--line: #d9e1e8;
|
||||
--line-strong: #c2ccd6;
|
||||
--text: #101828;
|
||||
--muted: #667085;
|
||||
--muted-2: #98a2b3;
|
||||
--brand: #176d61;
|
||||
--brand-2: #245b9d;
|
||||
--good: #167447;
|
||||
--warn: #a15c07;
|
||||
--danger: #b42318;
|
||||
--sidebar: #11161d;
|
||||
--sidebar-soft: #1b222b;
|
||||
--sidebar-line: #2c3541;
|
||||
--focus: rgba(23, 109, 97, 0.16);
|
||||
--shadow: 0 18px 52px rgba(16, 24, 40, 0.14);
|
||||
--panel-subtle: #f5f8fb;
|
||||
--panel-strong: #eef2f6;
|
||||
--surface-soft: #f2f7f6;
|
||||
--overlay: rgba(24, 34, 47, 0.42);
|
||||
--topbar-bg: rgba(255, 255, 255, 0.86);
|
||||
|
||||
/* Lines */
|
||||
--line: #e5eaf0;
|
||||
--line-strong: #d3dce4;
|
||||
|
||||
/* Text */
|
||||
--heading: #253040;
|
||||
--text: #333f4d;
|
||||
--text-soft: #45525f;
|
||||
--muted: #6d7885;
|
||||
--muted-2: #9aa4b1;
|
||||
|
||||
/* Brand */
|
||||
--brand: #1f7d6f;
|
||||
--brand-strong: #196155;
|
||||
--brand-2: #3a6cae;
|
||||
--brand-tint: #e8f4f0;
|
||||
--brand-tint-border: #c8e2db;
|
||||
--brand-tint-text: #235d53;
|
||||
|
||||
/* Semantic */
|
||||
--good: #1f8a57;
|
||||
--good-tint: #eaf6ef;
|
||||
--good-border: #c1e1cf;
|
||||
--warn: #a86a12;
|
||||
--warn-tint: #fcf4e4;
|
||||
--warn-border: #e7d09e;
|
||||
--danger: #c0392b;
|
||||
--danger-tint: #fcefec;
|
||||
--danger-border: #eecac3;
|
||||
--danger-text: #8f2f27;
|
||||
|
||||
/* Accent (collectibles / craft) */
|
||||
--purple: #6a4fa3;
|
||||
--purple-tint: #f4effb;
|
||||
--purple-border: #dcd0f0;
|
||||
--purple-text: #5a4590;
|
||||
|
||||
/* Inputs & controls */
|
||||
--input-bg: #ffffff;
|
||||
--btn-bg: #ffffff;
|
||||
--btn-text: #29323d;
|
||||
--btn-hover: #f4f7fa;
|
||||
--switch-track: #c8d0d6;
|
||||
|
||||
/* Code / JSON blocks */
|
||||
--code-bg: #1b2733;
|
||||
--code-text: #d6e3ef;
|
||||
--code-border: #2b3a49;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #1c2530;
|
||||
--sidebar-soft: #26313d;
|
||||
--sidebar-line: #313c4a;
|
||||
--sidebar-row: #232d38;
|
||||
--sidebar-text: #dbe3ec;
|
||||
--sidebar-muted: #8b98a8;
|
||||
--sidebar-faint: #7c8a9a;
|
||||
--sidebar-heading: #ffffff;
|
||||
|
||||
/* Effects */
|
||||
--focus: rgba(31, 125, 111, 0.16);
|
||||
--shadow: 0 12px 34px rgba(24, 39, 56, 0.1);
|
||||
--shadow-sm: 0 2px 10px rgba(24, 39, 56, 0.05);
|
||||
--shadow-brand: 0 8px 22px rgba(31, 125, 111, 0.22);
|
||||
|
||||
/* Radii */
|
||||
--radius-xs: 8px;
|
||||
--radius-sm: 9px;
|
||||
--radius: 11px;
|
||||
--radius-lg: 14px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg: #0f141a;
|
||||
--bg-accent: #131a22;
|
||||
--panel: #171f28;
|
||||
--panel-subtle: #1c2530;
|
||||
--panel-strong: #212c38;
|
||||
--surface-soft: #1a232d;
|
||||
--overlay: rgba(5, 8, 12, 0.62);
|
||||
--topbar-bg: rgba(21, 28, 36, 0.86);
|
||||
|
||||
--line: #29333f;
|
||||
--line-strong: #38434f;
|
||||
|
||||
--heading: #eef3f8;
|
||||
--text: #d5dde6;
|
||||
--text-soft: #c2ccd6;
|
||||
--muted: #98a4b1;
|
||||
--muted-2: #6d7885;
|
||||
|
||||
--brand: #37a596;
|
||||
--brand-strong: #45b6a6;
|
||||
--brand-2: #6fa8e6;
|
||||
--brand-tint: #14322d;
|
||||
--brand-tint-border: #245349;
|
||||
--brand-tint-text: #7fd3c4;
|
||||
|
||||
--good: #47c281;
|
||||
--good-tint: #12301f;
|
||||
--good-border: #245639;
|
||||
--warn: #e0aa4d;
|
||||
--warn-tint: #322810;
|
||||
--warn-border: #574413;
|
||||
--danger: #e6695c;
|
||||
--danger-tint: #35201d;
|
||||
--danger-border: #5c332d;
|
||||
--danger-text: #f0a49b;
|
||||
|
||||
--purple: #ac90e2;
|
||||
--purple-tint: #221b31;
|
||||
--purple-border: #3d3357;
|
||||
--purple-text: #c9b6ef;
|
||||
|
||||
--input-bg: #131a22;
|
||||
--btn-bg: #1e2731;
|
||||
--btn-text: #dbe2ea;
|
||||
--btn-hover: #26313d;
|
||||
--switch-track: #3a454f;
|
||||
|
||||
--code-bg: #0c1218;
|
||||
--code-text: #cdd9e5;
|
||||
--code-border: #232f3b;
|
||||
|
||||
--sidebar: #10151b;
|
||||
--sidebar-soft: #1c242f;
|
||||
--sidebar-line: #262f3a;
|
||||
--sidebar-row: #161d25;
|
||||
--sidebar-text: #cbd4de;
|
||||
--sidebar-muted: #7c8794;
|
||||
--sidebar-faint: #6f7b88;
|
||||
--sidebar-heading: #f0f4f8;
|
||||
|
||||
--focus: rgba(55, 165, 150, 0.24);
|
||||
--shadow: 0 16px 40px rgba(0, 0, 0, 0.46);
|
||||
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.38);
|
||||
--shadow-brand: 0 8px 22px rgba(55, 165, 150, 0.26);
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -35,7 +166,10 @@ body {
|
|||
margin: 0;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font: 13px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font: 13px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
transition: background-color 200ms ease, color 200ms ease;
|
||||
}
|
||||
|
||||
button,
|
||||
|
|
@ -64,7 +198,7 @@ a {
|
|||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
padding: 18px 12px;
|
||||
color: #eef2f6;
|
||||
color: var(--sidebar-text);
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--sidebar-line);
|
||||
}
|
||||
|
|
@ -82,7 +216,7 @@ a {
|
|||
}
|
||||
|
||||
.brand-elevated .brand-mark {
|
||||
box-shadow: 0 8px 24px rgba(23, 109, 97, 0.26);
|
||||
box-shadow: var(--shadow-brand);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
|
|
@ -93,7 +227,7 @@ a {
|
|||
color: #ffffff;
|
||||
background: var(--brand);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
|
|
@ -106,16 +240,17 @@ a {
|
|||
.brand small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #aeb8c4;
|
||||
color: var(--sidebar-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
padding: 0 8px;
|
||||
color: #8492a6;
|
||||
color: var(--sidebar-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
|
|
@ -136,26 +271,27 @@ a {
|
|||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 10px;
|
||||
color: #8fa0b4;
|
||||
color: var(--sidebar-muted);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-align: left;
|
||||
transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.nav-section-toggle:hover,
|
||||
.nav-section.active .nav-section-toggle {
|
||||
color: #ffffff;
|
||||
color: var(--sidebar-heading);
|
||||
background: var(--sidebar-soft);
|
||||
border-color: #34404d;
|
||||
border-color: var(--sidebar-line);
|
||||
}
|
||||
|
||||
.nav-section-chevron {
|
||||
justify-self: end;
|
||||
color: #8fa0b4;
|
||||
color: var(--sidebar-muted);
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
|
|
@ -176,24 +312,25 @@ a {
|
|||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 10px;
|
||||
color: #c6d0dc;
|
||||
color: var(--sidebar-text);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.nav-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
justify-self: center;
|
||||
background: #687789;
|
||||
background: var(--sidebar-faint);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item.active {
|
||||
color: #ffffff;
|
||||
color: var(--sidebar-heading);
|
||||
background: var(--sidebar-soft);
|
||||
border-color: #34404d;
|
||||
border-color: var(--sidebar-line);
|
||||
}
|
||||
|
||||
.nav-item.active .nav-dot {
|
||||
|
|
@ -213,14 +350,14 @@ a {
|
|||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 8px;
|
||||
color: #cbd5df;
|
||||
background: #171d25;
|
||||
border: 1px solid #27313c;
|
||||
border-radius: 7px;
|
||||
color: var(--sidebar-text);
|
||||
background: var(--sidebar-row);
|
||||
border: 1px solid var(--sidebar-line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.runtime-row strong {
|
||||
color: #ffffff;
|
||||
color: var(--sidebar-heading);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
|
|
@ -238,13 +375,14 @@ a {
|
|||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
background: var(--topbar-bg);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 2px 0 0;
|
||||
color: var(--heading);
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
|
@ -281,6 +419,7 @@ a {
|
|||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
transition: color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.language-switch button.active {
|
||||
|
|
@ -293,12 +432,36 @@ a {
|
|||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: inline-grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: var(--brand);
|
||||
border-color: var(--brand-tint-border);
|
||||
background: var(--brand-tint);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.actor-pill {
|
||||
display: inline-flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
|
|
@ -315,4 +478,5 @@ a {
|
|||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
min-width: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.overview-band {
|
||||
|
|
@ -25,6 +26,7 @@
|
|||
.section-head h2,
|
||||
.modal h2 {
|
||||
margin: 0;
|
||||
color: var(--heading);
|
||||
font-size: 18px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
|
@ -47,7 +49,7 @@
|
|||
padding: 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.status-item span,
|
||||
|
|
@ -70,16 +72,16 @@
|
|||
|
||||
.status-item.good,
|
||||
.metric.good {
|
||||
border-color: #afd8bf;
|
||||
border-color: var(--good-border);
|
||||
}
|
||||
|
||||
.status-item.warn,
|
||||
.metric.warn {
|
||||
border-color: #e7c77e;
|
||||
border-color: var(--warn-border);
|
||||
}
|
||||
|
||||
.metric.danger {
|
||||
border-color: #efb4ad;
|
||||
border-color: var(--danger-border);
|
||||
}
|
||||
|
||||
.command-grid {
|
||||
|
|
@ -97,11 +99,15 @@
|
|||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.launcher:hover {
|
||||
border-color: var(--brand);
|
||||
border-color: var(--brand-tint-border);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.launcher-icon {
|
||||
|
|
@ -110,9 +116,9 @@
|
|||
height: 38px;
|
||||
place-items: center;
|
||||
color: var(--brand);
|
||||
background: #edf7f4;
|
||||
border: 1px solid #c9e2dc;
|
||||
border-radius: 8px;
|
||||
background: var(--brand-tint);
|
||||
border: 1px solid var(--brand-tint-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.launcher-copy {
|
||||
|
|
@ -121,6 +127,7 @@
|
|||
}
|
||||
|
||||
.launcher-copy strong {
|
||||
color: var(--heading);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
|
@ -140,10 +147,10 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.page-frame {
|
||||
|
|
@ -165,7 +172,7 @@
|
|||
padding: 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
|
|
@ -195,9 +202,9 @@
|
|||
min-width: 0;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.picker-head {
|
||||
|
|
@ -206,7 +213,7 @@
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
|
|
@ -217,10 +224,10 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 9px;
|
||||
color: #0f3f38;
|
||||
background: #eef8f5;
|
||||
border: 1px solid #b9dcd3;
|
||||
border-radius: 7px;
|
||||
color: var(--brand-tint-text);
|
||||
background: var(--brand-tint);
|
||||
border: 1px solid var(--brand-tint-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.selected-entity strong,
|
||||
|
|
@ -237,8 +244,9 @@
|
|||
}
|
||||
|
||||
.selected-entity div span {
|
||||
color: #52606d;
|
||||
color: var(--brand-tint-text);
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.picker-search {
|
||||
|
|
@ -250,7 +258,7 @@
|
|||
padding: 0 6px 0 9px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.picker-search input {
|
||||
|
|
@ -267,7 +275,7 @@
|
|||
max-height: 236px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.picker-row {
|
||||
|
|
@ -278,7 +286,7 @@
|
|||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
|
|
@ -291,7 +299,7 @@
|
|||
|
||||
.picker-row:hover,
|
||||
.picker-row.selected {
|
||||
background: #f3f8f6;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.picker-row strong,
|
||||
|
|
@ -311,18 +319,24 @@
|
|||
|
||||
.picker-error {
|
||||
color: var(--danger);
|
||||
background: #fff2f0;
|
||||
border: 1px solid #efb4ad;
|
||||
border-radius: 7px;
|
||||
background: var(--danger-tint);
|
||||
border: 1px solid var(--danger-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
input {
|
||||
|
|
@ -366,9 +380,10 @@ textarea:focus {
|
|||
width: min(380px, 100%);
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.searchbox input {
|
||||
|
|
@ -386,16 +401,17 @@ textarea:focus {
|
|||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
color: #1d2939;
|
||||
background: #ffffff;
|
||||
color: var(--btn-text);
|
||||
background: var(--btn-bg);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #f7f9fb;
|
||||
background: var(--btn-hover);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
|
|
@ -410,7 +426,8 @@ textarea:focus {
|
|||
}
|
||||
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: #12594f;
|
||||
background: var(--brand-strong);
|
||||
border-color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
|
|
@ -419,22 +436,24 @@ textarea:focus {
|
|||
|
||||
.btn.danger {
|
||||
color: var(--danger);
|
||||
background: #fff7f5;
|
||||
border-color: #efb4ad;
|
||||
background: var(--danger-tint);
|
||||
border-color: var(--danger-border);
|
||||
}
|
||||
|
||||
.btn.danger:hover:not(:disabled) {
|
||||
background: #ffeceb;
|
||||
background: var(--danger-tint);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn.warn {
|
||||
color: var(--warn);
|
||||
background: #fff8ec;
|
||||
border-color: #e7c77e;
|
||||
background: var(--warn-tint);
|
||||
border-color: var(--warn-border);
|
||||
}
|
||||
|
||||
.btn.warn:hover:not(:disabled) {
|
||||
background: #fff1d6;
|
||||
background: var(--warn-tint);
|
||||
border-color: var(--warn);
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
|
|
@ -442,7 +461,7 @@ textarea:focus {
|
|||
.btn.warn:disabled,
|
||||
.btn.danger:disabled {
|
||||
color: var(--muted-2);
|
||||
background: #f3f5f7;
|
||||
background: var(--panel-strong);
|
||||
border-color: var(--line);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -476,8 +495,9 @@ textarea:focus {
|
|||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
|
|
@ -500,13 +520,13 @@ textarea:focus {
|
|||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 0;
|
||||
color: #475467;
|
||||
color: var(--muted);
|
||||
background: var(--panel-strong);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #fbfcfd;
|
||||
background: var(--panel-subtle);
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
|
|
@ -528,32 +548,69 @@ textarea:focus {
|
|||
min-height: 22px;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
color: #4f5b68;
|
||||
background: #f3f6f8;
|
||||
border: 1px solid #d7e0e8;
|
||||
color: var(--muted);
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.good {
|
||||
color: var(--good);
|
||||
background: #eef8f2;
|
||||
border-color: #b9dcc7;
|
||||
background: var(--good-tint);
|
||||
border-color: var(--good-border);
|
||||
}
|
||||
|
||||
.badge.danger {
|
||||
color: var(--danger);
|
||||
background: #fff2f0;
|
||||
border-color: #efb4ad;
|
||||
background: var(--danger-tint);
|
||||
border-color: var(--danger-border);
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
color: var(--warn);
|
||||
background: #fff8e7;
|
||||
border-color: #e7c77e;
|
||||
background: var(--warn-tint);
|
||||
border-color: var(--warn-border);
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bot-create-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bot-create-fields .duration-field input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bot-create-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.bot-create-note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.bot-create-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bot-create-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@
|
|||
padding: 14px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.entity-title {
|
||||
color: var(--heading);
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
|
|
@ -41,10 +42,10 @@
|
|||
.about-text {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
color: #344054;
|
||||
background: #fbfcfd;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.section-block,
|
||||
|
|
@ -54,7 +55,8 @@
|
|||
padding: 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
|
|
@ -79,7 +81,7 @@
|
|||
|
||||
.dock-title {
|
||||
padding-bottom: 4px;
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
font-weight: 800;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
|
@ -184,7 +186,7 @@
|
|||
padding: 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.operation-title {
|
||||
|
|
@ -192,6 +194,7 @@
|
|||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--heading);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
|
|
@ -212,10 +215,10 @@
|
|||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
color: #8a251d;
|
||||
background: #fff2f0;
|
||||
border: 1px solid #efb4ad;
|
||||
border-radius: 8px;
|
||||
color: var(--danger-text);
|
||||
background: var(--danger-tint);
|
||||
border: 1px solid var(--danger-border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.json-block {
|
||||
|
|
@ -223,10 +226,10 @@
|
|||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
color: #d8e6f0;
|
||||
background: #141a22;
|
||||
border: 1px solid #2a3542;
|
||||
border-radius: 8px;
|
||||
color: var(--code-text);
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--code-border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
|
@ -250,12 +253,12 @@
|
|||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.gift-metrics .metric {
|
||||
min-height: 68px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(145deg, #ffffff, #f6f9f9);
|
||||
background: var(--panel-subtle);
|
||||
}
|
||||
|
||||
.gift-metrics .metric strong { font-size: 17px; }
|
||||
|
|
@ -265,18 +268,82 @@
|
|||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
color: var(--brand);
|
||||
background: #eaf6f3;
|
||||
border: 1px solid #c7e3dc;
|
||||
background: var(--brand-tint);
|
||||
border: 1px solid var(--brand-tint-border);
|
||||
}
|
||||
|
||||
.gift-format-chips { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
|
||||
.gift-format-chips span { padding: 4px 8px; color: #33645d; background: #eef8f5; border: 1px solid #cfe5df; border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .02em; }
|
||||
.gift-format-chips span { padding: 4px 8px; color: var(--brand-tint-text); background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .02em; }
|
||||
|
||||
.gift-list-summary { margin-left: auto; color: var(--muted); font-size: 11px; font-weight: 700; }
|
||||
|
||||
.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(3, 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(220px, 280px) 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-y: auto;
|
||||
background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-lg);
|
||||
}
|
||||
.give-gift-option {
|
||||
display: grid; grid-template-columns: 46px minmax(0, 1fr) auto; gap: 11px; align-items: center; min-width: 0;
|
||||
padding: 9px 11px; 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-thumb { display: grid; place-items: center; width: 46px; height: 46px; }
|
||||
.give-gift-thumb canvas { width: 100% !important; height: 100% !important; }
|
||||
.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-price { justify-self: end; white-space: nowrap; }
|
||||
.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%; }
|
||||
|
|
@ -284,48 +351,48 @@
|
|||
.official-gift-categories { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.official-gift-categories button {
|
||||
display: inline-flex; align-items: center; gap: 7px; min-height: 32px; padding: 5px 10px;
|
||||
color: #49605c; background: #f7faf9; border: 1px solid #d7e2df; border-radius: 999px;
|
||||
color: var(--text-soft); background: var(--panel-subtle); border: 1px solid var(--line-strong); border-radius: 999px;
|
||||
font: inherit; font-size: 11px; font-weight: 800; cursor: pointer;
|
||||
transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.official-gift-categories button:hover { color: var(--brand); border-color: #9fc9c0; }
|
||||
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: 0 4px 12px rgba(23, 109, 97, .17); }
|
||||
.official-gift-categories button:hover { color: var(--brand); border-color: var(--brand-tint-border); }
|
||||
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: var(--shadow-brand); }
|
||||
.official-gift-categories button span {
|
||||
display: grid; min-width: 20px; height: 20px; padding: 0 5px; place-items: center;
|
||||
color: inherit; background: rgba(255,255,255,.65); border-radius: 999px; font-size: 10px;
|
||||
color: inherit; background: rgba(125, 140, 155, .22); border-radius: 999px; font-size: 10px;
|
||||
}
|
||||
.official-gift-categories button.active span { color: var(--brand); }
|
||||
.official-gift-categories button.active span { color: var(--brand); background: rgba(255, 255, 255, .85); }
|
||||
.official-gift-list {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 314px;
|
||||
min-height: 126px; padding: 8px; overflow: auto; border: 1px solid var(--line); border-radius: 14px;
|
||||
background: #f6f9f8; scrollbar-gutter: stable;
|
||||
min-height: 126px; padding: 8px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius-lg);
|
||||
background: var(--panel-subtle); scrollbar-gutter: stable;
|
||||
}
|
||||
.official-gift-option {
|
||||
display: grid; min-width: 0; gap: 8px; padding: 11px 12px; text-align: left; color: var(--text);
|
||||
background: #ffffff; border: 1px solid #dce6e3; border-radius: 11px; cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(32, 54, 50, .03);
|
||||
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;
|
||||
}
|
||||
.official-gift-option:hover { border-color: #9fc9c0; box-shadow: 0 5px 14px rgba(32, 76, 68, .08); transform: translateY(-1px); }
|
||||
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .12), 0 5px 14px rgba(32, 76, 68, .08); }
|
||||
.official-gift-option:hover { border-color: var(--brand-tint-border); box-shadow: var(--shadow); transform: translateY(-1px); }
|
||||
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px var(--focus), var(--shadow); }
|
||||
.official-gift-option-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: baseline; }
|
||||
.official-gift-option-head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.official-gift-option-head .mono { color: var(--muted); font-size: 9px; }
|
||||
.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: #667773; font-size: 10px; font-weight: 700; }
|
||||
.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: var(--muted); font-size: 10px; font-weight: 700; }
|
||||
.official-gift-capabilities { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.official-gift-capabilities > span {
|
||||
padding: 3px 7px; border: 1px solid transparent; border-radius: 999px; font-size: 9px; font-weight: 850; letter-spacing: .01em;
|
||||
}
|
||||
.official-gift-capabilities > span.yes { color: #136b4d; background: #e9f8f0; border-color: #bde6cf; }
|
||||
.official-gift-capabilities > span.craft { color: #6e3ca0; background: #f3ebfb; border-color: #d9c5ef; }
|
||||
.official-gift-capabilities > span.no { color: #78837f; background: #f1f3f2; border-color: #dde2e0; }
|
||||
.official-gift-capabilities > span.yes { color: var(--good); background: var(--good-tint); border-color: var(--good-border); }
|
||||
.official-gift-capabilities > span.craft { color: var(--purple); background: var(--purple-tint); border-color: var(--purple-border); }
|
||||
.official-gift-capabilities > span.no { color: var(--muted); background: var(--panel-strong); border-color: var(--line-strong); }
|
||||
.official-gift-empty {
|
||||
display: grid; grid-column: 1 / -1; min-height: 108px; place-items: center; padding: 20px;
|
||||
color: var(--muted); text-align: center; font-size: 12px;
|
||||
}
|
||||
.official-gift-selected {
|
||||
display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 14px; align-items: center;
|
||||
padding: 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-soft);
|
||||
padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface-soft);
|
||||
}
|
||||
.official-gift-selected .gift-animation-shell { width: 96px; height: 96px; }
|
||||
.official-gift-selected > div:last-child { display: grid; gap: 5px; min-width: 0; }
|
||||
|
|
@ -341,22 +408,22 @@
|
|||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
border: 1px dashed #b7ccc8;
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: border-color .16s ease, background .16s ease, box-shadow .16s ease;
|
||||
}
|
||||
|
||||
.gift-file-picker:hover,
|
||||
.gift-file-picker.has-file { background: #f8fcfb; border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .05); }
|
||||
.gift-file-picker.has-file { background: var(--brand-tint); border-color: var(--brand); box-shadow: 0 0 0 2px var(--focus); }
|
||||
.gift-file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.gift-file-icon { width: 40px; height: 40px; border-radius: 9px; }
|
||||
.gift-file-icon { width: 40px; height: 40px; border-radius: var(--radius-sm); }
|
||||
.gift-file-copy { display: grid; min-width: 0; gap: 2px; }
|
||||
.gift-field-label { color: var(--muted); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.gift-file-copy strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gift-file-copy strong { overflow: hidden; color: var(--heading); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gift-file-copy small { color: var(--muted); font-size: 11px; font-weight: 500; }
|
||||
.gift-file-action { padding: 7px 10px; color: var(--brand); background: #f0f8f6; border: 1px solid #c7e3dc; border-radius: 7px; font-size: 11px; font-weight: 800; }
|
||||
.gift-file-action { padding: 7px 10px; color: var(--brand); background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: var(--radius-sm); font-size: 11px; font-weight: 800; }
|
||||
|
||||
.gift-fields-grid {
|
||||
display: grid;
|
||||
|
|
@ -380,37 +447,37 @@
|
|||
height: 38px;
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.gift-fields-grid input:focus,
|
||||
.gift-reason-field input:focus { border-color: #77b6aa; box-shadow: 0 0 0 3px rgba(23, 109, 97, .08); outline: none; }
|
||||
.gift-reason-field input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--focus); outline: none; }
|
||||
|
||||
.gift-switch { display: inline-flex; align-items: center; gap: 9px; color: #344054; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.gift-switch { display: inline-flex; align-items: center; gap: 9px; color: var(--text-soft); font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.gift-switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||
.gift-switch-track { display: flex; width: 34px; height: 19px; align-items: center; padding: 2px; background: #c8d0d5; border-radius: 999px; transition: background .16s ease; }
|
||||
.gift-switch-track { display: flex; width: 34px; height: 19px; align-items: center; padding: 2px; background: var(--switch-track); border-radius: 999px; transition: background .16s ease; }
|
||||
.gift-switch-track span { width: 15px; height: 15px; background: #ffffff; border-radius: 50%; box-shadow: 0 1px 3px rgba(16, 24, 40, .22); transition: transform .16s ease; }
|
||||
.gift-switch input:checked + .gift-switch-track { background: var(--brand); }
|
||||
.gift-switch input:checked + .gift-switch-track span { transform: translateX(15px); }
|
||||
.gift-switch input:focus-visible + .gift-switch-track { outline: 3px solid rgba(23, 109, 97, .16); outline-offset: 2px; }
|
||||
.gift-validation { overflow: hidden; color: #d5fff5; background: #173631; border: 1px solid #24564e; border-radius: 9px; }
|
||||
.gift-validation-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; color: #e3fff9; background: rgba(255, 255, 255, .035); border-bottom: 1px solid rgba(255, 255, 255, .09); }
|
||||
.gift-switch input:focus-visible + .gift-switch-track { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.gift-validation { overflow: hidden; color: var(--code-text); background: var(--code-bg); border: 1px solid var(--code-border); border-radius: var(--radius-sm); }
|
||||
.gift-validation-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; color: var(--code-text); background: rgba(255, 255, 255, .035); border-bottom: 1px solid rgba(255, 255, 255, .09); }
|
||||
.gift-validation-head div { display: grid; gap: 2px; }
|
||||
.gift-validation-head span { color: #99cfc4; font-size: 10px; }
|
||||
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: #d5fff5; font-size: 11px; }
|
||||
.gift-validation-head span { color: var(--brand); font-size: 10px; }
|
||||
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: var(--code-text); font-size: 11px; }
|
||||
|
||||
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eef8f5); }
|
||||
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: var(--surface-soft); }
|
||||
.gift-animation { width: 200px; height: 200px; }
|
||||
.gift-animation canvas { width: 100% !important; height: 100% !important; }
|
||||
.gift-play { position: absolute; right: 8px; bottom: 8px; display: grid; width: 30px; height: 30px; place-items: center; color: var(--text); background: rgba(255,255,255,.9); border: 1px solid var(--line); border-radius: 50%; }
|
||||
.gift-play { position: absolute; right: 8px; bottom: 8px; display: grid; width: 30px; height: 30px; place-items: center; color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 50%; }
|
||||
|
||||
.gift-table-wrap { background: #ffffff; }
|
||||
.gift-table-wrap { background: var(--panel); }
|
||||
.gift-table { min-width: 1080px; }
|
||||
.gift-table th:first-child { width: 74px; }
|
||||
.gift-table td { vertical-align: middle; }
|
||||
.gift-animation-shell.compact { width: 56px; min-height: 56px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; }
|
||||
.gift-animation-shell.compact { width: 56px; min-height: 56px; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-sm); }
|
||||
.gift-animation-shell.compact .gift-animation { width: 54px; height: 54px; }
|
||||
.gift-animation-shell.compact .gift-play { right: 3px; bottom: 3px; width: 20px; height: 20px; }
|
||||
.gift-row-disabled { opacity: .68; }
|
||||
|
|
@ -422,65 +489,67 @@
|
|||
.gift-sort-order,
|
||||
.gift-source-size,
|
||||
.gift-convert-price { margin-top: 3px; color: var(--muted); font-size: 10px; }
|
||||
.gift-table-price { color: #755b00; }
|
||||
.gift-table-price { color: var(--warn); }
|
||||
.gift-table-actions { display: flex; align-items: center; gap: 6px; }
|
||||
.collectible-button { color: #6548a8; background: #f7f3ff; border-color: #ddd2f5; }
|
||||
.collectible-button:hover { background: #efe8ff; border-color: #cbbaf0; }
|
||||
.collectible-button { color: var(--purple); background: var(--purple-tint); border-color: var(--purple-border); }
|
||||
.collectible-button:hover { background: var(--purple-tint); border-color: var(--purple); }
|
||||
|
||||
.collectible-modal { width: min(1180px, 100%); max-height: min(92vh, 980px); }
|
||||
.collectible-modal .modal-head p { margin: 4px 0 0; color: var(--muted); font-size: 11px; }
|
||||
.collectible-modal-body { gap: 16px; overflow: auto; padding: 16px 18px 22px; background: #f5f7fa; }
|
||||
.collectible-modal-body { gap: 16px; overflow: auto; padding: 16px 18px 22px; background: var(--bg); }
|
||||
.collectible-loading { display: flex; min-height: 90px; align-items: center; justify-content: center; gap: 8px; color: var(--muted); }
|
||||
.collectible-empty { display: flex; align-items: center; gap: 12px; padding: 16px; color: #66568c; background: linear-gradient(135deg, #fbf9ff, #f2f7ff); border: 1px dashed #cfc3e9; border-radius: 12px; }
|
||||
.collectible-empty { display: flex; align-items: center; gap: 12px; padding: 16px; color: var(--purple-text); background: var(--purple-tint); border: 1px dashed var(--purple-border); border-radius: var(--radius); }
|
||||
.collectible-empty div,
|
||||
.collectible-definition-head > div:first-child,
|
||||
.collectible-section-head > div:first-child { display: grid; gap: 3px; }
|
||||
.collectible-empty span,
|
||||
.collectible-definition-head span,
|
||||
.collectible-section-head span { color: var(--muted); font-size: 10px; font-weight: 500; }
|
||||
.collectible-active { overflow: hidden; background: #ffffff; border: 1px solid #ddd6ee; border-radius: 12px; box-shadow: 0 5px 16px rgba(66, 46, 110, .05); }
|
||||
.collectible-active-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: linear-gradient(100deg, #fbf9ff, #f4f9ff); border-bottom: 1px solid #e9e4f3; }
|
||||
.collectible-active-head > div { display: flex; align-items: center; gap: 9px; color: #60458f; }
|
||||
.collectible-active { overflow: hidden; background: var(--panel); border: 1px solid var(--purple-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
|
||||
.collectible-active-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: var(--purple-tint); border-bottom: 1px solid var(--purple-border); }
|
||||
.collectible-active-head > div { display: flex; align-items: center; gap: 9px; color: var(--purple-text); }
|
||||
.collectible-active-head > div > div { display: grid; gap: 2px; }
|
||||
.collectible-active-head span { color: var(--muted); font-size: 10px; }
|
||||
.collectible-active-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 1px; background: var(--line); }
|
||||
.collectible-active-grid article { display: flex; min-width: 0; align-items: center; gap: 9px; padding: 9px 11px; background: #ffffff; }
|
||||
.collectible-active-grid article { display: flex; min-width: 0; align-items: center; gap: 9px; padding: 9px 11px; background: var(--panel); }
|
||||
.collectible-active-grid article > div:last-child { display: grid; min-width: 0; gap: 2px; }
|
||||
.collectible-active-grid article strong { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.collectible-active-grid article span { color: var(--muted); font-size: 9px; }
|
||||
.collectible-definition { overflow: hidden; background: #ffffff; border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 8px 24px rgba(16, 24, 40, .04); }
|
||||
.collectible-definition-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; background: linear-gradient(110deg, #f8fbfa, #fbf9ff); border-bottom: 1px solid var(--line); }
|
||||
.collectible-main-fields { padding: 14px 16px; background: #fbfcfd; border-bottom: 1px solid var(--line); }
|
||||
.collectible-definition { overflow: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
|
||||
.collectible-definition-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; background: var(--panel-subtle); border-bottom: 1px solid var(--line); }
|
||||
.collectible-main-fields { padding: 14px 16px; background: var(--panel-subtle); border-bottom: 1px solid var(--line); }
|
||||
.collectible-section { padding: 14px 16px; border-bottom: 1px solid var(--line); }
|
||||
.collectible-section:last-child { border-bottom: 0; }
|
||||
.collectible-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.collectible-section-tools { display: flex; align-items: center; gap: 7px; }
|
||||
.collectible-rows { display: grid; gap: 7px; }
|
||||
.collectible-row { position: relative; display: grid; align-items: end; gap: 7px; padding: 9px 9px 9px 36px; background: #fafbfc; border: 1px solid #e1e6eb; border-radius: 9px; }
|
||||
.collectible-row:hover { background: #ffffff; border-color: #cbd7dd; box-shadow: 0 3px 10px rgba(16, 24, 40, .035); }
|
||||
.collectible-row { position: relative; display: grid; align-items: end; gap: 7px; padding: 9px 9px 9px 36px; background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-sm); }
|
||||
.collectible-row:hover { background: var(--panel); border-color: var(--line-strong); box-shadow: var(--shadow-sm); }
|
||||
.collectible-row.animated { grid-template-columns: minmax(120px, 1.2fr) 90px 78px minmax(160px, 1.4fr) 48px 30px; }
|
||||
.collectible-row.backdrop { grid-template-columns: minmax(110px, 1.2fr) 70px 80px 70px repeat(4, 52px) 48px 30px; }
|
||||
.collectible-row-index { position: absolute; top: 0; bottom: 0; left: 0; display: grid; width: 27px; place-items: center; color: #71668c; background: #f0edf7; border-right: 1px solid #e0d9ed; border-radius: 8px 0 0 8px; font-size: 10px; font-weight: 800; }
|
||||
.collectible-row-index { position: absolute; top: 0; bottom: 0; left: 0; display: grid; width: 27px; place-items: center; color: var(--purple-text); background: var(--purple-tint); border-right: 1px solid var(--purple-border); border-radius: var(--radius-xs) 0 0 var(--radius-xs); font-size: 10px; font-weight: 800; }
|
||||
.collectible-row label { display: grid; min-width: 0; gap: 4px; }
|
||||
.collectible-row label > span { color: var(--muted); font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: .025em; }
|
||||
.collectible-row input:not([type="file"]) { width: 100%; min-width: 0; height: 32px; padding: 0 8px; color: var(--text); background: #ffffff; border: 1px solid #d5dde3; border-radius: 7px; font: inherit; font-size: 11px; }
|
||||
.collectible-row input:focus { border-color: #8d7aba; box-shadow: 0 0 0 3px rgba(111, 91, 174, .08); outline: none; }
|
||||
.collectible-row input:not([type="file"]) { width: 100%; min-width: 0; height: 32px; padding: 0 8px; color: var(--text); background: var(--input-bg); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); font: inherit; font-size: 11px; }
|
||||
.collectible-row input:focus { border-color: var(--purple); box-shadow: 0 0 0 3px var(--purple-tint); outline: none; }
|
||||
.collectible-file input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.collectible-file em { display: flex; min-width: 0; height: 32px; align-items: center; gap: 5px; overflow: hidden; padding: 0 8px; color: #625080; background: #f7f4fd; border: 1px dashed #cfc4e1; border-radius: 7px; font-size: 10px; font-style: normal; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.collectible-inline-preview { display: grid; width: 42px; height: 42px; place-items: center; overflow: hidden; color: #8c7cae; background: radial-gradient(circle, #ffffff, #eee8f8); border: 1px solid #ded5ed; border-radius: 8px; }
|
||||
.collectible-file em { display: flex; min-width: 0; height: 32px; align-items: center; gap: 5px; overflow: hidden; padding: 0 8px; color: var(--purple-text); background: var(--purple-tint); border: 1px dashed var(--purple-border); border-radius: var(--radius-sm); font-size: 10px; font-style: normal; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.collectible-inline-preview { display: grid; width: 42px; height: 42px; place-items: center; overflow: hidden; color: var(--purple); background: var(--purple-tint); border: 1px solid var(--purple-border); border-radius: var(--radius-sm); }
|
||||
.collectible-animation { width: 100%; height: 100%; overflow: hidden; }
|
||||
.collectible-animation.compact { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: radial-gradient(circle, #ffffff, #f0ebfa); border: 1px solid #e0d9ec; border-radius: 8px; }
|
||||
.collectible-animation.compact { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: var(--purple-tint); border: 1px solid var(--purple-border); border-radius: var(--radius-sm); }
|
||||
.collectible-animation canvas { width: 100% !important; height: 100% !important; }
|
||||
.collectible-animation.failed { color: #b42318; background: #fff4f2; }
|
||||
.collectible-animation.loading { color: #807397; }
|
||||
.collectible-file-error { grid-column: 1 / -1; color: #b42318; font-size: 10px; }
|
||||
.collectible-animation.failed { color: var(--danger); background: var(--danger-tint); }
|
||||
.collectible-animation.loading { color: var(--purple-text); }
|
||||
.collectible-file-error { grid-column: 1 / -1; color: var(--danger); font-size: 10px; }
|
||||
.collectible-color input { height: 32px !important; padding: 3px !important; cursor: pointer; }
|
||||
.collectible-backdrop-preview { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border: 1px solid rgba(42, 31, 71, .18); border-radius: 8px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); font-size: 11px; font-weight: 900; }
|
||||
.collectible-backdrop-preview { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border: 1px solid rgba(42, 31, 71, .18); border-radius: var(--radius-sm); box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); font-size: 11px; font-weight: 900; }
|
||||
.collectible-row .icon-btn { align-self: center; }
|
||||
.collectible-row .icon-btn:disabled { opacity: .28; }
|
||||
|
||||
@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,
|
||||
|
|
@ -506,3 +575,113 @@
|
|||
.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: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
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 {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emoji-id svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(17, 24, 39, 0.52);
|
||||
background: var(--overlay);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
|
|
@ -13,9 +14,9 @@
|
|||
max-height: min(820px, calc(100vh - 48px));
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
|
|
@ -43,10 +44,17 @@
|
|||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--btn-hover);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.command-steps {
|
||||
|
|
@ -73,7 +81,7 @@
|
|||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.command-step span {
|
||||
|
|
@ -81,7 +89,7 @@
|
|||
width: 20px;
|
||||
height: 20px;
|
||||
place-items: center;
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
|
|
@ -90,12 +98,12 @@
|
|||
|
||||
.command-step.active {
|
||||
color: var(--brand);
|
||||
border-color: #a9d8ce;
|
||||
border-color: var(--brand-tint-border);
|
||||
}
|
||||
|
||||
.command-step.done {
|
||||
color: var(--good);
|
||||
border-color: #b9dcc7;
|
||||
border-color: var(--good-border);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
|
|
@ -105,10 +113,16 @@
|
|||
|
||||
.form-field span,
|
||||
.form-stack span {
|
||||
color: #4b5563;
|
||||
color: var(--text-soft);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.form-field input:disabled,
|
||||
.form-field textarea:disabled {
|
||||
opacity: .6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.command-preview {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
|
@ -123,7 +137,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
|
|
@ -131,9 +145,9 @@
|
|||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #fbfcfd;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.result-line {
|
||||
|
|
@ -151,13 +165,13 @@
|
|||
}
|
||||
|
||||
.result-message {
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
justify-content: flex-end;
|
||||
padding: 12px 18px;
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
|
|
@ -174,9 +188,9 @@
|
|||
width: min(420px, 100%);
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
|
|
@ -201,14 +215,15 @@
|
|||
align-items: center;
|
||||
padding: 0 8px;
|
||||
color: var(--brand);
|
||||
background: #edf7f4;
|
||||
border: 1px solid #c9e2dc;
|
||||
background: var(--brand-tint);
|
||||
border: 1px solid var(--brand-tint-border);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-copy h1 {
|
||||
margin: 0;
|
||||
color: var(--heading);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
|
@ -237,13 +252,14 @@
|
|||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 18px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.loader-bar {
|
||||
width: 180px;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
background: #d7dde4;
|
||||
background: var(--line-strong);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
|
|
|
|||
106
cmd/telesrv-admin/web/src/theme.tsx
Normal file
106
cmd/telesrv-admin/web/src/theme.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useI18n } from "./i18n";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
const storageKey = "telesrv.admin.theme";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function applyTheme(theme: Theme) {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => initialTheme());
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
try {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
} catch {
|
||||
// Theme persistence is best-effort.
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Follow the OS preference until the user makes an explicit choice.
|
||||
useEffect(() => {
|
||||
if (!window.matchMedia) {
|
||||
return;
|
||||
}
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = (event: MediaQueryListEvent) => {
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = localStorage.getItem(storageKey);
|
||||
} catch {
|
||||
stored = null;
|
||||
}
|
||||
if (stored !== "light" && stored !== "dark") {
|
||||
setThemeState(event.matches ? "dark" : "light");
|
||||
}
|
||||
};
|
||||
media.addEventListener("change", onChange);
|
||||
return () => media.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => setThemeState(next), []);
|
||||
const toggleTheme = useCallback(() => setThemeState((current) => (current === "dark" ? "light" : "dark")), []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme]);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const value = useContext(ThemeContext);
|
||||
if (!value) {
|
||||
throw new Error("useTheme must be used inside ThemeProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function ThemeSwitch() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const nextIsDark = theme === "light";
|
||||
const label = t(nextIsDark ? "theme.switchToDark" : "theme.switchToLight");
|
||||
return (
|
||||
<button
|
||||
className="theme-toggle"
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function initialTheme(): Theme {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored === "light" || stored === "dark") {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// Storage is optional.
|
||||
}
|
||||
try {
|
||||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
return "dark";
|
||||
}
|
||||
} catch {
|
||||
// matchMedia can be unavailable in unusual embedded contexts.
|
||||
}
|
||||
return "light";
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -99,6 +112,27 @@ export type ChannelDetail = {
|
|||
AuditLogs: AuditLogRow[];
|
||||
};
|
||||
|
||||
export type BotRow = {
|
||||
ID: number;
|
||||
Username: string;
|
||||
FirstName: string;
|
||||
Verified: boolean;
|
||||
Scam: boolean;
|
||||
Fake: boolean;
|
||||
System: boolean;
|
||||
OwnerUserID: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type BotDetail = {
|
||||
Bot: BotRow;
|
||||
About: string;
|
||||
Description: string;
|
||||
OwnerUsername: string;
|
||||
AuditLogs: AuditLogRow[];
|
||||
};
|
||||
|
||||
export type MessageRow = {
|
||||
OwnerUserID: number;
|
||||
BoxID: number;
|
||||
|
|
@ -285,6 +319,32 @@ export type ChannelListResponse = {
|
|||
listing: boolean;
|
||||
};
|
||||
|
||||
export type BotListResponse = {
|
||||
query: string;
|
||||
limit: number;
|
||||
rows: BotRow[];
|
||||
has_more: boolean;
|
||||
next_before_id: number;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,6 +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 创建后注入。
|
||||
|
|
|
|||
9
deploy/migrations/0136_scam_fake_flags.down.sql
Normal file
9
deploy/migrations/0136_scam_fake_flags.down.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
ALTER TABLE public.channels
|
||||
DROP CONSTRAINT IF EXISTS channels_scam_fake_mutually_exclusive,
|
||||
DROP COLUMN IF EXISTS scam,
|
||||
DROP COLUMN IF EXISTS fake;
|
||||
|
||||
ALTER TABLE public.users
|
||||
DROP CONSTRAINT IF EXISTS users_scam_fake_mutually_exclusive,
|
||||
DROP COLUMN IF EXISTS scam,
|
||||
DROP COLUMN IF EXISTS fake;
|
||||
19
deploy/migrations/0136_scam_fake_flags.up.sql
Normal file
19
deploy/migrations/0136_scam_fake_flags.up.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- 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;
|
||||
|
||||
UPDATE public.users SET fake = false WHERE scam AND fake;
|
||||
ALTER TABLE public.users
|
||||
DROP CONSTRAINT IF EXISTS users_scam_fake_mutually_exclusive,
|
||||
ADD CONSTRAINT users_scam_fake_mutually_exclusive CHECK (NOT (scam AND fake));
|
||||
|
||||
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;
|
||||
|
||||
UPDATE public.channels SET fake = false WHERE scam AND fake;
|
||||
ALTER TABLE public.channels
|
||||
DROP CONSTRAINT IF EXISTS channels_scam_fake_mutually_exclusive,
|
||||
ADD CONSTRAINT channels_scam_fake_mutually_exclusive CHECK (NOT (scam AND fake));
|
||||
2
deploy/migrations/0137_channel_gigagroup.down.sql
Normal file
2
deploy/migrations/0137_channel_gigagroup.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE public.channels
|
||||
DROP COLUMN IF EXISTS gigagroup;
|
||||
3
deploy/migrations/0137_channel_gigagroup.up.sql
Normal file
3
deploy/migrations/0137_channel_gigagroup.up.sql
Normal 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;
|
||||
1
deploy/migrations/0138_star_gift_admin_grants.down.sql
Normal file
1
deploy/migrations/0138_star_gift_admin_grants.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.star_gift_admin_grant_commands;
|
||||
22
deploy/migrations/0138_star_gift_admin_grants.up.sql
Normal file
22
deploy/migrations/0138_star_gift_admin_grants.up.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- Direct admin collectible grants are one idempotent aggregate: unique
|
||||
-- issuance, saved ownership, private message, pts/outbox and this receipt.
|
||||
CREATE TABLE public.star_gift_admin_grant_commands (
|
||||
recipient_user_id bigint NOT NULL,
|
||||
command_key text NOT NULL,
|
||||
request_fingerprint bytea NOT NULL,
|
||||
sender_user_id bigint NOT NULL,
|
||||
gift_id bigint NOT NULL,
|
||||
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
|
||||
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT star_gift_admin_grant_commands_pkey PRIMARY KEY (recipient_user_id, command_key),
|
||||
CONSTRAINT star_gift_admin_grant_command_saved_uniq UNIQUE (saved_gift_id),
|
||||
CONSTRAINT star_gift_admin_grant_command_unique_uniq UNIQUE (unique_gift_id),
|
||||
CONSTRAINT star_gift_admin_grant_command_shape_check CHECK (
|
||||
recipient_user_id > 0
|
||||
AND sender_user_id = 777000
|
||||
AND gift_id > 0
|
||||
AND char_length(command_key) BETWEEN 1 AND 256
|
||||
AND octet_length(request_fingerprint) = 32
|
||||
)
|
||||
);
|
||||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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,9 @@ const (
|
|||
ActionPublishGiftCollectibles = "gifts.collectibles.publish"
|
||||
ActionSetStarGiftEnabled = "gifts.set_enabled"
|
||||
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
|
||||
ActionGiveGift = "gifts.give"
|
||||
ActionCreateBot = "bot.create"
|
||||
ActionDeleteBot = "bot.delete"
|
||||
|
||||
maxCommandIDLength = 128
|
||||
maxActorLength = 128
|
||||
|
|
@ -75,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 {
|
||||
|
|
@ -96,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 {
|
||||
|
|
@ -110,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)
|
||||
|
|
@ -127,6 +151,28 @@ type OfficialGiftsSource interface {
|
|||
Bundle(ctx context.Context, giftID int64, includeCollectible bool) (officialgifts.Bundle, error)
|
||||
}
|
||||
|
||||
// BotService creates bot accounts on behalf of the admin. It mirrors the
|
||||
// owner-scoped /newbot flow: a bot is a users row (is_bot=true) plus a bots row
|
||||
// owned by ownerUserID, and the returned token is shown once to the operator.
|
||||
type BotService interface {
|
||||
CreateBot(ctx context.Context, ownerUserID int64, name, username string) (domain.User, string, error)
|
||||
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
|
||||
|
|
@ -141,7 +187,10 @@ type Dependencies struct {
|
|||
ChannelNotifier ChannelNotifier
|
||||
Messages MessagesService
|
||||
Gifts GiftsService
|
||||
GiftGranter GiftGranter
|
||||
OfficialGifts OfficialGiftsSource
|
||||
Bots BotService
|
||||
Emoji EmojiService
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
|
|
@ -159,7 +208,10 @@ type Service struct {
|
|||
channelNotifier ChannelNotifier
|
||||
messages MessagesService
|
||||
gifts GiftsService
|
||||
giftGranter GiftGranter
|
||||
officialGifts OfficialGiftsSource
|
||||
bots BotService
|
||||
emoji EmojiService
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
|
|
@ -208,9 +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
|
||||
}
|
||||
|
|
@ -238,6 +299,10 @@ type CommandResult struct {
|
|||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// transientDetails are returned to the initiating caller only. They are
|
||||
// deliberately excluded from JSON so credentials can never enter command
|
||||
// replay or audit storage.
|
||||
transientDetails map[string]any
|
||||
}
|
||||
|
||||
type ImportStarGiftRequest struct {
|
||||
|
|
@ -282,6 +347,23 @@ type SetStarGiftSortOrderRequest struct {
|
|||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// GiveGiftRequest grants a catalog gift to a recipient (user or channel) from
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type StarGiftCollectibleAnimationUpload struct {
|
||||
Name string `json:"name"`
|
||||
RarityPermille int `json:"rarity_permille"`
|
||||
|
|
@ -346,6 +428,98 @@ 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"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type DeleteBotRequest struct {
|
||||
CommandMeta
|
||||
BotUserID int64 `json:"bot_user_id"`
|
||||
}
|
||||
|
||||
type RevokeSessionsRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
|
|
@ -691,6 +865,376 @@ 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")
|
||||
}
|
||||
if req.Scam && req.Fake {
|
||||
return CommandResult{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
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 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
|
||||
}
|
||||
if sender != domain.OfficialSystemUserID {
|
||||
return CommandResult{}, fmt.Errorf("gift sender must be the official system account")
|
||||
}
|
||||
req.Message = strings.TrimSpace(req.Message)
|
||||
if len([]rune(req.Message)) > 128 {
|
||||
return CommandResult{}, fmt.Errorf("gift message must be <= 128 characters")
|
||||
}
|
||||
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) {
|
||||
return CommandResult{}, fmt.Errorf("collectible attributes 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 req.Message != "" {
|
||||
details["message"] = 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)
|
||||
}
|
||||
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.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: req.Message,
|
||||
Upgrade: req.Upgrade,
|
||||
CommandKey: "admin-gift:" + req.CommandID,
|
||||
ModelAttributeID: req.ModelAttributeID,
|
||||
PatternAttributeID: req.PatternAttributeID,
|
||||
BackdropAttributeID: req.BackdropAttributeID,
|
||||
}); 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
|
||||
// the operator can copy it once.
|
||||
func (s *Service) CreateBot(ctx context.Context, req CreateBotRequest) (CommandResult, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin bot dependency is not configured")
|
||||
}
|
||||
if req.OwnerUserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("owner_user_id is required")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" || len([]rune(name)) > domain.MaxBotNameLength {
|
||||
return CommandResult{}, domain.ErrBotNameInvalid
|
||||
}
|
||||
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
|
||||
if !domain.ValidBotUsername(username) {
|
||||
return CommandResult{}, domain.ErrBotUsernameInvalid
|
||||
}
|
||||
req.Name = name
|
||||
req.Username = username
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionCreateBot, req.OwnerUserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"owner_user_id": req.OwnerUserID,
|
||||
"name": name,
|
||||
"username": username,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "bot creation validated", Details: details}, nil
|
||||
}
|
||||
bot, token, err := s.bots.CreateBot(ctx, req.OwnerUserID, name, username)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["bot_user_id"] = bot.ID
|
||||
if err := s.notifyUserChanged(ctx, bot); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
return CommandResult{
|
||||
Message: "bot created",
|
||||
Details: details,
|
||||
transientDetails: map[string]any{"token": token},
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBot permanently removes a user-created bot. The dry-run stage verifies
|
||||
// the target is a non-system bot; the confirm stage tombstones the account and
|
||||
// invalidates its token. System bots are rejected outright.
|
||||
func (s *Service) DeleteBot(ctx context.Context, req DeleteBotRequest) (CommandResult, error) {
|
||||
if s == nil || s.bots == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin bot dependency is not configured")
|
||||
}
|
||||
if req.BotUserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("bot_user_id is required")
|
||||
}
|
||||
if domain.IsSystemUserID(req.BotUserID) {
|
||||
return CommandResult{}, fmt.Errorf("system bots cannot be deleted")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionDeleteBot, req.BotUserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"bot_user_id": req.BotUserID}
|
||||
if s.users != nil {
|
||||
u, found, err := s.users.AdminUser(ctx, req.BotUserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !found || !u.Bot {
|
||||
return CommandResult{}, domain.ErrBotNotFound
|
||||
}
|
||||
details["username"] = u.Username
|
||||
details["name"] = u.FirstName
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "bot deletion validated", Details: details}, nil
|
||||
}
|
||||
deleted, err := s.bots.DeleteBot(ctx, req.BotUserID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["deleted"] = true
|
||||
if err := s.notifyUserChanged(ctx, deleted); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
return CommandResult{Message: "bot deleted", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) SetChannelVerified(ctx context.Context, req SetChannelVerifiedRequest) (CommandResult, error) {
|
||||
if req.ChannelID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("channel_id is required")
|
||||
|
|
@ -731,6 +1275,191 @@ 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")
|
||||
}
|
||||
if req.Scam && req.Fake {
|
||||
return CommandResult{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
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")
|
||||
|
|
@ -1289,6 +2018,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
|
||||
|
|
@ -1368,14 +2105,24 @@ func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action strin
|
|||
if marshalErr != nil {
|
||||
return result, fmt.Errorf("marshal admin result: %w", marshalErr)
|
||||
}
|
||||
response := result
|
||||
if len(result.transientDetails) > 0 {
|
||||
response.Details = make(map[string]any, len(result.Details)+len(result.transientDetails))
|
||||
for key, value := range result.Details {
|
||||
response.Details[key] = value
|
||||
}
|
||||
for key, value := range result.transientDetails {
|
||||
response.Details[key] = value
|
||||
}
|
||||
}
|
||||
errorText := ""
|
||||
if opErr != nil {
|
||||
errorText = opErr.Error()
|
||||
}
|
||||
if _, err := s.commands.FinishCommand(ctx, meta.CommandID, status, resultJSON, errorText); err != nil {
|
||||
return result, err
|
||||
return response, err
|
||||
}
|
||||
return result, opErr
|
||||
return response, opErr
|
||||
}
|
||||
|
||||
func sameJSON(a, b []byte) bool {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,69 @@ func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCreateBotReturnsTokenOnceWithoutPersistingCredential(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
bots := &fakeBotService{token: "test-one-time-bot-credential"}
|
||||
svc := NewService(Dependencies{Commands: repo, Bots: bots, Now: fixedNow})
|
||||
req := CreateBotRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "create-bot-once", Actor: "ops", Reason: "requested"},
|
||||
OwnerUserID: 1001,
|
||||
Name: "Audit Safe Bot",
|
||||
Username: "audit_safe_bot",
|
||||
}
|
||||
|
||||
first, err := svc.CreateBot(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBot: %v", err)
|
||||
}
|
||||
if first.Details["token"] != bots.token || bots.createCalls != 1 {
|
||||
t.Fatalf("first result=%+v createCalls=%d", first, bots.createCalls)
|
||||
}
|
||||
stored := repo.items[req.CommandID].ResultJSON
|
||||
if bytes.Contains(stored, []byte(bots.token)) || bytes.Contains(stored, []byte(`"token"`)) {
|
||||
t.Fatalf("persisted admin result contains bot credential: %s", stored)
|
||||
}
|
||||
|
||||
replay, err := svc.CreateBot(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBot replay: %v", err)
|
||||
}
|
||||
if !replay.AlreadyExecuted || bots.createCalls != 1 {
|
||||
t.Fatalf("replay=%+v createCalls=%d", replay, bots.createCalls)
|
||||
}
|
||||
if _, leaked := replay.Details["token"]; leaked {
|
||||
t.Fatalf("replayed command exposed one-time bot token: %+v", replay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModerationFlagsRejectImpossibleScamFakeState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{1001: {ID: 1001}}}
|
||||
channels := &fakeChannelsService{channels: map[int64]domain.Channel{2001: {
|
||||
ID: 2001, Megagroup: true,
|
||||
}}}
|
||||
svc := NewService(Dependencies{Commands: repo, Users: users, Channels: channels, Now: fixedNow})
|
||||
meta := CommandMeta{CommandID: "invalid-user-flags", Actor: "ops", Reason: "test"}
|
||||
if _, err := svc.SetUserFlags(ctx, SetUserFlagsRequest{
|
||||
CommandMeta: meta, UserID: 1001, Scam: true, Fake: true,
|
||||
}); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("SetUserFlags error=%v", err)
|
||||
}
|
||||
meta.CommandID = "invalid-channel-flags"
|
||||
if _, err := svc.SetChannelFlags(ctx, SetChannelFlagsRequest{
|
||||
CommandMeta: meta, ChannelID: 2001, Scam: true, Fake: true,
|
||||
}); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("SetChannelFlags error=%v", err)
|
||||
}
|
||||
if len(repo.items) != 0 || users.users[1001].Scam || users.users[1001].Fake ||
|
||||
channels.channels[2001].Scam || channels.channels[2001].Fake {
|
||||
t.Fatalf("invalid moderation state reached command/store boundary: commands=%d user=%+v channel=%+v",
|
||||
len(repo.items), users.users[1001], channels.channels[2001])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFreezesBatchesAndReturnsOnlyActiveFacts(t *testing.T) {
|
||||
now := fixedNow()
|
||||
store := &fakeBatchRestrictionStore{fakeRestrictionStore: fakeRestrictionStore{items: map[int64]domain.AccountFreeze{
|
||||
|
|
@ -505,6 +568,22 @@ func (m *memoryCommandRepo) FinishCommand(_ context.Context, commandID string, s
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
type fakeBotService struct {
|
||||
token string
|
||||
createCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func (f *fakeBotService) CreateBot(_ context.Context, _ int64, name, username string) (domain.User, string, error) {
|
||||
f.createCalls++
|
||||
return domain.User{ID: 2001, FirstName: name, Username: username, Bot: true}, f.token, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotService) DeleteBot(_ context.Context, botUserID int64) (domain.User, error) {
|
||||
f.deleteCalls++
|
||||
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
|
||||
}
|
||||
|
||||
type fakeRestrictionStore struct {
|
||||
items map[int64]domain.AccountFreeze
|
||||
setCalls int
|
||||
|
|
@ -680,6 +759,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 +887,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 +1214,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{
|
||||
|
|
|
|||
|
|
@ -29,7 +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)
|
||||
|
|
@ -40,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)
|
||||
}
|
||||
|
|
@ -95,8 +109,20 @@ 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))
|
||||
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
|
||||
mux.HandleFunc("POST /v1/gifts/import", s.authenticated(s.handleImportStarGift))
|
||||
|
|
@ -106,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
|
||||
|
|
@ -168,6 +196,114 @@ 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) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.CreateBot(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteBot(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.DeleteBotRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.DeleteBot(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeSessions(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeSessionsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
|
|
@ -367,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 {
|
||||
|
|
@ -388,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 {
|
||||
|
|
|
|||
|
|
@ -250,6 +250,58 @@ func (fakeService) SetChannelVerified(_ context.Context, req admin.SetChannelVer
|
|||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) CreateBot(_ context.Context, req admin.CreateBotRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) DeleteBot(_ context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error) {
|
||||
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
|
||||
}
|
||||
|
|
@ -294,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package bots
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -372,6 +373,38 @@ func TestRevokeBotTokenRevokesSessions(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDeleteBotFailsClosedWhenSessionRevocationFails(t *testing.T) {
|
||||
users := memory.NewUserStore()
|
||||
botStore := &countingBotStore{BotStore: memory.NewBotStore(users)}
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
revocationErr := errors.New("authorization store unavailable")
|
||||
rev := &captureRevoker{err: revocationErr}
|
||||
svc := NewService(users, botStore, messages)
|
||||
svc.SetRouterHooks(rev)
|
||||
owner := newOwner(t, users, "+2099")
|
||||
bot := makeBot(t, svc, owner, "Delete Guard Bot", "delete_guard_bot")
|
||||
|
||||
if _, err := svc.DeleteBot(context.Background(), bot.ID); !errors.Is(err, domain.ErrBotSessionsNotRevoked) {
|
||||
t.Fatalf("DeleteBot error=%v, want ErrBotSessionsNotRevoked", err)
|
||||
}
|
||||
if botStore.deleteCalls != 0 {
|
||||
t.Fatalf("DeleteBotAccount calls=%d after failed session revocation", botStore.deleteCalls)
|
||||
}
|
||||
if _, found, err := botStore.GetBot(context.Background(), bot.ID); err != nil || !found {
|
||||
t.Fatalf("bot disappeared after failed revocation: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
rev.err = nil
|
||||
deleted, err := svc.DeleteBot(context.Background(), bot.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteBot after revocation recovery: %v", err)
|
||||
}
|
||||
if botStore.deleteCalls != 1 || deleted.ID != bot.ID || !deleted.Deleted {
|
||||
t.Fatalf("deleted=%+v deleteCalls=%d", deleted, botStore.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotWriteAccessGrant(t *testing.T) {
|
||||
svc, users, _, _ := newTestService(t)
|
||||
owner := newOwner(t, users, "+2012")
|
||||
|
|
@ -400,11 +433,12 @@ type captureRevoker struct {
|
|||
botUserID int64
|
||||
pushedCommandsTo int64
|
||||
pushedCommands []domain.BotCommand
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *captureRevoker) RevokeBotSessions(_ context.Context, botUserID int64) error {
|
||||
c.botUserID = botUserID
|
||||
return nil
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int64, commands []domain.BotCommand) {
|
||||
|
|
|
|||
|
|
@ -448,6 +448,45 @@ func (s *Service) ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domai
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// botAccountDeleter is the optional store capability used to permanently delete
|
||||
// a user-created bot. Only the Postgres store implements it, so the memory store
|
||||
// and other BotStore mocks are unaffected.
|
||||
type botAccountDeleter interface {
|
||||
DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error)
|
||||
}
|
||||
|
||||
// DeleteBot permanently removes a user-created bot. System service bots are
|
||||
// rejected. Live sessions are dropped and the bot's caches are invalidated so
|
||||
// the deletion is visible immediately. Returns the tombstoned user.
|
||||
func (s *Service) DeleteBot(ctx context.Context, botUserID int64) (domain.User, error) {
|
||||
if s == nil || s.bots == nil || botUserID == 0 {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
if domain.IsSystemUserID(botUserID) {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
deleter, ok := s.bots.(botAccountDeleter)
|
||||
if !ok {
|
||||
return domain.User{}, fmt.Errorf("bot deletion is not supported by the configured store")
|
||||
}
|
||||
// Session revocation is part of the deletion invariant: a deleted bot must
|
||||
// never retain an authenticated connection. Fail closed before tombstoning
|
||||
// when the hook is unavailable or revocation fails.
|
||||
if s.hooks == nil {
|
||||
return domain.User{}, domain.ErrBotSessionsNotRevoked
|
||||
}
|
||||
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
|
||||
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err))
|
||||
return domain.User{}, domain.ErrBotSessionsNotRevoked
|
||||
}
|
||||
u, err := deleter.DeleteBotAccount(ctx, botUserID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ExportBotToken 返回 bot token;revoke=true 时先轮换 secret 并撤销已登录 session。
|
||||
func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) {
|
||||
if revoke {
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ type countingBotStore struct {
|
|||
*memory.BotStore
|
||||
getBotCalls int
|
||||
getBotsCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func (s *countingBotStore) reset() {
|
||||
|
|
@ -198,6 +199,11 @@ func (s *countingBotStore) GetBots(ctx context.Context, botUserIDs []int64) (map
|
|||
return s.BotStore.GetBots(ctx, botUserIDs)
|
||||
}
|
||||
|
||||
func (s *countingBotStore) DeleteBotAccount(_ context.Context, botUserID int64) (domain.User, error) {
|
||||
s.deleteCalls++
|
||||
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
|
||||
}
|
||||
|
||||
func TestBotFatherCancelAndUnknown(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+1001")
|
||||
|
|
|
|||
|
|
@ -500,6 +500,49 @@ 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
|
||||
}
|
||||
if scam && fake {
|
||||
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
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 {
|
||||
|
|
|
|||
79
internal/app/files/emoji_animation.go
Normal file
79
internal/app/files/emoji_animation.go
Normal 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
|
||||
}
|
||||
|
|
@ -517,6 +517,18 @@ func (s *Service) UpgradeReceipt(ctx context.Context, userID int64, commandKey s
|
|||
return s.upgrades.StarGiftUpgradeReceipt(ctx, userID, commandKey)
|
||||
}
|
||||
|
||||
// GrantUnique atomically assigns a freshly minted collectible to a user.
|
||||
func (s *Service) GrantUnique(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) {
|
||||
if s == nil || s.upgrades == nil {
|
||||
return domain.AdminStarGiftGrantResult{}, fmt.Errorf("star gift upgrade store is not configured")
|
||||
}
|
||||
result, err := s.upgrades.GrantUniqueStarGift(ctx, req)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable
|
||||
|
|
|
|||
|
|
@ -365,6 +365,56 @@ 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
|
||||
}
|
||||
if scam && fake {
|
||||
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
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 下发
|
||||
// 正确性由读取路径即时派生保证,这里只做收尾与通知。
|
||||
|
|
|
|||
|
|
@ -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), "/"),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -327,6 +327,45 @@ type StarGiftUpgradeRequest struct {
|
|||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
|
||||
// Admin-controlled attribute overrides. When non-zero these pin the specific
|
||||
// collectible model/pattern/backdrop instead of the random pool draw. They
|
||||
// are only honoured on the admin grant path; the DB FK (attribute must belong
|
||||
// to the revision) remains the source of truth. The collectible number is
|
||||
// always assigned automatically (sequential).
|
||||
ModelAttributeID int64
|
||||
PatternAttributeID int64
|
||||
BackdropAttributeID int64
|
||||
}
|
||||
|
||||
// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to
|
||||
// Recipient from the official system account 777000 at no charge.
|
||||
// When Upgrade is set the gift is minted as a collectible; the optional
|
||||
// attribute IDs pin specific model/pattern/backdrop (0 => random). The
|
||||
// collectible number is always assigned automatically.
|
||||
type AdminStarGiftGrant struct {
|
||||
SenderID int64
|
||||
Recipient Peer
|
||||
GiftID int64
|
||||
HideName bool
|
||||
Message string
|
||||
Upgrade bool
|
||||
CommandKey string
|
||||
Date int
|
||||
RecipientBlocked bool
|
||||
ModelAttributeID int64
|
||||
PatternAttributeID int64
|
||||
BackdropAttributeID int64
|
||||
}
|
||||
|
||||
// AdminStarGiftGrantResult is the committed direct collectible assignment.
|
||||
// The saved gift, unique issuance, private message and replay receipt are one
|
||||
// aggregate transaction.
|
||||
type AdminStarGiftGrantResult struct {
|
||||
Saved SavedStarGift
|
||||
Unique UniqueStarGift
|
||||
Send SendPrivateTextResult
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
type StarGiftPurchaseRequest struct {
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ type User struct {
|
|||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Scam bool
|
||||
Fake bool
|
||||
Support bool
|
||||
Contact bool
|
||||
Mutual bool
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ var (
|
|||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserFrozen = errors.New("user account frozen")
|
||||
ErrAuthenticatedScopeInvalid = errors.New("authenticated user scope invalid")
|
||||
// ErrPeerModerationFlagsInvalid rejects the impossible scam+fake state at
|
||||
// every write boundary shared by user, bot and channel projections.
|
||||
ErrPeerModerationFlagsInvalid = errors.New("peer moderation flags invalid")
|
||||
// ErrPremiumRequired 表示该操作仅限有效会员(PREMIUM_ACCOUNT_REQUIRED)。
|
||||
ErrPremiumRequired = errors.New("premium account required")
|
||||
// ErrPremiumBotUnsupported 表示 bot 账号不可被授予会员(官方语义)。
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
81
internal/rpc/convert_flags.go
Normal file
81
internal/rpc/convert_flags.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 服务消息投递到收礼人私聊。
|
||||
|
|
@ -521,10 +523,17 @@ func (r *Router) onPaymentsGetSavedStarGifts(ctx context.Context, req *tg.Paymen
|
|||
if r.deps.Gifts == nil {
|
||||
return emptySavedStarGifts(), nil
|
||||
}
|
||||
// Gifts hidden from the profile (unsaved) are visible only to the owner (or a
|
||||
// channel admin). Never trust the client's exclude_unsaved flag for other
|
||||
// viewers: force-exclude hidden gifts unless the requester manages the owner.
|
||||
excludeUnsaved := req.ExcludeUnsaved
|
||||
if r.ensureCanManageStarGiftOwner(ctx, userID, owner) != nil {
|
||||
excludeUnsaved = true
|
||||
}
|
||||
collectionID, _ := req.GetCollectionID()
|
||||
page, err := r.deps.Gifts.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
|
||||
Owner: owner,
|
||||
ExcludeUnsaved: req.ExcludeUnsaved,
|
||||
ExcludeUnsaved: excludeUnsaved,
|
||||
ExcludeSaved: req.ExcludeSaved,
|
||||
ExcludeUnlimited: req.ExcludeUnlimited,
|
||||
ExcludeUnique: req.ExcludeUnique,
|
||||
|
|
@ -554,6 +563,17 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
|
|||
return emptySavedStarGifts(), nil
|
||||
}
|
||||
gifts := make([]domain.SavedStarGift, 0, len(refs))
|
||||
// A gift hidden from the profile (unsaved) is visible only to the owner or a
|
||||
// channel admin. Memoize the manage check per owner to avoid repeat lookups.
|
||||
manageCache := make(map[domain.Peer]bool)
|
||||
canManageOwner := func(owner domain.Peer) bool {
|
||||
if v, ok := manageCache[owner]; ok {
|
||||
return v
|
||||
}
|
||||
v := r.ensureCanManageStarGiftOwner(ctx, userID, owner) == nil
|
||||
manageCache[owner] = v
|
||||
return v
|
||||
}
|
||||
for _, ref := range refs {
|
||||
dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref)
|
||||
if err != nil {
|
||||
|
|
@ -567,6 +587,9 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
|
|||
return nil, internalErr()
|
||||
}
|
||||
if found && !g.Converted {
|
||||
if g.Unsaved && !canManageOwner(g.Owner) {
|
||||
continue
|
||||
}
|
||||
gifts = append(gifts, g)
|
||||
}
|
||||
}
|
||||
|
|
@ -667,9 +690,14 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
|
|||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound):
|
||||
return false, starGiftInvalidErr()
|
||||
case errors.Is(err, domain.ErrStarGiftAlreadyConverted):
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound),
|
||||
errors.Is(err, domain.ErrStarGiftAlreadyConverted),
|
||||
errors.Is(err, domain.ErrStarGiftAlreadyUpgraded),
|
||||
errors.Is(err, domain.ErrStarGiftOwnerInvalid),
|
||||
errors.Is(err, domain.ErrStarGiftUnavailable):
|
||||
// These are known business conditions (e.g. converting an already
|
||||
// upgraded/unique gift). Surface a clean client error instead of a
|
||||
// 500 INTERNAL_SERVER_ERROR.
|
||||
return false, starGiftInvalidErr()
|
||||
default:
|
||||
return false, internalErr()
|
||||
|
|
|
|||
98
internal/rpc/payments_star_gifts_admin.go
Normal file
98
internal/rpc/payments_star_gifts_admin.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type adminUniqueStarGiftGranter interface {
|
||||
GrantUnique(context.Context, domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error)
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// SenderID must be zero or the official system account (777000). When Upgrade
|
||||
// is true, the store assigns a genuine collectible directly in the same
|
||||
// transaction as its service message and durable updates. The optional
|
||||
// ModelAttributeID / PatternAttributeID / BackdropAttributeID pin specific
|
||||
// collectible facts (0 => random; number is always sequential). 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 senderID != domain.OfficialSystemUserID {
|
||||
return fmt.Errorf("gift sender must be the official system account")
|
||||
}
|
||||
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 assigns a collectible through the atomic store
|
||||
// boundary, so a failure cannot leave a regular gift, partial issuance, pts or
|
||||
// outbox event behind.
|
||||
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)
|
||||
}
|
||||
granter, ok := r.deps.Gifts.(adminUniqueStarGiftGranter)
|
||||
if !ok {
|
||||
return fmt.Errorf("atomic collectible grant is not configured")
|
||||
}
|
||||
recipientBlocked, err := r.peerBlocksUser(ctx, senderID, grant.Recipient.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grant.SenderID = senderID
|
||||
grant.Date = int(r.clock.Now().Unix())
|
||||
grant.RecipientBlocked = recipientBlocked
|
||||
if _, err := granter.GrantUnique(ctx, grant); err != nil {
|
||||
return err
|
||||
}
|
||||
r.invalidateStarGiftOwnerProjection(grant.Recipient)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -197,6 +197,113 @@ 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
|
||||
}
|
||||
if scam && fake {
|
||||
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
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, "@")))
|
||||
|
|
|
|||
40
internal/store/memory/moderation_flags_test.go
Normal file
40
internal/store/memory/moderation_flags_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestModerationStoresRejectScamAndFakeTogether(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := NewUserStore()
|
||||
user, err := users.Create(ctx, domain.User{Phone: "+15550009999", FirstName: "Flag"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := users.SetScamFake(ctx, user.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("user SetScamFake error=%v", err)
|
||||
}
|
||||
gotUser, found, err := users.ByID(ctx, user.ID)
|
||||
if err != nil || !found || gotUser.Scam || gotUser.Fake {
|
||||
t.Fatalf("user after rejected flags=%+v found=%v err=%v", gotUser, found, err)
|
||||
}
|
||||
|
||||
channels := NewChannelStore()
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: user.ID, Title: "Flags", Megagroup: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("channel SetChannelScamFake error=%v", err)
|
||||
}
|
||||
gotChannel, err := channels.GetChannelByID(ctx, created.Channel.ID)
|
||||
if err != nil || gotChannel.Scam || gotChannel.Fake {
|
||||
t.Fatalf("channel after rejected flags=%+v err=%v", gotChannel, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -283,6 +283,36 @@ 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) {
|
||||
if scam && fake {
|
||||
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
|
@ -87,6 +88,91 @@ func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profi
|
|||
return userFromModel(row), profile, nil
|
||||
}
|
||||
|
||||
// DeleteBotAccount permanently removes a user-created bot in one transaction:
|
||||
// it revokes the bot's sessions, purges its private state, releases its
|
||||
// username, drops the bots row (which invalidates the token) and tombstones the
|
||||
// users row. System service bots and non-bot users are rejected. The reused
|
||||
// helpers are the same vetted primitives that back account deletion, so the
|
||||
// tombstone satisfies users_deletion_state_check. Returns the tombstoned user
|
||||
// for change notifications.
|
||||
func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error) {
|
||||
if botUserID == 0 || domain.IsSystemUserID(botUserID) {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if err := lockUsersForUpdate(ctx, tx, botUserID); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: lock: %w", err)
|
||||
}
|
||||
u, found, err := NewUserStore(tx).ByID(ctx, botUserID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found || !u.Bot || u.Deleted {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
// Only bots backed by a bots row (created via /newbot or the admin) are
|
||||
// deletable here; system service bots are already excluded above.
|
||||
var hasBotRow bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_user_id = $1)`, botUserID).Scan(&hasBotRow); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: probe bots row: %w", err)
|
||||
}
|
||||
if !hasBotRow {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := enqueueAccountDeletionNotifications(ctx, tx, botUserID); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if _, err := revokeByUserExceptTx(ctx, tx, botUserID, 0); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: revoke sessions: %w", err)
|
||||
}
|
||||
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, ""); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: release username: %w", err)
|
||||
}
|
||||
// Drop the bots row so the token can no longer authenticate a login.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM bots WHERE bot_user_id = $1`, botUserID); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: delete bots row: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users SET
|
||||
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
|
||||
verified = false, support = false, last_seen_at = 0,
|
||||
premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0,
|
||||
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
|
||||
color_set = false, color = 0, color_background_emoji_id = 0,
|
||||
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
|
||||
birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0,
|
||||
deleted_at = $2, deletion_source = 'manual', deletion_reason = 'admin bot deletion',
|
||||
account_delete_at = NULL, updated_at = $2
|
||||
WHERE id = $1 AND deleted_at IS NULL`, botUserID, now); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: tombstone: %w", err)
|
||||
}
|
||||
u, found, err = NewUserStore(tx).ByID(ctx, botUserID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = domain.ErrUserNotFound
|
||||
}
|
||||
return domain.User{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: commit: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *BotStore) GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
|
||||
if botUserID == 0 {
|
||||
return domain.BotProfile{}, false, nil
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -284,6 +284,180 @@ 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
|
||||
}
|
||||
if scam && fake {
|
||||
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
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, "@")))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -119,7 +119,12 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
|
|||
type privateSendTxHooks struct {
|
||||
before func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) error
|
||||
projectMedia func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest) (privateSendMediaProjection, error)
|
||||
after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error
|
||||
// afterAllocate runs after the immutable logical message and both box IDs
|
||||
// exist, but before either box, update event or replay snapshot is written.
|
||||
// It may finalize req.Media using those IDs; all of its writes remain in the
|
||||
// same private-send transaction.
|
||||
afterAllocate func(context.Context, pgx.Tx, *domain.SendPrivateTextRequest, int, int) error
|
||||
after func(context.Context, pgx.Tx, domain.SendPrivateTextResult) error
|
||||
}
|
||||
|
||||
// privateSendMediaProjection separates the logical private-message payload
|
||||
|
|
@ -325,6 +330,44 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient pts: %w", err)
|
||||
}
|
||||
}
|
||||
if hooks.afterAllocate != nil {
|
||||
// A callback may replace the media after the ordinary request
|
||||
// fingerprint was computed. Requiring a complete caller-owned
|
||||
// fingerprint keeps random_id replay bound to the final aggregate intent.
|
||||
if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "after-allocate private send"); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
if err := hooks.afterAllocate(ctx, tx, &req, senderBoxID, recipientBoxID); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
media = privateSendMediaProjection{Shared: req.Media, Sender: req.Media, Recipient: req.Media}
|
||||
if hooks.projectMedia != nil {
|
||||
media, err = hooks.projectMedia(ctx, tx, &req)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
}
|
||||
sharedMediaJSON, err = encodeMessageMedia(media.Shared)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
senderMediaJSON, err = encodeMessageMedia(media.Sender)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
recipientMediaJSON, err = encodeMessageMedia(media.Recipient)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `UPDATE private_messages SET media=$3
|
||||
WHERE sender_user_id=$1 AND id=$2`, req.SenderUserID, pm.ID, sharedMediaJSON)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("finalize private message media: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("finalize private message media: logical message disappeared")
|
||||
}
|
||||
}
|
||||
|
||||
senderArg := sqlcgen.CreateMessageBoxParams{
|
||||
OwnerUserID: req.SenderUserID,
|
||||
|
|
|
|||
50
internal/store/postgres/moderation_flags_integration_test.go
Normal file
50
internal/store/postgres/moderation_flags_integration_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestModerationFlagsRejectImpossibleStateAtPostgresBoundary(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
user := createTestUser(t, ctx, users, "+1781"+suffix+"71", "ModerationFlags", "")
|
||||
|
||||
if _, err := users.SetScamFake(ctx, user.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("user store error=%v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE users SET scam=true,fake=true WHERE id=$1`, user.ID); err == nil {
|
||||
t.Fatal("users CHECK constraint accepted scam=true,fake=true")
|
||||
}
|
||||
|
||||
channels := NewChannelStore(pool)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: user.ID,
|
||||
Title: "Moderation " + suffix,
|
||||
Megagroup: true,
|
||||
Date: 1700002000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, true); !errors.Is(err, domain.ErrPeerModerationFlagsInvalid) {
|
||||
t.Fatalf("channel store error=%v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE channels SET scam=true,fake=true WHERE id=$1`, created.Channel.ID); err == nil {
|
||||
t.Fatal("channels CHECK constraint accepted scam=true,fake=true")
|
||||
}
|
||||
|
||||
gotUser, found, err := users.ByID(ctx, user.ID)
|
||||
if err != nil || !found || gotUser.Scam || gotUser.Fake {
|
||||
t.Fatalf("user after rejected writes=%+v found=%v err=%v", gotUser, found, err)
|
||||
}
|
||||
gotChannel, err := channels.GetChannelByID(ctx, created.Channel.ID)
|
||||
if err != nil || gotChannel.Scam || gotChannel.Fake {
|
||||
t.Fatalf("channel after rejected writes=%+v err=%v", gotChannel, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2283,6 +2283,8 @@ type User struct {
|
|||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LinkedCommunityID int64
|
||||
Scam bool
|
||||
Fake bool
|
||||
}
|
||||
|
||||
type UserBusinessProfile struct {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -603,6 +603,125 @@ FROM peer_star_gifts p JOIN unique_star_gifts u ON u.id=p.unique_gift_id WHERE p
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminUniqueStarGiftGrantIsAtomicAndReplayable(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
now := int(time.Now().Unix())
|
||||
recipient := createTestUser(t, ctx, NewUserStore(pool), "+1780"+suffix+"61", "AdminGiftRecipient", "")
|
||||
gifts := NewStarGiftStore(pool)
|
||||
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
|
||||
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "Admin Grant " + suffix, Stars: 50, ConvertStars: 25, Enabled: true,
|
||||
Document: collectibleTestDocument(baseDocumentID, "admin-grant-gift.tgs"),
|
||||
Blob: collectibleTestBlob(baseDocumentID, "admin-grant-gift"), Animation: collectibleTestAnimation("admin-grant-gift.tgs"),
|
||||
Actor: "integration", CommandID: "admin-grant-catalog-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create admin grant catalog gift: %v", err)
|
||||
}
|
||||
revision, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 2, SlugPrefix: "admin-grant-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model One", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+1, "admin-model-one.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "admin-model-one"), Animation: collectibleTestAnimationPtr("admin-model-one.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestDocumentPtr(baseDocumentID+2, "admin-model-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "admin-model-two"), Animation: collectibleTestAnimationPtr("admin-model-two.tgs")},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern One", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "admin-pattern-one.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "admin-pattern-one"), Animation: collectibleTestAnimationPtr("admin-pattern-one.tgs")},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500,
|
||||
Document: collectibleTestPatternDocumentPtr(baseDocumentID+4, "admin-pattern-two.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+4, "admin-pattern-two"), Animation: collectibleTestAnimationPtr("admin-pattern-two.tgs")},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop One", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
},
|
||||
Actor: "integration", CommandID: "admin-grant-pool-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish admin grant collectible pool: %v", err)
|
||||
}
|
||||
upgrades := NewStarGiftUpgradeStore(pool, NewMessageStore(pool))
|
||||
invalid := domain.AdminStarGiftGrant{
|
||||
SenderID: domain.OfficialSystemUserID, Recipient: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID},
|
||||
GiftID: entry.Gift.ID, Upgrade: true, CommandKey: "admin-invalid-" + suffix, Date: now,
|
||||
ModelAttributeID: revision.Models[0].ID + 9_999_999,
|
||||
}
|
||||
if _, err := upgrades.GrantUniqueStarGift(ctx, invalid); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
t.Fatalf("invalid admin grant error=%v", err)
|
||||
}
|
||||
var issued, messageCount, savedCount, uniqueCount, commandCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, revision.ID).Scan(&issued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalidRandomID := lifecycleCommandRandomID("admin-collectible-grant", recipient.ID, invalid.CommandKey)
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM private_messages WHERE sender_user_id=$1 AND random_id=$2`,
|
||||
domain.OfficialSystemUserID, invalidRandomID).Scan(&messageCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type='user' AND owner_peer_id=$1 AND gift_id=$2`,
|
||||
recipient.ID, entry.Gift.ID).Scan(&savedCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM unique_star_gifts WHERE gift_id=$1`, entry.Gift.ID).Scan(&uniqueCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_admin_grant_commands WHERE recipient_user_id=$1`,
|
||||
recipient.ID).Scan(&commandCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if issued != 0 || messageCount != 0 || savedCount != 0 || uniqueCount != 0 || commandCount != 0 {
|
||||
t.Fatalf("failed grant leaked state: issued=%d messages=%d saved=%d unique=%d commands=%d",
|
||||
issued, messageCount, savedCount, uniqueCount, commandCount)
|
||||
}
|
||||
|
||||
req := invalid
|
||||
req.CommandKey = "admin-success-" + suffix
|
||||
req.Message = "atomic collectible"
|
||||
req.ModelAttributeID = revision.Models[0].ID
|
||||
req.PatternAttributeID = revision.Patterns[0].ID
|
||||
req.BackdropAttributeID = revision.Backdrops[0].ID
|
||||
granted, err := upgrades.GrantUniqueStarGift(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("grant admin unique gift: %v", err)
|
||||
}
|
||||
action := granted.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
|
||||
if granted.Duplicate || granted.Saved.MsgID <= 0 || granted.Saved.MsgID != granted.Saved.UpgradeMsgID ||
|
||||
granted.Saved.UniqueGiftID != granted.Unique.ID || granted.Unique.Num != 1 ||
|
||||
action == nil || !action.Assigned || !action.Saved || action.Gift.ID != granted.Unique.ID {
|
||||
t.Fatalf("admin grant result=%+v action=%+v", granted, action)
|
||||
}
|
||||
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, granted.Send.RecipientMessage.Pts-1, 1)
|
||||
if err != nil || len(events) != 1 || events[0].Message.Media == nil ||
|
||||
events[0].Message.Media.ServiceAction == nil ||
|
||||
events[0].Message.Media.ServiceAction.StarGiftUnique == nil ||
|
||||
events[0].Message.Media.ServiceAction.StarGiftUnique.Gift.ID != granted.Unique.ID {
|
||||
t.Fatalf("admin grant durable update=%+v err=%v", events, err)
|
||||
}
|
||||
|
||||
replay, err := upgrades.GrantUniqueStarGift(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("replay admin unique gift: %v", err)
|
||||
}
|
||||
if !replay.Duplicate || replay.Saved.ID != granted.Saved.ID || replay.Unique.ID != granted.Unique.ID ||
|
||||
replay.Send.RecipientMessage.ID != granted.Send.RecipientMessage.ID {
|
||||
t.Fatalf("admin grant replay=%+v want saved=%d unique=%d msg=%d",
|
||||
replay, granted.Saved.ID, granted.Unique.ID, granted.Send.RecipientMessage.ID)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT issued FROM star_gift_collectible_revisions WHERE id=$1`, revision.ID).Scan(&issued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_admin_grant_commands WHERE recipient_user_id=$1`,
|
||||
recipient.ID).Scan(&commandCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if issued != 1 || commandCount != 1 {
|
||||
t.Fatalf("replay duplicated aggregate: issued=%d commands=%d", issued, commandCount)
|
||||
}
|
||||
}
|
||||
|
||||
func collectibleTestAnimation(name string) domain.StarGiftAnimation {
|
||||
return domain.StarGiftAnimation{
|
||||
SourceName: name, SourceFormat: domain.StarGiftAnimationTGS,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
|
|
@ -46,6 +47,296 @@ func NewStarGiftUpgradeStore(db sqlcgen.DBTX, messages *MessageStore, opts ...St
|
|||
return s
|
||||
}
|
||||
|
||||
// GrantUniqueStarGift atomically assigns a newly minted collectible from the
|
||||
// official system account. The saved gift, unique issuance, service message,
|
||||
// pts/outbox and immutable command receipt share MessageStore's transaction.
|
||||
func (s *StarGiftUpgradeStore) GrantUniqueStarGift(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) {
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
req.Message = strings.TrimSpace(req.Message)
|
||||
if s == nil || s.db == nil || s.messages == nil || req.SenderID != domain.OfficialSystemUserID ||
|
||||
req.Recipient.Type != domain.PeerTypeUser || req.Recipient.ID <= 0 || req.GiftID <= 0 || !req.Upgrade ||
|
||||
req.CommandKey == "" || len(req.CommandKey) > 256 || req.Date <= 0 || len([]rune(req.Message)) > 128 ||
|
||||
req.ModelAttributeID < 0 || req.PatternAttributeID < 0 || req.BackdropAttributeID < 0 {
|
||||
return domain.AdminStarGiftGrantResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
fingerprint := adminStarGiftGrantFingerprint(req)
|
||||
if replay, found, err := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, domain.SendPrivateTextResult{}); err != nil || found {
|
||||
return replay, err
|
||||
}
|
||||
|
||||
placeholder := &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Assigned: true,
|
||||
Saved: true,
|
||||
},
|
||||
}}
|
||||
messageReq := domain.SendPrivateTextRequest{
|
||||
SenderUserID: req.SenderID,
|
||||
RecipientUserID: req.Recipient.ID,
|
||||
RandomID: lifecycleCommandRandomID("admin-collectible-grant", req.Recipient.ID, req.CommandKey),
|
||||
Date: req.Date,
|
||||
OriginUserID: req.SenderID,
|
||||
RecipientBlocked: req.RecipientBlocked,
|
||||
IdempotencyFingerprint: fingerprint[:],
|
||||
Media: placeholder,
|
||||
}
|
||||
|
||||
var result domain.AdminStarGiftGrantResult
|
||||
hooks := privateSendTxHooks{
|
||||
afterAllocate: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest, senderBoxID, recipientBoxID int) error {
|
||||
ownerMessageID := recipientBoxID
|
||||
if req.SenderID == req.Recipient.ID {
|
||||
ownerMessageID = senderBoxID
|
||||
}
|
||||
if ownerMessageID <= 0 {
|
||||
return fmt.Errorf("admin collectible grant missing owner message id")
|
||||
}
|
||||
|
||||
var revisionID int64
|
||||
var enabled bool
|
||||
if err := tx.QueryRow(ctx, `SELECT active_revision_id,enabled
|
||||
FROM star_gift_catalog WHERE gift_id=$1 FOR UPDATE`, req.GiftID).Scan(&revisionID, &enabled); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrStarGiftNotFound
|
||||
}
|
||||
return fmt.Errorf("lock admin collectible catalog gift: %w", err)
|
||||
}
|
||||
gift, found, err := NewStarGiftStore(tx).CatalogRevision(ctx, revisionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || !enabled || gift.ID != req.GiftID {
|
||||
return domain.ErrStarGiftNotFound
|
||||
}
|
||||
revision, err := lockActiveCollectibleRevision(ctx, tx, gift.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if revision.Issued >= revision.SupplyTotal {
|
||||
return domain.ErrStarGiftCollectibleSoldOut
|
||||
}
|
||||
modelID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_models", revision.ID, req.ModelAttributeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patternID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_patterns", revision.ID, req.PatternAttributeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backdropID, err := resolveCollectibleAttribute(ctx, tx, "star_gift_collectible_backdrops", revision.ID, req.BackdropAttributeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var craftable bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS (
|
||||
SELECT 1 FROM star_gift_collectible_models
|
||||
WHERE collectible_revision_id=$1 AND crafted
|
||||
)`, revision.ID).Scan(&craftable); err != nil {
|
||||
return fmt.Errorf("load admin collectible craft capability: %w", err)
|
||||
}
|
||||
craftChancePermille, canCraftAt := 0, 0
|
||||
if craftable {
|
||||
craftChancePermille = s.lifecycle.CraftChancePermille
|
||||
if craftChancePermille > 0 {
|
||||
canCraftAt = starGiftCraftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
saved := domain.SavedStarGift{
|
||||
Owner: req.Recipient,
|
||||
FromUserID: req.SenderID,
|
||||
GiftID: gift.ID,
|
||||
RevisionID: gift.RevisionID,
|
||||
MsgID: ownerMessageID,
|
||||
Date: req.Date,
|
||||
NameHidden: req.HideName,
|
||||
LifecycleStatus: domain.StarGiftLifecycleActive,
|
||||
Message: req.Message,
|
||||
TransferStars: s.lifecycle.TransferStars,
|
||||
CanExportAt: starGiftReadyAt(req.Date, s.lifecycle.ExportDelaySeconds),
|
||||
CanTransferAt: starGiftReadyAt(req.Date, s.lifecycle.TransferDelaySeconds),
|
||||
CanResellAt: starGiftReadyAt(req.Date, s.lifecycle.ResellDelaySeconds),
|
||||
DropOriginalDetailsStars: s.lifecycle.DropOriginalDetailsStars,
|
||||
CanCraftAt: canCraftAt,
|
||||
UpgradeMsgID: ownerMessageID,
|
||||
}
|
||||
savedID, err := NewStarGiftStore(tx).Create(ctx, saved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
saved.ID = savedID
|
||||
|
||||
num := revision.Issued + 1
|
||||
var uniqueID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil {
|
||||
return fmt.Errorf("allocate admin unique star gift id: %w", err)
|
||||
}
|
||||
slug := fmt.Sprintf("%s-%d", revision.SlugPrefix, num)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO unique_star_gifts
|
||||
(id, gift_id, collectible_revision_id, source_saved_gift_id, title, slug, num,
|
||||
owner_peer_type, owner_peer_id, model_attribute_id, pattern_attribute_id,
|
||||
backdrop_attribute_id, keep_original_details, original_owner_peer_type, original_owner_peer_id,
|
||||
craft_chance_permille, offer_min_stars)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,true,$13,$14,$15,$16)`,
|
||||
uniqueID, gift.ID, revision.ID, savedID, gift.Title, slug, num,
|
||||
string(req.Recipient.Type), req.Recipient.ID, modelID, patternID, backdropID,
|
||||
string(req.Recipient.Type), req.Recipient.ID, craftChancePermille, s.lifecycle.OfferMinStars); err != nil {
|
||||
return fmt.Errorf("insert admin unique star gift: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_collectible_revisions SET issued=issued+1 WHERE id=$1`, revision.ID); err != nil {
|
||||
return fmt.Errorf("increment admin collectible issuance: %w", err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE peer_star_gifts
|
||||
SET unique_gift_id=$2,upgrade_msg_id=$3,convert_stars=0,prepaid_upgrade_stars=0,prepaid_upgrade_hash='',
|
||||
transfer_stars=$4,can_export_at=$5,can_transfer_at=$6,can_resell_at=$7,
|
||||
drop_original_details_stars=$8,can_craft_at=$9
|
||||
WHERE id=$1 AND unique_gift_id IS NULL AND lifecycle_status='active'`,
|
||||
savedID, uniqueID, ownerMessageID, s.lifecycle.TransferStars, saved.CanExportAt,
|
||||
saved.CanTransferAt, saved.CanResellAt, s.lifecycle.DropOriginalDetailsStars, canCraftAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("link admin unique star gift: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return fmt.Errorf("link admin unique star gift lost aggregate row")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO star_gift_admin_grant_commands
|
||||
(recipient_user_id,command_key,request_fingerprint,sender_user_id,gift_id,saved_gift_id,unique_gift_id,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,to_timestamp($8))`,
|
||||
req.Recipient.ID, req.CommandKey, fingerprint[:], req.SenderID, gift.ID, savedID, uniqueID, req.Date); err != nil {
|
||||
return fmt.Errorf("insert admin collectible grant command: %w", err)
|
||||
}
|
||||
|
||||
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, uniqueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("new admin unique star gift %d disappeared", uniqueID)
|
||||
}
|
||||
saved.UniqueGiftID = uniqueID
|
||||
saved.Unique = &unique
|
||||
if err := registerUserStarGiftMessageRef(ctx, tx, req.Recipient.ID, ownerMessageID, savedID, uniqueID); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Saved, result.Unique = saved, unique
|
||||
send.Media = adminStarGiftUniqueMedia(saved, unique)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
if replay, found, replayErr := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, sent); replayErr != nil || found {
|
||||
return replay, replayErr
|
||||
}
|
||||
}
|
||||
return domain.AdminStarGiftGrantResult{}, err
|
||||
}
|
||||
result.Send, result.Duplicate = sent, sent.Duplicate
|
||||
if sent.Duplicate {
|
||||
replay, found, replayErr := s.loadAdminStarGiftGrantReplay(ctx, req, fingerprint, sent)
|
||||
if replayErr != nil {
|
||||
return domain.AdminStarGiftGrantResult{}, replayErr
|
||||
}
|
||||
if !found {
|
||||
return domain.AdminStarGiftGrantResult{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return replay, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func adminStarGiftUniqueMedia(saved domain.SavedStarGift, unique domain.UniqueStarGift) *domain.MessageMedia {
|
||||
fromUserID := saved.FromUserID
|
||||
if saved.NameHidden {
|
||||
fromUserID = 0
|
||||
}
|
||||
return &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGiftUnique,
|
||||
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
|
||||
Gift: unique, FromUserID: fromUserID, Assigned: true, Saved: true,
|
||||
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars,
|
||||
CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
|
||||
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func adminStarGiftGrantFingerprint(req domain.AdminStarGiftGrant) [32]byte {
|
||||
return sha256.Sum256([]byte(fmt.Sprintf(
|
||||
"telesrv:admin-star-gift-grant:v1:%d:%s:%d:%d:%t:%q:%d:%d:%d",
|
||||
req.SenderID, req.Recipient.Type, req.Recipient.ID, req.GiftID, req.HideName, req.Message,
|
||||
req.ModelAttributeID, req.PatternAttributeID, req.BackdropAttributeID,
|
||||
)))
|
||||
}
|
||||
|
||||
func (s *StarGiftUpgradeStore) loadAdminStarGiftGrantReplay(
|
||||
ctx context.Context,
|
||||
req domain.AdminStarGiftGrant,
|
||||
fingerprint [32]byte,
|
||||
sent domain.SendPrivateTextResult,
|
||||
) (domain.AdminStarGiftGrantResult, bool, error) {
|
||||
var storedFingerprint []byte
|
||||
var senderID, giftID, savedID, uniqueID int64
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT request_fingerprint,sender_user_id,gift_id,saved_gift_id,unique_gift_id
|
||||
FROM star_gift_admin_grant_commands
|
||||
WHERE recipient_user_id=$1 AND command_key=$2`, req.Recipient.ID, req.CommandKey).Scan(
|
||||
&storedFingerprint, &senderID, &giftID, &savedID, &uniqueID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AdminStarGiftGrantResult{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AdminStarGiftGrantResult{}, false, err
|
||||
}
|
||||
if senderID != req.SenderID || giftID != req.GiftID || !bytes.Equal(storedFingerprint, fingerprint[:]) {
|
||||
return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
saved, found, err := savedStarGiftByID(ctx, s.db, savedID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return domain.AdminStarGiftGrantResult{}, false, err
|
||||
}
|
||||
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return domain.AdminStarGiftGrantResult{}, false, err
|
||||
}
|
||||
if saved.Owner != req.Recipient || saved.FromUserID != req.SenderID || saved.GiftID != req.GiftID ||
|
||||
saved.UniqueGiftID != uniqueID || saved.MsgID <= 0 || saved.UpgradeMsgID != saved.MsgID ||
|
||||
unique.SourceSavedGiftID != savedID || unique.Owner != req.Recipient {
|
||||
return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if sent.SenderMessage.ID == 0 {
|
||||
replay, replayFound, replayErr := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{
|
||||
SenderUserID: req.SenderID, RecipientUserID: req.Recipient.ID,
|
||||
RandomID: lifecycleCommandRandomID("admin-collectible-grant", req.Recipient.ID, req.CommandKey),
|
||||
IdempotencyFingerprint: fingerprint[:],
|
||||
})
|
||||
if replayErr != nil {
|
||||
return domain.AdminStarGiftGrantResult{}, false, replayErr
|
||||
}
|
||||
if !replayFound {
|
||||
return domain.AdminStarGiftGrantResult{}, false, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
sent = replay
|
||||
}
|
||||
uniqueCopy := unique
|
||||
saved.Unique = &uniqueCopy
|
||||
return domain.AdminStarGiftGrantResult{
|
||||
Saved: saved, Unique: unique, Send: sent, Duplicate: true,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftUpgradeStore) UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
|
||||
if s == nil || s.db == nil || s.messages == nil || req.UserID <= 0 || !req.Ref.Valid() ||
|
||||
(req.Ref.Owner.Type == domain.PeerTypeUser && req.Ref.Owner.ID != req.UserID) ||
|
||||
|
|
@ -150,15 +441,15 @@ 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
|
||||
}
|
||||
|
|
@ -666,6 +957,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" {
|
||||
|
|
|
|||
|
|
@ -321,6 +321,40 @@ 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) {
|
||||
if scam && fake {
|
||||
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
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 +581,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),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ type userBaseValue struct {
|
|||
CountryCode string `json:"country_code"`
|
||||
Verified bool `json:"verified"`
|
||||
Support bool `json:"support"`
|
||||
// scam / fake 同理必须随缓存往返:丢失会让缓存命中路径把带标记的账号输出成
|
||||
// 普通账号,导致资料页 SCAM/FAKE 标记随缓存命中/未命中间歇性消失(与 bot 列同坑)。
|
||||
Scam bool `json:"scam,omitempty"`
|
||||
Fake bool `json:"fake,omitempty"`
|
||||
// bot 字段必须随缓存往返:丢失会让缓存命中路径把 bot 输出成普通用户,
|
||||
// 污染客户端本地缓存(TDesktop 的 bot 标记不可逆)。
|
||||
Bot bool `json:"bot,omitempty"`
|
||||
|
|
@ -78,6 +82,8 @@ func baseValueFromUser(u domain.User) userBaseValue {
|
|||
CountryCode: u.CountryCode,
|
||||
Verified: u.Verified,
|
||||
Support: u.Support,
|
||||
Scam: u.Scam,
|
||||
Fake: u.Fake,
|
||||
Bot: u.Bot,
|
||||
BotInfoVersion: u.BotInfoVersion,
|
||||
PremiumUntil: u.PremiumUntil,
|
||||
|
|
@ -110,6 +116,8 @@ func (v userBaseValue) user() domain.User {
|
|||
CountryCode: v.CountryCode,
|
||||
Verified: v.Verified,
|
||||
Support: v.Support,
|
||||
Scam: v.Scam,
|
||||
Fake: v.Fake,
|
||||
Bot: v.Bot,
|
||||
BotInfoVersion: v.BotInfoVersion,
|
||||
PremiumUntil: v.PremiumUntil,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ type StarGiftStore interface {
|
|||
type StarGiftUpgradeStore interface {
|
||||
UpgradeStarGift(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
|
||||
StarGiftUpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error)
|
||||
GrantUniqueStarGift(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error)
|
||||
}
|
||||
|
||||
// StarGiftLifecycleStore owns transactions that span collectible ownership, listings,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue