no-name-here commited on
Commit
35fc737
Β·
verified Β·
1 Parent(s): f291f26

Upload 4 files

Browse files
Files changed (2) hide show
  1. Dockerfile +8 -6
  2. main.py +383 -236
Dockerfile CHANGED
@@ -1,7 +1,7 @@
1
  FROM python:3.11-slim-bookworm
2
 
3
  ENV TZ=Asia/Dhaka
4
- ENV PYTHONPATH=/app
5
  ARG DEBIAN_FRONTEND=noninteractive
6
 
7
  RUN apt-get update && \
@@ -13,11 +13,13 @@ RUN apt-get update && \
13
  RUN pip install --no-cache-dir -U pip wheel==0.45.1
14
 
15
  WORKDIR /app
16
- COPY requirements.txt /app
 
 
17
  RUN pip install -U -r requirements.txt
18
 
19
- COPY . /app
 
20
 
21
- # Webhook mode: expose port and run with uvicorn
22
- EXPOSE 8000
23
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
 
1
  FROM python:3.11-slim-bookworm
2
 
3
  ENV TZ=Asia/Dhaka
4
+ ENV PYTHONUNBUFFERED=1
5
  ARG DEBIAN_FRONTEND=noninteractive
6
 
7
  RUN apt-get update && \
 
13
  RUN pip install --no-cache-dir -U pip wheel==0.45.1
14
 
15
  WORKDIR /app
16
+
17
+ # Only copy what's needed β€” single file bot
18
+ COPY requirements.txt .
19
  RUN pip install -U -r requirements.txt
20
 
21
+ COPY main.py .
22
+ COPY config.env .
23
 
24
+ EXPOSE 7860
25
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
main.py CHANGED
@@ -1,46 +1,296 @@
1
  # Copyright (C) @TheSmartBisnu
2
  # Channel: https://t.me/itsSmartDev
3
  #
4
- # Webhook-only rewrite: no direct Telegram Bot API HTTP calls are made.
5
- # Every handler returns a response dict that FastAPI sends back as the
6
- # webhook reply. Pyrogram (user session + bot client) is still used
7
- # exclusively for downloading / forwarding media via MTProto.
8
 
9
  import os
10
  import sys
11
  import shutil
12
  import psutil
13
  import asyncio
 
 
14
  from time import time
 
15
  from contextlib import asynccontextmanager
 
16
 
17
- # Ensure the project root is on sys.path so `helpers.*` imports always resolve
18
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
-
20
  from fastapi import FastAPI, Request
21
  from fastapi.responses import JSONResponse
22
 
23
  from pyrogram import Client
24
  from pyrogram.enums import ParseMode
25
  from pyrogram.errors import PeerIdInvalid, BadRequest, FloodWait
 
 
 
26
  from pyleaves import Leaves
27
 
28
- from helpers.utils import processMediaGroup, progressArgs, send_media
29
- from helpers.files import (
30
- get_download_path,
31
- fileSizeLimit,
32
- get_readable_file_size,
33
- get_readable_time,
34
- cleanup_download,
35
- cleanup_downloads_root,
 
 
 
 
 
 
 
 
36
  )
37
- from helpers.msg import getChatMsgID, get_file_name, get_parsed_msg
38
- from config import PyroConf
39
- from logger import LOGGER
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
- # ---------------------------------------------------------------------------
42
- # Pyrogram clients (MTProto only – no Bot API HTTP)
43
- # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  bot = Client(
45
  "media_bot",
46
  api_id=PyroConf.API_ID,
@@ -62,12 +312,11 @@ user = Client(
62
  sleep_threshold=30,
63
  )
64
 
65
- # ---------------------------------------------------------------------------
66
  # Global state
67
- # ---------------------------------------------------------------------------
68
  RUNNING_TASKS: set = set()
69
- download_semaphore: asyncio.Semaphore | None = None
70
-
71
 
72
  def track_task(coro):
73
  task = asyncio.create_task(coro)
@@ -75,80 +324,51 @@ def track_task(coro):
75
  task.add_done_callback(RUNNING_TASKS.discard)
76
  return task
77
 
78
-
79
- # ---------------------------------------------------------------------------
80
  # Webhook response helpers
81
- # NOTE: All functions return plain dicts – FastAPI serialises them as JSON.
82
- # Telegram treats the JSON body of a webhook response as an API call,
83
- # so we set "method" to the Bot-API method we want Telegram to execute.
84
- # ---------------------------------------------------------------------------
85
-
86
  def _msg(chat_id: int, text: str, parse_mode: str = "Markdown",
87
- reply_markup: dict | None = None, disable_web_page_preview: bool = False) -> dict:
88
- payload: dict = {
89
- "method": "sendMessage",
90
- "chat_id": chat_id,
91
- "text": text,
92
- "parse_mode": parse_mode,
93
- }
94
  if reply_markup:
95
  payload["reply_markup"] = reply_markup
96
  if disable_web_page_preview:
97
  payload["disable_web_page_preview"] = True
98
  return payload
99
 
100
-
101
  def _update_channel_markup() -> dict:
102
- return {
103
- "inline_keyboard": [[
104
- {"text": "Update Channel", "url": "https://t.me/itsSmartDev"}
105
- ]]
106
- }
107
-
108
-
109
- # ---------------------------------------------------------------------------
110
- # Synchronous command handlers (instant webhook replies)
111
- # ---------------------------------------------------------------------------
112
 
 
 
 
113
  def handle_start(chat_id: int) -> dict:
114
- text = (
115
  "πŸ‘‹ **Welcome to Media Downloader Bot!**\n\n"
116
  "I can grab photos, videos, audio, and documents from any Telegram post.\n"
117
- "Just send me a link (paste it directly or use `/dl <link>`),\n"
118
- "or reply to a message with `/dl`.\n\n"
119
- "ℹ️ Use `/help` to view all commands and examples.\n"
120
  "πŸ”’ Make sure the user client is part of the chat.\n\n"
121
- "Ready? Send me a Telegram post link!"
122
- )
123
- return _msg(chat_id, text, reply_markup=_update_channel_markup(),
124
- disable_web_page_preview=True)
125
-
126
 
127
  def handle_help(chat_id: int) -> dict:
