no-name-here commited on
Commit
3f48026
·
verified ·
1 Parent(s): c031311

Upload 10 files

Browse files
Files changed (10) hide show
  1. Dockerfile +17 -13
  2. config.env +17 -0
  3. config.py +33 -0
  4. docker-compose.yml +13 -0
  5. files.py +82 -0
  6. logger.py +24 -0
  7. main.py +529 -0
  8. msg.py +66 -0
  9. requirements.txt +5 -11
  10. utils.py +145 -0
Dockerfile CHANGED
@@ -1,18 +1,22 @@
1
- # Dockerfile
2
- FROM python:3.9-slim
3
 
4
- # Set a working directory
5
- # WORKDIR /app
6
 
7
- # Copy dependency definitions and install them
8
- COPY requirements.txt .
9
- RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
 
 
10
 
11
- # Copy the application code
12
- COPY . .
13
 
14
- # Expose the port that uvicorn will listen on (Hugging Face Spaces uses port 7860)
15
- EXPOSE 7860
 
16
 
17
- # Start the FastAPI app with uvicorn
18
- CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
 
 
1
+ FROM python:3.11-slim-bookworm
 
2
 
3
+ ENV TZ=Asia/Dhaka
4
+ ARG DEBIAN_FRONTEND=noninteractive
5
 
