Cxops commited on
Commit
e2202d4
·
verified ·
1 Parent(s): cc7bfe2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +567 -312
app.py CHANGED
@@ -1,129 +1,205 @@
1
  import os
2
- import time
3
  import re
 
 
4
  import base64
5
  import hashlib
6
  import shutil
7
- import threading
8
- import asyncio
9
  import sqlite3
10
  import tempfile
11
- import secrets
12
- from typing import Optional
 
 
13
 
14
  import requests
15
  from flask import Flask
16
  from pyrogram import Client, filters
17
- from pyrogram.types import InlineKeyboardMarkup, InlineKeyboardButton
18
- from huggingface_hub import HfApi, get_hf_file_metadata, hf_hub_download, hf_hub_url
 
 
 
 
 
 
 
 
 
 
19
  from cryptography.hazmat.primitives.ciphers.aead import AESSIV
20
 
21
- # --------------- CONFIG ---------------
22
- API_ID = int(os.environ["API_ID"])
23
- API_HASH = os.environ["API_HASH"]
24
- BOT_TOKEN = os.environ["BOT_TOKEN"]
25
- HF_TOKEN = os.environ["HF_TOKEN"]
26
- OBFUSCATION_KEY = os.environ.get("OBFUSCATION_KEY", "change-me-to-a-secret-string")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- DATASET_REPO = "Cxops/Bzxv"
29
- MAX_FILE_SIZE_TG = 1900 * 1024 * 1024 # 1900 MB per Telegram part
30
- MIN_FREE_DISK = 45 * 1024 * 1024 * 1024 # 45 GB
31
- DATASET_MAX_SIZE = 8.7 * 1024 * 1024 * 1024 * 1024 # 8.7 TB
32
- KEEPALIVE_INTERVAL = 300 # 5 minutes
33
- DB_PATH = "bot_files.db"
34
 
35
- # --------------- DETERMINISTIC ENCRYPTION (AES‑SIV) ---------------
36
- siv_key = hashlib.sha512(OBFUSCATION_KEY.encode()).digest()
37
- aes_siv = AESSIV(siv_key)
38
 
39
  def encrypt_text(plain: str) -> str:
40
- token = aes_siv.encrypt(plain.encode(), None)
 
41
  return base64.urlsafe_b64encode(token).decode().rstrip("=")
42
 
 
43
  def decrypt_text(encoded: str) -> str:
 
44
  padding = 4 - len(encoded) % 4
45
  if padding != 4:
46
  encoded += "=" * padding
47
  token = base64.urlsafe_b64decode(encoded.encode())
48
- return aes_siv.decrypt(token, None).decode()
 
49
 
50
  def get_encrypted_folder(user_id: int) -> str:
51
  return encrypt_text(str(user_id))
52
 
 
53
  def get_encrypted_filename(original: str) -> str:
54
  return encrypt_text(original)
55
 
56
- # --------------- SQLITE DATABASE ---------------
 
 
 
 
57
  conn = sqlite3.connect(DB_PATH, check_same_thread=False)
 
 
58
  cursor = conn.cursor()
59
- cursor.execute("""
 
60
  CREATE TABLE IF NOT EXISTS files (
61
- user_id INTEGER,
62
- enc_path TEXT UNIQUE,
63
  orig_name TEXT,
64
- size INTEGER,
65
  timestamp REAL
66
  )
67
- """)
 
68
  conn.commit()
69
 
70
- def db_add_file(user_id, enc_path, orig_name, size):
 
71
  cursor.execute(
72
  "INSERT OR REPLACE INTO files (user_id, enc_path, orig_name, size, timestamp) VALUES (?, ?, ?, ?, ?)",
73
- (user_id, enc_path, orig_name, size, time.time())
74
  )
75
  conn.commit()
76
 
77
- def db_get_user_files(user_id):
78
- cursor.execute("SELECT enc_path, orig_name, size FROM files WHERE user_id = ? ORDER BY timestamp DESC", (user_id,))
79
- return cursor.fetchall()
80
 
81
- def db_remove_file(enc_path):
 
 
 
 
 
 
 
 
82
  cursor.execute("DELETE FROM files WHERE enc_path = ?", (enc_path,))
83
  conn.commit()
84
 
85
- def db_remove_all_user_files(user_id):
 
86
  cursor.execute("DELETE FROM files WHERE user_id = ?", (user_id,))
87
  conn.commit()
88
 
89
- # --------------- CALLBACK MAPPINGS ---------------
90
- file_callback_map = {} # token -> enc_path
91
- upload_prompt_map = {} # token -> list of (user_id, message_id, orig_filename)
92
 
93
- # Album group storage: media_group_id -> dict
94
- album_groups = {}
 
95
 
96
- def make_token():
 
 
 
 
 
 
 
 
 
 
97
  return secrets.token_hex(8)
98
 
99
- # --------------- FLASK HEALTH SERVER ---------------
 
 
 
 
100
  web = Flask(__name__)
101
 
 
102
  @web.route("/")
103
  def home():
104
- return "Telegram HF Storage Bot Running"
 
105
 
106
  def run_web():
107
  web.run(host="0.0.0.0", port=7860)
108
 
109
- # --------------- HUGGING FACE CLIENT ---------------
 
 
 
 
110
  hf = HfApi(token=HF_TOKEN)
111
 
112
- # --------------- PYROGRAM BOT ---------------
113
  app = Client(
114
  "storage_bot",
115
  api_id=API_ID,
116
  api_hash=API_HASH,
117
- bot_token=BOT_TOKEN
118
  )
119
 
120
- # --------------- ASYNC QUEUE & STATE ---------------
121
- upload_queue = asyncio.Queue()
122
- active_jobs = {}
 
 
 
