Cxops commited on
Commit
97dc0cf
·
verified ·
1 Parent(s): 3a06940

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +581 -399
app.py CHANGED
@@ -1,36 +1,28 @@
1
- import os
2
- import re
3
- import sys
4
- import time
5
- import base64
6
- import hashlib
7
- import shutil
8
- import logging
9
- import secrets
10
- import sqlite3
11
- import tempfile
12
- import threading
13
- import asyncio
14
  from pathlib import Path
15
- from typing import Optional, List, Tuple, Union
16
 
17
  import requests
18
  from flask import Flask
19
  from pyrogram import Client, filters
20
- from pyrogram.types import (
21
- InlineKeyboardMarkup,
22
- InlineKeyboardButton,
23
- Message,
24
- CallbackQuery,
25
- )
26
- from huggingface_hub import (
27
- HfApi,
28
- get_hf_file_metadata,
29
- hf_hub_download,
30
- hf_hub_url,
31
- )
32
  from cryptography.hazmat.primitives.ciphers.aead import AESSIV
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  # =============================================================================
35
  # CONFIGURATION
36
  # =============================================================================
@@ -41,18 +33,18 @@ BOT_TOKEN: str = os.environ["BOT_TOKEN"]
41
  HF_TOKEN: str = os.environ["HF_TOKEN"]
42
  OBFUSCATION_KEY: str = os.environ.get("OBFUSCATION_KEY", "change-me-to-a-secret-string")
43
 
44
- # Hard‑coded Space URL for keep‑alive pings
45
- SPACE_HOST: str = "https://cxops-push.hf.space"
46
 
47
- DATASET_REPO: str = "Cxops/Bzxv"
48
  DATASET_TYPE: str = "dataset"
49
- MAX_FILE_SIZE_TG: int = 1900 * 1024 * 1024 # 1900 MB per part
50
- MIN_FREE_DISK: int = 45 * 1024 * 1024 * 1024 # 45 GB
51
- DATASET_MAX_SIZE: int = int(8.7 * 1024**4) # 8.7 TB
52
- KEEPALIVE_INTERVAL: int = 300 # 5 minutes
 
 
53
  DB_PATH: str = "bot_files.db"
54
 