128
- text = (
129
  "πŸ’‘ **Media Downloader Bot Help**\n\n"
130
  "➀ **Download Media**\n"
131
- " – Send `/dl <post_URL>` **or** just paste a Telegram post link.\n\n"
132
  "➀ **Batch Download**\n"
133
- " – Send `/bdl start_link end_link` to grab a series of posts.\n"
134
- " πŸ’‘ Example: `/bdl https://t.me/mychannel/100 https://t.me/mychannel/120`\n\n"
135
- "➀ **Requirements**\n"
136
- " – Make sure the user client is part of the chat.\n\n"
137
- "➀ **If the bot hangs**\n"
138
- " – Send `/killall` to cancel any pending downloads.\n\n"
139
- "➀ **Logs**\n"
140
- " – Send `/logs` to download the bot's logs file.\n\n"
141
- "➀ **Cleanup**\n"
142
- " – Send `/cleanup` to remove temporary downloaded files from disk.\n\n"
143
- "➀ **Stats**\n"
144
- " – Send `/stats` to view current status.\n\n"
145
- "**Example**:\n"
146
- " β€’ `/dl https://t.me/itsSmartDev/547`\n"
147
- " β€’ `https://t.me/itsSmartDev/547`"
148
- )
149
- return _msg(chat_id, text, reply_markup=_update_channel_markup(),
150
- disable_web_page_preview=True)
151
-
152
 
153
  def handle_stats(chat_id: int) -> dict:
154
  current_time = get_readable_time(int(time() - PyroConf.BOT_START_TIME))
@@ -159,84 +379,68 @@ def handle_stats(chat_id: int) -> dict:
159
  mem = psutil.virtual_memory().percent
160
  disk = psutil.disk_usage("/").percent
161
  proc = psutil.Process(os.getpid())
162
- text = (
163
  "**≧◉◑◉≦ Bot is Up and Running successfully.**\n\n"
164
- f"**➜ Bot Uptime:** `{current_time}`\n"
165
- f"**➜ Total Disk Space:** `{get_readable_file_size(total)}`\n"
166
- f"**➜ Used:** `{get_readable_file_size(used)}`\n"
167
- f"**➜ Free:** `{get_readable_file_size(free)}`\n"
168
- f"**➜ Memory Usage:** `{round(proc.memory_info()[0] / 1024**2)} MiB`\n\n"
169
- f"**➜ Upload:** `{sent}`\n"
170
- f"**➜ Download:** `{recv}`\n\n"
171
- f"**➜ CPU:** `{cpu}%` | **➜ RAM:** `{mem}%` | **➜ DISK:** `{disk}%`"
172
- )
173
- return _msg(chat_id, text)
174
-
175
 
176
  def handle_killall(chat_id: int) -> dict:
177
  cancelled = sum(1 for t in list(RUNNING_TASKS) if not t.done() and t.cancel())
178
  return _msg(chat_id, f"**Cancelled {cancelled} running task(s).**")
179
 
180
-
181
  def handle_cleanup(chat_id: int) -> dict:
182
  try:
183
  files_removed, bytes_freed = cleanup_downloads_root()
184
  if files_removed == 0:
185
  return _msg(chat_id, "🧹 **Cleanup complete:** no local downloads found.")
186
- return _msg(
187
- chat_id,
188
  f"🧹 **Cleanup complete:** removed `{files_removed}` file(s), "
189
- f"freed `{get_readable_file_size(bytes_freed)}`."
190
- )
191
  except Exception as e:
192
  LOGGER(__name__).error(f"Cleanup failed: {e}")
193
  return _msg(chat_id, "❌ **Cleanup failed.** Check logs for details.")
194
 
195
-
196
- # ---------------------------------------------------------------------------
197
- # Async download helpers (run in background, reply via Pyrogram MTProto)
198
- # ---------------------------------------------------------------------------
199
-
200
- async def _send_reply(chat_id: int, reply_to: int, text: str):
201
- """Send a plain text reply via Pyrogram bot (MTProto)."""
202
- await bot.send_message(chat_id, text, reply_to_message_id=reply_to)
203
-
204
-
 
 
 
 
 
 
 
 
 
 
205
  async def handle_download(chat_id: int, reply_to_id: int, post_url: str):
206
- """Download one post and send result back via Pyrogram (MTProto only)."""
207
  async with download_semaphore:
208
  if "?" in post_url:
209
  post_url = post_url.split("?", 1)[0]
210
-
211
- # We need a thin Message-like proxy so helpers that call
212
- # message.reply() still work. We wrap Pyrogram send_message.
213
- class _MsgProxy:
214
- def __init__(self, cid, rid):
215
- self.id = rid
216
- self.chat = type("C", (), {"id": cid})()
217
-
218
- async def reply(self, text, **kw):
219
- await bot.send_message(chat_id, text,
220
- reply_to_message_id=reply_to_id, **kw)
221
-
222
- async def reply_document(self, document, caption="", **kw):
223
- await bot.send_document(chat_id, document, caption=caption,
224
- reply_to_message_id=reply_to_id, **kw)
225
-
226
  message = _MsgProxy(chat_id, reply_to_id)
227
-
228
  try:
229
  _cid, message_id = getChatMsgID(post_url)
230
- chat_message = await user.get_messages(chat_id=_cid, message_ids=message_id)
231
-
232
- LOGGER(__name__).info(f"Downloading media from URL: {post_url}")
233
 
234
  if chat_message.document or chat_message.video or chat_message.audio:
235
  file_size = (
236
  chat_message.document.file_size if chat_message.document
237
  else chat_message.video.file_size if chat_message.video
238
- else chat_message.audio.file_size
239
- )
240
  if not await fileSizeLimit(file_size, message, "download",
241
  user.me.is_premium):
242
  return
@@ -255,12 +459,10 @@ async def handle_download(chat_id: int, reply_to_id: int, post_url: str):
255
  elif chat_message.media:
256
  start_time = time()
257
  progress_message = await bot.send_message(
258
- chat_id, "**πŸ“₯ Downloading Progress...**",
259
  reply_to_message_id=reply_to_id)
260
-
261
- filename = get_file_name(message_id, chat_message)
262
  download_path = get_download_path(reply_to_id, filename)
263
-
264
  media_path = None
265
  for attempt in range(2):
266
  try:
@@ -268,38 +470,32 @@ async def handle_download(chat_id: int, reply_to_id: int, post_url: str):
268
  file_name=download_path,
269
  progress=Leaves.progress_for_pyrogram,
270
  progress_args=progressArgs(
271
- "πŸ“₯ Downloading Progress", progress_message, start_time),
272
- )
273
  break
274
  except FloodWait as e:
275
  wait_s = int(getattr(e, "value", 0) or 0)
276
- LOGGER(__name__).warning(f"FloodWait: {wait_s}s")
277
  if wait_s > 0 and attempt == 0:
278
  await asyncio.sleep(wait_s + 1)
279
  continue
280
  raise
281
 
282
  if not media_path or not os.path.exists(media_path):
283
- await progress_message.edit("**❌ Download failed: File not saved properly**")
 
284
  return
285
-
286
  file_size = os.path.getsize(media_path)
287
  if file_size == 0:
288
  await progress_message.edit("**❌ Download failed: File is empty**")
289
  cleanup_download(media_path)
290
  return
291
 
292
- LOGGER(__name__).info(f"Downloaded: {media_path} ({file_size} bytes)")
293
-
294
  media_type = (
295
- "photo" if chat_message.photo
296
- else "video" if chat_message.video
297
- else "audio" if chat_message.audio
298
- else "document"
299
- )
300
  await send_media(bot, message, media_path, media_type,
301
  parsed_caption, progress_message, start_time)
302
-
303
  cleanup_download(media_path)
304
  await progress_message.delete()
305
 
@@ -310,108 +506,86 @@ async def handle_download(chat_id: int, reply_to_id: int, post_url: str):
310
 
311
  except FloodWait as e:
312
  wait_s = int(getattr(e, "value", 0) or 0)
313
- LOGGER(__name__).warning(f"FloodWait in handle_download: {wait_s}s")
314
  if wait_s > 0:
315
  await asyncio.sleep(wait_s + 1)
316
  except (PeerIdInvalid, BadRequest, KeyError):
317
  await message.reply("**Make sure the user client is part of the chat.**")
318
  except Exception as e:
319
  await message.reply(f"**❌ {str(e)}**")
320
- LOGGER(__name__).error(e)
321
 
322
 
323
  async def handle_batch_download(chat_id: int, reply_to_id: int,
324
  start_link: str, end_link: str):
325
- """Batch download posts via Pyrogram (MTProto only)."""
326
-
327
- class _MsgProxy:
328
- def __init__(self, cid, rid):
329
- self.id = rid
330
- self.chat = type("C", (), {"id": cid})()
331
-
332
- async def reply(self, text, **kw):
333
- await bot.send_message(chat_id, text,
334
- reply_to_message_id=reply_to_id, **kw)
335
-
336
  message = _MsgProxy(chat_id, reply_to_id)
337
-
338
  try:
339
  start_chat, start_id = getChatMsgID(start_link)
340
  end_chat, end_id = getChatMsgID(end_link)
341
  except Exception as e:
342
  await message.reply(f"**❌ Error parsing links:\n{e}**")
343
  return
344
-
345
  if start_chat != end_chat:
346
  await message.reply("**❌ Both links must be from the same channel.**")
347
  return
348
  if start_id > end_id:
349
  await message.reply("**❌ Invalid range: start ID cannot exceed end ID.**")
350
  return
351
-
352
  try:
353
  await user.get_chat(start_chat)
354
  except Exception:
355
  pass
356
 
357
- prefix = start_link.rsplit("/", 1)[0]
358
  loading = await bot.send_message(
359
- chat_id,
360
- f"πŸ“₯ **Downloading posts {start_id}–{end_id}…**",
361
- reply_to_message_id=reply_to_id,
362
- )
363
 
364
  downloaded = skipped = failed = 0
365
  processed_media_groups: set = set()
366
  batch_tasks = []
367
- BATCH_SIZE = PyroConf.BATCH_SIZE
368
 
369
  for msg_id in range(start_id, end_id + 1):
370
  url = f"{prefix}/{msg_id}"
371
  try:
372
- chat_msg = await user.get_messages(chat_id=start_chat, message_ids=msg_id)
 
373
  if not chat_msg:
374
  skipped += 1
375
  continue
376
-
377
  if chat_msg.media_group_id:
378
  if chat_msg.media_group_id in processed_media_groups:
379
  skipped += 1
380
  continue
381
  processed_media_groups.add(chat_msg.media_group_id)
382
-
383
- if not (chat_msg.media_group_id or chat_msg.media or
384
- chat_msg.text or chat_msg.caption):
385
  skipped += 1
386
  continue
387
-
388
- task = track_task(handle_download(chat_id, reply_to_id, url))
389
- batch_tasks.append(task)
390
-
391
- if len(batch_tasks) >= BATCH_SIZE:
392
  results = await asyncio.gather(*batch_tasks, return_exceptions=True)
393
  for result in results:
394
  if isinstance(result, asyncio.CancelledError):
395
  await loading.delete()
396
  await message.reply(
397
- f"**❌ Batch canceled** after downloading `{downloaded}` posts.")
398
  return
399
  elif isinstance(result, Exception):
400
  failed += 1
401
- LOGGER(__name__).error(f"Batch error: {result}")
402
  else:
403
  downloaded += 1
404
  batch_tasks.clear()
405
  await asyncio.sleep(PyroConf.FLOOD_WAIT_DELAY)
406
-
407
  except Exception as e:
408
  failed += 1
409
  LOGGER(__name__).error(f"Error at {url}: {e}")
410
 
411
  if batch_tasks:
412
  results = await asyncio.gather(*batch_tasks, return_exceptions=True)
413
- for result in results:
414
- if isinstance(result, Exception):
415
  failed += 1
416
  else:
417
  downloaded += 1
@@ -422,14 +596,11 @@ async def handle_batch_download(chat_id: int, reply_to_id: int,
422
  "━━━━━━━━━━━━━━━━━━━\n"
423
  f"πŸ“₯ **Downloaded** : `{downloaded}` post(s)\n"
424
  f"⏭️ **Skipped** : `{skipped}` (no content)\n"
425
- f"❌ **Failed** : `{failed}` error(s)"
426
- )
427
-
428
-
429
- # ---------------------------------------------------------------------------
430
- # FastAPI app + lifespan (start/stop Pyrogram clients)
431
- # ---------------------------------------------------------------------------
432
 
 
 
 
433
  @asynccontextmanager
434
  async def lifespan(app: FastAPI):
435
  global download_semaphore
@@ -437,97 +608,73 @@ async def lifespan(app: FastAPI):
437
  LOGGER(__name__).info("Starting Pyrogram clients…")
438
  await user.start()
439
  await bot.start()
440
- LOGGER(__name__).info("Bot started and ready for webhooks.")
441
  yield
