Hfzune commited on
Commit
93017ef
·
verified ·
1 Parent(s): 7386a65

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +172 -298
app.py CHANGED
@@ -1,36 +1,25 @@
1
- # =========================================================
2
- # HfFolder shim MUST be first executable code
3
- # =========================================================
 
4
  import huggingface_hub
5
  if not hasattr(huggingface_hub, "HfFolder"):
6
  class _HfFolder:
7
  @staticmethod
8
  def get_token(): return huggingface_hub.get_token()
9
  @staticmethod
10
- def save_token(token): huggingface_hub.login(token)
11
  @staticmethod
12
  def delete_token(): huggingface_hub.logout()
13
  huggingface_hub.HfFolder = _HfFolder
14
 
15
- # =========================================================
16
- # All other imports
17
- # =========================================================
18
- import os
19
- import re
20
- import csv
21
- import json
22
- import sqlite3
23
- import tempfile
24
- import logging
25
- import asyncio
26
- import uuid
27
- import threading
28
- import httpx
29
- import pymupdf
30
  import spaces
 
31
  import gradio as gr
 
32
  from PIL import Image
33
- from gradio_client import Client, handle_file as gradio_handle_file
34
  from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
35
  from telegram.ext import (
36
  Application, CommandHandler, MessageHandler, filters,
@@ -41,319 +30,204 @@ from fastapi import Request
41
  from fastapi.responses import JSONResponse
42
  from starlette.routing import Route as StarletteRoute
43
 
44
- # --- gradio_client fetch timeout ---
45
- import gradio_client.client as gradio_client_module
46
- _original_httpx_get = httpx.get
47
- def _httpx_get_with_timeout(*args, **kwargs):
48
- if "timeout" not in kwargs:
49
- kwargs["timeout"] = httpx.Timeout(120.0, connect=10.0)
50
- return _original_httpx_get(*args, **kwargs)
51
- gradio_client_module.httpx.get = _httpx_get_with_timeout
52
-
53
- # =========================================================
54
- # Configuration
55
- # =========================================================
56
- TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
57
- HF_SPACE_ID = "khang119966/DeepSeek-OCR-DEMO"
58
- DB_PATH = "ocr_records.db"
59
- WEBHOOK_URL = os.getenv("WEBHOOK_URL", "").rstrip("/")
60
-
61
- if not TELEGRAM_BOT_TOKEN:
62
- raise RuntimeError("Missing TELEGRAM_BOT_TOKEN")
63
- if not WEBHOOK_URL:
64
- raise RuntimeError("Missing WEBHOOK_URL (e.g. https://your-space.hf.space)")
65
-
66
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
67
  logger = logging.getLogger(__name__)
68
 
69
- # =========================================================
70
- # Gradio Client
71
- # =========================================================
72
- try:
73
- gradio_client = Client(HF_SPACE_ID, httpx_kwargs={"timeout": httpx.Timeout(120.0, connect=10.0)})
74
- logger.info(f"Connected to OCR Space: {HF_SPACE_ID}")
75
- except Exception as e:
76
- logger.error(f"Failed to init Gradio Client: {e}")
77
- raise e
78
-
79
- # =========================================================
80
- # Database
81
- # =========================================================
 
 
 
 
 
 
 
 
 
82
  def init_db():
83
  conn = sqlite3.connect(DB_PATH)
84
- cur = conn.cursor()
85
- cur.execute("""
86
- CREATE TABLE IF NOT EXISTS voters (
87
- id INTEGER PRIMARY KEY AUTOINCREMENT,
88
- voter_id TEXT, name TEXT, father_name TEXT, husband_name TEXT,
89
- house_no TEXT, age INTEGER, gender TEXT, raw_json TEXT
90
- )
91
- """)
92
- conn.commit()
93
- conn.close()
94
-
95
- # =========================================================
96
- # Parsing
97
- # =========================================================
98
- def parse_markdown_to_records(raw_markdown: str) -> list[dict]:
99
- table_match = re.search(r'<table>.*?</table>', raw_markdown, re.DOTALL)
100
- if not table_match:
101
- return []
102
- html_content = table_match.group(0)
103
- cells = re.findall(r'<td>(.*?)</td>', html_content, re.DOTALL)
104
- id_pattern = re.compile(r'(UP/\d+/\d+/\d+|[A-Z]{3}\d{7,})')
105
- voter_ids = [c for c in cells if id_pattern.fullmatch(c.strip())]
106
- detail_cells = [c for c in cells if "नाम:" in c]
107
- records = []
108
- for i in range(min(len(voter_ids), len(detail_cells))):
109
- voter_id = voter_ids[i].strip()
110
- text = re.sub(r'<br\s*/?>', '\n', detail_cells[i])
111
  text = re.sub(r'<[^>]+>', '', text)
112
- nm = re.search(r'नाम:\s*([^\n]+)', text)
113
- fm = re.search(r'पिता का नाम:\s*([^\n]+)', text)
114
- hm = re.search(r'पति का नाम:\s*([^\n]+)', text)
115
- hm_no = re.search(r'मकान संख्या:\s*([^\n]+)', text)
116
- ag = re.search(r'आयु:\s*(\d+)', text)
117
- gm = re.search(r'लिंग:\s*([^\n]+)', text)
118
- records.append({
119
- "voter_id": voter_id,
120
- "name": nm.group(1).strip() if nm else "",
121
- "father_name": fm.group(1).strip() if fm else "",
122
- "husband_name": hm.group(1).strip() if hm else "",
123
- "house_no": hm_no.group(1).strip() if hm_no else "",
124
- "age": int(ag.group(1)) if ag else 0,
125
- "gender": gm.group(1).strip() if gm else "",
126
  })
127
- return records
128
 
129
- # =========================================================
130
- # OCR call
131
- # =========================================================
132
- def call_space_ocr(image_path: str) -> str:
133
- clean = os.path.join(tempfile.gettempdir(), f"ocr_input_{uuid.uuid4().hex}.jpg")
134
  try:
135
- with Image.open(image_path) as img:
136
- img.convert("RGB").save(clean, "JPEG")
137
- result = gradio_client.predict(
138
- image=gradio_handle_file(clean),
139
- model_size="Gundam (Recommended)",
140
- task_type="📄 Convert to Markdown",
141
- ref_text=" ",
142
- api_name="/process_ocr_task",
143
- )
144
- return result[0] if isinstance(result, tuple) else str(result)
145
- except Exception as exc:
146
- logger.exception(f"OCR failed: {image_path}")
147
- raise exc
148
  finally:
149
- if os.path.exists(clean):
150
- try: os.remove(clean)
151
- except OSError: pass
152
 
153
- # =========================================================
154
- # Progress bar
155
- # =========================================================
156
- async def animate_progress(status_msg, stop_event: asyncio.Event):
157
- bar_len = 10
158
- i = 0
159
- while not stop_event.is_set():
160
- bar = "█" * i + "░" * (bar_len - i)
161
- try:
162
- await status_msg.edit_text(f"⚙️ OCR in progress...\n`[{bar}]`", parse_mode="Markdown")
163
- except BadRequest:
164
- pass
165
- i = (i + 1) % (bar_len + 1)
166
- await asyncio.sleep(2)
167
 
168
- # =========================================================
169
- # Telegram handlers
170
- # =========================================================
171
- async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
172
- await update.message.reply_text("👋 Send me an Image! I'll extract voter data and let you verify before saving.")
173
-
174
- async def handle_file(update: Update, context: ContextTypes.DEFAULT_TYPE):
175
  msg = update.message
176
  if msg.document:
177
  if not msg.document.mime_type.startswith("image/"):
178
- await msg.reply_text("❌ Please send an Image.")
179
- return
180
- tg_file = await context.bot.get_file(msg.document)
181
- original_name = msg.document.file_name or "upload.jpg"
182
  elif msg.photo:
183
- tg_file = await context.bot.get_file(msg.photo[-1].file_id)
184
- original_name = f"photo_{msg.message_id}.jpg"
185
- else:
186
- return
187
 
188
- safe_name = re.sub(r"[^\w.\-]", "_", original_name)
189
- download_path = os.path.join(tempfile.gettempdir(), safe_name)
190
- await tg_file.download_to_drive(download_path)
191
- status_msg = await msg.reply_text("📥 File received. Preparing image...")
192
 
193
  try:
194
- stop_event = asyncio.Event()
195
- progress_task = asyncio.create_task(animate_progress(status_msg, stop_event))
196
- raw_text = await asyncio.to_thread(call_space_ocr, download_path)
197
- stop_event.set()
198
- await progress_task
199
- await status_msg.edit_text("✅ OCR Complete! Parsing data...", parse_mode="Markdown")
200
-
201
- records = parse_markdown_to_records(raw_text)
202
  if not records:
203
- await msg.reply_text("⚠️ No structured records found. Raw text:\n\n" + raw_text[:3000])
204
- return
205
 
206
- csv_path = os.path.join(tempfile.gettempdir(), f"ocr_results_{uuid.uuid4().hex}.csv")
207
- with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
208
- writer = csv.DictWriter(f, fieldnames=["voter_id", "name", "father_name", "husband_name", "house_no", "age", "gender"])
209
- writer.writeheader()
210
- writer.writerows(records)
211
 
212
- context.user_data['temp_csv_path'] = csv_path
213
- context.user_data['temp_records'] = records
214
-
215
- keyboard = [[
216
- InlineKeyboardButton("✅ Save to DB", callback_data="save_db"),
217
- InlineKeyboardButton("❌ Cancel", callback_data="cancel"),
218
- ]]
219
  await msg.reply_document(
220
- document=open(csv_path, "rb"),
221
- filename="extracted_voters.csv",
222
- caption=f"📊 Extracted **{len(records)}** records.\nPlease verify. Click **Save to DB** if correct.",
223
- parse_mode="Markdown",
224
- reply_markup=InlineKeyboardMarkup(keyboard),
225
  )
226
- except Exception as exc:
227
- if 'stop_event' in locals():
228
- stop_event.set()
229
- await progress_task
230
- await status_msg.edit_text(f"❌ Error: {exc}", parse_mode="Markdown")
231
  finally:
232
- if os.path.exists(download_path):
233
- os.remove(download_path)
234
-
235
- async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
236
- query = update.callback_query
237
- await query.answer()
238
- csv_path = context.user_data.get('temp_csv_path')
239
- records = context.user_data.get('temp_records')
240
-
241
- if query.data == "save_db" and records:
242
- try:
243
- conn = sqlite3.connect(DB_PATH)
244
- cur = conn.cursor()
245
- for r in records:
246
- cur.execute("""
247
- INSERT INTO voters (voter_id, name, father_name, husband_name, house_no, age, gender, raw_json)
248
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
249
- """, (r["voter_id"], r["name"], r["father_name"], r["husband_name"],
250
- r["house_no"], r["age"], r["gender"], json.dumps(r, ensure_ascii=False)))
251
- conn.commit()
252
- conn.close()
253
- await query.edit_message_caption(caption=f"✅ Success! {len(records)} records saved to DB.")
254
- except Exception as e:
255
- await query.edit_message_caption(caption=f"❌ Database Error: {e}")
256
- elif query.data == "cancel":
257
- await query.edit_message_caption(caption="❌ Cancelled. Data not saved.")
258
-
259
- if csv_path and os.path.exists(csv_path):
260
- os.remove(csv_path)
261
- context.user_data.clear()
262
-
263
- # =========================================================
264
- # Telegram Application (webhook mode)
265
- # =========================================================
266
- telegram_app = (
267
- Application.builder()
268
- .token(TELEGRAM_BOT_TOKEN)
269
- .updater(None)
270
- .build()
271
- )
272
  telegram_app.add_handler(CommandHandler("start", start))
273
- telegram_app.add_handler(MessageHandler((filters.Document.ALL | filters.PHOTO) & ~filters.COMMAND, handle_file))
274
- telegram_app.add_handler(CallbackQueryHandler(button_callback))
275
-
276
- # =========================================================
277
- # Dedicated event loop for Telegram (separate from Uvicorn)
278
- # =========================================================
279
- _telegram_loop = asyncio.new_event_loop()
280
- _telegram_ready = threading.Event()
281
 
282
- async def _init_telegram():
283
- init_db()
284
- await telegram_app.initialize()
285
- await telegram_app.start()
286
- webhook_url = f"{WEBHOOK_URL}/webhook/{TELEGRAM_BOT_TOKEN}"
287
- await telegram_app.bot.set_webhook(url=webhook_url, drop_pending_updates=True)
288
- logger.info(f"✅ Webhook set: {webhook_url}")
289
-
290
- def _run_telegram_loop():
291
- asyncio.set_event_loop(_telegram_loop)
292
- _telegram_loop.run_until_complete(_init_telegram())
293
- _telegram_ready.set()
294
- _telegram_loop.run_forever()
295
-
296
- _thread = threading.Thread(target=_run_telegram_loop, daemon=True)
297
- _thread.start()
298
-
299
- # Wait (up to 60s) for Telegram to be fully initialized before accepting webhooks
300
- if not _telegram_ready.wait(timeout=60):
301
- logger.error("Telegram init did not complete within 60s")
302
- else:
303
- logger.info("Telegram loop is ready.")
304
-
305
- # =========================================================
306
- # Webhook & health handlers
307
- # =========================================================
308
- async def telegram_webhook(request: Request):
309
  try:
310
- data = await request.json()
311
  update = Update.de_json(data, telegram_app.bot)
312
- # Bridge: schedule process_update on Telegram's own loop
313
- future = asyncio.run_coroutine_threadsafe(
314
- telegram_app.process_update(update),
315
- _telegram_loop,
316
- )
317
- await asyncio.wait_for(asyncio.wrap_future(future), timeout=120.0)
318
  except Exception as e:
319
- logger.exception(f"Webhook handler error: {e}")
320
  return JSONResponse({"ok": True})
321
 
322
- async def health_handler(request: Request):
323
  return JSONResponse({"status": "ok"})
324
 
325
- # =========================================================
326
- # ZeroGPU hidden button
327
- # =========================================================
328
- @spaces.GPU(duration=10)
329
- def _zerogpu_register():
330
- return "gpu-ready"
331
 
332
- with gr.Blocks(title="Telegram OCR Bot") as demo:
333
- gr.Markdown("# ✅ Telegram OCR Bot — keep this Space awake by pinging `/health`")
334
- _btn = gr.Button("init-gpu", visible=False)
335
- _btn.click(fn=_zerogpu_register, inputs=None, outputs=None)
336
 
337
- # =========================================================
338
- # Main
339
- # =========================================================
340
  if __name__ == "__main__":
341
- demo.launch(
342
- server_name="0.0.0.0",
343
- server_port=7860,
344
- share=False,
345
- ssr_mode=False,
346
- prevent_thread_lock=True,
347
- )
348
-
349
- # Inject routes at index 0 (beat Gradio's catch-all)
350
- live_app = demo.app
351
- live_app.router.routes.insert(
352
- 0, StarletteRoute(f"/webhook/{TELEGRAM_BOT_TOKEN}", telegram_webhook, methods=["POST"])
353
- )
354
- live_app.router.routes.insert(
355
- 0, StarletteRoute("/health", health_handler, methods=["GET"])
356
- )
357
- live_app.build_middleware_stack()
358
-
359
  demo.block_thread()
 
1
+ # ─── 1. Runtime dependency fix (MUST be first) ───
2
+ import bootstrap # noqa: F401
3
+
4
+ # ─── 2. HfFolder shim for old Gradio ───
5
  import huggingface_hub
6
  if not hasattr(huggingface_hub, "HfFolder"):
7
  class _HfFolder:
8
  @staticmethod
9
  def get_token(): return huggingface_hub.get_token()
10
  @staticmethod
11
+ def save_token(t): huggingface_hub.login(t)
12
  @staticmethod
13
  def delete_token(): huggingface_hub.logout()
14
  huggingface_hub.HfFolder = _HfFolder
15
 
16
+ # ─── 3. spaces BEFORE torch ───
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  import spaces
18
+ import torch
19
  import gradio as gr
20
+ import os, re, csv, json, sqlite3, tempfile, logging, asyncio, uuid, httpx
21
  from PIL import Image
22
+ from transformers import AutoModel, AutoTokenizer
23
  from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
24
  from telegram.ext import (
25
  Application, CommandHandler, MessageHandler, filters,
 
30
  from fastapi.responses import JSONResponse
31
  from starlette.routing import Route as StarletteRoute
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
34
  logger = logging.getLogger(__name__)
35
 
36
+ # ─── 4. Config ───
37
+ TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
38
+ WEBHOOK_URL = os.getenv("WEBHOOK_URL", "").rstrip("/")
39
+ DB_PATH = "ocr_records.db"
40
+ MODEL_ID = "strangervisionhf/deepseek-ocr-transformers-v4.57.6"
41
+ GPU_DURATION = 30 # seconds reserved per OCR call
42
+
43
+ if not TELEGRAM_BOT_TOKEN or not WEBHOOK_URL:
44
+ raise RuntimeError("TELEGRAM_BOT_TOKEN and WEBHOOK_URL are required")
45
+
46
+ # ─── 5. Load model on CPU at module level ───
47
+ logger.info("Loading DeepSeek-OCR on CPU...")
48
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
49
+ model = AutoModel.from_pretrained(
50
+ MODEL_ID,
51
+ _attn_implementation="eager",
52
+ trust_remote_code=True,
53
+ use_safetensors=True,
54
+ ).eval()
55
+ logger.info("✅ Model loaded (CPU).")
56
+
57
+ # ─── 6. DB + parsing (unchanged from your current app) ───
58
  def init_db():
59
  conn = sqlite3.connect(DB_PATH)
60
+ conn.execute("""CREATE TABLE IF NOT EXISTS voters (
61
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
62
+ voter_id TEXT, name TEXT, father_name TEXT, husband_name TEXT,
63
+ house_no TEXT, age INTEGER, gender TEXT, raw_json TEXT)""")
64
+ conn.commit(); conn.close()
65
+
66
+ def parse_markdown(raw: str) -> list[dict]:
67
+ m = re.search(r'<table>.*?</table>', raw, re.DOTALL)
68
+ if not m: return []
69
+ cells = re.findall(r'<td>(.*?)</td>', m.group(0), re.DOTALL)
70
+ ids = [c for c in cells if re.fullmatch(r'(UP/\d+/\d+/\d+|[A-Z]{3}\d{7,})', c.strip())]
71
+ details = [c for c in cells if "नाम:" in c]
72
+ out = []
73
+ for i in range(min(len(ids), len(details))):
74
+ text = re.sub(r'<br\s*/?>', '\n', details[i])
 
 
 
 
 
 
 
 
 
 
 
 
75
  text = re.sub(r'<[^>]+>', '', text)
76
+ def f(p):
77
+ r = re.search(p, text); return r.group(1).strip() if r else ""
78
+ age = re.search(r'आयु:\s*(\d+)', text)
79
+ out.append({
80
+ "voter_id": ids[i].strip(),
81
+ "name": f(r'नाम:\s*([^\n]+)'),
82
+ "father_name": f(r'पिता का नाम:\s*([^\n]+)'),
83
+ "husband_name": f(r'पति का नाम:\s*([^\n]+)'),
84
+ "house_no": f(r'मकान संख्या:\s*([^\n]+)'),
85
+ "age": int(age.group(1)) if age else 0,
86
+ "gender": f(r'लिंग:\s*([^\n]+)'),
 
 
 
87
  })
88
+ return out
89
 
90
+ # ─── 7. GPU OCR call ───
91
+ @spaces.GPU(duration=GPU_DURATION)
92
+ def run_ocr(image_path: str) -> str:
93
+ """Runs on GPU. Model moves to VRAM, then back to CPU to free quota."""
94
+ model.cuda().to(torch.bfloat16)
95
  try:
96
+ with tempfile.TemporaryDirectory() as out:
97
+ result = model.infer(
98
+ tokenizer,
99
+ prompt="<image>\n<|grounding|>Convert the document to markdown.",
100
+ image_file=image_path,
101
+ output_path=out,
102
+ base_size=1024, image_size=640, crop_mode=True,
103
+ save_results=False, test_compress=True, eval_mode=True,
104
+ )
105
+ return result
 
 
 
106
  finally:
107
+ model.cpu() # free VRAM immediately
 
 
108
 
109
+ # ─── 8. Telegram handlers ───
110
+ async def start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
111
+ await update.message.reply_text("👋 Send an image — I'll OCR it locally and let you verify before saving.")
 
 
 
 
 
 
 
 
 
 
 
112
 
113
+ async def on_image(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
 
 
 
 
 
 
114
  msg = update.message
115
  if msg.document:
116
  if not msg.document.mime_type.startswith("image/"):
117
+ await msg.reply_text("❌ Please send an image."); return
118
+ f = await ctx.bot.get_file(msg.document)
119
+ name = msg.document.file_name or "img.jpg"
 
120
  elif msg.photo:
121
+ f = await ctx.bot.get_file(msg.photo[-1].file_id)
122
+ name = f"photo_{msg.message_id}.jpg"
123
+ else: return
 
124
 
125
+ path = os.path.join(tempfile.gettempdir(), re.sub(r"[^\w.\-]", "_", name))
126
+ await f.download_to_drive(path)
127
+ status = await msg.reply_text("⚙️ Running OCR on GPU… (cold start may take 60s)")
 
128
 
129
  try:
130
+ raw = await asyncio.to_thread(run_ocr, path)
131
+ records = parse_markdown(raw)
 
 
 
 
 
 
132
  if not records:
133
+ await msg.reply_text("⚠️ No table found. Raw:\n\n" + raw[:3000]); return
 
134
 
135
+ csv_path = os.path.join(tempfile.gettempdir(), f"out_{uuid.uuid4().hex}.csv")
136
+ with open(csv_path, "w", newline="", encoding="utf-8-sig") as fh:
137
+ w = csv.DictWriter(fh, fieldnames=["voter_id","name","father_name","husband_name","house_no","age","gender"])
138
+ w.writeheader(); w.writerows(records)
 
139
 
140
+ ctx.user_data['csv'] = csv_path
141
+ ctx.user_data['records'] = records
142
+ kb = [[InlineKeyboardButton("✅ Save to DB", callback_data="save"),
143
+ InlineKeyboardButton("❌ Cancel", callback_data="cancel")]]
 
 
 
144
  await msg.reply_document(
145
+ open(csv_path, "rb"), filename="extracted_voters.csv",
146
+ caption=f"📊 Extracted **{len(records)}** records. Verify, then Save.",
147
+ parse_mode="Markdown", reply_markup=InlineKeyboardMarkup(kb),
 
 
148
  )
149
+ except Exception as e:
150
+ logger.exception("OCR pipeline failed")
151
+ await status.edit_text(f"❌ Error: {e}")
 
 
152
  finally:
153
+ if os.path.exists(path): os.remove(path)
154
+
155
+ async def on_button(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
156
+ q = update.callback_query; await q.answer()
157
+ csv_path = ctx.user_data.get('csv')
158
+ records = ctx.user_data.get('records')
159
+ if q.data == "save" and records:
160
+ conn = sqlite3.connect(DB_PATH)
161
+ for r in records:
162
+ conn.execute("""INSERT INTO voters
163
+ (voter_id,name,father_name,husband_name,house_no,age,gender,raw_json)
164
+ VALUES (?,?,?,?,?,?,?,?)""",
165
+ (r["voter_id"], r["name"], r["father_name"], r["husband_name"],
166
+ r["house_no"], r["age"], r["gender"], json.dumps(r, ensure_ascii=False)))
167
+ conn.commit(); conn.close()
168
+ await q.edit_message_caption(caption=f"✅ Saved {len(records)} records to DB.")
169
+ elif q.data == "cancel":
170
+ await q.edit_message_caption(caption="❌ Cancelled.")
171
+ if csv_path and os.path.exists(csv_path): os.remove(csv_path)
172
+ ctx.user_data.clear()
173
+
174
+ # ─── 9. Telegram app (webhook mode) ───
175
+ telegram_app = Application.builder().token(TELEGRAM_BOT_TOKEN).updater(None).build()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  telegram_app.add_handler(CommandHandler("start", start))
177
+ telegram_app.add_handler(MessageHandler((filters.Document.ALL | filters.PHOTO) & ~filters.COMMAND, on_image))
178
+ telegram_app.add_handler(CallbackQueryHandler(on_button))
 
 
 
 
 
 
179
 
180
+ # ─── 10. Dedicated Telegram loop ───
181
+ import threading
182
+ _loop = asyncio.new_event_loop()
183
+ _ready = threading.Event()
184
+
185
+ def _run():
186
+ asyncio.set_event_loop(_loop)
187
+ async def _init():
188
+ init_db()
189
+ await telegram_app.initialize()
190
+ await telegram_app.start()
191
+ await telegram_app.bot.set_webhook(
192
+ url=f"{WEBHOOK_URL}/webhook/{TELEGRAM_BOT_TOKEN}",
193
+ drop_pending_updates=True)
194
+ logger.info(f"✅ Webhook set: {WEBHOOK_URL}/webhook/{TELEGRAM_BOT_TOKEN}")
195
+ _loop.run_until_complete(_init())
196
+ _ready.set()
197
+ _loop.run_forever()
198
+
199
+ threading.Thread(target=_run, daemon=True).start()
200
+ _ready.wait(timeout=60)
201
+
202
+ # ─── 11. Webhook + health routes ───
203
+ async def telegram_webhook(req: Request):
 
 
 
204
  try:
205
+ data = await req.json()
206
  update = Update.de_json(data, telegram_app.bot)
207
+ fut = asyncio.run_coroutine_threadsafe(telegram_app.process_update(update), _loop)
208
+ await asyncio.wait_for(asyncio.wrap_future(fut), timeout=120)
 
 
 
 
209
  except Exception as e:
210
+ logger.exception(f"Webhook error: {e}")
211
  return JSONResponse({"ok": True})
212
 
213
+ async def health(req: Request):
214
  return JSONResponse({"status": "ok"})
215
 
216
+ # ─── 12. Hidden GPU button (ZeroGPU scanner needs it) ───
217
+ @spaces.GPU(duration=1)
218
+ def _probe(): return "ok"
 
 
 
219
 
220
+ with gr.Blocks() as demo:
221
+ gr.Markdown("# DeepSeek-OCR Telegram Bot — ping `/health` to keep alive")
222
+ _b = gr.Button("init-gpu", visible=False)
223
+ _b.click(fn=_probe)
224
 
225
+ # ─── 13. Launch ───
 
 
226
  if __name__ == "__main__":
227
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False,
228
+ ssr_mode=False, prevent_thread_lock=True)
229
+ live = demo.app
230
+ live.router.routes.insert(0, StarletteRoute(f"/webhook/{TELEGRAM_BOT_TOKEN}", telegram_webhook, methods=["POST"]))
231
+ live.router.routes.insert(0, StarletteRoute("/health", health, methods=["GET"]))
232
+ live.build_middleware_stack()
 
 
 
 
 
 
 
 
 
 
 
 
233
  demo.block_thread()