rmi-backend/app/telegram_bot/db.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

412 lines
14 KiB
Python

#!/usr/bin/env python3
"""
CryptoRugMunch Bot - Database Layer
====================================
SQLite-based user tracking, subscriptions, scan counts, watchlists.
"""
import contextlib
import os
import sqlite3
import time
from datetime import UTC, datetime, timedelta
DB_PATH = os.getenv("BOT_DB_PATH", "/root/backend/data/bot_users.db")
def get_db() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db():
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY,
username TEXT,
first_name TEXT,
tier TEXT DEFAULT 'free',
tier_expires INTEGER DEFAULT 0,
total_scans INTEGER DEFAULT 0,
total_ai_msgs INTEGER DEFAULT 0,
created_at INTEGER,
last_active INTEGER,
is_banned INTEGER DEFAULT 0,
referral_code TEXT UNIQUE,
referred_by TEXT,
stars_balance INTEGER DEFAULT 0,
bonus_scans INTEGER DEFAULT 0,
bonus_ai_msgs INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS weekly_usage (
user_id INTEGER,
week_start TEXT,
scans INTEGER DEFAULT 0,
ai_msgs INTEGER DEFAULT 0,
PRIMARY KEY (user_id, week_start)
);
CREATE TABLE IF NOT EXISTS watchlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
token_address TEXT,
chain TEXT,
symbol TEXT,
alert_price REAL,
alert_direction TEXT,
created_at INTEGER,
is_active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS scan_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
token_address TEXT,
chain TEXT,
scan_type TEXT,
risk_score REAL,
created_at INTEGER
);
CREATE TABLE IF NOT EXISTS star_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
amount INTEGER,
action TEXT,
telegram_payment_id TEXT,
created_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_weekly_user ON weekly_usage(user_id, week_start);
CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlists(user_id, is_active);
CREATE INDEX IF NOT EXISTS idx_scan_user ON scan_history(user_id, created_at);
""")
# Migration: add bonus columns if they don't exist (SQLite safe)
with contextlib.suppress(sqlite3.OperationalError):
conn.execute("ALTER TABLE users ADD COLUMN bonus_scans INTEGER DEFAULT 0")
with contextlib.suppress(sqlite3.OperationalError):
conn.execute("ALTER TABLE users ADD COLUMN bonus_ai_msgs INTEGER DEFAULT 0")
conn.commit()
conn.close()
# ── User Management ──
def get_or_create_user(user_id: int, username: str | None = None, first_name: str | None = None) -> dict:
conn = get_db()
row = conn.execute("SELECT * FROM users WHERE user_id = ?", (user_id,)).fetchone()
if row:
conn.execute(
"UPDATE users SET last_active = ?, username = COALESCE(?, username), first_name = COALESCE(?, first_name) WHERE user_id = ?",
(int(time.time()), username, first_name, user_id),
)
conn.commit()
result = dict(row)
else:
now = int(time.time())
import secrets
ref_code = secrets.token_hex(4).upper()
conn.execute(
"INSERT INTO users (user_id, username, first_name, created_at, last_active, referral_code) VALUES (?, ?, ?, ?, ?, ?)",
(user_id, username, first_name, now, now, ref_code),
)
conn.commit()
row = conn.execute("SELECT * FROM users WHERE user_id = ?", (user_id,)).fetchone()
result = dict(row)
conn.close()
return result
def get_user_tier(user_id: int) -> str:
conn = get_db()
row = conn.execute("SELECT tier, tier_expires FROM users WHERE user_id = ?", (user_id,)).fetchone()
conn.close()
if not row:
return "free"
if row["tier"] != "free" and row["tier_expires"] > 0 and time.time() > row["tier_expires"]:
conn = get_db()
conn.execute("UPDATE users SET tier = 'free', tier_expires = 0 WHERE user_id = ?", (user_id,))
conn.commit()
conn.close()
return "free"
return row["tier"]
def set_user_tier(user_id: int, tier: str, days: int = 30):
expires = int(time.time() + timedelta(days=days).total_seconds()) if tier != "free" else 0
conn = get_db()
conn.execute("UPDATE users SET tier = ?, tier_expires = ? WHERE user_id = ?", (tier, expires, user_id))
conn.commit()
conn.close()
# ── Weekly Usage ──
def _current_week_start() -> str:
"""Return Monday of the current UTC week as YYYY-MM-DD."""
now = datetime.now(UTC)
monday = now - timedelta(days=now.weekday())
return monday.strftime("%Y-%m-%d")
def get_weekly_usage(user_id: int) -> tuple[int, int]:
week = _current_week_start()
conn = get_db()
row = conn.execute(
"SELECT scans, ai_msgs FROM weekly_usage WHERE user_id = ? AND week_start = ?",
(user_id, week),
).fetchone()
conn.close()
return (row["scans"], row["ai_msgs"]) if row else (0, 0)
def increment_usage(user_id: int, scan: bool = False, ai_msg: bool = False):
"""Consume 1 scan or AI msg. Tries weekly allotment first, then bonus balance."""
week = _current_week_start()
conn = get_db()
if scan:
# Check if weekly allotment has room
row = conn.execute(
"SELECT scans FROM weekly_usage WHERE user_id = ? AND week_start = ?", (user_id, week)
).fetchone()
user = conn.execute("SELECT bonus_scans FROM users WHERE user_id = ?", (user_id,)).fetchone()
from config import TIERS
tier = get_user_tier(user_id)
limit = TIERS.get(tier, TIERS["free"]).get("scans_per_week", 25)
current = row["scans"] if row else 0
if current < limit:
# Use weekly allotment
conn.execute(
"""
INSERT INTO weekly_usage (user_id, week_start, scans, ai_msgs)
VALUES (?, ?, 1, 0)
ON CONFLICT(user_id, week_start) DO UPDATE SET scans = scans + 1
""",
(user_id, week),
)
elif user and user["bonus_scans"] > 0:
# Use bonus balance
conn.execute("UPDATE users SET bonus_scans = bonus_scans - 1 WHERE user_id = ?", (user_id,))
else:
conn.close()
return False # No quota available
conn.execute("UPDATE users SET total_scans = total_scans + 1 WHERE user_id = ?", (user_id,))
if ai_msg:
row = conn.execute(
"SELECT ai_msgs FROM weekly_usage WHERE user_id = ? AND week_start = ?", (user_id, week)
).fetchone()
user = conn.execute("SELECT bonus_ai_msgs FROM users WHERE user_id = ?", (user_id,)).fetchone()
from config import TIERS
tier = get_user_tier(user_id)
limit = TIERS.get(tier, TIERS["free"]).get("ai_msgs_per_week", 15)
current = row["ai_msgs"] if row else 0
if current < limit:
conn.execute(
"""
INSERT INTO weekly_usage (user_id, week_start, scans, ai_msgs)
VALUES (?, ?, 0, 1)
ON CONFLICT(user_id, week_start) DO UPDATE SET ai_msgs = ai_msgs + 1
""",
(user_id, week),
)
elif user and user["bonus_ai_msgs"] > 0:
conn.execute("UPDATE users SET bonus_ai_msgs = bonus_ai_msgs - 1 WHERE user_id = ?", (user_id,))
else:
conn.close()
return False
conn.execute("UPDATE users SET total_ai_msgs = total_ai_msgs + 1 WHERE user_id = ?", (user_id,))
conn.commit()
conn.close()
return True
def check_rate_limit(user_id: int, action: str = "scan") -> tuple[bool, int, int]:
"""Returns (allowed, current_used, max_allowed).
'allowed' is True if weekly allotment OR bonus balance has room."""
from config import TIERS
tier = get_user_tier(user_id)
tier_config = TIERS.get(tier, TIERS["free"])
scans_used, ai_used = get_weekly_usage(user_id)
conn = get_db()
user = conn.execute("SELECT bonus_scans, bonus_ai_msgs FROM users WHERE user_id = ?", (user_id,)).fetchone()
conn.close()
bonus_s = user["bonus_scans"] if user else 0
bonus_a = user["bonus_ai_msgs"] if user else 0
if action == "scan":
limit = tier_config.get("scans_per_week", 25)
current = scans_used
bonus = bonus_s
else:
limit = tier_config.get("ai_msgs_per_week", 15)
current = ai_used
bonus = bonus_a
# Allowed if weekly allotment has room OR bonus balance > 0
allowed = (current < limit) or (bonus > 0)
return allowed, current, limit
# ── Watchlist ──
def add_watchlist(
user_id: int,
token_address: str,
chain: str,
symbol: str | None = None,
alert_price: float | None = None,
alert_dir: str | None = None,
) -> bool:
tier = get_user_tier(user_id)
max_items = {"free": 0, "scout": 10, "hunter": 50, "pro": -1}.get(tier, 0)
if max_items == 0:
return False
conn = get_db()
count = conn.execute(
"SELECT COUNT(*) as c FROM watchlists WHERE user_id = ? AND is_active = 1", (user_id,)
).fetchone()["c"]
if max_items != -1 and count >= max_items:
conn.close()
return False
conn.execute(
"INSERT INTO watchlists (user_id, token_address, chain, symbol, alert_price, alert_direction, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(user_id, token_address, chain, symbol, alert_price, alert_dir, int(time.time())),
)
conn.commit()
conn.close()
return True
def get_watchlist(user_id: int) -> list[dict]:
conn = get_db()
rows = conn.execute(
"SELECT * FROM watchlists WHERE user_id = ? AND is_active = 1 ORDER BY created_at DESC",
(user_id,),
).fetchall()
conn.close()
return [dict(r) for r in rows]
def remove_watchlist(user_id: int, token_address: str) -> bool:
conn = get_db()
cursor = conn.execute(
"UPDATE watchlists SET is_active = 0 WHERE user_id = ? AND token_address = ?",
(user_id, token_address),
)
conn.commit()
result = cursor.rowcount > 0
conn.close()
return result
# ── Scan History ──
def log_scan(user_id: int, token_address: str, chain: str, scan_type: str, risk_score: float):
conn = get_db()
conn.execute(
"INSERT INTO scan_history (user_id, token_address, chain, scan_type, risk_score, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(user_id, token_address, chain, scan_type, risk_score, int(time.time())),
)
conn.commit()
conn.close()
# ── Stars Balance ──
def add_stars(user_id: int, amount: int, action: str, payment_id: str | None = None):
conn = get_db()
conn.execute("UPDATE users SET stars_balance = stars_balance + ? WHERE user_id = ?", (amount, user_id))
conn.execute(
"INSERT INTO star_transactions (user_id, amount, action, telegram_payment_id, created_at) VALUES (?, ?, ?, ?, ?)",
(user_id, amount, action, payment_id, int(time.time())),
)
conn.commit()
conn.close()
def spend_stars(user_id: int, amount: int, action: str) -> bool:
conn = get_db()
row = conn.execute("SELECT stars_balance FROM users WHERE user_id = ?", (user_id,)).fetchone()
if not row or row["stars_balance"] < amount:
conn.close()
return False
conn.execute("UPDATE users SET stars_balance = stars_balance - ? WHERE user_id = ?", (amount, user_id))
conn.execute(
"INSERT INTO star_transactions (user_id, amount, action, created_at) VALUES (?, ?, ?, ?)",
(user_id, -amount, action, int(time.time())),
)
conn.commit()
conn.close()
return True
def apply_top_up(user_id: int, pack_type: str, amount: int, stars_cost: int, payment_id: str | None = None) -> bool:
"""Apply a top-up purchase: deduct stars, credit bonus balance."""
conn = get_db()
row = conn.execute("SELECT stars_balance FROM users WHERE user_id = ?", (user_id,)).fetchone()
if not row or row["stars_balance"] < stars_cost:
conn.close()
return False
# Deduct stars
conn.execute(
"UPDATE users SET stars_balance = stars_balance - ? WHERE user_id = ?",
(stars_cost, user_id),
)
# Credit bonus
if pack_type == "scans":
conn.execute("UPDATE users SET bonus_scans = bonus_scans + ? WHERE user_id = ?", (amount, user_id))
elif pack_type == "ai_msgs":
conn.execute(
"UPDATE users SET bonus_ai_msgs = bonus_ai_msgs + ? WHERE user_id = ?",
(amount, user_id),
)
# Log transaction
conn.execute(
"INSERT INTO star_transactions (user_id, amount, action, telegram_payment_id, created_at) VALUES (?, ?, ?, ?, ?)",
(user_id, -stars_cost, f"top_up_{pack_type}_{amount}", payment_id, int(time.time())),
)
conn.commit()
conn.close()
return True
# ── Stats ──
def get_user_stats(user_id: int) -> dict:
conn = get_db()
user = conn.execute("SELECT * FROM users WHERE user_id = ?", (user_id,)).fetchone()
watchlist_count = conn.execute(
"SELECT COUNT(*) as c FROM watchlists WHERE user_id = ? AND is_active = 1", (user_id,)
).fetchone()["c"]
conn.close()
if not user:
return {}
scans_used, ai_used = get_weekly_usage(user_id)
return {
**dict(user),
"watchlist_count": watchlist_count,
"scans_this_week": scans_used,
"ai_msgs_this_week": ai_used,
}
# Init on import
init_db()