6
+ RUN apt-get update && \
7
+ apt-get install -y --no-install-recommends \
8
+ git build-essential tzdata ffmpeg libssl-dev libffi-dev && \
9
+ ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime && echo "$TZ" > /etc/timezone && \
10
+ rm -rf /var/lib/apt/lists/*
11
 
12
+ RUN pip install --no-cache-dir -U pip wheel==0.45.1
 
13
 
14
+ WORKDIR /app
15
+ COPY requirements.txt /app
16
+ RUN pip install -U -r requirements.txt
17
 
18
+ COPY . /app
19
+
20
+ # Webhook mode: expose port and run with uvicorn
21
+ EXPOSE 8000
22
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
config.env ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Telegram API credentials – get from https://my.telegram.org
2
+ API_ID=
3
+ API_HASH=
4
+
5
+ # Bot token from @BotFather
6
+ BOT_TOKEN=
7
+
8
+ # User session string – generate via @SmartUtilBot → /pyro
9
+ SESSION_STRING=
10
+
11
+ # Optional webhook secret (set same value in setWebhook call)
12
+ WEBHOOK_SECRET=
13
+
14
+ # Tuning
15
+ MAX_CONCURRENT_DOWNLOADS=1
16
+ BATCH_SIZE=1
17
+ FLOOD_WAIT_DELAY=10
config.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (C) @TheSmartBisnu
2
+ # Channel: https://t.me/itsSmartDev
3
+
4
+ from os import getenv
5
+ from time import time
6
+ from dotenv import load_dotenv
7
+
8
+ try:
9
+ load_dotenv("config.env.local")
10
+ load_dotenv("config.env")
11
+ except Exception:
12
+ pass
13
+
14
+ if not getenv("BOT_TOKEN") or getenv("BOT_TOKEN", "").count(":") != 1:
15
+ print("Error: BOT_TOKEN must be in format '123456:abcdefghijklmnopqrstuvwxyz'")
16
+ exit(1)
17
+
18
+ if not getenv("SESSION_STRING") or getenv("SESSION_STRING") == "xxxxxxxxxxxxxxxxxxxxxxx":
19
+ print("Error: SESSION_STRING must be set with a valid string")
20
+ exit(1)
21
+
22
+
23
+ class PyroConf:
24
+ API_ID = int(getenv("API_ID", "6"))
25
+ API_HASH = getenv("API_HASH", "eb06d4abfb49dc3eeb1aeb98ae0f581e")
26
+ BOT_TOKEN = getenv("BOT_TOKEN")
27
+ SESSION_STRING = getenv("SESSION_STRING")
28
+ WEBHOOK_SECRET = getenv("WEBHOOK_SECRET", "") # optional: verify X-Telegram-Bot-Api-Secret-Token
29
+ BOT_START_TIME = time()
30
+
31
+ MAX_CONCURRENT_DOWNLOADS = int(getenv("MAX_CONCURRENT_DOWNLOADS", "1"))
32
+ BATCH_SIZE = int(getenv("BATCH_SIZE", "1"))
33
+ FLOOD_WAIT_DELAY = int(getenv("FLOOD_WAIT_DELAY", "10"))
docker-compose.yml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.9"
2
+
3
+ services:
4
+ bot:
5
+ build: .
6
+ restart: unless-stopped
7
+ ports:
8
+ - "8000:8000"
9
+ env_file:
10
+ - config.env
11
+ volumes:
12
+ - ./downloads:/app/downloads
13
+ - ./logs.txt:/app/logs.txt
files.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (C) @TheSmartBisnu
2
+ # Channel: https://t.me/itsSmartDev
3
+
4
+ import os
5
+ import shutil
6
+ from typing import Optional
7
+
8
+ from logger import LOGGER
9
+
10
+ SIZE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"]
11
+
12
+
13
+ def get_download_path(folder_id: int, filename: str, root_dir: str = "downloads") -> str:
14
+ folder = os.path.join(root_dir, str(folder_id))
15
+ os.makedirs(folder, exist_ok=True)
16
+ return os.path.join(folder, filename)
17
+
18
+
19
+ def cleanup_download(path: str) -> None:
20
+ try:
21
+ LOGGER(__name__).info(f"Cleaning Download: {path}")
22
+ if os.path.exists(path):
23
+ os.remove(path)
24
+ if os.path.exists(path + ".temp"):
25
+ os.remove(path + ".temp")
26
+ folder = os.path.dirname(path)
27
+ if os.path.isdir(folder) and not os.listdir(folder):
28
+ os.rmdir(folder)
29
+ except Exception as e:
30
+ LOGGER(__name__).error(f"Cleanup failed for {path}: {e}")
31
+
32
+
33
+ def cleanup_downloads_root(root_dir: str = "downloads") -> tuple[int, int]:
34
+ if not os.path.isdir(root_dir):
35
+ return 0, 0
36
+ file_count = 0
37
+ total_size = 0
38
+ for dirpath, _, filenames in os.walk(root_dir):
39
+ for name in filenames:
40
+ file_count += 1
41
+ try:
42
+ total_size += os.path.getsize(os.path.join(dirpath, name))
43
+ except OSError:
44
+ pass
45
+ shutil.rmtree(root_dir, ignore_errors=True)
46
+ return file_count, total_size
47
+
48
+
49
+ def get_readable_file_size(size_in_bytes: Optional[float]) -> str:
50
+ if size_in_bytes is None or size_in_bytes < 0:
51
+ return "0B"
52
+ for unit in SIZE_UNITS:
53
+ if size_in_bytes < 1024:
54
+ return f"{size_in_bytes:.2f} {unit}"
55
+ size_in_bytes /= 1024
56
+ return "File too large"
57
+
58
+
59
+ def get_readable_time(seconds: int) -> str:
60
+ result = ""
61
+ days, remainder = divmod(seconds, 86400)
62
+ if int(days):
63
+ result += f"{int(days)}d"
64
+ hours, remainder = divmod(remainder, 3600)
65
+ if int(hours):
66
+ result += f"{int(hours)}h"
67
+ minutes, seconds = divmod(remainder, 60)
68
+ if int(minutes):
69
+ result += f"{int(minutes)}m"
70
+ result += f"{int(seconds)}s"
71
+ return result
72
+
73
+
74
+ async def fileSizeLimit(file_size, message, action_type="download", is_premium=False):
75
+ MAX_FILE_SIZE = 2 * 2_097_152_000 if is_premium else 2_097_152_000
76
+ if file_size > MAX_FILE_SIZE:
77
+ await message.reply(
78
+ f"The file size exceeds the {get_readable_file_size(MAX_FILE_SIZE)} "
79
+ f"limit and cannot be {action_type}ed."
80
+ )
81
+ return False
82
+ return True
logger.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ from logging.handlers import RotatingFileHandler
4
+
5
+ try:
6
+ os.remove("logs.txt")
7
+ except Exception:
8
+ pass
9
+
10
+ logging.basicConfig(
11
+ level=logging.INFO,
12
+ format="[%(asctime)s - %(levelname)s] - %(funcName)s() - Line %(lineno)d: %(name)s - %(message)s",
13
+ datefmt="%d-%b-%y %I:%M:%S %p",
14
+ handlers=[
15
+ RotatingFileHandler("logs.txt", mode="w+", maxBytes=5_000_000, backupCount=10),
16
+ logging.StreamHandler(),
17
+ ],
18
+ )
19
+
20
+ logging.getLogger("pyrogram").setLevel(logging.ERROR)
21
+
22
+
23
+ def LOGGER(name: str) -> logging.Logger:
24
+ return logging.getLogger(name)
main.py ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 shutil
11
+ import psutil
12
+ import asyncio
13
+ from time import time
14
+ from contextlib import asynccontextmanager
15
+
16
+ from fastapi import FastAPI, Request
17
+ from fastapi.responses import JSONResponse
18
+
19
+ from pyrogram import Client
20
+ from pyrogram.enums import ParseMode
21
+ from pyrogram.errors import PeerIdInvalid, BadRequest, FloodWait
22
+ from pyleaves import Leaves
23
+
24
+ from helpers.utils import processMediaGroup, progressArgs, send_media
25
+ from helpers.files import (
26
+ get_download_path,
27
+ fileSizeLimit,
28
+ get_readable_file_size,
29
+ get_readable_time,
30
+ cleanup_download,
31
+ cleanup_downloads_root,
32
+ )
33
+ from helpers.msg import getChatMsgID, get_file_name, get_parsed_msg
34
+ from config import PyroConf
35
+ from logger import LOGGER
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Pyrogram clients (MTProto only – no Bot API HTTP)
39
+ # ---------------------------------------------------------------------------
40
+ bot = Client(
41
+ "media_bot",
42
+ api_id=PyroConf.API_ID,
43
+ api_hash=PyroConf.API_HASH,
44
+ bot_token=PyroConf.BOT_TOKEN,
45
+ workers=100,
46
+ parse_mode=ParseMode.MARKDOWN,
47
+ max_concurrent_transmissions=1,
48
+ sleep_threshold=30,
49
+ )
50
+
51
+ user = Client(
52
+ "user_session",
53
+ api_id=PyroConf.API_ID,
54
+ api_hash=PyroConf.API_HASH,
55
+ session_string=PyroConf.SESSION_STRING,
56
+ workers=100,
57
+ max_concurrent_transmissions=1,
58
+ sleep_threshold=30,
59
+ )
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Global state
63
+ # ---------------------------------------------------------------------------
64
+ RUNNING_TASKS: set = set()
65
+ download_semaphore: asyncio.Semaphore | None = None
66
+
67
+
68
+ def track_task(coro):
69
+ task = asyncio.create_task(coro)
70
+ RUNNING_TASKS.add(task)
71
+ task.add_done_callback(RUNNING_TASKS.discard)
72
+ return task
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Webhook response helpers
77
+ # NOTE: All functions return plain dicts – FastAPI serialises them as JSON.
78
+ # Telegram treats the JSON body of a webhook response as an API call,
79
+ # so we set "method" to the Bot-API method we want Telegram to execute.
80
+ # ---------------------------------------------------------------------------
81
+
82
+ def _msg(chat_id: int, text: str, parse_mode: str = "Markdown",
83
+ reply_markup: dict | None = None, disable_web_page_preview: bool = False) -> dict:
84
+ payload: dict = {
85
+ "method": "sendMessage",
86
+ "chat_id": chat_id,
87
+ "text": text,
88
+ "parse_mode": parse_mode,
89
+ }
90
+ if reply_markup:
91
+ payload["reply_markup"] = reply_markup
92
+ if disable_web_page_preview:
93
+ payload["disable_web_page_preview"] = True
94
+ return payload
95
+
96
+
97
+ def _update_channel_markup() -> dict:
98
+ return {
99
+ "inline_keyboard": [[
100
+ {"text": "Update Channel", "url": "https://t.me/itsSmartDev"}
101
+ ]]
102
+ }
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Synchronous command handlers (instant webhook replies)
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def handle_start(chat_id: int) -> dict:
110
+ text = (
111
+ "👋 **Welcome to Media Downloader Bot!**\n\n"
112
+ "I can grab photos, videos, audio, and documents from any Telegram post.\n"
113
+ "Just send me a link (paste it directly or use `/dl <link>`),\n"
114
+ "or reply to a message with `/dl`.\n\n"
115
+ "ℹ️ Use `/help` to view all commands and examples.\n"
116
+ "🔒 Make sure the user client is part of the chat.\n\n"
117
+ "Ready? Send me a Telegram post link!"
118
+ )
119
+ return _msg(chat_id, text, reply_markup=_update_channel_markup(),
120
+ disable_web_page_preview=True)
121
+
122
+
123
+ def handle_help(chat_id: int) -> dict:
124
+ text = (
125
+ "💡 **Media Downloader Bot Help**\n\n"
126
+ "➤ **Download Media**\n"
127
+ " – Send `/dl <post_URL>` **or** just paste a Telegram post link.\n\n"
128
+ "➤ **Batch Download**\n"
129
+ " – Send `/bdl start_link end_link` to grab a series of posts.\n"
130
+ " 💡 Example: `/bdl https://t.me/mychannel/100 https://t.me/mychannel/120`\n\n"
131
+ "➤ **Requirements**\n"
132
+ " – Make sure the user client is part of the chat.\n\n"
133
+ "➤ **If the bot hangs**\n"
134
+ " – Send `/killall` to cancel any pending downloads.\n\n"
135
+ "➤ **Logs**\n"
136
+ " – Send `/logs` to download the bot's logs file.\n\n"
137
+ "➤ **Cleanup**\n"
138
+ " – Send `/cleanup` to remove temporary downloaded files from disk.\n\n"
139
+ "➤ **Stats**\n"
140
+ " – Send `/stats` to view current status.\n\n"
141
+ "**Example**:\n"
142
+ " • `/dl https://t.me/itsSmartDev/547`\n"
143
+ " • `https://t.me/itsSmartDev/547`"
144
+ )
145
+ return _msg(chat_id, text, reply_markup=_update_channel_markup(),
146
+ disable_web_page_preview=True)
147
+
148
+
149
+ def handle_stats(chat_id: int) -> dict:
150
+ current_time = get_readable_time(int(time() - PyroConf.BOT_START_TIME))
151
+ total, used, free = shutil.disk_usage(".")
152
+ sent = get_readable_file_size(psutil.net_io_counters().bytes_sent)
153
+ recv = get_readable_file_size(psutil.net_io_counters().bytes_recv)
154
+ cpu = psutil.cpu_percent(interval=0.2)
155
+ mem = psutil.virtual_memory().percent
156
+ disk = psutil.disk_usage("/").percent
157
+ proc = psutil.Process(os.getpid())
158
+ text = (
159
+ "**≧◉◡◉≦ Bot is Up and Running successfully.**\n\n"
160
+ f"**➜ Bot Uptime:** `{current_time}`\n"
161
+ f"**➜ Total Disk Space:** `{get_readable_file_size(total)}`\n"
162
+ f"**➜ Used:** `{get_readable_file_size(used)}`\n"
163
+ f"**➜ Free:** `{get_readable_file_size(free)}`\n"
164
+ f"**➜ Memory Usage:** `{round(proc.memory_info()[0] / 1024**2)} MiB`\n\n"
165
+ f"**➜ Upload:** `{sent}`\n"
166
+ f"**➜ Download:** `{recv}`\n\n"
167
+ f"**➜ CPU:** `{cpu}%` | **➜ RAM:** `{mem}%` | **➜ DISK:** `{disk}%`"
168
+ )
169
+ return _msg(chat_id, text)
170
+
171
+
172
+ def handle_killall(chat_id: int) -> dict:
173
+ cancelled = sum(1 for t in list(RUNNING_TASKS) if not t.done() and t.cancel())
174
+ return _msg(chat_id, f"**Cancelled {cancelled} running task(s).**")
175
+
176
+
177
+ def handle_cleanup(chat_id: int) -> dict:
178
+ try:
179
+ files_removed, bytes_freed = cleanup_downloads_root()
180
+ if files_removed == 0:
181
+ return _msg(chat_id, "🧹 **Cleanup complete:** no local downloads found.")
182
+ return _msg(
183
+ chat_id,
184
+ f"🧹 **Cleanup complete:** removed `{files_removed}` file(s), "
185
+ f"freed `{get_readable_file_size(bytes_freed)}`."
186
+ )
187
+ except Exception as e:
188
+ LOGGER(__name__).error(f"Cleanup failed: {e}")
189
+ return _msg(chat_id, "❌ **Cleanup failed.** Check logs for details.")
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Async download helpers (run in background, reply via Pyrogram MTProto)
194
+ # ---------------------------------------------------------------------------
195
+
196
+ async def _send_reply(chat_id: int, reply_to: int, text: str):
197
+ """Send a plain text reply via Pyrogram bot (MTProto)."""
198
+ await bot.send_message(chat_id, text, reply_to_message_id=reply_to)
199
+
200
+
201
+ async def handle_download(chat_id: int, reply_to_id: int, post_url: str):
202
+ """Download one post and send result back via Pyrogram (MTProto only)."""
203
+ async with download_semaphore:
204
+ if "?" in post_url:
205
+ post_url = post_url.split("?", 1)[0]
206
+
207
+ # We need a thin Message-like proxy so helpers that call
208
+ # message.reply() still work. We wrap Pyrogram send_message.
209
+ class _MsgProxy:
210
+ def __init__(self, cid, rid):
211
+ self.id = rid
212
+ self.chat = type("C", (), {"id": cid})()
213
+
214
+ async def reply(self, text, **kw):
215
+ await bot.send_message(chat_id, text,
216
+ reply_to_message_id=reply_to_id, **kw)
217
+
218
+ async def reply_document(self, document, caption="", **kw):
219
+ await bot.send_document(chat_id, document, caption=caption,
220
+ reply_to_message_id=reply_to_id, **kw)
221
+
222
+ message = _MsgProxy(chat_id, reply_to_id)
223
+
224
+ try:
225
+ _cid, message_id = getChatMsgID(post_url)
226
+ chat_message = await user.get_messages(chat_id=_cid, message_ids=message_id)
227
+
228
+ LOGGER(__name__).info(f"Downloading media from URL: {post_url}")
229
+
230
+ if chat_message.document or chat_message.video or chat_message.audio:
231
+ file_size = (
232
+ chat_message.document.file_size if chat_message.document
233
+ else chat_message.video.file_size if chat_message.video
234
+ else chat_message.audio.file_size
235
+ )
236
+ if not await fileSizeLimit(file_size, message, "download",
237
+ user.me.is_premium):
238
+ return
239
+
240
+ parsed_caption = await get_parsed_msg(
241
+ chat_message.caption or "", chat_message.caption_entities)
242
+ parsed_text = await get_parsed_msg(
243
+ chat_message.text or "", chat_message.entities)
244
+
245
+ if chat_message.media_group_id:
246
+ if not await processMediaGroup(chat_message, bot, message):
247
+ await message.reply(
248
+ "**Could not extract any valid media from the media group.**")
249
+ return
250
+
251
+ elif chat_message.media:
252
+ start_time = time()
253
+ progress_message = await bot.send_message(
254
+ chat_id, "**📥 Downloading Progress...**",
255
+ reply_to_message_id=reply_to_id)
256
+
257
+ filename = get_file_name(message_id, chat_message)
258
+ download_path = get_download_path(reply_to_id, filename)
259
+
260
+ media_path = None
261
+ for attempt in range(2):
262
+ try:
263
+ media_path = await chat_message.download(
264
+ file_name=download_path,
265
+ progress=Leaves.progress_for_pyrogram,
266
+ progress_args=progressArgs(
267
+ "📥 Downloading Progress", progress_message, start_time),
268
+ )
269
+ break
270
+ except FloodWait as e:
271
+ wait_s = int(getattr(e, "value", 0) or 0)
272
+ LOGGER(__name__).warning(f"FloodWait: {wait_s}s")
273
+ if wait_s > 0 and attempt == 0:
274
+ await asyncio.sleep(wait_s + 1)
275
+ continue
276
+ raise
277
+
278
+ if not media_path or not os.path.exists(media_path):
279
+ await progress_message.edit("**❌ Download failed: File not saved properly**")
280
+ return
281
+
282
+ file_size = os.path.getsize(media_path)
283
+ if file_size == 0:
284
+ await progress_message.edit("**❌ Download failed: File is empty**")
285
+ cleanup_download(media_path)
286
+ return
287
+
288
+ LOGGER(__name__).info(f"Downloaded: {media_path} ({file_size} bytes)")
289
+
290
+ media_type = (
291
+ "photo" if chat_message.photo
292
+ else "video" if chat_message.video
293
+ else "audio" if chat_message.audio
294
+ else "document"
295
+ )
296
+ await send_media(bot, message, media_path, media_type,
297
+ parsed_caption, progress_message, start_time)
298
+
299
+ cleanup_download(media_path)
300
+ await progress_message.delete()
301
+
302
+ elif chat_message.text or chat_message.caption:
303
+ await message.reply(parsed_text or parsed_caption)
304
+ else:
305
+ await message.reply("**No media or text found in the post URL.**")
306
+
307
+ except FloodWait as e:
308
+ wait_s = int(getattr(e, "value", 0) or 0)
309
+ LOGGER(__name__).warning(f"FloodWait in handle_download: {wait_s}s")
310
+ if wait_s > 0:
311
+ await asyncio.sleep(wait_s + 1)
312
+ except (PeerIdInvalid, BadRequest, KeyError):
313
+ await message.reply("**Make sure the user client is part of the chat.**")
314
+ except Exception as e:
315
+ await message.reply(f"**❌ {str(e)}**")
316
+ LOGGER(__name__).error(e)
317
+
318
+
319
+ async def handle_batch_download(chat_id: int, reply_to_id: int,
320
+ start_link: str, end_link: str):
321
+ """Batch download posts via Pyrogram (MTProto only)."""
322
+
323
+ class _MsgProxy:
324
+ def __init__(self, cid, rid):
325
+ self.id = rid
326
+ self.chat = type("C", (), {"id": cid})()
327
+
328
+ async def reply(self, text, **kw):
329
+ await bot.send_message(chat_id, text,
330
+ reply_to_message_id=reply_to_id, **kw)
331
+
332
+ message = _MsgProxy(chat_id, reply_to_id)
333
+
334
+ try:
335
+ start_chat, start_id = getChatMsgID(start_link)
336
+ end_chat, end_id = getChatMsgID(end_link)
337
+ except Exception as e:
338
+ await message.reply(f"**❌ Error parsing links:\n{e}**")
339
+ return
340
+
341
+ if start_chat != end_chat:
342
+ await message.reply("**❌ Both links must be from the same channel.**")
343
+ return
344
+ if start_id > end_id:
345
+ await message.reply("**❌ Invalid range: start ID cannot exceed end ID.**")
346
+ return
347
+
348
+ try:
349
+ await user.get_chat(start_chat)
350
+ except Exception:
351
+ pass
352
+
353
+ prefix = start_link.rsplit("/", 1)[0]
354
+ loading = await bot.send_message(
355
+ chat_id,
356
+ f"📥 **Downloading posts {start_id}–{end_id}…**",
357
+ reply_to_message_id=reply_to_id,
358
+ )
359
+
360
+ downloaded = skipped = failed = 0
361
+ processed_media_groups: set = set()
362
+ batch_tasks = []
363
+ BATCH_SIZE = PyroConf.BATCH_SIZE
364
+
365
+ for msg_id in range(start_id, end_id + 1):
366
+ url = f"{prefix}/{msg_id}"
367
+ try:
368
+ chat_msg = await user.get_messages(chat_id=start_chat, message_ids=msg_id)
369
+ if not chat_msg:
370
+ skipped += 1
371
+ continue
372
+
373
+ if chat_msg.media_group_id:
374
+ if chat_msg.media_group_id in processed_media_groups:
375
+ skipped += 1
376
+ continue
377
+ processed_media_groups.add(chat_msg.media_group_id)
378
+
379
+ if not (chat_msg.media_group_id or chat_msg.media or
380
+ chat_msg.text or chat_msg.caption):
381
+ skipped += 1
382
+ continue
383
+
384
+ task = track_task(handle_download(chat_id, reply_to_id, url))
385
+ batch_tasks.append(task)
386
+
387
+ if len(batch_tasks) >= BATCH_SIZE:
388
+ results = await asyncio.gather(*batch_tasks, return_exceptions=True)
389
+ for result in results:
390
+ if isinstance(result, asyncio.CancelledError):
391
+ await loading.delete()
392
+ await message.reply(
393
+ f"**❌ Batch canceled** after downloading `{downloaded}` posts.")
394
+ return
395
+ elif isinstance(result, Exception):
396
+ failed += 1
397
+ LOGGER(__name__).error(f"Batch error: {result}")
398
+ else:
399
+ downloaded += 1
400
+ batch_tasks.clear()
401
+ await asyncio.sleep(PyroConf.FLOOD_WAIT_DELAY)
402
+
403
+ except Exception as e:
404
+ failed += 1
405
+ LOGGER(__name__).error(f"Error at {url}: {e}")
406
+
407
+ if batch_tasks:
408
+ results = await asyncio.gather(*batch_tasks, return_exceptions=True)
409
+ for result in results:
410
+ if isinstance(result, Exception):
411
+ failed += 1
412
+ else:
413
+ downloaded += 1
414
+
415
+ await loading.delete()
416
+ await message.reply(
417
+ "**✅ Batch Process Complete!**\n"
418
+ "━━━━━━━━━━━━━━━━━━━\n"
419
+ f"📥 **Downloaded** : `{downloaded}` post(s)\n"
420
+ f"⏭️ **Skipped** : `{skipped}` (no content)\n"
421
+ f"❌ **Failed** : `{failed}` error(s)"
422
+ )
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # FastAPI app + lifespan (start/stop Pyrogram clients)
427
+ # ---------------------------------------------------------------------------
428
+
429
+ @asynccontextmanager
430
+ async def lifespan(app: FastAPI):
431
+ global download_semaphore
432
+ download_semaphore = asyncio.Semaphore(PyroConf.MAX_CONCURRENT_DOWNLOADS)
433
+ LOGGER(__name__).info("Starting Pyrogram clients…")
434
+ await user.start()
435
+ await bot.start()
436
+ LOGGER(__name__).info("Bot started and ready for webhooks.")
437
+ yield
438
+ LOGGER(__name__).info("Stopping Pyrogram clients…")
439
+ await bot.stop()
440
+ await user.stop()
441
+
442
+
443
+ app = FastAPI(lifespan=lifespan)
444
+
445
+ # ---------------------------------------------------------------------------
446
+ # Webhook endpoint
447
+ # ---------------------------------------------------------------------------
448
+
449
+ KNOWN_COMMANDS = {"start", "help", "dl", "bdl", "stats", "logs", "killall", "cleanup"}
450
+
451
+
452
+ @app.post("/webhook")
453
+ async def telegram_webhook(request: Request):
454
+ update = await request.json()
455
+ LOGGER(__name__).debug(f"Received update: {update}")
456
+
457
+ if "message" not in update:
458
+ return JSONResponse({"status": "ok"})
459
+
460
+ msg = update["message"]
461
+ chat_id = msg["chat"]["id"]
462
+ text = msg.get("text", "").strip()
463
+ msg_id = msg["message_id"]
464
+
465
+ # ---- /start -------------------------------------------------------
466
+ if text.startswith("/start"):
467
+ return JSONResponse(handle_start(chat_id))
468
+
469
+ # ---- /help --------------------------------------------------------
470
+ if text.startswith("/help"):
471
+ return JSONResponse(handle_help(chat_id))
472
+
473
+ # ---- /stats -------------------------------------------------------
474
+ if text.startswith("/stats"):
475
+ return JSONResponse(handle_stats(chat_id))
476
+
477
+ # ---- /killall -----------------------------------------------------
478
+ if text.startswith("/killall"):
479
+ return JSONResponse(handle_killall(chat_id))
480
+
481
+ # ---- /cleanup -----------------------------------------------------
482
+ if text.startswith("/cleanup"):
483
+ return JSONResponse(handle_cleanup(chat_id))
484
+
485
+ # ---- /logs --------------------------------------------------------
486
+ if text.startswith("/logs"):
487
+ # Sending a document cannot be done as a direct webhook reply;
488
+ # use Pyrogram MTProto instead.
489
+ async def _send_logs():
490
+ if os.path.exists("logs.txt"):
491
+ await bot.send_document(chat_id, "logs.txt",
492
+ caption="**Logs**",
493
+ reply_to_message_id=msg_id)
494
+ else:
495
+ await bot.send_message(chat_id, "**Not exists**",
496
+ reply_to_message_id=msg_id)
497
+ track_task(_send_logs())
498
+ return JSONResponse({"status": "ok"})
499
+
500
+ # ---- /dl <url> ----------------------------------------------------
501
+ if text.startswith("/dl"):
502
+ parts = text.split(None, 1)
503
+ if len(parts) < 2:
504
+ return JSONResponse(_msg(chat_id, "**Provide a post URL after the /dl command.**"))
505
+ post_url = parts[1].strip()
506
+ track_task(handle_download(chat_id, msg_id, post_url))
507
+ return JSONResponse({"status": "ok"})
508
+
509
+ # ---- /bdl <start> <end> ------------------------------------------
510
+ if text.startswith("/bdl"):
511
+ parts = text.split()
512
+ if (len(parts) != 3 or
513
+ not all(p.startswith("https://t.me/") for p in parts[1:])):
514
+ return JSONResponse(_msg(
515
+ chat_id,
516
+ "🚀 **Batch Download Process**\n"
517
+ "`/bdl start_link end_link`\n\n"
518
+ "💡 **Example:**\n"
519
+ "`/bdl https://t.me/mychannel/100 https://t.me/mychannel/120`"
520
+ ))
521
+ track_task(handle_batch_download(chat_id, msg_id, parts[1], parts[2]))
522
+ return JSONResponse({"status": "ok"})
523
+
524
+ # ---- plain URL or unknown text ------------------------------------
525
+ if text and not text.startswith("/"):
526
+ track_task(handle_download(chat_id, msg_id, text))
527
+ return JSONResponse({"status": "ok"})
528
+
529
+ return JSONResponse({"status": "ok"})
msg.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (C) @TheSmartBisnu
2
+ # Channel: https://t.me/itsSmartDev
3
+
4
+ from pyrogram.parser import Parser
5
+ from pyrogram.utils import get_channel_id
6
+
7
+
8
+ async def get_parsed_msg(text, entities):
9
+ return Parser.unparse(text, entities or [], is_html=False)
10
+
11
+
12
+ def getChatMsgID(link: str):
13
+ linkps = link.split("/")
14
+ chat_id = message_thread_id = message_id = None
15
+
16
+ try:
17
+ if len(linkps) == 7 and linkps[3] == "c":
18
+ chat_id = get_channel_id(int(linkps[4]))
19
+ message_thread_id = int(linkps[5])
20
+ message_id = int(linkps[6])
21
+ elif len(linkps) == 6:
22
+ if linkps[3] == "c":
23
+ chat_id = get_channel_id(int(linkps[4]))
24
+ message_id = int(linkps[5])
25
+ else:
26
+ chat_id = linkps[3]
27
+ message_thread_id = int(linkps[4])
28
+ message_id = int(linkps[5])
29
+ elif len(linkps) == 5:
30
+ chat_id = linkps[3]
31
+ if chat_id == "m":
32
+ raise ValueError("Invalid ClientType used to parse this message link")
33
+ message_id = int(linkps[4])
34
+ except (ValueError, TypeError):
35
+ raise ValueError("Invalid post URL. Must end with a numeric ID.")
36
+
37
+ if not chat_id or not message_id:
38
+ raise ValueError("Please send a valid Telegram post URL.")
39
+
40
+ return chat_id, message_id
41
+
42
+
43
+ def get_file_name(message_id: int, chat_message) -> str:
44
+ if chat_message.document:
45
+ return chat_message.document.file_name
46
+ elif chat_message.video:
47
+ return chat_message.video.file_name or f"{message_id}.mp4"
48
+ elif chat_message.audio:
49
+ return chat_message.audio.file_name or f"{message_id}.mp3"
50
+ elif chat_message.voice:
51
+ return f"{message_id}.ogg"
52
+ elif chat_message.video_note:
53
+ return f"{message_id}.mp4"
54
+ elif chat_message.animation:
55
+ return chat_message.animation.file_name or f"{message_id}.gif"
56
+ elif chat_message.sticker:
57
+ if chat_message.sticker.is_animated:
58
+ return f"{message_id}.tgs"
59
+ elif chat_message.sticker.is_video:
60
+ return f"{message_id}.webm"
61
+ else:
62
+ return f"{message_id}.webp"
63
+ elif chat_message.photo:
64
+ return f"{message_id}.jpg"
65
+ else:
66
+ return f"{message_id}"
requirements.txt CHANGED
@@ -1,13 +1,7 @@
 
 
 
 
 
1
  fastapi
2
  uvicorn[standard]
3
- requests
4
- telebot
5
- av
6
- streamlit
7
- python-telegram-bot
8
- asyncio
9
- APScheduler
10
- validators
11
- aiofiles
12
- httpx
13
- SQLAlchemy
 
1
+ pyrofork
2
+ pyleaves
3
+ tgcrypto
4
+ python-dotenv
5
+ psutil
6
  fastapi
7
  uvicorn[standard]
 
 
 
 
 
 
 
 
 
 
 
utils.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (C) @TheSmartBisnu
2
+ # Channel: https://t.me/itsSmartDev
3
+
4
+ import asyncio
5
+ from time import time
6
+
7
+ from pyrogram import Client
8
+ from pyrogram.errors import FloodWait
9
+ from pyrogram.types import InputMediaPhoto, InputMediaVideo, InputMediaAudio, InputMediaDocument
10
+
11
+ from helpers.files import get_download_path, cleanup_download, get_readable_file_size
12
+ from helpers.msg import get_file_name
13
+ from logger import LOGGER
14
+
15
+
16
+ def progressArgs(action: str, progress_message, start_time: float):
17
+ """Return kwargs tuple for Leaves.progress_for_pyrogram."""
18
+ return (action, progress_message, start_time)
19
+
20
+
21
+ async def send_media(
22
+ bot: Client,
23
+ message, # _MsgProxy or real pyrogram Message
24
+ media_path: str,
25
+ media_type: str,
26
+ caption: str,
27
+ progress_message,
28
+ start_time: float,
29
+ ):
30
+ """Upload a local file back to the user via Pyrogram MTProto."""
31
+ chat_id = message.chat.id
32
+ reply_to = message.id
33
+
34
+ send_map = {
35
+ "photo": bot.send_photo,
36
+ "video": bot.send_video,
37
+ "audio": bot.send_audio,
38
+ "document": bot.send_document,
39
+ }
40
+ sender = send_map.get(media_type, bot.send_document)
41
+
42
+ for attempt in range(2):
43
+ try:
44
+ await sender(
45
+ chat_id,
46
+ media_path,
47
+ caption=caption or "",
48
+ reply_to_message_id=reply_to,
49
+ )
50
+ return
51
+ except FloodWait as e:
52
+ wait_s = int(getattr(e, "value", 0) or 0)
53
+ LOGGER(__name__).warning(f"FloodWait while uploading: {wait_s}s")
54
+ if wait_s > 0 and attempt == 0:
55
+ await asyncio.sleep(wait_s + 1)
56
+ continue
57
+ raise
58
+
59
+
60
+ async def processMediaGroup(chat_message, bot: Client, message) -> bool:
61
+ """
62
+ Download all items in a media group and send them as an album (or
63
+ individually if the album send fails).
64
+ Returns True if at least one item was sent.
65
+ """
66
+ from pyrogram import Client as _Client
67
+
68
+ user_client = None
69
+ # Retrieve the user client from the message's chat context isn't possible
70
+ # without passing it in, so we import it from main at call time.
71
+ try:
72
+ from __main__ import user as user_client
73
+ except ImportError:
74
+ pass
75
+
76
+ if user_client is None:
77
+ LOGGER(__name__).error("processMediaGroup: could not obtain user client")
78
+ return False
79
+
80
+ chat_id = message.chat.id
81
+ reply_to = message.id
82
+
83
+ try:
84
+ group_messages = await user_client.get_media_group(
85
+ chat_message.chat.id, chat_message.id
86
+ )
87
+ except Exception as e:
88
+ LOGGER(__name__).error(f"Failed to get media group: {e}")
89
+ return False
90
+
91
+ if not group_messages:
92
+ return False
93
+
94
+ downloaded_paths = []
95
+ media_list = []
96
+
97
+ for idx, gm in enumerate(group_messages):
98
+ try:
99
+ filename = get_file_name(gm.id, gm)
100
+ download_path = get_download_path(reply_to, f"grp_{idx}_{filename}")
101
+ path = await gm.download(file_name=download_path)
102
+ if not path:
103
+ continue
104
+ downloaded_paths.append(path)
105
+
106
+ caption_text = ""
107
+ if idx == 0 and (gm.caption or gm.text):
108
+ from pyrogram.parser import Parser
109
+ caption_text = Parser.unparse(
110
+ gm.caption or gm.text or "",
111
+ gm.caption_entities or gm.entities or [],
112
+ is_html=False,
113
+ )
114
+
115
+ if gm.photo:
116
+ media_list.append(InputMediaPhoto(path, caption=caption_text))
117
+ elif gm.video:
118
+ media_list.append(InputMediaVideo(path, caption=caption_text))
119
+ elif gm.audio:
120
+ media_list.append(InputMediaAudio(path, caption=caption_text))
121
+ else:
122
+ media_list.append(InputMediaDocument(path, caption=caption_text))
123
+
124
+ except Exception as e:
125
+ LOGGER(__name__).error(f"Error downloading group item {gm.id}: {e}")
126
+
127
+ if not media_list:
128
+ for p in downloaded_paths:
129
+ cleanup_download(p)
130
+ return False
131
+
132
+ try:
133
+ await bot.send_media_group(chat_id, media_list, reply_to_message_id=reply_to)
134
+ except Exception as e:
135
+ LOGGER(__name__).warning(f"send_media_group failed ({e}), sending individually…")
136
+ for path in downloaded_paths:
137
+ try:
138
+ await bot.send_document(chat_id, path, reply_to_message_id=reply_to)
139
+ except Exception as ie:
140
+ LOGGER(__name__).error(f"Individual send failed: {ie}")
141
+
142
+ for p in downloaded_paths:
143
+ cleanup_download(p)
144
+
145
+ return True