"""PostgreSQL БД бота: пользователи, баланс, покупки, инвойсы, сессии.""" from __future__ import annotations import json import re import secrets from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from typing import Any from psycopg import errors as pg_errors from psycopg.rows import dict_row from psycopg_pool import ConnectionPool from services.passwords import hash_password, new_session_token, verify_password LOGIN_RE = re.compile(r"^[a-zA-Z0-9_]{3,32}$") EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$") QR_LOGIN_TTL = timedelta(minutes=5) WEBAUTHN_CHALLENGE_TTL = timedelta(minutes=5) SESSION_DAYS = 30 # Блокировка миграций схемы (bot + webapp стартуют вместе) _SCHEMA_ADVISORY_LOCK = 872_014_401 def _utc_now() -> datetime: return datetime.now(timezone.utc) @dataclass(frozen=True, slots=True) class BotUser: telegram_id: int username: str | None full_name: str | None balance: float created_at: datetime | str login: str | None = None has_password: bool = False is_telegram: bool = True extra_devices: int = 0 device_blocked: bool = False extra_traffic_packs: int = 0 extras_next_billing_at: datetime | None = None extras_active: bool = True auto_renew: bool = True email: str | None = None banned: bool = False referral_code: str | None = None referred_by: int | None = None trial_used_at: datetime | None = None notify_telegram: bool = True notify_email: bool = True @property def display_name(self) -> str: if self.full_name: return self.full_name if self.login: return self.login if self.username: return f"@{self.username}" return f"ID {self.telegram_id}" class AuthError(ValueError): pass class Database: def __init__(self, database_url: str) -> None: if not database_url: raise ValueError("DATABASE_URL пуст") self.database_url = database_url self.pool = ConnectionPool( conninfo=database_url, min_size=1, max_size=10, kwargs={"row_factory": dict_row}, open=True, ) self.init_schema() def close(self) -> None: self.pool.close() def init_schema(self) -> None: statements = [ """ CREATE TABLE IF NOT EXISTS users ( telegram_id BIGINT PRIMARY KEY, username TEXT, full_name TEXT, balance NUMERIC(12, 2) NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS login TEXT """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS session_token TEXT """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS session_expires TIMESTAMPTZ """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS extra_devices INTEGER NOT NULL DEFAULT 0 """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS device_blocked BOOLEAN NOT NULL DEFAULT FALSE """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS extra_traffic_packs INTEGER NOT NULL DEFAULT 0 """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS extras_next_billing_at TIMESTAMPTZ """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS extras_active BOOLEAN NOT NULL DEFAULT TRUE """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS expiry_reminder_sent_on DATE """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS auto_renew BOOLEAN NOT NULL DEFAULT TRUE """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT """, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_login_unique ON users (login) WHERE login IS NOT NULL """, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users (lower(email)) WHERE email IS NOT NULL AND email <> '' """, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_session_token ON users (session_token) WHERE session_token IS NOT NULL """, """ CREATE TABLE IF NOT EXISTS login_qr ( token TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'pending', telegram_id BIGINT, session_token TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, approved_at TIMESTAMPTZ ) """, """ CREATE INDEX IF NOT EXISTS idx_login_qr_expires ON login_qr (expires_at) """, """ CREATE TABLE IF NOT EXISTS webauthn_credentials ( credential_id TEXT PRIMARY KEY, telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE, public_key BYTEA NOT NULL, sign_count BIGINT NOT NULL DEFAULT 0, transports TEXT NOT NULL DEFAULT '[]', device_name TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_used_at TIMESTAMPTZ ) """, """ CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials (telegram_id) """, """ CREATE TABLE IF NOT EXISTS webauthn_challenges ( challenge TEXT PRIMARY KEY, telegram_id BIGINT, purpose TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges (expires_at) """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS referral_code TEXT """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by BIGINT """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS trial_used_at TIMESTAMPTZ """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_telegram BOOLEAN NOT NULL DEFAULT TRUE """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_email BOOLEAN NOT NULL DEFAULT TRUE """, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_referral_code ON users (referral_code) WHERE referral_code IS NOT NULL """, """ CREATE TABLE IF NOT EXISTS user_sessions ( token TEXT PRIMARY KEY, telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, user_agent TEXT, ip TEXT ) """, """ CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions (telegram_id, expires_at DESC) """, """ CREATE TABLE IF NOT EXISTS promo_codes ( id BIGSERIAL PRIMARY KEY, code TEXT NOT NULL, kind TEXT NOT NULL, value NUMERIC(12, 2) NOT NULL, max_uses INTEGER, used_count INTEGER NOT NULL DEFAULT 0, tariff_id TEXT, active BOOLEAN NOT NULL DEFAULT TRUE, expires_at TIMESTAMPTZ, note TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_promo_codes_code ON promo_codes (lower(code)) """, """ CREATE TABLE IF NOT EXISTS promo_redemptions ( id BIGSERIAL PRIMARY KEY, promo_id BIGINT NOT NULL REFERENCES promo_codes(id) ON DELETE CASCADE, telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE, amount_saved NUMERIC(12, 2) NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (promo_id, telegram_id) ) """, """ CREATE TABLE IF NOT EXISTS password_reset_tokens ( token TEXT PRIMARY KEY, telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE, expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ CREATE TABLE IF NOT EXISTS payment_events ( id BIGSERIAL PRIMARY KEY, provider TEXT NOT NULL, event_type TEXT NOT NULL, telegram_id BIGINT, amount NUMERIC(12, 2), invoice_ref TEXT, status TEXT NOT NULL DEFAULT 'ok', meta TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ CREATE INDEX IF NOT EXISTS idx_payment_events_created ON payment_events (created_at DESC) """, """ CREATE TABLE IF NOT EXISTS referral_rewards ( id BIGSERIAL PRIMARY KEY, referrer_id BIGINT NOT NULL REFERENCES users(telegram_id), referee_id BIGINT NOT NULL REFERENCES users(telegram_id), amount NUMERIC(12, 2) NOT NULL, kind TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (referee_id, kind) ) """, """ CREATE SEQUENCE IF NOT EXISTS web_user_id_seq START WITH 1 INCREMENT BY 1 """, """ CREATE TABLE IF NOT EXISTS transactions ( id BIGSERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), amount NUMERIC(12, 2) NOT NULL, kind TEXT NOT NULL, meta TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ CREATE INDEX IF NOT EXISTS idx_transactions_user_id ON transactions (telegram_id, id DESC) """, """ CREATE TABLE IF NOT EXISTS purchases ( id BIGSERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), tariff_id TEXT NOT NULL, price NUMERIC(12, 2) NOT NULL, remnawave_uuid TEXT, subscription_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ CREATE TABLE IF NOT EXISTS invoices ( id BIGSERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), invoice_id BIGINT NOT NULL, kind TEXT NOT NULL, amount_rub NUMERIC(12, 2) NOT NULL, tariff_id TEXT, status TEXT NOT NULL DEFAULT 'active', pay_url TEXT, payload TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), paid_at TIMESTAMPTZ ) """, """ CREATE SEQUENCE IF NOT EXISTS local_invoice_id_seq START WITH 900000000001 """, """ ALTER TABLE invoices ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'cryptobot' """, """ ALTER TABLE invoices ADD COLUMN IF NOT EXISTS order_id TEXT """, """ ALTER TABLE invoices ADD COLUMN IF NOT EXISTS external_uuid TEXT """, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_provider_invoice ON invoices (provider, invoice_id) """, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_order_id ON invoices (order_id) WHERE order_id IS NOT NULL """, """ CREATE TABLE IF NOT EXISTS digiseller_payments ( inv BIGINT PRIMARY KEY, unique_code TEXT NOT NULL, telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), amount_rub NUMERIC(12, 2) NOT NULL, product_id BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_digiseller_unique_code ON digiseller_payments (unique_code) """, """ CREATE TABLE IF NOT EXISTS topup_codes ( code TEXT PRIMARY KEY, amount_rub NUMERIC(12, 2) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by TEXT, used_at TIMESTAMPTZ, used_by BIGINT REFERENCES users(telegram_id) ) """, """ CREATE INDEX IF NOT EXISTS idx_topup_codes_used ON topup_codes (used_at NULLS FIRST, created_at DESC) """, """ CREATE TABLE IF NOT EXISTS tickets ( id BIGSERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), subject TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ CREATE INDEX IF NOT EXISTS idx_tickets_user ON tickets (telegram_id, updated_at DESC) """, """ CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets (status, updated_at DESC) """, """ CREATE TABLE IF NOT EXISTS ticket_messages ( id BIGSERIAL PRIMARY KEY, ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, author_role TEXT NOT NULL, author_id BIGINT, body TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages (ticket_id, id ASC) """, """ CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ CREATE TABLE IF NOT EXISTS xui_servers ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, api_url TEXT NOT NULL, username TEXT NOT NULL DEFAULT '', password TEXT NOT NULL DEFAULT '', api_key TEXT NOT NULL DEFAULT '', inbound_id INTEGER, inbound_remark TEXT, subscription_url TEXT NOT NULL DEFAULT '', enabled BOOLEAN NOT NULL DEFAULT TRUE, verify_ssl BOOLEAN NOT NULL DEFAULT TRUE, last_check_at TIMESTAMPTZ, last_check_ok BOOLEAN, last_check_msg TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ ALTER TABLE xui_servers ADD COLUMN IF NOT EXISTS api_key TEXT NOT NULL DEFAULT '' """, """ CREATE TABLE IF NOT EXISTS xui_wg_configs ( id BIGSERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), server_id BIGINT NOT NULL REFERENCES xui_servers(id) ON DELETE CASCADE, email TEXT NOT NULL, sub_id TEXT NOT NULL, subscription_url TEXT NOT NULL, remark TEXT, enabled BOOLEAN NOT NULL DEFAULT TRUE, expire_at TIMESTAMPTZ, traffic_bytes BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (server_id, email) ) """, """ ALTER TABLE xui_wg_configs ADD COLUMN IF NOT EXISTS expire_at TIMESTAMPTZ """, """ ALTER TABLE xui_wg_configs ADD COLUMN IF NOT EXISTS traffic_bytes BIGINT NOT NULL DEFAULT 0 """, """ CREATE INDEX IF NOT EXISTS idx_xui_wg_configs_user ON xui_wg_configs (telegram_id, id DESC) """, """ CREATE INDEX IF NOT EXISTS idx_xui_servers_enabled ON xui_servers (enabled, id) """, """ CREATE TABLE IF NOT EXISTS awg_servers ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, api_url TEXT NOT NULL, api_token TEXT NOT NULL DEFAULT '', panel_server_id INTEGER NOT NULL DEFAULT 0, protocol TEXT NOT NULL DEFAULT 'awg2', enabled BOOLEAN NOT NULL DEFAULT TRUE, verify_ssl BOOLEAN NOT NULL DEFAULT TRUE, last_check_at TIMESTAMPTZ, last_check_ok BOOLEAN, last_check_msg TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """, """ CREATE TABLE IF NOT EXISTS awg_configs ( id BIGSERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), server_id BIGINT NOT NULL REFERENCES awg_servers(id) ON DELETE CASCADE, client_id TEXT NOT NULL, client_name TEXT NOT NULL DEFAULT '', remark TEXT, config_text TEXT NOT NULL DEFAULT '', vpn_link TEXT NOT NULL DEFAULT '', protocol TEXT NOT NULL DEFAULT 'awg2', enabled BOOLEAN NOT NULL DEFAULT TRUE, expire_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (server_id, client_id) ) """, """ CREATE INDEX IF NOT EXISTS idx_awg_configs_user ON awg_configs (telegram_id, id DESC) """, """ CREATE INDEX IF NOT EXISTS idx_awg_servers_enabled ON awg_servers (enabled, id) """, ] with self.pool.connection() as conn: # Один процесс за раз — иначе гонка CREATE IF NOT EXISTS → 42P07 conn.execute("SELECT pg_advisory_lock(%s)", (_SCHEMA_ADVISORY_LOCK,)) try: for stmt in statements: try: with conn.transaction(): conn.execute(stmt) except (pg_errors.DuplicateTable, pg_errors.DuplicateObject): # Параллельный старт / повторный apply — таблица уже есть continue # Старый UNIQUE(invoice_id) мешает разным провайдерам — убираем, если есть with conn.transaction(): conn.execute( """ DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'invoices_invoice_id_key' ) THEN ALTER TABLE invoices DROP CONSTRAINT invoices_invoice_id_key; END IF; END $$; """ ) with conn.transaction(): conn.execute( """ UPDATE users SET extras_next_billing_at = NOW() + INTERVAL '30 days' WHERE extras_next_billing_at IS NULL AND extra_devices > 0 """ ) conn.commit() finally: conn.execute("SELECT pg_advisory_unlock(%s)", (_SCHEMA_ADVISORY_LOCK,)) conn.commit() def ensure_user( self, telegram_id: int, *, username: str | None = None, full_name: str | None = None, ) -> BotUser: with self.pool.connection() as conn: row = conn.execute( "SELECT * FROM users WHERE telegram_id = %s", (telegram_id,), ).fetchone() if row: conn.execute( """ UPDATE users SET username = COALESCE(%s, username), full_name = COALESCE(%s, full_name) WHERE telegram_id = %s """, (username, full_name, telegram_id), ) else: conn.execute( """ INSERT INTO users (telegram_id, username, full_name, balance, created_at) VALUES (%s, %s, %s, 0, %s) """, (telegram_id, username, full_name, _utc_now()), ) conn.commit() row = conn.execute( "SELECT * FROM users WHERE telegram_id = %s", (telegram_id,), ).fetchone() assert row is not None return self._to_user(row) def get_user(self, telegram_id: int) -> BotUser | None: with self.pool.connection() as conn: row = conn.execute( "SELECT * FROM users WHERE telegram_id = %s", (telegram_id,), ).fetchone() return self._to_user(row) if row else None def get_user_by_login(self, login: str) -> BotUser | None: with self.pool.connection() as conn: row = conn.execute( "SELECT * FROM users WHERE lower(login) = lower(%s)", (login.strip(),), ).fetchone() return self._to_user(row) if row else None def get_user_by_session(self, token: str | None) -> BotUser | None: if not token: return None now = _utc_now() with self.pool.connection() as conn: row = conn.execute( """ SELECT u.* FROM user_sessions s JOIN users u ON u.telegram_id = s.telegram_id WHERE s.token = %s AND s.expires_at > %s """, (token, now), ).fetchone() if row: user = self._to_user(row) if user.banned: return None return user row = conn.execute( """ SELECT * FROM users WHERE session_token = %s AND session_expires IS NOT NULL AND session_expires > %s """, (token, now), ).fetchone() if not row: return None user = self._to_user(row) if user.banned: return None return user def create_session( self, telegram_id: int, *, user_agent: str | None = None, ip: str | None = None, ) -> str: token = new_session_token() expires = _utc_now() + timedelta(days=SESSION_DAYS) with self.pool.connection() as conn: conn.execute( """ INSERT INTO user_sessions (token, telegram_id, created_at, expires_at, user_agent, ip) VALUES (%s, %s, %s, %s, %s, %s) """, ( token, telegram_id, _utc_now(), expires, (user_agent or "")[:300] or None, (ip or "")[:80] or None, ), ) # legacy single-token column (совместимость) conn.execute( """ UPDATE users SET session_token = %s, session_expires = %s WHERE telegram_id = %s """, (token, expires, telegram_id), ) conn.commit() return token def clear_session(self, token: str | None) -> None: if not token: return with self.pool.connection() as conn: conn.execute("DELETE FROM user_sessions WHERE token = %s", (token,)) conn.execute( """ UPDATE users SET session_token = NULL, session_expires = NULL WHERE session_token = %s """, (token,), ) conn.commit() def clear_all_sessions(self, telegram_id: int, *, keep_token: str | None = None) -> int: with self.pool.connection() as conn: if keep_token: row = conn.execute( """ DELETE FROM user_sessions WHERE telegram_id = %s AND token <> %s """, (telegram_id, keep_token), ) else: row = conn.execute( "DELETE FROM user_sessions WHERE telegram_id = %s", (telegram_id,), ) deleted = row.rowcount if row is not None else 0 if not keep_token: conn.execute( """ UPDATE users SET session_token = NULL, session_expires = NULL WHERE telegram_id = %s """, (telegram_id,), ) conn.commit() return int(deleted or 0) def list_sessions(self, telegram_id: int) -> list[dict[str, Any]]: with self.pool.connection() as conn: rows = conn.execute( """ SELECT token, created_at, expires_at, user_agent, ip FROM user_sessions WHERE telegram_id = %s AND expires_at > %s ORDER BY created_at DESC LIMIT 20 """, (telegram_id, _utc_now()), ).fetchall() return [dict(r) for r in rows] def set_user_email(self, telegram_id: int, email: str | None) -> BotUser: raw = (email or "").strip().lower() if raw and not EMAIL_RE.fullmatch(raw): raise AuthError("Некорректный email") value = raw or None with self.pool.connection() as conn: if value: clash = conn.execute( """ SELECT telegram_id FROM users WHERE lower(email) = %s AND telegram_id <> %s LIMIT 1 """, (value, telegram_id), ).fetchone() if clash: raise AuthError("Этот email уже привязан к другому аккаунту") conn.execute( "UPDATE users SET email = %s WHERE telegram_id = %s", (value, telegram_id), ) conn.commit() user = self.get_user(telegram_id) if not user: raise AuthError("Пользователь не найден") return user def create_login_qr(self) -> dict[str, Any]: token = secrets.token_urlsafe(24) now = _utc_now() expires = now + QR_LOGIN_TTL with self.pool.connection() as conn: conn.execute( """ INSERT INTO login_qr (token, status, created_at, expires_at) VALUES (%s, 'pending', %s, %s) """, (token, now, expires), ) conn.commit() return {"token": token, "expires_at": expires, "expires_in": int(QR_LOGIN_TTL.total_seconds())} def approve_login_qr(self, token: str, telegram_id: int) -> None: token = (token or "").strip() if not token: raise AuthError("Некорректный QR-токен") now = _utc_now() with self.pool.connection() as conn: row = conn.execute( "SELECT * FROM login_qr WHERE token = %s", (token,), ).fetchone() if not row: raise AuthError("QR-сессия не найдена") if row["status"] != "pending": raise AuthError("QR уже использован или подтверждён") if row["expires_at"] <= now: conn.execute( "UPDATE login_qr SET status = 'expired' WHERE token = %s", (token,), ) conn.commit() raise AuthError("QR истёк — обновите код на сайте") updated = conn.execute( """ UPDATE login_qr SET status = 'approved', telegram_id = %s, approved_at = %s WHERE token = %s AND status = 'pending' AND expires_at > %s RETURNING token """, (telegram_id, now, token, now), ).fetchone() conn.commit() if not updated: raise AuthError("Не удалось подтвердить QR") def poll_login_qr(self, token: str) -> dict[str, Any]: token = (token or "").strip() if not token: raise AuthError("Некорректный QR-токен") now = _utc_now() with self.pool.connection() as conn: row = conn.execute( "SELECT * FROM login_qr WHERE token = %s", (token,), ).fetchone() if not row: raise AuthError("QR-сессия не найдена") if row["expires_at"] <= now and row["status"] == "pending": conn.execute( "UPDATE login_qr SET status = 'expired' WHERE token = %s", (token,), ) conn.commit() return {"status": "expired"} if row["status"] == "pending": return {"status": "pending"} if row["status"] in {"expired", "consumed"}: return {"status": row["status"]} if row["status"] != "approved": return {"status": str(row["status"])} telegram_id = int(row["telegram_id"]) claimed = conn.execute( """ UPDATE login_qr SET status = 'consumed' WHERE token = %s AND status = 'approved' RETURNING telegram_id """, (token,), ).fetchone() conn.commit() if not claimed: return {"status": "consumed"} session = self.create_session(telegram_id) with self.pool.connection() as conn: conn.execute( "UPDATE login_qr SET session_token = %s WHERE token = %s", (session, token), ) user_row = conn.execute( "SELECT * FROM users WHERE telegram_id = %s", (telegram_id,), ).fetchone() conn.commit() if not user_row: raise AuthError("Пользователь не найден") return { "status": "approved", "token": session, "user": self._to_user(user_row), } def save_webauthn_challenge( self, *, challenge_b64: str, purpose: str, telegram_id: int | None = None, ) -> None: expires = _utc_now() + WEBAUTHN_CHALLENGE_TTL with self.pool.connection() as conn: conn.execute( "DELETE FROM webauthn_challenges WHERE expires_at < %s", (_utc_now(),), ) conn.execute( """ INSERT INTO webauthn_challenges (challenge, telegram_id, purpose, expires_at) VALUES (%s, %s, %s, %s) ON CONFLICT (challenge) DO UPDATE SET telegram_id = EXCLUDED.telegram_id, purpose = EXCLUDED.purpose, expires_at = EXCLUDED.expires_at """, (challenge_b64, telegram_id, purpose, expires), ) conn.commit() def take_webauthn_challenge( self, challenge_b64: str, *, purpose: str ) -> dict[str, Any] | None: now = _utc_now() with self.pool.connection() as conn: row = conn.execute( """ DELETE FROM webauthn_challenges WHERE challenge = %s AND purpose = %s AND expires_at > %s RETURNING challenge, telegram_id, purpose """, (challenge_b64, purpose, now), ).fetchone() conn.commit() return dict(row) if row else None def list_webauthn_credentials(self, telegram_id: int) -> list[dict[str, Any]]: with self.pool.connection() as conn: rows = conn.execute( """ SELECT credential_id, telegram_id, public_key, sign_count, transports, device_name, created_at, last_used_at FROM webauthn_credentials WHERE telegram_id = %s ORDER BY created_at DESC """, (telegram_id,), ).fetchall() return [dict(r) for r in rows] def get_webauthn_credential(self, credential_id: str) -> dict[str, Any] | None: with self.pool.connection() as conn: row = conn.execute( """ SELECT credential_id, telegram_id, public_key, sign_count, transports, device_name, created_at, last_used_at FROM webauthn_credentials WHERE credential_id = %s """, (credential_id,), ).fetchone() return dict(row) if row else None def add_webauthn_credential( self, *, telegram_id: int, credential_id: str, public_key: bytes, sign_count: int, transports: list[str] | None = None, device_name: str | None = None, ) -> None: with self.pool.connection() as conn: conn.execute( """ INSERT INTO webauthn_credentials ( credential_id, telegram_id, public_key, sign_count, transports, device_name, created_at ) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( credential_id, telegram_id, public_key, int(sign_count), json.dumps(transports or []), (device_name or "").strip() or None, _utc_now(), ), ) conn.commit() def update_webauthn_sign_count(self, credential_id: str, sign_count: int) -> None: with self.pool.connection() as conn: conn.execute( """ UPDATE webauthn_credentials SET sign_count = %s, last_used_at = %s WHERE credential_id = %s """, (int(sign_count), _utc_now(), credential_id), ) conn.commit() def delete_webauthn_credential(self, telegram_id: int, credential_id: str) -> bool: with self.pool.connection() as conn: row = conn.execute( """ DELETE FROM webauthn_credentials WHERE telegram_id = %s AND credential_id = %s RETURNING credential_id """, (telegram_id, credential_id), ).fetchone() conn.commit() return row is not None def register_with_password( self, *, login: str, password: str, full_name: str | None = None, auto_renew: bool = True, ) -> tuple[BotUser, str]: login_norm = login.strip() if not LOGIN_RE.fullmatch(login_norm): raise AuthError("Логин: 3–32 символа (латиница, цифры, _)") if len(password) < 6: raise AuthError("Пароль минимум 6 символов") if self.get_user_by_login(login_norm): raise AuthError("Такой логин уже занят") pwd_hash = hash_password(password) with self.pool.connection() as conn: web_id = conn.execute("SELECT nextval('web_user_id_seq') AS id").fetchone() assert web_id is not None telegram_id = -int(web_id["id"]) try: conn.execute( """ INSERT INTO users ( telegram_id, username, full_name, balance, created_at, login, password_hash, auto_renew ) VALUES (%s, %s, %s, 0, %s, %s, %s, %s) """, ( telegram_id, login_norm, full_name or login_norm, _utc_now(), login_norm, pwd_hash, bool(auto_renew), ), ) except Exception as exc: # noqa: BLE001 conn.rollback() raise AuthError("Не удалось зарегистрировать") from exc conn.commit() token = self.create_session(telegram_id) user = self.get_user(telegram_id) assert user is not None return user, token def login_with_password(self, login: str, password: str) -> tuple[BotUser, str]: login_norm = login.strip() with self.pool.connection() as conn: row = conn.execute( "SELECT * FROM users WHERE lower(login) = lower(%s)", (login_norm,), ).fetchone() if not row or not row.get("password_hash"): raise AuthError("Неверный логин или пароль") if not verify_password(password, row["password_hash"]): raise AuthError("Неверный логин или пароль") user = self._to_user(row) token = self.create_session(user.telegram_id) return user, token def login_or_register_telegram( self, telegram_id: int, *, username: str | None = None, full_name: str | None = None, link_from: int | None = None, ) -> tuple[BotUser, str]: if telegram_id <= 0: raise AuthError("Некорректный Telegram ID") if link_from is not None and link_from < 0 and link_from != telegram_id: user = self._merge_web_into_telegram( web_id=link_from, telegram_id=telegram_id, username=username, full_name=full_name, ) else: user = self.ensure_user( telegram_id, username=username, full_name=full_name, ) token = self.create_session(user.telegram_id) return user, token def _merge_web_into_telegram( self, *, web_id: int, telegram_id: int, username: str | None, full_name: str | None, ) -> BotUser: with self.pool.connection() as conn: web = conn.execute( "SELECT * FROM users WHERE telegram_id = %s FOR UPDATE", (web_id,), ).fetchone() if not web: raise AuthError("Веб-аккаунт не найден") tg = conn.execute( "SELECT * FROM users WHERE telegram_id = %s FOR UPDATE", (telegram_id,), ).fetchone() if tg is None: # Переносим PK: создаём tg-строку и перекидываем FK conn.execute( """ INSERT INTO users ( telegram_id, username, full_name, balance, created_at, login, password_hash ) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( telegram_id, username or web["username"], full_name or web["full_name"], web["balance"], web["created_at"], web["login"], web["password_hash"], ), ) for table in ("transactions", "purchases", "invoices"): conn.execute( f"UPDATE {table} SET telegram_id = %s WHERE telegram_id = %s", (telegram_id, web_id), ) conn.execute("DELETE FROM users WHERE telegram_id = %s", (web_id,)) else: # Telegram уже есть — сливаем баланс и логин в него new_balance = float(tg["balance"]) + float(web["balance"]) login = tg["login"] or web["login"] pwd = tg["password_hash"] or web["password_hash"] conn.execute( """ UPDATE users SET balance = %s, login = %s, password_hash = %s, username = COALESCE(%s, username), full_name = COALESCE(%s, full_name) WHERE telegram_id = %s """, ( new_balance, login, pwd, username, full_name, telegram_id, ), ) for table in ("transactions", "purchases", "invoices"): conn.execute( f"UPDATE {table} SET telegram_id = %s WHERE telegram_id = %s", (telegram_id, web_id), ) conn.execute("DELETE FROM users WHERE telegram_id = %s", (web_id,)) conn.commit() row = conn.execute( "SELECT * FROM users WHERE telegram_id = %s", (telegram_id,), ).fetchone() assert row is not None return self._to_user(row) def get_balance(self, telegram_id: int) -> float: return self.ensure_user(telegram_id).balance def add_balance( self, telegram_id: int, amount: float, *, kind: str = "topup", meta: str | None = None, ) -> float: if amount == 0: return self.get_balance(telegram_id) with self.pool.connection() as conn: self._ensure_user_conn(conn, telegram_id) conn.execute( "UPDATE users SET balance = balance + %s WHERE telegram_id = %s", (amount, telegram_id), ) conn.execute( """ INSERT INTO transactions (telegram_id, amount, kind, meta, created_at) VALUES (%s, %s, %s, %s, %s) """, (telegram_id, amount, kind, meta, _utc_now()), ) row = conn.execute( "SELECT balance FROM users WHERE telegram_id = %s", (telegram_id,), ).fetchone() conn.commit() assert row is not None return float(row["balance"]) def try_charge( self, telegram_id: int, amount: float, *, kind: str = "purchase", meta: str | None = None, ) -> tuple[bool, float]: if amount < 0: raise ValueError("amount must be >= 0") with self.pool.connection() as conn: self._ensure_user_conn(conn, telegram_id) row = conn.execute( "SELECT balance FROM users WHERE telegram_id = %s FOR UPDATE", (telegram_id,), ).fetchone() assert row is not None balance = float(row["balance"]) if balance < amount: conn.commit() return False, balance conn.execute( "UPDATE users SET balance = balance - %s WHERE telegram_id = %s", (amount, telegram_id), ) conn.execute( """ INSERT INTO transactions (telegram_id, amount, kind, meta, created_at) VALUES (%s, %s, %s, %s, %s) """, (telegram_id, -amount, kind, meta, _utc_now()), ) conn.commit() return True, balance - amount def record_purchase( self, telegram_id: int, *, tariff_id: str, price: float, remnawave_uuid: str | None, subscription_url: str | None, ) -> None: with self.pool.connection() as conn: conn.execute( """ INSERT INTO purchases ( telegram_id, tariff_id, price, remnawave_uuid, subscription_url, created_at ) VALUES (%s, %s, %s, %s, %s, %s) """, ( telegram_id, tariff_id, price, remnawave_uuid, subscription_url, _utc_now(), ), ) conn.commit() def recent_transactions(self, telegram_id: int, limit: int = 5) -> list[dict[str, Any]]: items, _total = self.list_transactions(telegram_id, limit=limit, offset=0) return items def list_transactions( self, telegram_id: int, *, limit: int = 10, offset: int = 0, ) -> tuple[list[dict[str, Any]], int]: limit = max(1, min(int(limit), 50)) offset = max(0, int(offset)) with self.pool.connection() as conn: total_row = conn.execute( "SELECT COUNT(*) AS c FROM transactions WHERE telegram_id = %s", (telegram_id,), ).fetchone() total = int(total_row["c"] if total_row else 0) rows = conn.execute( """ SELECT amount, kind, meta, created_at FROM transactions WHERE telegram_id = %s ORDER BY id DESC LIMIT %s OFFSET %s """, (telegram_id, limit, offset), ).fetchall() return [dict(r) for r in rows], total def list_users_admin( self, *, query: str = "", limit: int = 50, offset: int = 0, ) -> tuple[list[dict[str, Any]], int]: q = (query or "").strip() limit = max(1, min(int(limit), 200)) offset = max(0, int(offset)) with self.pool.connection() as conn: if q: pattern = f"%{q}%" tid: int | None = None if q.lstrip("-").isdigit(): try: tid = int(q) except ValueError: tid = None if tid is not None: rows = conn.execute( """ SELECT telegram_id, username, full_name, login, balance, created_at, banned FROM users WHERE telegram_id = %s OR login ILIKE %s OR username ILIKE %s OR full_name ILIKE %s ORDER BY created_at DESC LIMIT %s OFFSET %s """, (tid, pattern, pattern, pattern, limit, offset), ).fetchall() count_row = conn.execute( """ SELECT COUNT(*) AS c FROM users WHERE telegram_id = %s OR login ILIKE %s OR username ILIKE %s OR full_name ILIKE %s """, (tid, pattern, pattern, pattern), ).fetchone() else: rows = conn.execute( """ SELECT telegram_id, username, full_name, login, balance, created_at, banned FROM users WHERE login ILIKE %s OR username ILIKE %s OR full_name ILIKE %s OR CAST(telegram_id AS TEXT) LIKE %s ORDER BY created_at DESC LIMIT %s OFFSET %s """, (pattern, pattern, pattern, pattern, limit, offset), ).fetchall() count_row = conn.execute( """ SELECT COUNT(*) AS c FROM users WHERE login ILIKE %s OR username ILIKE %s OR full_name ILIKE %s OR CAST(telegram_id AS TEXT) LIKE %s """, (pattern, pattern, pattern, pattern), ).fetchone() else: rows = conn.execute( """ SELECT telegram_id, username, full_name, login, balance, created_at, banned FROM users ORDER BY created_at DESC LIMIT %s OFFSET %s """, (limit, offset), ).fetchall() count_row = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone() total = int(count_row["c"] or 0) if count_row else 0 return [dict(r) for r in rows], total def admin_credit_balance( self, telegram_id: int, amount: float, *, comment: str, admin_note: str | None = None, ) -> float: """Пополнение с баланса админом; comment виден пользователю в истории.""" if amount <= 0: raise ValueError("amount must be > 0") text = (comment or "").strip()[:200] or "Зачисление администратором" meta = text if admin_note: meta = f"{text} | admin:{admin_note[:80]}" return self.add_balance( telegram_id, amount, kind="admin_credit", meta=meta, ) def next_local_invoice_id(self) -> int: with self.pool.connection() as conn: row = conn.execute("SELECT nextval('local_invoice_id_seq') AS id").fetchone() conn.commit() assert row is not None return int(row["id"]) def create_invoice_record( self, *, telegram_id: int, invoice_id: int, kind: str, amount_rub: float, pay_url: str | None, payload: str | None, tariff_id: str | None = None, provider: str = "cryptobot", order_id: str | None = None, external_uuid: str | None = None, ) -> None: self.ensure_user(telegram_id) with self.pool.connection() as conn: conn.execute( """ INSERT INTO invoices ( telegram_id, invoice_id, kind, amount_rub, tariff_id, status, pay_url, payload, created_at, provider, order_id, external_uuid ) VALUES (%s, %s, %s, %s, %s, 'active', %s, %s, %s, %s, %s, %s) """, ( telegram_id, invoice_id, kind, amount_rub, tariff_id, pay_url, payload, _utc_now(), provider, order_id, external_uuid, ), ) conn.commit() def get_invoice( self, invoice_id: int, *, provider: str | None = None, ) -> dict[str, Any] | None: with self.pool.connection() as conn: if provider: row = conn.execute( """ SELECT * FROM invoices WHERE invoice_id = %s AND provider = %s """, (invoice_id, provider), ).fetchone() else: row = conn.execute( "SELECT * FROM invoices WHERE invoice_id = %s", (invoice_id,), ).fetchone() return dict(row) if row else None def get_invoice_by_order_id(self, order_id: str) -> dict[str, Any] | None: with self.pool.connection() as conn: row = conn.execute( "SELECT * FROM invoices WHERE order_id = %s", (order_id,), ).fetchone() return dict(row) if row else None def digiseller_inv_used(self, inv: int) -> bool: with self.pool.connection() as conn: row = conn.execute( "SELECT 1 FROM digiseller_payments WHERE inv = %s", (inv,), ).fetchone() return row is not None def digiseller_code_used(self, unique_code: str) -> bool: with self.pool.connection() as conn: row = conn.execute( "SELECT 1 FROM digiseller_payments WHERE unique_code = %s", (unique_code,), ).fetchone() return row is not None def record_digiseller_payment( self, *, inv: int, unique_code: str, telegram_id: int, amount_rub: float, product_id: int | None, ) -> bool: """True если записали впервые, False если уже был такой inv/code.""" self.ensure_user(telegram_id) with self.pool.connection() as conn: try: conn.execute( """ INSERT INTO digiseller_payments ( inv, unique_code, telegram_id, amount_rub, product_id, created_at ) VALUES (%s, %s, %s, %s, %s, %s) """, ( inv, unique_code, telegram_id, amount_rub, product_id, _utc_now(), ), ) conn.commit() return True except Exception: # noqa: BLE001 conn.rollback() return False def create_topup_codes( self, *, amount_rub: float, count: int, created_by: str = "", codes: list[str], ) -> int: if amount_rub <= 0 or not codes: return 0 inserted = 0 with self.pool.connection() as conn: for code in codes: try: conn.execute( """ INSERT INTO topup_codes (code, amount_rub, created_by, created_at) VALUES (%s, %s, %s, %s) """, (code, amount_rub, created_by or None, _utc_now()), ) inserted += 1 except Exception: # noqa: BLE001 continue conn.commit() return inserted def redeem_topup_code( self, *, code: str, telegram_id: int, ) -> tuple[float, bool]: """(сумма, уже_использован). NotFound -> ValueError.""" with self.pool.connection() as conn: row = conn.execute( """ SELECT code, amount_rub, used_at FROM topup_codes WHERE code = %s FOR UPDATE """, (code,), ).fetchone() if not row: raise ValueError("Код не найден") amount = float(row["amount_rub"]) if row["used_at"] is not None: conn.commit() return amount, True conn.execute( """ UPDATE topup_codes SET used_at = %s, used_by = %s WHERE code = %s AND used_at IS NULL """, (_utc_now(), telegram_id, code), ) conn.commit() return amount, False def list_topup_codes( self, *, status: str = "all", limit: int = 50, offset: int = 0, ) -> list[dict[str, Any]]: limit = max(1, min(int(limit), 200)) offset = max(0, int(offset)) where = "" if status == "unused": where = "WHERE used_at IS NULL" elif status == "used": where = "WHERE used_at IS NOT NULL" with self.pool.connection() as conn: rows = conn.execute( f""" SELECT code, amount_rub, created_at, created_by, used_at, used_by FROM topup_codes {where} ORDER BY created_at DESC LIMIT %s OFFSET %s """, (limit, offset), ).fetchall() return [dict(r) for r in rows] def topup_code_stats(self) -> dict[str, int]: with self.pool.connection() as conn: row = conn.execute( """ SELECT COUNT(*) FILTER (WHERE used_at IS NULL) AS unused, COUNT(*) FILTER (WHERE used_at IS NOT NULL) AS used, COUNT(*) AS total FROM topup_codes """ ).fetchone() return { "unused": int(row["unused"] or 0), "used": int(row["used"] or 0), "total": int(row["total"] or 0), } def mark_invoice_paid(self, invoice_id: int, *, provider: str | None = None) -> bool: with self.pool.connection() as conn: if provider: row = conn.execute( """ UPDATE invoices SET status = 'paid', paid_at = %s WHERE invoice_id = %s AND provider = %s AND status IS DISTINCT FROM 'paid' RETURNING invoice_id """, (_utc_now(), invoice_id, provider), ).fetchone() else: row = conn.execute( """ UPDATE invoices SET status = 'paid', paid_at = %s WHERE invoice_id = %s AND status IS DISTINCT FROM 'paid' RETURNING invoice_id """, (_utc_now(), invoice_id), ).fetchone() conn.commit() return row is not None @staticmethod def _to_user(row: dict[str, Any]) -> BotUser: tid = int(row["telegram_id"]) return BotUser( telegram_id=tid, username=row["username"], full_name=row["full_name"], balance=float(row["balance"]), created_at=row["created_at"], login=row.get("login"), has_password=bool(row.get("password_hash")), is_telegram=tid > 0, extra_devices=int(row.get("extra_devices") or 0), device_blocked=bool(row.get("device_blocked")), extra_traffic_packs=int(row.get("extra_traffic_packs") or 0), extras_next_billing_at=row.get("extras_next_billing_at"), extras_active=bool(row.get("extras_active", True)), auto_renew=bool(row["auto_renew"]) if row.get("auto_renew") is not None else True, email=(str(row["email"]).strip() if row.get("email") else None) or None, banned=bool(row.get("banned")), referral_code=(str(row["referral_code"]).strip() if row.get("referral_code") else None) or None, referred_by=int(row["referred_by"]) if row.get("referred_by") is not None else None, trial_used_at=row.get("trial_used_at"), notify_telegram=bool(row["notify_telegram"]) if row.get("notify_telegram") is not None else True, notify_email=bool(row["notify_email"]) if row.get("notify_email") is not None else True, ) def set_device_blocked(self, telegram_id: int, blocked: bool) -> None: with self.pool.connection() as conn: self._ensure_user_conn(conn, telegram_id) conn.execute( "UPDATE users SET device_blocked = %s WHERE telegram_id = %s", (bool(blocked), telegram_id), ) conn.commit() def add_extra_devices(self, telegram_id: int, slots: int) -> int: if slots <= 0: return self.ensure_user(telegram_id).extra_devices with self.pool.connection() as conn: self._ensure_user_conn(conn, telegram_id) row = conn.execute( """ UPDATE users SET extra_devices = extra_devices + %s WHERE telegram_id = %s RETURNING extra_devices """, (int(slots), telegram_id), ).fetchone() conn.commit() assert row is not None return int(row["extra_devices"]) def add_extra_traffic_packs(self, telegram_id: int, packs: int) -> int: if packs <= 0: return self.ensure_user(telegram_id).extra_traffic_packs with self.pool.connection() as conn: self._ensure_user_conn(conn, telegram_id) row = conn.execute( """ UPDATE users SET extra_traffic_packs = extra_traffic_packs + %s WHERE telegram_id = %s RETURNING extra_traffic_packs """, (int(packs), telegram_id), ).fetchone() conn.commit() assert row is not None return int(row["extra_traffic_packs"]) def set_extras_active(self, telegram_id: int, active: bool) -> None: with self.pool.connection() as conn: self._ensure_user_conn(conn, telegram_id) conn.execute( "UPDATE users SET extras_active = %s WHERE telegram_id = %s", (bool(active), telegram_id), ) conn.commit() def set_extras_billing( self, telegram_id: int, *, next_billing_at: datetime | None, active: bool, ) -> None: with self.pool.connection() as conn: self._ensure_user_conn(conn, telegram_id) conn.execute( """ UPDATE users SET extras_next_billing_at = %s, extras_active = %s WHERE telegram_id = %s """, (next_billing_at, bool(active), telegram_id), ) conn.commit() def list_users_for_extras_billing(self, *, limit: int = 200) -> list[int]: with self.pool.connection() as conn: rows = conn.execute( """ SELECT telegram_id FROM users WHERE (extra_devices > 0 OR extra_traffic_packs > 0) AND ( extras_active = FALSE OR extras_next_billing_at IS NULL OR extras_next_billing_at <= NOW() ) ORDER BY extras_next_billing_at NULLS FIRST LIMIT %s """, (int(limit),), ).fetchall() return [int(r["telegram_id"]) for r in rows] def list_telegram_ids_with_purchases(self, *, limit: int = 500) -> list[int]: with self.pool.connection() as conn: rows = conn.execute( """ SELECT DISTINCT p.telegram_id FROM purchases p WHERE p.telegram_id > 0 ORDER BY p.telegram_id LIMIT %s """, (int(limit),), ).fetchall() return [int(r["telegram_id"]) for r in rows] def set_auto_renew(self, telegram_id: int, enabled: bool) -> BotUser: with self.pool.connection() as conn: self._ensure_user_conn(conn, telegram_id) row = conn.execute( """ UPDATE users SET auto_renew = %s WHERE telegram_id = %s RETURNING * """, (bool(enabled), int(telegram_id)), ).fetchone() conn.commit() assert row is not None return self._to_user(row) def get_last_tariff_id(self, telegram_id: int) -> str | None: with self.pool.connection() as conn: row = conn.execute( """ SELECT tariff_id FROM purchases WHERE telegram_id = %s ORDER BY id DESC LIMIT 1 """, (telegram_id,), ).fetchone() return str(row["tariff_id"]) if row else None def expiry_reminder_sent_on(self, telegram_id: int) -> date | None: with self.pool.connection() as conn: row = conn.execute( "SELECT expiry_reminder_sent_on FROM users WHERE telegram_id = %s", (telegram_id,), ).fetchone() if not row: return None val = row.get("expiry_reminder_sent_on") if val is None: return None if isinstance(val, date): return val return val.date() if hasattr(val, "date") else None def mark_expiry_reminder_sent(self, telegram_id: int, on: date) -> None: with self.pool.connection() as conn: self._ensure_user_conn(conn, telegram_id) conn.execute( """ UPDATE users SET expiry_reminder_sent_on = %s WHERE telegram_id = %s """, (on, telegram_id), ) conn.commit() def count_open_tickets(self, telegram_id: int) -> int: with self.pool.connection() as conn: row = conn.execute( """ SELECT COUNT(*) AS c FROM tickets WHERE telegram_id = %s AND status <> 'closed' """, (telegram_id,), ).fetchone() return int(row["c"]) if row else 0 def create_ticket( self, telegram_id: int, *, subject: str, body: str, ) -> dict[str, Any]: subject = (subject or "").strip() body = (body or "").strip() if not subject or not body: raise ValueError("Тема и сообщение обязательны") with self.pool.connection() as conn: self._ensure_user_conn(conn, telegram_id) row = conn.execute( """ INSERT INTO tickets (telegram_id, subject, status, created_at, updated_at) VALUES (%s, %s, 'open', NOW(), NOW()) RETURNING * """, (telegram_id, subject), ).fetchone() assert row is not None ticket_id = int(row["id"]) conn.execute( """ INSERT INTO ticket_messages (ticket_id, author_role, author_id, body) VALUES (%s, 'user', %s, %s) """, (ticket_id, telegram_id, body), ) conn.commit() return dict(row) def list_tickets_for_user( self, telegram_id: int, *, limit: int = 30, offset: int = 0, include_closed: bool = True, ) -> list[dict[str, Any]]: limit = max(1, min(int(limit), 100)) offset = max(0, int(offset)) with self.pool.connection() as conn: if include_closed: rows = conn.execute( """ SELECT t.*, ( SELECT COUNT(*) FROM ticket_messages m WHERE m.ticket_id = t.id ) AS message_count FROM tickets t WHERE t.telegram_id = %s ORDER BY t.updated_at DESC LIMIT %s OFFSET %s """, (telegram_id, limit, offset), ).fetchall() else: rows = conn.execute( """ SELECT t.*, ( SELECT COUNT(*) FROM ticket_messages m WHERE m.ticket_id = t.id ) AS message_count FROM tickets t WHERE t.telegram_id = %s AND t.status <> 'closed' ORDER BY t.updated_at DESC LIMIT %s OFFSET %s """, (telegram_id, limit, offset), ).fetchall() return [dict(r) for r in rows] def list_tickets_admin( self, *, status: str = "open", limit: int = 50, offset: int = 0, ) -> tuple[list[dict[str, Any]], int]: limit = max(1, min(int(limit), 100)) offset = max(0, int(offset)) status = (status or "open").strip().lower() with self.pool.connection() as conn: if status == "all": total_row = conn.execute("SELECT COUNT(*) AS c FROM tickets").fetchone() rows = conn.execute( """ SELECT t.*, u.username, u.full_name, u.login, ( SELECT COUNT(*) FROM ticket_messages m WHERE m.ticket_id = t.id ) AS message_count FROM tickets t LEFT JOIN users u ON u.telegram_id = t.telegram_id ORDER BY CASE t.status WHEN 'open' THEN 0 WHEN 'answered' THEN 1 ELSE 2 END, t.updated_at DESC LIMIT %s OFFSET %s """, (limit, offset), ).fetchall() else: total_row = conn.execute( "SELECT COUNT(*) AS c FROM tickets WHERE status = %s", (status,), ).fetchone() rows = conn.execute( """ SELECT t.*, u.username, u.full_name, u.login, ( SELECT COUNT(*) FROM ticket_messages m WHERE m.ticket_id = t.id ) AS message_count FROM tickets t LEFT JOIN users u ON u.telegram_id = t.telegram_id WHERE t.status = %s ORDER BY t.updated_at DESC LIMIT %s OFFSET %s """, (status, limit, offset), ).fetchall() total = int(total_row["c"]) if total_row else 0 return [dict(r) for r in rows], total def ticket_stats(self) -> dict[str, int]: with self.pool.connection() as conn: rows = conn.execute( """ SELECT status, COUNT(*) AS c FROM tickets GROUP BY status """ ).fetchall() stats = {"open": 0, "answered": 0, "closed": 0, "total": 0} for r in rows: st = str(r["status"]) c = int(r["c"]) if st in stats: stats[st] = c stats["total"] += c return stats def get_ticket(self, ticket_id: int) -> dict[str, Any] | None: with self.pool.connection() as conn: row = conn.execute( """ SELECT t.*, u.username, u.full_name, u.login FROM tickets t LEFT JOIN users u ON u.telegram_id = t.telegram_id WHERE t.id = %s """, (int(ticket_id),), ).fetchone() return dict(row) if row else None def list_ticket_messages( self, ticket_id: int, *, limit: int = 200, ) -> list[dict[str, Any]]: limit = max(1, min(int(limit), 500)) with self.pool.connection() as conn: rows = conn.execute( """ SELECT * FROM ticket_messages WHERE ticket_id = %s ORDER BY id ASC LIMIT %s """, (int(ticket_id), limit), ).fetchall() return [dict(r) for r in rows] def add_ticket_message( self, ticket_id: int, *, author_role: str, author_id: int | None, body: str, new_status: str | None = None, ) -> dict[str, Any]: body = (body or "").strip() if not body: raise ValueError("Пустое сообщение") role = (author_role or "").strip().lower() if role not in ("user", "admin", "ai"): raise ValueError("Некорректная роль") with self.pool.connection() as conn: ticket = conn.execute( "SELECT * FROM tickets WHERE id = %s FOR UPDATE", (int(ticket_id),), ).fetchone() if not ticket: raise ValueError("Тикет не найден") if str(ticket["status"]) == "closed": raise ValueError("Тикет закрыт") status = new_status or str(ticket["status"]) if new_status is None: status = "answered" if role in ("admin", "ai") else "open" conn.execute( """ INSERT INTO ticket_messages (ticket_id, author_role, author_id, body) VALUES (%s, %s, %s, %s) """, (int(ticket_id), role, author_id, body), ) conn.execute( """ UPDATE tickets SET status = %s, updated_at = NOW() WHERE id = %s """, (status, int(ticket_id)), ) conn.commit() row = conn.execute( "SELECT * FROM tickets WHERE id = %s", (int(ticket_id),), ).fetchone() assert row is not None return dict(row) def set_ticket_status(self, ticket_id: int, status: str) -> dict[str, Any] | None: status = (status or "").strip().lower() if status not in ("open", "answered", "closed"): raise ValueError("Некорректный статус") with self.pool.connection() as conn: conn.execute( """ UPDATE tickets SET status = %s, updated_at = NOW() WHERE id = %s """, (status, int(ticket_id)), ) conn.commit() row = conn.execute( "SELECT * FROM tickets WHERE id = %s", (int(ticket_id),), ).fetchone() return dict(row) if row else None def get_setting(self, key: str, default: str = "") -> str: with self.pool.connection() as conn: row = conn.execute( "SELECT value FROM app_settings WHERE key = %s", (key,), ).fetchone() if not row: return default return str(row["value"] if row["value"] is not None else default) def set_setting(self, key: str, value: str) -> None: with self.pool.connection() as conn: conn.execute( """ INSERT INTO app_settings (key, value, updated_at) VALUES (%s, %s, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() """, (key, value if value is not None else ""), ) conn.commit() def get_settings_map(self, keys: list[str]) -> dict[str, str]: if not keys: return {} with self.pool.connection() as conn: rows = conn.execute( "SELECT key, value FROM app_settings WHERE key = ANY(%s)", (list(keys),), ).fetchall() out = {k: "" for k in keys} for r in rows: out[str(r["key"])] = str(r["value"] if r["value"] is not None else "") return out def set_settings_map(self, data: dict[str, str]) -> None: if not data: return with self.pool.connection() as conn: for key, value in data.items(): conn.execute( """ INSERT INTO app_settings (key, value, updated_at) VALUES (%s, %s, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() """, (str(key), "" if value is None else str(value)), ) conn.commit() # ─── 3x-ui servers ───────────────────────────────────────────────────── def list_xui_servers(self, *, enabled_only: bool = False) -> list[dict[str, Any]]: where = "WHERE enabled = TRUE" if enabled_only else "" with self.pool.connection() as conn: rows = conn.execute( f""" SELECT id, name, api_url, username, password, api_key, inbound_id, inbound_remark, subscription_url, enabled, verify_ssl, last_check_at, last_check_ok, last_check_msg, created_at, updated_at FROM xui_servers {where} ORDER BY id ASC """ ).fetchall() return [dict(r) for r in rows] def get_xui_server(self, server_id: int) -> dict[str, Any] | None: with self.pool.connection() as conn: row = conn.execute( """ SELECT id, name, api_url, username, password, api_key, inbound_id, inbound_remark, subscription_url, enabled, verify_ssl, last_check_at, last_check_ok, last_check_msg, created_at, updated_at FROM xui_servers WHERE id = %s """, (int(server_id),), ).fetchone() return dict(row) if row else None def create_xui_server( self, *, name: str, api_url: str, username: str = "", password: str = "", api_key: str = "", subscription_url: str = "", inbound_id: int | None = None, inbound_remark: str | None = None, enabled: bool = True, verify_ssl: bool = True, ) -> dict[str, Any]: with self.pool.connection() as conn: row = conn.execute( """ INSERT INTO xui_servers ( name, api_url, username, password, api_key, subscription_url, inbound_id, inbound_remark, enabled, verify_ssl ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id, name, api_url, username, password, api_key, inbound_id, inbound_remark, subscription_url, enabled, verify_ssl, last_check_at, last_check_ok, last_check_msg, created_at, updated_at """, ( name.strip(), api_url.strip(), (username or "").strip(), password or "", (api_key or "").strip(), (subscription_url or "").strip(), inbound_id, (inbound_remark or "").strip() or None, bool(enabled), bool(verify_ssl), ), ).fetchone() conn.commit() return dict(row) def update_xui_server(self, server_id: int, **fields: Any) -> dict[str, Any] | None: allowed = { "name", "api_url", "username", "password", "api_key", "subscription_url", "inbound_id", "inbound_remark", "enabled", "verify_ssl", "last_check_at", "last_check_ok", "last_check_msg", } updates = {k: v for k, v in fields.items() if k in allowed} if not updates: return self.get_xui_server(server_id) cols = [] vals: list[Any] = [] for key, value in updates.items(): cols.append(f"{key} = %s") if key in {"name", "api_url", "username", "subscription_url", "api_key"} and isinstance( value, str ): vals.append(value.strip()) elif key == "inbound_remark": vals.append((value or "").strip() or None if isinstance(value, str) else value) else: vals.append(value) cols.append("updated_at = NOW()") vals.append(int(server_id)) with self.pool.connection() as conn: row = conn.execute( f""" UPDATE xui_servers SET {", ".join(cols)} WHERE id = %s RETURNING id, name, api_url, username, password, api_key, inbound_id, inbound_remark, subscription_url, enabled, verify_ssl, last_check_at, last_check_ok, last_check_msg, created_at, updated_at """, tuple(vals), ).fetchone() conn.commit() return dict(row) if row else None def delete_xui_server(self, server_id: int) -> bool: with self.pool.connection() as conn: cur = conn.execute( "DELETE FROM xui_servers WHERE id = %s", (int(server_id),), ) conn.commit() return cur.rowcount > 0 def set_xui_server_check( self, server_id: int, *, ok: bool, message: str, ) -> None: with self.pool.connection() as conn: conn.execute( """ UPDATE xui_servers SET last_check_at = NOW(), last_check_ok = %s, last_check_msg = %s, updated_at = NOW() WHERE id = %s """, (bool(ok), (message or "")[:500], int(server_id)), ) conn.commit() # ─── 3x-ui WireGuard configs ─────────────────────────────────────────── def count_xui_wg_configs(self, telegram_id: int) -> int: with self.pool.connection() as conn: row = conn.execute( "SELECT COUNT(*) AS c FROM xui_wg_configs WHERE telegram_id = %s", (int(telegram_id),), ).fetchone() return int(row["c"] if row else 0) def list_xui_wg_configs(self, telegram_id: int) -> list[dict[str, Any]]: with self.pool.connection() as conn: rows = conn.execute( """ SELECT c.id, c.telegram_id, c.server_id, c.email, c.sub_id, c.subscription_url, c.remark, c.enabled, c.expire_at, c.traffic_bytes, c.created_at, s.name AS server_name FROM xui_wg_configs c LEFT JOIN xui_servers s ON s.id = c.server_id WHERE c.telegram_id = %s ORDER BY c.id DESC """, (int(telegram_id),), ).fetchall() return [dict(r) for r in rows] def get_xui_wg_config( self, config_id: int, *, telegram_id: int | None = None ) -> dict[str, Any] | None: with self.pool.connection() as conn: if telegram_id is None: row = conn.execute( """ SELECT id, telegram_id, server_id, email, sub_id, subscription_url, remark, enabled, expire_at, traffic_bytes, created_at FROM xui_wg_configs WHERE id = %s """, (int(config_id),), ).fetchone() else: row = conn.execute( """ SELECT id, telegram_id, server_id, email, sub_id, subscription_url, remark, enabled, expire_at, traffic_bytes, created_at FROM xui_wg_configs WHERE id = %s AND telegram_id = %s """, (int(config_id), int(telegram_id)), ).fetchone() return dict(row) if row else None def create_xui_wg_config( self, *, telegram_id: int, server_id: int, email: str, sub_id: str, subscription_url: str, remark: str = "", enabled: bool = True, expire_at: datetime | None = None, traffic_bytes: int = 0, ) -> dict[str, Any]: with self.pool.connection() as conn: row = conn.execute( """ INSERT INTO xui_wg_configs ( telegram_id, server_id, email, sub_id, subscription_url, remark, enabled, expire_at, traffic_bytes ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id, telegram_id, server_id, email, sub_id, subscription_url, remark, enabled, expire_at, traffic_bytes, created_at """, ( int(telegram_id), int(server_id), email, sub_id, subscription_url, (remark or "").strip() or None, bool(enabled), expire_at, int(traffic_bytes or 0), ), ).fetchone() conn.commit() return dict(row) def set_xui_wg_config_enabled( self, config_id: int, telegram_id: int, enabled: bool ) -> dict[str, Any] | None: with self.pool.connection() as conn: row = conn.execute( """ UPDATE xui_wg_configs SET enabled = %s WHERE id = %s AND telegram_id = %s RETURNING id, telegram_id, server_id, email, sub_id, subscription_url, remark, enabled, expire_at, traffic_bytes, created_at """, (bool(enabled), int(config_id), int(telegram_id)), ).fetchone() conn.commit() return dict(row) if row else None def update_xui_wg_config_limits( self, config_id: int, *, expire_at: datetime | None = None, traffic_bytes: int | None = None, ) -> None: sets: list[str] = [] vals: list[Any] = [] if expire_at is not None: sets.append("expire_at = %s") vals.append(expire_at) if traffic_bytes is not None: sets.append("traffic_bytes = %s") vals.append(int(traffic_bytes)) if not sets: return vals.append(int(config_id)) with self.pool.connection() as conn: conn.execute( f"UPDATE xui_wg_configs SET {', '.join(sets)} WHERE id = %s", tuple(vals), ) conn.commit() def delete_xui_wg_config(self, config_id: int, telegram_id: int) -> bool: with self.pool.connection() as conn: cur = conn.execute( "DELETE FROM xui_wg_configs WHERE id = %s AND telegram_id = %s", (int(config_id), int(telegram_id)), ) conn.commit() return cur.rowcount > 0 # ─── AmneziaWG (AWG 2.0) via Amnezia Web Panel ───────────────────────── def list_awg_servers(self, *, enabled_only: bool = False) -> list[dict[str, Any]]: where = "WHERE enabled = TRUE" if enabled_only else "" with self.pool.connection() as conn: rows = conn.execute( f""" SELECT id, name, api_url, api_token, panel_server_id, protocol, enabled, verify_ssl, last_check_at, last_check_ok, last_check_msg, created_at, updated_at FROM awg_servers {where} ORDER BY id ASC """ ).fetchall() return [dict(r) for r in rows] def get_awg_server(self, server_id: int) -> dict[str, Any] | None: with self.pool.connection() as conn: row = conn.execute( """ SELECT id, name, api_url, api_token, panel_server_id, protocol, enabled, verify_ssl, last_check_at, last_check_ok, last_check_msg, created_at, updated_at FROM awg_servers WHERE id = %s """, (int(server_id),), ).fetchone() return dict(row) if row else None def create_awg_server( self, *, name: str, api_url: str, api_token: str = "", panel_server_id: int = 0, protocol: str = "awg2", enabled: bool = True, verify_ssl: bool = True, ) -> dict[str, Any]: with self.pool.connection() as conn: row = conn.execute( """ INSERT INTO awg_servers ( name, api_url, api_token, panel_server_id, protocol, enabled, verify_ssl ) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id, name, api_url, api_token, panel_server_id, protocol, enabled, verify_ssl, last_check_at, last_check_ok, last_check_msg, created_at, updated_at """, ( name.strip(), api_url.strip(), (api_token or "").strip(), int(panel_server_id), (protocol or "awg2").strip() or "awg2", bool(enabled), bool(verify_ssl), ), ).fetchone() conn.commit() return dict(row) def update_awg_server(self, server_id: int, **fields: Any) -> dict[str, Any] | None: allowed = { "name", "api_url", "api_token", "panel_server_id", "protocol", "enabled", "verify_ssl", "last_check_at", "last_check_ok", "last_check_msg", } updates = {k: v for k, v in fields.items() if k in allowed} if not updates: return self.get_awg_server(server_id) cols = [] vals: list[Any] = [] for k, v in updates.items(): cols.append(f"{k} = %s") vals.append(v) cols.append("updated_at = NOW()") vals.append(int(server_id)) with self.pool.connection() as conn: row = conn.execute( f""" UPDATE awg_servers SET {', '.join(cols)} WHERE id = %s RETURNING id, name, api_url, api_token, panel_server_id, protocol, enabled, verify_ssl, last_check_at, last_check_ok, last_check_msg, created_at, updated_at """, tuple(vals), ).fetchone() conn.commit() return dict(row) if row else None def delete_awg_server(self, server_id: int) -> bool: with self.pool.connection() as conn: cur = conn.execute( "DELETE FROM awg_servers WHERE id = %s", (int(server_id),), ) conn.commit() return cur.rowcount > 0 def set_awg_server_check( self, server_id: int, *, ok: bool, message: str, ) -> None: with self.pool.connection() as conn: conn.execute( """ UPDATE awg_servers SET last_check_at = NOW(), last_check_ok = %s, last_check_msg = %s, updated_at = NOW() WHERE id = %s """, (bool(ok), (message or "")[:500], int(server_id)), ) conn.commit() def count_awg_configs(self, telegram_id: int) -> int: with self.pool.connection() as conn: row = conn.execute( "SELECT COUNT(*) AS c FROM awg_configs WHERE telegram_id = %s", (int(telegram_id),), ).fetchone() return int(row["c"] if row else 0) def list_awg_configs(self, telegram_id: int) -> list[dict[str, Any]]: with self.pool.connection() as conn: rows = conn.execute( """ SELECT c.id, c.telegram_id, c.server_id, c.client_id, c.client_name, c.remark, c.config_text, c.vpn_link, c.protocol, c.enabled, c.expire_at, c.created_at, s.name AS server_name FROM awg_configs c LEFT JOIN awg_servers s ON s.id = c.server_id WHERE c.telegram_id = %s ORDER BY c.id DESC """, (int(telegram_id),), ).fetchall() return [dict(r) for r in rows] def get_awg_config( self, config_id: int, *, telegram_id: int | None = None ) -> dict[str, Any] | None: with self.pool.connection() as conn: if telegram_id is None: row = conn.execute( """ SELECT id, telegram_id, server_id, client_id, client_name, remark, config_text, vpn_link, protocol, enabled, expire_at, created_at FROM awg_configs WHERE id = %s """, (int(config_id),), ).fetchone() else: row = conn.execute( """ SELECT id, telegram_id, server_id, client_id, client_name, remark, config_text, vpn_link, protocol, enabled, expire_at, created_at FROM awg_configs WHERE id = %s AND telegram_id = %s """, (int(config_id), int(telegram_id)), ).fetchone() return dict(row) if row else None def create_awg_config( self, *, telegram_id: int, server_id: int, client_id: str, client_name: str = "", remark: str = "", config_text: str = "", vpn_link: str = "", protocol: str = "awg2", enabled: bool = True, expire_at: datetime | None = None, ) -> dict[str, Any]: with self.pool.connection() as conn: row = conn.execute( """ INSERT INTO awg_configs ( telegram_id, server_id, client_id, client_name, remark, config_text, vpn_link, protocol, enabled, expire_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id, telegram_id, server_id, client_id, client_name, remark, config_text, vpn_link, protocol, enabled, expire_at, created_at """, ( int(telegram_id), int(server_id), client_id, (client_name or "").strip(), (remark or "").strip() or None, config_text or "", vpn_link or "", (protocol or "awg2").strip() or "awg2", bool(enabled), expire_at, ), ).fetchone() conn.commit() return dict(row) def update_awg_config(self, config_id: int, **fields: Any) -> dict[str, Any] | None: allowed = { "client_name", "remark", "config_text", "vpn_link", "protocol", "enabled", "expire_at", } updates = {k: v for k, v in fields.items() if k in allowed} if not updates: return self.get_awg_config(config_id) cols = [] vals: list[Any] = [] for k, v in updates.items(): cols.append(f"{k} = %s") vals.append(v) vals.append(int(config_id)) with self.pool.connection() as conn: row = conn.execute( f""" UPDATE awg_configs SET {', '.join(cols)} WHERE id = %s RETURNING id, telegram_id, server_id, client_id, client_name, remark, config_text, vpn_link, protocol, enabled, expire_at, created_at """, tuple(vals), ).fetchone() conn.commit() return dict(row) if row else None def delete_awg_config(self, config_id: int, telegram_id: int) -> bool: with self.pool.connection() as conn: cur = conn.execute( "DELETE FROM awg_configs WHERE id = %s AND telegram_id = %s", (int(config_id), int(telegram_id)), ) conn.commit() return cur.rowcount > 0 def list_telegram_ids_with_enabled_vpn_configs(self, *, limit: int = 500) -> list[int]: """Пользователи с включёнными WG или AWG конфигами (для фонового enforce).""" with self.pool.connection() as conn: rows = conn.execute( """ SELECT DISTINCT telegram_id FROM ( SELECT telegram_id FROM xui_wg_configs WHERE enabled = TRUE UNION SELECT telegram_id FROM awg_configs WHERE enabled = TRUE ) t ORDER BY telegram_id LIMIT %s """, (int(limit),), ).fetchall() return [int(r["telegram_id"]) for r in rows] # ─── Growth: ban / referral / promo / trial / password reset / audit ─── def set_banned(self, telegram_id: int, banned: bool) -> BotUser: with self.pool.connection() as conn: self._ensure_user_conn(conn, telegram_id) conn.execute( "UPDATE users SET banned = %s WHERE telegram_id = %s", (bool(banned), telegram_id), ) if banned: conn.execute( "DELETE FROM user_sessions WHERE telegram_id = %s", (telegram_id,), ) conn.execute( """ UPDATE users SET session_token = NULL, session_expires = NULL WHERE telegram_id = %s """, (telegram_id,), ) conn.commit() user = self.get_user(telegram_id) assert user is not None return user def delete_user(self, telegram_id: int) -> bool: """Полностью удалить пользователя и связанные записи. False если не найден.""" tid = int(telegram_id) with self.pool.connection() as conn: row = conn.execute( "SELECT telegram_id FROM users WHERE telegram_id = %s", (tid,), ).fetchone() if not row: return False # Снять ссылки с других пользователей conn.execute( "UPDATE users SET referred_by = NULL WHERE referred_by = %s", (tid,), ) conn.execute( "UPDATE topup_codes SET used_by = NULL WHERE used_by = %s", (tid,), ) conn.execute( "UPDATE login_qr SET telegram_id = NULL WHERE telegram_id = %s", (tid,), ) # Зависимые таблицы без ON DELETE CASCADE conn.execute( "DELETE FROM referral_rewards WHERE referrer_id = %s OR referee_id = %s", (tid, tid), ) conn.execute("DELETE FROM tickets WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM xui_wg_configs WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM awg_configs WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM transactions WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM purchases WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM invoices WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM digiseller_payments WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM payment_events WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM webauthn_challenges WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM user_sessions WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM webauthn_credentials WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM promo_redemptions WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM password_reset_tokens WHERE telegram_id = %s", (tid,)) conn.execute("DELETE FROM users WHERE telegram_id = %s", (tid,)) conn.commit() return True def set_notify_prefs( self, telegram_id: int, *, notify_telegram: bool | None = None, notify_email: bool | None = None, ) -> BotUser: user = self.ensure_user(telegram_id) tg = user.notify_telegram if notify_telegram is None else bool(notify_telegram) em = user.notify_email if notify_email is None else bool(notify_email) with self.pool.connection() as conn: conn.execute( """ UPDATE users SET notify_telegram = %s, notify_email = %s WHERE telegram_id = %s """, (tg, em, telegram_id), ) conn.commit() updated = self.get_user(telegram_id) assert updated is not None return updated def ensure_referral_code(self, telegram_id: int) -> str: user = self.ensure_user(telegram_id) if user.referral_code: return user.referral_code for _ in range(8): code = secrets.token_urlsafe(6).replace("-", "").replace("_", "")[:10].lower() with self.pool.connection() as conn: try: conn.execute( """ UPDATE users SET referral_code = %s WHERE telegram_id = %s AND referral_code IS NULL """, (code, telegram_id), ) conn.commit() except Exception: # noqa: BLE001 conn.rollback() continue fresh = self.get_user(telegram_id) if fresh and fresh.referral_code: return fresh.referral_code raise AuthError("Не удалось создать реферальный код") def get_user_by_referral_code(self, code: str) -> BotUser | None: raw = (code or "").strip().lower() if not raw: return None with self.pool.connection() as conn: row = conn.execute( "SELECT * FROM users WHERE lower(referral_code) = %s", (raw,), ).fetchone() return self._to_user(row) if row else None def attach_referrer(self, telegram_id: int, referrer_code: str) -> bool: """Привязать реферера только новым пользователям без referred_by.""" referrer = self.get_user_by_referral_code(referrer_code) if not referrer or referrer.telegram_id == telegram_id: return False with self.pool.connection() as conn: row = conn.execute( """ UPDATE users SET referred_by = %s WHERE telegram_id = %s AND referred_by IS NULL AND created_at > NOW() - INTERVAL '7 days' RETURNING telegram_id """, (referrer.telegram_id, telegram_id), ).fetchone() conn.commit() return row is not None def grant_referral_rewards_on_first_purchase( self, referee_id: int, *, referrer_bonus: float, referee_bonus: float, ) -> None: user = self.get_user(referee_id) if not user or not user.referred_by: return referrer_id = int(user.referred_by) with self.pool.connection() as conn: exists = conn.execute( """ SELECT 1 FROM referral_rewards WHERE referee_id = %s AND kind = 'first_purchase' """, (referee_id,), ).fetchone() if exists: return try: conn.execute( """ INSERT INTO referral_rewards (referrer_id, referee_id, amount, kind) VALUES (%s, %s, %s, 'first_purchase') """, (referrer_id, referee_id, float(referrer_bonus)), ) except Exception: # noqa: BLE001 conn.rollback() return conn.commit() if referrer_bonus > 0: self.add_balance( referrer_id, referrer_bonus, kind="referral_bonus", meta=f"ref:{referee_id}", ) if referee_bonus > 0: self.add_balance( referee_id, referee_bonus, kind="referral_welcome", meta=f"from:{referrer_id}", ) def mark_trial_used(self, telegram_id: int) -> None: with self.pool.connection() as conn: conn.execute( """ UPDATE users SET trial_used_at = %s WHERE telegram_id = %s AND trial_used_at IS NULL """, (_utc_now(), telegram_id), ) conn.commit() def create_promo_code( self, *, code: str, kind: str, value: float, max_uses: int | None = None, tariff_id: str | None = None, expires_at: datetime | None = None, note: str | None = None, ) -> dict[str, Any]: code_norm = (code or "").strip().upper() if len(code_norm) < 3: raise AuthError("Код слишком короткий") kind_n = (kind or "").strip().lower() if kind_n not in {"percent", "fixed", "days"}: raise AuthError("kind: percent | fixed | days") with self.pool.connection() as conn: row = conn.execute( """ INSERT INTO promo_codes ( code, kind, value, max_uses, tariff_id, expires_at, note ) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING * """, ( code_norm, kind_n, float(value), max_uses, tariff_id or None, expires_at, (note or "")[:200] or None, ), ).fetchone() conn.commit() assert row is not None return dict(row) def list_promo_codes(self, *, limit: int = 100) -> list[dict[str, Any]]: with self.pool.connection() as conn: rows = conn.execute( """ SELECT * FROM promo_codes ORDER BY id DESC LIMIT %s """, (max(1, min(limit, 200)),), ).fetchall() return [dict(r) for r in rows] def set_promo_active(self, promo_id: int, active: bool) -> None: with self.pool.connection() as conn: conn.execute( "UPDATE promo_codes SET active = %s WHERE id = %s", (bool(active), int(promo_id)), ) conn.commit() def apply_promo_to_price( self, *, code: str, telegram_id: int, tariff_id: str, price: float, days: int, ) -> tuple[float, int, dict[str, Any]]: """Вернуть (new_price, bonus_days, promo_row).""" code_norm = (code or "").strip().upper() if not code_norm: raise AuthError("Укажите промокод") now = _utc_now() with self.pool.connection() as conn: promo = conn.execute( "SELECT * FROM promo_codes WHERE lower(code) = lower(%s)", (code_norm,), ).fetchone() if not promo or not promo.get("active"): raise AuthError("Промокод не найден или выключен") if promo.get("expires_at") and promo["expires_at"] <= now: raise AuthError("Промокод истёк") if promo.get("tariff_id") and str(promo["tariff_id"]) != str(tariff_id): raise AuthError("Промокод не для этого тарифа") max_uses = promo.get("max_uses") if max_uses is not None and int(promo.get("used_count") or 0) >= int(max_uses): raise AuthError("Лимит использований промокода исчерпан") used = conn.execute( """ SELECT 1 FROM promo_redemptions WHERE promo_id = %s AND telegram_id = %s """, (promo["id"], telegram_id), ).fetchone() if used: raise AuthError("Вы уже использовали этот промокод") kind = str(promo["kind"]) value = float(promo["value"]) new_price = float(price) bonus_days = 0 if kind == "percent": new_price = max(0.0, price * (1.0 - min(100.0, value) / 100.0)) elif kind == "fixed": new_price = max(0.0, price - value) elif kind == "days": bonus_days = max(0, int(value)) return round(new_price, 2), bonus_days, dict(promo) def redeem_promo(self, *, promo_id: int, telegram_id: int, amount_saved: float) -> None: with self.pool.connection() as conn: conn.execute( """ INSERT INTO promo_redemptions (promo_id, telegram_id, amount_saved) VALUES (%s, %s, %s) ON CONFLICT (promo_id, telegram_id) DO NOTHING """, (int(promo_id), telegram_id, float(amount_saved)), ) conn.execute( """ UPDATE promo_codes SET used_count = used_count + 1 WHERE id = %s """, (int(promo_id),), ) conn.commit() def create_password_reset_token(self, telegram_id: int) -> str: token = secrets.token_urlsafe(32) expires = _utc_now() + timedelta(hours=1) with self.pool.connection() as conn: conn.execute( "DELETE FROM password_reset_tokens WHERE telegram_id = %s OR expires_at < %s", (telegram_id, _utc_now()), ) conn.execute( """ INSERT INTO password_reset_tokens (token, telegram_id, expires_at) VALUES (%s, %s, %s) """, (token, telegram_id, expires), ) conn.commit() return token def consume_password_reset_token(self, token: str) -> int: token = (token or "").strip() if not token: raise AuthError("Некорректный токен") with self.pool.connection() as conn: row = conn.execute( """ UPDATE password_reset_tokens SET used_at = %s WHERE token = %s AND used_at IS NULL AND expires_at > %s RETURNING telegram_id """, (_utc_now(), token, _utc_now()), ).fetchone() conn.commit() if not row: raise AuthError("Ссылка недействительна или истекла") return int(row["telegram_id"]) def set_password_hash(self, telegram_id: int, password: str) -> None: if len(password) < 6: raise AuthError("Пароль минимум 6 символов") pwd_hash = hash_password(password) with self.pool.connection() as conn: conn.execute( "UPDATE users SET password_hash = %s WHERE telegram_id = %s", (pwd_hash, telegram_id), ) conn.commit() def get_user_by_email(self, email: str) -> BotUser | None: raw = (email or "").strip().lower() if not raw: return None with self.pool.connection() as conn: row = conn.execute( "SELECT * FROM users WHERE lower(email) = %s", (raw,), ).fetchone() return self._to_user(row) if row else None def log_payment_event( self, *, provider: str, event_type: str, telegram_id: int | None = None, amount: float | None = None, invoice_ref: str | None = None, status: str = "ok", meta: str | None = None, ) -> None: with self.pool.connection() as conn: conn.execute( """ INSERT INTO payment_events ( provider, event_type, telegram_id, amount, invoice_ref, status, meta ) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( (provider or "")[:40], (event_type or "")[:60], telegram_id, amount, (invoice_ref or "")[:120] or None, (status or "ok")[:40], (meta or "")[:500] or None, ), ) conn.commit() def list_payment_events(self, *, limit: int = 50) -> list[dict[str, Any]]: with self.pool.connection() as conn: rows = conn.execute( """ SELECT * FROM payment_events ORDER BY id DESC LIMIT %s """, (max(1, min(limit, 200)),), ).fetchall() return [dict(r) for r in rows] @staticmethod def _ensure_user_conn(conn, telegram_id: int) -> None: row = conn.execute( "SELECT 1 FROM users WHERE telegram_id = %s", (telegram_id,), ).fetchone() if not row: conn.execute( """ INSERT INTO users (telegram_id, username, full_name, balance, created_at) VALUES (%s, NULL, NULL, 0, %s) ON CONFLICT (telegram_id) DO NOTHING """, (telegram_id, _utc_now()), )