Spaces:
Running
Running
| import re | |
| import hashlib | |
| import logging | |
| import asyncio | |
| from pyrogram import Client, filters | |
| from pyrogram.enums import ParseMode, ChatType | |
| from pyrogram.types import InlineKeyboardMarkup, InlineKeyboardButton, ForceReply | |
| from pyrogram.errors import MessageNotModified | |
| from mfinder import ADMINS, LOGGER | |
| from mfinder.plugins.admin_settings import get_log_channel | |
| from mfinder.db.requests_sql import ( | |
| add_movie_request, | |
| update_request_status, | |
| get_movie_request, | |
| get_pending_requests_count, | |
| get_all_pending_requests, | |
| ) | |
| # In-memory LRU-style cache mapping 12-char hashes to search queries to keep callback data < 64 bytes | |
| class SimpleCache: | |
| def __init__(self, maxsize=500): | |
| self.cache = {} | |
| self.maxsize = maxsize | |
| def get(self, key): | |
| return self.cache.get(key) | |
| def set(self, key, value): | |
| if len(self.cache) >= self.maxsize: | |
| first_key = next(iter(self.cache)) | |
| del self.cache[first_key] | |
| self.cache[key] = value | |
| REQUEST_QUERY_CACHE = SimpleCache() | |
| async def send_request_to_channel(bot, user, query_text: str, req_id: int): | |
| """Sends a formatted movie request card with Admin action buttons to the Log Channel.""" | |
| try: | |
| log_channel_id = await get_log_channel() | |
| if not log_channel_id: | |
| return None | |
| user_id = user.id | |
| first_name = user.first_name or "" | |
| last_name = user.last_name or "" | |
| full_name = f"{first_name} {last_name}".strip() | |
| username = f"@{user.username}" if user.username else "N/A" | |
| def escape_html(text): | |
| if not text: | |
| return "" | |
| return str(text).replace("&", "&").replace("<", "<").replace(">", ">") | |
| full_name_esc = escape_html(full_name) | |
| first_name_esc = escape_html(first_name) | |
| username_esc = escape_html(username) | |
| query_esc = escape_html(query_text) | |
| log_msg = ( | |
| f"π© <b>New Movie Request!</b>\n\n" | |
| f"π€ <b>User Details:</b>\n" | |
| f"β’ <b>User ID:</b> <code>{user_id}</code>\n" | |
| f"β’ <b>Name:</b> {full_name_esc}\n" | |
| f"β’ <b>Username:</b> {username_esc}\n" | |
| f"β’ <b>Mention:</b> <a href=\"tg://user?id={user_id}\">{first_name_esc}</a>\n\n" | |
| f"π¬ <b>Requested Movie:</b> <code>{query_esc}</code>\n" | |
| f"π <b>Request ID:</b> <code>#{req_id}</code>\n" | |
| f"π <b>Status:</b> β³ <code>Pending</code>" | |
| ) | |
| buttons = InlineKeyboardMarkup([ | |
| [ | |
| InlineKeyboardButton("β Mark Uploaded & Notify", callback_data=f"req_appr {req_id}"), | |
| InlineKeyboardButton("β Reject", callback_data=f"req_rejc {req_id}") | |
| ], | |
| [ | |
| InlineKeyboardButton("π¬ Message User", callback_data=f"req_msg {req_id}") | |
| ] | |
| ]) | |
| target_chat_id = int(log_channel_id) | |
| sent_msg = None | |
| try: | |
| sent_msg = await bot.send_message( | |
| chat_id=target_chat_id, | |
| text=log_msg, | |
| reply_markup=buttons, | |
| parse_mode=ParseMode.HTML | |
| ) | |
| except Exception as send_err: | |
| # If peer resolution fails (e.g. after container restart), refresh dialogs & retry | |
| try: | |
| async for _ in bot.get_dialogs(limit=100): | |
| pass | |
| sent_msg = await bot.send_message( | |
| chat_id=target_chat_id, | |
| text=log_msg, | |
| reply_markup=buttons, | |
| parse_mode=ParseMode.HTML | |
| ) | |
| except Exception as retry_err: | |
| LOGGER.warning(f"Failed to send request card to channel ({log_channel_id}): {retry_err}") | |
| if sent_msg: | |
| await update_request_status(req_id, "Pending", channel_msg_id=sent_msg.id) | |
| return sent_msg.id | |
| except Exception as e: | |
| LOGGER.warning(f"Error sending request card to log channel: {e}") | |
| return None | |
| async def request_command(bot, message): | |
| from mfinder.plugins.serve import verify_force_sub | |
| if not await verify_force_sub(bot, message, pending_query=message.text): | |
| return | |
| user = message.from_user | |
| if len(message.command) < 2: | |
| await message.reply_text( | |
| "β οΈ **Please provide a movie name to request!**\n\n" | |
| "**Example:** `/request Kantara Chapter 1`", | |
| quote=True, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| return | |
| query_text = message.text.split(None, 1)[1].strip() | |
| if len(query_text) < 2: | |
| await message.reply_text("β οΈ Movie title is too short.", quote=True) | |
| return | |
| user_name = f"{user.first_name or ''} {user.last_name or ''}".strip() | |
| req = await add_movie_request(user.id, user_name, query_text) | |
| asyncio.create_task(send_request_to_channel(bot, user, query_text, req.id)) | |
| reply_msg = await message.reply_text( | |
| f"β **Your request for** `{query_text}` **has been submitted to admins!**\n\n" | |
| f"π You will receive an automatic private DM as soon as it is uploaded.", | |
| quote=True, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| async def request_callback(bot, query): | |
| """Handles clicking the 'π© Request Movie' button in search UI.""" | |
| from mfinder.plugins.serve import verify_force_sub | |
| if not await verify_force_sub(bot, query): | |
| return | |
| user = query.from_user | |
| short_hash = query.data.split()[1] | |
| movie_title = REQUEST_QUERY_CACHE.get(short_hash) | |
| if not movie_title: | |
| await query.answer("β οΈ Session expired. Please search for the movie again to request it!", show_alert=True) | |
| return | |
| # Immediately answer callback query | |
| await query.answer("β Request submitted to admins!", show_alert=True) | |
| user_name = f"{user.first_name or ''} {user.last_name or ''}".strip() | |
| req = await add_movie_request(user.id, user_name, movie_title) | |
| asyncio.create_task(send_request_to_channel(bot, user, movie_title, req.id)) | |
| confirm_text = ( | |
| f"β **Your request for** `{movie_title}` **has been submitted to admins!**\n\n" | |
| f"π You will receive an automatic private DM as soon as it is uploaded." | |
| ) | |
| # Immediately edit message/remove button to prevent duplicate clicks/spam | |
| try: | |
| if query.message and query.message.reply_markup: | |
| new_rows = [] | |
| for row in query.message.reply_markup.inline_keyboard: | |
| filtered_row = [b for b in row if not (b.callback_data and b.callback_data.startswith("req_send"))] | |
| if filtered_row: | |
| new_rows.append(filtered_row) | |
| if new_rows: | |
| # Other buttons (suggestion buttons) exist: update reply_markup to remove request button | |
| await query.message.edit_reply_markup(reply_markup=InlineKeyboardMarkup(new_rows)) | |
| reply_msg = await query.message.reply_text( | |
| text=confirm_text, | |
| quote=True, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| else: | |
| # Standalone request button: replace message content directly into confirmation text | |
| await query.message.edit_text( | |
| text=confirm_text, | |
| reply_markup=None, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| else: | |
| await query.message.edit_text( | |
| text=confirm_text, | |
| reply_markup=None, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| except Exception as e: | |
| LOGGER.warning(f"Notice updating request callback UI: {e}") | |
| async def admin_request_action(bot, query): | |
| """Handles Admin clicking 'β Mark Uploaded' or 'β Reject' in the Log/Request Channel.""" | |
| user = query.from_user | |
| action = query.data.split()[0].split("_")[1] | |
| req_id = int(query.data.split()[1]) | |
| admin_name = user.first_name or "Admin" | |
| req = await get_movie_request(req_id) | |
| if not req: | |
| await query.answer("β οΈ Request record not found in database.", show_alert=True) | |
| return | |
| def escape_html(text): | |
| if not text: | |
| return "" | |
| return str(text).replace("&", "&").replace("<", "<").replace(">", ">") | |
| full_name_esc = escape_html(req.user_name or f"User {req.user_id}") | |
| query_esc = escape_html(req.query) | |
| admin_esc = escape_html(admin_name) | |
| if action == "appr": | |
| await update_request_status(req_id, "Uploaded") | |
| status_text = f"β <b>Uploaded</b> (by {admin_esc})" | |
| # 1. Update Channel Message | |
| card_msg = ( | |
| f"π© <b>Movie Request Updated</b>\n\n" | |
| f"π€ <b>User Details:</b>\n" | |
| f"β’ <b>User ID:</b> <code>{req.user_id}</code>\n" | |
| f"β’ <b>Name:</b> {full_name_esc}\n\n" | |
| f"π¬ <b>Requested Movie:</b> <code>{query_esc}</code>\n" | |
| f"π <b>Request ID:</b> <code>#{req_id}</code>\n" | |
| f"π <b>Status:</b> {status_text}" | |
| ) | |
| try: | |
| await query.message.edit_text(card_msg, parse_mode=ParseMode.HTML, reply_markup=None) | |
| except MessageNotModified: | |
| pass | |
| await query.answer("β Marked as Uploaded! Notifying user via DM...", show_alert=True) | |
| # 2. Automatically notify user in private DM | |
| dm_text = ( | |
| f"π <b>Great news, {full_name_esc}!</b>\n\n" | |
| f"The movie you requested (<b>{query_esc}</b>) has been <b>uploaded</b>! πΏ\n\n" | |
| f"Search for <code>{query_esc}</code> now to download." | |
| ) | |
| try: | |
| await bot.send_message(chat_id=req.user_id, text=dm_text, parse_mode=ParseMode.HTML) | |
| except Exception as dm_err: | |
| LOGGER.warning(f"Could not send DM to user {req.user_id} for request #{req_id}: {dm_err}") | |
| elif action == "rejc": | |
| await update_request_status(req_id, "Rejected") | |
| status_text = f"β <b>Rejected</b> (by {admin_esc})" | |
| # 1. Update Channel Message | |
| card_msg = ( | |
| f"π© <b>Movie Request Updated</b>\n\n" | |
| f"π€ <b>User Details:</b>\n" | |
| f"β’ <b>User ID:</b> <code>{req.user_id}</code>\n" | |
| f"β’ <b>Name:</b> {full_name_esc}\n\n" | |
| f"π¬ <b>Requested Movie:</b> <code>{query_esc}</code>\n" | |
| f"π <b>Request ID:</b> <code>#{req_id}</code>\n" | |
| f"π <b>Status:</b> {status_text}" | |
| ) | |
| try: | |
| await query.message.edit_text(card_msg, parse_mode=ParseMode.HTML, reply_markup=None) | |
| except MessageNotModified: | |
| pass | |
| await query.answer("β Request marked as Rejected.", show_alert=True) | |
| # 2. Automatically notify user in private DM | |
| dm_text = ( | |
| f"βΉοΈ <b>Hello {full_name_esc},</b>\n\n" | |
| f"Regarding your request for <b>{query_esc}</b>: Sorry, this movie is currently unavailable." | |
| ) | |
| try: | |
| await bot.send_message(chat_id=req.user_id, text=dm_text, parse_mode=ParseMode.HTML) | |
| except Exception as dm_err: | |
| LOGGER.warning(f"Could not send rejection DM to user {req.user_id} for request #{req_id}: {dm_err}") | |
| async def requests_admin_command(bot, message): | |
| """Admin command to check pending request count.""" | |
| count = await get_pending_requests_count() | |
| await message.reply_text( | |
| f"π **Movie Requests Summary**\n\n" | |
| f"β³ **Pending Requests:** `{count}`", | |
| quote=True, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| # Admin-User Communication Feature Implementation | |
| ADMIN_REPLY_STATE = {} | |
| async def admin_request_message_user(bot, query): | |
| user = query.from_user | |
| req_id = int(query.data.split()[1]) | |
| req = await get_movie_request(req_id) | |
| if not req: | |
| await query.answer("β οΈ Request record not found in database.", show_alert=True) | |
| return | |
| try: | |
| prompt = await bot.send_message( | |
| chat_id=query.message.chat.id, | |
| text=f"π¬ **Reply to this message with the text you want to send to the user** (ID: `{req.user_id}`) **regarding their movie request for** `{req.query}`:\n\n" | |
| f"Or type /cancel to abort.", | |
| reply_markup=ForceReply(selective=True), | |
| reply_to_message_id=query.message.id, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| # Use (chat_id, prompt.id) as key to support channels/discussion groups/private chats | |
| ADMIN_REPLY_STATE[(query.message.chat.id, prompt.id)] = { | |
| "target_user_id": req.user_id, | |
| "req_id": req_id, | |
| "movie": req.query, | |
| "prompt_msg_id": prompt.id | |
| } | |
| await query.answer("π¬ Please reply to the prompt with your message.") | |
| except Exception as e: | |
| await query.answer(f"β Failed to initiate message prompt: {e}", show_alert=True) | |
| async def admin_message_command(bot, message): | |
| if len(message.command) < 2: | |
| await message.reply_text( | |
| "β οΈ **Usage:**\n" | |
| "β’ `/message <user_id>`\n" | |
| "β’ `/message <username>`\n" | |
| "β’ `/message <user_id_or_username> <your message text>`", | |
| quote=True, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| return | |
| parts = message.text.split(None, 2) | |
| target = parts[1].strip() | |
| message_text = parts[2].strip() if len(parts) > 2 else None | |
| target_user_id = None | |
| if target.isdigit(): | |
| target_user_id = int(target) | |
| else: | |
| username_clean = target.lstrip("@") | |
| try: | |
| user_obj = await bot.get_users(username_clean) | |
| target_user_id = user_obj.id | |
| except Exception as e: | |
| await message.reply_text( | |
| f"β **Failed to resolve username** `@{username_clean}`:\n" | |
| f"`{e}`\n\n" | |
| f"Make sure the user has started/interacted with the bot before.", | |
| quote=True, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| return | |
| if message_text: | |
| try: | |
| user_msg = ( | |
| f"π **Message from Admins:**\n\n" | |
| f"{message_text}" | |
| ) | |
| await bot.send_message(chat_id=target_user_id, text=user_msg, parse_mode=ParseMode.MARKDOWN) | |
| await message.reply_text( | |
| f"β **Message successfully sent to user** `{target_user_id}`!", | |
| quote=True, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| except Exception as err: | |
| await message.reply_text( | |
| f"β **Failed to send message to user** `{target_user_id}`:\n" | |
| f"`{err}`", | |
| quote=True, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| return | |
| prompt = await message.reply_text( | |
| f"π¬ **Reply to this message with the text you want to send to user** `{target_user_id}`:\n\n" | |
| f"Or type /cancel to abort.", | |
| reply_markup=ForceReply(selective=True), | |
| quote=True, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| # Use (message.chat.id, prompt.id) as key | |
| ADMIN_REPLY_STATE[(message.chat.id, prompt.id)] = { | |
| "target_user_id": target_user_id, | |
| "prompt_msg_id": prompt.id | |
| } | |
| async def admin_reply_listener(bot, message): | |
| if not (message.reply_to_message and message.chat): | |
| message.continue_propagation() | |
| state_key = (message.chat.id, message.reply_to_message.id) | |
| if state_key not in ADMIN_REPLY_STATE: | |
| message.continue_propagation() | |
| # If it is in a group, only admins can reply | |
| if message.chat.type in [ChatType.GROUP, ChatType.SUPERGROUP]: | |
| if not (message.from_user and message.from_user.id in ADMINS): | |
| message.continue_propagation() | |
| text_to_send = message.text | |
| if not text_to_send: | |
| message.continue_propagation() | |
| if text_to_send.strip().startswith("/"): | |
| if text_to_send.strip().lower().startswith("/cancel"): | |
| ADMIN_REPLY_STATE.pop(state_key, None) | |
| await message.reply_text("β Message sending cancelled.", quote=True) | |
| return | |
| else: | |
| ADMIN_REPLY_STATE.pop(state_key, None) | |
| message.continue_propagation() | |
| state = ADMIN_REPLY_STATE.pop(state_key) | |
| target_user_id = state["target_user_id"] | |
| req_id = state.get("req_id") | |
| movie = state.get("movie") | |
| if req_id and movie: | |
| user_msg = ( | |
| f"π¬ **Admin replied to your movie request for** `{movie}`:\n\n" | |
| f"{text_to_send}" | |
| ) | |
| else: | |
| user_msg = ( | |
| f"π **Message from Admins:**\n\n" | |
| f"{text_to_send}" | |
| ) | |
| try: | |
| await bot.send_message(chat_id=target_user_id, text=user_msg, parse_mode=ParseMode.MARKDOWN) | |
| await message.reply_text( | |
| f"β **Message successfully sent to user** `{target_user_id}`!", | |
| quote=True, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| except Exception as err: | |
| await message.reply_text( | |
| f"β **Failed to send message to user** `{target_user_id}`:\n" | |
| f"`{err}`", | |
| quote=True, | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| def parse_title_words_and_year(text): | |
| # Insert space between 4-digit years and letters (e.g. beast2024 -> beast 2024) | |
| normalized = re.sub(r'(?<=[a-zA-Z])(?=\d{4})', ' ', text) | |
| normalized = re.sub(r'(?<=\d{4})(?=[a-zA-Z])', ' ', normalized) | |
| # Replace dots, underscores, dashes, brackets, parentheses with spaces | |
| normalized = re.sub(r'[\._\-()\[\]]', ' ', normalized) | |
| # Search for 4-digit year (1900-2030) | |
| match = re.search(r'\b(19\d\d|20[0-2]\d|2030)\b', normalized) | |
| if match: | |
| year = match.group(1) | |
| # Remove year from normalized | |
| title_part = normalized.replace(year, ' ') | |
| else: | |
| year = None | |
| title_part = normalized | |
| # Extract only lowercase alphanumeric words | |
| words = [w.lower() for w in re.findall(r'[a-zA-Z0-9]+', title_part) if w] | |
| return words, year | |
| def check_exact_match(query, file_name): | |
| q_words, q_year = parse_title_words_and_year(query) | |
| f_words, f_year = parse_title_words_and_year(file_name) | |
| if not q_words or not f_words: | |
| return False | |
| # If both years are specified, they must match exactly | |
| if q_year and f_year: | |
| if q_year != f_year: | |
| return False | |
| # Check if all query words are present in the file name words in correct sequence order | |
| q_idx = 0 | |
| for w in f_words: | |
| if q_idx < len(q_words) and w == q_words[q_idx]: | |
| q_idx += 1 | |
| return q_idx == len(q_words) | |
| async def check_and_notify_pending_requests(bot, file_name): | |
| """Checks pending requests for exact title and year match with file_name, | |
| notifies matching users and edits their request channel cards. | |
| """ | |
| try: | |
| from mfinder.plugins.admin_settings import get_log_channel | |
| from mfinder.utils.helpers import encode_title | |
| pending_reqs = await get_all_pending_requests() | |
| if not pending_reqs: | |
| return | |
| log_channel_id = await get_log_channel() | |
| for req in pending_reqs: | |
| try: | |
| if check_exact_match(req.query, file_name): | |
| LOGGER.info(f"Auto-match found for request #{req.id} (Query: '{req.query}') with file '{file_name}'") | |
| # 1. Update status in database | |
| await update_request_status(req.id, "Uploaded") | |
| # 2. Update channel message card (removing buttons) | |
| if log_channel_id and req.channel_msg_id: | |
| def escape_html(text): | |
| if not text: | |
| return "" | |
| return str(text).replace("&", "&").replace("<", "<").replace(">", ">") | |
| full_name_esc = escape_html(req.user_name or f"User {req.user_id}") | |
| query_esc = escape_html(req.query) | |
| status_text = "π€ <b>Auto-Matched & Uploaded</b>" | |
| card_msg = ( | |
| f"π© <b>Movie Request Updated</b>\n\n" | |
| f"π€ <b>User Details:</b>\n" | |
| f"β’ <b>User ID:</b> <code>{req.user_id}</code>\n" | |
| f"β’ <b>Name:</b> {full_name_esc}\n\n" | |
| f"π¬ <b>Requested Movie:</b> <code>{query_esc}</code>\n" | |
| f"π <b>Request ID:</b> <code>#{req.id}</code>\n" | |
| f"π <b>Status:</b> {status_text}" | |
| ) | |
| try: | |
| await bot.edit_message_text( | |
| chat_id=int(log_channel_id), | |
| message_id=req.channel_msg_id, | |
| text=card_msg, | |
| parse_mode=ParseMode.HTML, | |
| reply_markup=None | |
| ) | |
| except Exception as edit_err: | |
| LOGGER.warning(f"Could not edit channel message for auto-match request #{req.id}: {edit_err}") | |
| # 3. Notify user via private DM with Search button | |
| try: | |
| bot_me = bot.me if hasattr(bot, "me") and bot.me else await bot.get_me() | |
| bot_username = bot_me.username | |
| encoded = encode_title(req.query) | |
| search_url = f"https://t.me/{bot_username}?start=q_{encoded}_req" | |
| markup = InlineKeyboardMarkup([ | |
| [InlineKeyboardButton("πΏ Search Movie", url=search_url)] | |
| ]) | |
| dm_text = ( | |
| f"π <b>The movie you requested: {req.query} is now available!</b>\n\n" | |
| "Click below to search for it." | |
| ) | |
| await bot.send_message( | |
| chat_id=req.user_id, | |
| text=dm_text, | |
| reply_markup=markup, | |
| parse_mode=ParseMode.HTML | |
| ) | |
| except Exception as dm_err: | |
| LOGGER.warning(f"Could not notify user {req.user_id} via DM for auto-match request #{req.id}: {dm_err}") | |
| except Exception as item_err: | |
| LOGGER.warning(f"Error handling auto-match check for single request #{req.id}: {item_err}") | |
| except Exception as e: | |
| LOGGER.warning(f"Error in check_and_notify_pending_requests: {e}") | |