Repository: shamilmyran/sub-telegram Branch: main Commit: 5ee184efdb71 Files: 4 Total size: 104.6 KB Directory structure: gitextract_ds0fbhii/ ├── .gitignore ├── README.md ├── bot.py └── requirements.txt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Python __pycache__/ *.py[cod] *.so *.egg-info/ dist/ build/ # Virtual environment venv/ env/ .env # Bot data & logs bot_data.json bot.log *.log # IDE .vscode/ .idea/ # OS junk .DS_Store Thumbs.db ================================================ FILE: README.md ================================================ # MunaX Subtitle Engine – True Final Boss A lightning‑fast Telegram bot that searches 5 subtitle sources, delivers Malayalam subtitles instantly, and translates any language to Malayalam. ## Features - 🔍 Concurrent search over 5 sources - 🇮🇳 Malayalam‑first results - 🌐 Translate any subtitle to Malayalam - 📢 Broadcast to all users + groups - 🏆 Leaderboard & trending searches - 📥 Subtitle request system - 🔧 Maintenance mode - 🎨 Premium Malayalam UI ## Deploy 1. Clone this repository. 2. Install dependencies: `pip install -r requirements.txt` 3. Set environment variables: `BOT_TOKEN`, `RENDER_URL` (optional). 4. Run `python bot.py`. ## Admin Commands - `/stats` – Bot statistics - `/broadcast` – Send message to all users + groups - `/maintenance on/off` – Toggle maintenance mode - `/requests` – View pending subtitle requests - `/donereq
Kantara\n"
f" • Dune 2021\n"
f" • Breaking Bad S01E05\n\n"
f"──────────────────────────────────────\n"
f"/help · /history · /trending · /request"
)
def ui_join_required(name: str) -> str:
return ui_box("🔒", "Join Required",
f"{escape(name)}, bot use ചെയ്യാൻ ആദ്യം\n"
f"Malayalam Subtitle Hub join ചെയ്യണം.\n\n"
f"Join ചെയ്ത് ✅ Verify press ചെയ്യൂ."
)
def ui_searching(q: str, lines: List[str]) -> str:
progress = "".join(lines)
return (
f"╔══════════════════════════════════════╗\n"
f"║ 🔍 {pb('Searching'):<33}║\n"
f"╚══════════════════════════════════════╝\n\n"
f"🎬 {escape(q[:40])}\n\n"
f"{progress}"
)
def ui_results(q: str, total: int) -> str:
return ui_box("🎬", "Results",
f"🎬 {escape(q[:40])}\n\n"
f"📦 {total} results — ഏത് വേണം?\n"
f"🇮🇳 = Malayalam direct | 🎬 = Other lang"
)
def ui_not_found(q: str) -> str:
return ui_box("❌", "Not Found",
f"🎬 {escape(q)}\n\n"
f"Subtitle ലഭ്യമല്ല.\n\n"
f"💡 Try:\n"
f" • English title use ചെയ്യൂ\n"
f" • Year add ചെയ്യൂ: Dune 2021\n"
f" • Series: BB S01E05\n\n"
f"📥 /request {escape(q)}"
)
def ui_downloading() -> str:
return ui_box("📡", "Downloading",
f"{bar(0.4)} ⚡ Subtitle file fetch ചെയ്യുന്നു…"
)
def ui_sent(title: str) -> str:
return ui_box("✅", "Ready",
f"🎬 {escape(title[:50])}\n\n"
f"⚡ Subtitle ചാറ്റിൽ ready!\n\n"
f"──────────────────────────────────────\n"
f"🌐 Malayalam-ലേക്ക് translate ചെയ്യണോ?"
)
def ui_translate_start() -> str:
return ui_box("🌐", "Translating",
f"{bar(0.1)} ⚡ Translation engine start ചെയ്യുന്നു…\n\n"
f"Large files → ~2 min. Please wait."
)
def ui_translate_progress(done: int, total: int) -> str:
frac = done / total if total else 0
return (
f"╔══════════════════════════════════════╗\n"
f"║ 🌐 {pb('Translating'):<33}║\n"
f"╚══════════════════════════════════════╝\n\n"
f"{bar(frac)} ⚡ {done}/{total} batches\n\n"
f"Please wait…"
)
def ui_translate_done() -> str:
return ui_box("🇮🇳", "Done",
f"✨ Malayalam subtitle ready! ✨\n\n"
f"🎬 Enjoy the movie! 🍿\n\n"
f"──────────────────────────────────────\n"
f"{pb('MUNAX SUBS')} ☠️"
)
def ui_translate_fail() -> str:
return "☠️ Translation failed.\n\nFile too large or service down.\n/translate ചെയ്ത് retry ചെയ്യൂ."
def ui_large_file(size_kb: float) -> str:
return (
f"⚠️ Large file ({size_kb:.0f} KB)\n\n"
f"Translation ~3 min ആകും. Continue?"
)
# ═══════════════════════════════════════════════════════════════
# KEYBOARDS
# ═══════════════════════════════════════════════════════════════
def kb_join() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[InlineKeyboardButton("👥 Malayalam Subtitle Hub Join ചെയ്യൂ", url=GROUP_LINK)],
[InlineKeyboardButton("✅ Join ചെയ്തു — Verify", callback_data="verify")],
])
def kb_translate() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[InlineKeyboardButton("🇮🇳 Malayalam-ലേക്ക് Translate", callback_data="translate")],
[InlineKeyboardButton("✕ വേണ്ട", callback_data="skip")],
])
def kb_back(page: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([[
InlineKeyboardButton("⟨ Results-ലേക്ക് Back", callback_data=f"pg_{page}"),
]])
def kb_large_file() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[InlineKeyboardButton("✅ Continue", callback_data="translate_force")],
[InlineKeyboardButton("✕ Cancel", callback_data="skip")],
])
def kb_results(results: List[dict], page: int, query: str) -> Tuple[InlineKeyboardMarkup, int, int]:
start = page * PAGE_SIZE
chunk = results[start:start + PAGE_SIZE]
total = len(results)
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
rows = []
for i, r in enumerate(chunk):
idx = start + i
title = r.get("title", query)[:52]
badge = "🇮🇳" if "malayalam" in r.get("language", "").lower() else "🎬"
rows.append([InlineKeyboardButton(f"{badge} {title}", callback_data=f"sub_{idx}")])
nav = []
if page > 0:
nav.append(InlineKeyboardButton("⟨ Prev", callback_data=f"pg_{page - 1}"))
nav.append(InlineKeyboardButton(f"{page + 1}/{pages}", callback_data="noop"))
if page < pages - 1:
nav.append(InlineKeyboardButton("Next ⟩", callback_data=f"pg_{page + 1}"))
if nav:
rows.append(nav)
rows.append([InlineKeyboardButton("⟳ New Search", callback_data="new_search")])
return InlineKeyboardMarkup(rows), len(chunk), total
def kb_langs(langs: List[dict], page: int) -> Tuple[InlineKeyboardMarkup, List[dict]]:
sl = sorted(langs, key=lambda x: (0 if x["language"].lower() == "english" else 1, x["language"].lower()))
rows = [[InlineKeyboardButton(f"🌐 {l['language']}", callback_data=f"lang_{i}")] for i, l in enumerate(sl)]
rows.append([InlineKeyboardButton("⟨ Back", callback_data=f"pg_{page}")])
return InlineKeyboardMarkup(rows), sl
# ═══════════════════════════════════════════════════════════════
# LOGGING
# ═══════════════════════════════════════════════════════════════
logging.basicConfig(
format="%(asctime)s [%(levelname)-8s] %(message)s",
datefmt="%H:%M:%S",
level=logging.INFO,
handlers=[
logging.StreamHandler(),
RotatingFileHandler("bot.log", maxBytes=5 * 1024 * 1024, backupCount=3),
],
)
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════════════
# DATABASE
# ═══════════════════════════════════════════════════════════════
class Database:
def __init__(self, path: str = "bot_data.db"):
self.path = path
self.conn: Optional[aiosqlite.Connection] = None
async def init(self):
self.conn = await aiosqlite.connect(self.path)
# [F19] WAL before CREATE TABLE
await self.conn.execute("PRAGMA journal_mode=WAL")
await self.conn.execute("PRAGMA synchronous=NORMAL")
await self.conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY,
username TEXT,
first_name TEXT,
first_seen REAL,
downloads INTEGER DEFAULT 0,
banned BOOLEAN DEFAULT 0
);
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
query TEXT,
source TEXT,
timestamp REAL,
FOREIGN KEY(user_id) REFERENCES users(user_id)
);
CREATE TABLE IF NOT EXISTS groups (group_id INTEGER PRIMARY KEY);
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username TEXT,
title TEXT,
timestamp REAL,
done BOOLEAN DEFAULT 0
);
CREATE TABLE IF NOT EXISTS trending (
query TEXT PRIMARY KEY,
count INTEGER DEFAULT 1,
last_updated REAL
);
CREATE TABLE IF NOT EXISTS user_prefs (
user_id INTEGER PRIMARY KEY,
auto_translate BOOLEAN DEFAULT 0,
default_lang TEXT DEFAULT 'ml'
);
CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT);
CREATE INDEX IF NOT EXISTS idx_history_user ON history(user_id);
CREATE INDEX IF NOT EXISTS idx_requests_done ON requests(done);
CREATE INDEX IF NOT EXISTS idx_trending_last ON trending(last_updated);
""")
# [F04] maintenance row
await self.conn.execute(
"INSERT OR IGNORE INTO config(key,value) VALUES('maintenance','0')"
)
await self.conn.commit()
async def close(self):
if self.conn:
await self.conn.close()
self.conn = None
async def update_user(self, user_id: int, username: str, first_name: str):
await self.conn.execute(
"INSERT INTO users(user_id,username,first_name,first_seen,downloads) VALUES(?,?,?,?,0) "
"ON CONFLICT(user_id) DO UPDATE SET username=excluded.username,first_name=excluded.first_name",
(user_id, username or "", first_name or "", time.time()),
)
await self.conn.commit()
async def add_history(self, user_id: int, query: str, source: str = ""):
await self.conn.execute(
"INSERT INTO history(user_id,query,source,timestamp) VALUES(?,?,?,?)",
(user_id, query, source, time.time()),
)
await self.conn.commit()
await self.conn.execute(
"DELETE FROM history WHERE id NOT IN "
"(SELECT id FROM history WHERE user_id=? ORDER BY timestamp DESC LIMIT ?)",
(user_id, MAX_HISTORY),
)
await self.conn.commit()
async def get_history(self, user_id: int) -> List[Dict]:
cur = await self.conn.execute(
"SELECT query,source,timestamp FROM history WHERE user_id=? ORDER BY timestamp DESC LIMIT ?",
(user_id, MAX_HISTORY),
)
return [{"q": r[0], "src": r[1], "t": r[2]} for r in await cur.fetchall()]
async def increment_downloads(self, user_id: int):
await self.conn.execute(
"UPDATE users SET downloads=downloads+1 WHERE user_id=?", (user_id,)
)
await self.conn.commit()
async def get_user_info(self, user_id: int) -> Optional[Dict]:
cur = await self.conn.execute(
"SELECT username,first_name,first_seen,downloads FROM users WHERE user_id=?",
(user_id,),
)
row = await cur.fetchone()
return (
{"username": row[0], "first_name": row[1], "first_seen": row[2], "downloads": row[3]}
if row else None
)
async def is_banned(self, user_id: int) -> bool:
cur = await self.conn.execute("SELECT banned FROM users WHERE user_id=?", (user_id,))
row = await cur.fetchone()
return bool(row and row[0])
async def ban_user(self, user_id: int):
await self.conn.execute("UPDATE users SET banned=1 WHERE user_id=?", (user_id,))
await self.conn.commit()
async def unban_user(self, user_id: int):
await self.conn.execute("UPDATE users SET banned=0 WHERE user_id=?", (user_id,))
await self.conn.commit()
async def get_banned_users(self) -> List[int]:
cur = await self.conn.execute("SELECT user_id FROM users WHERE banned=1")
return [r[0] for r in await cur.fetchall()]
async def add_group(self, group_id: int):
await self.conn.execute(
"INSERT OR IGNORE INTO groups(group_id) VALUES(?)", (group_id,)
)
await self.conn.commit()
async def get_all_groups(self) -> List[int]:
cur = await self.conn.execute("SELECT group_id FROM groups")
return [r[0] for r in await cur.fetchall()]
async def track_trending(self, query: str):
now = time.time()
await self.conn.execute(
"INSERT INTO trending(query,count,last_updated) VALUES(?,1,?) "
"ON CONFLICT(query) DO UPDATE SET count=count+1,last_updated=?",
(query, now, now),
)
await self.conn.commit()
await self.conn.execute(
"DELETE FROM trending WHERE last_updated", (now - 30 * 86400,)
)
await self.conn.commit()
async def get_trending(self, limit: int = 10) -> List[Tuple[str, int]]:
cur = await self.conn.execute(
"SELECT query,count FROM trending ORDER BY count DESC LIMIT ?", (limit,)
)
return [(r[0], r[1]) for r in await cur.fetchall()]
async def add_request(self, user_id: int, username: str, title: str) -> bool:
cur = await self.conn.execute(
"SELECT COUNT(*) FROM requests WHERE user_id=? AND done=0", (user_id,)
)
if (await cur.fetchone())[0] >= MAX_REQUESTS_PER_USER:
return False
await self.conn.execute(
"INSERT INTO requests(user_id,username,title,timestamp) VALUES(?,?,?,?)",
(user_id, username, title, time.time()),
)
await self.conn.commit()
return True
async def get_pending_requests(self, limit: int = 20) -> List[Dict]:
cur = await self.conn.execute(
"SELECT id,user_id,username,title,timestamp FROM requests "
"WHERE done=0 ORDER BY timestamp DESC LIMIT ?",
(limit,),
)
return [
{"id": r[0], "uid": r[1], "username": r[2], "title": r[3], "t": r[4]}
for r in await cur.fetchall()
]
async def mark_request_done(self, title: str) -> int:
cur = await self.conn.execute(
"UPDATE requests SET done=1 WHERE title LIKE ? AND done=0", (f"%{title}%",)
)
await self.conn.commit()
return cur.rowcount
async def get_leaderboard(self, limit: int = 10) -> List[Tuple[int, str, int]]:
# [F23] returns user_id for accurate rank comparison
cur = await self.conn.execute(
"SELECT user_id,COALESCE(first_name,username,'User'),downloads "
"FROM users WHERE downloads>0 ORDER BY downloads DESC LIMIT ?",
(limit,),
)
return [(r[0], r[1], r[2]) for r in await cur.fetchall()]
async def get_user_pref(self, user_id: int, key: str, default: Any = None) -> Any:
cur = await self.conn.execute(
"SELECT auto_translate,default_lang FROM user_prefs WHERE user_id=?", (user_id,)
)
row = await cur.fetchone()
if not row:
return default
if key == "auto_translate":
return bool(row[0])
if key == "default_lang":
return row[1]
return default
async def set_user_pref(self, user_id: int, key: str, value: Any):
if key == "auto_translate":
await self.conn.execute(
"INSERT INTO user_prefs(user_id,auto_translate) VALUES(?,?) "
"ON CONFLICT(user_id) DO UPDATE SET auto_translate=?",
(user_id, int(value), int(value)),
)
elif key == "default_lang":
await self.conn.execute(
"INSERT INTO user_prefs(user_id,default_lang) VALUES(?,?) "
"ON CONFLICT(user_id) DO UPDATE SET default_lang=?",
(user_id, value, value),
)
await self.conn.commit()
async def get_total_stats(self) -> Tuple[int, int]:
cur = await self.conn.execute("SELECT COUNT(*),SUM(downloads) FROM users")
row = await cur.fetchone()
return (row[0] or 0, row[1] or 0)
async def get_all_user_ids(self) -> List[int]:
cur = await self.conn.execute("SELECT user_id FROM users")
return [r[0] for r in await cur.fetchall()]
async def is_maintenance(self) -> bool:
cur = await self.conn.execute("SELECT value FROM config WHERE key='maintenance'")
row = await cur.fetchone()
return bool(row and row[0] == "1")
async def set_maintenance(self, state: bool):
await self.conn.execute(
"UPDATE config SET value=? WHERE key='maintenance'", ("1" if state else "0",)
)
await self.conn.commit()
# ═══════════════════════════════════════════════════════════════
# UTILITIES
# ═══════════════════════════════════════════════════════════════
def escape(s: str) -> str:
return html.escape(s or "")
def is_valid_srt(content: str) -> bool:
"""[F07] ≥200 chars AND ≥5 subtitle blocks."""
if not content or len(content) < 200:
return False
blocks = [b for b in re.split(r"\n{2,}", content.strip()) if b.strip()]
if len(blocks) < 5:
return False
return bool(
re.search(
r"\d{1,2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{1,2}:\d{2}:\d{2}[,.]\d{3}",
content,
)
)
def vtt_to_srt(content: str) -> str:
"""[F14] Dots escaped (literal dot, not wildcard) in timestamp regex."""
content = re.sub(r"^WEBVTT[^\n]*\n", "", content)
content = re.sub(r"NOTE\b[^\n]*(?:\n[^\n]+)*", "", content)
content = re.sub(r"STYLE\b[^\n]*(?:\n[^\n]+)*", "", content)
content = re.sub(r"(\d{2}:\d{2}:\d{2})\.(\d{3})", r"\1,\2", content) # FIX: \.
content = re.sub(r"\b(\d{2}:\d{2})\.(\d{3})\b", r"00:\1,\2", content) # FIX: \.
content = re.sub(r"(<\d{2}:\d{2}:\d{2}\.\d{3}>)", "", content)
content = re.sub(r"\s+(align|position|line|size|region):[^\s]+", "", content)
content = re.sub(r"<[^>]+>", "", content)
blocks = [b.strip() for b in re.split(r"\n{2,}", content.strip()) if b.strip()]
out, seq = [], 1
for block in blocks:
lines = block.split("\n")
has_tc = any(re.match(r"\d{2}:\d{2}:\d{2},\d{3}", ln) for ln in lines)
if not has_tc:
continue
if lines and not re.match(r"\d{2}:\d{2}:\d{2},\d{3}", lines[0]) and not re.match(r"^\d+$", lines[0]):
lines = lines[1:]
if not lines:
continue
if re.match(r"^\d+$", lines[0]):
lines[0] = str(seq)
else:
lines.insert(0, str(seq))
out.append("\n".join(lines))
seq += 1
return "\n\n".join(out)
def clean_filename(text: str) -> str:
return re.sub(r"\s+", "_", re.sub(r"[^\w\s-]", "", text).strip())[:100]
def make_filename(movie: str, ext: str = ".srt") -> str:
return f"{clean_filename(movie)}{ext}"
def clean_language_name(lang: str) -> str:
lang = re.sub(r"[^\w\s-]", "", lang).strip()
return lang.title() or "Unknown"
def is_movie_query(text: str) -> bool:
t = text.strip()
if len(t) < 2:
return False
if re.search(r"[\u0D00-\u0D7F]", t):
return True
words = t.split()
if len(words) > 9 or t.endswith("?"):
return False
if words and words[0].lower() in {
"what","why","how","when","where","who","is","are",
"can","will","does","did","has","have",
}:
return False
alpha = {w.lower() for w in words if w.isalpha()}
if alpha and alpha.issubset(CHAT_WORDS):
return False
if re.search(r"\b(19|20)\d{2}\b", t):
return True
if re.search(r"\bS\d{1,2}E\d{1,2}\b", t, re.I):
return True
if re.search(r"\bseason\s+\d+\b", t, re.I):
return True
if any(w[0].isupper() for w in words if w.isalpha()):
return True
non_chat = [w for w in alpha if len(w) >= 3 and w not in CHAT_WORDS]
return bool(non_chat)
def is_clean_query(t: str) -> bool:
return not any(b in t.lower() for b in BANNED_TERMS)
def _score_title(result_title: str, query: str) -> int:
"""[F28] Proper title similarity score."""
_NOISE = {"the","a","an","of","and","or","in","on","at","to","for","with","part"}
def _norm(s: str) -> str:
s = re.sub(r"[^a-z0-9\s]", " ", s.lower())
return re.sub(r"\s+", " ", s).strip()
rt, qt = _norm(result_title), _norm(query)
rt_w, qt_w = [w for w in rt.split() if w not in _NOISE], [w for w in qt.split() if w not in _NOISE]
if not qt_w:
return 0
if rt == qt:
return 100
matched = sum(1 for w in qt_w if w in rt_w)
if matched == len(qt_w):
return 80 + matched
year_m = re.search(r"\b(19|20)\d{2}\b", query)
if year_m and year_m.group() in result_title:
return 60 + matched
if matched >= max(1, len(qt_w) // 2):
return 40 + matched
if qt.replace(" ", "") in rt.replace(" ", ""):
return 30
return 0
def rank_results(results: List[dict], query: str) -> List[dict]:
for item in results:
lang = item.get("language", "").lower()
if "malayalam" in lang or lang == "ml":
item["_score"] = 1000
else:
item["_score"] = _score_title(item.get("title", ""), query)
return sorted(results, key=lambda x: x["_score"], reverse=True)
def deduplicate(results: List[dict]) -> List[dict]:
"""[F11] Remove entries with identical download URLs."""
seen: set = set()
out: List[dict] = []
for r in results:
url = r.get("download", r.get("url", ""))
if url and url not in seen:
seen.add(url)
out.append(r)
elif not url:
out.append(r)
return out
# ═══════════════════════════════════════════════════════════════
# HTTP
# ═══════════════════════════════════════════════════════════════
def http_get(
url: str,
params: dict = None,
timeout: int = 60,
retries: int = 3,
headers: dict = None,
) -> Optional[requests.Response]:
if headers is None:
headers = {"User-Agent": "Mozilla/5.0"}
proxy_pool = PROXY_LIST[:] if PROXY_LIST else [None]
for attempt in range(retries):
proxy = random.choice(proxy_pool) if proxy_pool else None
proxies = {"http": proxy, "https": proxy} if proxy else None
try:
r = requests.get(url, params=params, timeout=timeout, headers=headers, proxies=proxies)
r.raise_for_status()
return r
except Exception:
if attempt == retries - 1:
return None
time.sleep(2 ** attempt)
return None
_SCRAPE_SESSION = requests.Session()
_SCRAPE_SESSION.headers.update({"User-Agent": "Mozilla/5.0", "Accept-Language": "en-US,en;q=0.9"})
def _scrape_get(url: str, params: dict = None, timeout: int = 15) -> Optional[requests.Response]:
"""[F16] Exponential backoff between retries."""
for attempt in range(3):
try:
r = _SCRAPE_SESSION.get(url, params=params, timeout=timeout, allow_redirects=True)
r.raise_for_status()
if len(r.content) < 100:
return None
return r
except Exception:
if attempt == 2:
return None
time.sleep(2 ** attempt)
return None
# ═══════════════════════════════════════════════════════════════
# FILE DOWNLOAD
# ═══════════════════════════════════════════════════════════════
def extract_zip_srt(zip_path: str) -> Optional[str]:
try:
with zipfile.ZipFile(zip_path, "r") as z:
srts = [n for n in z.namelist() if n.lower().endswith(".srt")]
if not srts:
return None
target = next((n for n in srts if "mal" in n.lower()), srts[0])
data = z.read(target)
if len(data) > MAX_FILE_SIZE:
return None
tmp = tempfile.NamedTemporaryFile(mode="wb", suffix=".srt", delete=False)
tmp.write(data)
tmp.close()
return tmp.name
except Exception:
return None
def download_file(url: str, depth: int = 0) -> Tuple[Optional[str], Optional[str]]:
"""[F21] Relative URLs resolved via urljoin."""
if depth > 3:
return None, "Download failed"
for headers in [
{"User-Agent": "Mozilla/5.0"},
{"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)"},
]:
r = http_get(url, timeout=60, headers=headers)
if not r:
continue
content = r.content
if len(content) > MAX_FILE_SIZE:
return None, "File too large (>5 MB)"
head = content[:600].decode("utf-8", errors="ignore").lower()
if content[:2] == b"PK":
tmp = tempfile.NamedTemporaryFile(mode="wb", suffix=".zip", delete=False)
tmp.write(content)
tmp.close()
srt = extract_zip_srt(tmp.name)
with contextlib.suppress(OSError):
os.unlink(tmp.name)
return (srt, None) if srt else (None, "Invalid ZIP")
if " Optional[str]:
"""[F12] Min 100 chars guard."""
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
c = f.read()
return c if len(c) > 100 else None
except Exception:
return None
async def send_subtitle_file(
bot, chat_id: int, path: str, filename: str,
caption: str, user_id: int, db: Database,
) -> bool:
try:
with open(path, "rb") as fp:
await bot.send_document(
chat_id, document=fp, filename=filename,
caption=caption, parse_mode="HTML",
)
await db.increment_downloads(user_id)
return True
except Exception as e:
logger.error(f"Send failed: {e}")
try:
await bot.send_message(chat_id, "⚠️ File send failed. Please try again.")
except Exception:
pass
return False
finally:
with contextlib.suppress(OSError):
os.unlink(path)
# ═══════════════════════════════════════════════════════════════
# SOURCES
# ═══════════════════════════════════════════════════════════════
async def _retry_source(func: Callable, q: str, retries: int = 2) -> List[dict]:
"""Retry a synchronous source function with backoff."""
for attempt in range(retries):
try:
result = await asyncio.to_thread(func, q)
if result:
return result
if attempt < retries - 1:
await asyncio.sleep(1)
except Exception as e:
logger.warning(f"{func.__name__} attempt {attempt + 1}: {e}")
if attempt == retries - 1:
return []
return []
def search_wyzie(q: str) -> List[dict]:
if not WYZIE_API_KEY or not OMDB_API_KEY:
return []
try:
clean_q = re.sub(r"\bS\d{1,2}E\d{1,2}\b", "", q, flags=re.I).strip()
r = http_get("https://www.omdbapi.com/", # [F15] HTTPS
params={"apikey": OMDB_API_KEY, "t": clean_q}, timeout=10)
if not r or r.json().get("Response") != "True":
return []
imdb_id = r.json().get("imdbID")
r2 = http_get("https://sub.wyzie.io/search",
params={"id": imdb_id, "source": "all", "key": WYZIE_API_KEY}, timeout=30)
if not r2 or not isinstance(r2.json(), list):
return []
results = []
for sub in r2.json():
lang = clean_language_name(sub.get("display", sub.get("language", "")))
dl = sub.get("url")
if not dl:
continue
results.append({
"_type": "wyzie", "title": f"{q} ({lang})",
"language": lang, "download": dl,
})
return results
except Exception as e:
logger.warning(f"Wyzie: {e}")
return []
def search_goat(q: str) -> List[dict]:
"""[F17] Uses _scrape_get (retry + session)."""
try:
r = _scrape_get(
f"https://malayalamsubtitles.in/search-and-download?search={quote_plus(q)}",
timeout=15,
)
if not r:
return []
soup = BeautifulSoup(r.text, "html.parser")
results, seen = [], set()
for a in soup.find_all("a", href=True):
href = a["href"]
if "/download/" in href and href not in seen:
title = a.get_text(strip=True)
if title and len(title) > 5 and title not in ["ഡൗൺലോഡ്", "Download"]:
results.append({
"_type": "goat", "title": title[:60],
"download": href, "language": "Malayalam",
})
seen.add(href)
return results[:10]
except Exception as e:
logger.warning(f"Team GOAT: {e}")
return []
def search_malsub_org(q: str) -> List[dict]:
try:
r = _scrape_get(f"https://malayalamsubtitles.org/?s={quote_plus(q)}", timeout=15)
if not r:
return []
soup = BeautifulSoup(r.text, "html.parser")
results = []
for a in soup.find_all("a", href=True):
href = a["href"]
if "?wpdmdl=" in href:
title = a.get_text(strip=True)
if title and len(title) > 3 and not any(
x in title.lower() for x in ["പരിഭാഷകൾ", "send", "submit"]
):
results.append({
"_type": "malsub", "title": title[:60],
"download": href, "language": "Malayalam",
})
return results[:10]
except Exception as e:
logger.warning(f"MalSub.org: {e}")
return []
def search_jerry(q: str) -> List[dict]:
try:
r = http_get(JERRY_SEARCH, params={"query": q}, timeout=15)
if r:
data = r.json()
if data.get("status") and isinstance(data.get("data"), list):
return [
{"_type": "jerry", "title": i.get("title", q)[:60], "url": i.get("url", "")}
for i in data["data"] if i.get("url")
]
except Exception as e:
logger.warning(f"Jerry: {e}")
return []
def get_jerry_langs(url: str) -> List[dict]:
try:
r = http_get(JERRY_DOWNLOAD, params={"url": url}, timeout=15)
if r:
data = r.json()
if data.get("status") and isinstance(data.get("data"), list):
return [
{"language": clean_language_name(l.get("language", "")), "download": l.get("url", "")}
for l in data["data"] if l.get("url")
]
except Exception as e:
logger.warning(f"Jerry langs: {e}")
return []
def search_ironman(q: str) -> List[dict]:
try:
r = http_get(IRON_SEARCH, params={"query": q}, timeout=15)
if r and isinstance(r.json(), list):
return [
{"_type": "ironman", "title": i.get("title", q)[:60], "url": i.get("url", "")}
for i in r.json() if i.get("url")
]
except Exception as e:
logger.warning(f"Ironman: {e}")
return []
def get_ironman_langs(url: str) -> List[dict]:
try:
r = http_get(IRON_DOWNLOAD, params={"url": url}, timeout=15)
if r and isinstance(r.json(), list):
return [
{"language": clean_language_name(l.get("language", "")), "download": l.get("url", "")}
for l in r.json()
]
except Exception as e:
logger.warning(f"Ironman langs: {e}")
return []
# ═══════════════════════════════════════════════════════════════
# TRANSLATION
# ═══════════════════════════════════════════════════════════════
def _deep_block(text: str, lang: str) -> str:
"""[F25] Chunks text >4800 chars instead of truncating."""
try:
translator = GoogleTranslator(source="auto", target=lang)
if len(text) <= 4800:
return translator.translate(text) or text
chunks = [text[i:i + 4800] for i in range(0, len(text), 4800)]
return "".join(translator.translate(c) or c for c in chunks)
except Exception:
return text
async def _gtx_batch(
client: httpx.AsyncClient,
sem: asyncio.Semaphore,
batch: List[str],
lang: str,
retries: int = 3,
) -> List[str]:
if not batch:
return []
url = "https://translate.googleapis.com/translate_a/single"
params = {"client": "gtx", "sl": "auto", "tl": lang, "dt": "t"}
data = [("q", t) for t in batch]
async with sem:
for attempt in range(retries):
try:
resp = await client.post(url, params=params, data=data, timeout=30.0)
if resp.status_code == 429:
await asyncio.sleep(2 ** attempt)
continue
resp.raise_for_status()
raw = resp.json()
out = [seg[0] for seg in raw[0] if seg and seg[0]]
if len(out) < len(batch):
out += batch[len(out):]
return out[: len(batch)]
except Exception:
if attempt == retries - 1:
return []
await asyncio.sleep(2 ** attempt)
return []
async def translate_srt_async(
content: str,
lang: str = "ml",
progress_msg: Optional[Message] = None,
) -> Optional[str]:
if not content or len(content) < 100:
return None
blocks = re.split(r"\n{2,}", content.strip())
if not blocks:
return None
texts = []
for block in blocks:
lines = block.strip().split("\n")
if len(lines) >= 3 and re.match(r"\d{1,2}:\d{2}:\d{2}[,.]", lines[1]):
texts.append("\n".join(lines[2:]))
else:
texts.append(block)
BATCH = 50
batches = [texts[i:i + BATCH] for i in range(0, len(texts), BATCH)]
total_b = len(batches)
sem = asyncio.Semaphore(10)
done = 0
last_edit = [0.0]
async def _run(client: httpx.AsyncClient, batch: List[str]) -> List[str]:
nonlocal done
res = await _gtx_batch(client, sem, batch, lang)
done += 1
if progress_msg:
now = asyncio.get_event_loop().time()
if now - last_edit[0] >= 1.5: # throttle edits
last_edit[0] = now
with contextlib.suppress(TelegramError):
await progress_msg.edit_text(
ui_translate_progress(done, total_b), parse_mode="HTML"
)
return res
async with httpx.AsyncClient() as client:
raw = await asyncio.gather(*[_run(client, b) for b in batches])
translated: List[str] = []
for batch, res in zip(batches, raw):
if res:
translated.extend(res)
else:
fb = await asyncio.gather(
*[asyncio.to_thread(_deep_block, t, lang) for t in batch]
)
translated.extend(fb)
while len(translated) < len(blocks):
translated.append(blocks[len(translated)])
out = []
for block, tr in zip(blocks, translated):
lines = block.strip().split("\n")
if len(lines) >= 3 and re.match(r"\d{1,2}:\d{2}:\d{2}[,.]", lines[1]):
out.append(f"{lines[0]}\n{lines[1]}\n{tr}")
else:
out.append(tr)
return "\n\n".join(out)
# ═══════════════════════════════════════════════════════════════
# HELPERS
# ═══════════════════════════════════════════════════════════════
async def _try_send(bot, chat_id: int, text: str) -> bool:
try:
await bot.send_message(chat_id=chat_id, text=text, parse_mode="HTML")
return True
except Exception as e:
logger.warning(f"Send {chat_id}: {e}")
return False
# ═══════════════════════════════════════════════════════════════
# BOT HANDLERS
# ═══════════════════════════════════════════════════════════════
class BotHandlers:
def __init__(self, db: Database):
self.db = db
self.search_cache: Dict[str, Tuple[Any, float]] = {}
self.cache_lock = asyncio.Lock()
# [F02] All asyncio primitives inside __init__ (inside event loop)
self._search_sem = asyncio.Semaphore(MAX_CONCURRENT_SEARCH)
self._translation_sem = asyncio.Semaphore(MAX_CONCURRENT_TRANSLATIONS)
self._membership_lock = asyncio.Lock()
self._membership_cache: Dict[int, Tuple[bool, float]] = {}
self._rate_lock = asyncio.Lock()
self._rate_last: Dict[int, float] = {}
# ── Rate limiter ─────────────────────────────────────────────
async def _is_limited(self, user_id: int) -> bool:
async with self._rate_lock:
now = time.time()
last = self._rate_last.get(user_id, 0)
if now - last < RATE_LIMIT_S:
return True
self._rate_last[user_id] = now
return False
async def _wait_sec(self, user_id: int) -> int:
async with self._rate_lock:
now = time.time()
last = self._rate_last.get(user_id, 0)
return max(1, int(RATE_LIMIT_S - (now - last)))
# ── Membership ───────────────────────────────────────────────
async def _check_membership(self, bot, user_id: int, force: bool = False) -> bool:
if user_id in ADMIN_IDS:
return True
now = time.time()
async with self._membership_lock:
if not force and user_id in self._membership_cache:
status, ts = self._membership_cache[user_id]
if now - ts < MEMBERSHIP_CACHE_TTL:
return status
try:
member = await bot.get_chat_member(GROUP_ID, user_id)
status = member.status in ("member", "administrator", "creator")
except Exception as e:
logger.warning(f"Membership check {user_id}: {e}")
status = True # fail-open
async with self._membership_lock:
self._membership_cache[user_id] = (status, now)
return status
# ── Bounded cache ────────────────────────────────────────────
async def _cache_set(self, key: str, value: Any):
"""[F18] LRU eviction when cache full."""
if len(self.search_cache) >= MAX_CACHE_ENTRIES:
oldest = min(self.search_cache, key=lambda k: self.search_cache[k][1])
del self.search_cache[oldest]
self.search_cache[key] = value
# ── Gate user ────────────────────────────────────────────────
async def gate_user(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> bool:
user = update.effective_user
if not user:
return False
if await self.db.is_banned(user.id):
if update.message:
await update.message.reply_text("🚫 Access revoked.")
return False
if await self.db.is_maintenance() and user.id not in ADMIN_IDS:
if update.message:
await update.message.reply_text(
"🔧 Maintenance mode.\n\nEngine updating. Please wait. 🙏",
parse_mode="HTML",
)
return False
if not await self._check_membership(ctx.bot, user.id):
if update.message:
# [F10] correct join-required message (not start screen)
await update.message.reply_text(
ui_join_required(user.first_name or "User"),
reply_markup=kb_join(),
parse_mode="HTML",
)
return False
return True
# ── Parallel search with live progress ──────────────────────
async def _search_all_live(self, q: str, status_msg: Message) -> Tuple:
"""[F09] Progress callback throttled. [F24] Uses instance semaphore."""
sources = [
("Wyzie", search_wyzie),
("Team GOAT",search_goat),
("MSone", search_malsub_org),
("SubScene", search_jerry),
("OpenSub", search_ironman),
]
results = [[] for _ in sources]
done_flag = [False] * len(sources)
edit_lock = asyncio.Lock()
last_edit = [0.0]
async def _update():
async with edit_lock:
now = asyncio.get_event_loop().time()
if now - last_edit[0] < 1.2:
return
last_edit[0] = now
lines = [
f"{'✅' if done_flag[i] else '⏳'} {name}\n"
for i, (name, _) in enumerate(sources)
]
with contextlib.suppress(TelegramError):
await status_msg.edit_text(
ui_searching(q, lines), parse_mode="HTML"
)
async def _run(idx: int, func: Callable):
async with self._search_sem:
res = await _retry_source(func, q)
results[idx] = res
done_flag[idx] = True
await _update()
await asyncio.gather(*[_run(i, fn) for i, (_, fn) in enumerate(sources)])
return tuple(results)
# ── Core search ──────────────────────────────────────────────
async def _run_search(
self, query: str, status_msg: Message,
ctx: ContextTypes.DEFAULT_TYPE, user_id: int,
):
ctx.user_data["movie_name"] = query
ctx.user_data["user_id"] = user_id
await self.db.track_trending(query)
cache_key = f"raw_{query.lower()}"
async with self.cache_lock:
cached = self.search_cache.get(cache_key)
need_fetch = not (cached and time.time() - cached[1] < CACHE_TTL)
if need_fetch:
fetched = await self._search_all_live(query, status_msg)
async with self.cache_lock:
await self._cache_set(cache_key, (fetched, time.time()))
wyzie, goat, malsub, jerry, ironman = fetched
else:
wyzie, goat, malsub, jerry, ironman = cached[0]
with contextlib.suppress(TelegramError):
await status_msg.edit_text(
ui_searching(query, ["⚡ Using cached results…\n"]),
parse_mode="HTML",
)
# Combine, rank, dedup
all_results = deduplicate(
rank_results(
[item for src in [wyzie, goat, malsub, jerry, ironman] for item in src],
query,
)
)[:60]
# [F08][F26] Malayalam fast-path with retry up to 3 sources
mal_items = [r for r in all_results if "malayalam" in r.get("language", "").lower()]
if mal_items:
with contextlib.suppress(TelegramError):
await status_msg.edit_text(
"🇮🇳 Malayalam subtitle കിട്ടി!\n📡 Download ചെയ്യുന്നു…",
parse_mode="HTML",
)
path = content = mal_item = None
for candidate in mal_items[:3]:
p, _ = await asyncio.to_thread(download_file, candidate["download"])
if p:
c = read_file_content(p)
if is_valid_srt(c or ""):
path, content, mal_item = p, c, candidate
break
with contextlib.suppress(OSError):
os.unlink(p)
if path and mal_item:
fname = make_filename(query, ".srt")
src_label = SOURCE_LABEL.get(mal_item["_type"], mal_item["_type"])
ctx.user_data["last_content"] = content
caption = (
f"🎬 {escape(query)}\n"
f"✦ Malayalam Subtitle\nvia {src_label}"
)
sent = await send_subtitle_file(
ctx.bot, status_msg.chat_id, path, fname, caption, user_id, self.db
)
if sent:
await self.db.add_history(user_id, query, "Malayalam")
with contextlib.suppress(TelegramError):
await status_msg.delete()
lang = await self.db.get_user_pref(user_id, "default_lang", "ml")
if await self.db.get_user_pref(user_id, "auto_translate", False):
prog = await ctx.bot.send_message(
status_msg.chat_id, ui_translate_start(), parse_mode="HTML"
)
await self._run_translation(
ctx, status_msg.chat_id, prog, content, query, user_id, lang
)
else:
await ctx.bot.send_message(
status_msg.chat_id,
ui_sent(query),
reply_markup=kb_translate(),
parse_mode="HTML",
)
return
# all Malayalam attempts failed — fall to results list
if not all_results:
with contextlib.suppress(TelegramError):
await status_msg.edit_text(ui_not_found(query), parse_mode="HTML")
return
ctx.user_data["results"] = all_results
ctx.user_data["results_page"] = 0
kb, _, total = kb_results(all_results, 0, query)
with contextlib.suppress(TelegramError):
await status_msg.edit_text(
ui_results(query, total), reply_markup=kb, parse_mode="HTML"
)
# ── Translation runner ───────────────────────────────────────
async def _run_translation(
self,
ctx: ContextTypes.DEFAULT_TYPE,
chat_id: int,
progress_msg: Optional[Message],
content: str,
movie_name: str,
user_id: int,
lang: str = "ml", # [F06] respects user's default_lang
):
async with self._translation_sem:
try:
if not progress_msg:
progress_msg = await ctx.bot.send_message(
chat_id, ui_translate_start(), parse_mode="HTML"
)
translated = await asyncio.wait_for(
translate_srt_async(content, lang, progress_msg),
timeout=TRANSLATE_TIMEOUT,
)
if translated:
fname = make_filename(movie_name, ".srt")
with tempfile.NamedTemporaryFile(
mode="w", suffix=".srt", delete=False, encoding="utf-8"
) as tmp:
tmp.write(translated)
tmp_path = tmp.name
lang_name = LANG_MAP.get(lang, lang.upper())
caption = (
f"🎬 {escape(movie_name)}\n"
f"🌐 {lang_name} (Auto-Translated)\n"
f"via MunaX Engine\n"
f"⚠️ Auto-translated — quality may vary"
)
sent = await send_subtitle_file(
ctx.bot, chat_id, tmp_path, fname, caption, user_id, self.db
)
with contextlib.suppress(TelegramError):
await progress_msg.edit_text(
ui_translate_done() if sent else ui_translate_fail(),
parse_mode="HTML",
)
else:
with contextlib.suppress(TelegramError):
await progress_msg.edit_text(ui_translate_fail(), parse_mode="HTML")
except asyncio.TimeoutError:
with contextlib.suppress(TelegramError):
await progress_msg.edit_text(ui_translate_fail(), parse_mode="HTML")
except Exception as e:
logger.error(f"Translation error: {e}")
with contextlib.suppress(TelegramError):
await progress_msg.edit_text(ui_translate_fail(), parse_mode="HTML")
# ════════════════════════════════════════════════════════════
# USER COMMANDS
# ════════════════════════════════════════════════════════════
async def cmd_start(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
if not user or await self.db.is_banned(user.id):
return
await self.db.update_user(user.id, user.username, user.first_name)
if not await self._check_membership(ctx.bot, user.id):
await update.message.reply_text(
ui_join_required(user.first_name or "User"),
reply_markup=kb_join(), parse_mode="HTML",
)
return
info = await self.db.get_user_info(user.id)
downloads = info.get("downloads", 0) if info else 0
await update.message.reply_text(
ui_start(user.first_name or "User", downloads), parse_mode="HTML"
)
async def cmd_help(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
# [F05] HTML parse_mode (not Markdown)
await update.message.reply_text(
"📖 How to use MunaX Subs\n\n"
"1. Sinima / series title type ചെയ്യൂ\n"
"2. 5 sources ഒരേ സമയം search ആകും\n"
"3. Malayalam subtitle → ഉടൻ download\n"
"4. ഇല്ലെങ്കിൽ → language select → translate\n\n"
"──────────────────────────────────────\n"
"Tips:\n"
" → English title use ചെയ്യൂ\n"
" → Year add ചെയ്യൂ: Avatar 2009\n"
" → Series: Breaking Bad S01E05\n\n"
"──────────────────────────────────────\n"
"Commands\n"
" /search <movie> – Manual search\n"
" /translate – Translate last subtitle\n"
" /cancel – Reset session\n"
" /history – Recent downloads\n"
" /mystats – Your stats\n"
" /leaderboard – Top downloaders\n"
" /trending – Popular searches\n"
" /request <title> – Request a subtitle\n"
" /prefs – Preferences\n"
" /donate – Support the bot\n"
" /group – Community link\n"
" /report <text> – Bug report",
parse_mode="HTML",
)
async def cmd_cancel(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
ctx.user_data.clear()
await update.message.reply_text(
"✅ Session reset.\n\nSinima title type ചെയ്യൂ.",
parse_mode="HTML",
)
async def cmd_search(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
user = update.effective_user
query = " ".join(ctx.args).strip() if ctx.args else ""
if not query:
await update.message.reply_text(
"🔍 Usage: /search Movie Title\n\n"
"Or just type the title directly in chat.",
parse_mode="HTML",
)
return
if not is_clean_query(query):
await update.message.reply_text("🚫 Query not allowed.")
return
if await self._is_limited(user.id):
w = await self._wait_sec(user.id)
await update.message.reply_text(f"⏳ Please wait {w}s.", parse_mode="HTML")
return
await self.db.update_user(user.id, user.username, user.first_name)
await update.effective_chat.send_action(constants.ChatAction.TYPING)
lines = [f"⏳ {n}\n" for n in ["Wyzie","Team GOAT","MSone","SubScene","OpenSub"]]
status = await update.message.reply_text(
ui_searching(query, lines), parse_mode="HTML"
)
await self._run_search(query, status, ctx, user.id)
async def cmd_translate(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
user = update.effective_user
content = ctx.user_data.get("last_content")
if not content:
await update.message.reply_text(
"📭 Nothing to translate. Download a subtitle first."
)
return
if not is_valid_srt(content):
await update.message.reply_text(
"⚠️ Last file is not a valid subtitle. Download a subtitle first."
)
return
movie_name = ctx.user_data.get("movie_name", "subtitle")
chat_id = update.effective_chat.id
size_kb = len(content) / 1024
# [F06] fetch user's preferred language
lang = await self.db.get_user_pref(user.id, "default_lang", "ml")
ctx.user_data["_tr_chat_id"] = chat_id
ctx.user_data["_tr_movie_name"] = movie_name
ctx.user_data["_tr_lang"] = lang
ctx.user_data["_tr_content"] = content
if size_kb > 500:
await update.message.reply_text(
ui_large_file(size_kb), reply_markup=kb_large_file(), parse_mode="HTML"
)
return
prog = await update.message.reply_text(ui_translate_start(), parse_mode="HTML")
await self._run_translation(ctx, chat_id, prog, content, movie_name, user.id, lang)
async def cmd_history(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
hist = await self.db.get_history(update.effective_user.id)
if not hist:
await update.message.reply_text(
"📋 Download History\n\nഇതുവരെ ഒന്നും ഇല്ല.\n\nഒരു title search ചെയ്യൂ!",
parse_mode="HTML",
)
return
lines = []
for i, e in enumerate(hist, 1):
dt = datetime.fromtimestamp(e["t"]).strftime("%d %b")
src = f" · {escape(e['src'])}" if e.get("src") else ""
lines.append(f" {i:02d}. {escape(e['q'])}{src} {dt}")
await update.message.reply_text(
f"📋 Your Archive\n──────────────────────────────────────\n\n"
+ "\n".join(lines)
+ f"\n\nLast {len(hist)} downloads.",
parse_mode="HTML",
)
async def cmd_mystats(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
uid = update.effective_user.id
info = await self.db.get_user_info(uid)
if not info:
await update.message.reply_text(
"📊 Your Stats\n\nDownloads ഒന്നും ഇല്ല.\n\nഒരു sinima search ചെയ്യൂ!",
parse_mode="HTML",
)
return
first = datetime.fromtimestamp(info["first_seen"]).strftime("%d %b %Y")
dls = info.get("downloads", 0)
leaders = await self.db.get_leaderboard(50)
# [F23] compare by user_id
rank = next((i + 1 for i, (lid, _, _) in enumerate(leaders) if lid == uid), "?")
await update.message.reply_text(
f"📊 Your Stats\n──────────────────────────────────────\n\n"
f" 🎬 Downloads: {dls}\n"
f" 🏆 Rank: #{rank}\n"
f" 🕐 First visit: {first}",
parse_mode="HTML",
)
async def cmd_leaderboard(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
leaders = await self.db.get_leaderboard(10)
if not leaders:
await update.message.reply_text(
"🏆 Leaderboard\n\nഇതുവരെ downloads ഒന്നും ഇല്ല.",
parse_mode="HTML",
)
return
medals = ["🥇","🥈","🥉"] + ["🎬"] * 7
lines = [
f" {medals[i]} {escape(name[:20])} — {dl} downloads"
for i, (_, name, dl) in enumerate(leaders)
]
my_dls = (await self.db.get_user_info(update.effective_user.id) or {}).get("downloads", 0)
await update.message.reply_text(
"🏆 Top Downloaders\n──────────────────────────────────────\n\n"
+ "\n".join(lines)
+ f"\n\nYours: {my_dls} downloads",
parse_mode="HTML",
)
async def cmd_trending(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
trends = await self.db.get_trending(10)
if not trends:
await update.message.reply_text(
"🔥 Trending\n\nഇതുവരെ data ഇല്ല.", parse_mode="HTML"
)
return
lines = [
f" {i+1:02d}. {escape(q)} ({c}×)"
for i, (q, c) in enumerate(trends)
]
await update.message.reply_text(
"🔥 Trending Searches\n──────────────────────────────────────\n\n"
+ "\n".join(lines)
+ "\n\nThis month's popular titles.",
parse_mode="HTML",
)
async def cmd_request(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
user = update.effective_user
title = " ".join(ctx.args).strip() if ctx.args else ""
if not title:
await update.message.reply_text(
"📥 Subtitle Request\n\n"
"Usage: /request Movie Title\n\n"
"കിട്ടാത്ത subtitles request ചെയ്യൂ.",
parse_mode="HTML",
)
return
if not is_clean_query(title):
await update.message.reply_text("🚫 Title not allowed.")
return
saved = await self.db.add_request(user.id, user.username or "", title)
if not saved:
await update.message.reply_text(
f"⚠️ Request limit ({MAX_REQUESTS_PER_USER}) reached.\n\n"
f"Wait for previous requests to be processed.",
parse_mode="HTML",
)
return
await update.message.reply_text(
f"📥 Request saved!\n\n"
f"{escape(title)} — recorded.\n"
f"Team check ചെയ്ത് available ആക്കും.\n\n"
f"Updates: {GROUP_LINK}",
parse_mode="HTML",
disable_web_page_preview=True,
)
admin_msg = (
f"📥 New Request\n"
f"──────────────────────────────────────\n"
f" 👤 {escape(user.first_name or 'Unknown')} (@{user.username or 'none'})\n"
f" 🎬 {escape(title)}\n"
f" 🕐 {datetime.now().strftime('%d %b %Y %H:%M')}"
)
for aid in ADMIN_IDS:
await _try_send(ctx.bot, aid, admin_msg)
async def cmd_donate(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
await update.message.reply_text(
"💛 Support MunaX Subs\n──────────────────────────────────────\n\n"
"Real servers, real costs. ഒരു sinima save ആക്കിയിട്ടുണ്ടോ? ❤️\n\n"
f" 🏦 UPI: {UPI_ID}\n\n"
" 📸 Instagram\n\n"
"ഓരോ rupee-യും matter ആകും. 🙏",
parse_mode="HTML",
disable_web_page_preview=True,
)
async def cmd_group(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(
"👥 Malayalam Subtitle Hub\n──────────────────────────────────────\n\n"
"Subtitle requests, bug reports, movie talk — everything.\n\n"
f"🔗 {GROUP_LINK}\n\nCome join us! 🎬",
parse_mode="HTML",
)
async def cmd_report(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
user = update.effective_user
text = " ".join(ctx.args).strip() if ctx.args else ""
if not text:
await update.message.reply_text(
"🚨 Bug / Problem Report\n\n"
"Usage:\n/report Kantara subtitle download ആകുന്നില്ല",
parse_mode="HTML",
)
return
admin_msg = (
f"🚨 User Report\n"
f"──────────────────────────────────────\n"
f" 👤 {escape(user.first_name or 'Unknown')} (@{user.username or 'none'})\n"
f" 🆔 {user.id}\n"
f" 🕐 {datetime.now().strftime('%d %b %Y %H:%M')}\n\n"
f"{escape(text)}"
)
outcomes = await asyncio.gather(*[_try_send(ctx.bot, aid, admin_msg) for aid in ADMIN_IDS])
await update.message.reply_text(
"✅ Report received. Team-നെ അറിയിച്ചു. 🙏"
if any(outcomes) else
"⚠️ Deliver failed. Contact @munax9 directly.",
parse_mode="HTML",
)
async def cmd_feedback(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
user = update.effective_user
fb = " ".join(ctx.args).strip() if ctx.args else ""
if not fb:
await update.message.reply_text(
"📝 Send Feedback\n\nUsage: /feedback Your message",
parse_mode="HTML",
)
return
admin_msg = (
f"💬 Feedback\n"
f"──────────────────────────────────────\n"
f" 👤 {escape(user.first_name or 'Unknown')} (@{user.username or 'none'})\n"
f" 🆔 {user.id}\n\n{escape(fb)}"
)
outcomes = await asyncio.gather(*[_try_send(ctx.bot, aid, admin_msg) for aid in ADMIN_IDS])
await update.message.reply_text(
"📨 Feedback sent. Thanks!" if any(outcomes) else "⚠️ Deliver failed.",
parse_mode="HTML",
)
async def cmd_prefs(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await self.gate_user(update, ctx):
return
user = update.effective_user
args = ctx.args or []
if not args:
auto = await self.db.get_user_pref(user.id, "auto_translate", False)
lang = await self.db.get_user_pref(user.id, "default_lang", "ml")
await update.message.reply_text(
"⚙️ Preferences\n──────────────────────────────────────\n\n"
f" 🔁 Auto-translate: {'On' if auto else 'Off'}\n"
f" 🌐 Default lang: {LANG_MAP.get(lang, lang)}\n\n"
"Change:\n"
" /prefs auto on|off\n"
" /prefs lang ml|en|hi|ta|…",
parse_mode="HTML",
)
return
if args[0] == "auto":
if len(args) < 2 or args[1] not in ("on", "off"):
await update.message.reply_text("Usage: /prefs auto on|off")
return
await self.db.set_user_pref(user.id, "auto_translate", args[1] == "on")
await update.message.reply_text("✅ Preferences updated.", parse_mode="HTML")
elif args[0] == "lang":
if len(args) < 2 or args[1] not in LANG_MAP:
await update.message.reply_text(
f"Usage: /prefs lang {'|'.join(list(LANG_MAP.keys())[:12])}|…",
parse_mode="HTML",
)
return
await self.db.set_user_pref(user.id, "default_lang", args[1])
await update.message.reply_text("✅ Preferences updated.", parse_mode="HTML")
else:
await update.message.reply_text("Unknown option. Use /prefs auto|lang")
# ════════════════════════════════════════════════════════════
# ADMIN COMMANDS — all fully implemented [F01]
# ════════════════════════════════════════════════════════════
async def cmd_admin(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
await update.message.reply_text("🚫 Access denied.")
return
await update.message.reply_text(
"🛡️ Admin Panel\n──────────────────────────────────────\n\n"
"/stats – Bot statistics\n"
"/ping – Response time\n"
"/broadcast <msg> – Message all users\n"
"/ban <id> – Ban user\n"
"/unban <id> – Unban user\n"
"/banned – List banned users\n"
"/requests – Pending requests\n"
"/donereq <title> – Mark request done\n"
"/userinfo <id> – User details\n"
"/maintenance on|off – Toggle maintenance\n"
"/clearcache – Clear search cache",
parse_mode="HTML",
)
async def cmd_stats(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
return
total_users, total_dls = await self.db.get_total_stats()
banned = len(await self.db.get_banned_users())
groups = len(await self.db.get_all_groups())
async with self.cache_lock:
cache_sz = len(self.search_cache)
maint = await self.db.is_maintenance()
await update.message.reply_text(
f"📊 Engine Status\n──────────────────────────────────────\n\n"
f" 👥 Users: {total_users:,}\n"
f" 📥 Downloads: {total_dls:,}\n"
f" 🚫 Banned: {banned}\n"
f" 👥 Groups: {groups}\n"
f" 🗃️ Cache: {cache_sz} entries\n"
f" 🔧 Maintenance: {'On' if maint else 'Off'}\n\n"
f"{pb('MUNAX SUBS')} ☠️ {VERSION}",
parse_mode="HTML",
)
async def cmd_ping(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
return
start = time.time()
msg = await update.message.reply_text("🏓 Checking…")
ms = int((time.time() - start) * 1000)
await msg.edit_text(
f"🏓 Engine alive. {ms} ms\nAll systems operational.",
parse_mode="HTML",
)
async def cmd_broadcast(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
return
text = " ".join(ctx.args).strip() if ctx.args else ""
if not text:
await update.message.reply_text("Usage: /broadcast Your message here")
return
users = await self.db.get_all_user_ids()
groups = await self.db.get_all_groups()
total = len(users) + len(groups)
status = await update.message.reply_text(
f"📡 Broadcasting to {len(users)} users + {len(groups)} groups…",
parse_mode="HTML",
)
sem = asyncio.Semaphore(20)
async def _send(chat_id: int) -> bool:
async with sem:
result = await _try_send(ctx.bot, chat_id, text)
await asyncio.sleep(0.05) # [F22] flood-safe
return result
outcomes = await asyncio.gather(*[_send(i) for i in users + groups])
sent = sum(outcomes)
await status.edit_text(
f"📡 Broadcast complete.\n\n"
f" ✅ Delivered: {sent}\n"
f" ☠️ Failed: {total - sent}",
parse_mode="HTML",
)
async def cmd_ban(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
return
if not ctx.args:
await update.message.reply_text("Usage: /ban {uid} — banned.", parse_mode="HTML"
)
async def cmd_unban(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
return
if not ctx.args:
await update.message.reply_text("Usage: /unban {uid} — unbanned.", parse_mode="HTML"
)
async def cmd_banned(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
return
banned = await self.db.get_banned_users()
if not banned:
await update.message.reply_text("✅ No banned users.")
return
lines = []
for uid in banned:
info = await self.db.get_user_info(uid)
uname = info.get("username", "?") if info else "?"
fname = info.get("first_name", "?") if info else "?"
lines.append(f" ☠️ {uid} @{escape(uname)} {escape(fname)}")
await update.message.reply_text(
f"🚫 Banned ({len(banned)})\n──────────────────────────────────────\n"
+ "\n".join(lines),
parse_mode="HTML",
)
async def cmd_requests(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
return
reqs = await self.db.get_pending_requests(20)
if not reqs:
await update.message.reply_text("✅ No pending requests.")
return
lines = []
for r in reqs:
dt = datetime.fromtimestamp(r["t"]).strftime("%d %b")
uname = f"@{r['username']}" if r.get("username") else str(r.get("uid","?"))
lines.append(f" 🎬 {escape(r['title'])} — {uname} {dt}")
await update.message.reply_text(
f"📥 Pending Requests ({len(reqs)})\n──────────────────────────────────────\n\n"
+ "\n".join(lines)
+ "\n\n/donereq <title> to mark done",
parse_mode="HTML",
)
async def cmd_donereq(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
return
title = " ".join(ctx.args).strip() if ctx.args else ""
if not title:
await update.message.reply_text("Usage: /donereq Movie Title")
return
cnt = await self.db.mark_request_done(title)
await update.message.reply_text(
f"✅ {escape(title)} — {cnt} request(s) marked done.",
parse_mode="HTML",
)
async def cmd_userinfo(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
return
if not ctx.args:
await update.message.reply_text("Usage: /userinfo {uid}.", parse_mode="HTML"
)
return
banned = await self.db.is_banned(uid)
hist = await self.db.get_history(uid)
first = datetime.fromtimestamp(info["first_seen"]).strftime("%d %b %Y %H:%M")
recent = "\n".join(f" · {escape(h['q'])}" for h in hist[:5]) or " (none)"
await update.message.reply_text(
f"👤 User Info\n──────────────────────────────────────\n\n"
f" 🆔 {uid}\n"
f" 👤 {escape(info.get('first_name',''))} @{escape(info.get('username',''))}\n"
f" 📥 Downloads: {info.get('downloads',0)}\n"
f" 🕐 First seen: {first}\n"
f" 🚫 Banned: {'Yes' if banned else 'No'}\n\n"
f"Recent searches:\n{recent}",
parse_mode="HTML",
)
async def cmd_maintenance(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
return
arg = (ctx.args[0].lower() if ctx.args else "")
if arg not in ("on","off"):
await update.message.reply_text("Usage: /maintenance on|off")
return
await self.db.set_maintenance(arg == "on")
await update.message.reply_text(
f"🔧 Maintenance: {'On' if arg == 'on' else 'Off'}",
parse_mode="HTML",
)
async def cmd_clearcache(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_user.id not in ADMIN_IDS:
return
async with self.cache_lock:
cnt = len(self.search_cache)
self.search_cache.clear()
await update.message.reply_text(
f"🗃️ Cache cleared. {cnt} entries removed.", parse_mode="HTML"
)
# ════════════════════════════════════════════════════════════
# MESSAGE HANDLER
# ════════════════════════════════════════════════════════════
async def handle_message(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
msg = update.message
chat = update.effective_chat
if not user or user.is_bot:
return
if not await self.gate_user(update, ctx):
return
text = (msg.text or "").strip()
if not text:
return
is_group = chat.type in ("group","supergroup")
bot_username = ctx.bot.username or ctx.bot_data.get("username","")
mentioned = bool(bot_username and re.search(rf"@{re.escape(bot_username)}", text, re.I))
clean_text = re.sub(rf"@{re.escape(bot_username)}", "", text, flags=re.I).strip() if bot_username else text
if not clean_text:
clean_text = text
if is_group:
await self.db.add_group(chat.id)
if not mentioned and not is_movie_query(clean_text):
return
else:
if not is_movie_query(clean_text):
return
if not is_clean_query(clean_text):
return
if await self._is_limited(user.id):
w = await self._wait_sec(user.id)
await msg.reply_text(f"⏳ Please wait {w}s.", parse_mode="HTML")
return
await self.db.update_user(user.id, user.username, user.first_name)
await chat.send_action(constants.ChatAction.TYPING)
lines = [f"⏳ {n}\n" for n in ["Wyzie","Team GOAT","MSone","SubScene","OpenSub"]]
status = await msg.reply_text(ui_searching(clean_text, lines), parse_mode="HTML")
await self._run_search(clean_text, status, ctx, user.id)
# ════════════════════════════════════════════════════════════
# INLINE QUERY [F27]
# ════════════════════════════════════════════════════════════
async def inline_query(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
query = update.inline_query.query.strip()
user_id = update.inline_query.from_user.id
if not query or len(query) < 2 or not is_clean_query(query):
await update.inline_query.answer([], cache_time=0)
return
if await self.db.is_banned(user_id):
await update.inline_query.answer([], cache_time=0)
return
cache_key = f"raw_{query.lower()}"
raw_items: List[dict] = []
async with self.cache_lock:
cached = self.search_cache.get(cache_key)
if cached and time.time() - cached[1] < CACHE_TTL:
raw_items = [item for src in cached[0] for item in src]
bot_uname = ctx.bot.username or ""
if not raw_items:
await update.inline_query.answer([
InlineQueryResultArticle(
id=str(uuid.uuid4()),
title=f"🔍 Search: {query}",
description="Bot-ൽ type ചെയ്ത് full results കാണൂ",
input_message_content=InputTextMessageContent(
f"🎬 {escape(query)} subtitle — @{bot_uname}-ൽ search ചെയ്യൂ",
parse_mode="HTML",
),
)
], cache_time=0)
return
ranked = deduplicate(rank_results(raw_items, query))[:5]
answers = []
for r in ranked:
title = r.get("title", query)[:60]
lang = r.get("language","Unknown")
src = SOURCE_LABEL.get(r.get("_type",""), "Unknown")
badge = "🇮🇳" if "malayalam" in lang.lower() else "🎬"
answers.append(InlineQueryResultArticle(
id=str(uuid.uuid4()),
title=f"{badge} {title}",
description=f"{lang} · {src}",
input_message_content=InputTextMessageContent(
f"🎬 {escape(title)}\n\nDownload: @{bot_uname}",
parse_mode="HTML",
),
))
await update.inline_query.answer(answers, cache_time=60)
# ════════════════════════════════════════════════════════════
# CALLBACK HANDLER
# ════════════════════════════════════════════════════════════
async def handle_callback(self, update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query
user = q.from_user
if await self.db.is_banned(user.id):
await q.answer("🚫 Access revoked.", show_alert=True)
return
if await self.db.is_maintenance() and user.id not in ADMIN_IDS:
await q.answer("🔧 Maintenance mode. Try later.", show_alert=True)
return
if not await self._check_membership(ctx.bot, user.id):
await q.answer("Group join ചെയ്യൂ first.", show_alert=True)
return
try:
await q.answer()
except TelegramError:
return
data = q.data
chat_id = q.message.chat_id
user_id = ctx.user_data.get("user_id", user.id)
movie_name = ctx.user_data.get("movie_name", "Movie")
cur_page = ctx.user_data.get("results_page", 0)
# ── simple actions ──────────────────────────────────────
if data == "noop":
return
if data == "new_search":
ctx.user_data.clear()
await q.message.edit_text(
"🔍 Ready!\n\nSinima / series title type ചെയ്യൂ.\n\n"
"Example: Kantara · Dune 2021 · BB S01E05",
parse_mode="HTML",
)
return
if data == "verify":
if await self._check_membership(ctx.bot, user.id, force=True):
await q.message.edit_text(
"✅ Verified! Welcome! 🎬\n\nSinima title type ചെയ്യൂ.",
parse_mode="HTML",
)
else:
await q.message.edit_text(
"❌ Not joined yet.\n\nGroup join ചെയ്ത് verify ചെയ്യൂ.",
parse_mode="HTML",
)
return
if data == "skip":
await q.message.edit_text(
"✅ OK! Subtitle ചാറ്റിൽ ഉണ്ട്.\n\nAnotherSinima title type ചെയ്യൂ.",
parse_mode="HTML",
)
return
if data in ("translate","translate_force"):
content = ctx.user_data.get("last_content") or ctx.user_data.get("_tr_content")
if not content:
await q.message.edit_text(
"📭 Nothing to translate. Download a subtitle first."
)
return
_chat_id = ctx.user_data.get("_tr_chat_id", chat_id)
_movie_name = ctx.user_data.get("_tr_movie_name", movie_name)
_user_id = ctx.user_data.get("_tr_user_id", user_id)
lang = ctx.user_data.get("_tr_lang") or await self.db.get_user_pref(user_id, "default_lang", "ml")
size_kb = len(content) / 1024
if data == "translate" and size_kb > 500:
await q.message.edit_text(
ui_large_file(size_kb), reply_markup=kb_large_file(), parse_mode="HTML"
)
ctx.user_data["_tr_content"] = content
ctx.user_data["_tr_chat_id"] = _chat_id
ctx.user_data["_tr_movie_name"] = _movie_name
ctx.user_data["_tr_user_id"] = _user_id
ctx.user_data["_tr_lang"] = lang
return
await q.message.edit_text(ui_translate_start(), parse_mode="HTML")
await self._run_translation(
ctx, _chat_id, q.message, content, _movie_name, _user_id, lang
)
return
if data.startswith("pg_"):
page = int(data[3:])
ctx.user_data["results_page"] = page
results = ctx.user_data.get("results", [])
kb, _, _ = kb_results(results, page, movie_name)
with contextlib.suppress(TelegramError):
await q.message.edit_reply_markup(reply_markup=kb)
return
# ── result selection ────────────────────────────────────
if data.startswith("sub_"):
idx = int(data[4:])
results = ctx.user_data.get("results", [])
if idx >= len(results):
await q.message.edit_text(
"⏱ Session expired.\n\nSinima name വീണ്ടും type ചെയ്യൂ.",
parse_mode="HTML",
)
return
sel = results[idx]
typ = sel["_type"]
title = sel.get("title", movie_name)
src_label = SOURCE_LABEL.get(typ, typ)
ctx.user_data["selected_title"] = title
# Direct-download sources
if typ in ("wyzie","goat","malsub"):
await q.message.edit_text(ui_downloading(), parse_mode="HTML")
path, err = await asyncio.to_thread(download_file, sel["download"])
if path:
content = read_file_content(path)
if is_valid_srt(content or ""):
fname = make_filename(title, ".srt")
lang_name = sel.get("language","Subtitle")
ctx.user_data["last_content"] = content
caption = (
f"🎬 {escape(title)}\n"
f"✦ {lang_name}\nvia {src_label}"
)
sent = await send_subtitle_file(
ctx.bot, chat_id, path, fname, caption, user_id, self.db
)
if sent:
await self.db.add_history(
user_id, title, f"{lang_name} · {src_label}"
)
lang = await self.db.get_user_pref(user_id, "default_lang", "ml")
if await self.db.get_user_pref(user_id, "auto_translate", False):
await q.message.edit_text(
ui_translate_start(), parse_mode="HTML"
)
await self._run_translation(
ctx, chat_id, q.message, content, title, user_id, lang
)
else:
await q.message.edit_text(
ui_sent(title),
reply_markup=kb_translate(),
parse_mode="HTML",
)
return
else:
with contextlib.suppress(OSError):
os.unlink(path)
await q.message.edit_text(
f"☠️ {err or 'Download failed.'}\n\nMറ്റൊരു result try ചെയ്യൂ.",
reply_markup=kb_back(cur_page),
parse_mode="HTML",
)
return
# Jerry / Ironman — need language selection
if typ in ("jerry","ironman"):
get_langs = get_jerry_langs if typ == "jerry" else get_ironman_langs
await q.message.edit_text(
f"🔎 {escape(title[:50])}\n\nAvailable languages check ചെയ്യുന്നു…",
parse_mode="HTML",
)
langs = await asyncio.to_thread(get_langs, sel["url"])
if not langs:
await q.message.edit_text(
"☠️ No languages found.\n\nMറ്റൊരു result try ചെയ്യൂ.",
reply_markup=kb_back(cur_page),
parse_mode="HTML",
)
return
mal = next(
(l for l in langs if "malayalam" in l.get("language","").lower()), None
)
oth = [l for l in langs if "malayalam" not in l.get("language","").lower()]
if mal:
await q.message.edit_text(
"🇮🇳 Malayalam കിട്ടി! Download ചെയ്യുന്നു…",
parse_mode="HTML",
)
path, err = await asyncio.to_thread(download_file, mal["download"])
if path:
content = read_file_content(path)
if is_valid_srt(content or ""):
fname = make_filename(title, ".srt")
ctx.user_data["last_content"] = content
caption = (
f"🎬 {escape(title)}\n"
f"✦ Malayalam Subtitle\nvia {src_label}"
)
sent = await send_subtitle_file(
ctx.bot, chat_id, path, fname, caption, user_id, self.db
)
if sent:
await self.db.add_history(user_id, title, src_label)
lang = await self.db.get_user_pref(user_id, "default_lang", "ml")
if await self.db.get_user_pref(user_id, "auto_translate", False):
await q.message.edit_text(
ui_translate_start(), parse_mode="HTML"
)
await self._run_translation(
ctx, chat_id, q.message, content, title, user_id, lang
)
else:
await q.message.edit_text(
ui_sent(title),
reply_markup=kb_translate(),
parse_mode="HTML",
)
return
else:
with contextlib.suppress(OSError):
os.unlink(path)
await q.message.edit_text(
f"☠️ {err or 'Download failed.'}",
reply_markup=kb_back(cur_page),
parse_mode="HTML",
)
return
if oth:
kb_l, sl = kb_langs(oth[:12], cur_page)
ctx.user_data["pending_langs"] = [
{"language": l["language"], "download": l["download"], "_src": typ}
for l in sl
]
await q.message.edit_text(
f"🎬 {escape(title[:50])}\n\n"
f"🇮🇳 Malayalam ഇല്ല. ഒരു language select ചെയ്യൂ — translate ചെയ്യാം:",
reply_markup=kb_l,
parse_mode="HTML",
)
else:
await q.message.edit_text(
"☠️ No languages found.",
reply_markup=kb_back(cur_page),
parse_mode="HTML",
)
return
# ── language download ───────────────────────────────────
if data.startswith("lang_"):
idx = int(data[5:])
langs = ctx.user_data.get("pending_langs", [])
title = ctx.user_data.get("selected_title", movie_name)
if idx >= len(langs):
await q.message.edit_text(
"⏱ Session expired.\n\nSinima name വീണ്ടും type ചെയ്യൂ.",
parse_mode="HTML",
)
return
li = langs[idx]
lang_name = li["language"]
src_label = SOURCE_LABEL.get(li.get("_src",""), li.get("_src",""))
await q.message.edit_text(
f"📡 {escape(lang_name)} download ചെയ്യുന്നു…", parse_mode="HTML"
)
path, err = await asyncio.to_thread(download_file, li["download"])
if not path:
await q.message.edit_text(
f"☠️ {err or 'Download failed.'}",
reply_markup=kb_back(cur_page),
parse_mode="HTML",
)
return
content = read_file_content(path)
if not is_valid_srt(content or ""):
with contextlib.suppress(OSError):
os.unlink(path)
await q.message.edit_text(
"⚠️ File not valid.\n\nMറ്റൊരു result try ചെയ്യൂ.",
reply_markup=kb_back(cur_page),
parse_mode="HTML",
)
return
fname = make_filename(title, ".srt")
ctx.user_data["last_content"] = content
caption = (
f"🎬 {escape(title)}\n"
f"✦ {lang_name}\nvia {src_label}"
)
sent = await send_subtitle_file(
ctx.bot, chat_id, path, fname, caption, user_id, self.db
)
if sent:
await self.db.add_history(user_id, title, f"{lang_name} · {src_label}")
lang = await self.db.get_user_pref(user_id, "default_lang", "ml")
if await self.db.get_user_pref(user_id, "auto_translate", False):
await q.message.edit_text(ui_translate_start(), parse_mode="HTML")
await self._run_translation(
ctx, chat_id, q.message, content, title, user_id, lang
)
else:
await q.message.edit_text(
ui_sent(title), reply_markup=kb_translate(), parse_mode="HTML"
)
# ═══════════════════════════════════════════════════════════════
# HEALTH SERVER
# ═══════════════════════════════════════════════════════════════
class HealthHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-type","application/json")
self.end_headers()
self.wfile.write(
json.dumps({"status":"ok","timestamp":datetime.utcnow().isoformat()}).encode()
)
def log_message(self, *a, **kw):
pass
def run_health(port: int):
HTTPServer(("0.0.0.0", port), HealthHandler).serve_forever()
# ═══════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════
async def post_init(app: Application):
"""[F13] db.init() runs here — correct event loop."""
db = app.bot_data["db"]
await db.init()
me = await app.bot.get_me()
app.bot_data["username"] = me.username
logger.info(f"✅ Engine live: @{me.username}")
if not WYZIE_API_KEY:
logger.warning("WYZIE_API_KEY not set — Wyzie disabled")
if not OMDB_API_KEY:
logger.warning("OMDB_API_KEY not set — OMDB disabled")
async def post_stop(app: Application):
"""[F20] Graceful DB close."""
db = app.bot_data.get("db")
if db:
await db.close()
logger.info("✅ Database closed cleanly.")
def main():
port = int(os.environ.get("PORT", 8080))
use_webhook = bool(RENDER_URL)
health_port = port + 1 if use_webhook else port
threading.Thread(target=run_health, args=(health_port,), daemon=True).start()
logger.info(f"Health server on port {health_port}")
db = Database()
app = (
Application.builder()
.token(BOT_TOKEN)
.post_init(post_init)
.post_stop(post_stop)
.build()
)
app.bot_data["db"] = db
h = BotHandlers(db)
# ── User commands ──────────────────────────────────────────
app.add_handler(CommandHandler("start", h.cmd_start))
app.add_handler(CommandHandler("help", h.cmd_help))
app.add_handler(CommandHandler("cancel", h.cmd_cancel))
app.add_handler(CommandHandler("search", h.cmd_search))
app.add_handler(CommandHandler("translate", h.cmd_translate))
app.add_handler(CommandHandler("history", h.cmd_history))
app.add_handler(CommandHandler("mystats", h.cmd_mystats))
app.add_handler(CommandHandler("leaderboard", h.cmd_leaderboard))
app.add_handler(CommandHandler("trending", h.cmd_trending))
app.add_handler(CommandHandler("request", h.cmd_request))
app.add_handler(CommandHandler("donate", h.cmd_donate))
app.add_handler(CommandHandler("group", h.cmd_group))
app.add_handler(CommandHandler("report", h.cmd_report))
app.add_handler(CommandHandler("feedback", h.cmd_feedback))
app.add_handler(CommandHandler("prefs", h.cmd_prefs))
# ── Admin commands ─────────────────────────────────────────
app.add_handler(CommandHandler("admin", h.cmd_admin))
app.add_handler(CommandHandler("stats", h.cmd_stats))
app.add_handler(CommandHandler("ping", h.cmd_ping))
app.add_handler(CommandHandler("broadcast", h.cmd_broadcast))
app.add_handler(CommandHandler("ban", h.cmd_ban))
app.add_handler(CommandHandler("unban", h.cmd_unban))
app.add_handler(CommandHandler("banned", h.cmd_banned))
app.add_handler(CommandHandler("requests", h.cmd_requests))
app.add_handler(CommandHandler("donereq", h.cmd_donereq))
app.add_handler(CommandHandler("userinfo", h.cmd_userinfo))
app.add_handler(CommandHandler("maintenance", h.cmd_maintenance))
app.add_handler(CommandHandler("clearcache", h.cmd_clearcache))
# ── Message + Callback + Inline ───────────────────────────
app.add_handler(
MessageHandler(filters.TEXT & ~filters.COMMAND, h.handle_message)
)
app.add_handler(
CallbackQueryHandler(
h.handle_callback,
pattern=r"^(noop|new_search|verify|translate|translate_force|skip|sub_\d+|pg_\d+|lang_\d+)$",
)
)
app.add_handler(InlineQueryHandler(h.inline_query))
app.add_error_handler(
lambda u, c: logger.error("Unhandled error", exc_info=c.error)
)
logger.info("━" * 50)
logger.info(f" {pb('MUNAX SUBS')} ☠️ {VERSION}")
logger.info("━" * 50)
if use_webhook:
logger.info(f"Mode: WEBHOOK → {RENDER_URL}")
app.run_webhook(
listen="0.0.0.0",
port=port,
url_path=BOT_TOKEN,
webhook_url=f"{RENDER_URL}/{BOT_TOKEN}",
drop_pending_updates=True,
)
else:
logger.info("Mode: POLLING")
app.run_polling(drop_pending_updates=True)
if __name__ == "__main__":
main()
================================================
FILE: requirements.txt
================================================
python-telegram-bot[webhooks]>=20.7
requests>=2.31.0
httpx>=0.25.0
beautifulsoup4>=4.12.0
deep-translator>=1.11.0
lxml>=4.9.0
aiosqlite>=0.19.0