55
- # Logging
56
  logging.basicConfig(
57
  level=logging.INFO,
58
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
@@ -61,36 +53,29 @@ logging.basicConfig(
61
  logger = logging.getLogger("StorageBot")
62
 
63
  # =============================================================================
64
- # DETERMINISTIC ENCRYPTION (AES‑SIV)
65
  # =============================================================================
66
 
67
  _siv_key = hashlib.sha512(OBFUSCATION_KEY.encode()).digest()
68
  _aes_siv = AESSIV(_siv_key)
69
 
70
-
71
  def encrypt_text(plain: str) -> str:
72
- """Return URL‑safe base64 (no padding) deterministic ciphertext."""
73
  token = _aes_siv.encrypt(plain.encode(), None)
74
  return base64.urlsafe_b64encode(token).decode().rstrip("=")
75
 
76
-
77
  def decrypt_text(encoded: str) -> str:
78
- """Recover original string from URL‑safe base64 (with or without padding)."""
79
  padding = 4 - len(encoded) % 4
80
  if padding != 4:
81
  encoded += "=" * padding
82
  token = base64.urlsafe_b64decode(encoded.encode())
83
  return _aes_siv.decrypt(token, None).decode()
84
 
85
-
86
  def get_encrypted_folder(user_id: int) -> str:
87
  return encrypt_text(str(user_id))
88
 
89
-
90
  def get_encrypted_filename(original: str) -> str:
91
  return encrypt_text(original)
92
 
93
-
94
  # =============================================================================
95
  # DATABASE
96
  # =============================================================================
@@ -99,8 +84,7 @@ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
99
  conn.row_factory = sqlite3.Row
100
  conn.execute("PRAGMA journal_mode=WAL")
101
  cursor = conn.cursor()
102
- cursor.execute(
103
- """
104
  CREATE TABLE IF NOT EXISTS files (
105
  user_id INTEGER,
106
  enc_path TEXT UNIQUE,
@@ -108,11 +92,9 @@ cursor.execute(
108
  size INTEGER,
109
  timestamp REAL
110
  )
111
- """
112
- )
113
  conn.commit()
114
 
115
-
116
  def db_add_file(user_id: int, enc_path: str, orig_name: str, size: int) -> None:
117
  cursor.execute(
118
  "INSERT OR REPLACE INTO files (user_id, enc_path, orig_name, size, timestamp) VALUES (?, ?, ?, ?, ?)",
@@ -120,7 +102,6 @@ def db_add_file(user_id: int, enc_path: str, orig_name: str, size: int) -> None:
120
  )
121
  conn.commit()
122
 
123
-
124
  def db_get_user_files(user_id: int) -> List[Tuple[str, str, int]]:
125
  cursor.execute(
126
  "SELECT enc_path, orig_name, size FROM files WHERE user_id = ? ORDER BY timestamp DESC",
@@ -128,53 +109,55 @@ def db_get_user_files(user_id: int) -> List[Tuple[str, str, int]]:
128
  )
129
  return [(row[0], row[1], row[2]) for row in cursor.fetchall()]
130
 
131
-
132
  def db_remove_file(enc_path: str) -> None:
133
  cursor.execute("DELETE FROM files WHERE enc_path = ?", (enc_path,))
134
  conn.commit()
135
 
136
-
137
  def db_remove_all_user_files(user_id: int) -> None:
138
  cursor.execute("DELETE FROM files WHERE user_id = ?", (user_id,))
139
  conn.commit()
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  # =============================================================================
143
- # CALLBACK TOKEN MAPS
144
  # =============================================================================
145
 
146
- file_callback_map: dict[str, str] = {} # token → enc_path
147
- # Each entry: tuple with 3 or 4 elements.
148
- # Local: (user_id, msg_id, filename, file_id)
149
- # Remote: (user_id, file_id, filename)
150
  upload_prompt_map: dict[str, List[Tuple]] = {}
151
-
152
- # Album grouping
153
  album_groups: dict[str, dict] = {}
154
-
155
 
156
  def make_token() -> str:
157
  return secrets.token_hex(8)
158
 
159
-
160
  # =============================================================================
161
- # FLASK HEALTH SERVER
162
  # =============================================================================
163
 
164
  web = Flask(__name__)
165
 
166
-
167
  @web.route("/")
168
  def home():
169
  return "Status: OK - Bot Running"
170
 
171
-
172
  def run_web():
173
  web.run(host="0.0.0.0", port=7860)
174
 
175
-
176
  # =============================================================================
177
- # HUGGING FACE CLIENT & PYROGRAM BOT
178
  # =============================================================================
179
 
180
  hf = HfApi(token=HF_TOKEN)
@@ -187,16 +170,15 @@ app = Client(
187
  )
188
 
189
  # =============================================================================
190
- # ASYNC QUEUE & STATE
191
  # =============================================================================
192
 
193
  upload_queue: asyncio.Queue = asyncio.Queue()
194
  active_jobs: dict[int, dict] = {}
195
  bot_start_time = time.time()
196
 
197
-
198
  # =============================================================================
199
- # UTILITY FUNCTIONS
200
  # =============================================================================
201
 
202
  def cleanup(path: str) -> None:
@@ -208,11 +190,9 @@ def cleanup(path: str) -> None:
208
  except Exception:
209
  pass
210
 
211
-
212
  def get_free_disk() -> int:
213
  return shutil.disk_usage("/").free
214
 
215
-
216
  def format_size(bytes_val: int) -> str:
217
  for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
218
  if bytes_val < 1024.0:
@@ -220,20 +200,49 @@ def format_size(bytes_val: int) -> str:
220
  bytes_val /= 1024.0
221
  return f"{bytes_val:.1f} PB"
222
 
223
-
224
  def format_speed(bytes_per_sec: float) -> str:
225
  return f"{format_size(bytes_per_sec)}/s"
226
 
227
-
228
- def get_memory_usage() -> str:
229
  try:
230
- with open("/proc/self/status") as f:
 
 
 
 
 
 
 
231
  for line in f:
232
- if line.startswith("VmRSS:"):
233
- return format_size(int(line.split()[1]) * 1024)
234
  except Exception:
235
- return "unknown"
 
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
  async def send_progress_updater(
239
  job_id: int,
@@ -249,15 +258,19 @@ async def send_progress_updater(
249
  elapsed = max(time.time() - start_time, 0.01)
250
  current = bytes_holder[0]
251
  speed = current / elapsed
252
- pct = (100 * current / total_bytes) if total_bytes else 0
253
- eta = int((total_bytes - current) / speed) if speed > 0 else -1
254
- text = (
255
- f"📤 Sending to Telegram...\n"
256
- f"{pct:.1f}% of {format_size(total_bytes)}\n"
257
- f"{format_size(current)} sent\n"
258
- f"Speed: {format_speed(speed)}\n"
259
- f"ETA: {eta}s" if eta >= 0 else "ETA: ..."
260
- )
 
 
 
 
261
  try:
262
  await status_msg.edit_text(
263
  text,
@@ -272,9 +285,8 @@ async def send_progress_updater(
272
  task = asyncio.create_task(updater())
273
  return task, bytes_holder
274
 
275
-
276
  # =============================================================================
277
- # UPLOAD / DOWNLOAD JOBS
278
  # =============================================================================
279
 
280
  async def do_upload_job(
@@ -286,10 +298,12 @@ async def do_upload_job(
286
  cancel_event: asyncio.Event,
287
  status_msg: Message,
288
  file_id: Optional[str] = None,
 
289
  ) -> None:
290
- """Download file from Telegram (by message or file_id) and upload to HF."""
291
  temp_dir = tempfile.mkdtemp()
 
292
  local_path = None
 
293
  try:
294
  await status_msg.edit_text(
295
  "⬇️ Downloading from Telegram...",
@@ -298,47 +312,49 @@ async def do_upload_job(
298
  ),
299
  )
300
  last_update = [0]
 
 
301
 
302
- async def download_progress(current, total):
 
 
303
  now = time.time()
304
  if now - last_update[0] < 3:
305
  return
306
  last_update[0] = now
307
  elapsed = max(time.time() - download_start, 0.01)
308
  speed = current / elapsed
309
- pct = (100 * current / total) if total else 0
310
- text = (
311
- f"⬇️ Downloading from Telegram...\n"
312
- f"{pct:.1f}% of {format_size(total)}\n"
313
- f"{format_size(current)} downloaded\n"
314
- f"Speed: {format_speed(speed)}"
315
- )
316
- try:
317
- await status_msg.edit_text(
318
- text,
319
- reply_markup=InlineKeyboardMarkup(
320
- [[InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]]
321
- ),
322
- )
323
- except Exception:
324
- pass
325
-
326
- download_start = time.time()
327
-
328
- if isinstance(file_msg_or_id, Message):
329
- local_path = await file_msg_or_id.download(
330
- file_name=os.path.join(temp_dir, orig_filename),
331
- progress=download_progress,
332
- )
333
- else:
334
- # Download by file_id
335
- local_path = await app.download_media(
336
- file_msg_or_id,
337
- file_name=os.path.join(temp_dir, orig_filename),
338
- progress=download_progress,
339
- )
340
 
341
  if cancel_event.is_set():
 
342
  return
343
 
344
  file_size = os.path.getsize(local_path)
@@ -355,36 +371,30 @@ async def do_upload_job(
355
  )
356
 
357
  loop = asyncio.get_event_loop()
358
- upload_done = loop.create_future()
359
-
360
  def sync_upload():
361
  hf.upload_file(
362
  path_or_fileobj=local_path,
363
  path_in_repo=repo_path,
364
  repo_id=DATASET_REPO,
365
  repo_type=DATASET_TYPE,
 
366
  )
367
 
368
  task = loop.run_in_executor(None, sync_upload)
369
-
370
- while not task.done():
371
- if cancel_event.is_set():
372
- break
373
- await asyncio.sleep(1)
374
 
375
  if cancel_event.is_set():
376
  try:
377
- hf.delete_file(
378
- path_in_repo=repo_path,
379
- repo_id=DATASET_REPO,
380
- repo_type=DATASET_TYPE,
381
- )
382
  except Exception:
383
  pass
384
  await status_msg.edit_text("❌ Upload cancelled.", reply_markup=None)
385
  return
386
 
387
- await task
388
  db_add_file(user_id, repo_path, orig_filename, file_size)
389
  await status_msg.edit_text(f"✅ Uploaded: `{orig_filename}`", reply_markup=None)
390
  logger.info("Uploaded %s for user %d", orig_filename, user_id)
@@ -405,6 +415,7 @@ async def do_download_job(
405
  status_msg: Message,
406
  ) -> None:
407
  temp_dir = tempfile.mkdtemp()
 
408
  try:
409
  loop = asyncio.get_event_loop()
410
 
@@ -423,6 +434,7 @@ async def do_download_job(
423
  repo_type=DATASET_TYPE,
424
  token=HF_TOKEN,
425
  local_dir=temp_dir,
 
426
  ),
427
  )
428
 
@@ -440,7 +452,8 @@ async def do_download_job(
440
  job_id, status_msg, cancel_event, total_size
441
  )
442
 
443
- async def send_progress(current, total):
 
444
  bytes_holder[0] = current
445
 
446
  await app.send_document(
@@ -449,36 +462,47 @@ async def do_download_job(
449
  file_name=orig_name,
450
  caption=orig_name,
451
  progress=send_progress,
 
452
  )
453
  prog_task.cancel()
454
- await status_msg.delete()
 
 
 
455
  else:
456
  part_size = MAX_FILE_SIZE_TG
457
  num_parts = -(-total_size // part_size)
458
  with open(local_file, "rb") as f:
459
  for i in range(1, num_parts + 1):
460
  if cancel_event.is_set():
461
- await status_msg.edit_text("❌ Cancelled during send.")
462
- return
463
  await status_msg.edit_text(
464
  f"📤 Sending part {i}/{num_parts}...",
465
  reply_markup=InlineKeyboardMarkup(
466
  [[InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]]
467
  ),
468
  )
469
- chunk = f.read(part_size)
470
  part_name = f"{orig_name}.part{i}of{num_parts}"
471
  part_path = os.path.join(temp_dir, part_name)
472
  with open(part_path, "wb") as p:
473
- p.write(chunk)
 
 
 
 
 
 
474
  await app.send_document(
475
  chat_id,
476
  document=part_path,
477
  file_name=part_name,
478
  caption=f"{orig_name} – Part {i}/{num_parts}",
 
479
  )
480
  os.remove(part_path)
481
- await status_msg.delete()
 
482
  logger.info("Downloaded %s for chat %d", orig_name, chat_id)
483
 
484
  except Exception as e:
@@ -489,16 +513,12 @@ async def do_download_job(
489
 
490
 
491
  # =============================================================================
492
- # DELETE FUNCTIONS
493
  # =============================================================================
494
 
495
  async def delete_user_file(chat_id, user_id, enc_path, token, query: CallbackQuery):
496
  try:
497
- hf.delete_file(
498
- path_in_repo=enc_path,
499
- repo_id=DATASET_REPO,
500
- repo_type=DATASET_TYPE,
501
- )
502
  db_remove_file(enc_path)
503
  file_callback_map.pop(token, None)
504
  await query.answer("File deleted.", show_alert=True)
@@ -515,20 +535,12 @@ async def delete_all_user_files(chat_id, user_id, query: CallbackQuery):
515
 
516
  for enc_path, _, _ in user_files:
517
  try:
518
- hf.delete_file(
519
- path_in_repo=enc_path,
520
- repo_id=DATASET_REPO,
521
- repo_type=DATASET_TYPE,
522
- )
523
  except Exception:
524
  pass
525
 
526
  db_remove_all_user_files(user_id)
527
-
528
- tokens_to_delete = [
529
- tok for tok, path in file_callback_map.items()
530
- if path.startswith(get_encrypted_folder(user_id))
531
- ]
532
  for tok in tokens_to_delete:
533
  file_callback_map.pop(tok, None)
534
 
@@ -542,14 +554,14 @@ async def refresh_file_list(chat_id: int, user_id: int, message: Message):
542
  return await message.edit_text("You have no uploaded files.", reply_markup=None)
543
 
544
  buttons = []
545
- for enc_path, orig_name, _ in user_files:
546
  token = make_token()
547
  file_callback_map[token] = enc_path
 
548
  buttons.append([
549
- InlineKeyboardButton(f"📥 {orig_name[:40]}", callback_data=f"get:{token}"),
550
  InlineKeyboardButton("🗑 Delete", callback_data=f"delete:{token}"),
551
  ])
552
-
553
  buttons.append([InlineKeyboardButton("⚠️ Delete All Files", callback_data=f"delete_all:{user_id}")])
554
 
555
  await message.edit_text(
@@ -559,48 +571,169 @@ async def refresh_file_list(chat_id: int, user_id: int, message: Message):
559
 
560
 
561
  # =============================================================================
562
- # ALBUM GROUPING
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  # =============================================================================
564
 
565
  async def process_album_group(media_group_id: str):
566
  group_data = album_groups.pop(media_group_id, None)
567
  if not group_data:
568
  return
569
-
570
  chat_id = group_data['chat_id']
571
  user_id = group_data['user_id']
572
- messages = group_data['messages'] # list of (message_id, filename, file_id)
 
 
 
 
 
 
 
 
 
573
 
574
- if len(messages) == 1:
575
- msg_id, filename, file_id = messages[0]
576
- token = make_token()
577
- upload_prompt_map[token] = [(user_id, msg_id, filename, file_id)]
578
- kb = InlineKeyboardMarkup([
579
- [InlineKeyboardButton("⬆️ Upload File", callback_data=f"upload:{token}")],
580
- [InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")],
581
- ])
582
- await app.send_message(chat_id, f"Do you want to upload `{filename}`?", reply_markup=kb)
583
- else:
584
- files_list = "\n".join(f"• {fname}" for _, fname, _ in messages)
585
- token = make_token()
586
- upload_prompt_map[token] = [(user_id, msg_id, fname, fid) for msg_id, fname, fid in messages]
587
-
588
- button_rows = [
589
- [InlineKeyboardButton("⬆️ Upload All", callback_data=f"upload_all:{token}")]
590
- ]
591
- for msg_id, fname, fid in messages:
592
- individual_token = make_token()
593
- upload_prompt_map[individual_token] = [(user_id, msg_id, fname, fid)]
594
- button_rows.append(
595
- [InlineKeyboardButton(f"📤 {fname[:40]}", callback_data=f"upload:{individual_token}")]
 
 
 
 
596
  )
597
- button_rows.append([InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
 
599
- await app.send_message(
600
- chat_id,
601
- f"Received {len(messages)} files.\n{files_list}\n\nChoose:",
602
- reply_markup=InlineKeyboardMarkup(button_rows),
603
- )
 
 
 
 
 
 
 
 
 
 
 
604
 
605
 
606
  # =============================================================================
@@ -609,9 +742,11 @@ async def process_album_group(media_group_id: str):
609
 
610
  async def upload_worker():
611
  while True:
612
- job = await upload_queue.get()
613
- user_id, chat_id, file_obj, orig_filename, job_id, file_id = job
614
- cancel_event = asyncio.Event()
 
 
615
  active_jobs[job_id] = {"status": "running", "cancel_event": cancel_event}
616
 
617
  while get_free_disk() < MIN_FREE_DISK:
@@ -628,16 +763,14 @@ async def upload_worker():
628
  active_jobs[job_id]["status_msg"] = status_msg
629
 
630
  try:
631
- await do_upload_job(
632
- user_id, chat_id, file_obj, orig_filename, job_id, cancel_event, status_msg, file_id
633
- )
634
  finally:
635
  active_jobs.pop(job_id, None)
636
  upload_queue.task_done()
637
 
638
 
639
  # =============================================================================
640
- # BOT COMMAND HANDLERS
641
  # =============================================================================
642
 
643
  @app.on_message(filters.command("start"))
@@ -645,30 +778,28 @@ async def cmd_start(_, message: Message):
645
  await message.reply_text(
646
  "👋 **Welcome to the Storage Bot!**\n\n"
647
  "Send me any file or a Telegram message link to upload it to our cloud dataset.\n"
648
- "Use /help for a full command list."
 
649
  )
650
 
651
-
652
  @app.on_message(filters.command("help"))
653
  async def cmd_help(_, message: Message):
654
  text = (
655
  "**Available Commands:**\n\n"
656
- "/start – Show welcome message\n"
657
- "/files – List your uploaded files (with download/delete buttons)\n"
658
- "/status – Show system & dataset space\n"
659
- "/delete_all – Delete all your files (confirmation button)\n"
660
- "/upload <link> – Directly upload a file from a Telegram message link\n"
661
- "/help – This help text\n\n"
662
- "**How to use:**\n"
663
- "• Send any file (document, video, audio, photo, sticker, voice, etc.) → bot asks to upload.\n"
664
- "• Send multiple files as an album → bot shows one message with 'Upload All' + individual buttons.\n"
665
- "• Send a message link (like https://t.me/.../123) → bot fetches the file and asks to upload.\n"
666
- "• Use /upload <link> to skip the prompt and upload instantly.\n"
667
- "• In /files list, click a file to download it back, or use the Delete button."
668
  )
669
  await message.reply_text(text, disable_web_page_preview=True)
670
 
671
-
672
  @app.on_message(filters.command("status"))
673
  async def cmd_status(_, message: Message):
674
  free = get_free_disk()
@@ -677,32 +808,24 @@ async def cmd_status(_, message: Message):
677
  try:
678
  info = hf.dataset_info(repo_id=DATASET_REPO)
679
  used = info.used_storage or 0
680
- except Exception:
 
681
  used = 0
682
  dataset_text = f"📦 Dataset: {format_size(used)} used of {format_size(DATASET_MAX_SIZE)}"
683
 
684
  uptime_sec = int(time.time() - bot_start_time)
685
  uptime_text = f"⏱️ Uptime: {uptime_sec//3600}h {(uptime_sec%3600)//60}m {uptime_sec%60}s"
686
- mem_text = f"🧠 Memory: {get_memory_usage()}"
687
- cpu_text = f"🖥️ CPU cores: {os.cpu_count()}"
688
-
689
- try:
690
- r = requests.head("https://huggingface.co", timeout=5)
691
- hf_ping = "OK" if r.status_code < 400 else f"HTTP {r.status_code}"
692
- except Exception:
693
- hf_ping = "unreachable"
694
 
695
- await message.reply_text(
696
- f"{disk_text}\n{dataset_text}\n{uptime_text}\n{mem_text}\n{cpu_text}\n"
697
- f"🌐 Hugging Face: {hf_ping}"
698
- )
699
 
 
 
700
 
701
  @app.on_message(filters.command("files"))
702
  async def cmd_files(_, message: Message):
703
  user_id = message.from_user.id
704
  user_files = db_get_user_files(user_id)
705
-
706
  if not user_files:
707
  enc_folder = get_encrypted_folder(user_id)
708
  try:
@@ -723,28 +846,25 @@ async def cmd_files(_, message: Message):
723
  db_add_file(user_id, path, orig_name, size)
724
  user_files = db_get_user_files(user_id)
725
  except Exception as e:
726
- logger.warning("Fallback scan failed for user %d: %s", user_id, e)
727
-
728
  if not user_files:
729
  return await message.reply_text("You have no uploaded files.")
730
-
731
  buttons = []
732
- for enc_path, orig_name, _ in user_files:
733
  token = make_token()
734
  file_callback_map[token] = enc_path
 
735
  buttons.append([
736
- InlineKeyboardButton(f"📥 {orig_name[:40]}", callback_data=f"get:{token}"),
737
  InlineKeyboardButton("🗑 Delete", callback_data=f"delete:{token}"),
738
  ])
739
-
740
  buttons.append([InlineKeyboardButton("⚠️ Delete All Files", callback_data=f"delete_all:{user_id}")])
741
-
742
  await message.reply_text(
743
  "Your uploaded files (click to download, delete to remove):",
744
  reply_markup=InlineKeyboardMarkup(buttons),
 
745
  )
746
 
747
-
748
  @app.on_message(filters.command("delete_all"))
749
  async def cmd_delete_all(_, message: Message):
750
  user_id = message.from_user.id
@@ -752,23 +872,34 @@ async def cmd_delete_all(_, message: Message):
752
  [InlineKeyboardButton("Yes, delete all my files", callback_data=f"delete_all:{user_id}")],
753
  [InlineKeyboardButton("Cancel", callback_data="cancel_upload:0")],
754
  ])
755
- await message.reply_text(
756
- "⚠️ This will permanently delete **all** your files from the dataset. Are you sure?",
757
- reply_markup=kb,
758
- )
759
-
 
 
 
 
 
 
 
 
 
 
 
 
 
760
 
761
  @app.on_message(filters.command("upload"))
762
  async def cmd_upload(_, message: Message):
763
  args = message.text.split(maxsplit=1)
764
  if len(args) < 2:
765
  return await message.reply_text("Usage: /upload <link>")
766
-
767
  link = args[1].strip()
768
  match = re.search(r'(https?://t\.me/((?:c/)?[\w\d_]+)/(\d+))', link)
769
  if not match:
770
  return await message.reply_text("Invalid Telegram message link.")
771
-
772
  chat_str, msg_id = match.group(2), int(match.group(3))
773
  try:
774
  if chat_str.startswith("c/"):
@@ -778,81 +909,127 @@ async def cmd_upload(_, message: Message):
778
  msg = await app.get_messages(chat_id, msg_id)
779
  except Exception as e:
780
  return await message.reply_text(f"❌ Could not fetch the message: {e}")
781
-
782
  if not msg.media:
783
- return await message.reply_text("The linked message doesn't contain any downloadable file.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
784
 
785
- # Extract file_id and filename
786
- file_id = None
 
787
  if msg.document:
788
- filename = msg.document.file_name
789
- file_id = msg.document.file_id
790
  elif msg.video:
791
- filename = msg.video.file_name or f"{msg.video.file_unique_id}.mp4"
792
- file_id = msg.video.file_id
793
  elif msg.audio:
794
- filename = msg.audio.file_name or f"{msg.audio.file_unique_id}.mp3"
795
- file_id = msg.audio.file_id
796
  elif msg.photo:
797
- filename = f"{msg.photo.file_unique_id}.jpg"
798
- file_id = msg.photo.file_id
799
  elif msg.animation:
800
- filename = msg.animation.file_name or f"{msg.animation.file_unique_id}.mp4"
801
- file_id = msg.animation.file_id
802
  elif msg.sticker:
803
  if msg.sticker.is_animated:
804
- filename = f"{msg.sticker.file_unique_id}.tgs"
805
  elif msg.sticker.is_video:
806
- filename = f"{msg.sticker.file_unique_id}.webm"
807
  else:
808
- filename = f"{msg.sticker.file_unique_id}.webp"
809
- file_id = msg.sticker.file_id
810
  elif msg.video_note:
811
- filename = f"{msg.video_note.file_unique_id}.mp4"
812
- file_id = msg.video_note.file_id
813
  elif msg.voice:
814
- filename = f"{msg.voice.file_unique_id}.ogg"
815
- file_id = msg.voice.file_id
816
- else:
817
- filename = f"file_{msg_id}"
818
-
819
- job_id = int(time.time() * 1000 + hash(filename))
820
- active_jobs[job_id] = {"status": "queued"}
821
- await upload_queue.put((message.from_user.id, message.chat.id, file_id, filename, job_id, file_id))
822
- await message.reply_text(f"📦 `{filename}` queued for upload.")
823
 
824
 
825
  # =============================================================================
826
- # CALLBACK HANDLERS
827
  # =============================================================================
828
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
  @app.on_callback_query(filters.regex("^get:"))
830
  async def cb_get_file(_, query: CallbackQuery):
831
  token = query.data.replace("get:", "")
832
  enc_path = file_callback_map.get(token)
833
  if not enc_path:
834
  return await query.answer("Invalid or expired link. Use /files again.", show_alert=True)
835
-
836
  job_id = int(time.time() * 1000)
837
  cancel_event = asyncio.Event()
838
  active_jobs[job_id] = {"status": "running", "cancel_event": cancel_event, "type": "download"}
839
-
840
  try:
841
  orig_name = decrypt_text(enc_path.split("/")[-1])
842
  except Exception:
843
  orig_name = enc_path.split("/")[-1]
844
-
845
  status_msg = await query.message.edit_text(
846
  "⏳ Preparing download...",
847
- reply_markup=InlineKeyboardMarkup(
848
- [[InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]]
849
- ),
850
  )
851
  active_jobs[job_id]["status_msg"] = status_msg
852
-
853
  asyncio.create_task(do_download_job(query.message.chat.id, enc_path, orig_name, job_id, cancel_event, status_msg))
854
 
855
-
856
  @app.on_callback_query(filters.regex("^delete:"))
857
  async def cb_delete_file(_, query: CallbackQuery):
858
  token = query.data.replace("delete:", "")
@@ -862,23 +1039,26 @@ async def cb_delete_file(_, query: CallbackQuery):
862
  user_id = query.from_user.id
863
  await delete_user_file(query.message.chat.id, user_id, enc_path, token, query)
864
 
865
-
866
  @app.on_callback_query(filters.regex("^delete_all:"))
867
  async def cb_delete_all(_, query: CallbackQuery):
868
  user_id = int(query.data.split(":")[-1])
869
  await delete_all_user_files(query.message.chat.id, user_id, query)
870
 
871
-
872
  @app.on_callback_query(filters.regex("^cancel_job:"))
873
  async def cb_cancel_job(_, query: CallbackQuery):
874
  _, job_id_str = query.data.split(":")
875
  job_id = int(job_id_str)
876
- if job_id in active_jobs:
877
- active_jobs[job_id]["cancel_event"].set()
878
- await query.answer("Cancelling...")
 
 
 
 
 
 
879
  else:
880
- await query.answer("Job not found.")
881
-
882
 
883
  @app.on_callback_query(filters.regex("^upload:"))
884
  async def cb_upload(_, query: CallbackQuery):
@@ -886,49 +1066,59 @@ async def cb_upload(_, query: CallbackQuery):
886
  entries = upload_prompt_map.pop(token, None)
887
  if not entries:
888
  return await query.answer("Prompt expired.", show_alert=True)
889
-
890
  for entry in entries:
891
  user_id = entry[0]
892
- if len(entry) == 4:
893
- # Local: (user_id, msg_id, filename, file_id)
894
- msg_id = entry[1]
895
- filename = entry[2]
896
- file_id = entry[3]
897
  try:
898
  file_msg = await app.get_messages(query.message.chat.id, msg_id)
899
  except Exception:
900
- await query.answer(f"Message for {filename} missing.", show_alert=True)
901
  continue
902
  if not file_msg:
903
- await query.answer(f"File {filename} no longer available.", show_alert=True)
904
  continue
905
  file_obj = file_msg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906
  else:
907
- # Remote: (user_id, file_id, filename)
908
- file_id = entry[1]
909
- filename = entry[2]
 
 
 
910
  file_obj = file_id
911
 
912
  job_id = int(time.time() * 1000 + hash(filename))
913
- active_jobs[job_id] = {"status": "queued"}
914
- await upload_queue.put((user_id, query.message.chat.id, file_obj, filename, job_id, file_id))
915
-
916
  await query.message.edit_text(f"📦 {len(entries)} file(s) added to queue.", reply_markup=None)
917
 
918
-
919
  @app.on_callback_query(filters.regex("^upload_all:"))
920
  async def cb_upload_all(_, query: CallbackQuery):
921
  token = query.data.replace("upload_all:", "")
922
  entries = upload_prompt_map.pop(token, None)
923
  if not entries:
924
  return await query.answer("Prompt expired.", show_alert=True)
925
-
926
  for entry in entries:
927
  user_id = entry[0]
928
- if len(entry) == 4:
929
- msg_id = entry[1]
930
- filename = entry[2]
931
- file_id = entry[3]
932
  try:
933
  file_msg = await app.get_messages(query.message.chat.id, msg_id)
934
  except Exception:
@@ -936,37 +1126,56 @@ async def cb_upload_all(_, query: CallbackQuery):
936
  if not file_msg:
937
  continue
938
  file_obj = file_msg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
939
  else:
940
- file_id = entry[1]
941
- filename = entry[2]
 
 
 
 
942
  file_obj = file_id
943
 
944
  job_id = int(time.time() * 1000 + hash(filename))
945
- active_jobs[job_id] = {"status": "queued"}
946
- await upload_queue.put((user_id, query.message.chat.id, file_obj, filename, job_id, file_id))
947
-
948
  await query.message.edit_text(f"📦 All {len(entries)} files added to queue.", reply_markup=None)
949
 
950
-
951
  @app.on_callback_query(filters.regex("^cancel_upload:"))
952
  async def cb_cancel_upload_prompt(_, query: CallbackQuery):
953
  await query.message.delete()
954
 
955
 
956
  # =============================================================================
957
- # MESSAGE LINK HANDLER (interactive prompt)
958
  # =============================================================================
959
 
960
  @app.on_message(
961
  filters.text
962
- & ~filters.command(["start", "help", "status", "files", "delete_all", "upload"])
963
  & filters.regex(r'https?://t\.me/')
964
  )
965
  async def on_message_link(client, message: Message):
966
  links = re.findall(r'(https?://t\.me/((?:c/)?[\w\d_]+)/(\d+))', message.text)
967
  if not links:
968
  return
969
-
970
  chat_str, msg_id = links[0][1], int(links[0][2])
971
  try:
972
  if chat_str.startswith("c/"):
@@ -977,61 +1186,42 @@ async def on_message_link(client, message: Message):
977
  except Exception as e:
978
  await message.reply_text(f"❌ Could not access the message: {e}")
979
  return
980
-
981
  if not msg.media:
982
  await message.reply_text("The linked message does not contain any downloadable file.")
983
  return
984
 
985
- # Extract filename and file_id
986
- file_id = None
987
- if msg.document:
988
- filename = msg.document.file_name
989
- file_id = msg.document.file_id
990
- elif msg.video:
991
- filename = msg.video.file_name or f"{msg.video.file_unique_id}.mp4"
992
- file_id = msg.video.file_id
993
- elif msg.audio:
994
- filename = msg.audio.file_name or f"{msg.audio.file_unique_id}.mp3"
995
- file_id = msg.audio.file_id
996
- elif msg.photo:
997
- filename = f"{msg.photo.file_unique_id}.jpg"
998
- file_id = msg.photo.file_id
999
- elif msg.animation:
1000
- filename = msg.animation.file_name or f"{msg.animation.file_unique_id}.mp4"
1001
- file_id = msg.animation.file_id
1002
- elif msg.sticker:
1003
- if msg.sticker.is_animated:
1004
- filename = f"{msg.sticker.file_unique_id}.tgs"
1005
- elif msg.sticker.is_video:
1006
- filename = f"{msg.sticker.file_unique_id}.webm"
1007
- else:
1008
- filename = f"{msg.sticker.file_unique_id}.webp"
1009
- file_id = msg.sticker.file_id
1010
- elif msg.video_note:
1011
- filename = f"{msg.video_note.file_unique_id}.mp4"
1012
- file_id = msg.video_note.file_id
1013
- elif msg.voice:
1014
- filename = f"{msg.voice.file_unique_id}.ogg"
1015
- file_id = msg.voice.file_id
1016
- else:
1017
- filename = f"file_{msg_id}"
1018
-
1019
- # Remote entry: (user_id, file_id, filename)
1020
- token = make_token()
1021
- upload_prompt_map[token] = [(message.from_user.id, file_id, filename)]
1022
 
 
 
1023
  kb = InlineKeyboardMarkup([
1024
- [InlineKeyboardButton("⬆️ Upload File", callback_data=f"upload:{token}")],
1025
  [InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")],
1026
  ])
1027
- await message.reply_text(
1028
- f"Found file `{filename}` in the linked message. Do you want to upload it?",
1029
- reply_markup=kb,
1030
- )
1031
 
1032
 
1033
  # =============================================================================
1034
- # DIRECT FILE RECEIVER (any supported media)
1035
  # =============================================================================
1036
 
1037
  @app.on_message(
@@ -1039,39 +1229,10 @@ async def on_message_link(client, message: Message):
1039
  | filters.animation | filters.sticker | filters.video_note | filters.voice
1040
  )
1041
  async def on_direct_file(_, message: Message):
1042
- if message.document:
1043
- filename = message.document.file_name
1044
- file_id = message.document.file_id
1045
- elif message.video:
1046
- filename = message.video.file_name or f"{message.video.file_unique_id}.mp4"
1047
- file_id = message.video.file_id
1048
- elif message.audio:
1049
- filename = message.audio.file_name or f"{message.audio.file_unique_id}.mp3"
1050
- file_id = message.audio.file_id
1051
- elif message.photo:
1052
- filename = f"{message.photo.file_unique_id}.jpg"
1053
- file_id = message.photo.file_id
1054
- elif message.animation:
1055
- filename = message.animation.file_name or f"{message.animation.file_unique_id}.mp4"
1056
- file_id = message.animation.file_id
1057
- elif message.sticker:
1058
- if message.sticker.is_animated:
1059
- filename = f"{message.sticker.file_unique_id}.tgs"
1060
- elif message.sticker.is_video:
1061
- filename = f"{message.sticker.file_unique_id}.webm"
1062
- else:
1063
- filename = f"{message.sticker.file_unique_id}.webp"
1064
- file_id = message.sticker.file_id
1065
- elif message.video_note:
1066
- filename = f"{message.video_note.file_unique_id}.mp4"
1067
- file_id = message.video_note.file_id
1068
- elif message.voice:
1069
- filename = f"{message.voice.file_unique_id}.ogg"
1070
- file_id = message.voice.file_id
1071
- else:
1072
  return
1073
 
1074
- # Album grouping
1075
  if message.media_group_id and (message.photo or message.video or message.document):
1076
  group_id = str(message.media_group_id)
1077
  if group_id not in album_groups:
@@ -1081,8 +1242,7 @@ async def on_direct_file(_, message: Message):
1081
  'messages': [],
1082
  'timer': None,
1083
  }
1084
- album_groups[group_id]['messages'].append((message.id, filename, file_id))
1085
-
1086
  if album_groups[group_id]['timer']:
1087
  album_groups[group_id]['timer'].cancel()
1088
 
@@ -1093,19 +1253,35 @@ async def on_direct_file(_, message: Message):
1093
  album_groups[group_id]['timer'] = asyncio.create_task(timer_callback())
1094
  return
1095
 
1096
- # Single file – local entry: (user_id, msg_id, filename, file_id)
1097
- token = make_token()
1098
- upload_prompt_map[token] = [(message.from_user.id, message.id, filename, file_id)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1099
 
 
 
1100
  kb = InlineKeyboardMarkup([
1101
- [InlineKeyboardButton("⬆️ Upload File", callback_data=f"upload:{token}")],
1102
  [InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")],
1103
  ])
1104
  await message.reply_text(f"Do you want to upload `{filename}`?", reply_markup=kb)
1105
 
1106
 
1107
  # =============================================================================
1108
- # KEEP‑ALIVE PING
1109
  # =============================================================================
1110
 
1111
  def keepalive():
@@ -1118,13 +1294,19 @@ def keepalive():
1118
 
1119
 
1120
  # =============================================================================
1121
- # MAIN ENTRY POINT
1122
  # =============================================================================
1123
 
1124
  async def main():
 
 
 
 
 
 
1125
  asyncio.create_task(upload_worker())
1126
  await app.start()
1127
- logger.info("Bot started successfully")
1128
  await asyncio.Event().wait()
1129
 
1130
 
 
1
+ import os, re, sys, time, base64, hashlib, shutil, logging, secrets, sqlite3, tempfile, threading, asyncio
 
 
 
 
 
 
 
 
 
 
 
 
2
  from pathlib import Path
3
+ from typing import Optional, List, Tuple, Union, Set
4
 
5
  import requests
6
  from flask import Flask
7
  from pyrogram import Client, filters
8
+ from pyrogram.types import InlineKeyboardMarkup, InlineKeyboardButton, Message, CallbackQuery
9
+ from huggingface_hub import HfApi, get_hf_file_metadata, hf_hub_download, hf_hub_url
 
 
 
 
 
 
 
 
 
 
10
  from cryptography.hazmat.primitives.ciphers.aead import AESSIV
11
 
12
+ # Force tgCrypto for maximum Telegram speed
13
+ try:
14
+ import tgcrypto # noqa: F401
15
+ except ImportError:
16
+ print("❌ tgCrypto is NOT installed! Install it with: pip install tgCrypto")
17
+ sys.exit(1)
18
+
19
+ # Optional uvloop
20
+ try:
21
+ import uvloop
22
+ UVLOOP_AVAILABLE = True
23
+ except ImportError:
24
+ UVLOOP_AVAILABLE = False
25
+
26
  # =============================================================================
27
  # CONFIGURATION
28
  # =============================================================================
 
33
  HF_TOKEN: str = os.environ["HF_TOKEN"]
34
  OBFUSCATION_KEY: str = os.environ.get("OBFUSCATION_KEY", "change-me-to-a-secret-string")
35
 
36
+ SPACE_HOST: str = "https://ionzm-Helix.hf.space"
 
37
 
38
+ DATASET_REPO: str = "ionzm/Bzxv"
39
  DATASET_TYPE: str = "dataset"
40
+ MAX_FILE_SIZE_TG: int = 1900 * 1024 * 1024 # 1900 MB
41
+ MAX_SINGLE_FILE_SIZE: int = 45 * 1024**3 # 45 GB
42
+ MIN_FREE_DISK: int = 45 * 1024 * 1024 * 1024 # 45 GB
43
+ DATASET_MAX_SIZE: int = int(8.7 * 1024**4) # 8.7 TB
44
+ KEEPALIVE_INTERVAL: int = 300 # 5 minutes
45
+ UPLOAD_TIMEOUT: int = 7200 # 2 hours
46
  DB_PATH: str = "bot_files.db"
47
 
 
48
  logging.basicConfig(
49
  level=logging.INFO,
50
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
 
53
  logger = logging.getLogger("StorageBot")
54
 
55
  # =============================================================================
56
+ # DETERMINISTIC ENCRYPTION (AES-SIV)
57
  # =============================================================================
58
 
59
  _siv_key = hashlib.sha512(OBFUSCATION_KEY.encode()).digest()
60
  _aes_siv = AESSIV(_siv_key)
61
 
 
62
  def encrypt_text(plain: str) -> str:
 
63
  token = _aes_siv.encrypt(plain.encode(), None)
64
  return base64.urlsafe_b64encode(token).decode().rstrip("=")
65
 
 
66
  def decrypt_text(encoded: str) -> str:
 
67
  padding = 4 - len(encoded) % 4
68
  if padding != 4:
69
  encoded += "=" * padding
70
  token = base64.urlsafe_b64decode(encoded.encode())
71
  return _aes_siv.decrypt(token, None).decode()
72
 
 
73
  def get_encrypted_folder(user_id: int) -> str:
74
  return encrypt_text(str(user_id))
75
 
 
76
  def get_encrypted_filename(original: str) -> str:
77
  return encrypt_text(original)
78
 
 
79
  # =============================================================================
80
  # DATABASE
81
  # =============================================================================
 
84
  conn.row_factory = sqlite3.Row
85
  conn.execute("PRAGMA journal_mode=WAL")
86
  cursor = conn.cursor()
87
+ cursor.execute("""
 
88
  CREATE TABLE IF NOT EXISTS files (
89
  user_id INTEGER,
90
  enc_path TEXT UNIQUE,
 
92
  size INTEGER,
93
  timestamp REAL
94
  )
95
+ """)
 
96
  conn.commit()
97
 
 
98
  def db_add_file(user_id: int, enc_path: str, orig_name: str, size: int) -> None:
99
  cursor.execute(
100
  "INSERT OR REPLACE INTO files (user_id, enc_path, orig_name, size, timestamp) VALUES (?, ?, ?, ?, ?)",
 
102
  )
103
  conn.commit()
104
 
 
105
  def db_get_user_files(user_id: int) -> List[Tuple[str, str, int]]:
106
  cursor.execute(
107
  "SELECT enc_path, orig_name, size FROM files WHERE user_id = ? ORDER BY timestamp DESC",
 
109
  )
110
  return [(row[0], row[1], row[2]) for row in cursor.fetchall()]
111
 
 
112
  def db_remove_file(enc_path: str) -> None:
113
  cursor.execute("DELETE FROM files WHERE enc_path = ?", (enc_path,))
114
  conn.commit()
115
 
 
116
  def db_remove_all_user_files(user_id: int) -> None:
117
  cursor.execute("DELETE FROM files WHERE user_id = ?", (user_id,))
118
  conn.commit()
119
 
120
+ def db_file_exists(user_id: int, orig_name: str, size: Optional[int] = None) -> Optional[Tuple[str, int]]:
121
+ if size is not None:
122
+ cursor.execute(
123
+ "SELECT enc_path, size FROM files WHERE user_id = ? AND orig_name = ? AND size = ?",
124
+ (user_id, orig_name, size),
125
+ )
126
+ else:
127
+ cursor.execute(
128
+ "SELECT enc_path, size FROM files WHERE user_id = ? AND orig_name = ?",
129
+ (user_id, orig_name),
130
+ )
131
+ row = cursor.fetchone()
132
+ return (row[0], row[1]) if row else None
133
 
134
  # =============================================================================
135
+ # IN-MEMORY STORES
136
  # =============================================================================
137
 
138
+ file_callback_map: dict[str, str] = {}
 
 
 
139
  upload_prompt_map: dict[str, List[Tuple]] = {}
 
 
140
  album_groups: dict[str, dict] = {}
141
+ duplicate_prompt_map: dict[str, dict] = {}
142
 
143
  def make_token() -> str:
144
  return secrets.token_hex(8)
145
 
 
146
  # =============================================================================
147
+ # FLASK HEALTH CHECK
148
  # =============================================================================
149
 
150
  web = Flask(__name__)
151
 
 
152
  @web.route("/")
153
  def home():
154
  return "Status: OK - Bot Running"
155
 
 
156
  def run_web():
157
  web.run(host="0.0.0.0", port=7860)
158
 
 
159
  # =============================================================================
160
+ # HUGGING FACE & PYROGRAM
161
  # =============================================================================
162
 
163
  hf = HfApi(token=HF_TOKEN)
 
170
  )
171
 
172
  # =============================================================================
173
+ # GLOBAL STATE
174
  # =============================================================================
175
 
176
  upload_queue: asyncio.Queue = asyncio.Queue()
177
  active_jobs: dict[int, dict] = {}
178
  bot_start_time = time.time()
179
 
 
180
  # =============================================================================
181
+ # UTILITY HELPERS
182
  # =============================================================================
183
 
184
  def cleanup(path: str) -> None:
 
190
  except Exception:
191
  pass
192
 
 
193
  def get_free_disk() -> int:
194
  return shutil.disk_usage("/").free
195
 
 
196
  def format_size(bytes_val: int) -> str:
197
  for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
198
  if bytes_val < 1024.0:
 
200
  bytes_val /= 1024.0
201
  return f"{bytes_val:.1f} PB"
202
 
 
203
  def format_speed(bytes_per_sec: float) -> str:
204
  return f"{format_size(bytes_per_sec)}/s"
205
 
206
+ def get_total_memory() -> int:
 
207
  try:
208
+ with open("/sys/fs/cgroup/memory/memory.limit_in_bytes") as f:
209
+ limit = int(f.read().strip())
210
+ if limit < 9_223_372_036_854_771_200:
211
+ return limit
212
+ except Exception:
213
+ pass
214
+ try:
215
+ with open("/proc/meminfo") as f:
216
  for line in f:
217
+ if line.startswith("MemTotal:"):
218
+ return int(line.split()[1]) * 1024
219
  except Exception:
220
+ pass
221
+ return 0
222
 
223
+ def extract_file_size(msg: Message) -> Optional[int]:
224
+ if msg.document:
225
+ return msg.document.file_size
226
+ if msg.video:
227
+ return msg.video.file_size
228
+ if msg.audio:
229
+ return msg.audio.file_size
230
+ if msg.photo:
231
+ return msg.photo.file_size
232
+ if msg.animation:
233
+ return msg.animation.file_size
234
+ if msg.sticker:
235
+ return msg.sticker.file_size
236
+ if msg.video_note:
237
+ return msg.video_note.file_size
238
+ if msg.voice:
239
+ return msg.voice.file_size
240
+ return None
241
+
242
+ def check_file_size_limit(filename: str, size: Optional[int]) -> Optional[str]:
243
+ if size is not None and size > MAX_SINGLE_FILE_SIZE:
244
+ return f"❌ File `{filename}` is too large ({format_size(size)}). Maximum allowed is {format_size(MAX_SINGLE_FILE_SIZE)}."
245
+ return None
246
 
247
  async def send_progress_updater(
248
  job_id: int,
 
258
  elapsed = max(time.time() - start_time, 0.01)
259
  current = bytes_holder[0]
260
  speed = current / elapsed
261
+ text = "📤 Sending to Telegram...\n"
262
+ if total_bytes > 0:
263
+ pct = (100 * current / total_bytes)
264
+ text += f"{pct:.1f}% of {format_size(total_bytes)}\n"
265
+ text += f"{format_size(current)} sent / {format_size(total_bytes)}\n"
266
+ else:
267
+ text += f"{format_size(current)} sent\n"
268
+ text += f"Speed: {format_speed(speed)}\n"
269
+ if total_bytes > 0 and speed > 0:
270
+ eta = int((total_bytes - current) / speed)
271
+ text += f"ETA: {eta}s"
272
+ else:
273
+ text += "ETA: ..."
274
  try:
275
  await status_msg.edit_text(
276
  text,
 
285
  task = asyncio.create_task(updater())
286
  return task, bytes_holder
287
 
 
288
  # =============================================================================
289
+ # CORE UPLOAD / DOWNLOAD LOGIC (FIXED)
290
  # =============================================================================
291
 
292
  async def do_upload_job(
 
298
  cancel_event: asyncio.Event,
299
  status_msg: Message,
300
  file_id: Optional[str] = None,
301
+ expected_size: Optional[int] = None,
302
  ) -> None:
 
303
  temp_dir = tempfile.mkdtemp()
304
+ active_jobs[job_id]["temp_dir"] = temp_dir
305
  local_path = None
306
+ orig_filename = os.path.basename(orig_filename)
307
  try:
308
  await status_msg.edit_text(
309
  "⬇️ Downloading from Telegram...",
 
312
  ),
313
  )
314
  last_update = [0]
315
+ download_start = time.time()
316
+ total_size = expected_size if expected_size and expected_size > 0 else 0
317
 
318
+ # Pyrogram expects a sync progress function, not async
319
+ def download_progress(current, total):
320
+ nonlocal total_size
321
  now = time.time()
322
  if now - last_update[0] < 3:
323
  return
324
  last_update[0] = now
325
  elapsed = max(time.time() - download_start, 0.01)
326
  speed = current / elapsed
327
+ if total and total > 0:
328
+ total_size = total
329
+ elif total_size == 0 and expected_size and expected_size > 0:
330
+ total_size = expected_size
331
+
332
+ text = "⬇️ Downloading from Telegram...\n"
333
+ if total_size > 0:
334
+ pct = (100 * current / total_size)
335
+ text += f"{pct:.1f}% of {format_size(total_size)}\n"
336
+ text += f"{format_size(current)} downloaded / {format_size(total_size)}\n"
337
+ else:
338
+ text += f"{format_size(current)} downloaded\n"
339
+ text += f"Speed: {format_speed(speed)}"
340
+ # Schedule edit in main loop
341
+ asyncio.create_task(status_msg.edit_text(
342
+ text,
343
+ reply_markup=InlineKeyboardMarkup(
344
+ [[InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]]
345
+ ),
346
+ ))
347
+
348
+ # Fixed: Use app.download_media() for both Message and file_id
349
+ local_path = await app.download_media(
350
+ file_msg_or_id,
351
+ file_name=os.path.join(temp_dir, orig_filename),
352
+ progress=download_progress,
353
+ block_size=1_048_576,
354
+ )
 
 
 
355
 
356
  if cancel_event.is_set():
357
+ await status_msg.edit_text("❌ Upload cancelled.", reply_markup=None)
358
  return
359
 
360
  file_size = os.path.getsize(local_path)
 
371
  )
372
 
373
  loop = asyncio.get_event_loop()
 
 
374
  def sync_upload():
375
  hf.upload_file(
376
  path_or_fileobj=local_path,
377
  path_in_repo=repo_path,
378
  repo_id=DATASET_REPO,
379
  repo_type=DATASET_TYPE,
380
+ chunk_size=50 * 1024 * 1024,
381
  )
382
 
383
  task = loop.run_in_executor(None, sync_upload)
384
+ try:
385
+ await asyncio.wait_for(asyncio.wrap_future(task), timeout=UPLOAD_TIMEOUT)
386
+ except asyncio.TimeoutError:
387
+ await status_msg.edit_text("❌ Upload timed out.", reply_markup=None)
388
+ return
389
 
390
  if cancel_event.is_set():
391
  try:
392
+ hf.delete_file(path_in_repo=repo_path, repo_id=DATASET_REPO, repo_type=DATASET_TYPE)
 
 
 
 
393
  except Exception:
394
  pass
395
  await status_msg.edit_text("❌ Upload cancelled.", reply_markup=None)
396
  return
397
 
 
398
  db_add_file(user_id, repo_path, orig_filename, file_size)
399
  await status_msg.edit_text(f"✅ Uploaded: `{orig_filename}`", reply_markup=None)
400
  logger.info("Uploaded %s for user %d", orig_filename, user_id)
 
415
  status_msg: Message,
416
  ) -> None:
417
  temp_dir = tempfile.mkdtemp()
418
+ active_jobs[job_id]["temp_dir"] = temp_dir
419
  try:
420
  loop = asyncio.get_event_loop()
421
 
 
434
  repo_type=DATASET_TYPE,
435
  token=HF_TOKEN,
436
  local_dir=temp_dir,
437
+ resume_download=True,
438
  ),
439
  )
440
 
 
452
  job_id, status_msg, cancel_event, total_size
453
  )
454
 
455
+ # Pyrogram expects a sync progress callback
456
+ def send_progress(current, total):
457
  bytes_holder[0] = current
458
 
459
  await app.send_document(
 
462
  file_name=orig_name,
463
  caption=orig_name,
464
  progress=send_progress,
465
+ block_size=1_048_576,
466
  )
467
  prog_task.cancel()
468
+ if cancel_event.is_set():
469
+ await status_msg.edit_text("❌ Download cancelled.", reply_markup=None)
470
+ else:
471
+ await status_msg.delete()
472
  else:
473
  part_size = MAX_FILE_SIZE_TG
474
  num_parts = -(-total_size // part_size)
475
  with open(local_file, "rb") as f:
476
  for i in range(1, num_parts + 1):
477
  if cancel_event.is_set():
478
+ await status_msg.edit_text("❌ Cancelled during send.", reply_markup=None)
479
+ break
480
  await status_msg.edit_text(
481
  f"📤 Sending part {i}/{num_parts}...",
482
  reply_markup=InlineKeyboardMarkup(
483
  [[InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]]
484
  ),
485
  )
 
486
  part_name = f"{orig_name}.part{i}of{num_parts}"
487
  part_path = os.path.join(temp_dir, part_name)
488
  with open(part_path, "wb") as p:
489
+ bytes_written = 0
490
+ while bytes_written < part_size:
491
+ buf = f.read(min(1024 * 1024, part_size - bytes_written))
492
+ if not buf:
493
+ break
494
+ p.write(buf)
495
+ bytes_written += len(buf)
496
  await app.send_document(
497
  chat_id,
498
  document=part_path,
499
  file_name=part_name,
500
  caption=f"{orig_name} – Part {i}/{num_parts}",
501
+ block_size=1_048_576,
502
  )
503
  os.remove(part_path)
504
+ if not cancel_event.is_set():
505
+ await status_msg.delete()
506
  logger.info("Downloaded %s for chat %d", orig_name, chat_id)
507
 
508
  except Exception as e:
 
513
 
514
 
515
  # =============================================================================
516
+ # DELETION HELPERS (unchanged, but included for completeness)
517
  # =============================================================================
518
 
519
  async def delete_user_file(chat_id, user_id, enc_path, token, query: CallbackQuery):
520
  try:
521
+ hf.delete_file(path_in_repo=enc_path, repo_id=DATASET_REPO, repo_type=DATASET_TYPE)
 
 
 
 
522
  db_remove_file(enc_path)
523
  file_callback_map.pop(token, None)
524
  await query.answer("File deleted.", show_alert=True)
 
535
 
536
  for enc_path, _, _ in user_files:
537
  try:
538
+ hf.delete_file(path_in_repo=enc_path, repo_id=DATASET_REPO, repo_type=DATASET_TYPE)
 
 
 
 
539
  except Exception:
540
  pass
541
 
542
  db_remove_all_user_files(user_id)
543
+ tokens_to_delete = [tok for tok, path in file_callback_map.items() if path.startswith(get_encrypted_folder(user_id))]
 
 
 
 
544
  for tok in tokens_to_delete:
545
  file_callback_map.pop(tok, None)
546
 
 
554
  return await message.edit_text("You have no uploaded files.", reply_markup=None)
555
 
556
  buttons = []
557
+ for enc_path, orig_name, size in user_files:
558
  token = make_token()
559
  file_callback_map[token] = enc_path
560
+ display = f"{orig_name[:25]} ({format_size(size)})" if size else orig_name[:25]
561
  buttons.append([
562
+ InlineKeyboardButton(f"📥 {display}", callback_data=f"get:{token}"),
563
  InlineKeyboardButton("🗑 Delete", callback_data=f"delete:{token}"),
564
  ])
 
565
  buttons.append([InlineKeyboardButton("⚠️ Delete All Files", callback_data=f"delete_all:{user_id}")])
566
 
567
  await message.edit_text(
 
571
 
572
 
573
  # =============================================================================
574
+ # DUPLICATE DETECTION & HANDLING (unchanged)
575
+ # =============================================================================
576
+
577
+ def check_duplicate(user_id: int, orig_name: str) -> Optional[int]:
578
+ row = db_file_exists(user_id, orig_name)
579
+ if row:
580
+ return row[1]
581
+ enc_folder = get_encrypted_folder(user_id)
582
+ enc_filename = get_encrypted_filename(orig_name)
583
+ enc_path = f"{enc_folder}/{enc_filename}"
584
+ try:
585
+ if hf.file_exists(repo_id=DATASET_REPO, filename=enc_path, repo_type=DATASET_TYPE):
586
+ db_add_file(user_id, enc_path, orig_name, 0)
587
+ return 0
588
+ except Exception as e:
589
+ logger.warning("HF duplicate check failed: %s", e)
590
+ return None
591
+
592
+
593
+ def generate_unique_filename(user_id: int, base: str, ext: str, reserved: Optional[Set[str]] = None) -> str:
594
+ counter = 1
595
+ while True:
596
+ candidate = f"{base}_{counter}{ext}"
597
+ if reserved and candidate in reserved:
598
+ counter += 1
599
+ continue
600
+ if not db_file_exists(user_id, candidate):
601
+ return candidate
602
+ counter += 1
603
+
604
+
605
+ async def handle_duplicate(
606
+ user_id: int,
607
+ chat_id: int,
608
+ file_obj: Union[Message, str],
609
+ orig_filename: str,
610
+ expected_size: Optional[int],
611
+ existing_size: int,
612
+ file_id: Optional[str] = None,
613
+ msg_id: Optional[int] = None,
614
+ reserved_names: Optional[Set[str]] = None,
615
+ ):
616
+ if expected_size is not None and existing_size != expected_size:
617
+ base, ext = os.path.splitext(orig_filename)
618
+ new_name = generate_unique_filename(user_id, base, ext, reserved_names)
619
+ if reserved_names is not None:
620
+ reserved_names.add(new_name)
621
+ job_id = int(time.time() * 1000 + hash(new_name))
622
+ cancel_event = asyncio.Event()
623
+ active_jobs[job_id] = {"status": "queued", "cancel_event": cancel_event}
624
+ await upload_queue.put((user_id, chat_id, file_obj, new_name, job_id, file_id, expected_size, cancel_event))
625
+ await app.send_message(
626
+ chat_id,
627
+ f"ℹ️ File `{orig_filename}` already exists with a different size. "
628
+ f"Uploading as `{new_name}`."
629
+ )
630
+ else:
631
+ token = secrets.token_hex(8)
632
+ duplicate_prompt_map[token] = {
633
+ 'user_id': user_id,
634
+ 'chat_id': chat_id,
635
+ 'file_obj': file_obj,
636
+ 'orig_filename': orig_filename,
637
+ 'job_id': int(time.time() * 1000 + hash(orig_filename)),
638
+ 'file_id': file_id,
639
+ 'expected_size': expected_size,
640
+ 'existing_size': existing_size,
641
+ 'msg_id': msg_id,
642
+ }
643
+ size_note = f" ({format_size(expected_size)})" if expected_size else ""
644
+ kb = InlineKeyboardMarkup([
645
+ [InlineKeyboardButton("⏭️ Skip", callback_data=f"dup_skip:{token}")],
646
+ [InlineKeyboardButton("📤 Upload (rename)", callback_data=f"dup_keep:{token}")],
647
+ ])
648
+ await app.send_message(
649
+ chat_id,
650
+ f"⚠️ File `{orig_filename}` already exists{size_note}.\nWhat do you want to do?",
651
+ reply_markup=kb,
652
+ )
653
+
654
+
655
+ # =============================================================================
656
+ # ALBUM GROUPING (unchanged)
657
  # =============================================================================
658
 
659
  async def process_album_group(media_group_id: str):
660
  group_data = album_groups.pop(media_group_id, None)
661
  if not group_data:
662
  return
 
663
  chat_id = group_data['chat_id']
664
  user_id = group_data['user_id']
665
+ messages = group_data['messages']
666
+
667
+ valid_files = []
668
+ skipped_names = []
669
+ for msg_id, filename, file_id, size in messages:
670
+ limit_msg = check_file_size_limit(filename, size)
671
+ if limit_msg:
672
+ skipped_names.append(filename)
673
+ else:
674
+ valid_files.append((msg_id, filename, file_id, size))
675
 
676
+ if not valid_files:
677
+ if skipped_names:
678
+ skip_text = "\n".join(f"• `{f}`" for f in skipped_names)
679
+ await app.send_message(chat_id,
680
+ f"❌ All files exceed the {format_size(MAX_SINGLE_FILE_SIZE)} limit and were skipped:\n{skip_text}")
681
+ return
682
+
683
+ reserved_names: Set[str] = set()
684
+ non_dup = []
685
+
686
+ for msg_id, filename, file_id, size in valid_files:
687
+ existing_size = check_duplicate(user_id, filename)
688
+ if existing_size is not None:
689
+ try:
690
+ file_msg = await app.get_messages(chat_id, msg_id)
691
+ except Exception:
692
+ file_msg = None
693
+ await handle_duplicate(
694
+ user_id, chat_id,
695
+ file_obj=file_msg if file_msg else file_id,
696
+ orig_filename=filename,
697
+ expected_size=size,
698
+ existing_size=existing_size,
699
+ file_id=file_id,
700
+ msg_id=msg_id,
701
+ reserved_names=reserved_names,
702
  )
703
+ else:
704
+ non_dup.append((msg_id, filename, file_id, size))
705
+
706
+ if non_dup:
707
+ if len(non_dup) == 1:
708
+ msg_id, filename, file_id, _ = non_dup[0]
709
+ token = make_token()
710
+ upload_prompt_map[token] = [(user_id, msg_id, filename, file_id)]
711
+ kb = InlineKeyboardMarkup([
712
+ [InlineKeyboardButton("⬆️ Upload File", callback_data=f"upload:{token}")],
713
+ [InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")],
714
+ ])
715
+ await app.send_message(chat_id, f"Do you want to upload `{filename}`?", reply_markup=kb)
716
+ else:
717
+ files_list = "\n".join(f"• {fname} ({format_size(fsize)})" for _, fname, _, fsize in non_dup)
718
+ token = make_token()
719
+ upload_prompt_map[token] = [(user_id, msg_id, fname, fid) for msg_id, fname, fid, _ in non_dup]
720
 
721
+ button_rows = [
722
+ [InlineKeyboardButton("⬆️ Upload All", callback_data=f"upload_all:{token}")]
723
+ ]
724
+ for msg_id, fname, fid, _ in non_dup:
725
+ individual_token = make_token()
726
+ upload_prompt_map[individual_token] = [(user_id, msg_id, fname, fid)]
727
+ button_rows.append([InlineKeyboardButton(f"📤 {fname[:40]}", callback_data=f"upload:{individual_token}")])
728
+ button_rows.append([InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")])
729
+
730
+ prompt_text = f"Received {len(non_dup)} non‑duplicate files.\n{files_list}\n\nChoose:"
731
+ if skipped_names:
732
+ prompt_text += f"\n⚠️ Skipped (exceed {format_size(MAX_SINGLE_FILE_SIZE)}): " + ", ".join(f"`{f}`" for f in skipped_names)
733
+ await app.send_message(chat_id, prompt_text, reply_markup=InlineKeyboardMarkup(button_rows))
734
+
735
+ if skipped_names and not non_dup:
736
+ await app.send_message(chat_id, f"⚠️ Skipped {len(skipped_names)} file(s) (exceed {format_size(MAX_SINGLE_FILE_SIZE)}): " + ", ".join(f"`{f}`" for f in skipped_names))
737
 
738
 
739
  # =============================================================================
 
742
 
743
  async def upload_worker():
744
  while True:
745
+ user_id, chat_id, file_obj, orig_filename, job_id, file_id, expected_size, cancel_event = await upload_queue.get()
746
+ if cancel_event.is_set():
747
+ upload_queue.task_done()
748
+ continue
749
+
750
  active_jobs[job_id] = {"status": "running", "cancel_event": cancel_event}
751
 
752
  while get_free_disk() < MIN_FREE_DISK:
 
763
  active_jobs[job_id]["status_msg"] = status_msg
764
 
765
  try:
766
+ await do_upload_job(user_id, chat_id, file_obj, orig_filename, job_id, cancel_event, status_msg, file_id, expected_size)
 
 
767
  finally:
768
  active_jobs.pop(job_id, None)
769
  upload_queue.task_done()
770
 
771
 
772
  # =============================================================================
773
+ # BOT COMMAND HANDLERS (unchanged)
774
  # =============================================================================
775
 
776
  @app.on_message(filters.command("start"))
 
778
  await message.reply_text(
779
  "👋 **Welcome to the Storage Bot!**\n\n"
780
  "Send me any file or a Telegram message link to upload it to our cloud dataset.\n"
781
+ "Use /help for a full command list.\n\n"
782
+ "Type /cancelall to stop all running uploads instantly."
783
  )
784
 
 
785
  @app.on_message(filters.command("help"))
786
  async def cmd_help(_, message: Message):
787
  text = (
788
  "**Available Commands:**\n\n"
789
+ "/start – Welcome message\n"
790
+ "/files – List your uploaded files (download/delete)\n"
791
+ "/status – System & dataset space\n"
792
+ "/delete_all – Delete all your files (confirmation)\n"
793
+ "/upload <link> – Upload a file from a Telegram message link instantly\n"
794
+ "/cancelall – Cancel every active upload / download job\n"
795
+ "/help – This help\n\n"
796
+ "**Duplicate handling:**\n"
797
+ "• Same name, different size → automatically renamed with _1, _2 ... and uploaded.\n"
798
+ "• Same name, same size → you get a Skip / Upload (rename) choice.\n"
799
+ "**Speed:** tgCrypto enforced, 1 MB blocks, 50 MB HF chunks – maximum possible throughput."
 
800
  )
801
  await message.reply_text(text, disable_web_page_preview=True)
802
 
 
803
  @app.on_message(filters.command("status"))
804
  async def cmd_status(_, message: Message):
805
  free = get_free_disk()
 
808
  try:
809
  info = hf.dataset_info(repo_id=DATASET_REPO)
810
  used = info.used_storage or 0
811
+ except Exception as e:
812
+ logger.warning("Failed to fetch dataset info: %s", e)
813
  used = 0
814
  dataset_text = f"📦 Dataset: {format_size(used)} used of {format_size(DATASET_MAX_SIZE)}"
815
 
816
  uptime_sec = int(time.time() - bot_start_time)
817
  uptime_text = f"⏱️ Uptime: {uptime_sec//3600}h {(uptime_sec%3600)//60}m {uptime_sec%60}s"
 
 
 
 
 
 
 
 
818
 
819
+ mem_text = f"🧠 Memory: {format_size(get_total_memory())}"
820
+ cpu_text = f"🖥️ CPU cores: {os.cpu_count()}"
 
 
821
 
822
+ status_msg = f"{disk_text}\n{dataset_text}\n{uptime_text}\n{mem_text}\n{cpu_text}"
823
+ await message.reply_text(status_msg)
824
 
825
  @app.on_message(filters.command("files"))
826
  async def cmd_files(_, message: Message):
827
  user_id = message.from_user.id
828
  user_files = db_get_user_files(user_id)
 
829
  if not user_files:
830
  enc_folder = get_encrypted_folder(user_id)
831
  try:
 
846
  db_add_file(user_id, path, orig_name, size)
847
  user_files = db_get_user_files(user_id)
848
  except Exception as e:
849
+ logger.warning("Fallback scan failed: %s", e)
 
850
  if not user_files:
851
  return await message.reply_text("You have no uploaded files.")
 
852
  buttons = []
853
+ for enc_path, orig_name, size in user_files:
854
  token = make_token()
855
  file_callback_map[token] = enc_path
856
+ display = f"{orig_name[:20]} ({format_size(size)})" if size else orig_name[:20]
857
  buttons.append([
858
+ InlineKeyboardButton(f"📥 {display}", callback_data=f"get:{token}"),
859
  InlineKeyboardButton("🗑 Delete", callback_data=f"delete:{token}"),
860
  ])
 
861
  buttons.append([InlineKeyboardButton("⚠️ Delete All Files", callback_data=f"delete_all:{user_id}")])
 
862
  await message.reply_text(
863
  "Your uploaded files (click to download, delete to remove):",
864
  reply_markup=InlineKeyboardMarkup(buttons),
865
+ disable_web_page_preview=True,
866
  )
867
 
 
868
  @app.on_message(filters.command("delete_all"))
869
  async def cmd_delete_all(_, message: Message):
870
  user_id = message.from_user.id
 
872
  [InlineKeyboardButton("Yes, delete all my files", callback_data=f"delete_all:{user_id}")],
873
  [InlineKeyboardButton("Cancel", callback_data="cancel_upload:0")],
874
  ])
875
+ await message.reply_text("⚠️ Permanently delete **all** your files? This cannot be undone.", reply_markup=kb)
876
+
877
+ @app.on_message(filters.command("cancelall"))
878
+ async def cmd_cancel_all(_, message: Message):
879
+ cancelled = 0
880
+ for job_id, job in list(active_jobs.items()):
881
+ if "cancel_event" in job:
882
+ job["cancel_event"].set()
883
+ cancelled += 1
884
+ try:
885
+ if "status_msg" in job:
886
+ await job["status_msg"].delete()
887
+ except Exception:
888
+ pass
889
+ while not upload_queue.empty():
890
+ upload_queue.get_nowait()
891
+ upload_queue.task_done()
892
+ await message.reply_text(f"❌ Cancelled {cancelled} active job(s) and cleared the queue.")
893
 
894
  @app.on_message(filters.command("upload"))
895
  async def cmd_upload(_, message: Message):
896
  args = message.text.split(maxsplit=1)
897
  if len(args) < 2:
898
  return await message.reply_text("Usage: /upload <link>")
 
899
  link = args[1].strip()
900
  match = re.search(r'(https?://t\.me/((?:c/)?[\w\d_]+)/(\d+))', link)
901
  if not match:
902
  return await message.reply_text("Invalid Telegram message link.")
 
903
  chat_str, msg_id = match.group(2), int(match.group(3))
904
  try:
905
  if chat_str.startswith("c/"):
 
909
  msg = await app.get_messages(chat_id, msg_id)
910
  except Exception as e:
911
  return await message.reply_text(f"❌ Could not fetch the message: {e}")
 
912
  if not msg.media:
913
+ return await message.reply_text("The linked message doesn't contain a downloadable file.")
914
+ file_id, expected_size, filename = _extract_file_info(msg)
915
+ if not filename:
916
+ return await message.reply_text("Could not determine file name.")
917
+
918
+ limit_err = check_file_size_limit(filename, expected_size)
919
+ if limit_err:
920
+ return await message.reply_text(limit_err)
921
+
922
+ existing_size = check_duplicate(message.from_user.id, filename)
923
+ if existing_size is not None:
924
+ await handle_duplicate(
925
+ user_id=message.from_user.id,
926
+ chat_id=message.chat.id,
927
+ file_obj=file_id,
928
+ orig_filename=filename,
929
+ expected_size=expected_size,
930
+ existing_size=existing_size,
931
+ file_id=file_id,
932
+ )
933
+ return
934
+
935
+ job_id = int(time.time() * 1000 + hash(filename))
936
+ cancel_event = asyncio.Event()
937
+ active_jobs[job_id] = {"status": "queued", "cancel_event": cancel_event}
938
+ await upload_queue.put((message.from_user.id, message.chat.id, file_id, filename, job_id, file_id, expected_size, cancel_event))
939
+ await message.reply_text(f"📦 `{filename}` queued for upload.")
940
 
941
+
942
+ def _extract_file_info(msg: Message) -> Tuple[Optional[str], Optional[int], Optional[str]]:
943
+ expected_size = extract_file_size(msg)
944
  if msg.document:
945
+ return msg.document.file_id, expected_size, msg.document.file_name
 
946
  elif msg.video:
947
+ return msg.video.file_id, expected_size, msg.video.file_name or f"{msg.video.file_unique_id}.mp4"
 
948
  elif msg.audio:
949
+ return msg.audio.file_id, expected_size, msg.audio.file_name or f"{msg.audio.file_unique_id}.mp3"
 
950
  elif msg.photo:
951
+ return msg.photo.file_id, expected_size, f"{msg.photo.file_unique_id}.jpg"
 
952
  elif msg.animation:
953
+ return msg.animation.file_id, expected_size, msg.animation.file_name or f"{msg.animation.file_unique_id}.mp4"
 
954
  elif msg.sticker:
955
  if msg.sticker.is_animated:
956
+ fn = f"{msg.sticker.file_unique_id}.tgs"
957
  elif msg.sticker.is_video:
958
+ fn = f"{msg.sticker.file_unique_id}.webm"
959
  else:
960
+ fn = f"{msg.sticker.file_unique_id}.webp"
961
+ return msg.sticker.file_id, expected_size, fn
962
  elif msg.video_note:
963
+ return msg.video_note.file_id, expected_size, f"{msg.video_note.file_unique_id}.mp4"
 
964
  elif msg.voice:
965
+ return msg.voice.file_id, expected_size, f"{msg.voice.file_unique_id}.ogg"
966
+ return None, None, None
 
 
 
 
 
 
 
967
 
968
 
969
  # =============================================================================
970
+ # CALLBACK HANDLERS (unchanged)
971
  # =============================================================================
972
 
973
+ @app.on_callback_query(filters.regex("^dup_skip:"))
974
+ async def cb_dup_skip(_, query: CallbackQuery):
975
+ token = query.data.split(":")[-1]
976
+ duplicate_prompt_map.pop(token, None)
977
+ await query.message.edit_text("❌ Upload skipped.")
978
+ await query.answer("Skipped.")
979
+
980
+ @app.on_callback_query(filters.regex("^dup_keep:"))
981
+ async def cb_dup_keep(_, query: CallbackQuery):
982
+ token = query.data.split(":")[-1]
983
+ data = duplicate_prompt_map.pop(token, None)
984
+ if not data:
985
+ return await query.answer("Prompt expired.", show_alert=True)
986
+
987
+ file_obj = data.get('file_obj')
988
+ if file_obj is None:
989
+ try:
990
+ file_obj = await app.get_messages(data['chat_id'], data['msg_id'])
991
+ except Exception:
992
+ return await query.answer("Original message no longer available.", show_alert=True)
993
+
994
+ base, ext = os.path.splitext(data['orig_filename'])
995
+ new_filename = generate_unique_filename(data['user_id'], base, ext)
996
+
997
+ job_id = data['job_id']
998
+ cancel_event = asyncio.Event()
999
+ active_jobs[job_id] = {"status": "queued", "cancel_event": cancel_event}
1000
+ await upload_queue.put((
1001
+ data['user_id'],
1002
+ data['chat_id'],
1003
+ file_obj,
1004
+ new_filename,
1005
+ job_id,
1006
+ data['file_id'],
1007
+ data['expected_size'],
1008
+ cancel_event,
1009
+ ))
1010
+ await query.message.edit_text(f"📦 `{new_filename}` queued (renamed duplicate).")
1011
+ await query.answer("Duplicate will be uploaded with a new name.")
1012
+
1013
  @app.on_callback_query(filters.regex("^get:"))
1014
  async def cb_get_file(_, query: CallbackQuery):
1015
  token = query.data.replace("get:", "")
1016
  enc_path = file_callback_map.get(token)
1017
  if not enc_path:
1018
  return await query.answer("Invalid or expired link. Use /files again.", show_alert=True)
 
1019
  job_id = int(time.time() * 1000)
1020
  cancel_event = asyncio.Event()
1021
  active_jobs[job_id] = {"status": "running", "cancel_event": cancel_event, "type": "download"}
 
1022
  try:
1023
  orig_name = decrypt_text(enc_path.split("/")[-1])
1024
  except Exception:
1025
  orig_name = enc_path.split("/")[-1]
 
1026
  status_msg = await query.message.edit_text(
1027
  "⏳ Preparing download...",
1028
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]]),
 
 
1029
  )
1030
  active_jobs[job_id]["status_msg"] = status_msg
 
1031
  asyncio.create_task(do_download_job(query.message.chat.id, enc_path, orig_name, job_id, cancel_event, status_msg))
1032
 
 
1033
  @app.on_callback_query(filters.regex("^delete:"))
1034
  async def cb_delete_file(_, query: CallbackQuery):
1035
  token = query.data.replace("delete:", "")
 
1039
  user_id = query.from_user.id
1040
  await delete_user_file(query.message.chat.id, user_id, enc_path, token, query)
1041
 
 
1042
  @app.on_callback_query(filters.regex("^delete_all:"))
1043
  async def cb_delete_all(_, query: CallbackQuery):
1044
  user_id = int(query.data.split(":")[-1])
1045
  await delete_all_user_files(query.message.chat.id, user_id, query)
1046
 
 
1047
  @app.on_callback_query(filters.regex("^cancel_job:"))
1048
  async def cb_cancel_job(_, query: CallbackQuery):
1049
  _, job_id_str = query.data.split(":")
1050
  job_id = int(job_id_str)
1051
+ job = active_jobs.get(job_id)
1052
+ if job and "cancel_event" in job:
1053
+ job["cancel_event"].set()
1054
+ await query.answer("Job cancelled. Cleaning up...")
1055
+ try:
1056
+ if "status_msg" in job:
1057
+ await job["status_msg"].delete()
1058
+ except Exception:
1059
+ pass
1060
  else:
1061
+ await query.answer("Job not found or already finished.")
 
1062
 
1063
  @app.on_callback_query(filters.regex("^upload:"))
1064
  async def cb_upload(_, query: CallbackQuery):
 
1066
  entries = upload_prompt_map.pop(token, None)
1067
  if not entries:
1068
  return await query.answer("Prompt expired.", show_alert=True)
 
1069
  for entry in entries:
1070
  user_id = entry[0]
1071
+ if len(entry) == 4 and isinstance(entry[1], int) and entry[1] > 0:
1072
+ msg_id, filename, file_id = entry[1], entry[2], entry[3]
 
 
 
1073
  try:
1074
  file_msg = await app.get_messages(query.message.chat.id, msg_id)
1075
  except Exception:
 
1076
  continue
1077
  if not file_msg:
 
1078
  continue
1079
  file_obj = file_msg
1080
+ expected_size = extract_file_size(file_msg)
1081
+ limit_err = check_file_size_limit(filename, expected_size)
1082
+ if limit_err:
1083
+ await query.message.reply_text(limit_err)
1084
+ continue
1085
+
1086
+ existing_size = check_duplicate(user_id, filename)
1087
+ if existing_size is not None:
1088
+ await handle_duplicate(
1089
+ user_id, query.message.chat.id, file_obj,
1090
+ orig_filename=filename,
1091
+ expected_size=expected_size,
1092
+ existing_size=existing_size,
1093
+ file_id=file_id,
1094
+ msg_id=msg_id,
1095
+ )
1096
+ continue
1097
  else:
1098
+ file_id, filename = entry[1], entry[2]
1099
+ expected_size = entry[3] if len(entry) == 4 else None
1100
+ limit_err = check_file_size_limit(filename, expected_size)
1101
+ if limit_err:
1102
+ await query.message.reply_text(limit_err)
1103
+ continue
1104
  file_obj = file_id
1105
 
1106
  job_id = int(time.time() * 1000 + hash(filename))
1107
+ cancel_event = asyncio.Event()
1108
+ active_jobs[job_id] = {"status": "queued", "cancel_event": cancel_event}
1109
+ await upload_queue.put((user_id, query.message.chat.id, file_obj, filename, job_id, file_id, expected_size, cancel_event))
1110
  await query.message.edit_text(f"📦 {len(entries)} file(s) added to queue.", reply_markup=None)
1111
 
 
1112
  @app.on_callback_query(filters.regex("^upload_all:"))
1113
  async def cb_upload_all(_, query: CallbackQuery):
1114
  token = query.data.replace("upload_all:", "")
1115
  entries = upload_prompt_map.pop(token, None)
1116
  if not entries:
1117
  return await query.answer("Prompt expired.", show_alert=True)
 
1118
  for entry in entries:
1119
  user_id = entry[0]
1120
+ if len(entry) == 4 and isinstance(entry[1], int) and entry[1] > 0:
1121
+ msg_id, filename, file_id = entry[1], entry[2], entry[3]
 
 
1122
  try:
1123
  file_msg = await app.get_messages(query.message.chat.id, msg_id)
1124
  except Exception:
 
1126
  if not file_msg:
1127
  continue
1128
  file_obj = file_msg
1129
+ expected_size = extract_file_size(file_msg)
1130
+ limit_err = check_file_size_limit(filename, expected_size)
1131
+ if limit_err:
1132
+ await query.message.reply_text(limit_err)
1133
+ continue
1134
+
1135
+ existing_size = check_duplicate(user_id, filename)
1136
+ if existing_size is not None:
1137
+ await handle_duplicate(
1138
+ user_id, query.message.chat.id, file_obj,
1139
+ orig_filename=filename,
1140
+ expected_size=expected_size,
1141
+ existing_size=existing_size,
1142
+ file_id=file_id,
1143
+ msg_id=msg_id,
1144
+ )
1145
+ continue
1146
  else:
1147
+ file_id, filename = entry[1], entry[2]
1148
+ expected_size = entry[3] if len(entry) == 4 else None
1149
+ limit_err = check_file_size_limit(filename, expected_size)
1150
+ if limit_err:
1151
+ await query.message.reply_text(limit_err)
1152
+ continue
1153
  file_obj = file_id
1154
 
1155
  job_id = int(time.time() * 1000 + hash(filename))
1156
+ cancel_event = asyncio.Event()
1157
+ active_jobs[job_id] = {"status": "queued", "cancel_event": cancel_event}
1158
+ await upload_queue.put((user_id, query.message.chat.id, file_obj, filename, job_id, file_id, expected_size, cancel_event))
1159
  await query.message.edit_text(f"📦 All {len(entries)} files added to queue.", reply_markup=None)
1160
 
 
1161
  @app.on_callback_query(filters.regex("^cancel_upload:"))
1162
  async def cb_cancel_upload_prompt(_, query: CallbackQuery):
1163
  await query.message.delete()
1164
 
1165
 
1166
  # =============================================================================
1167
+ # MESSAGE LINK INTERACTIVE PROMPT (unchanged)
1168
  # =============================================================================
1169
 
1170
  @app.on_message(
1171
  filters.text
1172
+ & ~filters.command(["start", "help", "status", "files", "delete_all", "upload", "cancelall"])
1173
  & filters.regex(r'https?://t\.me/')
1174
  )
1175
  async def on_message_link(client, message: Message):
1176
  links = re.findall(r'(https?://t\.me/((?:c/)?[\w\d_]+)/(\d+))', message.text)
1177
  if not links:
1178
  return
 
1179
  chat_str, msg_id = links[0][1], int(links[0][2])
1180
  try:
1181
  if chat_str.startswith("c/"):
 
1186
  except Exception as e:
1187
  await message.reply_text(f"❌ Could not access the message: {e}")
1188
  return
 
1189
  if not msg.media:
1190
  await message.reply_text("The linked message does not contain any downloadable file.")
1191
  return
1192
 
1193
+ file_id, expected_size, filename = _extract_file_info(msg)
1194
+ if not filename:
1195
+ return await message.reply_text("Could not determine file name.")
1196
+
1197
+ limit_err = check_file_size_limit(filename, expected_size)
1198
+ if limit_err:
1199
+ return await message.reply_text(limit_err)
1200
+
1201
+ existing_size = check_duplicate(message.from_user.id, filename)
1202
+ if existing_size is not None:
1203
+ await handle_duplicate(
1204
+ user_id=message.from_user.id,
1205
+ chat_id=message.chat.id,
1206
+ file_obj=file_id,
1207
+ orig_filename=filename,
1208
+ expected_size=expected_size,
1209
+ existing_size=existing_size,
1210
+ file_id=file_id,
1211
+ )
1212
+ return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1213
 
1214
+ upload_token = make_token()
1215
+ upload_prompt_map[upload_token] = [(message.from_user.id, file_id, filename, expected_size)]
1216
  kb = InlineKeyboardMarkup([
1217
+ [InlineKeyboardButton("⬆️ Upload File", callback_data=f"upload:{upload_token}")],
1218
  [InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")],
1219
  ])
1220
+ await message.reply_text(f"Found file `{filename}`. Do you want to upload it?", reply_markup=kb)
 
 
 
1221
 
1222
 
1223
  # =============================================================================
1224
+ # DIRECT FILE RECEIVER (unchanged)
1225
  # =============================================================================
1226
 
1227
  @app.on_message(
 
1229
  | filters.animation | filters.sticker | filters.video_note | filters.voice
1230
  )
1231
  async def on_direct_file(_, message: Message):
1232
+ file_id, expected_size, filename = _extract_file_info(message)
1233
+ if not filename:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1234
  return
1235
 
 
1236
  if message.media_group_id and (message.photo or message.video or message.document):
1237
  group_id = str(message.media_group_id)
1238
  if group_id not in album_groups:
 
1242
  'messages': [],
1243
  'timer': None,
1244
  }
1245
+ album_groups[group_id]['messages'].append((message.id, filename, file_id, expected_size))
 
1246
  if album_groups[group_id]['timer']:
1247
  album_groups[group_id]['timer'].cancel()
1248
 
 
1253
  album_groups[group_id]['timer'] = asyncio.create_task(timer_callback())
1254
  return
1255
 
1256
+ limit_err = check_file_size_limit(filename, expected_size)
1257
+ if limit_err:
1258
+ return await message.reply_text(limit_err)
1259
+
1260
+ existing_size = check_duplicate(message.from_user.id, filename)
1261
+ if existing_size is not None:
1262
+ await handle_duplicate(
1263
+ user_id=message.from_user.id,
1264
+ chat_id=message.chat.id,
1265
+ file_obj=message,
1266
+ orig_filename=filename,
1267
+ expected_size=expected_size,
1268
+ existing_size=existing_size,
1269
+ file_id=file_id,
1270
+ msg_id=message.id,
1271
+ )
1272
+ return
1273
 
1274
+ upload_token = make_token()
1275
+ upload_prompt_map[upload_token] = [(message.from_user.id, message.id, filename, file_id)]
1276
  kb = InlineKeyboardMarkup([
1277
+ [InlineKeyboardButton("⬆️ Upload File", callback_data=f"upload:{upload_token}")],
1278
  [InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")],
1279
  ])
1280
  await message.reply_text(f"Do you want to upload `{filename}`?", reply_markup=kb)
1281
 
1282
 
1283
  # =============================================================================
1284
+ # KEEP-ALIVE PING
1285
  # =============================================================================
1286
 
1287
  def keepalive():
 
1294
 
1295
 
1296
  # =============================================================================
1297
+ # MAIN
1298
  # =============================================================================
1299
 
1300
  async def main():
1301
+ if UVLOOP_AVAILABLE:
1302
+ uvloop.install()
1303
+ logger.info("✅ uvloop installed – faster asyncio event loop.")
1304
+ else:
1305
+ logger.info("ℹ️ uvloop not installed – install it with: pip install uvloop")
1306
+
1307
  asyncio.create_task(upload_worker())
1308
  await app.start()
1309
+ logger.info("Bot started successfully (tgcrypto: %s)", "available" if hasattr(app, "tgcrypto") else "missing")
1310
  await asyncio.Event().wait()
1311
 
1312