Hfzune commited on
Commit
42f40ba
·
verified ·
1 Parent(s): 9e6f417

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +689 -0
app.py ADDED
@@ -0,0 +1,689 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ══════════════════════════════════════════════════════════════════
2
+ # CRITICAL SHIM — MUST BE THE FIRST EXECUTABLE CODE
3
+ # ══════════════════════════════════════════════════════════════════
4
+ import huggingface_hub
5
+
6
+ if not hasattr(huggingface_hub, "HfFolder"):
7
+ class _HfFolder:
8
+ @staticmethod
9
+ def get_token():
10
+ try: return huggingface_hub.get_token()
11
+ except Exception: return None
12
+ @staticmethod
13
+ def save_token(token):
14
+ try: huggingface_hub.login(token)
15
+ except Exception: pass
16
+ @staticmethod
17
+ def delete_token():
18
+ try: huggingface_hub.logout()
19
+ except Exception: pass
20
+ huggingface_hub.HfFolder = _HfFolder
21
+
22
+ # ── Now safe to import gradio ─────────────────────────────────────
23
+ import os
24
+ import asyncio
25
+ import html
26
+ import re
27
+ import time
28
+ import uuid
29
+ import threading
30
+ import traceback
31
+ import logging
32
+ from typing import Optional
33
+
34
+ import duckdb
35
+ import httpx
36
+ import gradio as gr
37
+ from fastapi import Request
38
+ from fastapi.responses import JSONResponse
39
+ from starlette.routing import Route as StarletteRoute
40
+
41
+ # ─────────────────────────────────────────────
42
+ # CONFIG
43
+ # ─────────────────────────────────────────────
44
+ BOT_TOKEN = os.environ.get("BOT_TOKEN", "").strip()
45
+ HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
46
+ WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "1221").strip()
47
+ SPACE_URL = os.environ.get("SPACE_URL", "").strip().rstrip("/")
48
+ TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "").strip().rstrip("/")
49
+
50
+ TELEGRAM_API = (
51
+ f"{TELEGRAM_PROXY}/bot{BOT_TOKEN}" if TELEGRAM_PROXY
52
+ else f"https://api.telegram.org/bot{BOT_TOKEN}"
53
+ )
54
+
55
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
56
+ log = logging.getLogger("bot")
57
+
58
+ # ─────────────────────────────────────────────
59
+ # DATASETS & API FALLBACKS
60
+ # ─────────────────────────────────────────────
61
+ # 1. ICMR Dataset
62
+ ICMR_URLS = [
63
+ "https://huggingface.co/datasets/Kzr0xx/Icmr-and-hitek/resolve/main/part1.parquet",
64
+ "https://huggingface.co/datasets/Kzr0xx/Icmr-and-hitek/resolve/main/part2a.parquet",
65
+ "https://huggingface.co/datasets/Kzr0xx/Icmr-and-hitek/resolve/main/part2b_new.parquet",
66
+ ]
67
+
68
+ # 2. HiTeck Dataset (Only streaming the 7 phone index files, ignoring Aadhaar)
69
+ HITECK_URLS = [
70
+ f"https://huggingface.co/datasets/Hfzune/HiTeckgroop/resolve/main/idx_phone.{i}.parquet"
71
+ for i in range(7)
72
+ ]
73
+
74
+ # Combined list for DuckDB
75
+ PARQUET_URLS = ICMR_URLS + HITECK_URLS
76
+
77
+ # API Fallback Endpoints (Used when DuckDB is warming up)
78
+ API_850CR = "https://sauravsingh2111-850crfastsearch.hf.space/search"
79
+ API_INDDATA = "https://tfqdeadlo-inddataapi.hf.space/search"
80
+
81
+ PAGE_SIZE = 4
82
+ FETCH_LIMIT = 40
83
+ QUERY_TIMEOUT = 90
84
+ USER_COOLDOWN = 5
85
+ CACHE_TTL = 300
86
+
87
+ # Only 10-digit mobile numbers are searched
88
+ PHONE_REGEX = re.compile(r"^\d{10}$")
89
+
90
+ # Truncation per column (keeps pages under Telegram's 4096-char limit)
91
+ TRUNC = {
92
+ "name": 80, "fathersName": 80, "address": 200,
93
+ "phoneNumber": 40, "aadharNumber": 40, "otherNumber": 40,
94
+ "district": 40, "pincode": 15, "state": 40, "town": 40, "source": 30,
95
+ }
96
+
97
+ # ─────────────────────────────────────────────
98
+ # DUCKDB ENGINE
99
+ # ─────────────────────────────────────────────
100
+ _con = None
101
+ _con_lock = threading.Lock()
102
+ _con_ready = threading.Event()
103
+ _query_lock = threading.Lock()
104
+ _warmed = threading.Event()
105
+ _warm_thread_started = False
106
+
107
+ def get_con() -> duckdb.DuckDBPyConnection:
108
+ global _con
109
+ with _con_lock:
110
+ if _con is not None:
111
+ return _con
112
+ log.info("🔧 Initializing DuckDB...")
113
+ c = duckdb.connect(":memory:")
114
+ c.execute("INSTALL httpfs; LOAD httpfs;")
115
+ if HF_TOKEN:
116
+ c.execute(f"CREATE SECRET hf (TYPE HUGGINGFACE, TOKEN '{HF_TOKEN}');")
117
+ c.execute("SET enable_object_cache=true;")
118
+ c.execute("SET preserve_insertion_order=false;")
119
+ c.execute("SET threads=6;") # Increased threads for 10 files
120
+ c.execute("SET memory_limit='12GB';")
121
+ urls = ", ".join(f"'{u}'" for u in PARQUET_URLS)
122
+ try:
123
+ c.execute(f"SELECT COUNT(*) FROM read_parquet([{urls}], union_by_name=true) WHERE 1=0")
124
+ log.info("✅ DuckDB connection ready")
125
+ except Exception as e:
126
+ log.warning(f"⚠️ Warm-up metadata warning: {e}")
127
+ _con = c
128
+ _con_ready.set()
129
+ return _con
130
+
131
+ def warm_phone_cache() -> None:
132
+ """Forces DuckDB to read the entire phoneNumber column across all 10 files."""
133
+ global _warm_thread_started
134
+ if _warmed.is_set():
135
+ return
136
+ _warm_thread_started = True
137
+ try:
138
+ log.info("🔥 Warm-up starting (may take 3-10 minutes for 200GB+)...")
139
+ t0 = time.time()
140
+ con = get_con()
141
+ urls = ", ".join(f"'{u}'" for u in PARQUET_URLS)
142
+ with _query_lock:
143
+ con.execute(f"""
144
+ SELECT SUM(HASH(CAST(phoneNumber AS VARCHAR)))
145
+ FROM read_parquet([{urls}], union_by_name=true)
146
+ WHERE phoneNumber IS NOT NULL
147
+ """).fetchall()
148
+ elapsed = time.time() - t0
149
+ _warmed.set()
150
+ log.info(f"✅ Warm-up complete in {elapsed:.1f}s")
151
+ except Exception as e:
152
+ log.error(f"⚠️ Warm-up failed: {e}")
153
+ _warm_thread_started = False # Allow retry
154
+
155
+ def run_search_safe(phone: str, limit: int = FETCH_LIMIT, timeout_secs: int = QUERY_TIMEOUT) -> list:
156
+ """Search by 10-digit phone in DuckDB. Cancel-on-timeout via con.interrupt()."""
157
+ con = get_con()
158
+ with _query_lock:
159
+ stopped = threading.Event()
160
+ def watchdog():
161
+ if not stopped.wait(timeout_secs):
162
+ log.warning(f"⏱️ Timeout {timeout_secs}s — interrupting DuckDB")
163
+ try: con.interrupt()
164
+ except Exception as e: log.warning(f"⚠️ interrupt() failed: {e}")
165
+ wd = threading.Thread(target=watchdog, daemon=True)
166
+ wd.start()
167
+ try:
168
+ urls = ", ".join(f"'{u}'" for u in PARQUET_URLS)
169
+ sql = f"""
170
+ SELECT name, fathersName, phoneNumber, aadharNumber, otherNumber,
171
+ address, district, pincode, state, town, source
172
+ FROM read_parquet([{urls}], union_by_name=true)
173
+ WHERE CAST(phoneNumber AS VARCHAR) = ?
174
+ LIMIT {int(limit)}
175
+ """
176
+ cur = con.execute(sql, [phone])
177
+ cols = [d[0] for d in cur.description]
178
+ return [dict(zip(cols, r)) for r in cur.fetchall()]
179
+ except duckdb.InterruptException:
180
+ raise TimeoutError(f"Search timed out after {timeout_secs}s")
181
+ finally:
182
+ stopped.set()
183
+
184
+ # ─────────────────────────────────────────────
185
+ # API FALLBACK ENGINE
186
+ # ─────────────────────────────────────────────
187
+ async def _q_cr850(client, number):
188
+ """850CR Fast — GET /search/{number}"""
189
+ try:
190
+ r = await client.get(f"{API_850CR}/{number}", timeout=40.0)
191
+ if r.status_code != 200:
192
+ return []
193
+ data = r.json()
194
+ if not data.get("success") or not data.get("results"):
195
+ return []
196
+ out = []
197
+ for item in data["results"]:
198
+ addr = item.get("address") or {}
199
+ full = addr.get("full_address", "") if isinstance(addr, dict) else ""
200
+ if full: full = full.lstrip(", ").strip()
201
+ out.append({
202
+ "name": item.get("name"),
203
+ "fathersName": item.get("father_name"),
204
+ "phoneNumber": number,
205
+ "aadharNumber": None,
206
+ "otherNumber": item.get("alternate_number"),
207
+ "address": full,
208
+ "district": None,
209
+ "pincode": None,
210
+ "state": item.get("state"),
211
+ "town": None,
212
+ "source": "850CR Fast"
213
+ })
214
+ return out
215
+ except Exception as e:
216
+ log.warning(f"850CR API failed: {e}")
217
+ return []
218
+
219
+ async def _q_inddata(client, number):
220
+ """IndData 1.4B — GET /search?mobile={number}"""
221
+ try:
222
+ r = await client.get(API_INDDATA, params={"mobile": number}, timeout=30.0)
223
+ if r.status_code != 200:
224
+ return []
225
+ data = r.json()
226
+ rows = data.get("data") if isinstance(data, dict) else None
227
+ if not isinstance(rows, list):
228
+ return []
229
+ out = []
230
+ for item in rows:
231
+ out.append({
232
+ "name": item.get("name"),
233
+ "fathersName": item.get("fname"),
234
+ "phoneNumber": number,
235
+ "aadharNumber": None,
236
+ "otherNumber": None,
237
+ "address": item.get("address"),
238
+ "district": None,
239
+ "pincode": None,
240
+ "state": None,
241
+ "town": None,
242
+ "source": "IndData 1.4B"
243
+ })
244
+ return out
245
+ except Exception as e:
246
+ log.warning(f"IndData API failed: {e}")
247
+ return []
248
+
249
+ async def search_apis(number: str) -> list:
250
+ """Fallback to APIs when DuckDB is not warmed up."""
251
+ log.info(f"🔄 DuckDB not warmed. Falling back to APIs for {number}")
252
+ async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
253
+ results = await asyncio.gather(
254
+ _q_cr850(client, number),
255
+ _q_inddata(client, number),
256
+ return_exceptions=True
257
+ )
258
+ merged = []
259
+ for res in results:
260
+ if isinstance(res, list):
261
+ merged.extend(res)
262
+ return merged
263
+
264
+ # ─────────────────────────────────────────────
265
+ # DEDUPLICATION & FORMATTING
266
+ # ─────────────────────────────────────────────
267
+ def _norm(s) -> str:
268
+ if not s: return ""
269
+ return re.sub(r"\s+", " ", str(s).strip().lower())
270
+
271
+ def _dedup_key(rec: dict):
272
+ name = _norm(rec.get("name"))
273
+ father = _norm(rec.get("fathersName"))
274
+ addr = _norm(rec.get("address"))
275
+ addr_n = re.sub(r"[^a-z0-9]+", " ", addr).strip()
276
+
277
+ if name and addr_n: return ("na", name, addr_n)
278
+ if name and father: return ("nf", name, father)
279
+ if addr_n: return ("a", addr_n)
280
+ return None
281
+
282
+ def dedup_records(records: list) -> list:
283
+ seen = {}
284
+ result = []
285
+ for rec in records:
286
+ key = _dedup_key(rec)
287
+ if key is None:
288
+ result.append(rec)
289
+ continue
290
+ idx = seen.get(key)
291
+ if idx is None:
292
+ seen[key] = len(result)
293
+ result.append(rec)
294
+ else:
295
+ # Keep the one with more filled fields
296
+ existing = result[idx]
297
+ existing_fields = sum(1 for v in existing.values() if v)
298
+ new_fields = sum(1 for v in rec.values() if v)
299
+ if new_fields > existing_fields:
300
+ result[idx] = rec
301
+ return result
302
+
303
+ def _tv(v, n: int) -> str:
304
+ if v is None: return ""
305
+ s = str(v).strip()
306
+ if s.lower() in ("", "null", "none", "nan"): return ""
307
+ return s if len(s) <= n else s[: n - 1] + "…"
308
+
309
+ FIELD_LABELS = [
310
+ ("name", "👤", "Name"),
311
+ ("fathersName", "👨", "Father"),
312
+ ("phoneNumber", "📞", "Phone"),
313
+ ("otherNumber", "📱", "Other"),
314
+ ("aadharNumber","🆔", "Aadhaar"),
315
+ ("address", "🏠", "Address"),
316
+ ("district", "🏙️", "District"),
317
+ ("pincode", "📮", "Pincode"),
318
+ ("state", "🗺️", "State"),
319
+ ("town", "🌆", "Town"),
320
+ ("source", "📂", "Source"),
321
+ ]
322
+
323
+ def format_result(idx: int, row: dict) -> str:
324
+ lines = [f"<b>━━━ #{idx} ━━━</b>"]
325
+ for key, emoji, label in FIELD_LABELS:
326
+ v = _tv(row.get(key), TRUNC.get(key, 60))
327
+ if v:
328
+ lines.append(f"{emoji} <b>{label}:</b> <code>{html.escape(v)}</code>")
329
+ return "\n".join(lines)
330
+
331
+ def render_page(results, page, total_pages, phone, elapsed, source_note=""):
332
+ start = page * PAGE_SIZE
333
+ chunk = results[start:start + PAGE_SIZE]
334
+ head = (
335
+ f"📱 <b>Phone:</b> <code>{html.escape(phone)}</code>\n"
336
+ f"📄 <b>Page {page+1}/{total_pages}</b> · "
337
+ f"{len(results)} result(s) · ⏱️ {elapsed:.1f}s"
338
+ )
339
+ if source_note:
340
+ head += f"\n⚠️ <i>{source_note}</i>"
341
+ body = "\n\n".join(format_result(start + i + 1, r) for i, r in enumerate(chunk))
342
+ return f"{head}\n\n{body}"
343
+
344
+ def page_keyboard(search_id, page, total_pages):
345
+ nav = []
346
+ if page > 0:
347
+ nav.append({"text": "◀ Prev", "callback_data": f"p:{search_id}:{page-1}"})
348
+ nav.append({"text": f"{page+1} / {total_pages}", "callback_data": "noop"})
349
+ if page < total_pages - 1:
350
+ nav.append({"text": "Next ▶", "callback_data": f"p:{search_id}:{page+1}"})
351
+ return {"inline_keyboard": [nav]}
352
+
353
+ # ─────────────────────────────────────────────
354
+ # TELEGRAM API HELPERS
355
+ # ─────────────────────────────────────────────
356
+ async def tg(method: str, payload: dict, timeout: float = 30) -> dict:
357
+ try:
358
+ async with httpx.AsyncClient(timeout=timeout) as client:
359
+ r = await client.post(f"{TELEGRAM_API}/{method}", json=payload)
360
+ return r.json()
361
+ except Exception as e:
362
+ log.error(f"❌ tg({method}) error: {e!r}")
363
+ return {"ok": False, "error": str(e)}
364
+
365
+ async def send(chat_id, text, **kw):
366
+ kw.setdefault("parse_mode", "HTML")
367
+ kw.setdefault("disable_web_page_preview", True)
368
+ res = await tg("sendMessage", {"chat_id": chat_id, "text": text, **kw})
369
+ if res.get("ok"): return res["result"]["message_id"]
370
+ log.warning(f"⚠️ sendMessage failed: {res}")
371
+ return None
372
+
373
+ async def edit(chat_id, message_id, text, **kw):
374
+ kw.setdefault("parse_mode", "HTML")
375
+ kw.setdefault("disable_web_page_preview", True)
376
+ res = await tg("editMessageText", {"chat_id": chat_id, "message_id": message_id, "text": text, **kw})
377
+ if not res.get("ok") and "message is not modified" not in str(res):
378
+ log.warning(f"⚠️ editMessageText failed: {res}")
379
+
380
+ # ─────────────────────────────────────────────
381
+ # STATE MANAGEMENT
382
+ # ─────────────────────────────────────────────
383
+ SEARCH_CACHE = {}
384
+ USER_LAST = {}
385
+ STATS = {"queries": 0, "timeouts": 0, "errors": 0, "started": time.time()}
386
+
387
+ def cache_gc():
388
+ while True:
389
+ time.sleep(300)
390
+ now = time.time()
391
+ dead = [k for k, v in SEARCH_CACHE.items() if now - v["ts"] > CACHE_TTL]
392
+ for k in dead: SEARCH_CACHE.pop(k, None)
393
+
394
+ # ─────────────────────────────────────────────
395
+ # HANDLERS
396
+ # ─────────────────────────────────────────────
397
+ WELCOME = (
398
+ "👋 <b>Phone Lookup Bot</b>\n\n"
399
+ "Send me any <b>10-digit mobile number</b> and I'll return every "
400
+ "record we have on file.\n\n"
401
+ "Example: <code>9555179248</code>\n\n"
402
+ "<i>Commands:</i>\n"
403
+ "• /start — show this message\n"
404
+ "• /status — bot health\n"
405
+ )
406
+
407
+ async def handle_message(msg: dict):
408
+ try:
409
+ chat_id = msg["chat"]["id"]
410
+ text = (msg.get("text") or "").strip()
411
+ log.info(f"💬 Message from {chat_id}: {text[:50]!r}")
412
+ if not text: return
413
+
414
+ # ── /status ─────────────────────────────
415
+ if text == "/status":
416
+ uptime = (time.time() - STATS["started"]) / 60
417
+ await send(chat_id, (
418
+ f"<b>📊 Bot Status</b>\n\n"
419
+ f"⏱️ Uptime: <code>{uptime:.1f} min</code>\n"
420
+ f"🔌 DuckDB: <code>{_con_ready.is_set()}</code>\n"
421
+ f"🔥 Cache warmed: <code>{_warmed.is_set()}</code>\n"
422
+ f"🔍 Queries: <code>{STATS['queries']}</code>\n"
423
+ f"⏰ Timeouts: <code>{STATS['timeouts']}</code>\n"
424
+ f"❌ Errors: <code>{STATS['errors']}</code>\n"
425
+ f"💾 Cached searches: <code>{len(SEARCH_CACHE)}</code>"
426
+ ))
427
+ return
428
+
429
+ # ── /start ──────────────────────────────
430
+ if text in ("/start", "/help"):
431
+ await send(chat_id, WELCOME)
432
+ if not _warmed.is_set():
433
+ if not _warm_thread_started:
434
+ threading.Thread(target=warm_phone_cache, daemon=True).start()
435
+ return
436
+
437
+ # ── Validate 10-digit phone ─────────────
438
+ cleaned = "".join(ch for ch in text if ch.isdigit())
439
+ if not PHONE_REGEX.match(cleaned):
440
+ await send(chat_id, "❌ <b>Invalid input</b>\n\nSend exactly a <b>10-digit mobile number</b>.\nExample: <code>9555179248</code>")
441
+ return
442
+
443
+ phone = cleaned
444
+
445
+ # ── Cooldown ────────────────────────────
446
+ now = time.time()
447
+ if now - USER_LAST.get(chat_id, 0) < USER_COOLDOWN:
448
+ wait = USER_COOLDOWN - (now - USER_LAST.get(chat_id, 0))
449
+ await send(chat_id, f"⏳ Wait {wait:.0f}s before next search.")
450
+ return
451
+ USER_LAST[chat_id] = now
452
+
453
+ # ── Ensure warm-up is running ───────────
454
+ if not _warmed.is_set() and not _warm_thread_started:
455
+ threading.Thread(target=warm_phone_cache, daemon=True).start()
456
+
457
+ # ── Placeholder ─────────────────────────
458
+ placeholder = await send(
459
+ chat_id,
460
+ f"🔍 <b>Searching…</b>\n\n📱 <code>{html.escape(phone)}</code>\n\n"
461
+ f"<i>First query after restart may take up to {QUERY_TIMEOUT}s. Repeats are fast.</i>"
462
+ )
463
+ if placeholder is None: return
464
+
465
+ # ── Run Search (DuckDB with API Fallback) ─
466
+ loop = asyncio.get_running_loop()
467
+ t0 = time.time()
468
+ source_note = ""
469
+
470
+ try:
471
+ if not _warmed.is_set():
472
+ # Fallback to APIs if warming
473
+ results = await search_apis(phone)
474
+ source_note = "DuckDB warming in background. Showing API fallback results."
475
+ else:
476
+ # Use DuckDB
477
+ results = await asyncio.wait_for(
478
+ loop.run_in_executor(None, run_search_safe, phone, FETCH_LIMIT),
479
+ timeout=QUERY_TIMEOUT + 10,
480
+ )
481
+ except asyncio.TimeoutError:
482
+ STATS["timeouts"] += 1
483
+ await edit(chat_id, placeholder, f"⏱️ <b>Timed out after {QUERY_TIMEOUT}s</b>\n\nTry the same number again — cache is warming.")
484
+ return
485
+ except TimeoutError:
486
+ STATS["timeouts"] += 1
487
+ await edit(chat_id, placeholder, f"⏱️ <b>Timed out after {QUERY_TIMEOUT}s</b>\n\nTry the same number again.")
488
+ return
489
+ except Exception as e:
490
+ STATS["errors"] += 1
491
+ log.error(f"❌ search error: {e!r}\n{traceback.format_exc()}")
492
+ await edit(chat_id, placeholder, f"❌ <b>Error</b>\n<code>{html.escape(str(e)[:200])}</code>")
493
+ return
494
+
495
+ elapsed = time.time() - t0
496
+ STATS["queries"] += 1
497
+ log.info(f"✅ Search returned {len(results)} rows in {elapsed:.1f}s")
498
+
499
+ # ── Deduplicate ──────────────────────────
500
+ results = dedup_records(results)
501
+ log.info(f"🧹 Deduplicated to {len(results)} rows")
502
+
503
+ # ── No results ──────────────────────────
504
+ if not results:
505
+ await edit(chat_id, placeholder, f"❌ <b>No record found</b>\n\n📱 <code>{html.escape(phone)}</code>\n⏱️ {elapsed:.1f}s")
506
+ return
507
+
508
+ # ── Paginate ────────────────────────────
509
+ search_id = uuid.uuid4().hex[:10]
510
+ total_pages = (len(results) + PAGE_SIZE - 1) // PAGE_SIZE
511
+ SEARCH_CACHE[search_id] = {
512
+ "ts": time.time(),
513
+ "results": results,
514
+ "query": phone,
515
+ "elapsed": elapsed,
516
+ "source_note": source_note
517
+ }
518
+ await edit(
519
+ chat_id, placeholder,
520
+ render_page(results, 0, total_pages, phone, elapsed, source_note),
521
+ reply_markup=page_keyboard(search_id, 0, total_pages),
522
+ )
523
+ except Exception as e:
524
+ log.error(f"❌ handle_message fatal: {e!r}\n{traceback.format_exc()}")
525
+
526
+ async def handle_callback(cb: dict):
527
+ try:
528
+ data = cb.get("data", "")
529
+ cb_id = cb["id"]
530
+ msg = cb.get("message") or {}
531
+ chat_id = msg.get("chat", {}).get("id")
532
+ message_id = msg.get("message_id")
533
+
534
+ if not chat_id or not message_id:
535
+ await tg("answerCallbackQuery", {"callback_query_id": cb_id})
536
+ return
537
+ if data == "noop" or not data.startswith("p:"):
538
+ await tg("answerCallbackQuery", {"callback_query_id": cb_id})
539
+ return
540
+
541
+ try:
542
+ _, search_id, page_str = data.split(":")
543
+ page = int(page_str)
544
+ except Exception:
545
+ await tg("answerCallbackQuery", {"callback_query_id": cb_id})
546
+ return
547
+
548
+ entry = SEARCH_CACHE.get(search_id)
549
+ if not entry:
550
+ await tg("answerCallbackQuery", {"callback_query_id": cb_id, "text": "Session expired — search again.", "show_alert": True})
551
+ return
552
+
553
+ results = entry["results"]
554
+ total_pages = (len(results) + PAGE_SIZE - 1) // PAGE_SIZE
555
+ page = max(0, min(page, total_pages - 1))
556
+
557
+ await edit(
558
+ chat_id, message_id,
559
+ render_page(results, page, total_pages, entry["query"], entry["elapsed"], entry.get("source_note", "")),
560
+ reply_markup=page_keyboard(search_id, page, total_pages),
561
+ )
562
+ await tg("answerCallbackQuery", {"callback_query_id": cb_id})
563
+ except Exception as e:
564
+ log.error(f"❌ handle_callback fatal: {e!r}\n{traceback.format_exc()}")
565
+
566
+ # ─────────────────────────────────────────────
567
+ # WEBHOOK & HEALTH
568
+ # ─────────────────────────────────────────────
569
+ async def set_webhook():
570
+ if not SPACE_URL: return
571
+ url = f"{SPACE_URL}/webhook"
572
+ payload = {
573
+ "url": url,
574
+ "secret_token": WEBHOOK_SECRET,
575
+ "drop_pending_updates": True,
576
+ "allowed_updates": ["message", "callback_query"],
577
+ }
578
+ for attempt in range(1, 4):
579
+ try:
580
+ async with httpx.AsyncClient(timeout=30) as client:
581
+ r = await client.post(f"{TELEGRAM_API}/setWebhook", json=payload)
582
+ data = r.json()
583
+ log.info(f"🔗 setWebhook → {data}")
584
+ if data.get("ok"): return
585
+ except Exception as e:
586
+ log.warning(f"⚠️ setWebhook attempt {attempt}: {e}")
587
+ await asyncio.sleep(3)
588
+
589
+ async def _health_endpoint(request: Request):
590
+ return JSONResponse({
591
+ "status": "ok",
592
+ "duckdb_ready": _con_ready.is_set(),
593
+ "warmed": _warmed.is_set(),
594
+ "cached_searches": len(SEARCH_CACHE),
595
+ "bot_token_set": bool(BOT_TOKEN),
596
+ "stats": STATS,
597
+ })
598
+
599
+ async def webhook_handler(request: Request):
600
+ if WEBHOOK_SECRET:
601
+ got = request.headers.get("x-telegram-bot-api-secret-token", "")
602
+ if got != WEBHOOK_SECRET:
603
+ return JSONResponse({"ok": False}, status_code=403)
604
+ try:
605
+ update = await request.json()
606
+ except Exception:
607
+ return JSONResponse({"ok": True})
608
+
609
+ if "message" in update:
610
+ asyncio.create_task(handle_message(update["message"]))
611
+ elif "callback_query" in update:
612
+ asyncio.create_task(handle_callback(update["callback_query"]))
613
+ return JSONResponse({"ok": True})
614
+
615
+ # ─────────────────────────────────────────────
616
+ # GRADIO UI & LAUNCH
617
+ # ─────────────────────────────────────────────
618
+ with gr.Blocks(title="Phone Lookup Bot") as demo:
619
+ gr.Markdown("# 📞 Phone Lookup Bot")
620
+ gr.Markdown(
621
+ "Telegram bot that streams `Kzr0xx/Icmr-and-hitek` and `Hfzune/HiTeckgroop` "
622
+ "from HuggingFace on demand, with API fallback during warm-up."
623
+ )
624
+ gr.Markdown("### Status")
625
+ status_box = gr.JSON(label="Health")
626
+ demo.load(
627
+ fn=lambda: {
628
+ "status": "ok",
629
+ "duckdb_ready": _con_ready.is_set(),
630
+ "warmed": _warmed.is_set(),
631
+ "cached_searches": len(SEARCH_CACHE),
632
+ },
633
+ outputs=status_box,
634
+ )
635
+ try:
636
+ import spaces
637
+ @spaces.GPU(duration=10)
638
+ def _zerogpu_register():
639
+ return "gpu-ready"
640
+ _zerogpu_btn = gr.Button("init-gpu", visible=False)
641
+ _zerogpu_btn.click(fn=_zerogpu_register, inputs=None, outputs=None)
642
+ log.info("✅ @spaces.GPU registered")
643
+ except Exception as e:
644
+ log.warning(f"⚠️ ZeroGPU registration: {e}")
645
+
646
+ # ─────────────────────────────────────────────
647
+ # STARTUP THREADS
648
+ # ─────────────────────────────────────────────
649
+ threading.Thread(target=cache_gc, daemon=True).start()
650
+ threading.Thread(target=get_con, daemon=True).start()
651
+ threading.Thread(target=warm_phone_cache, daemon=True).start()
652
+
653
+ # ─────────────────────────────────────────────
654
+ # LAUNCH & ROUTE INJECTION
655
+ # ─────────────────────────────────────────────
656
+ demo.launch(
657
+ server_name="0.0.0.0",
658
+ server_port=7860,
659
+ share=False,
660
+ ssr_mode=False,
661
+ prevent_thread_lock=True,
662
+ )
663
+
664
+ log.info("🔎 Launch returned — injecting routes")
665
+ live_app = demo.app
666
+
667
+ live_app.router.routes.insert(0, StarletteRoute("/webhook", webhook_handler, methods=["POST"]))
668
+ live_app.router.routes.insert(0, StarletteRoute("/health", _health_endpoint, methods=["GET"]))
669
+
670
+ try:
671
+ live_app.build_middleware_stack()
672
+ log.info("✅ Middleware stack rebuilt")
673
+ except Exception as e:
674
+ log.warning(f"⚠️ Stack rebuild: {e}")
675
+
676
+ log.info("📋 First 5 routes:")
677
+ for route in live_app.router.routes[:5]:
678
+ path = getattr(route, "path", "?")
679
+ methods = getattr(route, "methods", None)
680
+ log.info(f" {sorted(methods) if methods else '?'} {path}")
681
+
682
+ def _register_webhook_later():
683
+ loop = asyncio.new_event_loop()
684
+ asyncio.set_event_loop(loop)
685
+ loop.run_until_complete(set_webhook())
686
+
687
+ threading.Thread(target=_register_webhook_later, daemon=True).start()
688
+
689
+ demo.block_thread()