import logging import time import random from telegram import Update from telegram.ext import ( ContextTypes, CallbackContext, ConversationHandler, ) from telegram.constants import ParseMode from telegram.error import Forbidden, BadRequest from database import * from config import * from helpers import * from keyboards import * import pytz from datetime import datetime from datetime import time as dtime logger = logging.getLogger(__name__) async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): user = update.message.from_user args = context.args if not user.username: await update.message.reply_text("Por favor, configura un nombre de usuario en Telegram para participar. ¡Es necesario para notificarte si ganas!") return if args and args[0].startswith("join_"): try: raffle_id = int(args[0].split("_")[1]) raffle = get_raffle(raffle_id) # Get raffle details remaining_numbers_amount = get_remaining_numbers_amount(raffle_id) if raffle and raffle['active'] and remaining_numbers_amount > 0: # Check what time is it, if it's between 21:15 and 22:00, users can't join madrid_tz = pytz.timezone("Europe/Madrid") current_time = datetime.now(madrid_tz) if current_time.time() >= dtime(21, 15) and current_time.time() <= dtime(22, 0): await update.message.reply_text("Lo siento, no puedes unirte a sorteos entre las 21:15 y las 22:00 (hora de España) para evitar conflictos con el sorteo en directo. Inténtalo de nuevo más tarde.") return if len(get_reserved_numbers(user.id, raffle_id)) > 0: await update.message.reply_text("Ya tienes participaciones reservadas para este sorteo. Por favor, completa la donación o espera a que caduquen antes de unirte de nuevo.") return # The user wants to join this raffle. # Start the private conversation for number selection. logger.info(f"User {user.id} started bot with join link for raffle {raffle_id}") context.user_data['joining_raffle_id'] = raffle_id keyboard = generate_numbers_keyboard(raffle_id, user.id) is_vip = is_vip_member_of_homelabs(user.id) if is_vip: await update.message.reply_text( f"¡Hola usuario VIP! Vamos a unirnos al sorteo '{raffle['name']}'.\n\n" f"🌍 Envío internacional: {'Sí ✅' if raffle['international_shipping'] else 'No ❌'}\n" f"La donación mínima es de {raffle['price'] - VIP_DISCOUNT_PER_NUMBER}€.\n\n" "Por favor, selecciona tus números:", reply_markup=keyboard ) else: await update.message.reply_text( f"¡Hola! Vamos a unirnos al sorteo '{raffle['name']}'.\n\n" f"🌍 Envío internacional: {'Sí ✅' if raffle['international_shipping'] else 'No ❌'}\n" f"La donación mínima es de {raffle['price']}€.\n\n" "Por favor, selecciona tus números:", reply_markup=keyboard ) else: await update.message.reply_text("Este sorteo ya no está activo o no tiene números disponibles.") except (ValueError, IndexError): await update.message.reply_text("Enlace de participación inválido.") else: # Generic start message await update.message.reply_text("Hola, soy el bot de La Suerte es Tuya. Puedes participar desde los anuncios en los canales.") async def new_raffle_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Starts the conversation to create a new raffle. Asks for channels.""" user_id = update.message.from_user.id if user_id not in ADMIN_IDS: await update.message.reply_text("No tienes permiso para crear sorteos.") return ConversationHandler.END if not CHANNELS: await update.message.reply_text("No hay canales configurados. Añade CHANNEL_IDS al .env") return ConversationHandler.END context.user_data['new_raffle'] = {'channel': ""} # Initialize data for this user keyboard = generate_channel_selection_keyboard() await update.message.reply_text( "Vamos a crear un nuevo sorteo.\n\n" "Paso 1: Selecciona el canal donde se publicará el sorteo.", reply_markup=keyboard, parse_mode=ParseMode.HTML ) return SELECTING_CHANNEL async def select_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Handles channel selection or finishing selection.""" query = update.callback_query await query.answer() callback_data = query.data if callback_data.startswith(SELECT_CHANNEL_PREFIX): channel_id = callback_data[len(SELECT_CHANNEL_PREFIX):] context.user_data['new_raffle']['channel'] = channel_id await query.edit_message_text( "Canal seleccionad. Ahora, por favor, envía el título del sorteo.", parse_mode=ParseMode.HTML ) return TYPING_TITLE # Should not happen, but good practice await context.bot.send_message(chat_id=query.from_user.id, text="Opción inválida.") return SELECTING_CHANNEL async def receive_title(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Receives the raffle title and asks for description.""" title = update.message.text.strip() if not title: await update.message.reply_text("El título no puede estar vacío. Inténtalo de nuevo.") return TYPING_TITLE context.user_data['new_raffle']['title'] = title await update.message.reply_text("Título guardado. Ahora envía la descripción del sorteo.", parse_mode=ParseMode.HTML) return TYPING_DESCRIPTION async def receive_description(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Receives the raffle description and asks for price.""" description = update.message.text.strip() if not description: await update.message.reply_text("La descripción no puede estar vacía. Inténtalo de nuevo.") return TYPING_DESCRIPTION context.user_data['new_raffle']['description'] = description await update.message.reply_text("Descripción guardada. Ahora envía la imagen para el sorteo.", parse_mode=ParseMode.HTML) return SENDING_IMAGE async def receive_image(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Receives image, then asks for prices for selected channels.""" if not update.message.photo: await update.message.reply_text("Por favor, envía una imagen.") return SENDING_IMAGE photo_file_id = update.message.photo[-1].file_id context.user_data['new_raffle']['image_file_id'] = photo_file_id await update.message.reply_text( "Imagen guardada. Ahora, ¿el sorteo permite envíos internacionales? Responde con 'Sí' o 'No'.", parse_mode=ParseMode.HTML ) return INTERNATIONAL_SHIPPING async def receive_international_shipping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Receives whether international shipping is available.""" text = update.message.text.strip().lower() if text in ['sí', 'si', 's', 'yes', 'y']: context.user_data['new_raffle']['international_shipping'] = 1 elif text in ['no', 'n']: context.user_data['new_raffle']['international_shipping'] = 0 else: await update.message.reply_text("Por favor, responde con 'Sí' o 'No'.") return INTERNATIONAL_SHIPPING await update.message.reply_text( "Perfecto. Ahora, introduce el precio por número para el canal seleccionado (solo el número, ej: 5).", parse_mode=ParseMode.HTML ) return TYPING_PRICE_FOR_CHANNEL async def receive_price_for_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Receives price for the current channel, stores it, and asks for next or confirms.""" price_text = update.message.text.strip() channel_id = context.user_data['new_raffle']['channel'] try: price = int(price_text) if not (1 <= price <= 999): # Allow higher prices maybe raise ValueError("El precio debe ser un número entre 1 y 999.") except ValueError: channel_alias = REVERSE_CHANNELS.get(channel_id, f"ID:{channel_id}") await update.message.reply_text(f"Precio inválido para {channel_alias}. Debe ser un número (ej: 5). Inténtalo de nuevo.") return TYPING_PRICE_FOR_CHANNEL # Stay in this state context.user_data['new_raffle']['price'] = price logger.info(f"Price for channel {channel_id} set to {price}") await _show_creation_confirmation(update, context) return CONFIRMING_CREATION async def _show_creation_confirmation(update: Update, context: ContextTypes.DEFAULT_TYPE): """Shows the final confirmation message before creating the raffle.""" raffle_data = context.user_data['new_raffle'] channel_id = raffle_data.get('channel', "") price = raffle_data.get('price', 0) confirmation_text = ( "¡Perfecto! Revisa los datos del sorteo:\n\n" f"📌 Título: {raffle_data.get('title', 'N/A')}\n" f"📝 Descripción: {raffle_data.get('description', 'N/A')}\n" f"📺 Canal: {REVERSE_CHANNELS.get(channel_id, channel_id)}\n" f"🌍 Envío internacional: {'Sí ✅' if raffle_data.get('international_shipping', 0) else 'No ❌'}\n" f"💶 Donación mínima: {price}€\n" f"🖼️ Imagen: (Adjunta)\n\n" "¿Confirmas la creación de este sorteo?" ) keyboard = generate_confirmation_keyboard() # Message from which confirmation is triggered is the last price input message. # We need to send the photo with this as caption. await context.bot.send_photo( chat_id=update.message.chat_id, # Send to the admin's chat photo=raffle_data['image_file_id'], caption=confirmation_text, reply_markup=keyboard, parse_mode=ParseMode.HTML ) async def confirm_creation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: query = update.callback_query await query.answer() user_data = context.user_data.get('new_raffle') if not user_data: # Should not happen await query.edit_message_caption("Error: No se encontraron datos. Empieza de nuevo.", reply_markup=None) return ConversationHandler.END if query.data == CONFIRM_CREATION_CALLBACK: await query.edit_message_caption("Confirmado. Creando y anunciando...", reply_markup=None) name = user_data.get('title') description = user_data.get('description') price = user_data.get('price') image_file_id = user_data.get('image_file_id') channel_id = user_data.get('channel') international_shipping = user_data.get('international_shipping', 0) exists_international_shipping = False if international_shipping != None: exists_international_shipping = True if not all([name, description, image_file_id, price, exists_international_shipping]): missing_fields = [] if not name: missing_fields.append("título") if not description: missing_fields.append("descripción") if not image_file_id: missing_fields.append("imagen") if not price: missing_fields.append("precio") if not exists_international_shipping: missing_fields.append("envío internacional") await context.bot.send_message(query.from_user.id, f"Faltan datos: {', '.join(missing_fields)}. Creación cancelada.") context.user_data.pop('new_raffle', None) return ConversationHandler.END raffle_id = create_raffle(name, description, price, image_file_id, channel_id, international_shipping) if raffle_id: await context.bot.send_message(query.from_user.id, f"✅ ¡Sorteo '{name}' creado con éxito!") await _announce_raffle_in_channels(context, raffle_id, query.from_user.id, initial_announcement=True) else: await context.bot.send_message(query.from_user.id, f"❌ Error al guardar el sorteo. Nombre '{name}' podría existir.") elif query.data == CANCEL_CREATION_CALLBACK: await query.edit_message_caption("Creación cancelada.", reply_markup=None) context.user_data.pop('new_raffle', None) return ConversationHandler.END async def cancel_creation_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Handles /cancel command during the conversation.""" user_id = update.message.from_user.id if 'new_raffle' in context.user_data: context.user_data.pop('new_raffle', None) await update.message.reply_text("Creación de sorteo cancelada.") logger.info(f"Admin {user_id} cancelled raffle creation via /cancel.") return ConversationHandler.END else: await update.message.reply_text("No hay ninguna creación de sorteo en curso para cancelar.") return ConversationHandler.END # Or return current state if applicable # ... (other handlers and functions) ... async def incorrect_input_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """ Handles messages that are not of the expected type in the current conversation state. This function tries to determine which conversation (creation or edit) is active. """ user_id = update.message.from_user.id current_conversation_data = None current_state_key = None # To store the key like '_new_raffle_conv_state' conversation_type = "desconocida" # For logging # Try to determine which conversation is active by checking user_data if 'new_raffle' in context.user_data and '_new_raffle_conv_state' in context.user_data: # Assuming PTB stores state like this current_conversation_data = context.user_data['new_raffle'] current_state_key = '_new_raffle_conv_state' current_state = context.user_data[current_state_key] conversation_type = "creación de sorteo" else: # Not in a known conversation, or state tracking key is different. # This message might be outside any conversation this handler is for. # logger.debug(f"User {user_id} sent unexpected message, but not in a tracked conversation state for incorrect_input_type.") # For safety, if it's a fallback in a ConversationHandler, returning the current state (if known) or END is best. # If this is a global fallback, it shouldn't interfere. # If it's a fallback *within* a ConversationHandler, PTB should handle current state. # For this specific function, we assume it's called as a fallback in one of the convs. active_conv_state = context.user_data.get(ConversationHandler.STATE) # More generic way to get current state of active conv if active_conv_state: await update.message.reply_text( "Entrada no válida para este paso. " "Usa /cancelar o /cancelar_edicion si quieres salir del proceso actual." ) return active_conv_state # Return to the current state of the conversation else: # logger.debug("No active conversation detected by ConversationHandler.STATE for incorrect_input_type.") return ConversationHandler.END # Or simply don't reply if it's truly unexpected logger.warning(f"User {user_id} sent incorrect input type during {conversation_type} (State: {current_state}). Message: {update.message.text or ''}") # --- Handle incorrect input for RAFFLE CREATION states --- if conversation_type == "creación de sorteo": if current_state == SENDING_IMAGE: await update.message.reply_text( "Por favor, envía una IMAGEN para el sorteo, no texto u otro tipo de archivo.\n" "Si quieres cancelar, usa /cancelar." ) return SENDING_IMAGE # Stay in the image sending state elif current_state == TYPING_TITLE: await update.message.reply_text("Por favor, envía TEXTO para el título del sorteo. Usa /cancelar para salir.") return TYPING_TITLE elif current_state == TYPING_DESCRIPTION: await update.message.reply_text("Por favor, envía TEXTO para la descripción del sorteo. Usa /cancelar para salir.") return TYPING_DESCRIPTION elif current_state == TYPING_PRICE_FOR_CHANNEL: channel_id_for_price = current_conversation_data.get('current_channel_for_price') channel_alias = REVERSE_CHANNELS.get(channel_id_for_price, f"ID:{channel_id_for_price}") if channel_id_for_price else "el canal actual" await update.message.reply_text( f"Por favor, envía un NÚMERO para el precio del sorteo en {channel_alias}.\n" "Usa /cancelar para salir." ) return TYPING_PRICE_FOR_CHANNEL # Add more states if needed (e.g., if SELECTING_CHANNELS expects only callbacks) # Generic fallback message if specific state isn't handled above for some reason await update.message.reply_text( "Entrada no válida para este paso de la conversación.\n" "Si estás creando un sorteo, usa /cancelar para salir.\n" "Si estás editando un sorteo, usa /cancelar_edicion para salir." ) return current_state # Return to the state the conversation was in # Handle number selection callback async def number_callback(update: Update, context: CallbackContext): """Handles clicks on the number buttons in the private chat.""" query = update.callback_query user_id = query.from_user.id if not query.from_user.username: await query.answer("Por favor, configura un nombre de usuario en Telegram para participar.") return username = query.from_user.username try: data = query.data.split(':') action = data[0] # "number" raffle_id = int(data[1]) if action != "random_num": value = data[2] # Can be number string or "next"/"prev" else: value = "" except (IndexError, ValueError): logger.error(f"Invalid callback data in number_callback: {query.data}") await query.answer("Error: Acción inválida.") return # --- Handle Paging --- if value == "next": # Determine current page (requires inspecting the current keyboard, which is complex) # Easier: Store current page in user_data or derive from button structure if possible. # Simplest robust approach: Assume the keyboard generation knows the max pages. # Let's try generating the next page's keyboard. # We need the *current* page to calculate the *next* page. # Hacky way: find the "next" button in the *current* keyboard's markup. If it exists, assume page 0. current_page = 0 # Default assumption if query.message.reply_markup: for row in query.message.reply_markup.inline_keyboard: for button in row: if button.callback_data == f"number:{raffle_id}:prev": current_page = 1 # If prev exists, we must be on page 1 break next_page = current_page + 1 # Basic check: Assume max 2 pages (0-49, 50-99) if next_page <= 1: keyboard = generate_numbers_keyboard(raffle_id, user_id, page=next_page) await query.edit_message_reply_markup(reply_markup=keyboard) await query.answer(f"Mostrando página {next_page + 1}") else: await query.answer("Ya estás en la última página.") return elif value == "prev": # Similar logic to find current page. current_page = 1 # Default assumption if prev is clicked if query.message.reply_markup: has_next = False for row in query.message.reply_markup.inline_keyboard: for button in row: if button.callback_data == f"number:{raffle_id}:next": has_next = True break if not has_next: # If no "next" button, we must be on page 1 current_page = 1 else: # If "next" exists, we must be on page 0 (edge case, shouldn't happen if prev was clicked) current_page = 0 # Should logically be 1 if prev was clicked. prev_page = current_page - 1 if prev_page >= 0: keyboard = generate_numbers_keyboard(raffle_id, user_id, page=prev_page) await query.edit_message_reply_markup(reply_markup=keyboard) await query.answer(f"Mostrando página {prev_page + 1}") else: await query.answer("Ya estás en la primera página.") return # --- Handle Number Selection/Deselection --- if action == "number": try: number = int(value) if not (0 <= number <= 99): raise ValueError("Number out of range") number_string = f"{number:02}" # Determine page number for refresh page = 0 if number < 50 else 1 except ValueError: logger.warning(f"Invalid number value in callback: {value}") await query.answer("Participación no válida.") return elif action == "random_num": # Handle random number selection try: remaining_free_numbers = get_remaining_numbers(raffle_id) if not remaining_free_numbers: await query.answer("No hay participaciones disponibles para seleccionar aleatoriamente.") return else: # Select a random number from the available ones number_string = random.choice(remaining_free_numbers) if not (0 <= int(number_string) <= 99): raise ValueError("Random number out of range") page = 0 if int(number_string) < 50 else 1 except ValueError: logger.warning(f"Invalid random number value in callback: {value}") await query.answer("Participación aleatoria no válida.") return logger.debug(f"User {user_id} interacted with number {number_string} for raffle {raffle_id}") # Check the status of the number participant_data = get_participant_by_number(raffle_id, number_string) # Check anyone holding this number if participant_data: participant_user_id = participant_data['user_id'] participant_step = participant_data['step'] participant_db_id = participant_data['id'] # The ID from the participants table user_name = participant_data['user_name'] or participant_data['user_id'] if participant_user_id == user_id: # User clicked a number they already interact with if participant_step == "waiting_for_payment": # User clicked a number they have reserved -> Deselect it remove_reserved_number(participant_db_id, number_string) await query.answer(f"Has quitado la reserva de la participación {number_string}.") logger.info(f"User {user_id} (Name: {user_name}) deselected reserved number {number_string} for raffle {raffle_id}") # Refresh keyboard keyboard = generate_numbers_keyboard(raffle_id, user_id, page=page) await query.edit_message_reply_markup(reply_markup=keyboard) elif participant_step == "completed": # User clicked a number they have paid for -> Inform them await query.answer(f"Ya tienes la participación {number_string} (donada).") logger.debug(f"User {user_id} clicked their own completed number {number_string}") else: # Should not happen with current steps, but catch just in case await query.answer(f"Estado desconocido para tu participación {number_string}.") logger.warning(f"User {user_id} clicked number {number_string} with unexpected step {participant_step}") else: # User clicked a number taken by someone else status_msg = "reservada" if participant_step == "waiting_for_payment" else "donada" await query.answer(f"La participación {number_string} ya ha sido {status_msg} por otro usuario.") logger.debug(f"User {user_id} clicked number {number_string} taken by user {participant_user_id}") else: # Number is free -> Reserve it for the user reserve_number(user_id, username, raffle_id, number_string) await query.answer(f"Participación {number_string} reservada para ti. Confirma tu selección cuando termines.") logger.info(f"User {user_id} (Name: {username}) reserved number {number_string} for raffle {raffle_id}") # Refresh keyboard to show the lock icon keyboard = generate_numbers_keyboard(raffle_id, user_id, page=page) await query.edit_message_reply_markup(reply_markup=keyboard) async def confirm_callback(update: Update, context: CallbackContext): """Handles the 'Confirmar Selección' button click.""" query = update.callback_query user_id = query.from_user.id try: raffle_id = int(query.data.split(":")[1]) except (IndexError, ValueError): logger.error(f"Invalid callback data received in confirm_callback: {query.data}") await query.answer("Error: Acción inválida.") return # Get numbers reserved by this user for this raffle reserved_numbers = get_reserved_numbers(user_id, raffle_id) # Returns a list of strings if not reserved_numbers: await query.answer("No has seleccionado ninguna participación nueva para confirmar.") return await query.answer("Generando enlace de donación...") # Give feedback raffle_info = get_raffle(raffle_id) if not raffle_info: logger.error(f"Cannot find raffle {raffle_id} during confirmation for user {user_id}") # Use query.edit_message_caption or text depending on what was sent before try: await query.edit_message_caption("Error: No se pudo encontrar la información del sorteo.", reply_markup=None) except BadRequest: await query.edit_message_text("Error: No se pudo encontrar la información del sorteo.", reply_markup=None) return # Get participant DB ID (needed for setting invoice ID) # We assume reserve_number created the row if it didn't exist participant = get_participant_by_user_id_and_step(user_id, "waiting_for_payment") if not participant: logger.error(f"Cannot find participant record for user {user_id}, raffle {raffle_id} in 'waiting_for_payment' step during confirmation.") try: await query.edit_message_caption("Error: No se encontró tu registro. Selecciona las participaciones de nuevo.", reply_markup=None) except BadRequest: await query.edit_message_text("Error: No se encontró tu registro. Selecciona las participaciones de nuevo.", reply_markup=None) return participant_db_id = participant['id'] price_per_number = raffle_info['price'] if price_per_number is None: logger.error(f"Price not found for raffle {raffle_id} during confirmation for user {user_id}") await query.answer("Error: Ha habido un problema desconocido, contacta con el administrador.", show_alert=True) return user_name = participant['user_name'] is_vip = is_vip_member_of_homelabs(user_id) if is_vip: price_per_number -= VIP_DISCOUNT_PER_NUMBER if price_per_number < 1: price_per_number = 1 # Minimum price total_price = len(reserved_numbers) * price_per_number paypal_link, invoice_id = create_paypal_order(get_paypal_access_token(), total_price, raffle_info['id'], reserved_numbers, user_name) mark_reservation_pending(participant_db_id, invoice_id) # Log the PayPal link for debugging logger.info(f"Generated PayPal link for user {user_id}: {paypal_link}") # Define the button text and create the URL button paypal_button_text = "💳 Donar con PayPal 💳" paypal_button = InlineKeyboardButton(paypal_button_text, url=paypal_link) # Create the InlineKeyboardMarkup containing the button payment_keyboard = InlineKeyboardMarkup([[paypal_button]]) # Modify the message text - remove the link placeholder, adjust instruction payment_message = ( f"👍 Selección Confirmada 👍\n\n" f"Participaciones reservadas: {', '.join(reserved_numbers)}\n" f"Donación mínima: {total_price:.2f}€\n\n" f"El ID de la factura es: {invoice_id}\n" f"Pulsa el botón para completar la donación en PayPal:\n" # Adjusted instruction # Link is now in the button below f"⚠️ Tienes {RESERVATION_TIMEOUT_MINUTES} minutos para pagar antes de que las participaciones se liberen.\n\n" f"Una vez hayas donado, se te notificará aquí. La donación puede tardar hasta 5 minutos en procesarse, sé paciente." ) try: await query.edit_message_text( text=payment_message, reply_markup=payment_keyboard, # Use the keyboard with the button disable_web_page_preview=True # Preview not needed as link is in button # No parse_mode needed ) logger.debug(f"Edited message text (fallback) for user {user_id} with PayPal button.") except Exception as text_e: logger.error(f"Failed to edit message text as fallback for user {user_id}: {text_e}") # Send new message with the button await context.bot.send_message(user_id, payment_message, reply_markup=payment_keyboard, disable_web_page_preview=True) async def cancel_callback(update: Update, context: CallbackContext): """Handles the 'Cancelar Selección' button click.""" query = update.callback_query user_id = query.from_user.id try: raffle_id = int(query.data.split(":")[1]) except (IndexError, ValueError): logger.error(f"Invalid callback data received in cancel_callback: {query.data}") await query.answer("Error: Acción inválida.") return # Get currently reserved (waiting_for_payment) numbers for this user/raffle reserved_numbers = get_reserved_numbers(user_id, raffle_id) if not reserved_numbers: await query.answer("No tienes ninguna selección de participaciones pendiente para cancelar.") # Optionally, revert the message back to the initial raffle selection prompt or just inform the user. # Let's just edit the message to say nothing is pending. try: await query.edit_message_caption("No hay participaciones reservadas para cancelar.", reply_markup=None) except BadRequest: # If message was text, not photo caption await query.edit_message_text("No hay participaciones reservadas para cancelar.", reply_markup=None) return # Cancel the reservation in the database cancelled = cancel_reserved_numbers(user_id, raffle_id) # This function deletes the 'waiting_for_payment' row if cancelled: # Check if the function indicated success (e.g., return True or affected rows) await query.answer(f"Selección cancelada. Participaciones liberadas: {', '.join(reserved_numbers)}") logger.info(f"User {user_id} cancelled reservation for raffle {raffle_id}. Numbers: {reserved_numbers}") # Edit the message to confirm cancellation try: await query.edit_message_caption("Tu selección de participaciones ha sido cancelada y las participaciones han sido liberadas.", reply_markup=None) except BadRequest: await query.edit_message_text("Tu selección de participaciones ha sido cancelada y las participaciones han sido liberadas.", reply_markup=None) # Optionally, you could send the user back to the raffle selection list or number grid. # For simplicity, we just end the interaction here. else: logger.error(f"Failed to cancel reservation in DB for user {user_id}, raffle {raffle_id}.") await query.answer("Error al cancelar la reserva. Contacta con un administrador.") # Don't change the message, let the user know there was an error # --- Helper Function for Ending Raffle (Refactored Logic) --- async def end_raffle_logic(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, winner_numbers: list[int], admin_user_id: int): """Core logic to end a raffle, find winners, and announce.""" raffle_details = get_raffle(raffle_id) # Gets basic info (name, description, active status) if not raffle_details: logger.error(f"Attempted to end non-existent raffle ID {raffle_id}") try: await context.bot.send_message(admin_user_id, f"Error: No se encontró el sorteo con ID {raffle_id}.") except Exception as e: logger.error(f"Failed to send error message to admin {admin_user_id}: {e}") return False raffle_name = raffle_details['name'] if not raffle_details['active']: logger.warning(f"Admin {admin_user_id} tried to end already inactive raffle '{raffle_name}' (ID: {raffle_id})") try: await context.bot.send_message(admin_user_id, f"El sorteo '{raffle_name}' ya estaba terminado.") except Exception as e: logger.error(f"Failed to send 'already inactive' message to admin {admin_user_id}: {e}") return False # End the raffle in DB if not end_raffle(raffle_id): logger.error(f"Failed to mark raffle ID {raffle_id} as inactive in the database.") try: await context.bot.send_message(admin_user_id, f"Error al marcar el sorteo '{raffle_name}' como terminado en la base de datos.") except Exception as e: logger.error(f"Failed to send DB error message to admin {admin_user_id}: {e}") return False logger.info(f"Raffle '{raffle_name}' (ID: {raffle_id}) marked as ended by admin {admin_user_id}.") # Get winners and format announcement channel_id_str = raffle_details['channel_id'] channel_alias = REVERSE_CHANNELS.get(channel_id_str, f"ID:{channel_id_str}") winners_str = get_winners(raffle_id, winner_numbers) formatted_winner_numbers = ", ".join(f"{n:02}" for n in sorted(winner_numbers)) announcement = f"🎯🏆🎯 ¡Resultados del Sorteo '{raffle_name}'! 🎯🏆🎯\n\n" announcement += f"Detalles del sorteo: https://t.me/{channel_alias}/{get_main_message_id(raffle_id)}\n" announcement += f"Participaciones ganadoras: {formatted_winner_numbers}\n\n" if len(winner_numbers) > 1 else f"Participación ganadora: {formatted_winner_numbers}\n\n" if winners_str: # Ensure winners_str is not empty or a "no winners" message itself announcement += f"Ganadores:\n{winners_str}\n\n¡Felicidades!" if len(winner_numbers) > 1 else f"Ganador:\n{winners_str}\n\n¡Felicidades!" else: announcement += "No hubo ganadores para estas participaciones." if len(winner_numbers) > 1 else "No hubo ganador para esta participación." announcement += f"\nPuedes comprobar los resultados en {JUEGOS_ONCE_URL}" announcement += "\n\nGracias a todos por participar. Mantente atento a futuros sorteos." main_announcement = f"🎯🏆🎯 Sorteo '{raffle_name}' terminado 🎯🏆🎯\n\n" main_announcement += f"{raffle_details['description']}\n\n" main_announcement += f"🌍 Envío internacional: {'Sí ✅' if raffle_details['international_shipping'] else 'No ❌'}\n" main_announcement += f"💵 Donación mínima: {raffle_details['price']}€\n" main_announcement += f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" main_message_id = get_main_message_id(raffle_id) try: await context.bot.send_message(chat_id=int(channel_id_str), text=announcement, parse_mode=ParseMode.HTML) await context.bot.edit_message_caption(chat_id=int(channel_id_str), message_id=main_message_id, caption=main_announcement, reply_markup=None, parse_mode=ParseMode.HTML) logger.info(f"Announced winners for raffle {raffle_id} in channel {channel_alias} (ID: {channel_id_str})") except Forbidden: logger.error(f"Permission error announcing winners in channel {channel_alias} (ID: {channel_id_str}).") except BadRequest as e: logger.error(f"Bad request announcing winners in channel {channel_alias} (ID: {channel_id_str}): {e}") except Exception as e: logger.error(f"Failed to announce winners in channel {channel_alias} (ID: {channel_id_str}): {e}") # Notify admin of success try: number_of_participations = get_total_participations(raffle_id) price_per_number = raffle_details['price'] total_gross = 0 total_net = 0 total_fees = 0 invoice_ids = get_all_invoice_ids(raffle_id) for inv_id in invoice_ids: gross, net, fees = get_paypal_amounts_for_invoice(inv_id) total_gross += gross total_net += net total_fees += fees for admin_user_id in ADMIN_IDS: await context.bot.send_message(admin_user_id, f"Sorteo '{raffle_name}' terminado y ganadores anunciados.") await context.bot.send_message( admin_user_id, f"Resumen del sorteo '{raffle_name}':\n" f"- Participaciones totales: {number_of_participations}\n" f"- Donación mínima por participación: {price_per_number}€\n" f"- Total recaudado: {total_gross}€\n" f"- Total en comisiones de PayPal: {total_fees:.2f}€\n" f"- Beneficio neto estimado: {total_net}€" ) except Exception as e: logger.error(f"Failed to notify admin {admin_user_id} about ended raffle '{raffle_name}': {e}") return True # --- Admin Menu Handlers --- async def admin_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handles the /menu command for admins in private chat.""" user = update.message.from_user chat = update.message.chat if user.id not in ADMIN_IDS: # Ignore silently if not admin return if chat.type != 'private': try: await update.message.reply_text("El comando /menu solo funciona en chat privado conmigo.") # Optionally delete the command from the group await context.bot.delete_message(chat.id, update.message.message_id) except Exception as e: logger.warning(f"Could not reply/delete admin /menu command in group {chat.id}: {e}") return logger.info(f"Admin {user.id} accessed /menu") keyboard = generate_admin_main_menu_keyboard() await update.message.reply_text("🛠️ Menú de Administrador 🛠️", reply_markup=keyboard, parse_mode=ParseMode.HTML) async def admin_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handles callbacks from the admin menus.""" query = update.callback_query user_id = query.from_user.id # Ensure the callback is from an admin if user_id not in ADMIN_IDS: await query.answer("No tienes permiso.", show_alert=True) return await query.answer() # Acknowledge callback data = query.data if data == ADMIN_MENU_CREATE: # Guide admin to use the conversation command await query.edit_message_text( "Para crear un nuevo sorteo, inicia la conversación usando el comando:\n\n" "/crear_sorteo\n\n" "Pulsa el botón abajo para volver al menú principal.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Volver al Menú", callback_data=ADMIN_MENU_BACK_MAIN)]]) ) elif data == ADMIN_MENU_LIST: logger.info(f"Admin {user_id} requested raffle list.") keyboard = generate_admin_list_raffles_keyboard() active_raffles = get_active_raffles() message_text = "Sorteos Activos\n\nSelecciona un sorteo para ver detalles, anunciar o terminar:" if active_raffles else "Sorteos Activas\n\nNo hay sorteos activos." await query.edit_message_text(message_text, reply_markup=keyboard, parse_mode=ParseMode.HTML) elif data == ADMIN_MENU_BACK_MAIN: keyboard = generate_admin_main_menu_keyboard() await query.edit_message_text("🛠️ Menú de Administrador 🛠️", reply_markup=keyboard, parse_mode=ParseMode.HTML) # --- Raffle Specific Actions --- elif data.startswith(ADMIN_VIEW_RAFFLE_PREFIX): try: await query.answer("Cargando detalles del sorteo...") raffle_id = int(data[len(ADMIN_VIEW_RAFFLE_PREFIX):]) logger.info(f"Admin {user_id} requested details for raffle {raffle_id}") details_text = format_raffle_details(raffle_id) # Use helper details_keyboard = generate_admin_raffle_details_keyboard(raffle_id) await query.edit_message_text(details_text, reply_markup=details_keyboard, parse_mode=ParseMode.HTML) except (ValueError, IndexError): logger.error(f"Invalid callback data for view raffle: {data}") await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_list_raffles_keyboard()) elif data.startswith(ADMIN_ANNOUNCE_RAFFLE_PREFIX): try: raffle_id = int(data[len(ADMIN_ANNOUNCE_RAFFLE_PREFIX):]) logger.info(f"Admin {user_id} requested re-announce for raffle {raffle_id}") await query.edit_message_text(f"📢 Re-anunciando el sorteo {get_raffle_name(raffle_id)}...", reply_markup=None) # Give feedback await _announce_raffle_in_channels(context, raffle_id, user_id) # Optionally go back to the list or details view after announcing keyboard = generate_admin_list_raffles_keyboard() # Go back to list await context.bot.send_message(user_id, "Volviendo a la lista de sorteos:", reply_markup=keyboard) except (ValueError, IndexError): logger.error(f"Invalid callback data for announce raffle: {data}") await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_list_raffles_keyboard()) elif data.startswith(ADMIN_UPDATE_IMAGE_PREFIX): try: raffle_id = int(data[len(ADMIN_UPDATE_IMAGE_PREFIX):]) logger.info(f"Admin {user_id} requested image update for raffle {raffle_id}") await query.edit_message_text(f"🔄 Actualizando imagen del sorteo {get_raffle_name(raffle_id)}...", reply_markup=None) # Use the extracted function to send/update the raffle image if send_raffle_update_image(raffle_id, bot_token=context.bot.token): await context.bot.send_message(user_id, f"✅ Imagen actualizada correctamente para el sorteo {get_raffle_name(raffle_id)}.") else: await context.bot.send_message(user_id, f"❌ Error al actualizar la imagen para el sorteo {get_raffle_name(raffle_id)}.") # Go back to raffle details details_text = format_raffle_details(raffle_id) details_keyboard = generate_admin_raffle_details_keyboard(raffle_id) await context.bot.send_message(user_id, details_text, reply_markup=details_keyboard, parse_mode=ParseMode.HTML) except (ValueError, IndexError): logger.error(f"Invalid callback data for update image: {data}") await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_list_raffles_keyboard()) elif data.startswith(ADMIN_END_RAFFLE_PROMPT_PREFIX): try: raffle_id = int(data[len(ADMIN_END_RAFFLE_PROMPT_PREFIX):]) except (ValueError, IndexError): logger.error(f"Invalid callback data for end raffle prompt: {data}") await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_main_menu_keyboard()) return raffle = get_raffle(raffle_id) if not raffle or not raffle['active']: await query.edit_message_text("Este sorteo no existe o ya ha terminado.", reply_markup=generate_admin_list_raffles_keyboard()) return # Store raffle ID and set flag to expect winner numbers context.user_data['admin_ending_raffle_id'] = raffle_id context.user_data['expecting_winners_for_raffle'] = raffle_id # Store ID for context check logger.info(f"Admin {user_id} prompted to end raffle {raffle_id} ('{raffle['name']}'). Expecting winner numbers.") keyboard = generate_admin_cancel_end_keyboard() await query.edit_message_text( f"Vas a terminar el sorteo: {raffle['name']}\n\n" "Por favor, envía ahora las participaciones ganadoras separadas por espacios (ej: 7 23 81).", reply_markup=keyboard, parse_mode=ParseMode.HTML ) elif data == ADMIN_CANCEL_END_PROCESS: # Clear the flags context.user_data.pop('admin_ending_raffle_id', None) context.user_data.pop('expecting_winners_for_raffle', None) logger.info(f"Admin {user_id} cancelled the raffle end process.") # Go back to the raffle list keyboard = generate_admin_list_raffles_keyboard() await query.edit_message_text("Sorteos Activos\n\nSelecciona un sorteo para terminarlo:", reply_markup=keyboard, parse_mode=ParseMode.HTML) elif data == ADMIN_NO_OP: # Just ignore clicks on placeholder buttons like "No hay sorteos activos" pass async def admin_receive_winner_numbers(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handles the text message containing winner numbers from admin.""" user_id = update.message.from_user.id chat_id = update.message.chat_id # Basic checks: admin, private chat, expecting numbers if user_id not in ADMIN_IDS or update.message.chat.type != 'private': return # Ignore irrelevant messages expecting_raffle_id = context.user_data.get('expecting_winners_for_raffle') ending_raffle_id = context.user_data.get('admin_ending_raffle_id') # Check if we are actually expecting numbers for the specific raffle ID if not expecting_raffle_id or expecting_raffle_id != ending_raffle_id or not ending_raffle_id: # Not expecting input, or state mismatch. Could be a normal message. # logger.debug(f"Received text from admin {user_id} but not expecting winners or mismatch.") return logger.info(f"Admin {user_id} submitted winner numbers for raffle {ending_raffle_id}: {update.message.text}") # Parse and validate winner numbers try: numbers_text = update.message.text.strip() if not numbers_text: raise ValueError("Input is empty.") # Split by space, convert to int, filter range, remove duplicates, sort winner_numbers = sorted(list(set( int(n) for n in numbers_text.split() if n.isdigit() and 0 <= int(n) <= 99 ))) if not winner_numbers: raise ValueError("No valid numbers between 0 and 99 found.") except ValueError as e: logger.warning(f"Invalid winner numbers format from admin {user_id}: {e}") keyboard = generate_admin_cancel_end_keyboard() await update.message.reply_text( f"❌ Participaciones inválidas: {e}\n\n" "Por favor, envía las participaciones ganadoras (0-99) separadas por espacios (ej: 7 23 81).", reply_markup=keyboard, parse_mode=ParseMode.HTML ) # Keep expecting input return # Clear the expectation flags *before* processing raffle_id_to_end = ending_raffle_id # Store locally before clearing context.user_data.pop('expecting_winners_for_raffle', None) context.user_data.pop('admin_ending_raffle_id', None) # Call the refactored ending logic await update.message.reply_text(f"Procesando finalización con participaciones: {', '.join(f'{n:02}' for n in winner_numbers)}...") success = await end_raffle_logic(context, raffle_id_to_end, winner_numbers, user_id) # Send admin back to the main menu after processing keyboard = generate_admin_main_menu_keyboard() await context.bot.send_message(chat_id=user_id, text="Volviendo al Menú Principal...", reply_markup=keyboard) async def _announce_raffle_in_channels(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, admin_user_id: int, initial_announcement: bool = False): """Fetches raffle details and sends announcement to its configured channels.""" raffle = get_raffle(raffle_id) if not raffle: logger.warning(f"Admin {admin_user_id} tried to announce non-existent raffle {raffle_id}.") try: await context.bot.send_message(admin_user_id, f"Error: El ID del sorteo {raffle_id} no existe.") except Exception as e: logger.error(f"Failed to send 'raffle not found' error to admin {admin_user_id}: {e}") return False if not initial_announcement and not raffle['active']: # For re-announcements, it must be active logger.warning(f"Admin {admin_user_id} tried to re-announce inactive raffle {raffle_id} ('{raffle['name']}').") try: await context.bot.send_message(admin_user_id, f"Error: El sorteo '{raffle['name']}' (ID {raffle_id}) no está activo y no se puede re-anunciar.") except Exception as e: logger.error(f"Failed to send 'raffle inactive' error to admin {admin_user_id}: {e}") return False raffle_name = raffle['name'] image_file_id = raffle['image_file_id'] raffle_description = raffle['description'][:350] # Get description for caption price = raffle['price'] channel_id_str = raffle['channel_id'] # Get remaining numbers ONCE before the loop for efficiency remaining_count = get_remaining_numbers_amount(raffle_id) logger.info(f"Admin {admin_user_id} initiating {'initial ' if initial_announcement else 're-'}announcement for raffle {raffle_id} ('{raffle_name}')") channel_alias = REVERSE_CHANNELS.get(channel_id_str, f"ID:{channel_id_str}") announce_caption = ( f"🏆 ¡{'Nuevo ' if initial_announcement else ''}Sorteo Disponible! 🏆\n\n" f"🌟 {raffle_name} 🌟\n\n" f"{raffle_description}\n\n" f"🌍 Envío internacional: {'Sí ✅' if raffle['international_shipping'] else 'No ❌'}\n" f"💵 Donación mínima: {price}€\n" f"🎟️ Participaciones disponibles: {remaining_count if remaining_count >= 0 else 'N/A'}\n\n" f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" ) message_args = {"parse_mode": ParseMode.HTML} if image_file_id: message_args["photo"] = image_file_id message_args["caption"] = announce_caption message_args["reply_markup"] = generate_channel_participate_keyboard(raffle_id) send_method = context.bot.send_photo else: message_args["text"] = announce_caption message_args["reply_markup"] = generate_channel_participate_keyboard(raffle_id) send_method = context.bot.send_message sent_message = None # Initialize sent_message variable try: # --- 1. Send the message --- sent_message = await send_method(chat_id=int(channel_id_str), **message_args) store_main_message_id(raffle_id, sent_message.message_id) logger.info(f"Announcement sent to channel {channel_alias} (ID: {channel_id_str}) for raffle {raffle_id}.") # --- 2. Attempt to pin the sent message --- try: # Disable notification for re-announcements or if initial is false # Enable notification for the very first announcement. disable_pin_notification = not initial_announcement await context.bot.pin_chat_message( chat_id=int(channel_id_str), message_id=sent_message.message_id, disable_notification=disable_pin_notification ) logger.info(f"Pinned announcement message {sent_message.message_id} in channel {channel_alias}.") except Forbidden as pin_e_forbidden: logger.warning(f"Could not pin message in channel {channel_alias} (Forbidden): {pin_e_forbidden}") except BadRequest as pin_e_bad_request: logger.warning(f"Could not pin message in channel {channel_alias} (Bad Request, e.g. no messages to pin): {pin_e_bad_request}") except Exception as pin_e: logger.warning(f"Could not pin message {sent_message.message_id if sent_message else 'N/A'} in channel {channel_alias}: {pin_e}") except Forbidden: logger.error(f"Permission error: Cannot send announcement to channel {channel_alias} (ID: {channel_id_str}).") except BadRequest as e: logger.error(f"Bad request sending announcement to channel {channel_alias} (ID: {channel_id_str}): {e}") except Exception as e: logger.error(f"Failed to send announcement to channel {channel_alias} (ID: {channel_id_str}): {e}") try: msg_to_admin = "Anuncio enviado con éxito." await context.bot.send_message(admin_user_id, msg_to_admin, parse_mode=ParseMode.HTML) except Exception as e: logger.error(f"Failed to send announcement summary to admin {admin_user_id}: {e}") return True