Files
TG-GDrive/bot.py
T
arsam 5fb6e77e43 Import source: bot
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:15:08 +03:30

2811 lines
114 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import re
import sys
import json
import asyncio
import logging
import secrets
import tempfile
import time
import collections
from datetime import datetime
from urllib.parse import urlparse, unquote
from pathlib import Path
from dotenv import load_dotenv
import psutil
from pyrogram import Client, filters, idle
from pyrogram.types import (
Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton,
BotCommand,
)
load_dotenv()
API_ID = int(os.getenv("API_ID", "0"))
API_HASH = os.getenv("API_HASH", "").strip()
BOT_TOKEN = os.getenv("BOT_TOKEN", "").strip()
RCLONE_REMOTE = "gdrive:"
def classify_to_folder(file_name: str, mime_type: str = "") -> str:
ext = file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
if mime_type.startswith("video/") or ext in ("mp4","mkv","avi","mov","webm","flv"):
return "Videos"
if mime_type.startswith("audio/") or ext in ("mp3","flac","wav","ogg","m4a","aac"):
return "Music"
if mime_type.startswith("image/") or ext in ("jpg","jpeg","png","gif","webp","bmp","heic"):
return "Photos"
if ext in ("exe","msi","deb","rpm","dmg","appimage"):
return "Software"
if ext in ("zip","rar","7z","tar","gz","bz2","xz","zst"):
return "Archives"
if ext == "gguf" or ext in ("safetensors","pt","ckpt","pth"):
return "Models"
if ext == "zim":
return "ZIM"
if ext in ("conf","ovpn","cfg","ini"):
return "Config"
if ext in ("pdf","doc","docx","txt","md","csv","xlsx","pptx"):
return "Docs"
if ext == "bin":
return "Bin"
return "Other"
LOG_FILE = Path(__file__).parent / "bot.log"
USERS_FILE = Path(__file__).parent / "authorized_users.json"
STREAM_THRESHOLD = 2 * (1 << 30) # 2 GB
QUOTAS_FILE = Path(__file__).parent / "quotas.json"
# ── Persisted authorized users ────────────────────────────────────────────────
def _load_users() -> set:
try:
return set(json.loads(USERS_FILE.read_text()))
except Exception:
return {"coaxys"}
def _save_users(users: set):
USERS_FILE.write_text(json.dumps(sorted(users)))
AUTHORIZED_USERS: set = _load_users()
# ── Quota system ──────────────────────────────────────────────────────────────
# quotas.json schema:
# { "quotas": {"username": <bytes>}, "usage": {"username": {"YYYY-MM-DD": <bytes>}} }
def _load_quotas() -> dict:
try:
return json.loads(QUOTAS_FILE.read_text())
except Exception:
return {"quotas": {}, "usage": {}}
def _save_quotas(data: dict):
QUOTAS_FILE.write_text(json.dumps(data, indent=2))
def parse_size(s: str) -> int | None:
"""Parse '5GB', '500MB', '2.5GB', '100KB' → bytes. Returns None on failure."""
m = re.fullmatch(r'([\d.]+)\s*(B|KB|MB|GB|TB)', s.strip(), re.IGNORECASE)
if not m:
return None
n, unit = float(m.group(1)), m.group(2).upper()
mul = {"B": 1, "KB": 1<<10, "MB": 1<<20, "GB": 1<<30, "TB": 1<<40}
return int(n * mul[unit])
def today() -> str:
return datetime.utcnow().strftime("%Y-%m-%d")
def get_quota(username: str) -> int | None:
"""Daily quota in bytes, or None if not set."""
return _load_quotas()["quotas"].get(username.lower())
def get_usage_today(username: str) -> int:
return _load_quotas()["usage"].get(username.lower(), {}).get(today(), 0)
def quota_remaining(username: str) -> int | None:
"""Returns remaining bytes today, or None if no quota set."""
q = get_quota(username)
if q is None:
return None
return max(0, q - get_usage_today(username))
def record_upload(username: str, size_bytes: int):
"""Add size_bytes to today's usage for username."""
data = _load_quotas()
u = username.lower()
data["usage"].setdefault(u, {})
data["usage"][u][today()] = data["usage"][u].get(today(), 0) + size_bytes
_save_quotas(data)
def has_upload_access(message: Message) -> tuple[bool, str]:
"""
Returns (allowed, reason).
Auth users always pass. Others need a quota with remaining bytes.
"""
u = (message.from_user.username or "").lower() if message.from_user else ""
if u in AUTHORIZED_USERS:
return True, "auth"
q = get_quota(u)
if q is None:
return False, "no_access"
rem = quota_remaining(u)
if rem == 0:
return False, "quota_exceeded"
return True, "quota"
BOT_START_TIME = time.time()
session_uploads = 0
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO
)
logger = logging.getLogger(__name__)
app = Client(
"gdrive_bot",
api_id=API_ID,
api_hash=API_HASH,
bot_token=BOT_TOKEN,
)
MEDIA_TYPES = (
"document", "video", "audio", "voice",
"photo", "animation", "video_note", "sticker"
)
URL_RE = re.compile(r'https?://\S+', re.IGNORECASE)
GDRIVE_FILE_RE = re.compile(r'drive\.google\.com/file/d/([a-zA-Z0-9_-]+)')
GDRIVE_UC_RE = re.compile(r'drive\.google\.com/uc\?.*[?&]id=([a-zA-Z0-9_-]+)')
GDRIVE_OPEN_RE = re.compile(r'drive\.google\.com/open\?.*id=([a-zA-Z0-9_-]+)')
def extract_gdrive_id(url: str) -> str | None:
for pat in (GDRIVE_FILE_RE, GDRIVE_UC_RE, GDRIVE_OPEN_RE):
m = pat.search(url)
if m:
return m.group(1)
return None
# ── yt-dlp media sites ────────────────────────────────────────────────────────
# URLs from these domains are handed to yt-dlp instead of rclone copyurl.
# Downloads land in gdrive:MEDIA-DLS/<category>/ (NSFW sites → MEDIA-DLS/NSFW).
MEDIA_DLS_DIR = "MEDIA-DLS"
COOKIES_FILE = Path(__file__).parent / "cookies.txt"
# ── Torrents & Telegram delivery ──────────────────────────────────────────────
# /tmp is RAM-backed tmpfs here, so torrents (which must hit disk — BitTorrent
# delivers pieces out of order, no RAM-stream path) use a real-disk work dir.
TORRENTS_DIR = "Torrents" # Drive destination root
WORK_DIR = Path(__file__).parent / "dl_tmp" # on /dev/sda1, NOT tmpfs
TORRENT_STREAM_THRESHOLD = 8 * (1 << 30) # ≥ 8 GB → per-file mode
DISK_HEADROOM = 2 * (1 << 30) # keep 2 GB free
TG_SEND_LIMIT = 2000 * (1 << 20) # Telegram bot upload cap (~2 GB)
MAGNET_RE = re.compile(r'magnet:\?xt=urn:[a-z0-9]+:[^\s]+', re.IGNORECASE)
VIDEO_SEND_EXTS = (".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v")
AUDIO_SEND_EXTS = (".mp3", ".m4a", ".flac", ".ogg", ".opus", ".wav", ".aac")
IMAGE_SEND_EXTS = (".jpg", ".jpeg", ".png", ".gif", ".webp")
YTDLP_DOMAINS = {
"youtube.com", "youtu.be", # videos, shorts, playlists, music
"instagram.com", # posts, reels, stories (needs cookies)
"tiktok.com", # videos, users, live
"twitter.com", "x.com", "t.co", # videos, spaces, broadcasts
"facebook.com", "fb.watch", # videos, reels
"reddit.com", "redd.it", # posts, v.redd.it
"pinterest.com", "pin.it", # video pins
"tumblr.com",
"linkedin.com", # posts, events
"aparat.com",
}
NSFW_DOMAINS = {
"pornhub.com", "xvideos.com", "xhamster.com",
"youporn.com", "redtube.com", "spankbang.com",
"redgifs.com",
}
# Spotify links are handled by spotdl (metadata from Spotify, audio fetched
# from YouTube Music — needs cookies.txt while this server's IP is YT-flagged).
SPOTIFY_DOMAINS = {"open.spotify.com", "play.spotify.com", "spotify.link"}
def spotify_match(url: str) -> bool:
try:
host = urlparse(url).netloc.lower().split(":")[0]
except Exception:
return False
return any(host == d or host.endswith("." + d) for d in SPOTIFY_DOMAINS)
def ytdlp_match(url: str) -> tuple[bool, bool]:
"""Returns (handled_by_ytdlp, is_nsfw) based on the URL's domain."""
try:
host = urlparse(url).netloc.lower().split(":")[0]
except Exception:
return False, False
for d in NSFW_DOMAINS:
if host == d or host.endswith("." + d):
return True, True
for d in YTDLP_DOMAINS:
if host == d or host.endswith("." + d):
return True, False
return False, False
def media_dls_folder(file_name: str, nsfw: bool) -> str:
"""Drive folder (relative to remote root) for a yt-dlp download."""
if nsfw:
return f"{MEDIA_DLS_DIR}/NSFW"
return f"{MEDIA_DLS_DIR}/{classify_to_folder(file_name)}"
# ── Utilities ─────────────────────────────────────────────────────────────────
def get_media(message: Message):
for attr in MEDIA_TYPES:
m = getattr(message, attr, None)
if m:
return attr, m
return None, None
def sanitize_filename(name: str) -> str:
"""Clean up filenames from bots/channels: strip emoji, newlines, garbage chars."""
# Normalise unicode (NFKC collapses weird lookalikes)
import unicodedata
name = unicodedata.normalize("NFKC", name)
# Control chars and newlines → space
name = re.sub(r'[\x00-\x1f\x7f]', ' ', name)
# Common garbage from forwarded bot messages
name = name.replace('→', '').replace('↵', '').replace('␊', '')
# Characters illegal in filenames on most filesystems
name = re.sub(r'[<>:"/\\|?*]', '_', name)
# Emoji: remove everything in supplementary Unicode planes (U+1F000+) and misc symbol blocks
name = re.sub(
r'[\U0001F000-\U0010FFFF' # supplementary planes — most emoji live here
r'☀-➿' # misc symbols, dingbats
r'⌀-⏿' # misc technical
r'︀-️' # variation selectors
r'​-‏' # zero-width / directional chars
r' -]', # general punctuation extras
'', name, flags=re.UNICODE
)
# Collapse runs of whitespace/underscores to a single underscore
name = re.sub(r'[\s_]+', '_', name).strip('_. ')
# Truncate stem to 180 chars but keep extension intact
root, ext = os.path.splitext(name)
root = root[:180].rstrip('_. ')
name = (root + ext).strip()
# Final fallback
return name or f"file_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
def resolve_filename(media_type: str, media, caption: str | None) -> str:
# Caption overrides — rename before upload
if caption and caption.strip():
name = caption.strip()
orig = getattr(media, "file_name", None)
if orig and "." in orig and "." not in name:
name += os.path.splitext(orig)[1]
return sanitize_filename(name)
name = getattr(media, "file_name", None)
if name:
return sanitize_filename(name)
ext = {
"photo": ".jpg", "voice": ".ogg", "video_note": ".mp4",
"sticker": ".webp", "animation": ".mp4", "video": ".mp4", "audio": ".mp3",
}
return f"file_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext.get(media_type, '.bin')}"
def filename_from_url(url: str) -> str:
path = unquote(urlparse(url).path)
name = os.path.basename(path.rstrip("/"))
if not name or "." not in name:
name = f"download_{datetime.now().strftime('%Y%m%d_%H%M%S')}.bin"
return sanitize_filename(name)
def fmt_size(b: int) -> str:
if b >= 1 << 30: return f"{b / (1<<30):.2f} GB"
if b >= 1 << 20: return f"{b / (1<<20):.1f} MB"
if b >= 1 << 10: return f"{b / (1<<10):.1f} KB"
return f"{b} B"
def fmt_dur(s: float) -> str:
s = int(s)
if s < 60: return f"{s}s"
if s < 3600: return f"{s//60}m {s%60:02d}s"
return f"{s//3600}h {(s%3600)//60}m"
def fmt_ram() -> str:
vm = psutil.virtual_memory()
return f"{fmt_size(vm.used)} / {fmt_size(vm.total)} ({vm.percent:.0f}%)"
def spd(bps: float) -> str:
return f"{fmt_size(int(bps))}/s" if bps > 0 else "—"
def choose_chunk(file_bytes: int) -> str:
if file_bytes == 0: return "256M"
if file_bytes < 10 * (1<<20): return "8M"
if file_bytes < 100 * (1<<20): return "64M"
if file_bytes < 500 * (1<<20): return "256M"
return "512M"
async def get_drive_entry(file_name: str, folder: str | None = None) -> dict | None:
"""lsjson metadata for a file — direct path lookup when the folder is
known (fast), recursive scan as fallback."""
try:
if folder:
proc = await asyncio.create_subprocess_exec(
"rclone", "lsjson", f"{RCLONE_REMOTE}{folder}/{file_name}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await proc.communicate()
if proc.returncode == 0:
files = json.loads(out.decode())
if files:
entry = files[0]
entry["Path"] = f"{folder}/{file_name}"
return entry
proc = await asyncio.create_subprocess_exec(
"rclone", "lsjson", RCLONE_REMOTE,
"--recursive", "--files-only",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await proc.communicate()
for f in json.loads(out.decode()):
if f["Name"] == file_name:
return f
return None
except Exception as e:
logger.warning(f"lsjson: {e}")
return None
async def get_drive_file_id(file_name: str) -> str | None:
entry = await get_drive_entry(file_name)
return entry["ID"] if entry else None
# ── Inline file-action buttons ────────────────────────────────────────────────
# Every uploaded file gets a short token; callbacks look up the token in
# FILE_ACTIONS (persisted, so buttons survive restarts).
ACTIONS_FILE = Path(__file__).parent / "file_actions.json"
def _load_actions() -> dict:
try:
return json.loads(ACTIONS_FILE.read_text())
except Exception:
return {}
FILE_ACTIONS: dict = _load_actions()
def _save_actions():
try:
while len(FILE_ACTIONS) > 500: # cap registry size
FILE_ACTIONS.pop(next(iter(FILE_ACTIONS)))
ACTIONS_FILE.write_text(json.dumps(FILE_ACTIONS))
except Exception as e:
logger.warning(f"actions save: {e}")
def register_file_action(name: str, folder: str, owner: str) -> str:
tok = secrets.token_hex(4)
FILE_ACTIONS[tok] = {"name": name, "folder": folder, "owner": owner}
_save_actions()
return tok
MOVE_TARGETS = [
"Videos", "Music", "Photos", "Docs",
"Archives", "Software", "Models", "Other",
f"{MEDIA_DLS_DIR}/Videos", f"{MEDIA_DLS_DIR}/Music", f"{MEDIA_DLS_DIR}/NSFW",
]
def file_buttons(tok: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[InlineKeyboardButton("🌍 Share", callback_data=f"share:{tok}"),
InlineKeyboardButton("ℹ️ Info", callback_data=f"info:{tok}")],
[InlineKeyboardButton("📂 Move", callback_data=f"mv:{tok}"),
InlineKeyboardButton("🗑 Delete", callback_data=f"del:{tok}")],
[InlineKeyboardButton("📥 Send to Telegram", callback_data=f"get:{tok}")],
])
def move_keyboard(tok: str) -> InlineKeyboardMarkup:
rows, row = [], []
for f in MOVE_TARGETS:
row.append(InlineKeyboardButton(f"📁 {f}", callback_data=f"mvto:{tok}:{f}"))
if len(row) == 2:
rows.append(row)
row = []
if row:
rows.append(row)
rows.append([InlineKeyboardButton("↩️ Cancel", callback_data=f"back:{tok}")])
return InlineKeyboardMarkup(rows)
def del_confirm_keyboard(tok: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([[
InlineKeyboardButton("✅ Yes, delete", callback_data=f"delc:{tok}"),
InlineKeyboardButton("↩️ Cancel", callback_data=f"back:{tok}"),
]])
async def send_links(message: Message, file_name: str,
folder: str | None = None, owner: str = ""):
entry = await get_drive_entry(file_name, folder)
if not entry or not entry.get("ID"):
return
file_id = entry["ID"]
real_folder = folder or os.path.dirname(entry.get("Path", "")) or "Other"
tok = register_file_action(file_name, real_folder, owner)
dl = f"https://drive.google.com/uc?export=download&id={file_id}"
view = f"https://drive.google.com/file/d/{file_id}/view"
wget = f"wget -O \"{file_name}\" \"{dl}\""
await message.reply_text(
f"🔗 **Links** — `{file_name}`\n\n"
f"📥 **Direct download:**\n`{dl}`\n\n"
f"👁 **View on Drive:**\n`{view}`\n\n"
f"🖥 **wget:**\n`{wget}`",
disable_web_page_preview=True,
reply_markup=file_buttons(tok),
)
# ── Auth ──────────────────────────────────────────────────────────────────────
def is_authorized(message: Message) -> bool:
u = message.from_user
if not u:
return False
return (u.username or "").lower() in AUTHORIZED_USERS
async def deny(message: Message):
await message.reply_text("⛔ You're not authorized to use this command.")
# ── /start ────────────────────────────────────────────────────────────────────
@app.on_message(filters.command("start"))
async def cmd_start(client: Client, message: Message):
vm = psutil.virtual_memory()
await message.reply_text(
"**GDrive Upload Bot** ⚡\n"
f"🖥 RAM: {fmt_size(vm.available)} free · 📡 MTProto + rclone + yt-dlp + aria2\n"
"━━━━━━━━━━━━━━━━━━━━\n\n"
+ USAGE_TUTORIAL +
"\n\n✏️ _Caption a file to rename it. After each upload you get a "
"download link plus buttons to Share / Move / Delete / send it back._\n\n"
"━━━━━━━━━━━━━━━━━━━━\n"
"Made by @Coaxys",
disable_web_page_preview=True,
)
# ── /folders ──────────────────────────────────────────────────────────────────
FOLDER_LIST = ["Videos", "Music", "Photos", "Software", "Archives", "Models",
"ZIM", "Config", "Docs", "Bin", "Other"]
@app.on_message(filters.command("folders"))
async def cmd_folders(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
desc = {
"Videos": "mp4, mkv, avi, mov, webm, flv",
"Music": "mp3, flac, wav, ogg, m4a, aac",
"Photos": "jpg, jpeg, png, gif, webp, bmp, heic",
"Software": "exe, msi, deb, rpm, dmg, appimage",
"Archives": "zip, rar, 7z, tar, gz, bz2, xz, zst",
"Models": "gguf, safetensors, pt, ckpt, pth",
"ZIM": "zim",
"Config": "conf, ovpn, cfg, ini",
"Docs": "pdf, doc, docx, txt, md, csv, xlsx, pptx",
"Bin": "bin",
"Other": "everything else",
}
lines = [f"📁 **{f}**\n `{desc.get(f, '')}`" for f in FOLDER_LIST]
await message.reply_text(
"📂 **Available Drive folders**\n\n"
+ "\n\n".join(lines) +
"\n\n_↳ Files are auto-sorted by extension / MIME type._"
)
# ── /list ────────────────────────────────────────────────────────────────────
@app.on_message(filters.command("list"))
async def cmd_list(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
wait = await message.reply_text("📂 Fetching latest files from Drive...")
try:
proc = await asyncio.create_subprocess_exec(
"rclone", "lsjson", RCLONE_REMOTE,
"--recursive", "--files-only",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(err.decode().strip())
files = json.loads(out.decode())
files.sort(key=lambda x: x["ModTime"], reverse=True)
top = files[:10]
total = len(files)
lines = []
for i, f in enumerate(top, 1):
size = f["Size"]
if size >= 1<<30: s = f"{size/(1<<30):.2f} GB"
elif size >= 1<<20: s = f"{size/(1<<20):.1f} MB"
elif size >= 1<<10: s = f"{size/(1<<10):.1f} KB"
else: s = f"{size} B"
date = f["ModTime"][:10]
name = f["Name"]
if len(name) > 40:
name = name[:37] + "..."
fid = f["ID"]
folder = f["Path"].split("/")[0] if "/" in f["Path"] else "root"
link = f"https://drive.google.com/file/d/{fid}/view"
lines.append(f"`{i:2}.` [{name}]({link})\n 📁 {folder} · 📦 {s} · 📅 {date}")
body = "\n\n".join(lines)
await wait.edit_text(
f"📂 **Latest {len(top)} of {total} files in Drive**\n\n{body}",
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"/list error: {e}")
await wait.edit_text(f"❌ Failed to fetch file list\n\n`{e}`")
# ── /listall ─────────────────────────────────────────────────────────────────
@app.on_message(filters.command("listall"))
async def cmd_listall(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
wait = await message.reply_text("📂 Fetching all files from Drive...")
try:
proc = await asyncio.create_subprocess_exec(
"rclone", "lsjson", RCLONE_REMOTE,
"--recursive", "--files-only",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(err.decode().strip())
files = json.loads(out.decode())
files.sort(key=lambda x: x["ModTime"], reverse=True)
if not files:
return await wait.edit_text("📂 Drive is empty.")
total_size = sum(f["Size"] for f in files)
def human(b):
if b >= 1<<30: return f"{b/(1<<30):.2f} GB"
if b >= 1<<20: return f"{b/(1<<20):.1f} MB"
if b >= 1<<10: return f"{b/(1<<10):.1f} KB"
return f"{b} B"
# Build lines and split into ≤4000-char pages
header = (
f"📂 **All files in Drive** ({len(files)} files · {human(total_size)} total)\n"
f"{'━'*22}\n\n"
)
pages = []
current = header
for i, f in enumerate(files, 1):
name = f["Name"]
disp = name if len(name) <= 45 else name[:42] + "..."
link = f"https://drive.google.com/file/d/{f['ID']}/view"
line = f"`{i:3}.` [{disp}]({link})\n 📦 {human(f['Size'])} · 📅 {f['ModTime'][:10]}\n\n"
if len(current) + len(line) > 4000:
pages.append(current)
current = line
else:
current += line
if current.strip():
pages.append(current)
# Edit first message, send rest as new replies
await wait.edit_text(
pages[0] + (f"\n_Page 1 of {len(pages)}_" if len(pages) > 1 else ""),
disable_web_page_preview=True,
)
for idx, page in enumerate(pages[1:], 2):
await message.reply_text(
page + f"\n_Page {idx} of {len(pages)}_",
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"/listall error: {e}")
await wait.edit_text(f"❌ Failed to fetch file list\n\n`{e}`")
# ── /status ───────────────────────────────────────────────────────────────────
@app.on_message(filters.command("status"))
async def cmd_status(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
uptime = time.time() - BOT_START_TIME
vm = psutil.virtual_memory()
disk = psutil.disk_usage("/")
cpu = psutil.cpu_percent(interval=0.5)
def bar(pct: float, width: int = 10) -> str:
filled = int(pct / 100 * width)
return "█" * filled + "░" * (width - filled)
await message.reply_text(
f"📊 **Bot Status**\n\n"
f"⏱ **Uptime:** {fmt_dur(uptime)}\n"
f"📤 **Uploads this session:** {session_uploads}\n\n"
f"🖥 **CPU:** {cpu:.1f}% `[{bar(cpu)}]`\n\n"
f"💾 **RAM:**\n"
f" Used: {fmt_size(vm.used)} / {fmt_size(vm.total)}\n"
f" Free: {fmt_size(vm.available)}\n"
f" `[{bar(vm.percent)}]` {vm.percent:.1f}%\n\n"
f"🗄 **Disk (/):**\n"
f" Used: {fmt_size(disk.used)} / {fmt_size(disk.total)}\n"
f" Free: {fmt_size(disk.free)}\n"
f" `[{bar(disk.percent)}]` {disk.percent:.1f}%\n\n"
f"📡 **Method:** MTProto + rclone\n"
f"🔀 **Stream threshold:** ≥ 2 GB"
)
# ── /setquota ────────────────────────────────────────────────────────────────
@app.on_message(filters.command("setquota"))
async def cmd_setquota(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
parts = message.command
if len(parts) < 3:
return await message.reply_text(
"Usage: `/setquota @username <size>`\n"
"Examples: `/setquota @john 5GB` · `/setquota @john 500MB`"
)
username = parts[1].lstrip("@").lower()
size_bytes = parse_size(parts[2])
if size_bytes is None:
return await message.reply_text(
"❌ Invalid size. Use formats like `5GB`, `500MB`, `2.5GB`, `100KB`."
)
data = _load_quotas()
data["quotas"][username] = size_bytes
_save_quotas(data)
logger.info(f"Quota set: {username} → {fmt_size(size_bytes)}/day")
await message.reply_text(
f"✅ Daily quota set for `@{username}`\n\n"
f"📦 **Limit:** {fmt_size(size_bytes)} / day\n"
f"🔄 Resets at midnight UTC"
)
# ── /clearquota ───────────────────────────────────────────────────────────────
@app.on_message(filters.command("clearquota"))
async def cmd_clearquota(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
parts = message.command
if len(parts) < 2:
return await message.reply_text("Usage: `/clearquota @username`")
username = parts[1].lstrip("@").lower()
data = _load_quotas()
if username not in data["quotas"]:
return await message.reply_text(f"ℹ️ `@{username}` has no quota set.")
del data["quotas"][username]
data["usage"].pop(username, None)
_save_quotas(data)
logger.info(f"Quota cleared: {username}")
await message.reply_text(f"✅ Quota and usage history cleared for `@{username}`.")
# ── /quotas ───────────────────────────────────────────────────────────────────
@app.on_message(filters.command("quotas"))
async def cmd_quotas(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
data = _load_quotas()
if not data["quotas"]:
return await message.reply_text("📋 No quotas configured yet.\n\nUse `/setquota @user <size>` to add one.")
lines = []
for username, limit in sorted(data["quotas"].items()):
used = get_usage_today(username)
remaining = max(0, limit - used)
pct = used / limit * 100 if limit else 0
bar_n = int(pct / 10)
bar = "█" * bar_n + "░" * (10 - bar_n)
lines.append(
f"👤 `@{username}`\n"
f" `[{bar}]` {pct:.1f}%\n"
f" 📦 Used: {fmt_size(used)} / {fmt_size(limit)}\n"
f" ✅ Left: {fmt_size(remaining)}"
)
await message.reply_text(
f"📋 **Quota Overview** — {today()} UTC\n\n"
+ "\n\n".join(lines) +
"\n\n🔄 Resets at midnight UTC"
)
# ── /myquota ──────────────────────────────────────────────────────────────────
@app.on_message(filters.command("myquota"))
async def cmd_myquota(client: Client, message: Message):
u = (message.from_user.username or "").lower() if message.from_user else ""
if not u:
return await message.reply_text("❌ Could not determine your username.")
if u in AUTHORIZED_USERS:
return await message.reply_text("✅ You're an **authorized user** — no quota limits apply to you.")
limit = get_quota(u)
if limit is None:
return await message.reply_text("🔒 You don't have a quota assigned. Contact an admin.")
used = get_usage_today(u)
remaining = max(0, limit - used)
pct = used / limit * 100 if limit else 0
bar_n = int(pct / 10)
bar = "█" * bar_n + "░" * (10 - bar_n)
await message.reply_text(
f"📊 **Your Quota — {today()} UTC**\n\n"
f"`[{bar}]` {pct:.1f}%\n\n"
f"📦 Used: {fmt_size(used)}\n"
f"✅ Remaining: {fmt_size(remaining)}\n"
f"🔒 Daily limit: {fmt_size(limit)}\n\n"
f"🔄 Resets at midnight UTC"
)
# ── /get ──────────────────────────────────────────────────────────────────────
async def _find_drive_file(query: str) -> tuple[dict | None, list[dict], str]:
"""Look up a Drive file by exact-or-partial name. Returns (match, candidates, error)."""
try:
proc = await asyncio.create_subprocess_exec(
"rclone", "lsjson", RCLONE_REMOTE, "--recursive", "--files-only",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
return None, [], err.decode().strip()
files = json.loads(out.decode())
exact = [f for f in files if f["Name"].lower() == query.lower()]
partial = [f for f in files if query.lower() in f["Name"].lower()]
matches = exact or partial
if not matches:
return None, [], ""
if len(matches) > 1 and not exact:
return None, matches, ""
return matches[0], [], ""
except Exception as e:
return None, [], str(e)
@app.on_message(filters.command("get"))
async def cmd_get(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
query = " ".join(message.command[1:]).strip()
if not query:
return await message.reply_text(
"Usage: `/get <filename or keyword>`\n\n"
"Pulls a file **from** Drive and sends it back here.\n"
"Partial names work — e.g. `/get holiday` matches `holiday-2026.mp4`.\n"
f"Max size {fmt_size(TG_SEND_LIMIT)} (Telegram limit)."
)
wait = await message.reply_text(f"🔍 Looking for `{query}` in Drive...")
match, candidates, err = await _find_drive_file(query)
if err:
return await wait.edit_text(f"❌ Drive lookup failed\n\n`{err}`")
if candidates:
names = "\n".join(f"• `{f['Name']}`" for f in candidates[:8])
more = f"\n_…and {len(candidates)-8} more_" if len(candidates) > 8 else ""
return await wait.edit_text(
f"⚠️ **Multiple matches for** `{query}`\n\n{names}{more}\n\nBe more specific."
)
if not match:
return await wait.edit_text(f"❌ No file matching `{query}` found in Drive.")
folder = os.path.dirname(match.get("Path", "")) or "Other"
await wait.delete()
await deliver_to_telegram(client, message, match["Name"], folder)
# ── /share ───────────────────────────────────────────────────────────────────
@app.on_message(filters.command("share"))
async def cmd_share(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
query = " ".join(message.command[1:]).strip()
if not query:
return await message.reply_text(
"Usage: `/share <filename or keyword>`\n\n"
"Makes the file publicly accessible and returns a shareable link.\n"
"Tip: partial name works — e.g. `/share Mitm` matches `Mitm-Yt.mp4`"
)
wait = await message.reply_text(f"🔍 Looking for `{query}` in Drive...")
# Find the file by exact or partial name match
try:
proc = await asyncio.create_subprocess_exec(
"rclone", "lsjson", RCLONE_REMOTE,
"--recursive", "--files-only",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(err.decode().strip())
files = json.loads(out.decode())
# Prefer exact match, fall back to partial
exact = [f for f in files if f["Name"].lower() == query.lower()]
partial = [f for f in files if query.lower() in f["Name"].lower()]
matches = exact or partial
if not matches:
return await wait.edit_text(f"❌ No file matching `{query}` found in Drive.")
if len(matches) > 1 and not exact:
names = "\n".join(f"• `{f['Name']}`" for f in matches[:8])
more = f"\n_…and {len(matches)-8} more_" if len(matches) > 8 else ""
return await wait.edit_text(
f"⚠️ **Multiple matches for** `{query}`\n\n{names}{more}\n\n"
f"Be more specific to share a single file."
)
target = matches[0]
name = target["Name"]
path = target["Path"] # includes subfolder, e.g. "Videos/filename.mp4"
size = target["Size"]
except Exception as e:
logger.error(f"/share lookup error: {e}")
return await wait.edit_text(f"❌ Drive lookup failed\n\n`{e}`")
await wait.edit_text(f"🔗 Making `{name}` public...")
# Generate share link via rclone link
try:
proc = await asyncio.create_subprocess_exec(
"rclone", "link", f"{RCLONE_REMOTE}{path}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(err.decode().strip() or out.decode().strip())
share_url = out.decode().strip()
file_id = share_url.split("id=")[-1] if "id=" in share_url else None
except Exception as e:
logger.error(f"/share rclone link error: {e}")
return await wait.edit_text(f"❌ Failed to generate share link\n\n`{e}`")
def human(b):
if b >= 1<<30: return f"{b/(1<<30):.2f} GB"
if b >= 1<<20: return f"{b/(1<<20):.1f} MB"
if b >= 1<<10: return f"{b/(1<<10):.1f} KB"
return f"{b} B"
dl_link = f"https://drive.google.com/uc?export=download&id={file_id}" if file_id else None
wget_line = f"`wget -O \"{name}\" \"{dl_link}\"`" if dl_link else ""
await wait.edit_text(
f"✅ **File is now public**\n\n"
f"📎 **{name}**\n"
f"📦 {human(size)}\n\n"
f"🔗 **Share link:**\n`{share_url}`\n\n"
+ (f"📥 **Direct download:**\n`{dl_link}`\n\n" if dl_link else "")
+ (f"🖥 **wget:**\n{wget_line}" if wget_line else ""),
disable_web_page_preview=True,
)
# ── /search ───────────────────────────────────────────────────────────────────
@app.on_message(filters.command("search"))
async def cmd_search(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
query = " ".join(message.command[1:]).strip().lower()
if not query:
return await message.reply_text("Usage: `/search <filename or keyword>`")
wait = await message.reply_text(f"🔍 Searching for `{query}`...")
try:
proc = await asyncio.create_subprocess_exec(
"rclone", "lsjson", RCLONE_REMOTE,
"--recursive", "--files-only",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(err.decode().strip())
files = json.loads(out.decode())
hits = [f for f in files if query in f["Name"].lower()]
hits.sort(key=lambda x: x["ModTime"], reverse=True)
if not hits:
return await wait.edit_text(f"🔍 No files matching `{query}` found in Drive.")
lines = []
for i, f in enumerate(hits[:15], 1):
size = f["Size"]
if size >= 1<<30: s = f"{size/(1<<30):.2f} GB"
elif size >= 1<<20: s = f"{size/(1<<20):.1f} MB"
elif size >= 1<<10: s = f"{size/(1<<10):.1f} KB"
else: s = f"{size} B"
name = f["Name"]
folder = f["Path"].split("/")[0] if "/" in f["Path"] else "root"
disp = name if len(name) <= 38 else name[:35] + "..."
link = f"https://drive.google.com/file/d/{f['ID']}/view"
lines.append(
f"`{i:2}.` [{disp}]({link})\n"
f" 📁 {folder} · 📦 {s} · 📅 {f['ModTime'][:10]}"
)
more = f"\n\n_…and {len(hits)-15} more_" if len(hits) > 15 else ""
await wait.edit_text(
f"🔍 **{len(hits)} result(s) for** `{query}`\n\n" + "\n\n".join(lines) + more,
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"/search error: {e}")
await wait.edit_text(f"❌ Search failed\n\n`{e}`")
# ── /stats ───────────────────────────────────────────────────────────────────
@app.on_message(filters.command("stats"))
async def cmd_stats(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
query = " ".join(message.command[1:]).strip()
if not query:
return await message.reply_text(
"Usage: `/stats <filename or keyword>`\n\n"
"Shows Drive file info: size, modified date, MIME type, folder path."
)
wait = await message.reply_text(f"🔍 Looking up `{query}`...")
try:
proc = await asyncio.create_subprocess_exec(
"rclone", "lsjson", RCLONE_REMOTE,
"--recursive", "--files-only",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(err.decode().strip())
files = json.loads(out.decode())
exact = [f for f in files if f["Name"].lower() == query.lower()]
partial = [f for f in files if query.lower() in f["Name"].lower()]
matches = exact or partial
if not matches:
return await wait.edit_text(f"❌ No file matching `{query}` found in Drive.")
if len(matches) > 1 and not exact:
names = "\n".join(f"• `{f['Name']}`" for f in matches[:8])
more = f"\n_…and {len(matches)-8} more_" if len(matches) > 8 else ""
return await wait.edit_text(
f"⚠️ **Multiple matches for** `{query}`\n\n{names}{more}\n\n"
f"Be more specific."
)
f = matches[0]
size = f["Size"]
if size >= 1<<30: s = f"{size/(1<<30):.2f} GB"
elif size >= 1<<20: s = f"{size/(1<<20):.1f} MB"
elif size >= 1<<10: s = f"{size/(1<<10):.1f} KB"
else: s = f"{size} B"
folder = f["Path"].split("/")[0] if "/" in f["Path"] else "root"
link = f"https://drive.google.com/file/d/{f['ID']}/view"
mtime = f["ModTime"][:19].replace("T", " ")
mime = f.get("MimeType", "—")
await wait.edit_text(
f"📄 **File Info**\n\n"
f"**{f['Name']}**\n\n"
f"📦 Size: {s}\n"
f"📅 Modified: {mtime}\n"
f"🗂 MIME: `{mime}`\n"
f"📁 Drive path: `{folder}/`\n"
f"🔗 View: {link}",
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"/stats error: {e}")
await wait.edit_text(f"❌ Stats lookup failed\n\n`{e}`")
# ── /delete ──────────────────────────────────────────────────────────────────
@app.on_message(filters.command("delete"))
async def cmd_delete(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
query = " ".join(message.command[1:]).strip()
if not query:
return await message.reply_text(
"Usage: `/delete <filename>`\n\n"
"Permanently deletes a file from Google Drive. Use `/search` to find the exact name."
)
wait = await message.reply_text(f"🔍 Looking for `{query}` to delete...")
try:
proc = await asyncio.create_subprocess_exec(
"rclone", "lsjson", RCLONE_REMOTE,
"--recursive", "--files-only",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(err.decode().strip())
files = json.loads(out.decode())
exact = [f for f in files if f["Name"].lower() == query.lower()]
partial = [f for f in files if query.lower() in f["Name"].lower()]
matches = exact or partial
if not matches:
return await wait.edit_text(f"❌ No file matching `{query}` found in Drive.")
if len(matches) > 1 and not exact:
names = "\n".join(f"• `{f['Name']}`" for f in matches[:8])
more = f"\n_…and {len(matches)-8} more_" if len(matches) > 8 else ""
return await wait.edit_text(
f"⚠️ **Multiple matches for** `{query}`\n\n{names}{more}\n\n"
f"Be more specific or use the full filename."
)
target = matches[0]
name = target["Name"]
path = target["Path"]
except Exception as e:
logger.error(f"/delete lookup error: {e}")
return await wait.edit_text(f"❌ Drive lookup failed\n\n`{e}`")
await wait.edit_text(f"🗑 Deleting `{name}`...")
try:
proc = await asyncio.create_subprocess_exec(
"rclone", "delete", f"{RCLONE_REMOTE}{path}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(err.decode().strip())
logger.info(f"Deleted: {path}")
await wait.edit_text(f"🗑 **Deleted**\n\n`{name}` has been removed from Google Drive.")
except Exception as e:
logger.error(f"/delete error: {e}")
await wait.edit_text(f"❌ Delete failed\n\n`{e}`")
# ── /df ──────────────────────────────────────────────────────────────────────
@app.on_message(filters.command("df"))
async def cmd_df(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
wait = await message.reply_text("📊 Checking Drive storage...")
try:
proc = await asyncio.create_subprocess_exec(
"rclone", "about", RCLONE_REMOTE, "--json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(err.decode().strip())
info = json.loads(out.decode())
total = info.get("total", 0)
used = info.get("used", 0)
free = info.get("free", 0)
# Trashed files are counted in "used". Also report "trashed" if available.
trashed = info.get("trashed", 0)
pct = used / total * 100 if total else 0
bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5))
lines = [
f"📦 **Drive Storage**\n",
f"`[{bar}]` {pct:.1f}%\n",
f"💾 Used: {fmt_size(used)}",
f"🟢 Free: {fmt_size(free)}",
f"📀 Total: {fmt_size(total)}",
]
if trashed:
lines.append(f"🗑 Trashed: {fmt_size(trashed)}")
await wait.edit_text("\n".join(lines))
except Exception as e:
logger.error(f"/df error: {e}")
# Fallback: rclone about on some backends returns nothing
await wait.edit_text(f"❌ Storage check failed\n\n`{e}`")
# ── /adduser & /removeuser ────────────────────────────────────────────────────
@app.on_message(filters.command("adduser"))
async def cmd_adduser(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
parts = message.command
if len(parts) < 2:
return await message.reply_text("Usage: `/adduser <username>`")
username = parts[1].lstrip("@").lower()
if username in AUTHORIZED_USERS:
return await message.reply_text(f"ℹ️ `@{username}` is already authorized.")
AUTHORIZED_USERS.add(username)
_save_users(AUTHORIZED_USERS)
logger.info(f"Added authorized user: {username}")
await message.reply_text(f"✅ `@{username}` added to authorized users.")
@app.on_message(filters.command("removeuser"))
async def cmd_removeuser(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
parts = message.command
if len(parts) < 2:
return await message.reply_text("Usage: `/removeuser <username>`")
username = parts[1].lstrip("@").lower()
if username not in AUTHORIZED_USERS:
return await message.reply_text(f"ℹ️ `@{username}` is not in the authorized list.")
AUTHORIZED_USERS.discard(username)
_save_users(AUTHORIZED_USERS)
logger.info(f"Removed authorized user: {username}")
await message.reply_text(f"✅ `@{username}` removed from authorized users.")
# ── /logs ─────────────────────────────────────────────────────────────────────
@app.on_message(filters.command("logs"))
async def cmd_logs(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
try:
lines = LOG_FILE.read_text(errors="replace").splitlines()
tail = "\n".join(lines[-50:])
# Send as file if content is large, otherwise inline
if len(tail) > 3800:
with tempfile.NamedTemporaryFile("w", suffix=".log",
delete=False, prefix="botlog_") as f:
f.write(tail)
tmp = f.name
await message.reply_document(tmp, caption="📄 Last 50 log lines")
os.remove(tmp)
else:
await message.reply_text(f"📄 **Last 50 log lines**\n\n```\n{tail}\n```")
except Exception as e:
await message.reply_text(f"❌ Could not read log\n\n`{e}`")
# ── /restart ──────────────────────────────────────────────────────────────────
@app.on_message(filters.command("restart"))
async def cmd_restart(client: Client, message: Message):
if not is_authorized(message):
return await deny(message)
await message.reply_text("♻️ Restarting bot... brb.")
logger.info("Restart requested via /restart")
async def do_restart():
await asyncio.sleep(1)
await client.stop()
os.execv(sys.executable, [sys.executable] + sys.argv)
asyncio.create_task(do_restart())
# ── /help ─────────────────────────────────────────────────────────────────────
@app.on_message(filters.command("help"))
async def cmd_help(client: Client, message: Message):
auth_note = (
"\n✅ You are an **authorized user**."
if is_authorized(message) else
"\n🔒 Commands require authorization to run."
)
await message.reply_text(
"**GDrive Bot — Commands**\n\n"
"━━━━━━━━━━━━━━━━━━━━\n"
"📂 **File & Transfer**\n"
"`/list` — Show the 10 most recent files in the Drive folder\n"
"`/listall` — List **every** file in the Drive folder (paginated)\n"
"`/search <query>` — Search files by name or keyword\n"
"`/share <filename>` — Make a file public and get a shareable link\n"
"`/get <filename>` — Pull a file **from** Drive back into this chat\n"
"`/stats <filename>` — Show detailed Drive file info (size, date, MIME, path)\n"
"`/delete <filename>` — Permanently delete a file from Drive\n\n"
"📁 **Folders**\n"
"`/folders` — List available Drive folders and which extensions go where\n\n"
"📊 **Info**\n"
"`/status` — Bot uptime, RAM, CPU, disk usage\n"
"`/df` — Show Google Drive storage usage (used, free, total)\n"
"`/logs` — Tail the last 50 lines of the bot log\n\n"
"👤 **User Management**\n"
"`/adduser <@username>` — Grant full (unlimited) upload access\n"
"`/removeuser <@username>` — Revoke full access\n\n"
"📊 **Quotas** _(for non-auth users)_\n"
"`/setquota @user <size>` — Set a daily upload quota (e.g. `5GB`, `500MB`)\n"
"`/clearquota @user` — Remove quota and reset usage\n"
"`/quotas` — Overview of all quotas and today's usage\n"
"`/myquota` — Check your own quota (any user)\n\n"
"⚙️ **System**\n"
"`/restart` — Gracefully restart the bot\n"
"`/help` — Show this message\n"
"`/start` — Show upload instructions\n\n"
"━━━━━━━━━━━━━━━━━━━━\n"
"**How to upload:**\n"
"📁 Send any file · 🌐 Paste a URL (or multiple!) · ✏️ Caption to rename\n"
"🔗 Google Drive shared links are auto-detected\n"
"🎬 Media links (YouTube, Instagram, TikTok, X, Reddit, Pinterest, "
"Tumblr, Facebook, LinkedIn, Aparat & more) are downloaded via yt-dlp "
f"into **{MEDIA_DLS_DIR}/**\n"
"🎵 Add the word `mp3` or `audio` after a media link to extract audio\n"
f"🎧 Spotify links (track/album/playlist) → spotdl → **{MEDIA_DLS_DIR}/Music**\n"
"🧲 Magnet links & `.torrent` files → aria2c → **Torrents/** (≥8 GB streams file-by-file)\n"
"🎛 Every upload gets buttons: 🌍 Share · ℹ️ Info · 📂 Move · 🗑 Delete · 📥 Send to TG\n\n"
"Made by @Coaxys"
+ auth_note
)
# ── Button callbacks ──────────────────────────────────────────────────────────
@app.on_callback_query()
async def handle_buttons(client: Client, cq: CallbackQuery):
parts = (cq.data or "").split(":", 2)
action = parts[0]
tok = parts[1] if len(parts) > 1 else ""
entry = FILE_ACTIONS.get(tok)
if not entry:
return await cq.answer("⚠️ These buttons expired.", show_alert=True)
user = (cq.from_user.username or "").lower() if cq.from_user else ""
if user not in AUTHORIZED_USERS and user != entry.get("owner", ""):
return await cq.answer("⛔ You can't manage this file.", show_alert=True)
name = entry["name"]
folder = entry["folder"]
rpath = f"{RCLONE_REMOTE}{folder}/{name}"
base = cq.message.text.markdown if (cq.message and cq.message.text) else "🔗 **Links**"
async def rclone(*args) -> tuple[bool, str]:
proc = await asyncio.create_subprocess_exec(
"rclone", *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
return proc.returncode == 0, (out.decode().strip() or err.decode().strip()
if proc.returncode == 0
else err.decode().strip() or out.decode().strip())
try:
if action == "share":
await cq.answer("🌍 Making public...")
ok, res = await rclone("link", rpath)
if not ok:
return await cq.answer(f"❌ Share failed: {res[:150]}", show_alert=True)
await cq.message.edit_text(
base + f"\n\n🌍 **Public link:**\n`{res}`",
disable_web_page_preview=True,
reply_markup=file_buttons(tok),
)
elif action == "info":
e = await get_drive_entry(name, folder)
if not e:
return await cq.answer("❓ Not found in Drive — moved or deleted?",
show_alert=True)
await cq.answer(
f"📎 {name[:70]}\n"
f"📦 {fmt_size(e.get('Size', 0))}\n"
f"🗂 {e.get('MimeType', '?')}\n"
f"📁 {folder}/\n"
f"🕐 {e.get('ModTime', '')[:19].replace('T', ' ')}",
show_alert=True,
)
elif action == "mv":
await cq.message.edit_reply_markup(move_keyboard(tok))
await cq.answer("📂 Pick a destination folder")
elif action == "mvto":
dest = parts[2] if len(parts) > 2 else ""
if not dest:
return await cq.answer("❌ Bad destination.", show_alert=True)
if dest == folder:
await cq.message.edit_reply_markup(file_buttons(tok))
return await cq.answer("Already there 😉")
await cq.answer(f"📂 Moving to {dest}/ ...")
ok, res = await rclone("moveto", rpath, f"{RCLONE_REMOTE}{dest}/{name}")
if not ok:
await cq.message.edit_reply_markup(file_buttons(tok))
return await cq.answer(f"❌ Move failed: {res[:150]}", show_alert=True)
entry["folder"] = dest
_save_actions()
await cq.message.edit_text(
base + f"\n\n📂 Moved: **{folder}/** → **{dest}/**",
disable_web_page_preview=True,
reply_markup=file_buttons(tok),
)
elif action == "del":
await cq.message.edit_reply_markup(del_confirm_keyboard(tok))
await cq.answer(f"⚠️ Delete “{name[:50]}” permanently?", show_alert=True)
elif action == "delc":
await cq.answer("🗑 Deleting...")
ok, res = await rclone("deletefile", rpath)
if not ok:
await cq.message.edit_reply_markup(file_buttons(tok))
return await cq.answer(f"❌ Delete failed: {res[:150]}", show_alert=True)
FILE_ACTIONS.pop(tok, None)
_save_actions()
await cq.message.edit_text(
base + "\n\n🗑 **Deleted from Drive** by "
f"@{cq.from_user.username or 'unknown'}.",
disable_web_page_preview=True,
reply_markup=None,
)
elif action == "get":
await cq.answer("📥 Fetching from Drive → Telegram...")
await deliver_to_telegram(client, cq.message, name, folder)
elif action == "back":
await cq.message.edit_reply_markup(file_buttons(tok))
await cq.answer()
else:
await cq.answer()
except Exception as e:
logger.error(f"button {action} failed: {e}")
try:
await cq.answer(f"❌ {str(e)[:150]}", show_alert=True)
except Exception:
pass
# ── URL handler ───────────────────────────────────────────────────────────────
@app.on_message(filters.text & ~filters.command(
["start", "help", "list", "listall", "status", "search", "share",
"adduser", "removeuser", "logs", "restart",
"setquota", "clearquota", "quotas", "myquota",
"folders", "delete", "stats", "df", "get"]
))
async def handle_url(client: Client, message: Message):
allowed, reason = has_upload_access(message)
if not allowed:
if reason == "quota_exceeded":
u = (message.from_user.username or "").lower()
limit = get_quota(u)
await message.reply_text(
f"🚫 **Daily quota exceeded**\n\n"
f"You've used your full {fmt_size(limit)} for today.\n"
f"🔄 Resets at midnight UTC."
)
return
text = message.text or ""
# Magnet links → torrent handler
magnets = MAGNET_RE.findall(text)
if magnets:
for i, mag in enumerate(magnets, 1):
await process_torrent(client, message, f"{i}/{len(magnets)}", magnet=mag)
return
urls = [u.rstrip(".,;)") for u in URL_RE.findall(text)]
if not urls:
return
# If multiple URLs found, process as batch
if len(urls) > 1:
batch_status = await message.reply_text(
f"📦 **Batch URL download**\n\n"
f"Found **{len(urls)} URLs**. Processing one by one..."
)
success = 0
fail = 0
for i, url in enumerate(urls, 1):
ok = await _process_url(client, message, url, f"{i}/{len(urls)}")
if ok:
success += 1
else:
fail += 1
await batch_status.edit_text(
f"📦 **Batch complete** — {success} ✅ / {fail} ❌\n"
f"_Check individual results above._"
)
return
# Single URL
await _process_url(client, message, urls[0], "1/1")
async def _process_url(client: Client, message: Message, url: str, seq: str) -> bool:
"""Process a single URL: media sites → yt-dlp, Google Drive links → direct
download, everything else → rclone copyurl. Returns True on success."""
if spotify_match(url):
return await _spotify_download(client, message, url, seq)
is_media, is_nsfw = ytdlp_match(url)
if is_media:
return await _ytdlp_download(client, message, url, seq, is_nsfw)
chunk = "512M"
total_start = time.time()
# Detect Google Drive shared links → use direct download URL
gdrive_id = extract_gdrive_id(url)
if gdrive_id:
dl_url = f"https://drive.google.com/uc?export=download&id={gdrive_id}"
# Try to get filename from content-disposition
file_name = await _fetch_gdrive_filename(gdrive_id)
if not file_name:
file_name = f"gdrive_{gdrive_id[:12]}"
# Try grabbing from the URL itself
from urllib.parse import unquote, urlparse
parsed = unquote(urlparse(url).path)
base = os.path.basename(parsed.rstrip("/"))
if base and "." in base:
file_name = sanitize_filename(base)
url_label = f"🔗 Google Drive file"
else:
dl_url = url
file_name = filename_from_url(url)
url_label = f"`{url[:80]}{'...' if len(url)>80 else ''}`"
status = await message.reply_text(
f"🌐 **{seq}** — URL received\n\n"
f"{url_label}\n\n"
f"📎 Saving as: **{file_name}**\n"
f"🧩 Chunk: {chunk}\n\n"
f"⏳ Starting rclone stream (URL → RAM → Drive)..."
)
folder = classify_to_folder(file_name)
rclone_cmd = [
"rclone", "copyurl", dl_url,
f"{RCLONE_REMOTE}{folder}/{file_name}",
"--drive-chunk-size", chunk,
"--buffer-size", "512M",
]
try:
proc = await asyncio.create_subprocess_exec(
*rclone_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except Exception as e:
await status.edit_text(f"❌ **rclone failed to start**\n\n`{e}`")
return False
# Pulse-update while rclone works (copyurl gives no byte-level progress)
start_ts = time.time()
dots = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"]
dot_i = 0
done_event = asyncio.Event()
async def pulse():
nonlocal dot_i
while not done_event.is_set():
elapsed = time.time() - start_ts
try:
await status.edit_text(
f"🌐 **{seq}** — Downloading & uploading...\n\n"
f"📎 **{file_name}**\n"
f"{dots[dot_i % len(dots)]} Elapsed: {fmt_dur(elapsed)}\n\n"
f"🧩 Chunk: {chunk} · 💾 Disk: **0 B**\n"
f"🖥 RAM: {fmt_ram()}"
)
except Exception:
pass
dot_i += 1
await asyncio.sleep(4)
pulse_task = asyncio.create_task(pulse())
out, err = await proc.communicate()
done_event.set()
pulse_task.cancel()
try:
await pulse_task
except asyncio.CancelledError:
pass
total_elapsed = time.time() - total_start
if proc.returncode != 0:
error = err.decode().strip() or out.decode().strip()
logger.error(f"rclone copyurl failed: {error}")
await status.edit_text(
f"❌ **URL download failed**\n\n"
f"📎 **{file_name}**\n\n"
f"💬 `{error}`"
)
return False
global session_uploads
session_uploads += 1
await status.edit_text(
f"✅ **Done!**\n\n"
f"📎 **{file_name}**\n"
f"📁 → **Google Drive / {folder}**\n\n"
f"🕐 Total: {fmt_dur(total_elapsed)} · 💾 Disk: **0 B**"
)
owner = (message.from_user.username or "").lower() if message.from_user else ""
await send_links(message, file_name, folder=folder, owner=owner)
return True
async def _fetch_gdrive_filename(file_id: str) -> str | None:
"""Try to get the filename from a Google Drive link via a HEAD request."""
try:
proc = await asyncio.create_subprocess_exec(
"curl", "-sI", "-L",
f"https://drive.google.com/uc?export=download&id={file_id}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await proc.communicate(timeout=10)
for line in out.decode(errors="replace").splitlines():
if line.lower().startswith("content-disposition:"):
m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";\n]+)', line)
if m:
from urllib.parse import unquote
name = unquote(m.group(1).strip())
if name and "." in name:
return sanitize_filename(name)
except Exception:
pass
return None
# ── yt-dlp media downloader ───────────────────────────────────────────────────
YTDLP_PROGRESS_RE = re.compile(
r'\[download\]\s+(?P<pct>[\d.]+)%\s+of\s+~?\s*(?P<total>[\d.]+\s*\S+?B)'
r'(?:\s+at\s+(?P<speed>\S+))?(?:\s+ETA\s+(?P<eta>\S+))?'
)
YTDLP_ITEM_RE = re.compile(r'\[download\] Downloading item (\d+) of (\d+)')
YTDLP_MAX_RUNTIME = 3 * 3600 # hard cap (livestreams could run forever)
YTDLP_PLAYLIST_CAP = "1:50" # max items pulled from a playlist/channel
async def _ytdlp_download(client: Client, message: Message, url: str,
seq: str, nsfw: bool) -> bool:
"""Download a media-site URL with yt-dlp to a temp dir, then rclone copy
every produced file into gdrive:MEDIA-DLS/<category>/. Returns success."""
text = message.text or ""
wants_audio = bool(re.search(r'\b(mp3|audio)\b', text, re.IGNORECASE))
username = (message.from_user.username or "").lower() if message.from_user else ""
site = urlparse(url).netloc.lower().removeprefix("www.")
temp_dir = tempfile.mkdtemp(prefix="ytdlp_")
total_start = time.time()
status = await message.reply_text(
f"🎬 **{seq}** — Media link detected\n\n"
f"🌐 **{site}**{' · 🔞 NSFW' if nsfw else ''}\n"
f"🎚 Mode: {'🎵 audio (mp3)' if wants_audio else '🎞 best video+audio'}\n\n"
f"⏳ Starting yt-dlp..."
)
cmd = [
sys.executable, "-m", "yt_dlp",
"--newline", "--no-warnings", "--color", "no_color",
"--no-playlist", "--playlist-items", YTDLP_PLAYLIST_CAP,
"--windows-filenames", "--trim-filenames", "180",
"--ffmpeg-location", "/usr/bin/ffmpeg",
"-o", os.path.join(temp_dir, "%(title)s [%(id)s].%(ext)s"),
]
if wants_audio:
cmd += ["-x", "--audio-format", "mp3"]
else:
cmd += ["-f", "bv*+ba/b", "--merge-output-format", "mp4"]
if COOKIES_FILE.exists():
cmd += ["--cookies", str(COOKIES_FILE)]
cmd.append(url)
def _cleanup():
for f in os.listdir(temp_dir):
try: os.remove(os.path.join(temp_dir, f))
except Exception: pass
try: os.rmdir(temp_dir)
except Exception: pass
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
except Exception as e:
await status.edit_text(f"❌ **yt-dlp failed to start**\n\n`{e}`")
_cleanup()
return False
tail = collections.deque(maxlen=15)
last_edit = 0.0
item_note = ""
async def read_progress():
nonlocal last_edit, item_note
while True:
line = await proc.stdout.readline()
if not line:
break
line = line.decode(errors="replace").rstrip()
if not line:
continue
tail.append(line)
m = YTDLP_ITEM_RE.search(line)
if m:
item_note = f" · 📚 Item {m.group(1)}/{m.group(2)}"
m = YTDLP_PROGRESS_RE.search(line)
now = time.time()
if m and now - last_edit >= 3:
last_edit = now
pct = min(float(m.group("pct")), 100.0)
bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5))
try:
await status.edit_text(
f"🎬 **{seq}** — yt-dlp downloading{item_note}\n\n"
f"🌐 **{site}**\n"
f"`[{bar}]` **{pct:.1f}%** of {m.group('total')}\n"
f"⚡ {m.group('speed') or '—'} · ⏱ ETA {m.group('eta') or '—'}\n\n"
f"🕐 Elapsed: {fmt_dur(now - total_start)} · 🖥 RAM: {fmt_ram()}"
)
except Exception:
pass
try:
await asyncio.wait_for(read_progress(), timeout=YTDLP_MAX_RUNTIME)
await proc.wait()
except asyncio.TimeoutError:
try: proc.kill()
except Exception: pass
await status.edit_text(
f"❌ **yt-dlp timed out** after {fmt_dur(YTDLP_MAX_RUNTIME)}\n\n"
f"🌐 {site} — `{url[:80]}`\n"
f"_Livestreams are capped at {fmt_dur(YTDLP_MAX_RUNTIME)}._"
)
_cleanup()
return False
files = [f for f in sorted(os.listdir(temp_dir))
if not f.endswith((".part", ".ytdl", ".temp", ".tmp"))]
if not files:
err_tail = "\n".join(list(tail)[-5:]) or "no output"
logger.error(f"yt-dlp failed for {url}: {err_tail}")
hint = ""
if "cookies" in err_tail.lower() or "login" in err_tail.lower() \
or "Sign in" in err_tail or "rate-limit" in err_tail.lower():
hint = "\n\n💡 This site may need login cookies (`cookies.txt` in the bot folder)."
await status.edit_text(
f"❌ **yt-dlp failed** (exit {proc.returncode})\n\n"
f"🌐 {site}\n💬 `{err_tail[:600]}`{hint}"
)
_cleanup()
return False
return await _upload_media_files(message, status, seq, site, temp_dir,
files, nsfw, username, total_start)
async def _upload_media_files(message, status, seq, site, temp_dir, files,
nsfw, username, total_start) -> bool:
"""rclone-copy each downloaded file into MEDIA-DLS/<category>, clean up
the temp dir, report the result and send Drive links. Returns success."""
uploaded = []
failed = []
total_bytes = 0
for i, fname in enumerate(files, 1):
fpath = os.path.join(temp_dir, fname)
fsize = os.path.getsize(fpath)
folder = media_dls_folder(fname, nsfw)
try:
await status.edit_text(
f"☁️ **{seq}** — Uploading {i}/{len(files)} to Drive\n\n"
f"📎 **{fname}**\n📦 {fmt_size(fsize)}\n"
f"📁 → **{folder}/**"
)
except Exception:
pass
up = await asyncio.create_subprocess_exec(
"rclone", "copy", fpath, f"{RCLONE_REMOTE}{folder}/",
"--drive-chunk-size", choose_chunk(fsize),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await up.communicate()
if up.returncode == 0:
uploaded.append((fname, folder))
total_bytes += fsize
try: os.remove(fpath)
except Exception: pass
else:
error = err.decode().strip() or out.decode().strip()
logger.error(f"rclone copy failed for {fname}: {error}")
failed.append(fname)
for f in os.listdir(temp_dir):
try: os.remove(os.path.join(temp_dir, f))
except Exception: pass
try: os.rmdir(temp_dir)
except Exception: pass
if not uploaded:
await status.edit_text(
f"❌ **Drive upload failed** for all {len(files)} file(s)\n\n"
f"🌐 {site} — check `/logs` for rclone errors."
)
return False
global session_uploads
session_uploads += len(uploaded)
if username and username not in AUTHORIZED_USERS:
record_upload(username, total_bytes)
total_elapsed = time.time() - total_start
file_lines = "\n".join(f"📎 `{n}` → **{f}/**" for n, f in uploaded[:10])
if len(uploaded) > 10:
file_lines += f"\n_…and {len(uploaded) - 10} more_"
fail_note = f"\n⚠️ Failed to upload: {len(failed)} file(s)" if failed else ""
await status.edit_text(
f"✅ **Done!** ({seq})\n\n"
f"🌐 **{site}** · {len(uploaded)} file(s) · 📦 {fmt_size(total_bytes)}\n"
f"{file_lines}{fail_note}\n\n"
f"🕐 Total: {fmt_dur(total_elapsed)}"
)
if len(uploaded) <= 3:
for fname, ffolder in uploaded:
await send_links(message, fname, folder=ffolder, owner=username)
return True
# ── Spotify downloader (spotdl) ───────────────────────────────────────────────
AUDIO_EXTS = (".mp3", ".m4a", ".ogg", ".opus", ".flac", ".wav")
async def _spotify_download(client: Client, message: Message, url: str,
seq: str) -> bool:
"""Download a Spotify track/album/playlist with spotdl (audio sourced from
YouTube Music), then upload to gdrive:MEDIA-DLS/Music. Returns success."""
username = (message.from_user.username or "").lower() if message.from_user else ""
temp_dir = tempfile.mkdtemp(prefix="spotdl_")
total_start = time.time()
status = await message.reply_text(
f"🎧 **{seq}** — Spotify link detected\n\n"
f"🔗 `{url[:80]}`\n\n"
f"⏳ Starting spotdl (Spotify metadata → YouTube Music audio)..."
)
cmd = [
sys.executable, "-m", "spotdl", "download", url,
"--output", os.path.join(temp_dir, "{artists} - {title}.{output-ext}"),
]
if COOKIES_FILE.exists():
cmd += ["--cookie-file", str(COOKIES_FILE)]
def _cleanup():
for f in os.listdir(temp_dir):
try: os.remove(os.path.join(temp_dir, f))
except Exception: pass
try: os.rmdir(temp_dir)
except Exception: pass
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
except Exception as e:
await status.edit_text(f"❌ **spotdl failed to start**\n\n`{e}`")
_cleanup()
return False
tail = collections.deque(maxlen=15)
last_edit = 0.0
async def read_output():
nonlocal last_edit
while True:
line = await proc.stdout.readline()
if not line:
break
line = line.decode(errors="replace").strip()
if not line:
continue
tail.append(line)
now = time.time()
if now - last_edit >= 4:
last_edit = now
done = len([f for f in os.listdir(temp_dir)
if f.endswith(AUDIO_EXTS)])
try:
await status.edit_text(
f"🎧 **{seq}** — spotdl working...\n\n"
f"🎵 Songs done: **{done}**\n"
f"💬 `{line[:120]}`\n\n"
f"🕐 Elapsed: {fmt_dur(now - total_start)} · 🖥 RAM: {fmt_ram()}"
)
except Exception:
pass
try:
await asyncio.wait_for(read_output(), timeout=YTDLP_MAX_RUNTIME)
await proc.wait()
except asyncio.TimeoutError:
try: proc.kill()
except Exception: pass
await status.edit_text(
f"❌ **spotdl timed out** after {fmt_dur(YTDLP_MAX_RUNTIME)}\n\n🔗 `{url[:80]}`"
)
_cleanup()
return False
files = [f for f in sorted(os.listdir(temp_dir)) if f.endswith(AUDIO_EXTS)]
if not files:
err_tail = "\n".join(list(tail)[-5:]) or "no output"
logger.error(f"spotdl failed for {url}: {err_tail}")
hint = ""
if "AudioProviderError" in err_tail or "bot" in err_tail.lower():
hint = ("\n\n💡 spotdl pulls audio from YouTube Music — this server's IP "
"is YT-flagged, so a `cookies.txt` in the bot folder is required.")
await status.edit_text(
f"❌ **spotdl failed** (exit {proc.returncode})\n\n"
f"🔗 `{url[:80]}`\n💬 `{err_tail[:600]}`{hint}"
)
_cleanup()
return False
return await _upload_media_files(message, status, seq, "spotify.com",
temp_dir, files, False, username, total_start)
# ── Torrents (aria2c) ─────────────────────────────────────────────────────────
def _bdecode(data: bytes, pos: int = 0):
"""Minimal bencode decoder — enough to read a .torrent file list."""
t = data[pos:pos + 1]
if t == b'i':
end = data.index(b'e', pos)
return int(data[pos + 1:end]), end + 1
if t == b'l':
pos += 1; out = []
while data[pos:pos + 1] != b'e':
v, pos = _bdecode(data, pos); out.append(v)
return out, pos + 1
if t == b'd':
pos += 1; out = {}
while data[pos:pos + 1] != b'e':
k, pos = _bdecode(data, pos)
v, pos = _bdecode(data, pos)
out[k] = v
return out, pos + 1
colon = data.index(b':', pos)
n = int(data[pos:colon])
start = colon + 1
return data[start:start + n], start + n
def torrent_files(torrent_path: str) -> tuple[str, list[tuple[str, int]]]:
"""Return (display_name, [(relative_path, size_bytes), ...]) from a .torrent.
relative_path includes the torrent's top folder for multi-file torrents."""
# Names are used verbatim for both the on-disk path aria2 writes and the
# Drive destination (Google Drive accepts spaces/unicode), so they must NOT
# be altered here — sanitizing would break the local-file lookup.
data = Path(torrent_path).read_bytes()
meta, _ = _bdecode(data)
info = meta[b'info']
name = info[b'name'].decode('utf-8', 'replace')
if b'files' in info: # multi-file
files = []
for f in info[b'files']:
parts = [p.decode('utf-8', 'replace') for p in f[b'path']]
rel = os.path.join(name, *parts)
files.append((rel, int(f[b'length'])))
return name, files
return name, [(name, int(info[b'length']))] # single-file
ARIA2_PROGRESS_RE = re.compile(
r'(?P<done>[\d.]+\w*)/(?P<total>[\d.]+\w*)\((?P<pct>\d+)%\)'
r'(?:\s+CN:(?P<cn>\d+))?(?:\s+SD:(?P<sd>\d+))?'
r'(?:\s+DL:(?P<dl>\S+?))?(?:\s+ETA:(?P<eta>\S+?))?\]'
)
ARIA2_COMMON = [
"--seed-time=0", "--seed-ratio=0.0", "--bt-stop-timeout=300",
"--summary-interval=1", "--enable-color=false", "--console-log-level=warn",
"--max-connection-per-server=16", "--split=16", "--bt-max-peers=200",
"--allow-overwrite=true", "--auto-file-renaming=false",
]
async def _aria2_fetch_metadata(magnet: str, work: str) -> str | None:
"""Download only the .torrent metadata for a magnet; return its path."""
proc = await asyncio.create_subprocess_exec(
"aria2c", magnet, "--dir", work,
"--bt-metadata-only=true", "--bt-save-metadata=true",
"--bt-stop-timeout=120", "--seed-time=0", "--console-log-level=warn",
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
)
try:
await asyncio.wait_for(proc.wait(), timeout=180)
except asyncio.TimeoutError:
try: proc.kill()
except Exception: pass
return None
tfiles = [f for f in os.listdir(work) if f.endswith(".torrent")]
return os.path.join(work, tfiles[0]) if tfiles else None
async def _aria2_run(source: str, work: str, status, header: str,
total_start: float, select_index: int | None = None) -> bool:
"""Run aria2c on a magnet/.torrent (optionally one file via --select-file),
streaming progress to the status message. Returns True on success."""
cmd = ["aria2c", source, "--dir", work] + ARIA2_COMMON
if select_index is not None:
cmd.append(f"--select-file={select_index}")
try:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
except Exception as e:
await status.edit_text(f"❌ **aria2c failed to start**\n\n`{e}`")
return False
last_edit = 0.0
async def pump():
nonlocal last_edit
while True:
raw = await proc.stdout.readline()
if not raw:
break
for line in raw.decode(errors="replace").replace("\r", "\n").split("\n"):
m = ARIA2_PROGRESS_RE.search(line)
now = time.time()
if m and now - last_edit >= 3:
last_edit = now
pct = int(m.group("pct"))
bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5))
try:
await status.edit_text(
f"{header}\n"
f"`[{bar}]` **{pct}%** ({m.group('done')}/{m.group('total')})\n"
f"⚡ {m.group('dl') or '—'}/s · 👥 {m.group('cn') or '0'} peers"
f" · ⏱ ETA {m.group('eta') or '—'}\n\n"
f"🕐 {fmt_dur(now - total_start)} · 🖥 RAM: {fmt_ram()}"
)
except Exception:
pass
try:
await asyncio.wait_for(pump(), timeout=YTDLP_MAX_RUNTIME)
await proc.wait()
except asyncio.TimeoutError:
try: proc.kill()
except Exception: pass
return False
return proc.returncode == 0
async def _upload_one(local_path: str, rel_path: str, status, label: str) -> tuple[bool, int]:
"""rclone-copy a single torrent file into Torrents/<reldir>/. Returns (ok, bytes)."""
if not os.path.exists(local_path):
return False, 0
size = os.path.getsize(local_path)
rel_dir = os.path.dirname(rel_path)
dest = f"{RCLONE_REMOTE}{TORRENTS_DIR}/{rel_dir}/" if rel_dir else f"{RCLONE_REMOTE}{TORRENTS_DIR}/"
try:
await status.edit_text(
f"☁️ {label}\n\n📎 **{os.path.basename(rel_path)}**\n"
f"📦 {fmt_size(size)} · 📁 → **{TORRENTS_DIR}/{rel_dir}/**"
)
except Exception:
pass
proc = await asyncio.create_subprocess_exec(
"rclone", "copy", local_path, dest, "--drive-chunk-size", choose_chunk(size),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
logger.error(f"torrent upload failed {rel_path}: {err.decode().strip()}")
return False, 0
return True, size
async def process_torrent(client: Client, message: Message, seq: str,
magnet: str | None = None, torrent_path: str | None = None) -> bool:
"""Download a magnet/.torrent via aria2c and upload to gdrive:Torrents/.
< 8 GB: whole download then upload. ≥ 8 GB: per-file (download→upload→delete)
to keep peak disk usage low. Returns success."""
import shutil
username = (message.from_user.username or "").lower() if message.from_user else ""
total_start = time.time()
WORK_DIR.mkdir(exist_ok=True)
work = tempfile.mkdtemp(dir=str(WORK_DIR), prefix="t_")
def _cleanup():
try: shutil.rmtree(work, ignore_errors=True)
except Exception: pass
status = await message.reply_text(
f"🧲 **{seq}** — Torrent received\n\n⏳ Fetching metadata (peers)..."
)
# Resolve to a local .torrent (parse file list before downloading data)
if magnet:
torrent_path = await _aria2_fetch_metadata(magnet, work)
if not torrent_path:
await status.edit_text(
"❌ **Couldn't fetch torrent metadata**\n\n"
"No peers responded within 3 min — the magnet may be dead."
)
_cleanup()
return False
source = torrent_path
try:
name, files = torrent_files(torrent_path)
except Exception as e:
logger.error(f"torrent parse failed: {e}")
await status.edit_text(f"❌ **Invalid .torrent file**\n\n`{e}`")
_cleanup()
return False
total = sum(s for _, s in files)
per_file = total >= TORRENT_STREAM_THRESHOLD
biggest = max((s for _, s in files), default=0)
free = shutil.disk_usage(str(WORK_DIR)).free
need = (biggest if per_file else total) + DISK_HEADROOM
if free < need:
await status.edit_text(
f"❌ **Not enough disk space**\n\n"
f"📦 Torrent: **{fmt_size(total)}** ({len(files)} files, biggest {fmt_size(biggest)})\n"
f"💾 Free disk: {fmt_size(free)} · needs ~{fmt_size(need)}\n\n"
f"_{'Per-file mode still needs the biggest file to fit.' if per_file else 'Try again when space frees up.'}_"
)
_cleanup()
return False
if username and username not in AUTHORIZED_USERS:
rem = quota_remaining(username)
if rem is not None and total > rem:
await status.edit_text(
f"🚫 **Not enough quota**\n\n📦 Torrent: {fmt_size(total)}\n"
f"✅ Remaining: {fmt_size(rem)}\n\n🔄 Resets at midnight UTC."
)
_cleanup()
return False
mode = f"🧩 Per-file (≥8 GB, low disk)" if per_file else "💾 Whole download"
await status.edit_text(
f"🧲 **{seq}** — {name}\n\n"
f"📦 {fmt_size(total)} · 📂 {len(files)} file(s)\n"
f"🔧 {mode} · 💾 Free disk: {fmt_size(free)}\n\n⏳ Starting aria2c..."
)
uploaded, failed, sent_bytes = [], [], 0
if not per_file:
ok = await _aria2_run(source, work, status,
f"🧲 **{seq}** — Downloading: {name}", total_start)
if not ok:
await status.edit_text(f"❌ **Torrent download failed** — {name}\n\nCheck `/logs`.")
_cleanup()
return False
for rel, _ in files:
up_ok, size = await _upload_one(
os.path.join(work, rel), rel, status,
f"**{seq}** — Uploading {len(uploaded)+1}/{len(files)}")
(uploaded if up_ok else failed).append(rel)
if up_ok:
sent_bytes += size
try: os.remove(os.path.join(work, rel))
except Exception: pass
else:
for i, (rel, _) in enumerate(files, 1): # aria2 file index is 1-based
hdr = f"🧲 **{seq}** — File {i}/{len(files)}: {os.path.basename(rel)}\n📥 Downloading"
ok = await _aria2_run(source, work, status, hdr, total_start, select_index=i)
local = os.path.join(work, rel)
if not ok or not os.path.exists(local):
failed.append(rel)
continue
up_ok, size = await _upload_one(
local, rel, status, f"**{seq}** — Uploading file {i}/{len(files)}")
(uploaded if up_ok else failed).append(rel)
if up_ok:
sent_bytes += size
try: os.remove(local) # free disk before next file
except Exception: pass
_cleanup()
if not uploaded:
await status.edit_text(f"❌ **Torrent failed** — no files uploaded for {name}.\n\nCheck `/logs`.")
return False
global session_uploads
session_uploads += len(uploaded)
if username and username not in AUTHORIZED_USERS:
record_upload(username, sent_bytes)
fail_note = f"\n⚠️ Failed: {len(failed)} file(s)" if failed else ""
await status.edit_text(
f"✅ **Torrent done!** ({seq})\n\n"
f"🧲 **{name}**\n"
f"📂 {len(uploaded)} file(s) · 📦 {fmt_size(sent_bytes)}\n"
f"📁 → **Drive / {TORRENTS_DIR}/{name}/**{fail_note}\n\n"
f"🕐 Total: {fmt_dur(time.time() - total_start)}"
)
if len(uploaded) <= 3:
for rel in uploaded:
rel_dir = os.path.dirname(rel)
await send_links(message, os.path.basename(rel),
folder=f"{TORRENTS_DIR}/{rel_dir}" if rel_dir else TORRENTS_DIR,
owner=username)
return True
# ── Telegram delivery (Drive → Telegram) ──────────────────────────────────────
async def deliver_to_telegram(client: Client, message: Message,
name: str, folder: str) -> bool:
"""Download a file from Drive and send it back into Telegram. Returns success."""
import shutil
entry = await get_drive_entry(name, folder)
if not entry or not entry.get("ID"):
await message.reply_text(f"❌ `{name}` not found in Drive (moved or deleted?).")
return False
size = entry.get("Size", 0)
if size > TG_SEND_LIMIT:
await message.reply_text(
f"⚠️ **Too big to send via Telegram**\n\n"
f"📎 **{name}** — {fmt_size(size)} (limit {fmt_size(TG_SEND_LIMIT)})\n\n"
f"Use 🌍 Share / `/share {name}` for a download link instead."
)
return False
status = await message.reply_text(
f"📤 **Fetching from Drive**\n\n📎 **{name}**\n📦 {fmt_size(size)}\n\n⏳ Downloading..."
)
WORK_DIR.mkdir(exist_ok=True)
work = tempfile.mkdtemp(dir=str(WORK_DIR), prefix="get_")
rpath = f"{RCLONE_REMOTE}{folder}/{name}"
proc = await asyncio.create_subprocess_exec(
"rclone", "copy", rpath, work, "--stats=1s", "--stats-one-line",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
rstat_re = re.compile(r'Transferred:\s+\S+\s+\w+\s*/\s*\S+\s+\w+,\s+(\d+)%')
last = 0.0
while True:
raw = await proc.stdout.readline()
if not raw:
break
m = rstat_re.search(raw.decode(errors="replace"))
now = time.time()
if m and now - last >= 3:
last = now
pct = int(m.group(1)); bar = "█"*int(pct/5)+"░"*(20-int(pct/5))
try: await status.edit_text(f"📤 **Fetching from Drive**\n\n📎 **{name}**\n`[{bar}]` **{pct}%**")
except Exception: pass
await proc.wait()
local = os.path.join(work, name)
if proc.returncode != 0 or not os.path.exists(local):
await status.edit_text(f"❌ **Drive download failed** for `{name}`.\n\nCheck `/logs`.")
shutil.rmtree(work, ignore_errors=True)
return False
up_start = time.time()
last_up = 0.0
async def up_prog(cur, tot):
nonlocal last_up
now = time.time()
if now - last_up < 3: return
last_up = now
pct = cur/tot*100 if tot else 0; bar = "█"*int(pct/5)+"░"*(20-int(pct/5))
try:
await status.edit_text(
f"📲 **Sending to Telegram**\n\n📎 **{name}**\n`[{bar}]` **{pct:.0f}%**\n"
f"📦 {fmt_size(cur)} / {fmt_size(tot)}"
)
except Exception: pass
ext = os.path.splitext(name)[1].lower()
try:
if ext in VIDEO_SEND_EXTS:
await client.send_video(message.chat.id, local, caption=f"🎬 {name}",
progress=up_prog, reply_to_message_id=message.id,
supports_streaming=True)
elif ext in AUDIO_SEND_EXTS:
await client.send_audio(message.chat.id, local, caption=f"🎵 {name}",
progress=up_prog, reply_to_message_id=message.id)
else:
await client.send_document(message.chat.id, local, caption=f"📎 {name}",
progress=up_prog, reply_to_message_id=message.id,
force_document=(ext not in IMAGE_SEND_EXTS))
await status.edit_text(
f"✅ **Sent!**\n\n📎 **{name}** · 📦 {fmt_size(size)}\n"
f"🕐 {fmt_dur(time.time() - up_start)}"
)
ok = True
except Exception as e:
logger.error(f"deliver send failed for {name}: {e}")
await status.edit_text(f"❌ **Telegram send failed** for `{name}`\n\n`{str(e)[:200]}`")
ok = False
finally:
shutil.rmtree(work, ignore_errors=True)
return ok
# ── File handler ──────────────────────────────────────────────────────────────
@app.on_message(filters.media)
async def handle_file(client: Client, message: Message):
allowed, reason = has_upload_access(message)
if not allowed:
if reason == "quota_exceeded":
u = (message.from_user.username or "").lower()
limit = get_quota(u)
await message.reply_text(
f"🚫 **Daily quota exceeded**\n\n"
f"You've used your full {fmt_size(limit)} for today.\n"
f"🔄 Resets at midnight UTC."
)
return
media_type, media = get_media(message)
if not media:
return
# .torrent files → torrent handler (download the metadata file, then aria2c)
doc_name = getattr(media, "file_name", "") or ""
doc_mime = getattr(media, "mime_type", "") or ""
if media_type == "document" and (doc_name.lower().endswith(".torrent")
or "bittorrent" in doc_mime.lower()):
WORK_DIR.mkdir(exist_ok=True)
tpath = await client.download_media(
message, file_name=os.path.join(str(WORK_DIR), f"in_{message.id}.torrent"))
if tpath:
await process_torrent(client, message, "1/1", torrent_path=tpath)
try: os.remove(tpath)
except Exception: pass
return
caption = (message.caption or "").strip() or None
file_name = resolve_filename(media_type, media, caption)
raw_size = getattr(media, "file_size", 0) or 0
size_str = fmt_size(raw_size) if raw_size else "unknown"
mime = getattr(media, "mime_type", None) or media_type
dc_id = getattr(media, "dc_id", None)
dc_note = f"DC{dc_id}" if dc_id else "DC?"
chunk_label = choose_chunk(raw_size)
use_stream = raw_size >= STREAM_THRESHOLD
method = "🌊 RAM stream" if use_stream else "💾 Download → Upload"
total_start = time.time()
username = (message.from_user.username or "").lower() if message.from_user else ""
rename_note = f"\n✏️ Renamed to: **{file_name}**" if caption else ""
# Quota pre-check: reject if file would exceed remaining quota
if username not in AUTHORIZED_USERS and raw_size:
rem = quota_remaining(username)
if rem is not None and raw_size > rem:
return await message.reply_text(
f"🚫 **Not enough quota**\n\n"
f"📦 File size: {fmt_size(raw_size)}\n"
f"✅ Remaining: {fmt_size(rem)}\n\n"
f"🔄 Quota resets at midnight UTC."
)
status = await message.reply_text(
f"📥 **Received**\n\n"
f"**{file_name}**\n"
f"📦 {size_str} · 🗂 `{mime}` · 📡 {dc_note}{rename_note}\n"
f"🔧 Method: {method} · 🧩 Chunk: {chunk_label}\n\n"
f"⏳ Starting..."
)
folder = classify_to_folder(file_name, mime)
if use_stream:
await _stream_to_drive(client, message, status, file_name, raw_size,
size_str, mime, dc_note, chunk_label, total_start,
username=username, folder=folder)
else:
await _download_then_upload(client, message, status, file_name, raw_size,
size_str, mime, dc_note, chunk_label, total_start,
username=username, folder=folder)
# ── Method A: download to disk → rclone copy (< 2 GB) ───────────────────────
async def _download_then_upload(
client, message, status, file_name, raw_size,
size_str, mime, dc_note, chunk_label, total_start, username="", folder="Other"
):
temp_dir = tempfile.mkdtemp()
local_path = os.path.join(temp_dir, file_name)
dl_start = time.time()
last_edit = 0.0
peak_dl = 0.0
speed_win = collections.deque(maxlen=6)
prev_curr = 0
async def progress(current: int, total: int):
nonlocal last_edit, peak_dl, prev_curr
now = time.time()
if now - last_edit < 3:
return
last_edit = now
elapsed = now - dl_start
avg = current / elapsed if elapsed > 0 else 0
speed_win.append(avg)
peak_dl = max(peak_dl, avg)
smooth = sum(speed_win) / len(speed_win)
pct = current / total * 100 if total else 0
eta = (total - current) / smooth if smooth > 0 else 0
bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5))
try:
await status.edit_text(
f"⬇️ **Downloading** · {dc_note}\n\n"
f"**{file_name}**\n"
f"`[{bar}]` **{pct:.1f}%**\n"
f"📦 {fmt_size(current)} / {fmt_size(total)}\n\n"
f"⚡ Speed: {spd(smooth)}\n"
f"🏆 Peak: {spd(peak_dl)}\n"
f"⏱ ETA: {fmt_dur(eta)} · Elapsed: {fmt_dur(elapsed)}\n\n"
f"🧩 Chunk: {chunk_label} · 🖥 RAM: {fmt_ram()}"
)
except Exception:
pass
MAX_RETRIES = 3
RETRY_DELAYS = [5, 15, 30]
last_error = None
for attempt in range(1, MAX_RETRIES + 1):
try:
# Reset progress state for each attempt
dl_start = time.time()
last_edit = 0.0
peak_dl = 0.0
speed_win.clear()
if attempt > 1:
try:
await status.edit_text(
f"🔄 **Retrying download** ({attempt}/{MAX_RETRIES}) · {dc_note}\n\n"
f"**{file_name}**\n"
f"📦 {size_str}\n\n"
f"⚠️ Previous error: `{last_error}`\n"
f"⏳ Starting attempt {attempt}..."
)
except Exception:
pass
# Clean up partial file from last attempt
try: os.remove(local_path)
except Exception: pass
downloaded = await client.download_media(message, file_name=local_path, progress=progress)
if not downloaded:
raise RuntimeError("download_media returned None")
break # success — exit retry loop
except Exception as e:
last_error = str(e)
logger.warning(f"Download attempt {attempt}/{MAX_RETRIES} failed: {e}")
if attempt == MAX_RETRIES:
logger.error(f"All {MAX_RETRIES} download attempts failed for {file_name}")
await status.edit_text(
f"❌ **Download failed** after {MAX_RETRIES} attempts\n\n"
f"**{file_name}**\n\n"
f"💬 Last error: `{last_error}`"
)
try: os.remove(local_path)
except Exception: pass
try: os.rmdir(temp_dir)
except Exception: pass
return
delay = RETRY_DELAYS[attempt - 1]
try:
await status.edit_text(
f"⚠️ **Connection dropped** (attempt {attempt}/{MAX_RETRIES}) · {dc_note}\n\n"
f"**{file_name}**\n\n"
f"💬 `{last_error}`\n\n"
f"⏳ Retrying in {delay}s..."
)
except Exception:
pass
await asyncio.sleep(delay)
dl_elapsed = time.time() - dl_start
avg_dl = raw_size / dl_elapsed if dl_elapsed > 0 and raw_size else 0
await status.edit_text(
f"☁️ **Uploading to Drive...**\n\n"
f"**{file_name}**\n"
f"📦 {size_str} downloaded in {fmt_dur(dl_elapsed)} ({spd(avg_dl)})\n\n"
f"⏳ rclone copying to Google Drive..."
)
up_start = time.time()
try:
proc = await asyncio.create_subprocess_exec(
"rclone", "copy", local_path, f"{RCLONE_REMOTE}{folder}/",
"--drive-chunk-size", chunk_label,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
up_elapsed = time.time() - up_start
total_elapsed = time.time() - total_start
avg_up = raw_size / up_elapsed if up_elapsed > 0 and raw_size else 0
if proc.returncode != 0:
error = err.decode().strip() or out.decode().strip()
logger.error(f"rclone copy failed: {error}")
await status.edit_text(
f"❌ **Upload failed**\n\n**{file_name}**\n\n💬 `{error}`\n\n"
f"💾 Local copy at:\n`{local_path}`"
)
return
global session_uploads
session_uploads += 1
if username and username not in AUTHORIZED_USERS:
record_upload(username, raw_size or up_elapsed * 1) # track actual bytes
quota_note = ""
if username and username not in AUTHORIZED_USERS:
rem = quota_remaining(username)
if rem is not None:
quota_note = f"\n📊 Quota left today: {fmt_size(rem)}"
await status.edit_text(
f"✅ **Done!**\n\n"
f"**{file_name}**\n"
f"📦 {size_str} · 🗂 `{mime}`\n\n"
f"📊 **Stats**\n"
f"⬇️ Download: {fmt_dur(dl_elapsed)} @ {spd(avg_dl)}\n"
f"☁️ Upload: {fmt_dur(up_elapsed)} @ {spd(avg_up)}\n"
f"🕐 Total: {fmt_dur(total_elapsed)}\n\n"
f"🧩 Chunk: {chunk_label} · 📡 {dc_note}\n"
f"📁 → **Google Drive / {folder}**{quota_note}"
)
except Exception as e:
logger.error(f"rclone copy error: {e}")
await status.edit_text(f"❌ **Upload error**\n\n**{file_name}**\n\n💬 `{e}`")
return
finally:
try: os.remove(local_path)
except Exception: pass
try: os.rmdir(temp_dir)
except Exception: pass
await send_links(message, file_name, folder=folder, owner=username)
# ── Method B: pure RAM stream via rclone rcat (≥ 2 GB) ──────────────────────
async def _stream_to_drive(
client, message, status, file_name, raw_size,
size_str, mime, dc_note, chunk_label, total_start, username="", folder="Other"
):
rclone_cmd = [
"rclone", "rcat", f"{RCLONE_REMOTE}{folder}/{file_name}",
"--drive-chunk-size", chunk_label,
]
if raw_size:
rclone_cmd += ["--size", str(raw_size)]
MAX_RETRIES = 3
RETRY_DELAYS = [5, 15, 30]
last_error = None
bytes_sent = 0
peak_spd = 0.0
stream_start = time.time()
for attempt in range(1, MAX_RETRIES + 1):
if attempt > 1:
delay = RETRY_DELAYS[attempt - 2]
try:
await status.edit_text(
f"⚠️ **Stream dropped** (attempt {attempt - 1}/{MAX_RETRIES}) · {dc_note}\n\n"
f"**{file_name}**\n"
f"📦 {fmt_size(bytes_sent)} received so far\n\n"
f"💬 `{last_error}`\n\n"
f"⏳ Retrying from 0% in {delay}s... (stream must restart)"
)
except Exception:
pass
await asyncio.sleep(delay)
# Reset counters — stream restarts from beginning
bytes_sent = 0
stream_start = time.time()
# Launch a fresh rclone process for each attempt
try:
rclone = await asyncio.create_subprocess_exec(
*rclone_cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except Exception as e:
logger.error(f"rclone launch failed: {e}")
await status.edit_text(f"❌ **rclone failed to start**\n\n`{e}`")
return
last_edit = 0.0
speed_win = collections.deque(maxlen=6)
win_start = stream_start
win_bytes = 0
stream_err = None
try:
async for chunk in client.stream_media(message):
rclone.stdin.write(chunk)
await rclone.stdin.drain()
bytes_sent += len(chunk)
win_bytes += len(chunk)
now = time.time()
win_age = now - win_start
if win_age >= 3:
inst = win_bytes / win_age
speed_win.append(inst)
peak_spd = max(peak_spd, inst)
win_bytes = 0
win_start = now
avg = sum(speed_win) / len(speed_win) if speed_win else 0
if now - last_edit >= 3:
last_edit = now
elapsed = now - stream_start
pct = bytes_sent / raw_size * 100 if raw_size else 0
eta = (raw_size - bytes_sent) / avg if avg > 0 and raw_size else 0
bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5))
retry_note = f" · 🔁 attempt {attempt}/{MAX_RETRIES}" if attempt > 1 else ""
try:
await status.edit_text(
f"🌊 **Streaming** · {dc_note}{retry_note}\n\n"
f"**{file_name}**\n"
f"`[{bar}]` **{pct:.1f}%**\n"
f"📦 {fmt_size(bytes_sent)} / {size_str}\n\n"
f"⚡ Speed: {spd(avg)}\n"
f"🏆 Peak: {spd(peak_spd)}\n"
f"⏱ ETA: {fmt_dur(eta)} · Elapsed: {fmt_dur(elapsed)}\n\n"
f"🧩 Chunk: {chunk_label} · 💾 Disk: **0 B**\n"
f"🖥 RAM: {fmt_ram()}"
)
except Exception:
pass
rclone.stdin.close()
except Exception as e:
stream_err = e
last_error = str(e)
logger.warning(f"Stream attempt {attempt}/{MAX_RETRIES} failed: {e}")
try: rclone.stdin.close()
except Exception: pass
try: rclone.kill()
except Exception: pass
if stream_err is None:
break # stream completed cleanly
if attempt == MAX_RETRIES:
await status.edit_text(
f"❌ **Stream failed** after {MAX_RETRIES} attempts\n\n"
f"**{file_name}**\n"
f"📦 {fmt_size(bytes_sent)} received before final failure\n\n"
f"💬 `{last_error}`"
)
return
stream_elapsed = time.time() - stream_start
try:
await status.edit_text(
f"☁️ **Flushing to Drive API...**\n\n"
f"**{file_name}**\n"
f"📦 {fmt_size(bytes_sent)} streamed in {fmt_dur(stream_elapsed)}\n"
f"⚡ Avg: {spd(bytes_sent / stream_elapsed if stream_elapsed else 0)}"
f" · 🏆 Peak: {spd(peak_spd)}\n\n"
f"⏳ rclone flushing last {chunk_label} chunk..."
)
except Exception:
pass
out, err = await rclone.communicate()
total_elapsed = time.time() - total_start
if rclone.returncode != 0:
error = err.decode().strip() or out.decode().strip()
logger.error(f"rclone rcat exit {rclone.returncode}: {error}")
await status.edit_text(
f"❌ **Drive upload failed**\n\n**{file_name}**\n"
f"📦 {fmt_size(bytes_sent)} streamed — rclone exited {rclone.returncode}\n\n"
f"💬 `{error}`"
)
return
global session_uploads
session_uploads += 1
if username and username not in AUTHORIZED_USERS:
record_upload(username, bytes_sent)
quota_note = ""
if username and username not in AUTHORIZED_USERS:
rem = quota_remaining(username)
if rem is not None:
quota_note = f"\n📊 Quota left today: {fmt_size(rem)}"
avg_final = bytes_sent / stream_elapsed if stream_elapsed > 0 else 0
await status.edit_text(
f"✅ **Done!**\n\n"
f"**{file_name}**\n"
f"📦 {fmt_size(bytes_sent)} · 🗂 `{mime}`\n\n"
f"📊 **Stats**\n"
f"⚡ Avg speed: {spd(avg_final)}\n"
f"🏆 Peak speed: {spd(peak_spd)}\n"
f"⏱ Stream: {fmt_dur(stream_elapsed)}\n"
f"🕐 Total: {fmt_dur(total_elapsed)}\n\n"
f"🧩 Chunk: {chunk_label} · 📡 {dc_note} · 💾 Disk: **0 B**\n"
f"📁 → **Google Drive / {folder}**{quota_note}"
)
await send_links(message, file_name, folder=folder, owner=username)
# ── yt-dlp auto-updater ───────────────────────────────────────────────────────
# Extractors break whenever sites change; a daily upgrade keeps them fresh.
# Downloads spawn `python -m yt_dlp` subprocesses, so a new version is picked
# up immediately — no bot restart needed.
YTDLP_UPDATE_INTERVAL = 24 * 3600
async def _ytdlp_version() -> str:
proc = await asyncio.create_subprocess_exec(
sys.executable, "-m", "yt_dlp", "--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
out, _ = await proc.communicate()
return out.decode().strip()
async def ytdlp_update_loop():
while True:
try:
before = await _ytdlp_version()
proc = await asyncio.create_subprocess_exec(
sys.executable, "-m", "pip", "install", "-q", "--upgrade", "yt-dlp",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
out, _ = await proc.communicate()
if proc.returncode != 0:
logger.warning(f"yt-dlp update failed (pip exit {proc.returncode}): "
f"{out.decode()[-300:]}")
else:
after = await _ytdlp_version()
if after != before:
logger.info(f"yt-dlp auto-updated: {before} → {after}")
else:
logger.info(f"yt-dlp up to date ({before})")
except Exception as e:
logger.warning(f"yt-dlp auto-update error: {e}")
await asyncio.sleep(YTDLP_UPDATE_INTERVAL)
# ── Command menu (the "/" pop-up pane) ────────────────────────────────────────
# Registered with Telegram on startup so typing "/" shows the list + hints.
BOT_COMMANDS = [
("start", "👋 How to use the bot + system info"),
("help", "📖 Full command reference"),
("get", "📥 Send a Drive file back into this chat"),
("search", "🔎 Find files in Drive by name"),
("list", "🗂 Show the 10 most recent files"),
("listall", "📚 List every file (paginated)"),
("share", "🌍 Make a file public + get a link"),
("stats", "ℹ️ Details for one Drive file"),
("delete", "🗑 Permanently delete a Drive file"),
("folders", "📁 Drive folders & where types go"),
("df", "💽 Google Drive storage usage"),
("status", "📊 Bot uptime, RAM, CPU, disk"),
("logs", "📜 Tail the last 50 log lines"),
("myquota", "📦 Check your own upload quota"),
("quotas", "📈 All quotas & today's usage (admin)"),
("setquota", "⚙️ Set a user's daily quota (admin)"),
("clearquota", "♻️ Remove a user's quota (admin)"),
("adduser", "➕ Grant a user full access (admin)"),
("removeuser", "➖ Revoke a user's access (admin)"),
("restart", "🔄 Gracefully restart the bot (admin)"),
]
# How-to that doubles as the "/" pane intro — shown by /start and /help.
USAGE_TUTORIAL = (
"**What I do:** send me anything and I save it to Google Drive.\n\n"
"📁 **Files** — forward or upload any file (caption = rename)\n"
"🌐 **Links** — paste a URL (or several); direct files stream straight to Drive\n"
"🎬 **Media sites** — YouTube, Instagram, TikTok, X, Reddit, Pinterest, "
"Tumblr, Facebook, Aparat, RedGifs & more → handled by yt-dlp\n"
"🎵 Add `mp3` after a media link to grab audio only\n"
"🎧 **Spotify** links → downloaded as MP3\n"
"🧲 **Torrents** — send a magnet link or a `.torrent` file\n"
"📥 **Get back** — `/get <name>` or the 📥 button pulls a file from Drive to Telegram\n\n"
"Type `/` to see every command."
)
async def register_commands():
try:
await app.set_bot_commands([BotCommand(c, d) for c, d in BOT_COMMANDS])
logger.info(f"Registered {len(BOT_COMMANDS)} bot commands with Telegram")
except Exception as e:
logger.warning(f"set_bot_commands failed: {e}")
# ── Entry ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
if not API_ID or not API_HASH or not BOT_TOKEN:
logger.error("API_ID, API_HASH, BOT_TOKEN must be set in .env")
raise SystemExit(1)
# Rotate log on startup: keep last 1000 lines if it exceeds 1 MB
try:
if LOG_FILE.stat().st_size > 1024 * 1024:
lines = LOG_FILE.read_text(errors="replace").splitlines()
LOG_FILE.write_text("\n".join(lines[-1000:]))
logging.info("Log trimmed to last 1000 lines (exceeded 1 MB)")
except Exception:
pass
logger.info("Bot starting — manual < 2 GB · stream ≥ 2 GB · URL download · yt-dlp media → MEDIA-DLS · caption rename")
async def _main():
await app.start()
await register_commands()
asyncio.create_task(ytdlp_update_loop())
logger.info("yt-dlp auto-updater scheduled (every 24 h)")
await idle()
await app.stop()
app.run(_main())