owpengram-server/cmd/telesrv-admin/readstore.go

1155 lines
37 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
const (
accountSearchLimit = 20
accountListDefaultLimit = 50
accountListMaxLimit = 100
channelSearchLimit = 50
channelListDefaultLimit = 50
channelListMaxLimit = 100
messagePageLimit = 100
)
type readStore struct {
pool *pgxpool.Pool
}
func newReadStore(pool *pgxpool.Pool) *readStore {
return &readStore{pool: pool}
}
type AccountRow struct {
ID int64
Phone string
Username string
FirstName string
LastName string
CreatedAt time.Time
UpdatedAt time.Time
Frozen bool
Reason string
Verified bool
Scam bool
Fake bool
PremiumUntil int64
LastActiveAt time.Time
DeviceCount int
LoginEmail string
}
type AccountDetail struct {
Account AccountRow
About string
LastSeenAt int64
Verified bool
Scam bool
Fake bool
Support bool
Bot bool
StarsBalance int64
StarsGranted bool
Restriction RestrictionRow
HasRestriction bool
Authorizations []AuthorizationRow
AuditLogs []AuditLogRow
}
type RestrictionRow struct {
Frozen bool
Since *time.Time
Until *time.Time
AppealURL string
Reason string
Actor string
CommandID string
UpdatedAt time.Time
}
type AuthorizationRow struct {
AuthKeyID int64
Hash int64
Layer int
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
IP string
PasswordPending bool
CreatedAt time.Time
ActiveAt time.Time
}
type AuditLogRow struct {
ID int64
CommandID string
Actor string
Action string
DryRun bool
Reason string
Status string
Error string
Result string
CreatedAt time.Time
}
type ChannelRow struct {
ID int64
AccessHash int64
CreatorUserID int64
Title string
About string
Username string
Broadcast bool
Megagroup bool
Forum bool
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
TopMessageID int
PinnedMessageID int
PTS int
Date int
CreatedAt time.Time
UpdatedAt time.Time
}
type ChannelDetail struct {
Channel ChannelRow
ChannelJSON string
AuditLogs []AuditLogRow
}
type StarGiftRow struct {
GiftID int64 `json:"GiftID,string"`
RevisionID int64 `json:"RevisionID,string"`
Revision int
Title string
Stars int64 `json:"Stars,string"`
ConvertStars int64 `json:"ConvertStars,string"`
Enabled bool
SortOrder int
DocumentID int64 `json:"DocumentID,string"`
SourceName string
SourceFormat string
AnimationSHA string
AnimationSize int64 `json:"AnimationSize,string"`
Width int
Height int
FrameRate float64
ReceivedCount int64 `json:"ReceivedCount,string"`
CreatedBy string
UpdatedAt time.Time
}
func (s *readStore) ListStarGifts(ctx context.Context) ([]StarGiftRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT c.gift_id, r.id, r.revision, r.title, r.stars, r.convert_stars,
c.enabled, c.sort_order, r.document_id, r.source_name, r.source_format,
encode(r.animation_sha256, 'hex'), d.size, r.width, r.height, r.frame_rate,
(SELECT COUNT(*) FROM peer_star_gifts p WHERE p.gift_id = c.gift_id),
r.created_by, c.updated_at
FROM star_gift_catalog c
JOIN star_gift_catalog_revisions r ON r.id = c.active_revision_id
JOIN documents d ON d.id = r.document_id
ORDER BY c.sort_order, c.gift_id
LIMIT $1`, domain.MaxStarGiftCatalogSize)
if err != nil {
return nil, fmt.Errorf("list star gifts: %w", err)
}
defer rows.Close()
out := make([]StarGiftRow, 0)
for rows.Next() {
var row StarGiftRow
if err := rows.Scan(
&row.GiftID, &row.RevisionID, &row.Revision, &row.Title, &row.Stars, &row.ConvertStars,
&row.Enabled, &row.SortOrder, &row.DocumentID, &row.SourceName, &row.SourceFormat,
&row.AnimationSHA, &row.AnimationSize, &row.Width, &row.Height, &row.FrameRate,
&row.ReceivedCount, &row.CreatedBy, &row.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
type StickerSetRow struct {
// ID must round-trip through JSON as a string: these are Telegram-style
// snowflake ids (18-19 digits), well past JS's 2^53 safe-integer limit, so
// a plain JSON number gets silently rounded by the browser (see StarGiftRow
// for the same fix applied to gift ids).
ID int64 `json:"ID,string"`
ShortName string
Title string
Count int
Kind string
SystemKey string
Official bool
Archived bool
Installed bool
SortOrder int
CreatedAt time.Time
// CoverDocumentID is the first document in the set, used as a small
// thumbnail in the admin list — empty when the set has no documents yet.
// Extracted straight from the jsonb array as text (document_ids->>0), so
// it never round-trips through a JSON number and risks the same 2^53
// precision loss as ID above.
CoverDocumentID string
}
// ListStickerSets lists non-system sticker/emoji sets. kind filters to exactly
// that set_kind ("stickers", "emoji", "masks"); an empty kind lists all of
// them (still excluding "system" — dice, animated emoji, premium/TON gifts
// and similar built-in packs are never admin-editable).
func (s *readStore) ListStickerSets(ctx context.Context, kind string) ([]StickerSetRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, short_name, title, count, set_kind, system_key, official, archived, installed, sort_order, created_at,
COALESCE(document_ids->>0, '')
FROM sticker_sets
WHERE deleted = false AND set_kind <> 'system'
AND ($1 = '' OR set_kind = $1)
ORDER BY set_kind, sort_order, id`, kind)
if err != nil {
return nil, fmt.Errorf("list sticker sets: %w", err)
}
defer rows.Close()
out := make([]StickerSetRow, 0)
for rows.Next() {
var item StickerSetRow
if err := rows.Scan(&item.ID, &item.ShortName, &item.Title, &item.Count, &item.Kind, &item.SystemKey, &item.Official, &item.Archived, &item.Installed, &item.SortOrder, &item.CreatedAt, &item.CoverDocumentID); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
// StickerSetDocumentIDs returns document ids as strings, not int64 — a plain
// JSON number array would let the browser silently round these snowflake ids
// past 2^53 (see StickerSetRow.ID for the same issue on the set id itself).
func (s *readStore) StickerSetDocumentIDs(ctx context.Context, setID int64) ([]string, error) {
// jsonb must be cast to text and scanned as a string — see the identical
// ::text-cast pattern in internal/store/postgres/media.go's sticker set
// select; scanning jsonb straight into []byte does not reliably decode here.
var raw string
err := s.pool.QueryRow(ctx, `SELECT document_ids::text FROM sticker_sets WHERE id = $1 AND deleted = false`, setID).Scan(&raw)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("sticker set documents: %w", err)
}
var ids []int64
if raw != "" && raw != "[]" {
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return nil, fmt.Errorf("sticker set documents: %w", err)
}
}
out := make([]string, len(ids))
for i, id := range ids {
out[i] = strconv.FormatInt(id, 10)
}
return out, nil
}
func (s *readStore) SearchAccounts(ctx context.Context, q string) ([]AccountRow, 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
}
phone := strings.TrimPrefix(strings.ReplaceAll(q, " ", ""), "+")
phoneRaw := strings.TrimSpace(q)
username := strings.ToLower(strings.TrimPrefix(q, "@"))
rows, err := s.pool.Query(ctx, `
WITH auth AS (
SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count
FROM authorizations
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, 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,
COALESCE(ap.login_email, '')
FROM users u
LEFT JOIN account_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
LEFT JOIN auth a ON a.user_id = u.id
LEFT JOIN account_passwords ap ON ap.user_id = u.id
WHERE u.id = $1 OR u.phone = $2 OR u.phone = $3 OR lower(u.username) = $4 OR p.username_lower = $4
ORDER BY u.id
LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
if err != nil {
return nil, fmt.Errorf("search accounts: %w", err)
}
defer rows.Close()
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.Scam, &item.Fake, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
return nil, err
}
out = append(out, item)
}
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 == "" {
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 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.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
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
WHERE NOT c.deleted
AND NOT c.monoforum
AND (c.broadcast OR c.megagroup)
AND (
c.id = $1
OR lower(COALESCE(c.username, '')) = $2
OR p.username_lower = $2
OR lower(c.title) LIKE '%' || $2 || '%'
)
ORDER BY c.updated_at DESC, c.id DESC
LIMIT $3`, id, username, channelSearchLimit)
if err != nil {
return nil, fmt.Errorf("search channels: %w", err)
}
defer rows.Close()
return scanChannelRows(rows)
}
func (s *readStore) ListChannels(ctx context.Context, beforeUpdatedUS, beforeID int64, limit int) ([]ChannelRow, bool, error) {
if limit <= 0 {
limit = channelListDefaultLimit
}
if limit > channelListMaxLimit {
limit = channelListMaxLimit
}
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.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
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
WHERE NOT c.deleted
AND NOT c.monoforum
AND (c.broadcast OR c.megagroup)
AND ($1::bigint = 0 OR (c.updated_at, c.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
ORDER BY c.updated_at DESC, c.id DESC
LIMIT $3`, beforeUpdatedUS, beforeID, limit+1)
if err != nil {
return nil, false, fmt.Errorf("list channels: %w", err)
}
defer rows.Close()
out, err := scanChannelRows(rows)
if err != nil {
return nil, false, err
}
hasMore := len(out) > limit
if hasMore {
out = out[:limit]
}
return out, hasMore, nil
}
func (s *readStore) ChannelDetail(ctx context.Context, channelID int64) (ChannelDetail, error) {
var out ChannelDetail
var raw []byte
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.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
FROM channels c
LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id
WHERE c.id = $1
AND NOT c.deleted
AND NOT c.monoforum
AND (c.broadcast OR c.megagroup)`, channelID).Scan(channelScanDestWithRaw(&out.Channel, &raw)...)
if err != nil {
return out, fmt.Errorf("get channel: %w", err)
}
out.ChannelJSON = prettyJSON(raw)
out.AuditLogs, err = s.channelAuditLogs(ctx, channelID)
if err != nil {
return out, err
}
return out, nil
}
type channelScanner interface {
Scan(dest ...any) error
}
func scanChannelRows(rows pgx.Rows) ([]ChannelRow, error) {
out := make([]ChannelRow, 0)
for rows.Next() {
var item ChannelRow
if err := scanChannelRow(rows, &item); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func scanChannelRow(row channelScanner, item *ChannelRow) error {
return row.Scan(channelScanDest(item)...)
}
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.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,
}
}
func channelScanDestWithRaw(item *ChannelRow, raw *[]byte) []any {
dest := channelScanDest(item)
return append(dest, raw)
}
func (s *readStore) ListAccounts(ctx context.Context, beforeActiveUS, beforeID int64, limit int) ([]AccountRow, bool, error) {
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
rows, err := s.pool.Query(ctx, `
WITH auth AS (
SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count
FROM authorizations
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, 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,
COALESCE(ap.login_email, '')
FROM users u
JOIN auth ON auth.user_id = u.id
LEFT JOIN account_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
LEFT JOIN account_passwords ap ON ap.user_id = u.id
WHERE NOT u.is_bot
AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
ORDER BY auth.last_active_at DESC, u.id DESC
LIMIT $3`, beforeActiveUS, beforeID, limit+1)
if err != nil {
return nil, false, fmt.Errorf("list accounts: %w", err)
}
defer rows.Close()
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.Scam, &item.Fake, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
return nil, false, err
}
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) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) {
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.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),
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u
LEFT JOIN account_restrictions r ON r.user_id = u.id
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.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 {
return out, fmt.Errorf("get account: %w", err)
}
out.Restriction, out.HasRestriction, err = s.restriction(ctx, userID)
if err != nil {
return out, err
}
out.Authorizations, err = s.authorizations(ctx, userID)
if err != nil {
return out, err
}
out.AuditLogs, err = s.auditLogs(ctx, userID)
if err != nil {
return out, err
}
return out, nil
}
func (s *readStore) restriction(ctx context.Context, userID int64) (RestrictionRow, bool, error) {
var r RestrictionRow
err := s.pool.QueryRow(ctx, `
SELECT frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
FROM account_restrictions
WHERE user_id = $1`, userID).Scan(
&r.Frozen, &r.Since, &r.Until, &r.AppealURL,
&r.Reason, &r.Actor, &r.CommandID, &r.UpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return RestrictionRow{}, false, nil
}
return RestrictionRow{}, false, fmt.Errorf("get restriction: %w", err)
}
return r, true, nil
}
func (s *readStore) authorizations(ctx context.Context, userID int64) ([]AuthorizationRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT auth_key_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations
WHERE user_id = $1
ORDER BY active_at DESC, created_at DESC
LIMIT 100`, userID)
if err != nil {
return nil, fmt.Errorf("list authorizations: %w", err)
}
defer rows.Close()
out := make([]AuthorizationRow, 0)
for rows.Next() {
var a AuthorizationRow
if err := rows.Scan(&a.AuthKeyID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion, &a.APIID, &a.AppVersion, &a.IP, &a.PasswordPending, &a.CreatedAt, &a.ActiveAt); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *readStore) auditLogs(ctx context.Context, userID int64) ([]AuditLogRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, command_id, actor, action, dry_run, reason, status, error, result, created_at
FROM admin_audit_logs
WHERE target_user_id = $1
ORDER BY id DESC
LIMIT 30`, userID)
if err != nil {
return nil, fmt.Errorf("list audit logs: %w", err)
}
defer rows.Close()
out := make([]AuditLogRow, 0)
for rows.Next() {
var a AuditLogRow
var result []byte
if err := rows.Scan(&a.ID, &a.CommandID, &a.Actor, &a.Action, &a.DryRun, &a.Reason, &a.Status, &a.Error, &result, &a.CreatedAt); err != nil {
return nil, err
}
a.Result = prettyJSON(result)
out = append(out, a)
}
return out, rows.Err()
}
func (s *readStore) channelAuditLogs(ctx context.Context, channelID int64) ([]AuditLogRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, command_id, actor, action, dry_run, reason, status, error, result, created_at
FROM admin_audit_logs
WHERE target_peer_type = 'channel' AND target_peer_id = $1
ORDER BY id DESC
LIMIT 30`, channelID)
if err != nil {
return nil, fmt.Errorf("list channel audit logs: %w", err)
}
defer rows.Close()
out := make([]AuditLogRow, 0)
for rows.Next() {
var a AuditLogRow
var result []byte
if err := rows.Scan(&a.ID, &a.CommandID, &a.Actor, &a.Action, &a.DryRun, &a.Reason, &a.Status, &a.Error, &result, &a.CreatedAt); err != nil {
return nil, err
}
a.Result = prettyJSON(result)
out = append(out, a)
}
return out, rows.Err()
}
type MessageRow struct {
OwnerUserID int64
BoxID int
PrivateMessageID int64
MessageSenderID int64
PeerID int64
FromUserID int64
Date int64
Outgoing bool
Body string
PTS int
Deleted bool
Media string
}
type GroupMessageRow struct {
ChannelID int64
ID int
SenderUserID int64
FromPeerType string
FromPeerID int64
Date int64
Post bool
Body string
PTS int
Deleted bool
Media string
ViewsCount int
EditDate int
Pinned bool
}
func (s *readStore) ListMessages(ctx context.Context, ownerUserID, peerID int64, beforeDate int64, beforeID int, limit int) ([]MessageRow, error) {
if limit <= 0 || limit > messagePageLimit {
limit = messagePageLimit
}
rows, err := s.pool.Query(ctx, `
SELECT owner_user_id, box_id, private_message_id, message_sender_id, peer_id, from_user_id,
message_date, outgoing, body, pts, deleted, COALESCE(media, '{}'::jsonb)
FROM message_boxes
WHERE owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2
AND ($3::bigint = 0 OR (message_date, box_id) < ($3::bigint, $4::int))
ORDER BY message_date DESC, box_id DESC
LIMIT $5`, ownerUserID, peerID, beforeDate, beforeID, limit)
if err != nil {
return nil, fmt.Errorf("list messages: %w", err)
}
defer rows.Close()
out := make([]MessageRow, 0)
for rows.Next() {
var item MessageRow
var media []byte
if err := rows.Scan(&item.OwnerUserID, &item.BoxID, &item.PrivateMessageID, &item.MessageSenderID, &item.PeerID, &item.FromUserID, &item.Date, &item.Outgoing, &item.Body, &item.PTS, &item.Deleted, &media); err != nil {
return nil, err
}
item.Media = prettyJSON(media)
out = append(out, item)
}
return out, rows.Err()
}
func (s *readStore) ListGroupMessages(ctx context.Context, channelID int64, beforeDate int64, beforeID int, limit int) ([]GroupMessageRow, error) {
if limit <= 0 || limit > messagePageLimit {
limit = messagePageLimit
}
rows, err := s.pool.Query(ctx, `
SELECT channel_id, id, sender_user_id, from_peer_type, from_peer_id,
message_date, post, body, pts, deleted, COALESCE(media, '{}'::jsonb),
views_count, edit_date, pinned
FROM channel_messages
WHERE channel_id = $1 AND NOT deleted
AND ($2::bigint = 0 OR (message_date, id) < ($2::bigint, $3::int))
ORDER BY message_date DESC, id DESC
LIMIT $4`, channelID, beforeDate, beforeID, limit)
if err != nil {
return nil, fmt.Errorf("list group messages: %w", err)
}
defer rows.Close()
out := make([]GroupMessageRow, 0)
for rows.Next() {
var item GroupMessageRow
var media []byte
if err := rows.Scan(&item.ChannelID, &item.ID, &item.SenderUserID, &item.FromPeerType, &item.FromPeerID, &item.Date, &item.Post, &item.Body, &item.PTS, &item.Deleted, &media, &item.ViewsCount, &item.EditDate, &item.Pinned); err != nil {
return nil, err
}
item.Media = prettyJSON(media)
out = append(out, item)
}
return out, rows.Err()
}
type MessageDetail struct {
Message MessageRow
MessageJSON string
DialogJSON string
PrivateJSON string
UpdateEvents []UpdateEventRow
Outbox []OutboxRow
}
type GroupMessageDetail struct {
Message GroupMessageRow
MessageJSON string
ChannelJSON string
UpdateEvents []ChannelUpdateEventRow
}
type UpdateEventRow struct {
PTS int
PTSCount int
Type string
Date int64
JSON string
}
type ChannelUpdateEventRow struct {
PTS int
PTSCount int
Type string
MessageID int
Date int64
SenderUserID int64
JSON string
}
type OutboxRow struct {
ID int64
TargetUserID int64
PTS int
EventType string
Status string
Attempts int
CreatedAt time.Time
UpdatedAt time.Time
}
func (s *readStore) MessageDetail(ctx context.Context, ownerUserID int64, msgID int) (MessageDetail, error) {
var out MessageDetail
var media []byte
var messageJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT owner_user_id, box_id, private_message_id, message_sender_id, peer_id, from_user_id,
message_date, outgoing, body, pts, deleted, COALESCE(media, '{}'::jsonb), row_to_json(mb)::jsonb
FROM message_boxes mb
WHERE owner_user_id = $1 AND box_id = $2`, ownerUserID, msgID).Scan(
&out.Message.OwnerUserID, &out.Message.BoxID, &out.Message.PrivateMessageID, &out.Message.MessageSenderID, &out.Message.PeerID,
&out.Message.FromUserID, &out.Message.Date, &out.Message.Outgoing, &out.Message.Body, &out.Message.PTS, &out.Message.Deleted,
&media, &messageJSON,
)
if err != nil {
return out, fmt.Errorf("get message: %w", err)
}
out.Message.Media = prettyJSON(media)
out.MessageJSON = prettyJSON(messageJSON)
out.DialogJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(d)::jsonb FROM dialogs d WHERE user_id = $1 AND peer_type = 'user' AND peer_id = $2`, ownerUserID, out.Message.PeerID)
out.PrivateJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(pm)::jsonb FROM private_messages pm WHERE id = $1`, out.Message.PrivateMessageID)
events, err := s.updateEvents(ctx, ownerUserID, msgID)
if err != nil {
return out, err
}
out.UpdateEvents = events
outbox, err := s.outbox(ctx, ownerUserID, out.Message.PTS)
if err != nil {
return out, err
}
out.Outbox = outbox
return out, nil
}
func (s *readStore) GroupMessageDetail(ctx context.Context, channelID int64, msgID int) (GroupMessageDetail, error) {
var out GroupMessageDetail
var media []byte
var messageJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT channel_id, id, sender_user_id, from_peer_type, from_peer_id,
message_date, post, body, pts, deleted, COALESCE(media, '{}'::jsonb),
views_count, edit_date, pinned, row_to_json(cm)::jsonb
FROM channel_messages cm
WHERE channel_id = $1 AND id = $2`, channelID, msgID).Scan(
&out.Message.ChannelID, &out.Message.ID, &out.Message.SenderUserID, &out.Message.FromPeerType, &out.Message.FromPeerID,
&out.Message.Date, &out.Message.Post, &out.Message.Body, &out.Message.PTS, &out.Message.Deleted, &media,
&out.Message.ViewsCount, &out.Message.EditDate, &out.Message.Pinned, &messageJSON,
)
if err != nil {
return out, fmt.Errorf("get group message: %w", err)
}
out.Message.Media = prettyJSON(media)
out.MessageJSON = prettyJSON(messageJSON)
out.ChannelJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(c)::jsonb FROM channels c WHERE id = $1`, channelID)
events, err := s.channelUpdateEvents(ctx, channelID, msgID)
if err != nil {
return out, err
}
out.UpdateEvents = events
return out, nil
}
func (s *readStore) rowJSON(ctx context.Context, sql string, args ...any) (string, error) {
var raw []byte
if err := s.pool.QueryRow(ctx, sql, args...).Scan(&raw); err != nil {
return "", err
}
return prettyJSON(raw), nil
}
func (s *readStore) updateEvents(ctx context.Context, ownerUserID int64, msgID int) ([]UpdateEventRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT pts, pts_count, event_type, date, row_to_json(e)::jsonb
FROM user_update_events e
WHERE user_id = $1 AND (
message_box_id = $2 OR message_ids @> $3::jsonb
)
ORDER BY pts DESC
LIMIT 20`, ownerUserID, msgID, fmt.Sprintf("[%d]", msgID))
if err != nil {
return nil, fmt.Errorf("list update events: %w", err)
}
defer rows.Close()
out := make([]UpdateEventRow, 0)
for rows.Next() {
var e UpdateEventRow
var raw []byte
if err := rows.Scan(&e.PTS, &e.PTSCount, &e.Type, &e.Date, &raw); err != nil {
return nil, err
}
e.JSON = prettyJSON(raw)
out = append(out, e)
}
return out, rows.Err()
}
func (s *readStore) channelUpdateEvents(ctx context.Context, channelID int64, msgID int) ([]ChannelUpdateEventRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT pts, pts_count, event_type, message_id, date, sender_user_id, row_to_json(e)::jsonb
FROM channel_update_events e
WHERE channel_id = $1 AND (
message_id = $2 OR message_ids @> $3::jsonb
)
ORDER BY pts DESC
LIMIT 20`, channelID, msgID, fmt.Sprintf("[%d]", msgID))
if err != nil {
return nil, fmt.Errorf("list channel update events: %w", err)
}
defer rows.Close()
out := make([]ChannelUpdateEventRow, 0)
for rows.Next() {
var e ChannelUpdateEventRow
var raw []byte
if err := rows.Scan(&e.PTS, &e.PTSCount, &e.Type, &e.MessageID, &e.Date, &e.SenderUserID, &raw); err != nil {
return nil, err
}
e.JSON = prettyJSON(raw)
out = append(out, e)
}
return out, rows.Err()
}
func (s *readStore) outbox(ctx context.Context, targetUserID int64, pts int) ([]OutboxRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, target_user_id, pts, event_type, status, attempts, created_at, updated_at
FROM dispatch_outbox
WHERE target_user_id = $1 AND pts = $2
ORDER BY id DESC
LIMIT 20`, targetUserID, pts)
if err != nil {
return nil, fmt.Errorf("list outbox: %w", err)
}
defer rows.Close()
out := make([]OutboxRow, 0)
for rows.Next() {
var row OutboxRow
if err := rows.Scan(&row.ID, &row.TargetUserID, &row.PTS, &row.EventType, &row.Status, &row.Attempts, &row.CreatedAt, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
func prettyJSON(raw []byte) string {
if len(raw) == 0 {
return "{}"
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return string(raw)
}
out, err := json.MarshalIndent(v, "", " ")
if err != nil {
return string(raw)
}
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)
}