123
  bot_start_time = time.time()
124
 
125
- # --------------- HELPERS ---------------
126
- def cleanup(path: str):
 
 
 
 
127
  try:
128
  if os.path.isdir(path):
129
  shutil.rmtree(path, ignore_errors=True)
@@ -132,36 +208,39 @@ def cleanup(path: str):
132
  except Exception:
133
  pass
134
 
 
135
  def get_free_disk() -> int:
136
- st = shutil.disk_usage("/")
137
- return st.free
138
 
139
- def format_size(bytes_val):
140
  for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
141
  if bytes_val < 1024.0:
142
  return f"{bytes_val:.1f} {unit}"
143
  bytes_val /= 1024.0
144
  return f"{bytes_val:.1f} PB"
145
 
146
- def format_speed(bytes_per_sec):
 
147
  return f"{format_size(bytes_per_sec)}/s"
148
 
 
149
  def get_memory_usage() -> str:
150
- """Return current process memory usage (RSS) as a human-readable string."""
151
  try:
152
- # Linux only
153
  with open("/proc/self/status") as f:
154
  for line in f:
155
  if line.startswith("VmRSS:"):
156
- # Value is in kB
157
- kb = int(line.split()[1])
158
- return format_size(kb * 1024)
159
  except Exception:
160
- pass
161
- return "unknown"
162
 
163
- async def send_progress_updater(job_id, status_msg, cancel_event, total_bytes):
164
- """Shows Telegram send progress every 3s."""
 
 
 
 
165
  bytes_holder = [0]
166
  start_time = time.time()
167
 
@@ -171,13 +250,20 @@ async def send_progress_updater(job_id, status_msg, cancel_event, total_bytes):
171
  current = bytes_holder[0]
172
  speed = current / elapsed
173
  pct = (100 * current / total_bytes) if total_bytes else 0
174
- text = f"📤 Sending to Telegram...\n{pct:.1f}% of {format_size(total_bytes)}\n{format_size(current)} sent\nSpeed: {format_speed(speed)}\nETA: {int((total_bytes - current) / speed) if speed > 0 else '...'}s"
 
 
 
 
 
 
 
175
  try:
176
  await status_msg.edit_text(
177
  text,
178
- reply_markup=InlineKeyboardMarkup([
179
- [InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]
180
- ])
181
  )
182
  except Exception:
183
  break
@@ -186,16 +272,31 @@ async def send_progress_updater(job_id, status_msg, cancel_event, total_bytes):
186
  task = asyncio.create_task(updater())
187
  return task, bytes_holder
188
 
189
- # --------------- UPLOAD JOB ---------------
190
- async def do_upload_job(user_id, chat_id, file_msg, orig_filename, job_id, cancel_event, status_msg):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  temp_dir = tempfile.mkdtemp()
192
  local_path = None
193
  try:
194
- # 1. Download from Telegram (show speed/progress)
195
- await status_msg.edit_text("⬇️ Downloading from Telegram...",
196
- reply_markup=InlineKeyboardMarkup([
197
- [InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]
198
- ]))
 
199
  last_update = [0]
200
 
201
  async def download_progress(current, total):
