Files
PolyWeather/src/database/db_manager.py
T
2026-05-29 17:22:33 +08:00

2566 lines
95 KiB
Python

import sqlite3
import os
import hashlib
import json
import secrets
import threading
import time
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, Set
import requests
from loguru import logger
class DBManager:
_init_lock = threading.Lock()
_initialized_paths: Set[str] = set()
_points_sync_lock = threading.Lock()
_points_sync_cache: Dict[str, Dict[str, Any]] = {}
_profile_sync_lock = threading.Lock()
_profile_sync_cache: Dict[str, Dict[str, Any]] = {}
def __init__(self, db_path: Optional[str] = None):
self.db_path = self._resolve_db_path(db_path)
self._ensure_initialized()
def _resolve_db_path(self, db_path: Optional[str]) -> str:
raw = (db_path or os.getenv("POLYWEATHER_DB_PATH") or "").strip()
if not raw:
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(project_root, "data", "polyweather.db")
return raw
def _get_connection(self):
conn = sqlite3.connect(self.db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def _init_cache_key(self) -> str:
return os.path.abspath(self.db_path)
def _ensure_initialized(self) -> None:
cache_key = self._init_cache_key()
with self._init_lock:
if cache_key in self._initialized_paths:
return
self._init_db()
self._initialized_paths.add(cache_key)
def _supabase_profiles_endpoint(self) -> str:
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
if not supabase_url:
return ""
return f"{supabase_url}/rest/v1/profiles"
def _supabase_service_headers(self) -> Dict[str, str]:
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
if not service_role_key:
return {}
return {
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
"Accept": "application/json",
"Content-Type": "application/json",
"Prefer": "return=minimal",
}
def _supabase_admin_users_endpoint(self) -> str:
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
if not supabase_url:
return ""
return f"{supabase_url}/auth/v1/admin/users"
def _profile_sync_min_interval_sec(self) -> float:
raw = str(
os.getenv("POLYWEATHER_SUPABASE_PROFILE_SYNC_MIN_INTERVAL_SEC", "3600")
or ""
).strip()
try:
return max(0.0, float(raw))
except ValueError:
return 3600.0
def _profile_sync_cache_key(self, supabase_user_id: str) -> str:
endpoint = self._supabase_profiles_endpoint()
return f"{endpoint}:{str(supabase_user_id or '').strip().lower()}"
def _should_skip_profile_sync(
self,
*,
supabase_user_id: str,
telegram_id: Optional[int],
telegram_username: Optional[str],
force: bool = False,
) -> bool:
if force:
return False
min_interval = self._profile_sync_min_interval_sec()
if min_interval <= 0:
return False
payload_key = {
"telegram_id": int(telegram_id) if telegram_id is not None else None,
"telegram_username": str(telegram_username or "").strip() or None,
}
cache_key = self._profile_sync_cache_key(supabase_user_id)
now_ts = time.monotonic()
with self._profile_sync_lock:
cached = self._profile_sync_cache.get(cache_key)
if not isinstance(cached, dict):
return False
cached_payload = cached.get("payload")
cached_ts = float(cached.get("ts") or 0.0)
return cached_payload == payload_key and now_ts - cached_ts < min_interval
def _remember_profile_sync(
self,
*,
supabase_user_id: str,
telegram_id: Optional[int],
telegram_username: Optional[str],
) -> None:
cache_key = self._profile_sync_cache_key(supabase_user_id)
payload_key = {
"telegram_id": int(telegram_id) if telegram_id is not None else None,
"telegram_username": str(telegram_username or "").strip() or None,
}
with self._profile_sync_lock:
self._profile_sync_cache[cache_key] = {
"payload": payload_key,
"ts": time.monotonic(),
}
if len(self._profile_sync_cache) > 4096:
oldest_key = min(
self._profile_sync_cache,
key=lambda key: float(
self._profile_sync_cache[key].get("ts") or 0.0
),
)
self._profile_sync_cache.pop(oldest_key, None)
def _points_sync_cache_key(self, telegram_id: int) -> str:
return f"{os.path.abspath(self.db_path)}:{int(telegram_id)}"
def _points_sync_min_interval_sec(self) -> float:
raw = str(
os.getenv("POLYWEATHER_SUPABASE_POINTS_SYNC_MIN_INTERVAL_SEC", "60")
or ""
).strip()
try:
return max(0.0, float(raw))
except Exception:
return 60.0
def _should_skip_points_metadata_sync(
self,
*,
telegram_id: int,
points: int,
force: bool,
) -> bool:
if force:
return False
cache_key = self._points_sync_cache_key(telegram_id)
now_ts = time.monotonic()
min_interval = self._points_sync_min_interval_sec()
with self._points_sync_lock:
cached = self._points_sync_cache.get(cache_key)
if not cached:
return False
cached_points = int(cached.get("points") or 0)
cached_ts = float(cached.get("ts") or 0.0)
if cached_points == int(points):
return True
return min_interval > 0 and (now_ts - cached_ts) < min_interval
def _remember_points_metadata_sync(
self,
*,
telegram_id: int,
points: int,
) -> None:
cache_key = self._points_sync_cache_key(telegram_id)
with self._points_sync_lock:
self._points_sync_cache[cache_key] = {
"points": int(points),
"ts": time.monotonic(),
}
if len(self._points_sync_cache) > 4096:
oldest_key = min(
self._points_sync_cache,
key=lambda key: float(
self._points_sync_cache[key].get("ts") or 0.0
),
)
self._points_sync_cache.pop(oldest_key, None)
def _sync_points_to_supabase_user_metadata(
self,
telegram_id: int,
*,
force: bool = False,
) -> bool:
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
if not supabase_url:
return False
headers = self._supabase_service_headers()
if not headers:
return False
endpoint = self._supabase_admin_users_endpoint()
if not endpoint:
return False
supabase_user_id = None
points = 0
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT supabase_user_id FROM supabase_bindings WHERE telegram_id = ? LIMIT 1",
(int(telegram_id),),
).fetchone()
if row and row["supabase_user_id"]:
supabase_user_id = str(row["supabase_user_id"]).strip()
if not supabase_user_id:
row = conn.execute(
"SELECT supabase_user_id FROM users WHERE telegram_id = ? LIMIT 1",
(int(telegram_id),),
).fetchone()
if row and row["supabase_user_id"]:
supabase_user_id = str(row["supabase_user_id"]).strip()
if not supabase_user_id:
return False
pts_row = conn.execute(
"SELECT points FROM users WHERE telegram_id = ? LIMIT 1",
(int(telegram_id),),
).fetchone()
if pts_row:
points = max(0, int(pts_row["points"] or 0))
if self._should_skip_points_metadata_sync(
telegram_id=int(telegram_id),
points=points,
force=force,
):
return False
try:
resp = requests.patch(
f"{endpoint}/{supabase_user_id}",
json={"user_metadata": {"points": points}},
headers={**headers, "Prefer": "return=minimal"},
timeout=8,
)
if resp.status_code not in (200, 204):
logger.warning(
"supabase points sync failed tg={} suid={} status={} body={}",
telegram_id,
supabase_user_id,
resp.status_code,
(resp.text or "")[:200],
)
return False
self._remember_points_metadata_sync(
telegram_id=int(telegram_id),
points=points,
)
return True
except Exception as exc:
logger.warning(
"supabase points sync error tg={} suid={}: {}",
telegram_id,
supabase_user_id,
exc,
)
return False
def _sync_supabase_profile_telegram_fields(
self,
*,
supabase_user_id: str,
telegram_id: Optional[int],
telegram_username: Optional[str],
force: bool = False,
) -> bool:
normalized_uid = str(supabase_user_id or "").strip().lower()
endpoint = self._supabase_profiles_endpoint()
headers = self._supabase_service_headers()
if not normalized_uid or not endpoint or not headers:
return False
if self._should_skip_profile_sync(
supabase_user_id=normalized_uid,
telegram_id=telegram_id,
telegram_username=telegram_username,
force=force,
):
return False
payload = {
"telegram_user_id": int(telegram_id) if telegram_id is not None else None,
"telegram_username": str(telegram_username or "").strip() or None,
"updated_at": datetime.now().isoformat(),
}
try:
response = requests.patch(
endpoint,
params={"id": f"eq.{normalized_uid}"},
json=payload,
headers=headers,
timeout=8,
)
if response.status_code not in {200, 204}:
logger.warning(
"supabase profiles telegram sync failed user_id={} status={} body={}",
normalized_uid,
response.status_code,
(response.text or "")[:240],
)
return False
self._remember_profile_sync(
supabase_user_id=normalized_uid,
telegram_id=telegram_id,
telegram_username=telegram_username,
)
return True
except Exception as exc:
logger.warning(
"supabase profiles telegram sync error user_id={}: {}",
normalized_uid,
exc,
)
return False
def _sync_bound_supabase_profiles_for_telegram(
self,
*,
telegram_id: int,
telegram_username: Optional[str],
) -> None:
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT supabase_user_id
FROM supabase_bindings
WHERE telegram_id = ?
""",
(int(telegram_id),),
).fetchall()
for row in rows:
user_id = str((row["supabase_user_id"] if row else "") or "").strip().lower()
if not user_id:
continue
self._sync_supabase_profile_telegram_fields(
supabase_user_id=user_id,
telegram_id=telegram_id,
telegram_username=telegram_username,
)
def _init_db(self):
"""Create tables if they don't exist."""
# Ensure directory exists
db_dir = os.path.dirname(self.db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
with self._get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
telegram_id INTEGER PRIMARY KEY,
username TEXT,
is_web_premium BOOLEAN DEFAULT 0,
web_expiry TIMESTAMP,
is_group_premium BOOLEAN DEFAULT 0,
group_expiry TIMESTAMP,
points INTEGER DEFAULT 0,
daily_points INTEGER DEFAULT 0,
daily_points_date TEXT,
message_count INTEGER DEFAULT 0,
last_message_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS activity_fingerprints (
telegram_id INTEGER NOT NULL,
activity_date TEXT NOT NULL,
fingerprint TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (telegram_id, activity_date, fingerprint)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS weekly_points_archive (
telegram_id INTEGER NOT NULL,
week_key TEXT NOT NULL,
points INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (telegram_id, week_key)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS weekly_reward_runs (
week_key TEXT PRIMARY KEY,
settled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
winners_count INTEGER DEFAULT 0,
summary_json TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS weekly_reward_payouts (
week_key TEXT NOT NULL,
telegram_id INTEGER NOT NULL,
rank INTEGER DEFAULT 0,
username TEXT,
points_bonus INTEGER DEFAULT 0,
pro_days INTEGER DEFAULT 0,
supabase_user_id TEXT,
pro_granted INTEGER DEFAULT 0,
pro_error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (week_key, telegram_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS payment_runtime_state (
state_key TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS city_summary_cache (
city TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_at_ts REAL NOT NULL,
version TEXT,
source_fingerprint TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS city_panel_cache (
city TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_at_ts REAL NOT NULL,
version TEXT,
source_fingerprint TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS city_nearby_cache (
city TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_at_ts REAL NOT NULL,
version TEXT,
source_fingerprint TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS city_market_cache (
city TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_at_ts REAL NOT NULL,
version TEXT,
source_fingerprint TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS city_full_cache (
city TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_at_ts REAL NOT NULL,
version TEXT,
source_fingerprint TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS cache_refresh_locks (
cache_key TEXT PRIMARY KEY,
locked_until_ts REAL NOT NULL,
owner TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS payment_audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_payment_audit_events_created_at ON payment_audit_events(created_at DESC)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS observation_patch_events (
revision INTEGER PRIMARY KEY AUTOINCREMENT,
schema_type TEXT NOT NULL,
schema_version INTEGER NOT NULL,
city TEXT NOT NULL,
source TEXT NOT NULL,
obs_time TEXT,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_observation_patch_events_city_revision
ON observation_patch_events(city, revision)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_observation_patch_events_created_at
ON observation_patch_events(created_at)
"""
)
conn.execute("""
CREATE TABLE IF NOT EXISTS app_analytics_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
user_id TEXT,
client_id TEXT,
session_id TEXT,
payload_json TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_app_analytics_events_created_at ON app_analytics_events(created_at DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_app_analytics_events_type_created_at ON app_analytics_events(event_type, created_at DESC)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS supabase_bindings (
supabase_user_id TEXT PRIMARY KEY,
telegram_id INTEGER NOT NULL,
supabase_email TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_supabase_bindings_telegram_id ON supabase_bindings(telegram_id)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS telegram_bind_tokens (
token TEXT PRIMARY KEY,
telegram_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_telegram_bind_tokens_expires ON telegram_bind_tokens(expires_at)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS web_telegram_bind_tokens (
token TEXT PRIMARY KEY,
supabase_user_id TEXT NOT NULL,
supabase_email TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_web_telegram_bind_tokens_expires ON web_telegram_bind_tokens(expires_at)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS airport_obs_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
icao TEXT NOT NULL,
city TEXT NOT NULL,
temp_c REAL,
wind_kt REAL,
pressure_hpa REAL,
obs_time TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_airport_obs_log_icao_time ON airport_obs_log(icao, created_at DESC)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS runway_obs_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
icao TEXT NOT NULL,
city TEXT NOT NULL,
runway TEXT NOT NULL,
tdz_temp REAL,
mid_temp REAL,
end_temp REAL,
target_runway_max REAL,
wind_dir INTEGER,
wind_speed REAL,
rvr INTEGER,
mor REAL,
humidity REAL,
otime_utc TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_runway_obs_log_icao_otime "
"ON runway_obs_log(icao, otime_utc DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_runway_obs_log_city_time "
"ON runway_obs_log(city, created_at DESC)"
)
self._ensure_column(conn, "users", "daily_points", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_points_date", "TEXT")
self._ensure_column(conn, "users", "weekly_points", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "weekly_points_week", "TEXT")
self._ensure_column(conn, "users", "supabase_user_id", "TEXT")
self._ensure_column(conn, "users", "supabase_email", "TEXT")
self._ensure_column(conn, "users", "welcome_bonus_claimed", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_city_queries", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_deb_queries", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_queries_date", "TEXT")
# Migrate legacy one-to-one binding column into mapping table.
conn.execute(
"""
INSERT OR IGNORE INTO supabase_bindings (
supabase_user_id, telegram_id, supabase_email, created_at, updated_at
)
SELECT
lower(trim(COALESCE(supabase_user_id, ''))),
telegram_id,
COALESCE(supabase_email, ''),
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM users
WHERE trim(COALESCE(supabase_user_id, '')) <> ''
"""
)
conn.commit()
logger.info(f"Database initialized successfully path={self.db_path}")
def _cache_table_name(self, kind: str) -> Optional[str]:
normalized = str(kind or "").strip().lower()
if normalized == "summary":
return "city_summary_cache"
if normalized == "panel":
return "city_panel_cache"
if normalized == "nearby":
return "city_nearby_cache"
if normalized == "market":
return "city_market_cache"
if normalized == "full":
return "city_full_cache"
return None
def get_city_cache(self, kind: str, city: str) -> Optional[Dict[str, Any]]:
table = self._cache_table_name(kind)
normalized_city = str(city or "").strip().lower()
if not table or not normalized_city:
return None
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
f"""
SELECT city, payload_json, updated_at, updated_at_ts, version, source_fingerprint
FROM {table}
WHERE city = ?
LIMIT 1
""",
(normalized_city,),
).fetchone()
if not row:
return None
try:
payload = json.loads(str(row["payload_json"] or "{}"))
except Exception:
return None
if not isinstance(payload, dict):
return None
return {
"city": str(row["city"] or normalized_city),
"payload": payload,
"updated_at": str(row["updated_at"] or ""),
"updated_at_ts": float(row["updated_at_ts"] or 0.0),
"version": str(row["version"] or ""),
"source_fingerprint": str(row["source_fingerprint"] or ""),
}
def set_city_cache(
self,
kind: str,
city: str,
payload: Dict[str, Any],
*,
version: str = "v1",
source_fingerprint: Optional[str] = None,
) -> None:
table = self._cache_table_name(kind)
normalized_city = str(city or "").strip().lower()
if not table or not normalized_city or not isinstance(payload, dict):
return
now = datetime.now().isoformat()
now_ts = datetime.now().timestamp()
with self._get_connection() as conn:
conn.execute(
f"""
INSERT INTO {table} (
city,
payload_json,
updated_at,
updated_at_ts,
version,
source_fingerprint
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(city) DO UPDATE SET
payload_json = excluded.payload_json,
updated_at = excluded.updated_at,
updated_at_ts = excluded.updated_at_ts,
version = excluded.version,
source_fingerprint = excluded.source_fingerprint
""",
(
normalized_city,
json.dumps(payload, ensure_ascii=False),
now,
now_ts,
str(version or "v1"),
str(source_fingerprint or ""),
),
)
conn.commit()
def acquire_cache_refresh_lock(
self,
cache_key: str,
*,
ttl_sec: int = 120,
owner: Optional[str] = None,
) -> Optional[str]:
normalized_key = str(cache_key or "").strip().lower()
if not normalized_key:
return None
lock_owner = str(owner or "").strip() or hashlib.sha1(
f"{normalized_key}:{datetime.now().timestamp()}".encode("utf-8")
).hexdigest()[:12]
now_ts = datetime.now().timestamp()
locked_until_ts = now_ts + max(15, int(ttl_sec or 120))
updated_at = datetime.now().isoformat()
with self._get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO cache_refresh_locks (cache_key, locked_until_ts, owner, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
locked_until_ts = excluded.locked_until_ts,
owner = excluded.owner,
updated_at = excluded.updated_at
WHERE cache_refresh_locks.locked_until_ts < ?
""",
(
normalized_key,
locked_until_ts,
lock_owner,
updated_at,
now_ts,
),
)
conn.commit()
return lock_owner if int(cursor.rowcount or 0) > 0 else None
def release_cache_refresh_lock(self, cache_key: str, owner: str) -> None:
normalized_key = str(cache_key or "").strip().lower()
normalized_owner = str(owner or "").strip()
if not normalized_key or not normalized_owner:
return
with self._get_connection() as conn:
conn.execute(
"""
DELETE FROM cache_refresh_locks
WHERE cache_key = ? AND owner = ?
""",
(normalized_key, normalized_owner),
)
conn.commit()
def get_payment_runtime_state(self, state_key: str) -> Optional[Dict[str, Any]]:
key = str(state_key or "").strip()
if not key:
return None
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT payload_json
FROM payment_runtime_state
WHERE state_key = ?
LIMIT 1
""",
(key,),
).fetchone()
if not row:
return None
try:
payload = json.loads(str(row["payload_json"] or "{}"))
except Exception:
return None
return payload if isinstance(payload, dict) else None
def set_payment_runtime_state(self, state_key: str, payload: Dict[str, Any]) -> None:
key = str(state_key or "").strip()
if not key:
return
body = payload if isinstance(payload, dict) else {}
with self._get_connection() as conn:
conn.execute(
"""
INSERT INTO payment_runtime_state (state_key, payload_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(state_key) DO UPDATE SET
payload_json = excluded.payload_json,
updated_at = excluded.updated_at
""",
(key, json.dumps(body, ensure_ascii=False), datetime.now().isoformat()),
)
conn.commit()
def append_payment_audit_event(self, event_type: str, payload: Dict[str, Any]) -> None:
kind = str(event_type or "").strip().lower()
if not kind:
return
body = payload if isinstance(payload, dict) else {}
with self._get_connection() as conn:
conn.execute(
"""
INSERT INTO payment_audit_events (event_type, payload_json, created_at)
VALUES (?, ?, ?)
""",
(kind, json.dumps(body, ensure_ascii=False), datetime.now().isoformat()),
)
conn.commit()
def append_app_analytics_event(
self,
event_type: str,
payload: Dict[str, Any],
*,
user_id: Optional[str] = None,
client_id: Optional[str] = None,
session_id: Optional[str] = None,
) -> None:
kind = str(event_type or "").strip().lower()
if not kind:
return
body = payload if isinstance(payload, dict) else {}
normalized_user_id = str(user_id or "").strip().lower() or None
normalized_client_id = str(client_id or "").strip() or None
normalized_session_id = str(session_id or "").strip() or None
with self._get_connection() as conn:
conn.execute(
"""
INSERT INTO app_analytics_events (
event_type,
user_id,
client_id,
session_id,
payload_json,
created_at
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
kind,
normalized_user_id,
normalized_client_id,
normalized_session_id,
json.dumps(body, ensure_ascii=False),
datetime.now().isoformat(),
),
)
conn.commit()
def list_app_analytics_events(
self,
*,
limit: int = 200,
event_type: Optional[str] = None,
since_iso: Optional[str] = None,
) -> List[Dict[str, Any]]:
safe_limit = max(1, min(int(limit or 200), 2000))
kind = str(event_type or "").strip().lower()
params: List[Any] = []
clauses: List[str] = []
if kind:
clauses.append("event_type = ?")
params.append(kind)
since_text = str(since_iso or "").strip()
if since_text:
clauses.append("created_at >= ?")
params.append(since_text)
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.append(safe_limit)
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
f"""
SELECT id, event_type, user_id, client_id, session_id, payload_json, created_at
FROM app_analytics_events
{where_sql}
ORDER BY id DESC
LIMIT ?
""",
tuple(params),
).fetchall()
out: List[Dict[str, Any]] = []
for row in rows:
try:
payload = json.loads(str(row["payload_json"] or "{}"))
except Exception:
payload = {}
out.append(
{
"id": int(row["id"]),
"event_type": str(row["event_type"] or ""),
"user_id": str(row["user_id"] or "") or None,
"client_id": str(row["client_id"] or "") or None,
"session_id": str(row["session_id"] or "") or None,
"payload": payload if isinstance(payload, dict) else {},
"created_at": row["created_at"],
}
)
return out
def get_app_analytics_funnel_summary(self, *, days: int = 30) -> Dict[str, Any]:
safe_days = max(1, min(int(days or 30), 365))
since_dt = datetime.now() - timedelta(days=safe_days)
rows = self.list_app_analytics_events(limit=5000, since_iso=since_dt.isoformat())
event_names = [
"signup_completed",
"dashboard_active",
"paywall_feature_clicked",
"paywall_viewed",
"checkout_started",
"checkout_succeeded",
]
summary: Dict[str, Dict[str, Any]] = {
name: {
"total": 0,
"unique_users": 0,
"unique_actors": 0,
}
for name in event_names
}
actor_sets: Dict[str, set[str]] = {name: set() for name in event_names}
user_sets: Dict[str, set[str]] = {name: set() for name in event_names}
for row in rows:
event_type = str(row.get("event_type") or "").strip().lower()
if event_type not in summary:
continue
summary[event_type]["total"] += 1
user_id = str(row.get("user_id") or "").strip().lower()
client_id = str(row.get("client_id") or "").strip()
session_id = str(row.get("session_id") or "").strip()
actor_key = ""
if user_id:
actor_key = f"user:{user_id}"
user_sets[event_type].add(user_id)
elif client_id:
actor_key = f"client:{client_id}"
elif session_id:
actor_key = f"session:{session_id}"
else:
actor_key = f"event:{row.get('id')}"
actor_sets[event_type].add(actor_key)
for name in event_names:
summary[name]["unique_users"] = len(user_sets[name])
summary[name]["unique_actors"] = len(actor_sets[name])
def _rate(numerator_key: str, denominator_key: str) -> Optional[float]:
denominator = int(summary[denominator_key]["unique_actors"] or 0)
numerator = int(summary[numerator_key]["unique_actors"] or 0)
if denominator <= 0:
return None
return round(numerator / denominator, 3)
return {
"window_days": safe_days,
"since": since_dt.isoformat(),
"events": summary,
"rates": {
"login_active_rate": _rate("dashboard_active", "signup_completed"),
"paywall_click_rate": _rate("paywall_feature_clicked", "dashboard_active"),
"paywall_view_rate": _rate("paywall_viewed", "paywall_feature_clicked"),
"checkout_start_rate": _rate("checkout_started", "paywall_viewed"),
"checkout_success_rate": _rate("checkout_succeeded", "checkout_started"),
},
}
def list_payment_audit_events(
self,
limit: int = 50,
event_type: Optional[str] = None,
) -> List[Dict[str, Any]]:
safe_limit = max(1, min(int(limit or 50), 500))
kind = str(event_type or "").strip().lower()
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
if kind:
rows = conn.execute(
"""
SELECT id, event_type, payload_json, created_at
FROM payment_audit_events
WHERE event_type = ?
ORDER BY id DESC
LIMIT ?
""",
(kind, safe_limit),
).fetchall()
else:
rows = conn.execute(
"""
SELECT id, event_type, payload_json, created_at
FROM payment_audit_events
ORDER BY id DESC
LIMIT ?
""",
(safe_limit,),
).fetchall()
out = []
for row in rows:
try:
payload = json.loads(str(row["payload_json"] or "{}"))
except Exception:
payload = {}
out.append(
{
"id": int(row["id"]),
"event_type": str(row["event_type"] or ""),
"payload": payload if isinstance(payload, dict) else {},
"created_at": row["created_at"],
}
)
return out
def mark_payment_audit_event_resolved(
self,
event_id: int,
resolved_by: str,
) -> Optional[Dict[str, Any]]:
safe_id = int(event_id or 0)
actor = str(resolved_by or "").strip().lower()
if safe_id <= 0 or not actor:
return None
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT id, event_type, payload_json, created_at
FROM payment_audit_events
WHERE id = ?
LIMIT 1
""",
(safe_id,),
).fetchone()
if not row:
return None
try:
payload = json.loads(str(row["payload_json"] or "{}"))
except Exception:
payload = {}
if not isinstance(payload, dict):
payload = {}
payload["resolved_at"] = datetime.now().isoformat()
payload["resolved_by"] = actor
conn.execute(
"""
UPDATE payment_audit_events
SET payload_json = ?
WHERE id = ?
""",
(json.dumps(payload, ensure_ascii=False), safe_id),
)
conn.commit()
return {
"id": int(row["id"]),
"event_type": str(row["event_type"] or ""),
"payload": payload,
"created_at": row["created_at"],
}
@staticmethod
def _safe_week_key(value: str) -> str:
text = str(value or "").strip()
if len(text) >= 8 and "-W" in text:
return text[:8]
return ""
def _upsert_weekly_archive(
self,
conn: sqlite3.Connection,
telegram_id: int,
week_key: str,
points: int,
) -> None:
wk = self._safe_week_key(week_key)
if not wk:
return
pts = max(0, int(points or 0))
conn.execute(
"""
INSERT INTO weekly_points_archive (telegram_id, week_key, points, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(telegram_id, week_key) DO UPDATE SET
points = excluded.points,
updated_at = excluded.updated_at
""",
(int(telegram_id), wk, pts, datetime.now().isoformat()),
)
@staticmethod
def _read_bonus_config(env_key: str, fallback: int) -> int:
raw = os.getenv(env_key)
if raw is None or raw.strip() == "":
return fallback
try:
return max(0, int(raw))
except Exception:
return fallback
def _ensure_column(self, conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
existing = {
row[1]
for row in conn.execute(f"PRAGMA table_info({table})").fetchall()
}
if column not in existing:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
def _find_telegram_id_by_supabase_user_id(
self,
conn: sqlite3.Connection,
supabase_user_id: str,
) -> Optional[int]:
key = str(supabase_user_id or "").strip().lower()
if not key:
return None
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT telegram_id
FROM supabase_bindings
WHERE lower(trim(COALESCE(supabase_user_id, ''))) = ?
LIMIT 1
""",
(key,),
).fetchone()
if row:
try:
return int(row["telegram_id"])
except Exception:
return None
# Legacy fallback before supabase_bindings migration.
row = conn.execute(
"""
SELECT telegram_id
FROM users
WHERE lower(trim(COALESCE(supabase_user_id, ''))) = ?
LIMIT 1
""",
(key,),
).fetchone()
if not row:
return None
try:
return int(row["telegram_id"])
except Exception:
return None
def get_user(self, telegram_id: int) -> Optional[Dict[str, Any]]:
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute("SELECT * FROM users WHERE telegram_id = ?", (telegram_id,))
row = cursor.fetchone()
if row:
user = dict(row)
now = datetime.now()
if user['web_expiry']:
expiry = datetime.fromisoformat(user['web_expiry'])
if expiry < now:
user['is_web_premium'] = False
if user['group_expiry']:
expiry = datetime.fromisoformat(user['group_expiry'])
if expiry < now:
user['is_group_premium'] = False
return user
return None
def get_user_by_supabase_user_id(self, supabase_user_id: str) -> Optional[Dict[str, Any]]:
key = str(supabase_user_id or "").strip().lower()
if not key:
return None
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
telegram_id = self._find_telegram_id_by_supabase_user_id(conn, key)
if telegram_id is None:
return None
row = conn.execute(
"""
SELECT *
FROM users
WHERE telegram_id = ?
LIMIT 1
""",
(int(telegram_id),),
).fetchone()
if row:
return dict(row)
return None
def list_supabase_user_ids_for_telegram(self, telegram_id: int) -> List[str]:
"""Return all Supabase accounts currently bound to a Telegram user."""
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT supabase_user_id
FROM supabase_bindings
WHERE telegram_id = ?
ORDER BY updated_at DESC, supabase_user_id ASC
""",
(int(telegram_id),),
).fetchall()
ids = {
str(row["supabase_user_id"] or "").strip().lower()
for row in rows
if str(row["supabase_user_id"] or "").strip()
}
legacy = conn.execute(
"""
SELECT supabase_user_id
FROM users
WHERE telegram_id = ?
LIMIT 1
""",
(int(telegram_id),),
).fetchone()
legacy_id = str((legacy["supabase_user_id"] if legacy else "") or "").strip().lower()
if legacy_id:
ids.add(legacy_id)
return sorted(ids)
def search_users(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
text = str(query or "").strip()
safe_limit = max(1, min(int(limit or 20), 100))
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
if not text:
rows = conn.execute(
"""
SELECT
telegram_id,
username,
points,
daily_points,
daily_points_date,
weekly_points,
weekly_points_week,
message_count,
supabase_user_id,
supabase_email,
created_at,
last_message_at
FROM users
ORDER BY points DESC, message_count DESC, telegram_id ASC
LIMIT ?
""",
(safe_limit,),
).fetchall()
return [dict(row) for row in rows]
rows = conn.execute(
"""
SELECT
telegram_id,
username,
points,
daily_points,
daily_points_date,
weekly_points,
weekly_points_week,
message_count,
supabase_user_id,
supabase_email,
created_at,
last_message_at
FROM users
WHERE
CAST(telegram_id AS TEXT) = ?
OR lower(trim(COALESCE(username, ''))) LIKE ?
OR lower(trim(COALESCE(supabase_email, ''))) LIKE ?
ORDER BY points DESC, message_count DESC, telegram_id ASC
LIMIT ?
""",
(
text,
f"%{text.lower()}%",
f"%{text.lower()}%",
safe_limit,
),
).fetchall()
return [dict(row) for row in rows]
def get_users_by_supabase_user_ids(
self,
supabase_user_ids: List[str],
) -> Dict[str, Dict[str, Any]]:
keys = [
str(item or "").strip().lower()
for item in (supabase_user_ids or [])
if str(item or "").strip()
]
if not keys:
return {}
placeholders = ",".join("?" for _ in keys)
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
f"""
SELECT
lower(trim(COALESCE(supabase_user_id, ''))) AS supabase_user_id,
telegram_id,
username,
supabase_email,
created_at,
points,
weekly_points,
message_count
FROM users
WHERE lower(trim(COALESCE(supabase_user_id, ''))) IN ({placeholders})
""",
tuple(keys),
).fetchall()
return {
str(row["supabase_user_id"] or "").strip().lower(): dict(row)
for row in rows
if str(row["supabase_user_id"] or "").strip()
}
def get_points_by_supabase_user_id(self, supabase_user_id: str) -> int:
user = self.get_user_by_supabase_user_id(supabase_user_id)
if not user:
return 0
try:
return max(0, int(user.get("points") or 0))
except Exception:
return 0
def get_points_by_supabase_email(self, supabase_email: str) -> int:
email = str(supabase_email or "").strip().lower()
if not email:
return 0
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT points
FROM users
WHERE lower(trim(COALESCE(supabase_email, ''))) = ?
LIMIT 1
""",
(email,),
).fetchone()
if not row:
row = conn.execute(
"""
SELECT u.points
FROM users u
JOIN supabase_bindings b ON b.telegram_id = u.telegram_id
WHERE lower(trim(COALESCE(b.supabase_email, ''))) = ?
LIMIT 1
""",
(email,),
).fetchone()
if row:
return max(0, int(row["points"] or 0))
return 0
def grant_points_by_supabase_email(
self,
supabase_email: str,
amount: int,
) -> Dict[str, Any]:
email = str(supabase_email or "").strip().lower()
points = int(amount or 0)
if not email:
return {"ok": False, "reason": "invalid_supabase_email"}
if points <= 0:
return {"ok": False, "reason": "invalid_amount"}
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT telegram_id, username, points, supabase_email
FROM users
WHERE lower(trim(COALESCE(supabase_email, ''))) = ?
LIMIT 1
""",
(email,),
).fetchone()
if not row:
return {"ok": False, "reason": "user_not_found", "supabase_email": email}
telegram_id = int(row["telegram_id"] or 0)
before = int(row["points"] or 0)
after = before + points
conn.execute(
"""
UPDATE users
SET points = ?
WHERE telegram_id = ?
""",
(after, telegram_id),
)
conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
return {
"ok": True,
"telegram_id": telegram_id,
"username": str(row["username"] or ""),
"supabase_email": str(row["supabase_email"] or email),
"points_before": before,
"points_added": points,
"points_after": after,
}
def deduct_points_by_supabase_email(
self,
supabase_email: str,
amount: int,
) -> Dict[str, Any]:
email = str(supabase_email or "").strip().lower()
points = int(amount or 0)
if not email:
return {"ok": False, "reason": "invalid_supabase_email"}
if points <= 0:
return {"ok": False, "reason": "invalid_amount"}
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT telegram_id, username, points, supabase_email
FROM users
WHERE lower(trim(COALESCE(supabase_email, ''))) = ?
LIMIT 1
""",
(email,),
).fetchone()
if not row:
return {"ok": False, "reason": "user_not_found", "supabase_email": email}
telegram_id = int(row["telegram_id"] or 0)
before = int(row["points"] or 0)
if before < points:
return {
"ok": False,
"reason": "insufficient_points",
"points_available": before,
"points_needed": points,
}
after = before - points
conn.execute(
"UPDATE users SET points = ? WHERE telegram_id = ?",
(after, telegram_id),
)
conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
return {
"ok": True,
"telegram_id": telegram_id,
"username": str(row["username"] or ""),
"supabase_email": str(row["supabase_email"] or email),
"points_before": before,
"points_deducted": points,
"points_after": after,
}
def transfer_points_by_email(
self,
from_email: str,
to_email: str,
amount: int,
) -> Dict[str, Any]:
"""Transfer points from one user to another within a single transaction."""
r_from = self.deduct_points_by_supabase_email(from_email, amount)
if not r_from.get("ok"):
return {"ok": False, "reason": f"deduct_failed: {r_from.get('reason')}", "from": r_from}
r_to = self.grant_points_by_supabase_email(to_email, amount)
if not r_to.get("ok"):
# Rollback: grant back to source
self.grant_points_by_supabase_email(from_email, amount)
return {"ok": False, "reason": f"grant_failed: {r_to.get('reason')}", "to": r_to}
return {
"ok": True,
"from": r_from,
"to": r_to,
"amount": amount,
}
def upsert_user(self, telegram_id: int, username: str):
with self._get_connection() as conn:
conn.execute("""
INSERT INTO users (telegram_id, username)
VALUES (?, ?)
ON CONFLICT(telegram_id) DO UPDATE SET
username = excluded.username
""", (telegram_id, username))
conn.commit()
self._sync_bound_supabase_profiles_for_telegram(
telegram_id=int(telegram_id),
telegram_username=username,
)
def bind_supabase_identity(
self,
telegram_id: int,
supabase_user_id: str,
supabase_email: str = "",
) -> Dict[str, Any]:
"""
Bind Supabase account to Telegram account.
Rules:
- One supabase_user_id can only belong to one telegram_id.
- One telegram_id can bind multiple supabase_user_id (shared points/profile).
"""
normalized_uid = str(supabase_user_id or "").strip().lower()
normalized_email = str(supabase_email or "").strip()
if not normalized_uid:
return {"ok": False, "reason": "invalid_supabase_user_id"}
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
# Ensure current telegram user row exists.
conn.execute(
"""
INSERT INTO users (telegram_id, username)
VALUES (?, COALESCE((SELECT username FROM users WHERE telegram_id = ?), ''))
ON CONFLICT(telegram_id) DO NOTHING
""",
(telegram_id, telegram_id),
)
current_row = conn.execute(
"""
SELECT telegram_id, supabase_user_id, supabase_email
FROM users
WHERE telegram_id = ?
LIMIT 1
""",
(telegram_id,),
).fetchone()
current_uid = str(
(current_row["supabase_user_id"] if current_row else "") or ""
).strip().lower()
owner_row = conn.execute(
"""
SELECT telegram_id
FROM supabase_bindings
WHERE lower(trim(COALESCE(supabase_user_id, ''))) = ?
LIMIT 1
""",
(normalized_uid,),
).fetchone()
owner_telegram_id = int(owner_row["telegram_id"]) if owner_row else None
if owner_telegram_id is not None and owner_telegram_id != int(telegram_id):
return {
"ok": False,
"reason": "supabase_already_bound_other",
"owner_telegram_id": owner_telegram_id,
}
conn.execute(
"""
INSERT INTO supabase_bindings (supabase_user_id, telegram_id, supabase_email, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(supabase_user_id) DO UPDATE SET
telegram_id = excluded.telegram_id,
supabase_email = excluded.supabase_email,
updated_at = excluded.updated_at
""",
(normalized_uid, int(telegram_id), normalized_email, datetime.now().isoformat()),
)
if current_uid == normalized_uid:
# Keep idempotent bind behavior while allowing email refresh.
conn.execute(
"""
UPDATE users
SET supabase_email = ?
WHERE telegram_id = ?
""",
(normalized_email, telegram_id),
)
user_row = conn.execute(
"""
SELECT username
FROM users
WHERE telegram_id = ?
LIMIT 1
""",
(telegram_id,),
).fetchone()
conn.commit()
self._sync_supabase_profile_telegram_fields(
supabase_user_id=normalized_uid,
telegram_id=int(telegram_id),
telegram_username=str((user_row["username"] if user_row else "") or "").strip(),
force=True,
)
return {"ok": True, "reason": "already_bound_same"}
conn.execute(
"""
UPDATE users
SET supabase_user_id = ?, supabase_email = ?
WHERE telegram_id = ?
""",
(normalized_uid, normalized_email, telegram_id),
)
user_row = conn.execute(
"""
SELECT username
FROM users
WHERE telegram_id = ?
LIMIT 1
""",
(telegram_id,),
).fetchone()
conn.commit()
self._sync_supabase_profile_telegram_fields(
supabase_user_id=normalized_uid,
telegram_id=int(telegram_id),
telegram_username=str((user_row["username"] if user_row else "") or "").strip(),
force=True,
)
return {"ok": True, "reason": "bound"}
def unbind_supabase_identity(self, telegram_id: int) -> Dict[str, Any]:
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
current = conn.execute(
"""
SELECT supabase_user_id
FROM users
WHERE telegram_id = ?
LIMIT 1
""",
(telegram_id,),
).fetchone()
current_uid = str((current["supabase_user_id"] if current else "") or "").strip()
links = conn.execute(
"""
SELECT supabase_user_id
FROM supabase_bindings
WHERE telegram_id = ?
""",
(int(telegram_id),),
).fetchall()
linked_user_ids = {
str((row["supabase_user_id"] if row else "") or "").strip().lower()
for row in links
}
if current_uid:
linked_user_ids.add(current_uid.lower())
linked_user_ids = {item for item in linked_user_ids if item}
if not current_uid and not linked_user_ids:
return {"ok": True, "reason": "not_bound"}
conn.execute(
"""
DELETE FROM supabase_bindings
WHERE telegram_id = ?
""",
(int(telegram_id),),
)
conn.execute(
"""
UPDATE users
SET supabase_user_id = '', supabase_email = ''
WHERE telegram_id = ?
""",
(telegram_id,),
)
conn.commit()
for user_id in linked_user_ids:
self._sync_supabase_profile_telegram_fields(
supabase_user_id=user_id,
telegram_id=None,
telegram_username=None,
force=True,
)
return {"ok": True, "reason": "unbound", "previous_supabase_user_id": current_uid}
def create_bind_token(self, telegram_id: int, ttl_minutes: int = 10) -> str:
token = secrets.token_urlsafe(16)
now = datetime.now()
expires_at = now + timedelta(minutes=max(1, int(ttl_minutes)))
with self._get_connection() as conn:
conn.execute(
"""
DELETE FROM telegram_bind_tokens
WHERE telegram_id = ? OR expires_at < ?
""",
(int(telegram_id), now.isoformat()),
)
conn.execute(
"""
INSERT INTO telegram_bind_tokens (token, telegram_id, expires_at)
VALUES (?, ?, ?)
""",
(token, int(telegram_id), expires_at.isoformat()),
)
conn.commit()
return token
def consume_bind_token(self, token: str) -> Optional[int]:
token = str(token or "").strip()
if not token:
return None
now = datetime.now()
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT telegram_id, expires_at
FROM telegram_bind_tokens
WHERE token = ?
LIMIT 1
""",
(token,),
).fetchone()
if not row:
return None
try:
expires_at = datetime.fromisoformat(row["expires_at"])
except Exception:
expires_at = now
if now > expires_at:
conn.execute("DELETE FROM telegram_bind_tokens WHERE token = ?", (token,))
conn.commit()
return None
conn.execute("DELETE FROM telegram_bind_tokens WHERE token = ?", (token,))
conn.commit()
return int(row["telegram_id"])
def peek_web_bind_token(self, token: str) -> Optional[Dict[str, str]]:
token = str(token or "").strip()
if not token:
return None
now = datetime.now()
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT supabase_user_id, supabase_email, expires_at
FROM web_telegram_bind_tokens
WHERE token = ?
LIMIT 1
""",
(token,),
).fetchone()
if not row:
return None
try:
expires_at = datetime.fromisoformat(row["expires_at"])
except Exception:
expires_at = now
if now > expires_at:
conn.execute("DELETE FROM web_telegram_bind_tokens WHERE token = ?", (token,))
conn.commit()
return None
return {
"supabase_user_id": str(row["supabase_user_id"] or "").strip().lower(),
"supabase_email": str(row["supabase_email"] or "").strip(),
}
def create_web_bind_token(
self,
supabase_user_id: str,
supabase_email: str = "",
ttl_minutes: int = 10,
) -> str:
normalized_uid = str(supabase_user_id or "").strip().lower()
if not normalized_uid:
raise ValueError("supabase_user_id is required")
token = secrets.token_urlsafe(16)
now = datetime.now()
expires_at = now + timedelta(minutes=max(1, int(ttl_minutes)))
with self._get_connection() as conn:
conn.execute(
"""
DELETE FROM web_telegram_bind_tokens
WHERE supabase_user_id = ? OR expires_at < ?
""",
(normalized_uid, now.isoformat()),
)
conn.execute(
"""
INSERT INTO web_telegram_bind_tokens (
token, supabase_user_id, supabase_email, expires_at
)
VALUES (?, ?, ?, ?)
""",
(token, normalized_uid, str(supabase_email or "").strip(), expires_at.isoformat()),
)
conn.commit()
return token
def consume_web_bind_token(self, token: str) -> Optional[Dict[str, str]]:
token = str(token or "").strip()
if not token:
return None
now = datetime.now()
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT supabase_user_id, supabase_email, expires_at
FROM web_telegram_bind_tokens
WHERE token = ?
LIMIT 1
""",
(token,),
).fetchone()
if not row:
return None
try:
expires_at = datetime.fromisoformat(row["expires_at"])
except Exception:
expires_at = now
conn.execute("DELETE FROM web_telegram_bind_tokens WHERE token = ?", (token,))
conn.commit()
if now > expires_at:
return None
return {
"supabase_user_id": str(row["supabase_user_id"] or "").strip().lower(),
"supabase_email": str(row["supabase_email"] or "").strip(),
}
def add_message_activity(
self,
telegram_id: int,
text: str,
points_to_add: int = 1,
cooldown_sec: int = 30,
daily_cap: int = 20,
min_text_length: int = 4,
) -> Dict[str, Any]:
"""Award points for valid group activity with cooldown and daily cap."""
now = datetime.now()
normalized = "".join((text or "").split()).lower()
if len(normalized) < min_text_length:
return {"awarded": False, "reason": "too_short"}
fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
today_str = now.strftime("%Y-%m-%d")
iso_year, iso_week, _ = now.isocalendar()
week_key = f"{iso_year}-W{iso_week:02d}"
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
# Keep dedupe table bounded.
stale_day = (now - timedelta(days=14)).strftime("%Y-%m-%d")
conn.execute(
"DELETE FROM activity_fingerprints WHERE activity_date < ?",
(stale_day,),
)
cursor = conn.execute(
"""
SELECT points, daily_points, daily_points_date, weekly_points, weekly_points_week, last_message_at,
message_count, welcome_bonus_claimed
FROM users WHERE telegram_id = ?
""",
(telegram_id,),
)
row = cursor.fetchone()
if not row:
return {"awarded": False, "reason": "user_missing"}
duplicated = conn.execute(
"""
SELECT 1
FROM activity_fingerprints
WHERE telegram_id = ? AND activity_date = ? AND fingerprint = ?
LIMIT 1
""",
(telegram_id, today_str, fingerprint),
).fetchone()
if duplicated:
return {"awarded": False, "reason": "duplicate_content"}
last_message_at = row["last_message_at"]
if last_message_at:
last_at = datetime.fromisoformat(last_message_at)
if (now - last_at).total_seconds() < cooldown_sec:
return {"awarded": False, "reason": "cooldown"}
daily_points = int(row["daily_points"] or 0)
daily_points_date = row["daily_points_date"] or ""
if daily_points_date != today_str:
daily_points = 0
# Guard against historical overflow values (legacy bug).
if daily_points > daily_cap:
daily_points = daily_cap
weekly_points = int(row["weekly_points"] or 0)
weekly_points_week = row["weekly_points_week"] or ""
if weekly_points_week != week_key:
if weekly_points_week and weekly_points > 0:
self._upsert_weekly_archive(
conn,
telegram_id=telegram_id,
week_key=weekly_points_week,
points=weekly_points,
)
weekly_points = 0
if daily_points >= daily_cap:
conn.execute(
"""
UPDATE users
SET last_message_at = ?, daily_points = ?, daily_points_date = ?,
weekly_points = ?, weekly_points_week = ?
WHERE telegram_id = ?
""",
(
now.isoformat(),
daily_points,
today_str,
weekly_points,
week_key,
telegram_id,
),
)
self._upsert_weekly_archive(
conn,
telegram_id=telegram_id,
week_key=week_key,
points=weekly_points,
)
conn.commit()
return {
"awarded": False,
"reason": "daily_cap",
"daily_points": daily_points,
"weekly_points": weekly_points,
}
remaining = max(0, daily_cap - daily_points)
points_added = min(max(0, points_to_add), remaining)
if points_added <= 0:
conn.commit()
return {
"awarded": False,
"reason": "daily_cap",
"daily_points": daily_points,
"weekly_points": weekly_points,
}
welcome_bonus = 0
first_message_bonus = 0
is_first_message_of_day = daily_points == 0
is_new_user = int(row["message_count"] or 0) == 0 and not int(row["welcome_bonus_claimed"] or 0)
if is_new_user:
welcome_bonus = self._read_bonus_config("POLYWEATHER_BOT_WELCOME_BONUS", 20)
if is_first_message_of_day:
first_message_bonus = self._read_bonus_config("POLYWEATHER_BOT_FIRST_MESSAGE_BONUS", 2)
total_added = points_added + welcome_bonus + first_message_bonus
conn.execute("""
UPDATE users
SET message_count = message_count + 1,
points = points + ?,
daily_points = ?,
daily_points_date = ?,
weekly_points = ?,
weekly_points_week = ?,
last_message_at = ?,
welcome_bonus_claimed = MAX(welcome_bonus_claimed, ?)
WHERE telegram_id = ?
""", (
total_added,
daily_points + total_added,
today_str,
weekly_points + total_added,
week_key,
now.isoformat(),
1 if welcome_bonus > 0 else 0,
telegram_id,
))
conn.execute(
"""
INSERT OR IGNORE INTO activity_fingerprints
(telegram_id, activity_date, fingerprint)
VALUES (?, ?, ?)
""",
(telegram_id, today_str, fingerprint),
)
self._upsert_weekly_archive(
conn,
telegram_id=telegram_id,
week_key=week_key,
points=weekly_points + total_added,
)
conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id)
return {
"awarded": True,
"reason": "ok",
"points_added": points_added,
"welcome_bonus": welcome_bonus,
"first_message_bonus": first_message_bonus,
"total_added": total_added,
"daily_points": daily_points + total_added,
"weekly_points": weekly_points + total_added,
"weekly_week": week_key,
}
def track_query_usage(self, telegram_id: int, query_type: str) -> Dict[str, Any]:
today_str = datetime.now().strftime("%Y-%m-%d")
column = "daily_city_queries" if query_type == "city" else "daily_deb_queries"
limit = (
self._read_bonus_config("POLYWEATHER_BOT_CITY_DAILY_FREE_LIMIT", 10)
if query_type == "city"
else self._read_bonus_config("POLYWEATHER_BOT_DEB_DAILY_FREE_LIMIT", 10)
)
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
f"SELECT {column}, daily_queries_date FROM users WHERE telegram_id = ?",
(telegram_id,),
).fetchone()
if not row:
return {"allowed": False, "reason": "user_missing", "used": 0, "limit": limit}
date = row["daily_queries_date"] or ""
used = int(row[column] or 0) if date == today_str else 0
if used >= limit:
return {"allowed": False, "reason": "daily_limit", "used": used, "limit": limit}
new_used = used + 1
conn.execute(
f"""
UPDATE users
SET {column} = ?, daily_queries_date = ?
WHERE telegram_id = ?
""",
(new_used, today_str, telegram_id),
)
conn.commit()
return {"allowed": True, "used": new_used, "limit": limit}
def spend_points(self, telegram_id: int, amount: int) -> Dict[str, Any]:
if amount <= 0:
user = self.get_user(telegram_id)
return {"ok": True, "balance": int((user or {}).get("points") or 0)}
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT points FROM users WHERE telegram_id = ?",
(telegram_id,),
).fetchone()
if not row:
return {"ok": False, "reason": "user_missing", "balance": 0, "required": amount}
balance = int(row["points"] or 0)
if balance < amount:
return {"ok": False, "reason": "insufficient_points", "balance": balance, "required": amount}
new_balance = balance - amount
conn.execute(
"UPDATE users SET points = ? WHERE telegram_id = ?",
(new_balance, telegram_id),
)
conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
return {"ok": True, "balance": new_balance, "spent": amount}
def spend_points_by_supabase_user_id(self, supabase_user_id: str, amount: int) -> Dict[str, Any]:
key = str(supabase_user_id or "").strip().lower()
if not key:
return {"ok": False, "reason": "invalid_supabase_user_id", "balance": 0, "required": amount}
if amount <= 0:
return {"ok": True, "balance": self.get_points_by_supabase_user_id(key)}
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
telegram_id = self._find_telegram_id_by_supabase_user_id(conn, key)
if telegram_id is None:
return {"ok": False, "reason": "user_missing", "balance": 0, "required": amount}
row = conn.execute(
"""
SELECT telegram_id, points
FROM users
WHERE telegram_id = ?
LIMIT 1
""",
(int(telegram_id),),
).fetchone()
if not row:
return {"ok": False, "reason": "user_missing", "balance": 0, "required": amount}
telegram_id = int(row["telegram_id"])
balance = int(row["points"] or 0)
if balance < amount:
return {"ok": False, "reason": "insufficient_points", "balance": balance, "required": amount}
new_balance = balance - amount
conn.execute(
"UPDATE users SET points = ? WHERE telegram_id = ?",
(new_balance, telegram_id),
)
conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
return {"ok": True, "balance": new_balance, "spent": amount}
def set_premium(self, telegram_id: int, plan: str, months: int = 1):
expiry = datetime.now() + timedelta(days=30 * months)
col_is = f"is_{plan}_premium"
col_expiry = f"{plan}_expiry"
with self._get_connection() as conn:
conn.execute(f"""
UPDATE users
SET {col_is} = 1, {col_expiry} = ?
WHERE telegram_id = ?
""", (expiry.isoformat(), telegram_id))
conn.commit()
logger.info(f"User {telegram_id} upgraded to {plan} premium until {expiry}")
def get_leaderboard(self, limit: int = 10):
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute("""
SELECT username, points, message_count
FROM users
ORDER BY points DESC
LIMIT ?
""", (limit,))
return [dict(row) for row in cursor.fetchall()]
def get_weekly_leaderboard(self, limit: int = 10):
now = datetime.now()
iso_year, iso_week, _ = now.isocalendar()
week_key = f"{iso_year}-W{iso_week:02d}"
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"""
SELECT
username,
u.points AS points,
u.message_count AS message_count,
u.telegram_id AS telegram_id,
COALESCE(a.points,
CASE
WHEN u.weekly_points_week = ? THEN COALESCE(u.weekly_points, 0)
ELSE 0
END
) AS weekly_points
FROM users u
LEFT JOIN weekly_points_archive a
ON a.telegram_id = u.telegram_id
AND a.week_key = ?
ORDER BY weekly_points DESC, points DESC, message_count DESC
LIMIT ?
""",
(week_key, week_key, limit),
)
return [dict(row) for row in cursor.fetchall()]
def get_weekly_profile(self, telegram_id: int) -> Dict[str, Any]:
now = datetime.now()
iso_year, iso_week, _ = now.isocalendar()
week_key = f"{iso_year}-W{iso_week:02d}"
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT
u.telegram_id,
COALESCE(u.points, 0) AS points,
COALESCE(u.message_count, 0) AS message_count,
COALESCE(a.points,
CASE
WHEN u.weekly_points_week = ? THEN COALESCE(u.weekly_points, 0)
ELSE 0
END
) AS weekly_points
FROM users u
LEFT JOIN weekly_points_archive a
ON a.telegram_id = u.telegram_id
AND a.week_key = ?
ORDER BY weekly_points DESC, points DESC, message_count DESC, u.telegram_id ASC
""",
(week_key, week_key),
).fetchall()
weekly_rank: Optional[int] = None
weekly_points = 0
total_ranked = 0
for idx, row in enumerate(rows, start=1):
row_weekly_points = int(row["weekly_points"] or 0)
if row_weekly_points > 0:
total_ranked += 1
if int(row["telegram_id"] or 0) == int(telegram_id):
weekly_rank = idx if row_weekly_points > 0 else None
weekly_points = row_weekly_points
return {
"week_key": week_key,
"weekly_points": max(0, int(weekly_points or 0)),
"weekly_rank": weekly_rank,
"total_ranked": total_ranked,
}
def get_weekly_profile_by_supabase_user_id(self, supabase_user_id: str) -> Dict[str, Any]:
key = str(supabase_user_id or "").strip().lower()
if not key:
return {"weekly_points": 0, "weekly_rank": None, "total_ranked": 0}
now = datetime.now()
iso_year, iso_week, _ = now.isocalendar()
week_key = f"{iso_year}-W{iso_week:02d}"
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
target_telegram_id = self._find_telegram_id_by_supabase_user_id(conn, key)
if target_telegram_id is None:
return {"weekly_points": 0, "weekly_rank": None, "total_ranked": 0}
rows = conn.execute(
"""
SELECT
telegram_id,
COALESCE(points, 0) AS points,
COALESCE(message_count, 0) AS message_count,
CASE
WHEN weekly_points_week = ? THEN COALESCE(weekly_points, 0)
ELSE 0
END AS weekly_points
FROM users
ORDER BY weekly_points DESC, points DESC, message_count DESC, telegram_id ASC
""",
(week_key,),
).fetchall()
weekly_rank: Optional[int] = None
weekly_points = 0
for idx, row in enumerate(rows, start=1):
if int(row["telegram_id"] or 0) == int(target_telegram_id):
weekly_rank = idx
weekly_points = int(row["weekly_points"] or 0)
break
return {
"weekly_points": max(0, int(weekly_points or 0)),
"weekly_rank": weekly_rank,
"total_ranked": len(rows),
}
def get_weekly_reward_candidates(self, week_key: str, limit: int = 10):
wk = self._safe_week_key(week_key)
if not wk:
return []
top_n = max(1, int(limit or 10))
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT *
FROM (
SELECT
u.telegram_id,
u.username,
lower(trim(COALESCE(u.supabase_user_id, ''))) AS supabase_user_id,
COALESCE(u.supabase_email, '') AS supabase_email,
COALESCE(u.points, 0) AS points,
COALESCE(u.message_count, 0) AS message_count,
COALESCE(a.points,
CASE
WHEN u.weekly_points_week = ? THEN COALESCE(u.weekly_points, 0)
ELSE 0
END
) AS weekly_points
FROM users u
LEFT JOIN weekly_points_archive a
ON a.telegram_id = u.telegram_id
AND a.week_key = ?
) ranked
WHERE weekly_points > 0
ORDER BY weekly_points DESC, points DESC, message_count DESC, telegram_id ASC
LIMIT ?
""",
(wk, wk, top_n),
).fetchall()
return [dict(row) for row in rows]
def get_weekly_participation_candidates(self, week_key: str, exclude_ids: set):
wk = self._safe_week_key(week_key)
if not wk:
return []
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT *
FROM (
SELECT
u.telegram_id,
u.username,
COALESCE(a.points,
CASE
WHEN u.weekly_points_week = ? THEN COALESCE(u.weekly_points, 0)
ELSE 0
END
) AS weekly_points
FROM users u
LEFT JOIN weekly_points_archive a
ON a.telegram_id = u.telegram_id
AND a.week_key = ?
) ranked
WHERE weekly_points > 0
""",
(wk, wk),
).fetchall()
return [dict(row) for row in rows if int(row["telegram_id"] or 0) not in exclude_ids]
def is_weekly_reward_settled(self, week_key: str) -> bool:
wk = self._safe_week_key(week_key)
if not wk:
return False
with self._get_connection() as conn:
row = conn.execute(
"SELECT 1 FROM weekly_reward_runs WHERE week_key = ? LIMIT 1",
(wk,),
).fetchone()
return bool(row)
def mark_weekly_reward_settled(
self,
week_key: str,
winners_count: int,
summary: Optional[Dict[str, Any]] = None,
) -> None:
wk = self._safe_week_key(week_key)
if not wk:
return
summary_json = None
if isinstance(summary, dict):
try:
summary_json = json.dumps(summary, ensure_ascii=False)
except Exception:
summary_json = None
with self._get_connection() as conn:
conn.execute(
"""
INSERT INTO weekly_reward_runs (week_key, settled_at, winners_count, summary_json)
VALUES (?, ?, ?, ?)
ON CONFLICT(week_key) DO UPDATE SET
settled_at = excluded.settled_at,
winners_count = excluded.winners_count,
summary_json = excluded.summary_json
""",
(
wk,
datetime.now().isoformat(),
max(0, int(winners_count or 0)),
summary_json,
),
)
conn.commit()
def apply_weekly_reward_payout(
self,
week_key: str,
telegram_id: int,
rank: int,
username: str,
points_bonus: int,
pro_days: int,
supabase_user_id: str = "",
pro_granted: bool = False,
pro_error: str = "",
) -> bool:
wk = self._safe_week_key(week_key)
if not wk:
return False
bonus = max(0, int(points_bonus or 0))
with self._get_connection() as conn:
exists = conn.execute(
"""
SELECT 1
FROM weekly_reward_payouts
WHERE week_key = ? AND telegram_id = ?
LIMIT 1
""",
(wk, int(telegram_id)),
).fetchone()
if exists:
return False
if bonus > 0:
conn.execute(
"UPDATE users SET points = COALESCE(points, 0) + ? WHERE telegram_id = ?",
(bonus, int(telegram_id)),
)
conn.execute(
"""
INSERT INTO weekly_reward_payouts (
week_key, telegram_id, rank, username, points_bonus, pro_days,
supabase_user_id, pro_granted, pro_error, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
wk,
int(telegram_id),
int(rank or 0),
str(username or ""),
bonus,
max(0, int(pro_days or 0)),
str(supabase_user_id or "").strip().lower(),
1 if pro_granted else 0,
str(pro_error or ""),
datetime.now().isoformat(),
),
)
conn.commit()
if bonus > 0:
self._sync_points_to_supabase_user_metadata(
int(telegram_id),
force=True,
)
return True
def append_airport_obs(
self,
*,
icao: str,
city: str,
temp_c: Optional[float] = None,
wind_kt: Optional[float] = None,
pressure_hpa: Optional[float] = None,
obs_time: str,
) -> None:
with self._get_connection() as conn:
conn.execute(
"""
INSERT INTO airport_obs_log (icao, city, temp_c, wind_kt, pressure_hpa, obs_time)
VALUES (?, ?, ?, ?, ?, ?)
""",
(str(icao).strip().upper(), str(city).strip().lower(),
temp_c, wind_kt, pressure_hpa, str(obs_time)),
)
conn.execute(
"DELETE FROM airport_obs_log WHERE created_at < datetime('now', '-2 hours')"
)
conn.commit()
def get_airport_obs_recent(
self, icao: str, minutes: int = 30
) -> List[Dict[str, Any]]:
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT icao, city, temp_c, wind_kt, pressure_hpa, obs_time, created_at
FROM airport_obs_log
WHERE icao = ? AND created_at >= datetime('now', ? || ' minutes')
ORDER BY created_at ASC
""",
(str(icao).strip().upper(), str(-int(minutes))),
).fetchall()
return [dict(r) for r in rows]
def append_runway_obs(
self,
icao: str,
city: str,
runway: str,
tdz_temp: Optional[float] = None,
mid_temp: Optional[float] = None,
end_temp: Optional[float] = None,
target_runway_max: Optional[float] = None,
wind_dir: Optional[int] = None,
wind_speed: Optional[float] = None,
rvr: Optional[int] = None,
mor: Optional[float] = None,
humidity: Optional[float] = None,
otime_utc: str = "",
) -> None:
"""Insert one runway observation row (deduplicated by icao+runway+otime_utc)."""
safe_icao = str(icao or "").strip().upper()
safe_city = str(city or "").strip().lower()
safe_runway = str(runway or "").strip().upper()
safe_otime = str(otime_utc or "").strip()
if not safe_icao or not safe_runway or not safe_otime:
return
with self._get_connection() as conn:
existing = conn.execute(
"SELECT id FROM runway_obs_log WHERE icao=? AND runway=? AND otime_utc=? LIMIT 1",
(safe_icao, safe_runway, safe_otime),
).fetchone()
if existing:
return
conn.execute(
"""
INSERT INTO runway_obs_log (
icao, city, runway,
tdz_temp, mid_temp, end_temp, target_runway_max,
wind_dir, wind_speed, rvr, mor, humidity,
otime_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
safe_icao, safe_city, safe_runway,
tdz_temp, mid_temp, end_temp, target_runway_max,
wind_dir, wind_speed, rvr, mor, humidity,
safe_otime,
),
)
conn.commit()
def get_runway_obs_recent(
self, icao: str, minutes: int = 60
) -> List[Dict[str, Any]]:
"""Get recent runway observations for trend analysis."""
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT icao, city, runway,
tdz_temp, mid_temp, end_temp, target_runway_max,
wind_dir, wind_speed, rvr, mor, humidity,
otime_utc, created_at
FROM runway_obs_log
WHERE icao = ? AND created_at >= datetime('now', ? || ' minutes')
ORDER BY created_at ASC
""",
(str(icao).strip().upper(), str(-int(minutes))),
).fetchall()
return [dict(r) for r in rows]