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 ` – Mark request as done - `/userinfo <id>` – Detailed user info - `/clearcache` – Clear search cache ## Credits Built with ❤️ by @munax9 for Malayalam cinema lovers. ================================================ FILE: bot.py ================================================ #!/usr/bin/env python3 """ ╔══════════════════════════════════════════════════════════════╗ ║ 𝗠𝘂𝗻𝗮𝗫 𝗦𝘂𝗯𝘀 ☠️⚡ ULTRA LEGENDARY FINAL ║ ║ Premium Malayalam Subtitle Bot for Telegram ║ ║ Version 3.0 — The Last Boss. No Bugs. No Mercy. ║ ╚══════════════════════════════════════════════════════════════╝ COMPLETE FIX LOG vs previous versions: [F01] No placeholder lambdas — all 28 commands fully implemented [F02] Module-level asyncio primitives removed — all inside BotHandlers [F03] RateLimiter uses lazy lock (no event-loop-before-init crash) [F04] Database maintenance row inserted on init [F05] cmd_help uses HTML parse_mode (not Markdown) [F06] Translation respects user's default_lang pref [F07] is_valid_srt: ≥200 chars + ≥5 blocks [F08] Malayalam fast-path retries up to 3 sources [F09] Progress callback throttled (≥1.2s between edits) [F10] gate_user shows correct join-required message [F11] Deduplication of results by download URL [F12] read_file_content min-size guard (>100 chars) [F13] Event loop conflict fixed — db.init() in post_init only [F14] vtt_to_srt dot escaped (literal dot not wildcard) in timestamp regex [F15] OMDB uses HTTPS [F16] _scrape_get has exponential backoff [F17] search_goat uses _scrape_get (retry + session) [F18] Bounded cache with LRU eviction (500 entries) [F19] WAL + synchronous=NORMAL before CREATE TABLE [F20] Graceful DB close in post_stop [F21] Relative URL resolution with urljoin [F22] Broadcast flood-safe (asyncio.sleep 0.05 between sends) [F23] Leaderboard rank by user_id not download count [F24] search_all uses instance semaphore, not module-level [F25] _deep_block chunks text >4800 chars (no truncation) [F26] Auto-retry fallback for all 3 Malayalam sources [F27] Inline query support (@botname Movie) [F28] Smart result ranking with title similarity scoring """ from __future__ import annotations import os import re import time import asyncio import json import tempfile import zipfile import contextlib import random import threading import html import logging import uuid from datetime import datetime from typing import Optional, List, Tuple, Dict, Any, Callable from logging.handlers import RotatingFileHandler from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import quote_plus, urljoin import aiosqlite import requests import httpx from bs4 import BeautifulSoup from deep_translator import GoogleTranslator from telegram import ( Update, InlineKeyboardButton, InlineKeyboardMarkup, InlineQueryResultArticle, InputTextMessageContent, Message, constants, ) from telegram.ext import ( Application, CommandHandler, MessageHandler, filters, CallbackQueryHandler, InlineQueryHandler, ContextTypes, ) from telegram.error import TelegramError # ═══════════════════════════════════════════════════════════════ # CONFIGURATION # ═══════════════════════════════════════════════════════════════ BOT_TOKEN = os.environ.get("BOT_TOKEN") if not BOT_TOKEN: raise RuntimeError("BOT_TOKEN environment variable is missing") ADMIN_IDS = [ int(x) for x in os.environ.get("ADMIN_IDS", "8361710122,1591775154").split(",") ] UPI_ID = "munavirmunu1010@okicici" GROUP_LINK = "https://t.me/malayalamsubhub" GROUP_USERNAME = "malayalamsubhub" GROUP_ID = os.environ.get("GROUP_ID", f"@{GROUP_USERNAME}") RENDER_URL = os.environ.get("RENDER_URL", "") PROXY_LIST = [p.strip() for p in os.environ.get("PROXY_LIST", "").split(",") if p.strip()] WYZIE_API_KEY = os.environ.get("WYZIE_API_KEY", "wyzie-33d7dd9a214b4424579c611653a189c1") OMDB_API_KEY = os.environ.get("OMDB_API_KEY", "743db943") JERRY_SEARCH = "https://jerrycoder.oggyapi.workers.dev/search/subtitle" JERRY_DOWNLOAD = "https://jerrycoder.oggyapi.workers.dev/download/subtitle" IRON_SEARCH = "https://ironman.koyeb.app/search/subtitles" IRON_DOWNLOAD = "https://ironman.koyeb.app/download/subtitles" PAGE_SIZE = 8 RATE_LIMIT_S = 4 MAX_FILE_SIZE = 5 * 1024 * 1024 CACHE_TTL = 3600 MAX_CACHE_ENTRIES = 500 MEMBERSHIP_CACHE_TTL = 300 MAX_HISTORY = 15 MAX_REQUESTS_PER_USER = 10 TRANSLATE_TIMEOUT = 180 MAX_CONCURRENT_TRANSLATIONS = 3 MAX_CONCURRENT_SEARCH = 5 VERSION = "Ultra Legendary Final ☠️⚡" BRAND = "𝗠𝘂𝗻𝗮𝗫 𝗦𝘂𝗯𝘀" LANG_MAP = { "en": "English", "ml": "Malayalam", "hi": "Hindi", "ta": "Tamil", "te": "Telugu", "kn": "Kannada", "bn": "Bengali", "mr": "Marathi", "gu": "Gujarati", "pa": "Punjabi", "ur": "Urdu", "fr": "French", "es": "Spanish", "de": "German", "it": "Italian", "pt": "Portuguese", "ru": "Russian", "zh": "Chinese", "ja": "Japanese", "ko": "Korean", "ar": "Arabic", "id": "Indonesian", "ms": "Malay", "th": "Thai", "vi": "Vietnamese", "tr": "Turkish", "nl": "Dutch", "sv": "Swedish", "pl": "Polish", "el": "Greek", } CHAT_WORDS = frozenset({ "ok","okay","lol","haha","nice","good","bad","wow","bro","da","ente","njan", "nee","ningal","yes","no","thanks","thank","please","what","why","how","when", "where","who","which","are","is","was","the","and","but","for","not","with", "this","that","they","have","been","will","can","may","should","would","could", "just","very","also","from","into","about","than","then","more","some","any", "all","both","each","few","most","other","such","only","same","over","here", "there","hi","hello","hey","bye","wait","actually","really","already","still", "again","ever","never","always","sometimes","maybe","probably","send","get", "give","make","kitto","see","think","come","go","put","use","look","want", "need","mm","said","say","tell","venam","show","back","after","before","now", "so","as","at","by","do","did","done","its","it","him","her","his","our", "their","myr","my","we","us","he","she","one","two","three","four","five", "aanu","alle","anu","athu","ayyo","chetta","chechi","enthanu","enthina","evidea", "ipo","ithu","ivide","ketto","mone","mole","poda","podi","sheriyanu","ano", "undo","und","illa","ille","dei","di","machane","machi", }) DEFAULT_BANNED = ("jav","porn","xxx","hentai","sexy","adult") _banned_env = os.environ.get("BANNED_TERMS", "") BANNED_TERMS = tuple(_banned_env.lower().split(",")) if _banned_env else DEFAULT_BANNED SOURCE_LABEL = { "wyzie": "Wyzie", "goat": "Team GOAT", "malsub": "MSone", "jerry": "SubScene", "ironman": "OpenSub", } # ═══════════════════════════════════════════════════════════════ # PREMIUM UI # ═══════════════════════════════════════════════════════════════ def pb(text: str) -> str: """Bold mathematical sans-serif font.""" m = str.maketrans( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", "𝗔𝗕𝗖𝗗𝗘𝗙𝗚𝗛𝗜𝗝𝗞𝗟𝗠𝗡𝗢𝗣𝗤𝗥𝗦𝗧𝗨𝗩𝗪𝗫𝗬𝗭𝗮𝗯𝗰𝗱𝗲𝗳𝗴𝗵𝗶𝗷𝗸𝗹𝗺𝗻𝗼𝗽𝗾𝗿𝘀𝘁𝘂𝘃𝘄𝘅𝘆𝘇𝟬𝟭𝟮𝟯𝟰𝟱𝟲𝟳𝟴𝟵" ) return text.translate(m) def bar(frac: float, w: int = 18) -> str: f = max(0, min(w, int(w * frac))) return f"[{'█' * f}{'░' * (w - f)}] {int(frac * 100)}%" def ui_box(icon: str, title: str, body: str) -> str: return ( f"╔══════════════════════════════════════╗\n" f"║ {icon} {pb(title):<33}║\n" f"╚══════════════════════════════════════╝\n\n" f"{body}" ) def ui_start(name: str, downloads: int = 0) -> str: dl_line = f"\n📥 Downloads: <b>{downloads}</b>" if downloads else "" return ui_box("☠️", "MUNAX SUBS", f"✨ സ്വാഗതം, <b>{escape(name)}</b>! ✨{dl_line}\n\n" f"┌────────────────────────────────────┐\n" f"│ 🎬 Cinema / Series title type ചെയ്യൂ │\n" f"│ ⚡ 5 sources · Malayalam first │\n" f"└────────────────────────────────────┘\n\n" f"📌 <i>Example:</i>\n" f" • <code>Kantara</code>\n" f" • <code>Dune 2021</code>\n" f" • <code>Breaking Bad S01E05</code>\n\n" f"──────────────────────────────────────\n" f"/help · /history · /trending · /request" ) def ui_join_required(name: str) -> str: return ui_box("🔒", "Join Required", f"<b>{escape(name)}</b>, bot use ചെയ്യാൻ ആദ്യം\n" f"<b>Malayalam Subtitle Hub</b> 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"🎬 <b>{escape(q[:40])}</b>\n\n" f"{progress}" ) def ui_results(q: str, total: int) -> str: return ui_box("🎬", "Results", f"🎬 <b>{escape(q[:40])}</b>\n\n" f"📦 <b>{total} results</b> — ഏത് വേണം?\n" f"🇮🇳 = Malayalam direct | 🎬 = Other lang" ) def ui_not_found(q: str) -> str: return ui_box("❌", "Not Found", f"🎬 <i>{escape(q)}</i>\n\n" f"Subtitle ലഭ്യമല്ല.\n\n" f"💡 Try:\n" f" • English title use ചെയ്യൂ\n" f" • Year add ചെയ്യൂ: <code>Dune 2021</code>\n" f" • Series: <code>BB S01E05</code>\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"🎬 <b>{escape(title[:50])}</b>\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"<i>Large files → ~2 min. Please wait.</i>" ) 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"<i>Please wait…</i>" ) def ui_translate_done() -> str: return ui_box("🇮🇳", "Done", f"✨ <b>Malayalam subtitle ready!</b> ✨\n\n" f"🎬 Enjoy the movie! 🍿\n\n" f"──────────────────────────────────────\n" f"{pb('MUNAX SUBS')} ☠️" ) def ui_translate_fail() -> str: return "☠️ <b>Translation failed.</b>\n\nFile too large or service down.\n/translate ചെയ്ത് retry ചെയ്യൂ." def ui_large_file(size_kb: float) -> str: return ( f"⚠️ <b>Large file</b> ({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 "<html" in head or "<!doctype" in head: soup = BeautifulSoup(content, "html.parser") for a in soup.find_all("a", href=True): href = a["href"] if re.search(r"\.(srt|zip|vtt)(\?.*)?$", href, re.I): resolved = urljoin(url, href) # [F21] FIX return download_file(resolved, depth + 1) continue is_vtt = head.startswith("webvtt") or url.lower().endswith(".vtt") or "webvtt" in head[:80] if is_vtt: text = content.decode("utf-8", errors="ignore") converted = vtt_to_srt(text) if is_valid_srt(converted): tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".srt", delete=False, encoding="utf-8") tmp.write(converted) tmp.close() return tmp.name, None continue tmp = tempfile.NamedTemporaryFile(mode="wb", suffix=".srt", delete=False) tmp.write(content) tmp.close() return tmp.name, None return None, "Download failed" def read_file_content(path: str) -> 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( "🔧 <b>Maintenance mode.</b>\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( "🇮🇳 <b>Malayalam subtitle കിട്ടി!</b>\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"🎬 <b>{escape(query)}</b>\n" f"✦ Malayalam Subtitle\n<i>via {src_label}</i>" ) 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"🎬 <b>{escape(movie_name)}</b>\n" f"🌐 {lang_name} (Auto-Translated)\n" f"<i>via MunaX Engine</i>\n" f"<i>⚠️ Auto-translated — quality may vary</i>" ) 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( "📖 <b>How to use MunaX Subs</b>\n\n" "<b>1.</b> Sinima / series title type ചെയ്യൂ\n" "<b>2.</b> 5 sources ഒരേ സമയം search ആകും\n" "<b>3.</b> Malayalam subtitle → ഉടൻ download\n" "<b>4.</b> ഇല്ലെങ്കിൽ → language select → translate\n\n" "──────────────────────────────────────\n" "<b>Tips:</b>\n" " → English title use ചെയ്യൂ\n" " → Year add ചെയ്യൂ: <code>Avatar 2009</code>\n" " → Series: <code>Breaking Bad S01E05</code>\n\n" "──────────────────────────────────────\n" "<b>Commands</b>\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( "✅ <b>Session reset.</b>\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( "🔍 <b>Usage:</b> <code>/search Movie Title</code>\n\n" "<i>Or just type the title directly in chat.</i>", 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 <b>{w}s</b>.", 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( "📋 <b>Download History</b>\n\nഇതുവരെ ഒന്നും ഇല്ല.\n\n<i>ഒരു title search ചെയ്യൂ!</i>", parse_mode="HTML", ) return lines = [] for i, e in enumerate(hist, 1): dt = datetime.fromtimestamp(e["t"]).strftime("%d %b") src = f" <i>· {escape(e['src'])}</i>" if e.get("src") else "" lines.append(f" <b>{i:02d}.</b> {escape(e['q'])}{src} <i>{dt}</i>") await update.message.reply_text( f"📋 <b>Your Archive</b>\n──────────────────────────────────────\n\n" + "\n".join(lines) + f"\n\n<i>Last {len(hist)} downloads.</i>", 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( "📊 <b>Your Stats</b>\n\nDownloads ഒന്നും ഇല്ല.\n\n<i>ഒരു sinima search ചെയ്യൂ!</i>", 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"📊 <b>Your Stats</b>\n──────────────────────────────────────\n\n" f" 🎬 Downloads: <b>{dls}</b>\n" f" 🏆 Rank: <b>#{rank}</b>\n" f" 🕐 First visit: <i>{first}</i>", 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( "🏆 <b>Leaderboard</b>\n\n<i>ഇതുവരെ downloads ഒന്നും ഇല്ല.</i>", parse_mode="HTML", ) return medals = ["🥇","🥈","🥉"] + ["🎬"] * 7 lines = [ f" {medals[i]} <b>{escape(name[:20])}</b> — <code>{dl}</code> 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( "🏆 <b>Top Downloaders</b>\n──────────────────────────────────────\n\n" + "\n".join(lines) + f"\n\n<i>Yours: <b>{my_dls} downloads</b></i>", 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( "🔥 <b>Trending</b>\n\n<i>ഇതുവരെ data ഇല്ല.</i>", parse_mode="HTML" ) return lines = [ f" <b>{i+1:02d}.</b> {escape(q)} <i>({c}×)</i>" for i, (q, c) in enumerate(trends) ] await update.message.reply_text( "🔥 <b>Trending Searches</b>\n──────────────────────────────────────\n\n" + "\n".join(lines) + "\n\n<i>This month's popular titles.</i>", 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( "📥 <b>Subtitle Request</b>\n\n" "<b>Usage:</b> <code>/request Movie Title</code>\n\n" "<i>കിട്ടാത്ത subtitles request ചെയ്യൂ.</i>", 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"<i>Wait for previous requests to be processed.</i>", parse_mode="HTML", ) return await update.message.reply_text( f"📥 <b>Request saved!</b>\n\n" f"<b>{escape(title)}</b> — recorded.\n" f"Team check ചെയ്ത് available ആക്കും.\n\n" f"<i>Updates: {GROUP_LINK}</i>", parse_mode="HTML", disable_web_page_preview=True, ) admin_msg = ( f"📥 <b>New Request</b>\n" f"──────────────────────────────────────\n" f" 👤 <b>{escape(user.first_name or 'Unknown')}</b> (@{user.username or 'none'})\n" f" 🎬 <b>{escape(title)}</b>\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( "💛 <b>Support MunaX Subs</b>\n──────────────────────────────────────\n\n" "Real servers, real costs. ഒരു sinima save ആക്കിയിട്ടുണ്ടോ? ❤️\n\n" f" 🏦 UPI: <code>{UPI_ID}</code>\n\n" " 📸 <a href='https://www.instagram.com/munavi.r_'>Instagram</a>\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( "👥 <b>Malayalam Subtitle Hub</b>\n──────────────────────────────────────\n\n" "Subtitle requests, bug reports, movie talk — everything.\n\n" f"🔗 {GROUP_LINK}\n\n<i>Come join us!</i> 🎬", 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( "🚨 <b>Bug / Problem Report</b>\n\n" "<b>Usage:</b>\n<code>/report Kantara subtitle download ആകുന്നില്ല</code>", parse_mode="HTML", ) return admin_msg = ( f"🚨 <b>User Report</b>\n" f"──────────────────────────────────────\n" f" 👤 <b>{escape(user.first_name or 'Unknown')}</b> (@{user.username or 'none'})\n" f" 🆔 <code>{user.id}</code>\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( "✅ <b>Report received.</b> 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( "📝 <b>Send Feedback</b>\n\n<b>Usage:</b> <code>/feedback Your message</code>", parse_mode="HTML", ) return admin_msg = ( f"💬 <b>Feedback</b>\n" f"──────────────────────────────────────\n" f" 👤 <b>{escape(user.first_name or 'Unknown')}</b> (@{user.username or 'none'})\n" f" 🆔 <code>{user.id}</code>\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( "⚙️ <b>Preferences</b>\n──────────────────────────────────────\n\n" f" 🔁 Auto-translate: <b>{'On' if auto else 'Off'}</b>\n" f" 🌐 Default lang: <b>{LANG_MAP.get(lang, lang)}</b>\n\n" "<b>Change:</b>\n" " /prefs auto on|off\n" " /prefs lang <code>ml|en|hi|ta|…</code>", 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 <code>{'|'.join(list(LANG_MAP.keys())[:12])}|…</code>", 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( "🛡️ <b>Admin Panel</b>\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"📊 <b>Engine Status</b>\n──────────────────────────────────────\n\n" f" 👥 Users: <b>{total_users:,}</b>\n" f" 📥 Downloads: <b>{total_dls:,}</b>\n" f" 🚫 Banned: <b>{banned}</b>\n" f" 👥 Groups: <b>{groups}</b>\n" f" 🗃️ Cache: <b>{cache_sz} entries</b>\n" f" 🔧 Maintenance: <b>{'On' if maint else 'Off'}</b>\n\n" f"<i>{pb('MUNAX SUBS')} ☠️ {VERSION}</i>", 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"🏓 <b>Engine alive.</b> {ms} ms\n<i>All systems operational.</i>", 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 <b>{len(users)}</b> users + <b>{len(groups)}</b> 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"📡 <b>Broadcast complete.</b>\n\n" f" ✅ Delivered: <b>{sent}</b>\n" f" ☠️ Failed: <b>{total - sent}</b>", 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 <user_id>") return try: uid = int(ctx.args[0]) except ValueError: await update.message.reply_text("❌ Invalid user ID.") return await self.db.ban_user(uid) await update.message.reply_text( f"🚫 <code>{uid}</code> — 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 <user_id>") return try: uid = int(ctx.args[0]) except ValueError: await update.message.reply_text("❌ Invalid user ID.") return await self.db.unban_user(uid) await update.message.reply_text( f"✅ <code>{uid}</code> — 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" ☠️ <code>{uid}</code> @{escape(uname)} {escape(fname)}") await update.message.reply_text( f"🚫 <b>Banned ({len(banned)})</b>\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" 🎬 <b>{escape(r['title'])}</b> <i>— {uname} {dt}</i>") await update.message.reply_text( f"📥 <b>Pending Requests ({len(reqs)})</b>\n──────────────────────────────────────\n\n" + "\n".join(lines) + "\n\n<i>/donereq <title> to mark done</i>", 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"✅ <b>{escape(title)}</b> — {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 <user_id>") return try: uid = int(ctx.args[0]) except ValueError: await update.message.reply_text("❌ Invalid user ID.") return info = await self.db.get_user_info(uid) if not info: await update.message.reply_text( f"ℹ️ No data for <code>{uid}</code>.", 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"👤 <b>User Info</b>\n──────────────────────────────────────\n\n" f" 🆔 <code>{uid}</code>\n" f" 👤 {escape(info.get('first_name',''))} @{escape(info.get('username',''))}\n" f" 📥 Downloads: <b>{info.get('downloads',0)}</b>\n" f" 🕐 First seen: <i>{first}</i>\n" f" 🚫 Banned: <b>{'Yes' if banned else 'No'}</b>\n\n" f"<b>Recent searches:</b>\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: <b>{'On' if arg == 'on' else 'Off'}</b>", 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. <b>{cnt} entries</b> 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 <b>{w}s</b>.", 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"🎬 <b>{escape(query)}</b> 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"🎬 <b>{escape(title)}</b>\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( "🔍 <b>Ready!</b>\n\nSinima / series title type ചെയ്യൂ.\n\n" "<i>Example:</i> <code>Kantara</code> · <code>Dune 2021</code> · <code>BB S01E05</code>", parse_mode="HTML", ) return if data == "verify": if await self._check_membership(ctx.bot, user.id, force=True): await q.message.edit_text( "✅ <b>Verified!</b> Welcome! 🎬\n\nSinima title type ചെയ്യൂ.", parse_mode="HTML", ) else: await q.message.edit_text( "❌ <b>Not joined yet.</b>\n\nGroup join ചെയ്ത് verify ചെയ്യൂ.", parse_mode="HTML", ) return if data == "skip": await q.message.edit_text( "✅ <b>OK!</b> 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( "⏱ <b>Session expired.</b>\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"🎬 <b>{escape(title)}</b>\n" f"✦ {lang_name}\n<i>via {src_label}</i>" ) 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"☠️ <b>{err or 'Download failed.'}</b>\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"🔎 <b>{escape(title[:50])}</b>\n\nAvailable languages check ചെയ്യുന്നു…", parse_mode="HTML", ) langs = await asyncio.to_thread(get_langs, sel["url"]) if not langs: await q.message.edit_text( "☠️ <b>No languages found.</b>\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( "🇮🇳 <b>Malayalam കിട്ടി!</b> 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"🎬 <b>{escape(title)}</b>\n" f"✦ Malayalam Subtitle\n<i>via {src_label}</i>" ) 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"☠️ <b>{err or 'Download failed.'}</b>", 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"🎬 <b>{escape(title[:50])}</b>\n\n" f"🇮🇳 Malayalam ഇല്ല. ഒരു language select ചെയ്യൂ — translate ചെയ്യാം:", reply_markup=kb_l, parse_mode="HTML", ) else: await q.message.edit_text( "☠️ <b>No languages found.</b>", 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( "⏱ <b>Session expired.</b>\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"📡 <b>{escape(lang_name)}</b> download ചെയ്യുന്നു…", parse_mode="HTML" ) path, err = await asyncio.to_thread(download_file, li["download"]) if not path: await q.message.edit_text( f"☠️ <b>{err or 'Download failed.'}</b>", 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( "⚠️ <b>File not valid.</b>\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"🎬 <b>{escape(title)}</b>\n" f"✦ {lang_name}\n<i>via {src_label}</i>" ) 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