442
- LOGGER(__name__).info("Stopping Pyrogram clients…")
443
  await bot.stop()
444
  await user.stop()
445
 
446
-
447
  app = FastAPI(lifespan=lifespan)
448
 
449
- # ---------------------------------------------------------------------------
450
  # Webhook endpoint
451
- # ---------------------------------------------------------------------------
452
-
453
- KNOWN_COMMANDS = {"start", "help", "dl", "bdl", "stats", "logs", "killall", "cleanup"}
454
-
455
-
456
  @app.post("/webhook")
457
  async def telegram_webhook(request: Request):
458
  update = await request.json()
459
- LOGGER(__name__).debug(f"Received update: {update}")
460
 
461
  if "message" not in update:
462
  return JSONResponse({"status": "ok"})
463
 
464
- msg = update["message"]
465
- chat_id = msg["chat"]["id"]
466
- text = msg.get("text", "").strip()
467
- msg_id = msg["message_id"]
468
 
469
- # ---- /start -------------------------------------------------------
470
  if text.startswith("/start"):
471
  return JSONResponse(handle_start(chat_id))
472
-
473
- # ---- /help --------------------------------------------------------
474
  if text.startswith("/help"):
475
  return JSONResponse(handle_help(chat_id))
476
-
477
- # ---- /stats -------------------------------------------------------
478
  if text.startswith("/stats"):
479
  return JSONResponse(handle_stats(chat_id))
480
-
481
- # ---- /killall -----------------------------------------------------
482
  if text.startswith("/killall"):
483
  return JSONResponse(handle_killall(chat_id))
484
-
485
- # ---- /cleanup -----------------------------------------------------
486
  if text.startswith("/cleanup"):
487
  return JSONResponse(handle_cleanup(chat_id))
488
 
489
- # ---- /logs --------------------------------------------------------
490
  if text.startswith("/logs"):
491
- # Sending a document cannot be done as a direct webhook reply;
492
- # use Pyrogram MTProto instead.
493
  async def _send_logs():
494
  if os.path.exists("logs.txt"):
495
- await bot.send_document(chat_id, "logs.txt",
496
- caption="**Logs**",
497
  reply_to_message_id=msg_id)
498
  else:
499
- await bot.send_message(chat_id, "**Not exists**",
500
  reply_to_message_id=msg_id)
501
  track_task(_send_logs())
502
  return JSONResponse({"status": "ok"})
503
 
504
- # ---- /dl <url> ----------------------------------------------------
505
  if text.startswith("/dl"):
506
  parts = text.split(None, 1)
507
  if len(parts) < 2:
508
- return JSONResponse(_msg(chat_id, "**Provide a post URL after the /dl command.**"))
509
- post_url = parts[1].strip()
510
- track_task(handle_download(chat_id, msg_id, post_url))
511
  return JSONResponse({"status": "ok"})
512
 
513
- # ---- /bdl <start> <end> ------------------------------------------
514
  if text.startswith("/bdl"):
515
  parts = text.split()
516
- if (len(parts) != 3 or
517
- not all(p.startswith("https://t.me/") for p in parts[1:])):
518
- return JSONResponse(_msg(
519
- chat_id,
520
- "πŸš€ **Batch Download Process**\n"
521
- "`/bdl start_link end_link`\n\n"
522
- "πŸ’‘ **Example:**\n"
523
- "`/bdl https://t.me/mychannel/100 https://t.me/mychannel/120`"
524
- ))
525
  track_task(handle_batch_download(chat_id, msg_id, parts[1], parts[2]))
526
  return JSONResponse({"status": "ok"})
527
 
528
- # ---- plain URL or unknown text ------------------------------------
529
  if text and not text.startswith("/"):
530
  track_task(handle_download(chat_id, msg_id, text))
531
  return JSONResponse({"status": "ok"})
532
 
533
- return JSONResponse({"status": "ok"})
 
1
  # Copyright (C) @TheSmartBisnu
2
  # Channel: https://t.me/itsSmartDev
3
  #
4
+ # Single-file webhook bot β€” zero local module imports.
5
+ # All helpers are inlined so HuggingFace Spaces Docker can run:
6
+ # uvicorn main:app --host 0.0.0.0 --port 7860
 
7
 
8
  import os
9
  import sys
10
  import shutil
11
  import psutil
12
  import asyncio
13
+ import logging
14
+ import traceback
15
  from time import time
16
+ from typing import Optional
17
  from contextlib import asynccontextmanager
18
+ from logging.handlers import RotatingFileHandler
19
 
20
+ from dotenv import load_dotenv
 
 
21
  from fastapi import FastAPI, Request
22
  from fastapi.responses import JSONResponse
23
 
24
  from pyrogram import Client
25
  from pyrogram.enums import ParseMode
26
  from pyrogram.errors import PeerIdInvalid, BadRequest, FloodWait
27
+ from pyrogram.parser import Parser
28
+ from pyrogram.utils import get_channel_id
29
+ from pyrogram.types import InputMediaPhoto, InputMediaVideo, InputMediaAudio, InputMediaDocument
30
  from pyleaves import Leaves
31
 
32
+ # ============================================================
33
+ # Logging
34
+ # ============================================================
35
+ try:
36
+ os.remove("logs.txt")
37
+ except Exception:
38
+ pass
39
+
40
+ logging.basicConfig(
41
+ level=logging.INFO,
42
+ format="[%(asctime)s - %(levelname)s] - %(funcName)s() - Line %(lineno)d: %(name)s - %(message)s",
43
+ datefmt="%d-%b-%y %I:%M:%S %p",
44
+ handlers=[
45
+ RotatingFileHandler("logs.txt", mode="w+", maxBytes=5_000_000, backupCount=10),
46
+ logging.StreamHandler(),
47
+ ],
48
  )