@@ -206,36 +307,52 @@ async def do_upload_job(user_id, chat_id, file_msg, orig_filename, job_id, cance
206
  elapsed = max(time.time() - download_start, 0.01)
207
  speed = current / elapsed
208
  pct = (100 * current / total) if total else 0
209
- text = f"⬇️ Downloading from Telegram...\n{pct:.1f}% of {format_size(total)}\n{format_size(current)} downloaded\nSpeed: {format_speed(speed)}"
 
 
 
 
 
210
  try:
211
  await status_msg.edit_text(
212
  text,
213
- reply_markup=InlineKeyboardMarkup([
214
- [InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]
215
- ])
216
  )
217
  except Exception:
218
  pass
219
 
220
  download_start = time.time()
221
- local_path = await file_msg.download(
222
- file_name=os.path.join(temp_dir, orig_filename),
223
- progress=download_progress
224
- )
 
 
 
 
 
 
 
 
 
 
225
  if cancel_event.is_set():
226
  return
227
 
228
  file_size = os.path.getsize(local_path)
229
 
230
- # 2. Upload to Hugging Face (no speed/progress)
231
  enc_folder = get_encrypted_folder(user_id)
232
  enc_filename = get_encrypted_filename(orig_filename)
233
  repo_path = f"{enc_folder}/{enc_filename}"
234
 
235
- await status_msg.edit_text("⬆️ Uploading to Hugging Face...",
236
- reply_markup=InlineKeyboardMarkup([
237
- [InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]
238
- ]))
 
 
239
 
240
  loop = asyncio.get_event_loop()
241
  upload_done = loop.create_future()
@@ -245,7 +362,7 @@ async def do_upload_job(user_id, chat_id, file_msg, orig_filename, job_id, cance
245
  path_or_fileobj=local_path,
246
  path_in_repo=repo_path,
247
  repo_id=DATASET_REPO,
248
- repo_type="dataset",
249
  )
250
 
251
  task = loop.run_in_executor(None, sync_upload)
@@ -260,7 +377,7 @@ async def do_upload_job(user_id, chat_id, file_msg, orig_filename, job_id, cance
260
  hf.delete_file(
261
  path_in_repo=repo_path,
262
  repo_id=DATASET_REPO,
263
- repo_type="dataset",
264
  )
265
  except Exception:
266
  pass
@@ -270,33 +387,43 @@ async def do_upload_job(user_id, chat_id, file_msg, orig_filename, job_id, cance
270
  await task
271
  db_add_file(user_id, repo_path, orig_filename, file_size)
272
  await status_msg.edit_text(f"✅ Uploaded: `{orig_filename}`", reply_markup=None)
 
273
 
274
  except Exception as e:
 
275
  await status_msg.edit_text(f"❌ Upload failed: {e}", reply_markup=None)
276
  finally:
277
  cleanup(temp_dir)
278
 
279
- # --------------- DOWNLOAD JOB ---------------
280
- async def do_download_job(chat_id, enc_path, orig_name, job_id, cancel_event, status_msg):
 
 
 
 
 
 
 
281
  temp_dir = tempfile.mkdtemp()
282
  try:
283
  loop = asyncio.get_event_loop()
284
 
285
- # 1. Download from HF using robust hf_hub_download (resilient to connection issues)
286
- await status_msg.edit_text("⬇️ Downloading from dataset...",
287
- reply_markup=InlineKeyboardMarkup([
288
- [InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]
289
- ]))
 
290
 
291
  download_future = loop.run_in_executor(
292
  None,
293
  lambda: hf_hub_download(
294
  repo_id=DATASET_REPO,
295
  filename=enc_path,
296
- repo_type="dataset",
297
  token=HF_TOKEN,
298
  local_dir=temp_dir,
299
- )
300
  )
301
 
302
  while not download_future.done():
@@ -308,7 +435,6 @@ async def do_download_job(chat_id, enc_path, orig_name, job_id, cancel_event, st
308
  local_file = download_future.result()
309
  total_size = os.path.getsize(local_file)
310
 
311
- # 2. Send back to Telegram (with speed/progress if single file)
312
  if total_size <= MAX_FILE_SIZE_TG:
313
  prog_task, bytes_holder = await send_progress_updater(
314
  job_id, status_msg, cancel_event, total_size
@@ -322,7 +448,7 @@ async def do_download_job(chat_id, enc_path, orig_name, job_id, cancel_event, st
322
  document=local_file,
323
  file_name=orig_name,
324
  caption=orig_name,
325
- progress=send_progress
326
  )
327
  prog_task.cancel()
328
  await status_msg.delete()
@@ -334,32 +460,44 @@ async def do_download_job(chat_id, enc_path, orig_name, job_id, cancel_event, st
334
  if cancel_event.is_set():
335
  await status_msg.edit_text("❌ Cancelled during send.")
336
  return
337
- await status_msg.edit_text(f"📤 Sending part {i}/{num_parts}...",
338
- reply_markup=InlineKeyboardMarkup([
339
- [InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]
340
- ]))
 
 
341
  chunk = f.read(part_size)
342
  part_name = f"{orig_name}.part{i}of{num_parts}"
343
  part_path = os.path.join(temp_dir, part_name)
344
  with open(part_path, "wb") as p:
345
  p.write(chunk)
346
- await app.send_document(chat_id, document=part_path, file_name=part_name,
347
- caption=f"{orig_name} – Part {i}/{num_parts}")
 
 
 
 
348
  os.remove(part_path)
349
  await status_msg.delete()
 
350
 
351
  except Exception as e:
 
352
  await status_msg.edit_text(f"❌ Download failed: {e}")
353
  finally:
354
  cleanup(temp_dir)
355
 
356
- # --------------- DELETE FUNCTIONS ---------------
357
- async def delete_user_file(chat_id, user_id, enc_path, token, query):
 
 
 
 
358
  try:
359
  hf.delete_file(
360
  path_in_repo=enc_path,
361
  repo_id=DATASET_REPO,
362
- repo_type="dataset",
363
  )
364
  db_remove_file(enc_path)
365
  file_callback_map.pop(token, None)
@@ -368,7 +506,8 @@ async def delete_user_file(chat_id, user_id, enc_path, token, query):
368
  except Exception as e:
369
  await query.answer(f"Error: {e}", show_alert=True)
370
 
371
- async def delete_all_user_files(chat_id, user_id, query):
 
372
  user_files = db_get_user_files(user_id)
373
  if not user_files:
374
  await query.answer("No files to delete.", show_alert=True)
@@ -379,42 +518,50 @@ async def delete_all_user_files(chat_id, user_id, query):
379
  hf.delete_file(
380
  path_in_repo=enc_path,
381
  repo_id=DATASET_REPO,
382
- repo_type="dataset",
383
  )
384
  except Exception:
385
  pass
386
 
387
  db_remove_all_user_files(user_id)
388
 
389
- tokens_to_delete = [tok for tok, path in file_callback_map.items() if path.startswith(get_encrypted_folder(user_id))]
 
 
 
390
  for tok in tokens_to_delete:
391
  file_callback_map.pop(tok, None)
392
 
393
  await query.answer(f"Deleted {len(user_files)} file(s).", show_alert=True)
394
  await refresh_file_list(chat_id, user_id, query.message)
395
 
396
- async def refresh_file_list(chat_id, user_id, message):
 
397
  user_files = db_get_user_files(user_id)
398
  if not user_files:
399
  return await message.edit_text("You have no uploaded files.", reply_markup=None)
400
 
401
  buttons = []
402
- for enc_path, orig_name, size in user_files:
403
  token = make_token()
404
  file_callback_map[token] = enc_path
405
  buttons.append([
406
  InlineKeyboardButton(f"📥 {orig_name[:40]}", callback_data=f"get:{token}"),
407
- InlineKeyboardButton("🗑 Delete", callback_data=f"delete:{token}")
408
  ])
409
 
410
  buttons.append([InlineKeyboardButton("⚠️ Delete All Files", callback_data=f"delete_all:{user_id}")])
411
 
412
  await message.edit_text(
413
  "Your uploaded files (click to download, delete to remove):",
414
- reply_markup=InlineKeyboardMarkup(buttons)
415
  )
416
 
417
- # --------------- ALBUM GROUP PROCESSING ---------------
 
 
 
 
418
  async def process_album_group(media_group_id: str):
419
  group_data = album_groups.pop(media_group_id, None)
420
  if not group_data:
@@ -422,97 +569,144 @@ async def process_album_group(media_group_id: str):
422
 
423
  chat_id = group_data['chat_id']
424
  user_id = group_data['user_id']
425
- messages = group_data['messages'] # list of (message_id, filename)
426
 
427
  if len(messages) == 1:
428
- msg_id, filename = messages[0]
429
  token = make_token()
430
- upload_prompt_map[token] = [(user_id, msg_id, filename)]
431
  kb = InlineKeyboardMarkup([
432
  [InlineKeyboardButton("⬆️ Upload File", callback_data=f"upload:{token}")],
433
- [InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")]
434
  ])
435
  await app.send_message(chat_id, f"Do you want to upload `{filename}`?", reply_markup=kb)
436
  else:
437
- files_list = "\n".join(f"• {fname}" for _, fname in messages)
438
  token = make_token()
439
- upload_prompt_map[token] = [(user_id, msg_id, fname) for msg_id, fname in messages]
440
 
441
- button_rows = []
442
- button_rows.append([InlineKeyboardButton("⬆️ Upload All", callback_data=f"upload_all:{token}")])
443
- for msg_id, fname in messages:
 
444
  individual_token = make_token()
445
- upload_prompt_map[individual_token] = [(user_id, msg_id, fname)]
446
- button_rows.append([InlineKeyboardButton(f"📤 {fname[:40]}", callback_data=f"upload:{individual_token}")])
 
 
447
  button_rows.append([InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")])
448
 
449
  await app.send_message(
450
  chat_id,
451
  f"Received {len(messages)} files.\n{files_list}\n\nChoose:",
452
- reply_markup=InlineKeyboardMarkup(button_rows)
453
  )
454
 
455
- # --------------- QUEUE WORKER ---------------
 
 
 
 
456
  async def upload_worker():
457
  while True:
458
  job = await upload_queue.get()
459
- user_id, chat_id, file_msg, orig_filename, job_id = job
460
  cancel_event = asyncio.Event()
461
  active_jobs[job_id] = {"status": "running", "cancel_event": cancel_event}
462
 
463
  while get_free_disk() < MIN_FREE_DISK:
 
464
  await asyncio.sleep(10)
465
 
466
  status_msg = await app.send_message(
467
- chat_id, "⏳ Starting upload...",
468
- reply_markup=InlineKeyboardMarkup([
469
- [InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]
470
- ])
 
471
  )
472
  active_jobs[job_id]["status_msg"] = status_msg
473
 
474
  try:
475
- await do_upload_job(user_id, chat_id, file_msg, orig_filename, job_id, cancel_event, status_msg)
 
 
476
  finally:
477
  active_jobs.pop(job_id, None)
478
  upload_queue.task_done()
479
 
480
- # --------------- BOT HANDLERS ---------------
 
 
 
 
481
  @app.on_message(filters.command("start"))
482
- async def start_cmd(_, message):
483
  await message.reply_text(
484
- "Send me any file or a Telegram message link to upload.\n"
485
- "/files – list your uploaded files\n"
486
- "/status – show disk, dataset & system status"
487
  )
488
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  @app.on_message(filters.command("status"))
490
- async def status_cmd(_, message):
491
  free = get_free_disk()
492
  disk_text = f"💾 Local disk: {format_size(free)} free (min {format_size(MIN_FREE_DISK)})"
493
 
494
  try:
495
  info = hf.dataset_info(repo_id=DATASET_REPO)
496
- used = info.used_storage if info.used_storage else 0
497
  except Exception:
498
  used = 0
499
  dataset_text = f"📦 Dataset: {format_size(used)} used of {format_size(DATASET_MAX_SIZE)}"
500
 
501
- uptime_seconds = int(time.time() - bot_start_time)
502
- uptime_text = f"⏱️ Uptime: {uptime_seconds//3600}h {(uptime_seconds%3600)//60}m {uptime_seconds%60}s"
503
  mem_text = f"🧠 Memory: {get_memory_usage()}"
 
 
 
 
 
 
 
 
 
 
 
 
504
 
505
- await message.reply_text(f"{disk_text}\n{dataset_text}\n{uptime_text}\n{mem_text}")
506
 
507
  @app.on_message(filters.command("files"))
508
- async def list_files_cmd(_, message):
509
  user_id = message.from_user.id
510
  user_files = db_get_user_files(user_id)
511
 
512
  if not user_files:
513
  enc_folder = get_encrypted_folder(user_id)
514
  try:
515
- all_hf = hf.list_repo_files(repo_id=DATASET_REPO, repo_type="dataset")
516
  hf_user_files = [f for f in all_hf if f.startswith(enc_folder + "/")]
517
  for path in hf_user_files:
518
  _, enc_name = path.split("/", 1)
@@ -521,41 +715,123 @@ async def list_files_cmd(_, message):
521
  except Exception:
522
  orig_name = enc_name
523
  try:
524
- file_url = hf_hub_url(repo_id=DATASET_REPO, filename=path, repo_type="dataset")
525
  meta = get_hf_file_metadata(url=file_url, token=HF_TOKEN)
526
  size = meta.size
527
  except Exception:
528
  size = 0
529
  db_add_file(user_id, path, orig_name, size)
530
  user_files = db_get_user_files(user_id)
531
- except Exception:
532
- pass
533
 
534
  if not user_files:
535
  return await message.reply_text("You have no uploaded files.")
536
 
537
  buttons = []
538
- for enc_path, orig_name, size in user_files:
539
  token = make_token()
540
  file_callback_map[token] = enc_path
541
  buttons.append([
542
  InlineKeyboardButton(f"📥 {orig_name[:40]}", callback_data=f"get:{token}"),
543
- InlineKeyboardButton("🗑 Delete", callback_data=f"delete:{token}")
544
  ])
545
 
546
  buttons.append([InlineKeyboardButton("⚠️ Delete All Files", callback_data=f"delete_all:{user_id}")])
547
 
548
  await message.reply_text(
549
  "Your uploaded files (click to download, delete to remove):",
550
- reply_markup=InlineKeyboardMarkup(buttons)
551
  )
552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  @app.on_callback_query(filters.regex("^get:"))
554
- async def get_file_callback(_, query):
555
  token = query.data.replace("get:", "")
556
  enc_path = file_callback_map.get(token)
557
  if not enc_path:
558
- return await query.answer("Invalid or expired file link. Use /files again.", show_alert=True)
559
 
560
  job_id = int(time.time() * 1000)
561
  cancel_event = asyncio.Event()
@@ -568,41 +844,33 @@ async def get_file_callback(_, query):
568
 
569
  status_msg = await query.message.edit_text(
570
  "⏳ Preparing download...",
571
- reply_markup=InlineKeyboardMarkup([
572
- [InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]
573
- ])
574
  )
575
  active_jobs[job_id]["status_msg"] = status_msg
576
 
577
- asyncio.create_task(do_download_job(
578
- query.message.chat.id,
579
- enc_path,
580
- orig_name,
581
- job_id,
582
- cancel_event,
583
- status_msg
584
- ))
585
 
586
  @app.on_callback_query(filters.regex("^delete:"))
587
- async def delete_file_callback(_, query):
588
  token = query.data.replace("delete:", "")
589
  enc_path = file_callback_map.pop(token, None)
590
  if not enc_path:
591
  return await query.answer("File not found or already deleted.", show_alert=True)
592
-
593
  user_id = query.from_user.id
594
- chat_id = query.message.chat.id
595
 
596
- await delete_user_file(chat_id, user_id, enc_path, token, query)
597
 
598
  @app.on_callback_query(filters.regex("^delete_all:"))
599
- async def delete_all_callback(_, query):
600
  user_id = int(query.data.split(":")[-1])
601
- chat_id = query.message.chat.id
602
- await delete_all_user_files(chat_id, user_id, query)
603
 
604
  @app.on_callback_query(filters.regex("^cancel_job:"))
605
- async def cancel_job_callback(_, query):
606
  _, job_id_str = query.data.split(":")
607
  job_id = int(job_id_str)
608
  if job_id in active_jobs:
@@ -611,81 +879,100 @@ async def cancel_job_callback(_, query):
611
  else:
612
  await query.answer("Job not found.")
613
 
 
614
  @app.on_callback_query(filters.regex("^upload:"))
615
- async def upload_callback(_, query):
616
  token = query.data.replace("upload:", "")
617
- data = upload_prompt_map.pop(token, None)
618
- if not data:
619
- return await query.answer("Upload prompt expired. Please send the file again.", show_alert=True)
620
 
621
- chat_id = query.message.chat.id
622
- for user_id, msg_id, orig_filename in data:
623
- try:
624
- file_msg = await app.get_messages(chat_id, msg_id)
625
- except Exception:
626
- await query.answer(f"Message for {orig_filename} missing.", show_alert=True)
627
- continue
628
- if not file_msg:
629
- await query.answer(f"File {orig_filename} no longer available.", show_alert=True)
630
- continue
 
 
 
 
 
 
 
 
 
 
 
631
 
632
- job_id = int(time.time() * 1000 + hash(orig_filename))
633
  active_jobs[job_id] = {"status": "queued"}
634
- await upload_queue.put((user_id, chat_id, file_msg, orig_filename, job_id))
 
 
635
 
636
- await query.message.edit_text(
637
- f"📦 {len(data)} file(s) added to queue.",
638
- reply_markup=None
639
- )
640
 
641
  @app.on_callback_query(filters.regex("^upload_all:"))
642
- async def upload_all_callback(_, query):
643
  token = query.data.replace("upload_all:", "")
644
- data = upload_prompt_map.pop(token, None)
645
- if not data:
646
  return await query.answer("Prompt expired.", show_alert=True)
647
 
648
- chat_id = query.message.chat.id
649
- for user_id, msg_id, orig_filename in data:
650
- try:
651
- file_msg = await app.get_messages(chat_id, msg_id)
652
- except Exception:
653
- continue
654
- if not file_msg:
655
- continue
656
- job_id = int(time.time() * 1000 + hash(orig_filename))
 
 
 
 
 
 
 
 
 
 
657
  active_jobs[job_id] = {"status": "queued"}
658
- await upload_queue.put((user_id, chat_id, file_msg, orig_filename, job_id))
 
 
659
 
660
- await query.message.edit_text(
661
- f"📦 All {len(data)} files added to queue.",
662
- reply_markup=None
663
- )
664
 
665
  @app.on_callback_query(filters.regex("^cancel_upload:"))
666
- async def cancel_upload_prompt(_, query):
667
  await query.message.delete()
668
 
669
- # ----------- MESSAGE LINK HANDLER -----------
670
- @app.on_message(filters.text & ~filters.command(["start", "files", "status", "delete_all"]) & filters.regex(r'https?://t\.me/'))
671
- async def on_message_link(client, message):
672
- # Extract first valid t.me link
 
 
 
 
 
 
 
673
  links = re.findall(r'(https?://t\.me/((?:c/)?[\w\d_]+)/(\d+))', message.text)
674
  if not links:
675
- return # shouldn't happen because of filter, but just in case
676
-
677
- # Process the first link found
678
- full_link, chat_str, msg_id_str = links[0]
679
- msg_id = int(msg_id_str)
680
 
 
681
  try:
682
  if chat_str.startswith("c/"):
683
- # Numeric chat id: t.me/c/123456789/1234
684
  chat_id = int("-100" + chat_str[2:])
685
  else:
686
- chat_id = chat_str # username
687
-
688
- # Fetch the message
689
  msg = await app.get_messages(chat_id, msg_id)
690
  except Exception as e:
691
  await message.reply_text(f"❌ Could not access the message: {e}")
@@ -695,17 +982,23 @@ async def on_message_link(client, message):
695
  await message.reply_text("The linked message does not contain any downloadable file.")
696
  return
697
 
698
- # Determine the file name from the fetched message
 
699
  if msg.document:
700
  filename = msg.document.file_name
 
701
  elif msg.video:
702
  filename = msg.video.file_name or f"{msg.video.file_unique_id}.mp4"
 
703
  elif msg.audio:
704
  filename = msg.audio.file_name or f"{msg.audio.file_unique_id}.mp3"
 
705
  elif msg.photo:
706
  filename = f"{msg.photo.file_unique_id}.jpg"
 
707
  elif msg.animation:
708
  filename = msg.animation.file_name or f"{msg.animation.file_unique_id}.mp4"
 
709
  elif msg.sticker:
710
  if msg.sticker.is_animated:
711
  filename = f"{msg.sticker.file_unique_id}.tgs"
@@ -713,97 +1006,54 @@ async def on_message_link(client, message):
713
  filename = f"{msg.sticker.file_unique_id}.webm"
714
  else:
715
  filename = f"{msg.sticker.file_unique_id}.webp"
 
716
  elif msg.video_note:
717
  filename = f"{msg.video_note.file_unique_id}.mp4"
 
718
  elif msg.voice:
719
  filename = f"{msg.voice.file_unique_id}.ogg"
 
720
  else:
721
  filename = f"file_{msg_id}"
722
 
723
- # We'll use the message object directly as the source for download.
724
- # Important: The upload queue expects a Telegram message object from the user's chat,
725
- # but here the message is from another chat. It still works because Pyrogram's download()
726
- # works on any message object the bot can access. So we can pass it directly.
727
-
728
  token = make_token()
729
- # Store (user_id, message_id, filename) - but message_id is from the original chat.
730
- # For the upload callback we need to retrieve the message by its original chat.
731
- # To simplify, we can store the chat_id and msg_id as well.
732
- # Let's modify the mapping to include chat_id: (user_id, chat_id_of_file, msg_id, filename)
733
- # But the current upload_prompt_map structure expects messages from the same chat as the user.
734
- # We'll extend the mapping to handle remote messages. We'll store:
735
- # token -> (user_id, file_chat_id, msg_id, filename)
736
- # We'll adjust upload_callback accordingly.
737
- upload_prompt_map[token] = [(message.from_user.id, chat_id, msg_id, filename)]
738
 
739
  kb = InlineKeyboardMarkup([
740
  [InlineKeyboardButton("⬆️ Upload File", callback_data=f"upload:{token}")],
741
- [InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")]
742
  ])
743
  await message.reply_text(
744
  f"Found file `{filename}` in the linked message. Do you want to upload it?",
745
- reply_markup=kb
746
  )
747
 
748
- # Override the upload callback to handle remote messages (those from message links)
749
- # We'll modify the upload_callback function to check if the data tuple has 4 elements instead of 3
750
- # and retrieve the message accordingly.
751
 
752
- @app.on_callback_query(filters.regex("^upload:"))
753
- async def upload_callback_v2(_, query):
754
- token = query.data.replace("upload:", "")
755
- data = upload_prompt_map.pop(token, None)
756
- if not data:
757
- return await query.answer("Upload prompt expired. Please send the file again.", show_alert=True)
758
 
759
- chat_id = query.message.chat.id
760
- for entry in data:
761
- if len(entry) == 4:
762
- # Remote message: (user_id, file_chat_id, msg_id, orig_filename)
763
- user_id, file_chat_id, msg_id, orig_filename = entry
764
- try:
765
- file_msg = await app.get_messages(file_chat_id, msg_id)
766
- except Exception:
767
- await query.answer(f"Message for {orig_filename} missing.", show_alert=True)
768
- continue
769
- else:
770
- # Local message: (user_id, msg_id, orig_filename)
771
- user_id, msg_id, orig_filename = entry
772
- try:
773
- file_msg = await app.get_messages(chat_id, msg_id)
774
- except Exception:
775
- await query.answer(f"Message for {orig_filename} missing.", show_alert=True)
776
- continue
777
-
778
- if not file_msg:
779
- await query.answer(f"File {orig_filename} no longer available.", show_alert=True)
780
- continue
781
-
782
- job_id = int(time.time() * 1000 + hash(orig_filename))
783
- active_jobs[job_id] = {"status": "queued"}
784
- await upload_queue.put((user_id, chat_id, file_msg, orig_filename, job_id))
785
-
786
- await query.message.edit_text(
787
- f"📦 {len(data)} file(s) added to queue.",
788
- reply_markup=None
789
- )
790
-
791
- # ----------- RECEIVING ALL FILE TYPES (direct messages) -----------
792
  @app.on_message(
793
- filters.document | filters.video | filters.audio | filters.photo |
794
- filters.animation | filters.sticker | filters.video_note | filters.voice
795
  )
796
- async def on_file(_, message):
797
  if message.document:
798
  filename = message.document.file_name
 
799
  elif message.video:
800
  filename = message.video.file_name or f"{message.video.file_unique_id}.mp4"
 
801
  elif message.audio:
802
  filename = message.audio.file_name or f"{message.audio.file_unique_id}.mp3"
 
803
  elif message.photo:
804
  filename = f"{message.photo.file_unique_id}.jpg"
 
805
  elif message.animation:
806
  filename = message.animation.file_name or f"{message.animation.file_unique_id}.mp4"
 
807
  elif message.sticker:
808
  if message.sticker.is_animated:
809
  filename = f"{message.sticker.file_unique_id}.tgs"
@@ -811,27 +1061,27 @@ async def on_file(_, message):
811
  filename = f"{message.sticker.file_unique_id}.webm"
812
  else:
813
  filename = f"{message.sticker.file_unique_id}.webp"
 
814
  elif message.video_note:
815
  filename = f"{message.video_note.file_unique_id}.mp4"
 
816
  elif message.voice:
817
  filename = f"{message.voice.file_unique_id}.ogg"
 
818
  else:
819
  return
820
 
 
821
  if message.media_group_id and (message.photo or message.video or message.document):
822
  group_id = str(message.media_group_id)
823
- chat_id = message.chat.id
824
- user_id = message.from_user.id
825
-
826
  if group_id not in album_groups:
827
  album_groups[group_id] = {
828
- 'chat_id': chat_id,
829
- 'user_id': user_id,
830
  'messages': [],
831
- 'timer': None
832
  }
833
-
834
- album_groups[group_id]['messages'].append((message.id, filename))
835
 
836
  if album_groups[group_id]['timer']:
837
  album_groups[group_id]['timer'].cancel()
@@ -843,36 +1093,41 @@ async def on_file(_, message):
843
  album_groups[group_id]['timer'] = asyncio.create_task(timer_callback())
844
  return
845
 
846
- # Single file
847
  token = make_token()
848
- upload_prompt_map[token] = [(message.from_user.id, message.id, filename)]
849
 
850
  kb = InlineKeyboardMarkup([
851
  [InlineKeyboardButton("⬆️ Upload File", callback_data=f"upload:{token}")],
852
- [InlineKeyboardButton("❌ Cancel", callback_data="cancel_upload:0")]
853
  ])
 
854
 
855
- await message.reply_text(
856
- f"Do you want to upload `{filename}`?",
857
- reply_markup=kb
858
- )
859
 
860
- # --------------- KEEPALIVE ---------------
 
 
 
861
  def keepalive():
862
  while True:
863
  try:
864
- requests.get("http://localhost:7860/", timeout=10)
865
- except Exception:
866
- pass
867
  time.sleep(KEEPALIVE_INTERVAL)
868
 
869
- # --------------- MAIN ---------------
 
 
 
 
870
  async def main():
871
  asyncio.create_task(upload_worker())
872
  await app.start()
873
- print("Bot started")
874
  await asyncio.Event().wait()
875
 
 
876
  if __name__ == "__main__":
877
  threading.Thread(target=run_web, daemon=True).start()
878
  threading.Thread(target=keepalive, daemon=True).start()
 
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
+ # =============================================================================
37
+
38
+ API_ID: int = int(os.environ["API_ID"])
39
+ API_HASH: str = os.environ["API_HASH"]
40
+ 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",
59
+ handlers=[logging.StreamHandler(sys.stdout)],
60
+ )
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
+ # =============================================================================
97
+
98
  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,
107
  orig_name TEXT,
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 (?, ?, ?, ?, ?)",
119
+ (user_id, enc_path, orig_name, size, time.time()),
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",
127
+ (user_id,),
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)
181
 
 
182
  app = Client(
183
  "storage_bot",
184
  api_id=API_ID,
185
  api_hash=API_HASH,
186
+ bot_token=BOT_TOKEN,
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:
203
  try:
204
  if os.path.isdir(path):
205
  shutil.rmtree(path, ignore_errors=True)
 
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:
219
  return f"{bytes_val:.1f} {unit}"
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,
240
+ status_msg: Message,
241
+ cancel_event: asyncio.Event,
242
+ total_bytes: int,
243
+ ) -> Tuple[asyncio.Task, list]:
244
  bytes_holder = [0]
245
  start_time = time.time()
246
 
 
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,
264
+ reply_markup=InlineKeyboardMarkup(
265
+ [[InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]]
266
+ ),
267
  )
268
  except Exception:
269
  break
 
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(
281
+ user_id: int,
282
+ chat_id: int,
283
+ file_msg_or_id: Union[Message, str],
284
+ orig_filename: str,
285
+ job_id: int,
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...",
296
+ reply_markup=InlineKeyboardMarkup(
297
+ [[InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]]
298
+ ),
299
+ )
300
  last_update = [0]
301
 
302
  async def download_progress(current, total):
 
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)
345
 
 
346
  enc_folder = get_encrypted_folder(user_id)
347
  enc_filename = get_encrypted_filename(orig_filename)
348
  repo_path = f"{enc_folder}/{enc_filename}"
349
 
350
+ await status_msg.edit_text(
351
+ "⬆️ Uploading to Hugging Face...",
352
+ reply_markup=InlineKeyboardMarkup(
353
+ [[InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]]
354
+ ),
355
+ )
356
 
357
  loop = asyncio.get_event_loop()
358
  upload_done = loop.create_future()
 
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)
 
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
 
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)
391
 
392
  except Exception as e:
393
+ logger.exception("Upload failed for %s", orig_filename)
394
  await status_msg.edit_text(f"❌ Upload failed: {e}", reply_markup=None)
395
  finally:
396
  cleanup(temp_dir)
397
 
398
+
399
+ async def do_download_job(
400
+ chat_id: int,
401
+ enc_path: str,
402
+ orig_name: str,
403
+ job_id: int,
404
+ cancel_event: asyncio.Event,
405
+ status_msg: Message,
406
+ ) -> None:
407
  temp_dir = tempfile.mkdtemp()
408
  try:
409
  loop = asyncio.get_event_loop()
410
 
411
+ await status_msg.edit_text(
412
+ "⬇️ Downloading from dataset...",
413
+ reply_markup=InlineKeyboardMarkup(
414
+ [[InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]]
415
+ ),
416
+ )
417
 
418
  download_future = loop.run_in_executor(
419
  None,
420
  lambda: hf_hub_download(
421
  repo_id=DATASET_REPO,
422
  filename=enc_path,
423
+ repo_type=DATASET_TYPE,
424
  token=HF_TOKEN,
425
  local_dir=temp_dir,
426
+ ),
427
  )
428
 
429
  while not download_future.done():
 
435
  local_file = download_future.result()
436
  total_size = os.path.getsize(local_file)
437
 
 
438
  if total_size <= MAX_FILE_SIZE_TG:
439
  prog_task, bytes_holder = await send_progress_updater(
440
  job_id, status_msg, cancel_event, total_size
 
448
  document=local_file,
449
  file_name=orig_name,
450
  caption=orig_name,
451
+ progress=send_progress,
452
  )
453
  prog_task.cancel()
454
  await status_msg.delete()
 
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:
485
+ logger.exception("Download failed for %s", orig_name)
486
  await status_msg.edit_text(f"❌ Download failed: {e}")
487
  finally:
488
  cleanup(temp_dir)
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)
 
506
  except Exception as e:
507
  await query.answer(f"Error: {e}", show_alert=True)
508
 
509
+
510
+ async def delete_all_user_files(chat_id, user_id, query: CallbackQuery):
511
  user_files = db_get_user_files(user_id)
512
  if not user_files:
513
  await query.answer("No files to delete.", show_alert=True)
 
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
 
535
  await query.answer(f"Deleted {len(user_files)} file(s).", show_alert=True)
536
  await refresh_file_list(chat_id, user_id, query.message)
537
 
538
+
539
+ async def refresh_file_list(chat_id: int, user_id: int, message: Message):
540
  user_files = db_get_user_files(user_id)
541
  if not user_files:
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(
556
  "Your uploaded files (click to download, delete to remove):",
557
+ reply_markup=InlineKeyboardMarkup(buttons),
558
  )
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:
 
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
+ # =============================================================================
607
+ # UPLOAD QUEUE WORKER
608
+ # =============================================================================
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:
618
+ logger.warning("Low disk space – pausing uploads")
619
  await asyncio.sleep(10)
620
 
621
  status_msg = await app.send_message(
622
+ chat_id,
623
+ "⏳ Starting upload...",
624
+ reply_markup=InlineKeyboardMarkup(
625
+ [[InlineKeyboardButton("❌ Cancel", callback_data=f"cancel_job:{job_id}")]]
626
+ ),
627
  )
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"))
644
+ 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()
675
  disk_text = f"💾 Local disk: {format_size(free)} free (min {format_size(MIN_FREE_DISK)})"
676
 
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:
709
+ all_hf = hf.list_repo_files(repo_id=DATASET_REPO, repo_type=DATASET_TYPE)
710
  hf_user_files = [f for f in all_hf if f.startswith(enc_folder + "/")]
711
  for path in hf_user_files:
712
  _, enc_name = path.split("/", 1)
 
715
  except Exception:
716
  orig_name = enc_name
717
  try:
718
+ file_url = hf_hub_url(repo_id=DATASET_REPO, filename=path, repo_type=DATASET_TYPE)
719
  meta = get_hf_file_metadata(url=file_url, token=HF_TOKEN)
720
  size = meta.size
721
  except Exception:
722
  size = 0
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
751
+ kb = InlineKeyboardMarkup([
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/"):
775
+ chat_id = int("-100" + chat_str[2:])
776
+ else:
777
+ chat_id = chat_str
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()
 
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:", "")
859
  enc_path = file_callback_map.pop(token, None)
860
  if not enc_path:
861
  return await query.answer("File not found or already deleted.", show_alert=True)
 
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:
 
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):
885
  token = query.data.replace("upload:", "")
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:
935
+ continue
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/"):
 
973
  chat_id = int("-100" + chat_str[2:])
974
  else:
975
+ chat_id = chat_str
 
 
976
  msg = await app.get_messages(chat_id, msg_id)
977
  except Exception as e:
978
  await message.reply_text(f"❌ Could not access the message: {e}")
 
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"
 
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(
1038
+ filters.document | filters.video | filters.audio | filters.photo
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"
 
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:
1078
  album_groups[group_id] = {
1079
+ 'chat_id': message.chat.id,
1080
+ 'user_id': message.from_user.id,
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()
 
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():
1112
  while True:
1113
  try:
1114
+ requests.get(SPACE_HOST, timeout=10)
1115
+ except Exception as e:
1116
+ logger.warning("Keep‑alive ping failed: %s", e)
1117
  time.sleep(KEEPALIVE_INTERVAL)
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
+
1131
  if __name__ == "__main__":
1132
  threading.Thread(target=run_web, daemon=True).start()
1133
  threading.Thread(target=keepalive, daemon=True).start()