Download app.py from Hfzune/Zinx: direct link, hf CLI and curl.
- Browser
- Download file 78.4 kB
-
https://huggingface.co/spaces/Hfzune/Zinx/resolve/main/app.py
- Command line
-
hf download hf://spaces/Hfzune/Zinx/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Hfzune/Zinx/resolve/main/app.py
78.4 kB
| # ══════════════════════════════════════════════════════════════════ | |
| # CRITICAL SHIM — MUST BE THE FIRST EXECUTABLE CODE | |
| # ══════════════════════════════════════════════════════════════════ | |
| import huggingface_hub | |
| if not hasattr(huggingface_hub, "HfFolder"): | |
| class _HfFolder: | |
| def get_token(): | |
| try: return huggingface_hub.get_token() | |
| except Exception: return None | |
| def save_token(token): | |
| try: huggingface_hub.login(token) | |
| except Exception: pass | |
| def delete_token(): | |
| try: huggingface_hub.logout() | |
| except Exception: pass | |
| huggingface_hub.HfFolder = _HfFolder | |
| # ── Now safe to import gradio ───────────────────────────────────── | |
| import os | |
| import io | |
| import csv | |
| import json | |
| import asyncio | |
| import html | |
| import re | |
| import time | |
| import uuid | |
| import threading | |
| import traceback | |
| import logging | |
| import urllib.parse | |
| from collections import deque, Counter | |
| from typing import Optional | |
| import duckdb | |
| import httpx | |
| import gradio as gr | |
| from fastapi import Request | |
| from fastapi.responses import JSONResponse | |
| from starlette.routing import Route as StarletteRoute | |
| # ───────────────────────────────────────────── | |
| # CONFIG | |
| # ───────────────────────────────────────────── | |
| VERSION = "1.4.0" | |
| BOT_TOKEN = os.environ.get("BOT_TOKEN", "").strip() | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "").strip() | |
| WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "1221").strip() | |
| SPACE_URL = os.environ.get("SPACE_URL", "").strip().rstrip("/") | |
| TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "").strip().rstrip("/") | |
| ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip() | |
| ADMIN_SESSION_TTL = 3600 | |
| ADMIN_PROMPT_TTL = 180 | |
| TELEGRAM_API = ( | |
| f"{TELEGRAM_PROXY}/bot{BOT_TOKEN}" if TELEGRAM_PROXY | |
| else f"https://api.telegram.org/bot{BOT_TOKEN}" | |
| ) | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") | |
| log = logging.getLogger("bot") | |
| # ───────────────────────────────────────────── | |
| # DATASETS — HiTeck + Flipkart + 2 APIs | |
| # ───────────────────────────────────────────── | |
| HITECK_URLS = [ | |
| f"https://huggingface.co/datasets/HiTeckGroup/HiTeckgroop/resolve/main/idx_phone.{i}.parquet" | |
| for i in range(7) | |
| ] | |
| PARQUET_URLS = HITECK_URLS | |
| # ── Flipkart dataset (single parquet, different schema) ── | |
| # Columns: CustomerName, CustomerPhone, CustomerEmail, CustomerAddress, | |
| # CustomerCity, CustomerState, CustomerPincode | |
| FLIPKART_URLS = [ | |
| "https://huggingface.co/datasets/Hfzune/Flipkart/resolve/main/flipkart.parquet" | |
| ] | |
| ALL_PARQUET_URLS = PARQUET_URLS + FLIPKART_URLS | |
| API_850CR = "https://tfqdeadlo-850crfastsearch.hf.space/search" | |
| API_INDDATA = "https://tfqdeadlo-inddataapi.hf.space/search" | |
| PAGE_SIZE = 4 | |
| FETCH_LIMIT = 40 | |
| QUERY_TIMEOUT = 90 | |
| USER_COOLDOWN = 5 | |
| CACHE_TTL = 300 | |
| LIVE_WINDOW = 300 | |
| HISTORY_LEN = 10 | |
| BATCH_MAX = 5 | |
| BATCH_TIMEOUT = 60 | |
| BROADCAST_DELAY = 0.05 | |
| PHONE_REGEX = re.compile(r"^\d{10}$") | |
| TRUNC = { | |
| "name": 80, "fathersName": 80, "address": 200, | |
| "phoneNumber": 40, "aadharNumber": 40, "otherNumber": 40, | |
| "district": 40, "pincode": 15, "state": 40, "town": 40, "source": 30, | |
| } | |
| # ───────────────────────────────────────────── | |
| # DUCKDB ENGINE | |
| # ───────────────────────────────────────────── | |
| _con = None | |
| _con_lock = threading.Lock() | |
| _con_ready = threading.Event() | |
| _query_lock = threading.Lock() | |
| _warmed = threading.Event() | |
| _warm_thread_started = False | |
| def get_con() -> duckdb.DuckDBPyConnection: | |
| global _con | |
| with _con_lock: | |
| if _con is not None: | |
| return _con | |
| log.info("🔧 Initializing DuckDB...") | |
| try: | |
| c = duckdb.connect(":memory:") | |
| c.execute("INSTALL httpfs; LOAD httpfs;") | |
| if HF_TOKEN: | |
| c.execute(f"CREATE SECRET hf (TYPE HUGGINGFACE, TOKEN '{HF_TOKEN}');") | |
| c.execute("SET enable_object_cache=true;") | |
| c.execute("SET preserve_insertion_order=false;") | |
| c.execute("SET threads=6;") | |
| c.execute("SET memory_limit='12GB';") | |
| urls = ", ".join(f"'{u}'" for u in ALL_PARQUET_URLS) | |
| try: | |
| c.execute(f"SELECT COUNT(*) FROM read_parquet([{urls}], union_by_name=true) WHERE 1=0") | |
| log.info("✅ DuckDB connection ready") | |
| except Exception as e: | |
| log.warning(f"⚠️ Metadata warning: {e}") | |
| _con = c | |
| _con_ready.set() | |
| return _con | |
| except Exception as e: | |
| log.error(f"❌ DuckDB init failed: {e!r}") | |
| raise | |
| def warm_phone_cache() -> None: | |
| global _warm_thread_started | |
| if _warmed.is_set(): | |
| return | |
| _warm_thread_started = True | |
| log.info(f"🔥 Warm-up starting ({len(ALL_PARQUET_URLS)} files)...") | |
| t0 = time.time() | |
| loaded = skipped = 0 | |
| try: | |
| con = get_con() | |
| for url in ALL_PARQUET_URLS: | |
| name = url.rsplit("/", 1)[-1] | |
| try: | |
| con.execute(f"SELECT * FROM read_parquet('{url}') LIMIT 1").fetchall() | |
| loaded += 1 | |
| log.info(f" ✅ warmed: {name}") | |
| except Exception as e: | |
| skipped += 1 | |
| log.warning(f" ⏭️ skipped {name}: {str(e)[:120]}") | |
| elapsed = time.time() - t0 | |
| _warmed.set() | |
| log.info(f"✅ Warm-up complete in {elapsed:.1f}s ({loaded} loaded, {skipped} skipped)") | |
| except Exception as e: | |
| log.error(f"⚠️ Warm-up fatal: {e!r}") | |
| _warm_thread_started = False | |
| def _flipkart_clean(rec: dict) -> dict: | |
| """URL-decode percent-encoded Flipkart text fields for display.""" | |
| for k in ("name", "address", "district", "state", "town"): | |
| v = rec.get(k) | |
| if isinstance(v, str) and "%" in v: | |
| try: | |
| rec[k] = urllib.parse.unquote(v) | |
| except Exception: | |
| pass | |
| return rec | |
| def run_search_safe(phone: str, limit: int = FETCH_LIMIT, timeout_secs: int = QUERY_TIMEOUT) -> list: | |
| con = get_con() | |
| with _query_lock: | |
| stopped = threading.Event() | |
| def watchdog(): | |
| if not stopped.wait(timeout_secs): | |
| log.warning(f"⏱️ Timeout {timeout_secs}s — interrupting DuckDB") | |
| try: con.interrupt() | |
| except Exception as e: log.warning(f"⚠️ interrupt() failed: {e}") | |
| wd = threading.Thread(target=watchdog, daemon=True) | |
| wd.start() | |
| results: list = [] | |
| try: | |
| # ── HiTeck parquets ── | |
| try: | |
| urls = ", ".join(f"'{u}'" for u in PARQUET_URLS) | |
| sql = f""" | |
| SELECT name, fathersName, phoneNumber, aadharNumber, otherNumber, | |
| address, district, pincode, state, town, source | |
| FROM read_parquet([{urls}], union_by_name=true) | |
| WHERE CAST(phoneNumber AS VARCHAR) = ? | |
| LIMIT {int(limit)} | |
| """ | |
| cur = con.execute(sql, [phone]) | |
| cols = [d[0] for d in cur.description] | |
| results.extend(dict(zip(cols, r)) for r in cur.fetchall()) | |
| except duckdb.InterruptException: | |
| raise TimeoutError(f"Search timed out after {timeout_secs}s") | |
| except Exception as e: | |
| log.warning(f"⚠️ HiTeck query failed for {phone}: {e!r}") | |
| # ── Flipkart parquet (schema-mapped) ── | |
| try: | |
| fk_urls = ", ".join(f"'{u}'" for u in FLIPKART_URLS) | |
| fk_sql = f""" | |
| SELECT | |
| CustomerName AS name, | |
| NULL AS fathersName, | |
| CustomerPhone AS phoneNumber, | |
| NULL AS aadharNumber, | |
| NULL AS otherNumber, | |
| CustomerAddress AS address, | |
| CustomerCity AS district, | |
| CustomerPincode AS pincode, | |
| CustomerState AS state, | |
| CustomerCity AS town, | |
| 'Flipkart' AS source | |
| FROM read_parquet([{fk_urls}], union_by_name=true) | |
| WHERE RIGHT( | |
| REPLACE(REPLACE(REPLACE( | |
| CAST(CustomerPhone AS VARCHAR), | |
| ' ', ''), '-', ''), '+', ''), | |
| 10 | |
| ) = ? | |
| LIMIT {int(limit)} | |
| """ | |
| cur = con.execute(fk_sql, [phone]) | |
| cols = [d[0] for d in cur.description] | |
| for r in cur.fetchall(): | |
| results.append(_flipkart_clean(dict(zip(cols, r)))) | |
| except duckdb.InterruptException: | |
| raise TimeoutError(f"Search timed out after {timeout_secs}s") | |
| except Exception as e: | |
| log.warning(f"⚠️ Flipkart query failed for {phone}: {e!r}") | |
| return results | |
| except duckdb.InterruptException: | |
| raise TimeoutError(f"Search timed out after {timeout_secs}s") | |
| finally: | |
| stopped.set() | |
| # ───────────────────────────────────────────── | |
| # API FALLBACK | |
| # ───────────────────────────────────────────── | |
| async def _q_cr850(client: httpx.AsyncClient, number: str) -> list: | |
| """850CR API — sauravsingh2111-850crfastsearch.hf.space""" | |
| try: | |
| r = await client.get(f"{API_850CR}/{number}", timeout=40.0) | |
| if r.status_code != 200: | |
| log.warning(f"850CR HTTP {r.status_code} for {number}") | |
| return [] | |
| data = r.json() | |
| if not data.get("success") or not data.get("results"): | |
| return [] | |
| out = [] | |
| for item in data["results"]: | |
| addr = item.get("address") or {} | |
| full = addr.get("full_address", "") if isinstance(addr, dict) else "" | |
| if full: | |
| full = full.lstrip(", ").strip() | |
| out.append({ | |
| "name": item.get("name"), | |
| "fathersName": item.get("father_name"), | |
| "phoneNumber": number, | |
| "aadharNumber": None, | |
| "otherNumber": item.get("alternate_number"), | |
| "address": full, | |
| "district": None, "pincode": None, | |
| "state": item.get("state"), | |
| "town": None, | |
| "source": "850CR Fast", | |
| }) | |
| return out | |
| except (httpx.TimeoutException, httpx.ConnectError) as e: | |
| log.warning(f"850CR network error for {number}: {e}") | |
| return [] | |
| except Exception as e: | |
| log.warning(f"850CR API failed for {number}: {e!r}") | |
| return [] | |
| async def _q_inddata(client: httpx.AsyncClient, number: str) -> list: | |
| """ | |
| IndData API — tfqdeadlo-inddataapi.hf.space/search?mobile=<num> | |
| Response shape: | |
| {"status":"success","data":{mobile,name,fname,address,alt,circle,id,email}} | |
| OR {"status":"error","message":"..."} if not found | |
| """ | |
| try: | |
| r = await client.get(API_INDDATA, params={"mobile": number}, timeout=30.0) | |
| if r.status_code != 200: | |
| log.warning(f"IndData HTTP {r.status_code} for {number}") | |
| return [] | |
| data = r.json() | |
| if data.get("status") != "success": | |
| return [] | |
| item = data.get("data") | |
| if not isinstance(item, dict): | |
| return [] | |
| return [{ | |
| "name": item.get("name"), | |
| "fathersName": item.get("fname"), | |
| "phoneNumber": number, | |
| "aadharNumber": None, | |
| "otherNumber": item.get("alt"), | |
| "address": item.get("address"), | |
| "district": None, | |
| "pincode": None, | |
| "state": None, | |
| "town": item.get("circle"), | |
| "source": "IndData 1.4B", | |
| }] | |
| except (httpx.TimeoutException, httpx.ConnectError) as e: | |
| log.warning(f"IndData network error for {number}: {e}") | |
| return [] | |
| except Exception as e: | |
| log.warning(f"IndData API failed for {number}: {e!r}") | |
| return [] | |
| async def search_apis(number: str) -> list: | |
| """Call both APIs in parallel, ignore individual failures.""" | |
| log.info(f"🔄 API search for {number}") | |
| try: | |
| async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: | |
| results = await asyncio.gather( | |
| _q_cr850(client, number), | |
| _q_inddata(client, number), | |
| return_exceptions=True, | |
| ) | |
| except Exception as e: | |
| log.error(f"❌ search_apis fatal: {e!r}") | |
| return [] | |
| merged = [] | |
| for res in results: | |
| if isinstance(res, list): | |
| merged.extend(res) | |
| elif isinstance(res, Exception): | |
| log.warning(f"API task raised: {res!r}") | |
| return merged | |
| # ───────────────────────────────────────────── | |
| # DEDUP & FORMATTING | |
| # ───────────────────────────────────────────── | |
| def _norm(s) -> str: | |
| if not s: return "" | |
| return re.sub(r"\s+", " ", str(s).strip().lower()) | |
| def _dedup_key(rec: dict): | |
| name = _norm(rec.get("name")) | |
| father = _norm(rec.get("fathersName")) | |
| addr = _norm(rec.get("address")) | |
| addr_n = re.sub(r"[^a-z0-9]+", " ", addr).strip() | |
| if name and addr_n: return ("na", name, addr_n) | |
| if name and father: return ("nf", name, father) | |
| if addr_n: return ("a", addr_n) | |
| return None | |
| def dedup_records(records: list) -> list: | |
| seen = {} | |
| result = [] | |
| for rec in records: | |
| if not isinstance(rec, dict): | |
| continue | |
| key = _dedup_key(rec) | |
| if key is None: | |
| result.append(rec); continue | |
| idx = seen.get(key) | |
| if idx is None: | |
| seen[key] = len(result); result.append(rec) | |
| else: | |
| existing = result[idx] | |
| ef = sum(1 for v in existing.values() if v) | |
| nf = sum(1 for v in rec.values() if v) | |
| if nf > ef: | |
| result[idx] = rec | |
| return result | |
| def _tv(v, n: int) -> str: | |
| if v is None: return "" | |
| s = str(v).strip() | |
| if s.lower() in ("", "null", "none", "nan"): return "" | |
| return s if len(s) <= n else s[: n - 1] + "…" | |
| FIELD_LABELS = [ | |
| ("name", "👤", "Name"), | |
| ("fathersName", "👨", "Father"), | |
| ("phoneNumber", "📞", "Phone"), | |
| ("otherNumber", "📱", "Other"), | |
| ("aadharNumber","🆔", "Aadhaar"), | |
| ("address", "🏠", "Address"), | |
| ("district", "🏙️", "District"), | |
| ("pincode", "📮", "Pincode"), | |
| ("state", "🗺️", "State"), | |
| ("town", "🌆", "Town"), | |
| ("source", "📂", "Source"), | |
| ] | |
| def format_result(idx: int, row: dict) -> str: | |
| lines = [f"<b>━━━ #{idx} ━━━</b>"] | |
| for key, emoji, label in FIELD_LABELS: | |
| v = _tv(row.get(key), TRUNC.get(key, 60)) | |
| if v: | |
| lines.append(f"{emoji} <b>{label}:</b> <code>{html.escape(v)}</code>") | |
| return "\n".join(lines) | |
| def _plain_record(idx: int, row: dict) -> str: | |
| lines = [f"#{idx}"] | |
| for key, _emoji, label in FIELD_LABELS: | |
| v = _tv(row.get(key), TRUNC.get(key, 60)) | |
| if v: | |
| lines.append(f"{label}: {v}") | |
| return "\n".join(lines) | |
| def render_page(results, page, total_pages, phone, elapsed, source_note=""): | |
| start = page * PAGE_SIZE | |
| chunk = results[start:start + PAGE_SIZE] | |
| head = ( | |
| f"📱 <b>Phone:</b> <code>{html.escape(phone)}</code>\n" | |
| f"📄 <b>Page {page+1}/{total_pages}</b> · " | |
| f"{len(results)} result(s) · ⏱️ {elapsed:.1f}s" | |
| ) | |
| if source_note: | |
| head += f"\n⚠️ <i>{source_note}</i>" | |
| body = "\n\n".join(format_result(start + i + 1, r) for i, r in enumerate(chunk)) | |
| return f"{head}\n\n{body}" | |
| def page_keyboard(search_id, page, total_pages, has_export=True): | |
| nav = [] | |
| if page > 0: | |
| nav.append({"text": "◀ Prev", "callback_data": f"p:{search_id}:{page-1}"}) | |
| nav.append({"text": f"{page+1} / {total_pages}", "callback_data": "noop"}) | |
| if page < total_pages - 1: | |
| nav.append({"text": "Next ▶", "callback_data": f"p:{search_id}:{page+1}"}) | |
| rows = [nav] | |
| extras = [{"text": "📋 Copy page", "callback_data": f"c:{search_id}:{page}"}] | |
| if has_export: | |
| extras.append({"text": "📥 Export", "callback_data": f"e:{search_id}"}) | |
| rows.append(extras) | |
| return {"inline_keyboard": rows} | |
| # ───────────────────────────────────────────── | |
| # ACTIVITY TRACKING | |
| # ───────────────────────────────────────────── | |
| ACTIVITY_LOG = deque(maxlen=10000) | |
| ACTIVE_USERS = {} | |
| UNIQUE_USERS = {} | |
| PHONE_COUNTS = Counter() | |
| PEAK_CONCURRENT = 0 | |
| _activity_lock = threading.Lock() | |
| _admin_lock = threading.Lock() | |
| _admin_sessions = {} | |
| _admin_states = {} | |
| BANNED_USERS: set = set() | |
| BAN_REASONS: dict = {} | |
| USER_HISTORY: dict = {} | |
| MAINTENANCE_MODE = False | |
| MAINTENANCE_REASON = "" | |
| _state_lock = threading.Lock() | |
| def _short_user(user_id: int, user: dict | None = None) -> str: | |
| info = UNIQUE_USERS.get(user_id) or {} | |
| username = (user or {}).get("username") or info.get("username") | |
| first = (user or {}).get("first_name") or info.get("first_name") | |
| if username: return f"@{username} ({user_id})" | |
| if first: return f"{first} ({user_id})" | |
| return f"({user_id})" | |
| def _log_activity(user: dict, action: str, input_val: str, reply_type: str, | |
| summary: str = "", elapsed_ms: int = 0, source: str = ""): | |
| if not user or "id" not in user: | |
| return | |
| now = time.time() | |
| user_id = user["id"] | |
| with _activity_lock: | |
| info = UNIQUE_USERS.setdefault(user_id, { | |
| "username": None, "first_name": None, "last_name": None, | |
| "count": 0, "first_seen": now, "last_seen": now, | |
| }) | |
| if user.get("username"): info["username"] = user["username"] | |
| if user.get("first_name"): info["first_name"] = user["first_name"] | |
| if user.get("last_name"): info["last_name"] = user["last_name"] | |
| info["count"] += 1 | |
| info["last_seen"] = now | |
| ACTIVE_USERS[user_id] = now | |
| if action == "search" and isinstance(input_val, str) and input_val.isdigit() and len(input_val) == 10: | |
| PHONE_COUNTS[input_val] += 1 | |
| ACTIVITY_LOG.append({ | |
| "ts": now, "user_id": user_id, | |
| "username": user.get("username"), "first_name": user.get("first_name"), | |
| "action": action, "input": (input_val or "")[:80], | |
| "reply_type": reply_type, "summary": (summary or "")[:150], | |
| "elapsed_ms": int(elapsed_ms), "source": source, | |
| }) | |
| def _get_live_users(): | |
| cutoff = time.time() - LIVE_WINDOW | |
| with _activity_lock: | |
| live = [(uid, ts) for uid, ts in ACTIVE_USERS.items() if ts >= cutoff] | |
| live.sort(key=lambda x: -x[1]) | |
| return live | |
| def _get_stats(): | |
| now = time.time() | |
| today_start = now - (now % 86400) | |
| with _activity_lock: | |
| today_users = set(); today_searches = 0; total_searches = 0 | |
| for e in ACTIVITY_LOG: | |
| if e["action"] == "search": | |
| total_searches += 1 | |
| if e["ts"] >= today_start: | |
| today_searches += 1; today_users.add(e["user_id"]) | |
| return { | |
| "unique_users": len(UNIQUE_USERS), | |
| "live_users": sum(1 for ts in ACTIVE_USERS.values() if ts >= now - LIVE_WINDOW), | |
| "today_users": len(today_users), | |
| "today_searches": today_searches, | |
| "total_searches": total_searches, | |
| "peak_concurrent": PEAK_CONCURRENT, | |
| "total_log_entries": len(ACTIVITY_LOG), | |
| "banned_count": len(BANNED_USERS), | |
| "maintenance": MAINTENANCE_MODE, | |
| } | |
| def _add_to_history(user_id: int, phone: str, count: int, elapsed: float, source: str): | |
| if user_id is None: return | |
| with _activity_lock: | |
| h = USER_HISTORY.setdefault(user_id, deque(maxlen=HISTORY_LEN)) | |
| h.appendleft({"phone": phone, "ts": time.time(), "count": count, | |
| "elapsed": elapsed, "source": source}) | |
| # ───────────────────────────────────────────── | |
| # BAN / MAINTENANCE HELPERS | |
| # ───────────────────────────────────────────── | |
| def _is_banned(user_id: int) -> bool: | |
| with _state_lock: | |
| return user_id in BANNED_USERS | |
| def _ban_user(user_id: int, reason: str = ""): | |
| with _state_lock: | |
| BANNED_USERS.add(user_id) | |
| BAN_REASONS[user_id] = reason | |
| def _unban_user(user_id: int) -> bool: | |
| with _state_lock: | |
| if user_id in BANNED_USERS: | |
| BANNED_USERS.discard(user_id) | |
| BAN_REASONS.pop(user_id, None) | |
| return True | |
| return False | |
| def _get_maintenance(): | |
| with _state_lock: | |
| return MAINTENANCE_MODE, MAINTENANCE_REASON | |
| def _set_maintenance(on: bool, reason: str = ""): | |
| global MAINTENANCE_MODE, MAINTENANCE_REASON | |
| with _state_lock: | |
| MAINTENANCE_MODE = on | |
| MAINTENANCE_REASON = reason if on else "" | |
| # ───────────────────────────────────────────── | |
| # ADMIN AUTH & STATE | |
| # ───────────────────────────────────────────── | |
| def _is_admin_session(user_id: int) -> bool: | |
| if user_id is None: return False | |
| with _admin_lock: | |
| exp = _admin_sessions.get(user_id) | |
| if exp is None: return False | |
| if time.time() > exp: | |
| _admin_sessions.pop(user_id, None); return False | |
| return True | |
| def _grant_admin(user_id: int): | |
| with _admin_lock: | |
| _admin_sessions[user_id] = time.time() + ADMIN_SESSION_TTL | |
| def _revoke_admin(user_id: int): | |
| with _admin_lock: | |
| _admin_sessions.pop(user_id, None) | |
| def _admin_state_get(user_id: int): | |
| with _admin_lock: | |
| entry = _admin_states.get(user_id) | |
| if entry is None: return None | |
| state, set_at = entry | |
| if time.time() - set_at > ADMIN_PROMPT_TTL: | |
| _admin_states.pop(user_id, None); return None | |
| return state | |
| def _admin_state_set(user_id: int, state: str): | |
| with _admin_lock: | |
| _admin_states[user_id] = (state, time.time()) | |
| def _admin_state_clear(user_id: int): | |
| with _admin_lock: | |
| _admin_states.pop(user_id, None) | |
| # ───────────────────────────────────────────── | |
| # ADMIN RENDERING (Telegram-side only) | |
| # ───────────────────────────────────────────── | |
| def _fmt_ts(ts: float) -> str: | |
| return time.strftime("%H:%M:%S", time.localtime(ts)) | |
| def admin_menu_kb(): | |
| m_on, _ = _get_maintenance() | |
| maint_label = "🟢 Maintenance: OFF" if not m_on else "🔴 Maintenance: ON" | |
| return { | |
| "inline_keyboard": [ | |
| [{"text": "🔴 Live Users", "callback_data": "adm:live"}, | |
| {"text": "📈 Stats", "callback_data": "adm:stats"}], | |
| [{"text": "📜 Activity Log", "callback_data": "adm:log:0"}, | |
| {"text": "👥 All Users", "callback_data": "adm:users:0"}], | |
| [{"text": "🔍 Top Searches", "callback_data": "adm:top"}, | |
| {"text": "🚫 Banned", "callback_data": "adm:banned"}], | |
| [{"text": "🚫 Ban User", "callback_data": "adm:ban"}, | |
| {"text": "✅ Unban User", "callback_data": "adm:unban"}], | |
| [{"text": "📢 Broadcast", "callback_data": "adm:broadcast"}, | |
| {"text": "📥 Export Log", "callback_data": "adm:export"}], | |
| [{"text": "🔎 Log by Phone", "callback_data": "adm:logphone"}, | |
| {"text": maint_label, "callback_data": "adm:maint"}], | |
| [{"text": "🔄 Refresh", "callback_data": "adm:menu"}], | |
| ] | |
| } | |
| def render_admin_menu() -> str: | |
| m_on, reason = _get_maintenance() | |
| m_line = f"🔴 Maintenance ON — {html.escape(reason)}" if m_on else "🟢 Operational" | |
| return ( | |
| f"🛡️ <b>Admin Panel</b> — v{VERSION}\n" | |
| "━━━━━━━━━━━━━━━━━━\n" | |
| f"{m_line}\n" | |
| f"👥 Users: {len(UNIQUE_USERS)} · 🚫 Banned: {len(BANNED_USERS)}\n\n" | |
| "Tap a button or use commands:\n" | |
| "• /live /stats /log /users /top\n" | |
| "• /ban <id> [reason] · /unban <id>\n" | |
| "• /broadcast <msg>\n" | |
| "• /export · /logphone <num>\n" | |
| "• /maintenance on|off [reason]\n" | |
| "• /logout" | |
| ) | |
| def render_live() -> str: | |
| live = _get_live_users() | |
| if not live: | |
| return f"🔴 <b>Live Users</b>\n\nNo one active in the last {LIVE_WINDOW // 60} minutes." | |
| lines = [f"🔴 <b>Live Users</b> (last {LIVE_WINDOW // 60} min): <b>{len(live)}</b>\n"] | |
| for uid, ts in live[:25]: | |
| lines.append(f"• {_short_user(uid)} — {_fmt_ts(ts)}") | |
| return "\n".join(lines) | |
| def render_stats() -> str: | |
| s = _get_stats() | |
| uptime = (time.time() - STATS["started"]) / 60 | |
| return ( | |
| "📈 <b>Bot Statistics</b>\n" | |
| "━━━━━━━━━━━━━━━━━━\n" | |
| f"⏱️ Uptime: <code>{uptime:.1f} min</code>\n" | |
| f"👥 Unique users: <code>{s['unique_users']}</code>\n" | |
| f"🔴 Live now: <code>{s['live_users']}</code>\n" | |
| f"📅 Active today: <code>{s['today_users']}</code>\n" | |
| f"🔍 Searches today: <code>{s['today_searches']}</code>\n" | |
| f"🔍 Total searches: <code>{s['total_searches']}</code>\n" | |
| f"📊 Peak concurrent: <code>{s['peak_concurrent']}</code>\n" | |
| f"🚫 Banned users: <code>{s['banned_count']}</code>\n" | |
| f"💾 Cached queries: <code>{len(SEARCH_CACHE)}</code>\n" | |
| f"📜 Log entries: <code>{s['total_log_entries']}</code>\n" | |
| f"⏰ Timeouts: <code>{STATS['timeouts']}</code>\n" | |
| f"❌ Errors: <code>{STATS['errors']}</code>" | |
| ) | |
| def render_log(entries, page=0, page_size=8): | |
| total = len(entries) | |
| total_pages = max(1, (total + page_size - 1) // page_size) | |
| page = max(0, min(page, total_pages - 1)) | |
| chunk = entries[page * page_size:(page + 1) * page_size] | |
| if not chunk: | |
| return "📜 <b>Activity Log</b>\n\nNo activity yet.", None | |
| lines = [f"📜 <b>Activity Log</b> — page {page + 1}/{total_pages} ({total} entries)\n"] | |
| for e in chunk: | |
| uname = e.get("username"); first = e.get("first_name") | |
| name_str = f"@{uname}" if uname else (first or "(no name)") | |
| uid = e["user_id"] | |
| reply = e.get("summary") or e.get("reply_type") or "-" | |
| elapsed = e.get("elapsed_ms", 0); source = e.get("source") or "" | |
| block = f"<code>{_fmt_ts(e['ts'])}</code> · <b>{html.escape(name_str)}</b> · <code>{uid}</code>\n" | |
| block += f" 📥 <code>{html.escape(e['input'] or '-')}</code>\n" | |
| reply_line = f" 📤 {html.escape(reply)}" | |
| if elapsed: reply_line += f" · {elapsed}ms" | |
| if source: reply_line += f" · {source}" | |
| block += reply_line | |
| lines.append(block) | |
| text = "\n\n".join(lines) | |
| nav = [] | |
| if page > 0: | |
| nav.append({"text": "◀ Newer", "callback_data": f"adm:log:{page - 1}"}) | |
| nav.append({"text": f"{page + 1}/{total_pages}", "callback_data": "adm:noop"}) | |
| if page < total_pages - 1: | |
| nav.append({"text": "Older ▶", "callback_data": f"adm:log:{page + 1}"}) | |
| kb = {"inline_keyboard": [nav]} if len(nav) > 1 else None | |
| return text, kb | |
| def render_users(page=0, page_size=8): | |
| users = sorted(UNIQUE_USERS.items(), key=lambda kv: -kv[1]["last_seen"]) | |
| total = len(users) | |
| total_pages = max(1, (total + page_size - 1) // page_size) | |
| page = max(0, min(page, total_pages - 1)) | |
| chunk = users[page * page_size:(page + 1) * page_size] | |
| if not chunk: | |
| return "👥 <b>All Users</b>\n\nNo users yet.", None | |
| lines = [f"👥 <b>All Users</b> — page {page + 1}/{total_pages} ({total} total)\n"] | |
| for uid, info in chunk: | |
| username = info.get("username"); first = info.get("first_name") or "" | |
| name_str = f"@{username}" if username else (first or "(no name)") | |
| ban_mark = " 🚫" if _is_banned(uid) else "" | |
| lines.append( | |
| f"<b>{html.escape(name_str)}</b>{ban_mark} · <code>{uid}</code>\n" | |
| f" Requests: {info['count']} · Last: {_fmt_ts(info['last_seen'])}" | |
| ) | |
| text = "\n\n".join(lines) | |
| nav = [] | |
| if page > 0: | |
| nav.append({"text": "◀ Prev", "callback_data": f"adm:users:{page - 1}"}) | |
| nav.append({"text": f"{page + 1}/{total_pages}", "callback_data": "adm:noop"}) | |
| if page < total_pages - 1: | |
| nav.append({"text": "Next ▶", "callback_data": f"adm:users:{page + 1}"}) | |
| kb = {"inline_keyboard": [nav]} if len(nav) > 1 else None | |
| return text, kb | |
| def render_top() -> str: | |
| if not PHONE_COUNTS: | |
| return "🔍 <b>Top Searched Numbers</b>\n\nNo searches yet." | |
| lines = ["🔍 <b>Top Searched Numbers</b>\n"] | |
| for i, (num, cnt) in enumerate(PHONE_COUNTS.most_common(10), 1): | |
| lines.append(f"{i}. <code>{num}</code> — {cnt} time(s)") | |
| return "\n".join(lines) | |
| def render_banned() -> str: | |
| with _state_lock: | |
| if not BANNED_USERS: | |
| return "🚫 <b>Banned Users</b>\n\nNo banned users." | |
| lines = [f"🚫 <b>Banned Users</b> ({len(BANNED_USERS)})\n"] | |
| for uid in list(BANNED_USERS)[:30]: | |
| reason = BAN_REASONS.get(uid) or "—" | |
| lines.append(f"• {_short_user(uid)} — {html.escape(reason)}") | |
| return "\n".join(lines) | |
| # ───────────────────────────────────────────── | |
| # TELEGRAM HELPERS | |
| # ───────────────────────────────────────────── | |
| async def tg(method: str, payload: dict, timeout: float = 30) -> dict: | |
| try: | |
| async with httpx.AsyncClient(timeout=timeout) as client: | |
| r = await client.post(f"{TELEGRAM_API}/{method}", json=payload) | |
| return r.json() | |
| except Exception as e: | |
| log.error(f"❌ tg({method}) error: {e!r}") | |
| return {"ok": False, "error": str(e)} | |
| async def send(chat_id, text, **kw): | |
| kw.setdefault("parse_mode", "HTML") | |
| kw.setdefault("disable_web_page_preview", True) | |
| kw = {k: v for k, v in kw.items() if v is not None} | |
| try: | |
| res = await tg("sendMessage", {"chat_id": chat_id, "text": text, **kw}) | |
| if res.get("ok"): | |
| return res["result"]["message_id"] | |
| log.warning(f"⚠️ sendMessage failed: {res}") | |
| except Exception as e: | |
| log.error(f"❌ send error: {e!r}") | |
| return None | |
| async def edit(chat_id, message_id, text, **kw): | |
| kw.setdefault("parse_mode", "HTML") | |
| kw.setdefault("disable_web_page_preview", True) | |
| kw = {k: v for k, v in kw.items() if v is not None} | |
| try: | |
| res = await tg("editMessageText", {"chat_id": chat_id, "message_id": message_id, "text": text, **kw}) | |
| if not res.get("ok") and "message is not modified" not in str(res): | |
| log.warning(f"⚠️ editMessageText failed: {res}") | |
| except Exception as e: | |
| log.error(f"❌ edit error: {e!r}") | |
| async def send_document(chat_id: int, filename: str, content: bytes, caption: str = ""): | |
| try: | |
| files = {"document": (filename, content, "application/octet-stream")} | |
| data = {"chat_id": str(chat_id)} | |
| if caption: | |
| data["caption"] = caption | |
| async with httpx.AsyncClient(timeout=60) as client: | |
| r = await client.post(f"{TELEGRAM_API}/sendDocument", data=data, files=files) | |
| res = r.json() | |
| if not res.get("ok"): | |
| log.warning(f"⚠️ sendDocument failed: {res}") | |
| return res | |
| except Exception as e: | |
| log.error(f"❌ sendDocument error: {e!r}") | |
| return {"ok": False} | |
| # ───────────────────────────────────────────── | |
| # INPUT NORMALIZATION | |
| # ───────────────────────────────────────────── | |
| def normalize_phone(text: str) -> tuple[Optional[str], Optional[str]]: | |
| raw = text.strip() | |
| digits = "".join(ch for ch in raw if ch.isdigit()) | |
| if not digits: | |
| return None, "not a valid number" | |
| if len(digits) == 11 and digits.startswith("0"): | |
| digits = digits[1:] | |
| if len(digits) == 12 and digits.startswith("91"): | |
| digits = digits[2:] | |
| elif len(digits) == 11 and digits.startswith("91"): | |
| digits = digits[1:] | |
| if len(digits) == 10: | |
| if PHONE_REGEX.match(digits): | |
| return digits, None | |
| return None, "not a valid 10-digit number" | |
| return None, f"expected 10 digits, got {len(digits)}" | |
| # ───────────────────────────────────────────── | |
| # STATE | |
| # ───────────────────────────────────────────── | |
| SEARCH_CACHE = {} | |
| USER_LAST = {} | |
| STATS = {"queries": 0, "timeouts": 0, "errors": 0, "started": time.time()} | |
| def cache_gc(): | |
| global PEAK_CONCURRENT | |
| while True: | |
| time.sleep(60) | |
| now = time.time() | |
| dead = [k for k, v in SEARCH_CACHE.items() if now - v["ts"] > CACHE_TTL] | |
| for k in dead: | |
| SEARCH_CACHE.pop(k, None) | |
| with _activity_lock: | |
| cutoff = now - LIVE_WINDOW | |
| for uid in list(ACTIVE_USERS.keys()): | |
| if ACTIVE_USERS[uid] < cutoff: | |
| del ACTIVE_USERS[uid] | |
| if len(ACTIVE_USERS) > PEAK_CONCURRENT: | |
| PEAK_CONCURRENT = len(ACTIVE_USERS) | |
| # ───────────────────────────────────────────── | |
| # CORE SEARCH — PROGRESSIVE FLOW | |
| # ───────────────────────────────────────────── | |
| async def _db_search_async(phone: str, loop) -> list: | |
| """Blocking DuckDB search wrapped for asyncio, with graceful failure.""" | |
| if not _con_ready.is_set(): | |
| return [] | |
| try: | |
| return await asyncio.wait_for( | |
| loop.run_in_executor(None, run_search_safe, phone, FETCH_LIMIT), | |
| timeout=QUERY_TIMEOUT + 10, | |
| ) | |
| except (asyncio.TimeoutError, TimeoutError): | |
| STATS["timeouts"] += 1 | |
| log.warning(f"⏱️ DB timeout for {phone}") | |
| return [] | |
| except Exception as e: | |
| STATS["errors"] += 1 | |
| log.warning(f"⚠️ DB error for {phone}: {e!r}") | |
| return [] | |
| async def _show_progressive(chat_id, message_id, phone, results, elapsed, source_note=""): | |
| """Create a new search entry & edit the message. Returns search_id.""" | |
| search_id = uuid.uuid4().hex[:10] | |
| total_pages = max(1, (len(results) + PAGE_SIZE - 1) // PAGE_SIZE) | |
| SEARCH_CACHE[search_id] = { | |
| "ts": time.time(), "results": results, "query": phone, | |
| "elapsed": elapsed, "source_note": source_note, | |
| } | |
| try: | |
| await edit( | |
| chat_id, message_id, | |
| render_page(results, 0, total_pages, phone, elapsed, source_note), | |
| reply_markup=page_keyboard(search_id, 0, total_pages), | |
| ) | |
| except Exception as e: | |
| log.warning(f"⚠️ progressive render failed: {e!r}") | |
| return search_id | |
| async def _do_progressive_search(phone: str, chat_id: int, placeholder_id: int, user: dict): | |
| """ | |
| Progressive search: | |
| 1. Fire both API and DuckDB in parallel. | |
| 2. Show API results as soon as they arrive. | |
| 3. Edit the SAME message when DuckDB (HiTeck + Flipkart) returns with more data. | |
| """ | |
| t0 = time.time() | |
| loop = asyncio.get_running_loop() | |
| db_task = asyncio.create_task(_db_search_async(phone, loop)) | |
| api_task = asyncio.create_task(search_apis(phone)) | |
| # ── Phase 1: API results (fast) ── | |
| api_results = [] | |
| try: | |
| api_results = await asyncio.wait_for(api_task, timeout=75) | |
| except asyncio.TimeoutError: | |
| log.warning(f"⏱️ API timeout for {phone}") | |
| except Exception as e: | |
| log.warning(f"⚠️ API error for {phone}: {e!r}") | |
| api_deduped = dedup_records(api_results) | |
| api_elapsed = time.time() - t0 | |
| search_id = None | |
| if api_deduped: | |
| search_id = await _show_progressive( | |
| chat_id, placeholder_id, phone, api_deduped, api_elapsed, | |
| source_note="⚡ Fast results — querying database for more…", | |
| ) | |
| else: | |
| try: | |
| await edit( | |
| chat_id, placeholder_id, | |
| f"🔍 <b>Querying database…</b>\n\n" | |
| f"📱 <code>{html.escape(phone)}</code>\n" | |
| f"⏱️ {api_elapsed:.1f}s · APIs returned nothing, trying DuckDB…", | |
| ) | |
| except Exception: | |
| pass | |
| # ── Phase 2: DuckDB results (HiTeck + Flipkart) ── | |
| db_results = [] | |
| try: | |
| db_results = await db_task | |
| except Exception as e: | |
| log.warning(f"⚠️ DB task raised for {phone}: {e!r}") | |
| elapsed = time.time() - t0 | |
| combined = dedup_records(api_results + db_results) | |
| new_from_db = max(0, len(combined) - len(api_deduped)) | |
| if api_results and db_results: | |
| source = "hybrid" | |
| elif db_results: | |
| source = "duckdb" | |
| elif api_results: | |
| source = "api" | |
| else: | |
| source = "empty" | |
| # ── No results anywhere ── | |
| if not combined: | |
| _log_activity(user, "search", phone, "no_results", | |
| "no records", int(elapsed * 1000), source) | |
| try: | |
| await edit( | |
| chat_id, placeholder_id, | |
| f"❌ <b>No record found</b>\n\n" | |
| f"📱 <code>{html.escape(phone)}</code>\n" | |
| f"⏱️ {elapsed:.1f}s", | |
| ) | |
| except Exception: | |
| pass | |
| return 0, elapsed, source | |
| # ── Final render ── | |
| note = f"➕ {new_from_db} more from database" if (new_from_db > 0 and api_deduped) else "" | |
| total_pages = max(1, (len(combined) + PAGE_SIZE - 1) // PAGE_SIZE) | |
| if search_id: | |
| # Same message, mutated cache entry | |
| SEARCH_CACHE[search_id]["results"] = combined | |
| SEARCH_CACHE[search_id]["elapsed"] = elapsed | |
| SEARCH_CACHE[search_id]["source_note"] = note | |
| SEARCH_CACHE[search_id]["ts"] = time.time() | |
| try: | |
| await edit( | |
| chat_id, placeholder_id, | |
| render_page(combined, 0, total_pages, phone, elapsed, note), | |
| reply_markup=page_keyboard(search_id, 0, total_pages), | |
| ) | |
| except Exception as e: | |
| log.warning(f"⚠️ final render failed: {e!r}") | |
| else: | |
| search_id = await _show_progressive( | |
| chat_id, placeholder_id, phone, combined, elapsed, note, | |
| ) | |
| STATS["queries"] += 1 | |
| _add_to_history(user.get("id"), phone, len(combined), elapsed, source) | |
| _log_activity(user, "search", phone, "results", | |
| f"{len(combined)} result(s) (+{new_from_db} new)", | |
| int(elapsed * 1000), source) | |
| return len(combined), elapsed, source | |
| async def _search_quiet(phone: str) -> tuple[int, bool]: | |
| """For batch mode — no UI updates, returns (count, ok).""" | |
| loop = asyncio.get_running_loop() | |
| api_task = asyncio.create_task(search_apis(phone)) | |
| db_task = asyncio.create_task(_db_search_async(phone, loop)) | |
| api_results, db_results = [], [] | |
| try: | |
| api_results = await asyncio.wait_for(api_task, timeout=25) | |
| except Exception: | |
| pass | |
| try: | |
| db_results = await asyncio.wait_for(db_task, timeout=max(5, BATCH_TIMEOUT - 25)) | |
| except Exception: | |
| pass | |
| combined = dedup_records(api_results + db_results) | |
| return len(combined), len(combined) > 0 | |
| # ───────────────────────────────────────────── | |
| # HANDLERS | |
| # ───────────────────────────────────────────── | |
| WELCOME_TEMPLATE = ( | |
| "👋 <b>Phone Lookup Bot</b> v{v}\n\n" | |
| "Send me any <b>10-digit mobile number</b> and I'll return every " | |
| "record we have on file.\n\n" | |
| "📌 <b>Examples</b>\n" | |
| "• <code>9876543210</code>\n" | |
| "• <code>+91 98765 43210</code>\n" | |
| "• <code>9876543210,9876543211</code> (batch)\n\n" | |
| "<i>Commands:</i>\n" | |
| "• /start — this message\n" | |
| "• /info — bot info & credits\n" | |
| "• /status — bot health\n" | |
| "• /history — your last 10 searches\n" | |
| "• /myid — your Telegram ID" | |
| ) | |
| async def handle_message(msg: dict): | |
| try: | |
| chat_id = msg["chat"]["id"] | |
| user = msg.get("from") or {} | |
| user_id = user.get("id") | |
| text = (msg.get("text") or "").strip() | |
| log.info(f"💬 Message from {user_id}: {text[:50]!r}") | |
| if not text or user_id is None: | |
| return | |
| # ── BAN CHECK ── | |
| if _is_banned(user_id): | |
| with _state_lock: | |
| reason = BAN_REASONS.get(user_id) or "no reason given" | |
| await send(chat_id, f"🚫 <b>You are banned.</b>\n\nReason: {html.escape(reason)}") | |
| _log_activity(user, "banned_hit", text[:40], "blocked", reason) | |
| return | |
| # ── ADMIN TOKEN INPUT ── | |
| state = _admin_state_get(user_id) | |
| if state and _is_admin_session(user_id) is False: | |
| if state == "await_admin_token": | |
| _admin_state_clear(user_id) | |
| if ADMIN_TOKEN and text == ADMIN_TOKEN: | |
| _grant_admin(user_id) | |
| _log_activity(user, "admin_login", "***", "granted", "session started") | |
| await send(chat_id, "✅ <b>Admin access granted</b>", | |
| reply_markup=admin_menu_kb()) | |
| await send(chat_id, render_admin_menu()) | |
| else: | |
| _log_activity(user, "admin_login", "***", "denied", "invalid token") | |
| await send(chat_id, "❌ <b>Invalid token.</b>\nTry /admin again.") | |
| return | |
| # ── ADMIN INPUT CAPTURE ── | |
| if state and _is_admin_session(user_id): | |
| _admin_state_clear(user_id) | |
| if state == "await_ban_id": | |
| parts = text.split(None, 1) | |
| target = parts[0].lstrip("@") | |
| reason = parts[1] if len(parts) > 1 else "" | |
| try: | |
| tid = int(target) | |
| except ValueError: | |
| await send(chat_id, "❗ Invalid user ID.") | |
| return | |
| _ban_user(tid, reason) | |
| _log_activity(user, "admin", f"/ban {tid}", "banned", reason or "no reason") | |
| await send(chat_id, f"🚫 Banned <code>{tid}</code> — {html.escape(reason or 'no reason')}") | |
| return | |
| if state == "await_unban_id": | |
| try: | |
| tid = int(text.strip()) | |
| except ValueError: | |
| await send(chat_id, "❗ Invalid user ID.") | |
| return | |
| ok = _unban_user(tid) | |
| _log_activity(user, "admin", f"/unban {tid}", "unbanned") | |
| await send(chat_id, "✅ Unbanned." if ok else "ℹ️ User was not banned.") | |
| return | |
| if state == "await_broadcast": | |
| asyncio.create_task(_run_broadcast(chat_id, text)) | |
| _log_activity(user, "admin", "/broadcast", "sending", f"{len(text)} chars") | |
| return | |
| if state == "await_logphone": | |
| phone, hint = normalize_phone(text) | |
| if not phone: | |
| await send(chat_id, f"❗ {hint}") | |
| return | |
| entries = [e for e in ACTIVITY_LOG | |
| if e["action"] == "search" and e["input"] == phone] | |
| entries.reverse() | |
| if not entries: | |
| await send(chat_id, f"🔎 No searches logged for <code>{phone}</code>.") | |
| return | |
| lines = [f"🔎 <b>Searches for</b> <code>{phone}</code> ({len(entries)} total)\n"] | |
| for e in entries[:20]: | |
| uname = e.get("username"); first = e.get("first_name") | |
| nm = f"@{uname}" if uname else (first or "(anon)") | |
| lines.append(f"• {_fmt_ts(e['ts'])} · {html.escape(nm)} · <code>{e['user_id']}</code>") | |
| await send(chat_id, "\n".join(lines)) | |
| _log_activity(user, "admin", f"logphone {phone}", "text") | |
| return | |
| if state == "await_maint_reason": | |
| _set_maintenance(True, text[:100]) | |
| _log_activity(user, "admin", "maintenance on", "state", text[:100]) | |
| await send(chat_id, f"🔴 <b>Maintenance ON</b>\nReason: {html.escape(text[:100])}") | |
| return | |
| # ── MAINTENANCE GATE ── | |
| m_on, m_reason = _get_maintenance() | |
| if m_on and not _is_admin_session(user_id): | |
| if text in ("/start", "/help", "/status", "/info", "/myid"): | |
| pass | |
| else: | |
| await send(chat_id, f"🛠️ <b>Bot under maintenance</b>\n\n{html.escape(m_reason)}") | |
| _log_activity(user, "maintenance_block", text[:40], "blocked") | |
| return | |
| # ── /admin ── | |
| if text == "/admin": | |
| if not ADMIN_TOKEN: | |
| await send(chat_id, "⚠️ Admin panel is not configured.") | |
| return | |
| if _is_admin_session(user_id): | |
| _log_activity(user, "admin", "/admin", "menu") | |
| await send(chat_id, render_admin_menu(), reply_markup=admin_menu_kb()) | |
| else: | |
| _admin_state_set(user_id, "await_admin_token") | |
| await send(chat_id, "🔐 <b>Admin access required</b>\n\n" | |
| "Send the admin token to continue. " | |
| "It expires in 3 minutes.") | |
| return | |
| # ── /logout ── | |
| if text == "/logout": | |
| if _is_admin_session(user_id): | |
| _revoke_admin(user_id) | |
| _log_activity(user, "admin_logout", "/logout", "revoked") | |
| await send(chat_id, "✅ Admin session ended.") | |
| else: | |
| await send(chat_id, "You are not logged in as admin.") | |
| return | |
| # ── ADMIN COMMANDS ── | |
| if text.startswith("/") and _is_admin_session(user_id): | |
| cmd = text.split()[0].lower() | |
| args = text.split()[1:] | |
| if cmd == "/live": | |
| _log_activity(user, "admin", "/live", "text") | |
| await send(chat_id, render_live()); return | |
| if cmd == "/stats": | |
| _log_activity(user, "admin", "/stats", "text") | |
| await send(chat_id, render_stats()); return | |
| if cmd == "/users": | |
| _log_activity(user, "admin", "/users", "text") | |
| t, kb = render_users(0); await send(chat_id, t, reply_markup=kb); return | |
| if cmd == "/top": | |
| _log_activity(user, "admin", "/top", "text") | |
| await send(chat_id, render_top()); return | |
| if cmd == "/log": | |
| entries = list(ACTIVITY_LOG); entries.reverse() | |
| label = "" | |
| if args: | |
| try: | |
| tid = int(args[0]) | |
| entries = [e for e in entries if e["user_id"] == tid] | |
| label = f" (user {tid})" | |
| except ValueError: | |
| target = args[0].lstrip("@").lower() | |
| entries = [e for e in entries if (e.get("username") or "").lower() == target] | |
| label = f" (@{target})" | |
| _log_activity(user, "admin", text, "log_view") | |
| t, kb = render_log(entries, 0) | |
| if label: t = t.replace("<b>Activity Log</b>", f"<b>Activity Log{label}</b>", 1) | |
| await send(chat_id, t, reply_markup=kb); return | |
| if cmd == "/banned": | |
| _log_activity(user, "admin", "/banned", "text") | |
| await send(chat_id, render_banned()); return | |
| if cmd == "/ban": | |
| if not args: | |
| _admin_state_set(user_id, "await_ban_id") | |
| await send(chat_id, "Send user ID (and optional reason):") | |
| return | |
| tid_s = args[0].lstrip("@") | |
| reason = " ".join(args[1:]) if len(args) > 1 else "" | |
| try: tid = int(tid_s) | |
| except ValueError: | |
| await send(chat_id, "❗ Invalid user ID."); return | |
| _ban_user(tid, reason) | |
| _log_activity(user, "admin", f"/ban {tid}", "banned", reason or "no reason") | |
| await send(chat_id, f"🚫 Banned <code>{tid}</code> — {html.escape(reason or 'no reason')}") | |
| return | |
| if cmd == "/unban": | |
| if not args: | |
| _admin_state_set(user_id, "await_unban_id") | |
| await send(chat_id, "Send user ID to unban:") | |
| return | |
| try: tid = int(args[0]) | |
| except ValueError: | |
| await send(chat_id, "❗ Invalid user ID."); return | |
| ok = _unban_user(tid) | |
| _log_activity(user, "admin", f"/unban {tid}", "unbanned") | |
| await send(chat_id, "✅ Unbanned." if ok else "ℹ️ User was not banned.") | |
| return | |
| if cmd == "/broadcast": | |
| if not args: | |
| _admin_state_set(user_id, "await_broadcast") | |
| await send(chat_id, "Send the message to broadcast to all users:") | |
| return | |
| await send(chat_id, "ℹ️ Use /broadcast with no args, then send the message.") | |
| return | |
| if cmd == "/export": | |
| _log_activity(user, "admin", "/export", "file") | |
| await _send_log_export(chat_id); return | |
| if cmd == "/logphone": | |
| if not args: | |
| _admin_state_set(user_id, "await_logphone") | |
| await send(chat_id, "Send a 10-digit number to search the log:") | |
| return | |
| phone, hint = normalize_phone(args[0]) | |
| if not phone: | |
| await send(chat_id, f"❗ {hint}"); return | |
| entries = [e for e in ACTIVITY_LOG | |
| if e["action"] == "search" and e["input"] == phone] | |
| entries.reverse() | |
| if not entries: | |
| await send(chat_id, f"🔎 No searches for <code>{phone}</code>."); return | |
| lines = [f"🔎 <b>Searches for</b> <code>{phone}</code> ({len(entries)})\n"] | |
| for e in entries[:20]: | |
| uname = e.get("username"); first = e.get("first_name") | |
| nm = f"@{uname}" if uname else (first or "(anon)") | |
| lines.append(f"• {_fmt_ts(e['ts'])} · {html.escape(nm)} · <code>{e['user_id']}</code>") | |
| await send(chat_id, "\n".join(lines)); return | |
| if cmd == "/maintenance": | |
| if not args: | |
| m_on, m_reason = _get_maintenance() | |
| st = "ON" if m_on else "OFF" | |
| await send(chat_id, f"🛠️ Maintenance: <b>{st}</b>" | |
| + (f"\nReason: {html.escape(m_reason)}" if m_on else "")) | |
| return | |
| mode = args[0].lower() | |
| if mode == "on": | |
| reason = " ".join(args[1:]) if len(args) > 1 else "" | |
| if reason: | |
| _set_maintenance(True, reason) | |
| _log_activity(user, "admin", "maintenance on", "state", reason) | |
| await send(chat_id, f"🔴 Maintenance ON — {html.escape(reason)}") | |
| else: | |
| _admin_state_set(user_id, "await_maint_reason") | |
| await send(chat_id, "Send a short reason for maintenance:") | |
| return | |
| elif mode == "off": | |
| _set_maintenance(False) | |
| _log_activity(user, "admin", "maintenance off", "state") | |
| await send(chat_id, "🟢 Maintenance OFF"); return | |
| await send(chat_id, "Usage: /maintenance on|off [reason]"); return | |
| # ── /status ── | |
| if text == "/status": | |
| uptime = (time.time() - STATS["started"]) / 60 | |
| m_on, _ = _get_maintenance() | |
| _log_activity(user, "status", "/status", "text") | |
| await send(chat_id, ( | |
| f"<b>📊 Bot Status</b> — v{VERSION}\n\n" | |
| f"⏱️ Uptime: <code>{uptime:.1f} min</code>\n" | |
| f"🔌 DuckDB: <code>{_con_ready.is_set()}</code>\n" | |
| f"🔥 Cache warmed: <code>{_warmed.is_set()}</code>\n" | |
| f"📂 Files: <code>{len(PARQUET_URLS)}</code> HiTeck + " | |
| f"<code>{len(FLIPKART_URLS)}</code> Flipkart\n" | |
| f"🔗 APIs: <code>850CR + IndData</code>\n" | |
| f"🛠️ Maintenance: <code>{m_on}</code>\n" | |
| f"🔍 Queries: <code>{STATS['queries']}</code>\n" | |
| f"⏰ Timeouts: <code>{STATS['timeouts']}</code>\n" | |
| f"❌ Errors: <code>{STATS['errors']}</code>" | |
| )); return | |
| # ── /info ── | |
| if text == "/info": | |
| uptime = (time.time() - STATS["started"]) / 60 | |
| s = _get_stats() | |
| _log_activity(user, "info", "/info", "text") | |
| await send(chat_id, ( | |
| f"ℹ️ <b>About This Bot</b>\n" | |
| "━━━━━━━━━━━━━━━━━━\n" | |
| f"🤖 Version: <code>{VERSION}</code>\n" | |
| f"📊 Sources: HiTeck (7) + Flipkart (1) + 2 APIs\n" | |
| f"👥 Unique users: <code>{s['unique_users']}</code>\n" | |
| f"🔍 Searches: <code>{s['total_searches']}</code>\n" | |
| f"⏱️ Avg latency (today): " | |
| f"<code>{_avg_latency_today():.1f}s</code>\n" | |
| f"🕒 Uptime: <code>{uptime:.0f} min</code>\n" | |
| "━━━━━━━━━━━━━━━━━━\n" | |
| "Built with ❤️ for fast, private lookups." | |
| )); return | |
| # ── /history ── | |
| if text == "/history": | |
| with _activity_lock: | |
| h = list(USER_HISTORY.get(user_id, [])) | |
| _log_activity(user, "history", "/history", "text") | |
| if not h: | |
| await send(chat_id, "📭 <b>No history yet</b>\n\nSearch a number to start.") | |
| return | |
| rows = [] | |
| for i, item in enumerate(h, 1): | |
| rows.append([{ | |
| "text": f"{i}. {item['phone']} ({item['count']} res)", | |
| "callback_data": f"h:{item['phone']}" | |
| }]) | |
| await send(chat_id, | |
| "🕘 <b>Your last searches</b>\n\nTap to search again:", | |
| reply_markup={"inline_keyboard": rows}) | |
| return | |
| # ── /myid ── | |
| if text == "/myid": | |
| _log_activity(user, "myid", "/myid", "text") | |
| await send(chat_id, f"🆔 <b>Your Telegram ID</b>\n<code>{user_id}</code>") | |
| return | |
| # ── /start, /help ── | |
| if text in ("/start", "/help"): | |
| _log_activity(user, "start", text, "text") | |
| await send(chat_id, WELCOME_TEMPLATE.format(v=VERSION)) | |
| if not _warmed.is_set() and not _warm_thread_started: | |
| threading.Thread(target=warm_phone_cache, daemon=True).start() | |
| return | |
| # ── BATCH SEARCH ── | |
| if "," in text: | |
| parts = [p.strip() for p in text.split(",") if p.strip()] | |
| if len(parts) < 2: | |
| await send(chat_id, "❌ <b>Batch format</b>\n\nExample: <code>9876543210,9876543211</code>") | |
| return | |
| if len(parts) > BATCH_MAX: | |
| await send(chat_id, f"❌ Max {BATCH_MAX} numbers per batch.") | |
| return | |
| phones = [] | |
| for p in parts: | |
| ph, hint = normalize_phone(p) | |
| if not ph: | |
| await send(chat_id, f"❌ Invalid number: <code>{html.escape(p)}</code> — {hint}") | |
| return | |
| phones.append(ph) | |
| await _handle_batch(chat_id, user, phones) | |
| return | |
| # ── SINGLE SEARCH ── | |
| phone, hint = normalize_phone(text) | |
| if not phone: | |
| _log_activity(user, "invalid", text, "error", hint) | |
| await send(chat_id, | |
| "❌ <b>Invalid input</b>\n\n" | |
| "Send a 10-digit number.\n" | |
| "Also accepts: <code>+91 98765 43210</code>, " | |
| "<code>09876543210</code>, <code>9876543210,9876543211</code>") | |
| return | |
| if text.strip() != phone and not text.strip().isdigit(): | |
| await send(chat_id, f"ℹ️ Interpreted as <code>{phone}</code>") | |
| # Cooldown | |
| now = time.time() | |
| if now - USER_LAST.get(user_id, 0) < USER_COOLDOWN: | |
| wait = USER_COOLDOWN - (now - USER_LAST.get(user_id, 0)) | |
| _log_activity(user, "cooldown", phone, "text", f"wait {wait:.0f}s") | |
| await send(chat_id, f"⏳ Wait {wait:.0f}s before next search.") | |
| return | |
| USER_LAST[user_id] = now | |
| if not _warmed.is_set() and not _warm_thread_started: | |
| threading.Thread(target=warm_phone_cache, daemon=True).start() | |
| placeholder = await send( | |
| chat_id, | |
| f"🔍 <b>Searching…</b>\n\n📱 <code>{html.escape(phone)}</code>\n\n" | |
| f"<i>Fast results appear instantly; database enrich follows.</i>" | |
| ) | |
| if placeholder is None: | |
| return | |
| try: | |
| await _do_progressive_search(phone, chat_id, placeholder, user) | |
| except asyncio.TimeoutError: | |
| STATS["timeouts"] += 1 | |
| _log_activity(user, "search", phone, "timeout", "progressive timeout") | |
| await edit(chat_id, placeholder, f"⏱️ Timed out. Try again.") | |
| except Exception as e: | |
| STATS["errors"] += 1 | |
| log.error(f"❌ progressive search error: {e!r}\n{traceback.format_exc()}") | |
| _log_activity(user, "search", phone, "error", str(e)[:100]) | |
| await edit(chat_id, placeholder, f"❌ Error: <code>{html.escape(str(e)[:200])}</code>") | |
| except Exception as e: | |
| log.error(f"❌ handle_message fatal: {e!r}\n{traceback.format_exc()}") | |
| async def _handle_batch(chat_id, user, phones): | |
| await send(chat_id, f"🔍 <b>Batch search</b> — {len(phones)} number(s)…") | |
| t0 = time.time() | |
| tasks = [asyncio.create_task(_search_quiet(p)) for p in phones] | |
| results = await asyncio.gather(*tasks, return_exceptions=True) | |
| total_elapsed = time.time() - t0 | |
| lines = [f"📊 <b>Batch results</b> ({len(phones)} numbers) — {total_elapsed:.1f}s\n"] | |
| for phone, res in zip(phones, results): | |
| if isinstance(res, Exception): | |
| lines.append(f"❌ <code>{phone}</code> — error: {html.escape(str(res)[:60])}") | |
| else: | |
| n, ok = res | |
| if ok: | |
| lines.append(f"✅ <code>{phone}</code> — {n} record(s)") | |
| else: | |
| lines.append(f"❌ <code>{phone}</code> — no results") | |
| kb_rows = [[{"text": f"View {phone}", "callback_data": f"h:{phone}"}] for phone in phones] | |
| _log_activity(user, "batch_search", ",".join(phones), "text", | |
| f"{len(phones)} nums in {total_elapsed:.1f}s") | |
| await send(chat_id, "\n".join(lines), | |
| reply_markup={"inline_keyboard": kb_rows}) | |
| async def _send_log_export(chat_id: int): | |
| entries = list(ACTIVITY_LOG) | |
| if not entries: | |
| await send(chat_id, "📭 No activity to export.") | |
| return | |
| buf = io.StringIO() | |
| w = csv.writer(buf) | |
| w.writerow(["timestamp", "user_id", "username", "first_name", | |
| "action", "input", "reply_type", "summary", "elapsed_ms", "source"]) | |
| for e in entries: | |
| w.writerow([ | |
| time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(e["ts"])), | |
| e["user_id"], e.get("username") or "", e.get("first_name") or "", | |
| e["action"], e.get("input", ""), e.get("reply_type", ""), | |
| e.get("summary", ""), e.get("elapsed_ms", 0), e.get("source", ""), | |
| ]) | |
| csv_bytes = buf.getvalue().encode("utf-8") | |
| ts = time.strftime("%Y%m%d_%H%M%S") | |
| await send_document(chat_id, f"activity_log_{ts}.csv", csv_bytes, | |
| caption=f"📥 Activity log — {len(entries)} entries") | |
| async def _run_broadcast(admin_chat_id: int, message: str): | |
| with _activity_lock: | |
| recipients = list(UNIQUE_USERS.keys()) | |
| if not recipients: | |
| await send(admin_chat_id, "📭 No users to broadcast to.") | |
| return | |
| await send(admin_chat_id, f"📢 Broadcasting to <b>{len(recipients)}</b> users…") | |
| sent = failed = 0 | |
| for uid in recipients: | |
| if _is_banned(uid): | |
| continue | |
| try: | |
| res = await tg("sendMessage", { | |
| "chat_id": uid, | |
| "text": f"📢 <b>Announcement</b>\n\n{message}", | |
| "parse_mode": "HTML", | |
| "disable_web_page_preview": True, | |
| }, timeout=15) | |
| if res.get("ok"): | |
| sent += 1 | |
| else: | |
| failed += 1 | |
| except Exception: | |
| failed += 1 | |
| await asyncio.sleep(BROADCAST_DELAY) | |
| await send(admin_chat_id, | |
| f"✅ <b>Broadcast complete</b>\n\n" | |
| f"Sent: <code>{sent}</code>\n" | |
| f"Failed: <code>{failed}</code>") | |
| _log_activity({"id": 0}, "admin", "broadcast", "done", | |
| f"sent={sent} failed={failed}") | |
| def _avg_latency_today() -> float: | |
| now = time.time() | |
| today_start = now - (now % 86400) | |
| with _activity_lock: | |
| vals = [e["elapsed_ms"] for e in ACTIVITY_LOG | |
| if e["action"] == "search" and e["ts"] >= today_start and e["elapsed_ms"] > 0] | |
| if not vals: | |
| return 0.0 | |
| return (sum(vals) / len(vals)) / 1000.0 | |
| async def handle_callback(cb: dict): | |
| try: | |
| data = cb.get("data", "") | |
| cb_id = cb["id"] | |
| msg = cb.get("message") or {} | |
| chat_id = msg.get("chat", {}).get("id") | |
| message_id = msg.get("message_id") | |
| user = cb.get("from") or {} | |
| user_id = user.get("id") | |
| if not chat_id or not message_id: | |
| await tg("answerCallbackQuery", {"callback_query_id": cb_id}) | |
| return | |
| if _is_banned(user_id): | |
| await tg("answerCallbackQuery", { | |
| "callback_query_id": cb_id, "text": "🚫 You are banned.", | |
| "show_alert": True, | |
| }); return | |
| # ── Admin callbacks ── | |
| if data.startswith("adm:"): | |
| if not _is_admin_session(user_id): | |
| await tg("answerCallbackQuery", { | |
| "callback_query_id": cb_id, | |
| "text": "Admin session expired — send /admin again.", | |
| "show_alert": True, | |
| }); return | |
| parts = data.split(":") | |
| action = parts[1] if len(parts) > 1 else "menu" | |
| if action == "menu": | |
| await edit(chat_id, message_id, render_admin_menu(), reply_markup=admin_menu_kb()) | |
| elif action == "live": | |
| await edit(chat_id, message_id, render_live(), reply_markup=admin_menu_kb()) | |
| elif action == "stats": | |
| await edit(chat_id, message_id, render_stats(), reply_markup=admin_menu_kb()) | |
| elif action == "top": | |
| await edit(chat_id, message_id, render_top(), reply_markup=admin_menu_kb()) | |
| elif action == "banned": | |
| await edit(chat_id, message_id, render_banned(), reply_markup=admin_menu_kb()) | |
| elif action == "log": | |
| page = int(parts[2]) if len(parts) > 2 else 0 | |
| entries = list(ACTIVITY_LOG); entries.reverse() | |
| t, kb = render_log(entries, page) | |
| await edit(chat_id, message_id, t, reply_markup=kb) | |
| elif action == "users": | |
| page = int(parts[2]) if len(parts) > 2 else 0 | |
| t, kb = render_users(page) | |
| await edit(chat_id, message_id, t, reply_markup=kb) | |
| elif action == "ban": | |
| _admin_state_set(user_id, "await_ban_id") | |
| await edit(chat_id, message_id, "🚫 Send user ID (and optional reason):") | |
| elif action == "unban": | |
| _admin_state_set(user_id, "await_unban_id") | |
| await edit(chat_id, message_id, "✅ Send user ID to unban:") | |
| elif action == "broadcast": | |
| _admin_state_set(user_id, "await_broadcast") | |
| await edit(chat_id, message_id, "📢 Send the message to broadcast:") | |
| elif action == "export": | |
| await edit(chat_id, message_id, "📥 Generating export…", | |
| reply_markup=admin_menu_kb()) | |
| await _send_log_export(chat_id) | |
| elif action == "logphone": | |
| _admin_state_set(user_id, "await_logphone") | |
| await edit(chat_id, message_id, "🔎 Send a 10-digit number:") | |
| elif action == "maint": | |
| m_on, _ = _get_maintenance() | |
| if m_on: | |
| _set_maintenance(False) | |
| await edit(chat_id, message_id, "🟢 Maintenance OFF", | |
| reply_markup=admin_menu_kb()) | |
| else: | |
| _admin_state_set(user_id, "await_maint_reason") | |
| await edit(chat_id, message_id, "Send a short maintenance reason:") | |
| elif action == "noop": | |
| pass | |
| await tg("answerCallbackQuery", {"callback_query_id": cb_id}) | |
| _log_activity(user, "admin", data, "menu") | |
| return | |
| # ── History re-run ── | |
| if data.startswith("h:"): | |
| phone = data.split(":", 1)[1] | |
| await tg("answerCallbackQuery", { | |
| "callback_query_id": cb_id, | |
| "text": f"Searching {phone}…", | |
| }) | |
| fake_msg = {"chat": {"id": chat_id}, "from": user, "text": phone} | |
| await handle_message(fake_msg) | |
| return | |
| # ── Copy page ── | |
| if data.startswith("c:"): | |
| try: | |
| _, sid, page_s = data.split(":") | |
| page = int(page_s) | |
| except Exception: | |
| await tg("answerCallbackQuery", {"callback_query_id": cb_id}) | |
| return | |
| entry = SEARCH_CACHE.get(sid) | |
| if not entry: | |
| await tg("answerCallbackQuery", { | |
| "callback_query_id": cb_id, | |
| "text": "Session expired.", "show_alert": True, | |
| }); return | |
| results = entry["results"] | |
| total_pages = max(1, (len(results) + PAGE_SIZE - 1) // PAGE_SIZE) | |
| page = max(0, min(page, total_pages - 1)) | |
| start = page * PAGE_SIZE | |
| chunk = results[start:start + PAGE_SIZE] | |
| text = "\n\n".join(_plain_record(start + i + 1, r) | |
| for i, r in enumerate(chunk)) | |
| await send(chat_id, f"<b>📋 Copy text for page {page + 1}</b>\n\n" | |
| f"<pre>{html.escape(text[:3500])}</pre>") | |
| await tg("answerCallbackQuery", {"callback_query_id": cb_id, | |
| "text": "Sent as a copyable message"}) | |
| _log_activity(user, "copy", entry["query"], "text") | |
| return | |
| # ── Export ── | |
| if data.startswith("e:"): | |
| sid = data.split(":", 1)[1] | |
| entry = SEARCH_CACHE.get(sid) | |
| if not entry: | |
| await tg("answerCallbackQuery", { | |
| "callback_query_id": cb_id, | |
| "text": "Session expired.", "show_alert": True, | |
| }); return | |
| phone = entry["query"] | |
| results = entry["results"] | |
| payload = json.dumps(results, indent=2, ensure_ascii=False).encode("utf-8") | |
| await send_document(chat_id, f"results_{phone}.json", payload, | |
| caption=f"📥 {len(results)} record(s) for {phone}") | |
| await tg("answerCallbackQuery", {"callback_query_id": cb_id, | |
| "text": "Sent as file"}) | |
| _log_activity(user, "export", phone, "file", f"{len(results)} records") | |
| return | |
| # ── Pagination ── | |
| if data == "noop" or not data.startswith("p:"): | |
| await tg("answerCallbackQuery", {"callback_query_id": cb_id}) | |
| return | |
| try: | |
| _, search_id, page_str = data.split(":") | |
| page = int(page_str) | |
| except Exception: | |
| await tg("answerCallbackQuery", {"callback_query_id": cb_id}) | |
| return | |
| entry = SEARCH_CACHE.get(search_id) | |
| if not entry: | |
| await tg("answerCallbackQuery", { | |
| "callback_query_id": cb_id, | |
| "text": "Session expired — search again.", "show_alert": True, | |
| }); return | |
| results = entry["results"] | |
| total_pages = max(1, (len(results) + PAGE_SIZE - 1) // PAGE_SIZE) | |
| page = max(0, min(page, total_pages - 1)) | |
| await edit( | |
| chat_id, message_id, | |
| render_page(results, page, total_pages, entry["query"], | |
| entry["elapsed"], entry.get("source_note", "")), | |
| reply_markup=page_keyboard(search_id, page, total_pages), | |
| ) | |
| await tg("answerCallbackQuery", {"callback_query_id": cb_id}) | |
| _log_activity(user, "paginate", entry["query"], "page", | |
| f"page {page + 1}/{total_pages}") | |
| except Exception as e: | |
| log.error(f"❌ handle_callback fatal: {e!r}\n{traceback.format_exc()}") | |
| # ───────────────────────────────────────────── | |
| # WEBHOOK & HEALTH | |
| # ───────────────────────────────────────────── | |
| async def set_webhook(): | |
| if not SPACE_URL: return | |
| url = f"{SPACE_URL}/webhook" | |
| payload = { | |
| "url": url, "secret_token": WEBHOOK_SECRET, | |
| "drop_pending_updates": True, | |
| "allowed_updates": ["message", "callback_query"], | |
| } | |
| for attempt in range(1, 4): | |
| try: | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| r = await client.post(f"{TELEGRAM_API}/setWebhook", json=payload) | |
| data = r.json() | |
| log.info(f"🔗 setWebhook → {data}") | |
| if data.get("ok"): return | |
| except Exception as e: | |
| log.warning(f"⚠️ setWebhook attempt {attempt}: {e}") | |
| await asyncio.sleep(3) | |
| async def _health_endpoint(request: Request): | |
| s = _get_stats() | |
| return JSONResponse({ | |
| "status": "ok", | |
| "version": VERSION, | |
| "duckdb_ready": _con_ready.is_set(), | |
| "warmed": _warmed.is_set(), | |
| "files": len(ALL_PARQUET_URLS), | |
| "hiteck_files": len(PARQUET_URLS), | |
| "flipkart_files": len(FLIPKART_URLS), | |
| "cached_searches": len(SEARCH_CACHE), | |
| "live_users": s["live_users"], | |
| "unique_users": s["unique_users"], | |
| "banned_users": s["banned_count"], | |
| "maintenance": s["maintenance"], | |
| "apis": {"cr850": API_850CR, "inddata": API_INDDATA}, | |
| "stats": STATS, | |
| }) | |
| async def webhook_handler(request: Request): | |
| if WEBHOOK_SECRET: | |
| got = request.headers.get("x-telegram-bot-api-secret-token", "") | |
| if got != WEBHOOK_SECRET: | |
| return JSONResponse({"ok": False}, status_code=403) | |
| try: | |
| update = await request.json() | |
| except Exception: | |
| return JSONResponse({"ok": True}) | |
| if "message" in update: | |
| asyncio.create_task(handle_message(update["message"])) | |
| elif "callback_query" in update: | |
| asyncio.create_task(handle_callback(update["callback_query"])) | |
| return JSONResponse({"ok": True}) | |
| # ───────────────────────────────────────────── | |
| # GRADIO UI & LAUNCH | |
| # ───────────────────────────────────────────── | |
| with gr.Blocks(title="Phone Lookup Bot") as demo: | |
| gr.Markdown(f"# 📞 Phone Lookup Bot v{VERSION}") | |
| gr.Markdown("Telegram bot: HiTeck + Flipkart parquet + 850CR API + IndData API (progressive results).") | |
| status_box = gr.JSON(label="Health") | |
| demo.load( | |
| fn=lambda: { | |
| "status": "ok", "version": VERSION, | |
| "duckdb_ready": _con_ready.is_set(), | |
| "warmed": _warmed.is_set(), | |
| "files": len(ALL_PARQUET_URLS), | |
| "cached_searches": len(SEARCH_CACHE), | |
| "live_users": _get_stats()["live_users"], | |
| "unique_users": _get_stats()["unique_users"], | |
| "maintenance": _get_stats()["maintenance"], | |
| }, | |
| outputs=status_box, | |
| ) | |
| try: | |
| import spaces | |
| def _zerogpu_register(): return "gpu-ready" | |
| _zerogpu_btn = gr.Button("init-gpu", visible=False) | |
| _zerogpu_btn.click(fn=_zerogpu_register, inputs=None, outputs=None) | |
| log.info("✅ @spaces.GPU registered") | |
| except Exception as e: | |
| log.warning(f"⚠️ ZeroGPU registration: {e}") | |
| # ───────────────────────────────────────────── | |
| # STARTUP | |
| # ───────────────────────────────────────────── | |
| threading.Thread(target=cache_gc, daemon=True).start() | |
| threading.Thread(target=get_con, daemon=True).start() | |
| threading.Thread(target=warm_phone_cache, daemon=True).start() | |
| demo.launch( | |
| server_name="0.0.0.0", server_port=7860, share=False, | |
| ssr_mode=False, prevent_thread_lock=True, | |
| ) | |
| log.info("🔎 Launch returned — injecting routes") | |
| live_app = demo.app | |
| live_app.router.routes.insert(0, StarletteRoute("/webhook", webhook_handler, methods=["POST"])) | |
| live_app.router.routes.insert(0, StarletteRoute("/health", _health_endpoint, methods=["GET"])) | |
| # ── Admin web panel — loaded from separate file if available ── | |
| try: | |
| from admin_panel import admin_dashboard # type: ignore | |
| live_app.router.routes.insert(0, StarletteRoute("/admin", admin_dashboard, methods=["GET"])) | |
| log.info("✅ /admin web dashboard loaded from admin_panel.py") | |
| except ImportError: | |
| log.info("ℹ️ admin_panel.py not present — /admin web route disabled (Telegram /admin still works)") | |
| except Exception as e: | |
| log.warning(f"⚠️ admin_panel.py load failed: {e!r}") | |
| try: | |
| live_app.build_middleware_stack() | |
| log.info("✅ Middleware stack rebuilt") | |
| except Exception as e: | |
| log.warning(f"⚠️ Stack rebuild: {e}") | |
| log.info("📋 First 6 routes:") | |
| for route in live_app.router.routes[:6]: | |
| path = getattr(route, "path", "?") | |
| methods = getattr(route, "methods", None) | |
| log.info(f" {sorted(methods) if methods else '?'} {path}") | |
| def _register_webhook_later(): | |
| loop = asyncio.new_event_loop() | |
| asyncio.set_event_loop(loop) | |
| loop.run_until_complete(set_webhook()) | |
| threading.Thread(target=_register_webhook_later, daemon=True).start() | |
| demo.block_thread() |