49
+ logging.getLogger("pyrogram").setLevel(logging.ERROR)
50
+
51
+ def LOGGER(name: str) -> logging.Logger:
52
+ return logging.getLogger(name)
53
+
54
+ # ============================================================
55
+ # Config
56
+ # ============================================================
57
+ try:
58
+ load_dotenv("config.env.local")
59
+ load_dotenv("config.env")
60
+ except Exception:
61
+ pass
62
+
63
+ _missing = [v for v in ("BOT_TOKEN", "SESSION_STRING", "API_ID", "API_HASH") if not os.getenv(v)]
64
+ if _missing:
65
+ print(f"ERROR: Missing required env vars: {', '.join(_missing)}")
66
+ sys.exit(1)
67
+
68
+ class PyroConf:
69
+ API_ID = int(os.getenv("API_ID", "6"))
70
+ API_HASH = os.getenv("API_HASH", "eb06d4abfb49dc3eeb1aeb98ae0f581e")
71
+ BOT_TOKEN = os.getenv("BOT_TOKEN")
72
+ SESSION_STRING = os.getenv("SESSION_STRING")
73
+ BOT_START_TIME = time()
74
+ MAX_CONCURRENT_DOWNLOADS = int(os.getenv("MAX_CONCURRENT_DOWNLOADS", "1"))
75
+ BATCH_SIZE = int(os.getenv("BATCH_SIZE", "1"))
76
+ FLOOD_WAIT_DELAY = int(os.getenv("FLOOD_WAIT_DELAY", "10"))
77
+
78
+ # ============================================================
79
+ # File helpers
80
+ # ============================================================
81
+ SIZE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"]
82
+
83
+ def get_download_path(folder_id: int, filename: str, root_dir: str = "downloads") -> str:
84
+ folder = os.path.join(root_dir, str(folder_id))
85
+ os.makedirs(folder, exist_ok=True)
86
+ return os.path.join(folder, filename)
87
+
88
+ def cleanup_download(path: str) -> None:
89
+ try:
90
+ if os.path.exists(path):
91
+ os.remove(path)
92
+ if os.path.exists(path + ".temp"):
93
+ os.remove(path + ".temp")
94
+ folder = os.path.dirname(path)
95
+ if os.path.isdir(folder) and not os.listdir(folder):
96
+ os.rmdir(folder)
97
+ except Exception as e:
98
+ LOGGER(__name__).error(f"Cleanup failed for {path}: {e}")
99
+
100
+ def cleanup_downloads_root(root_dir: str = "downloads") -> tuple:
101
+ if not os.path.isdir(root_dir):
102
+ return 0, 0
103
+ file_count = 0
104
+ total_size = 0
105
+ for dirpath, _, filenames in os.walk(root_dir):
106
+ for name in filenames:
107
+ file_count += 1
108
+ try:
109
+ total_size += os.path.getsize(os.path.join(dirpath, name))
110
+ except OSError:
111
+ pass
112
+ shutil.rmtree(root_dir, ignore_errors=True)
113
+ return file_count, total_size
114
+
115
+ def get_readable_file_size(size_in_bytes: Optional[float]) -> str:
116
+ if size_in_bytes is None or size_in_bytes < 0:
117
+ return "0B"
118
+ for unit in SIZE_UNITS:
119
+ if size_in_bytes < 1024:
120
+ return f"{size_in_bytes:.2f} {unit}"
121
+ size_in_bytes /= 1024
122
+ return "File too large"
123
+
124
+ def get_readable_time(seconds: int) -> str:
125
+ result = ""
126
+ days, remainder = divmod(seconds, 86400)
127
+ if int(days):
128
+ result += f"{int(days)}d"
129
+ hours, remainder = divmod(remainder, 3600)
130
+ if int(hours):
131
+ result += f"{int(hours)}h"
132
+ minutes, seconds = divmod(remainder, 60)
133
+ if int(minutes):
134
+ result += f"{int(minutes)}m"
135
+ result += f"{int(seconds)}s"
136
+ return result
137
+
138
+ async def fileSizeLimit(file_size, message, action_type="download", is_premium=False):
139
+ MAX_FILE_SIZE = 2 * 2_097_152_000 if is_premium else 2_097_152_000
140
+ if file_size > MAX_FILE_SIZE:
141
+ await message.reply(
142
+ f"The file size exceeds the {get_readable_file_size(MAX_FILE_SIZE)} "
143
+ f"limit and cannot be {action_type}ed.")
144
+ return False
145
+ return True
146
+
147
+ # ============================================================
148
+ # Message / link helpers
149
+ # ============================================================
150
+ async def get_parsed_msg(text, entities):
151
+ return Parser.unparse(text, entities or [], is_html=False)
152
+
153
+ def getChatMsgID(link: str):
154
+ linkps = link.split("/")
155
+ chat_id = message_id = None
156
+ try:
157
+ if len(linkps) == 7 and linkps[3] == "c":
158
+ chat_id = get_channel_id(int(linkps[4]))
159
+ message_id = int(linkps[6])
160
+ elif len(linkps) == 6:
161
+ if linkps[3] == "c":
162
+ chat_id = get_channel_id(int(linkps[4]))
163
+ message_id = int(linkps[5])
164
+ else:
165
+ chat_id = linkps[3]
166
+ message_id = int(linkps[5])
167
+ elif len(linkps) == 5:
168
+ chat_id = linkps[3]
169
+ if chat_id == "m":
170
+ raise ValueError("Invalid ClientType used to parse this message link")
171
+ message_id = int(linkps[4])
172
+ except (ValueError, TypeError):
173
+ raise ValueError("Invalid post URL. Must end with a numeric ID.")
174
+ if not chat_id or not message_id:
175
+ raise ValueError("Please send a valid Telegram post URL.")
176
+ return chat_id, message_id
177
+
178
+ def get_file_name(message_id: int, chat_message) -> str:
179
+ if chat_message.document:
180
+ return chat_message.document.file_name or f"{message_id}"
181
+ elif chat_message.video:
182
+ return chat_message.video.file_name or f"{message_id}.mp4"
183
+ elif chat_message.audio:
184
+ return chat_message.audio.file_name or f"{message_id}.mp3"
185
+ elif chat_message.voice:
186
+ return f"{message_id}.ogg"
187
+ elif chat_message.video_note:
188
+ return f"{message_id}.mp4"
189
+ elif chat_message.animation:
190
+ return chat_message.animation.file_name or f"{message_id}.gif"
191
+ elif chat_message.sticker:
192
+ if chat_message.sticker.is_animated:
193
+ return f"{message_id}.tgs"
194
+ elif chat_message.sticker.is_video:
195
+ return f"{message_id}.webm"
196
+ else:
197
+ return f"{message_id}.webp"
198
+ elif chat_message.photo:
199
+ return f"{message_id}.jpg"
200
+ return f"{message_id}"
201
+
202
+ # ============================================================
203
+ # Media helpers
204
+ # ============================================================
205
+ def progressArgs(action: str, progress_message, start_time: float):
206
+ return (action, progress_message, start_time)
207
+
208
+ async def send_media(bot_client, message, media_path, media_type,
209
+ caption, progress_message, start_time):
210
+ chat_id = message.chat.id
211
+ reply_to = message.id
212
+ send_map = {
213
+ "photo": bot_client.send_photo,
214
+ "video": bot_client.send_video,
215
+ "audio": bot_client.send_audio,
216
+ "document": bot_client.send_document,
217
+ }
218
+ sender = send_map.get(media_type, bot_client.send_document)
219
+ for attempt in range(2):
220
+ try:
221
+ await sender(chat_id, media_path, caption=caption or "",
222
+ reply_to_message_id=reply_to)
223
+ return
224
+ except FloodWait as e:
225
+ wait_s = int(getattr(e, "value", 0) or 0)
226
+ if wait_s > 0 and attempt == 0:
227
+ await asyncio.sleep(wait_s + 1)
228
+ continue
229
+ raise
230
+
231
+ async def processMediaGroup(chat_message, bot_client, message) -> bool:
232
+ chat_id = message.chat.id
233
+ reply_to = message.id
234
+ try:
235
+ group_messages = await user.get_media_group(
236
+ chat_message.chat.id, chat_message.id)
237
+ except Exception as e:
238
+ LOGGER(__name__).error(f"Failed to get media group: {e}")
239
+ return False
240
+ if not group_messages:
241
+ return False
242
+
243
+ downloaded_paths = []
244
+ media_list = []
245
+ for idx, gm in enumerate(group_messages):
246
+ try:
247
+ filename = get_file_name(gm.id, gm)
248
+ download_path = get_download_path(reply_to, f"grp_{idx}_{filename}")
249
+ path = await gm.download(file_name=download_path)
250
+ if not path:
251
+ continue
252
+ downloaded_paths.append(path)
253
+ cap = ""
254
+ if idx == 0 and (gm.caption or gm.text):
255
+ cap = Parser.unparse(
256
+ gm.caption or gm.text or "",
257
+ gm.caption_entities or gm.entities or [],
258
+ is_html=False)
259
+ if gm.photo:
260
+ media_list.append(InputMediaPhoto(path, caption=cap))
261
+ elif gm.video:
262
+ media_list.append(InputMediaVideo(path, caption=cap))
263
+ elif gm.audio:
264
+ media_list.append(InputMediaAudio(path, caption=cap))
265
+ else:
266
+ media_list.append(InputMediaDocument(path, caption=cap))
267
+ except Exception as e:
268
+ LOGGER(__name__).error(f"Error downloading group item {gm.id}: {e}")
269
 
270
+ if not media_list:
271
+ for p in downloaded_paths:
272
+ cleanup_download(p)
273
+ return False
274
+
275
+ try:
276
+ await bot_client.send_media_group(chat_id, media_list,
277
+ reply_to_message_id=reply_to)
278
+ except Exception as e:
279
+ LOGGER(__name__).warning(f"send_media_group failed ({e}), sending individually")
280
+ for path in downloaded_paths:
281
+ try:
282
+ await bot_client.send_document(chat_id, path,
283
+ reply_to_message_id=reply_to)
284
+ except Exception as ie:
285
+ LOGGER(__name__).error(f"Individual send failed: {ie}")
286
+
287
+ for p in downloaded_paths:
288
+ cleanup_download(p)
289
+ return True
290
+
291
+ # ============================================================
292
+ # Pyrogram clients
293
+ # ============================================================
294
  bot = Client(
295
  "media_bot",
296
  api_id=PyroConf.API_ID,
 
312
  sleep_threshold=30,
313
  )
314
 
315
+ # ============================================================
316
  # Global state
317
+ # ============================================================
318
  RUNNING_TASKS: set = set()
319
+ download_semaphore = None
 
320
 
321
  def track_task(coro):
322
  task = asyncio.create_task(coro)
 
324
  task.add_done_callback(RUNNING_TASKS.discard)
325
  return task
326
 
327
+ # ============================================================
 
328
  # Webhook response helpers
329
+ # ============================================================
 
 
 
 
330
  def _msg(chat_id: int, text: str, parse_mode: str = "Markdown",
331
+ reply_markup: dict = None, disable_web_page_preview: bool = False) -> dict:
332
+ payload = {"method": "sendMessage", "chat_id": chat_id,
333
+ "text": text, "parse_mode": parse_mode}
 
 
 
 
334
  if reply_markup:
335
  payload["reply_markup"] = reply_markup
336
  if disable_web_page_preview:
337
  payload["disable_web_page_preview"] = True
338
  return payload
339
 
 
340
  def _update_channel_markup() -> dict:
341
+ return {"inline_keyboard": [[
342
+ {"text": "Update Channel", "url": "https://t.me/itsSmartDev"}
343
+ ]]}
 
 
 
 
 
 
 
344
 
345
+ # ============================================================
346
+ # Instant command handlers (returned as webhook reply body)
347
+ # ============================================================
348
  def handle_start(chat_id: int) -> dict:
349
+ return _msg(chat_id,
350
  "πŸ‘‹ **Welcome to Media Downloader Bot!**\n\n"
351
  "I can grab photos, videos, audio, and documents from any Telegram post.\n"
352
+ "Just send me a link (paste it directly or use `/dl <link>`).\n\n"
353
+ "ℹ️ Use `/help` to view all commands.\n"
 
354
  "πŸ”’ Make sure the user client is part of the chat.\n\n"
355
+ "Ready? Send me a Telegram post link!",
356
+ reply_markup=_update_channel_markup(), disable_web_page_preview=True)
 
 
 
357
 
358
  def handle_help(chat_id: int) -> dict:
359
+ return _msg(chat_id,
360
  "πŸ’‘ **Media Downloader Bot Help**\n\n"
361
  "➀ **Download Media**\n"
362
+ " – `/dl <post_URL>` or paste a link directly.\n\n"
363
  "➀ **Batch Download**\n"
364
+ " – `/bdl start_link end_link`\n"
365
+ " πŸ’‘ Example: `/bdl https://t.me/mychannel/100 https://t.me/mychannel/120`\n\n"
366
+ "➀ `/killall` – cancel pending downloads\n"
367
+ "➀ `/logs` – get bot log file\n"
368
+ "➀ `/cleanup` – remove temp files\n"
369
+ "➀ `/stats` – show system stats\n\n"
370
+ "**Example**: `/dl https://t.me/itsSmartDev/547`",
371
+ reply_markup=_update_channel_markup(), disable_web_page_preview=True)
 
 
 
 
 
 
 
 
 
 
 
372
 
373
  def handle_stats(chat_id: int) -> dict:
374
  current_time = get_readable_time(int(time() - PyroConf.BOT_START_TIME))
 
379
  mem = psutil.virtual_memory().percent
380
  disk = psutil.disk_usage("/").percent
381
  proc = psutil.Process(os.getpid())
382
+ return _msg(chat_id,
383
  "**≧◉◑◉≦ Bot is Up and Running successfully.**\n\n"
384
+ f"**➜ Uptime:** `{current_time}`\n"
385
+ f"**➜ Disk:** `{get_readable_file_size(total)}` total "
386
+ f"`{get_readable_file_size(used)}` used "
387
+ f"`{get_readable_file_size(free)}` free\n"
388
+ f"**➜ Memory:** `{round(proc.memory_info()[0] / 1024**2)} MiB`\n"
389
+ f"**➜ Net:** ↑`{sent}` ↓`{recv}`\n"
390
+ f"**➜ CPU:** `{cpu}%` **RAM:** `{mem}%` **DISK:** `{disk}%`")
 
 
 
 
391
 
392
  def handle_killall(chat_id: int) -> dict:
393
  cancelled = sum(1 for t in list(RUNNING_TASKS) if not t.done() and t.cancel())
394
  return _msg(chat_id, f"**Cancelled {cancelled} running task(s).**")
395
 
 
396
  def handle_cleanup(chat_id: int) -> dict:
397
  try:
398
  files_removed, bytes_freed = cleanup_downloads_root()
399
  if files_removed == 0:
400
  return _msg(chat_id, "🧹 **Cleanup complete:** no local downloads found.")
401
+ return _msg(chat_id,
 
402
  f"🧹 **Cleanup complete:** removed `{files_removed}` file(s), "
403
+ f"freed `{get_readable_file_size(bytes_freed)}`.")
 
404
  except Exception as e:
405
  LOGGER(__name__).error(f"Cleanup failed: {e}")
406
  return _msg(chat_id, "❌ **Cleanup failed.** Check logs for details.")
407
 
408
+ # ============================================================
409
+ # Message proxy
410
+ # ============================================================
411
+ class _MsgProxy:
412
+ def __init__(self, cid: int, rid: int):
413
+ self.id = rid
414
+ self.chat = type("_Chat", (), {"id": cid})()
415
+
416
+ async def reply(self, text, **kw):
417
+ kw.pop("reply_markup", None)
418
+ await bot.send_message(self.chat.id, text,
419
+ reply_to_message_id=self.id)
420
+
421
+ async def reply_document(self, document, caption="", **kw):
422
+ await bot.send_document(self.chat.id, document, caption=caption,
423
+ reply_to_message_id=self.id)
424
+
425
+ # ============================================================
426
+ # Async download workers
427
+ # ============================================================
428
  async def handle_download(chat_id: int, reply_to_id: int, post_url: str):
 
429
  async with download_semaphore:
430
  if "?" in post_url:
431
  post_url = post_url.split("?", 1)[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  message = _MsgProxy(chat_id, reply_to_id)
 
433
  try:
434
  _cid, message_id = getChatMsgID(post_url)
435
+ chat_message = await user.get_messages(
436
+ chat_id=_cid, message_ids=message_id)
437
+ LOGGER(__name__).info(f"Downloading from: {post_url}")
438
 
439
  if chat_message.document or chat_message.video or chat_message.audio:
440
  file_size = (
441
  chat_message.document.file_size if chat_message.document
442
  else chat_message.video.file_size if chat_message.video
443
+ else chat_message.audio.file_size)
 
444
  if not await fileSizeLimit(file_size, message, "download",
445
  user.me.is_premium):
446
  return
 
459
  elif chat_message.media:
460
  start_time = time()
461
  progress_message = await bot.send_message(
462
+ chat_id, "**πŸ“₯ Downloading...**",
463
  reply_to_message_id=reply_to_id)
464
+ filename = get_file_name(message_id, chat_message)
 
465
  download_path = get_download_path(reply_to_id, filename)
 
466
  media_path = None
467
  for attempt in range(2):
468
  try:
 
470
  file_name=download_path,
471
  progress=Leaves.progress_for_pyrogram,
472
  progress_args=progressArgs(
473
+ "πŸ“₯ Downloading", progress_message, start_time))
 
474
  break
475
  except FloodWait as e:
476
  wait_s = int(getattr(e, "value", 0) or 0)
 
477
  if wait_s > 0 and attempt == 0:
478
  await asyncio.sleep(wait_s + 1)
479
  continue
480
  raise
481
 
482
  if not media_path or not os.path.exists(media_path):
483
+ await progress_message.edit(
484
+ "**❌ Download failed: File not saved properly**")
485
  return
 
486
  file_size = os.path.getsize(media_path)
487
  if file_size == 0:
488
  await progress_message.edit("**❌ Download failed: File is empty**")
489
  cleanup_download(media_path)
490
  return
491
 
 
 
492
  media_type = (
493
+ "photo" if chat_message.photo
494
+ else "video" if chat_message.video
495
+ else "audio" if chat_message.audio
496
+ else "document")
 
497
  await send_media(bot, message, media_path, media_type,
498
  parsed_caption, progress_message, start_time)
 
499
  cleanup_download(media_path)
500
  await progress_message.delete()
501
 
 
506
 
507
  except FloodWait as e:
508
  wait_s = int(getattr(e, "value", 0) or 0)
509
+ LOGGER(__name__).warning(f"FloodWait: {wait_s}s")
510
  if wait_s > 0:
511
  await asyncio.sleep(wait_s + 1)
512
  except (PeerIdInvalid, BadRequest, KeyError):
513
  await message.reply("**Make sure the user client is part of the chat.**")
514
  except Exception as e:
515
  await message.reply(f"**❌ {str(e)}**")
516
+ LOGGER(__name__).error(traceback.format_exc())
517
 
518
 
519
  async def handle_batch_download(chat_id: int, reply_to_id: int,
520
  start_link: str, end_link: str):
 
 
 
 
 
 
 
 
 
 
 
521
  message = _MsgProxy(chat_id, reply_to_id)
 
522
  try:
523
  start_chat, start_id = getChatMsgID(start_link)
524
  end_chat, end_id = getChatMsgID(end_link)
525
  except Exception as e:
526
  await message.reply(f"**❌ Error parsing links:\n{e}**")
527
  return
 
528
  if start_chat != end_chat:
529
  await message.reply("**❌ Both links must be from the same channel.**")
530
  return
531
  if start_id > end_id:
532
  await message.reply("**❌ Invalid range: start ID cannot exceed end ID.**")
533
  return
 
534
  try:
535
  await user.get_chat(start_chat)
536
  except Exception:
537
  pass
538
 
539
+ prefix = start_link.rsplit("/", 1)[0]
540
  loading = await bot.send_message(
541
+ chat_id, f"πŸ“₯ **Downloading posts {start_id}–{end_id}…**",
542
+ reply_to_message_id=reply_to_id)
 
 
543
 
544
  downloaded = skipped = failed = 0
545
  processed_media_groups: set = set()
546
  batch_tasks = []
 
547
 
548
  for msg_id in range(start_id, end_id + 1):
549
  url = f"{prefix}/{msg_id}"
550
  try:
551
+ chat_msg = await user.get_messages(
552
+ chat_id=start_chat, message_ids=msg_id)
553
  if not chat_msg:
554
  skipped += 1
555
  continue
 
556
  if chat_msg.media_group_id:
557
  if chat_msg.media_group_id in processed_media_groups:
558
  skipped += 1
559
  continue
560
  processed_media_groups.add(chat_msg.media_group_id)
561
+ if not (chat_msg.media_group_id or chat_msg.media
562
+ or chat_msg.text or chat_msg.caption):
 
563
  skipped += 1
564
  continue
565
+ batch_tasks.append(
566
+ track_task(handle_download(chat_id, reply_to_id, url)))
567
+ if len(batch_tasks) >= PyroConf.BATCH_SIZE:
 
 
568
  results = await asyncio.gather(*batch_tasks, return_exceptions=True)
569
  for result in results:
570
  if isinstance(result, asyncio.CancelledError):
571
  await loading.delete()
572
  await message.reply(
573
+ f"**❌ Batch canceled** after `{downloaded}` posts.")
574
  return
575
  elif isinstance(result, Exception):
576
  failed += 1
 
577
  else:
578
  downloaded += 1
579
  batch_tasks.clear()
580
  await asyncio.sleep(PyroConf.FLOOD_WAIT_DELAY)
 
581
  except Exception as e:
582
  failed += 1
583
  LOGGER(__name__).error(f"Error at {url}: {e}")
584
 
585
  if batch_tasks:
586
  results = await asyncio.gather(*batch_tasks, return_exceptions=True)
587
+ for r in results:
588
+ if isinstance(r, Exception):
589
  failed += 1
590
  else:
591
  downloaded += 1
 
596
  "━━━━━━━━━━━━━━━━━━━\n"
597
  f"πŸ“₯ **Downloaded** : `{downloaded}` post(s)\n"
598
  f"⏭️ **Skipped** : `{skipped}` (no content)\n"
599
+ f"❌ **Failed** : `{failed}` error(s)")
 
 
 
 
 
 
600
 
601
+ # ============================================================
602
+ # FastAPI lifespan
603
+ # ============================================================
604
  @asynccontextmanager
605
  async def lifespan(app: FastAPI):
606
  global download_semaphore
 
608
  LOGGER(__name__).info("Starting Pyrogram clients…")
609
  await user.start()
610
  await bot.start()
611
+ LOGGER(__name__).info("Bot ready β€” waiting for webhook updates.")
612
  yield
613
+ LOGGER(__name__).info("Shutting down…")
614
  await bot.stop()
615
  await user.stop()
616
 
 
617
  app = FastAPI(lifespan=lifespan)
618
 
619
+ # ============================================================
620
  # Webhook endpoint
621
+ # ============================================================
 
 
 
 
622
  @app.post("/webhook")
623
  async def telegram_webhook(request: Request):
624
  update = await request.json()
625
+ LOGGER(__name__).debug(f"Update: {update}")
626
 
627
  if "message" not in update:
628
  return JSONResponse({"status": "ok"})
629
 
630
+ msg = update["message"]
631
+ chat_id = msg["chat"]["id"]
632
+ text = msg.get("text", "").strip()
633
+ msg_id = msg["message_id"]
634
 
 
635
  if text.startswith("/start"):
636
  return JSONResponse(handle_start(chat_id))
 
 
637
  if text.startswith("/help"):
638
  return JSONResponse(handle_help(chat_id))
 
 
639
  if text.startswith("/stats"):
640
  return JSONResponse(handle_stats(chat_id))
 
 
641
  if text.startswith("/killall"):
642
  return JSONResponse(handle_killall(chat_id))
 
 
643
  if text.startswith("/cleanup"):
644
  return JSONResponse(handle_cleanup(chat_id))
645
 
 
646
  if text.startswith("/logs"):
 
 
647
  async def _send_logs():
648
  if os.path.exists("logs.txt"):
649
+ await bot.send_document(chat_id, "logs.txt", caption="**Logs**",
 
650
  reply_to_message_id=msg_id)
651
  else:
652
+ await bot.send_message(chat_id, "**No log file found.**",
653
  reply_to_message_id=msg_id)
654
  track_task(_send_logs())
655
  return JSONResponse({"status": "ok"})
656
 
 
657
  if text.startswith("/dl"):
658
  parts = text.split(None, 1)
659
  if len(parts) < 2:
660
+ return JSONResponse(
661
+ _msg(chat_id, "**Provide a post URL after the /dl command.**"))
662
+ track_task(handle_download(chat_id, msg_id, parts[1].strip()))
663
  return JSONResponse({"status": "ok"})
664
 
 
665
  if text.startswith("/bdl"):
666
  parts = text.split()
667
+ if (len(parts) != 3
668
+ or not all(p.startswith("https://t.me/") for p in parts[1:])):
669
+ return JSONResponse(_msg(chat_id,
670
+ "πŸš€ **Batch Download**\n`/bdl start_link end_link`\n\n"
671
+ "πŸ’‘ Example:\n"
672
+ "`/bdl https://t.me/mychannel/100 https://t.me/mychannel/120`"))
 
 
 
673
  track_task(handle_batch_download(chat_id, msg_id, parts[1], parts[2]))
674
  return JSONResponse({"status": "ok"})
675
 
 
676
  if text and not text.startswith("/"):
677
  track_task(handle_download(chat_id, msg_id, text))
678
  return JSONResponse({"status": "ok"})
679
 
680
+ return JSONResponse({"status": "ok"})