import logging import re import uuid import time import random from telegram import Update, InputMediaPhoto from telegram.ext import ( ContextTypes, CallbackContext, ConversationHandler, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ) from telegram.constants import ParseMode from telegram.error import Forbidden, BadRequest from database import * from config import * from helpers import * from keyboards import * logger = logging.getLogger(__name__) # --- Conversation Handler for Raffle Creation --- # Start command for users. Send message that they can join raffles now sending the command in the group. async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handles the /start command.""" chat = update.message.chat # Ignore /start in private chats, only allow in groups if chat.type == 'private': await update.message.reply_text("¡Hola! Soy el bot de sorteos.\n\n" "Para participar en un sorteo, usa el comando /sorteo en el grupo donde se anunció el sorteo.") return 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'] = {'channels': set()} # 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 los canales donde se publicará el sorteo. Pulsa 'Continuar' cuando termines.", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN ) return SELECTING_CHANNELS async def select_channels(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Handles channel selection or finishing selection.""" query = update.callback_query await query.answer() user_data = context.user_data.get('new_raffle', {}) selected_channels = user_data.get('channels', set()) callback_data = query.data if callback_data == f"{SELECT_CHANNEL_PREFIX}done": if not selected_channels: await context.bot.send_message(chat_id=query.from_user.id, text="Debes seleccionar al menos un canal.") return SELECTING_CHANNELS # Stay in the same state else: await query.edit_message_text( "Canales seleccionados. Ahora, por favor, envía el **título** del sorteo.", parse_mode=ParseMode.MARKDOWN ) return TYPING_TITLE elif callback_data == f"{SELECT_CHANNEL_PREFIX}cancel": await query.edit_message_text("Creación de sorteo cancelada.") context.user_data.pop('new_raffle', None) # Clean up user data return ConversationHandler.END elif callback_data.startswith(SELECT_CHANNEL_PREFIX): channel_id = callback_data[len(SELECT_CHANNEL_PREFIX):] if channel_id in selected_channels: selected_channels.remove(channel_id) else: selected_channels.add(channel_id) user_data['channels'] = selected_channels keyboard = generate_channel_selection_keyboard(selected_channels) await query.edit_message_reply_markup(reply_markup=keyboard) return SELECTING_CHANNELS # Stay in the same state # Should not happen, but good practice await context.bot.send_message(chat_id=query.from_user.id, text="Opción inválida.") return SELECTING_CHANNELS 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.MARKDOWN) 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.MARKDOWN) 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 context.user_data['new_raffle']['channel_prices'] = {} # Initialize dict for prices context.user_data['new_raffle']['channel_price_iterator'] = None # For iterating selected_channel_ids = list(context.user_data['new_raffle']['channels']) # Get the set, convert to list if not selected_channel_ids: await update.message.reply_text("Error: no se seleccionaron canales. Cancela y empieza de nuevo.", reply_markup=generate_confirmation_keyboard()) # Should not happen return ConversationHandler.END context.user_data['new_raffle']['channel_price_iterator'] = iter(selected_channel_ids) await _ask_next_channel_price(update, context) # Helper to ask for first/next price return TYPING_PRICE_FOR_CHANNELS async def _ask_next_channel_price(update: Update, context: ContextTypes.DEFAULT_TYPE): """Helper to ask for price for the next channel in the iterator.""" try: current_channel_id = next(context.user_data['new_raffle']['channel_price_iterator']) context.user_data['new_raffle']['current_channel_for_price'] = current_channel_id channel_alias = REVERSE_CHANNELS.get(current_channel_id, f"ID:{current_channel_id}") await update.message.reply_text( f"Imagen guardada.\nAhora, introduce el precio por número para el canal: **{channel_alias}** (solo el número, ej: 5).", parse_mode=ParseMode.MARKDOWN ) except StopIteration: # All channels processed context.user_data['new_raffle'].pop('current_channel_for_price', None) context.user_data['new_raffle'].pop('channel_price_iterator', None) # All prices collected, move to confirmation await _show_creation_confirmation(update, context) return CONFIRMING_CREATION return TYPING_PRICE_FOR_CHANNELS 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() current_channel_id = context.user_data['new_raffle'].get('current_channel_for_price') if not current_channel_id: # Should not happen await update.message.reply_text("Error interno. Por favor, /cancelar y reintentar.") return ConversationHandler.END try: price = int(price_text) if not (0 <= price <= 999): # Allow higher prices maybe raise ValueError("El precio debe ser un número entre 0 y 999.") except ValueError: channel_alias = REVERSE_CHANNELS.get(current_channel_id, f"ID:{current_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_CHANNELS # Stay in this state context.user_data['new_raffle']['channel_prices'][current_channel_id] = price logger.info(f"Price for channel {current_channel_id} set to {price}") # Ask for the next channel's price or proceed to confirmation next_state_or_value = await _ask_next_channel_price(update, context) return next_state_or_value 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'] selected_channel_ids = raffle_data.get('channels', set()) channel_prices = raffle_data.get('channel_prices', {}) prices_str_parts = [] for ch_id in selected_channel_ids: # Iterate in the order they were selected or a sorted order alias = REVERSE_CHANNELS.get(ch_id, f"ID:{ch_id}") price = channel_prices.get(ch_id, "N/A") prices_str_parts.append(f"- {alias}: {price}€") prices_display = "\n".join(prices_str_parts) if prices_str_parts else "Ninguno" 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"💶 **Donaciones por Canal:**\n{prices_display}\n" f"🖼️ **Imagen:** (Adjunta abajo)\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.MARKDOWN ) 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') image_file_id = user_data.get('image_file_id') channel_prices = user_data.get('channel_prices') # This is { 'channel_id': price } if not all([name, description, image_file_id, channel_prices]): await context.bot.send_message(query.from_user.id, "Faltan datos. Creación cancelada.") context.user_data.pop('new_raffle', None) return ConversationHandler.END raffle_id = create_raffle_with_channel_prices(name, description, image_file_id, channel_prices) if raffle_id: await context.bot.send_message(query.from_user.id, f"✅ ¡Sorteo '{name}' creado con éxito!") # Announce in channels (needs to be adapted for price per channel) 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" elif 'edit_raffle' in context.user_data and '_raffle_edit_conv_state' in context.user_data: # Check for edit state key current_conversation_data = context.user_data['edit_raffle'] current_state_key = '_raffle_edit_conv_state' current_state = context.user_data[current_state_key] conversation_type = "edició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_CHANNELS: 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_CHANNELS # Add more states if needed (e.g., if SELECTING_CHANNELS expects only callbacks) # --- Handle incorrect input for RAFFLE EDITING states --- elif conversation_type == "edición de sorteo": if current_state == EDIT_TYPING_PRICE_FOR_NEW_CHANNELS: 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 nuevo canal actual" await update.message.reply_text( f"Por favor, envía un NÚMERO para el precio del nuevo canal {channel_alias}.\n" "Usa /cancelar_edicion para salir." ) return EDIT_TYPING_PRICE_FOR_NEW_CHANNELS # Add more states if needed for edit flow # 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 # --- Existing Handlers (Review and Adapt if Necessary) --- async def enter_raffle(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handles the /sorteo command from users in groups.""" user = update.message.from_user chat = update.message.chat # Use chat object chat_id = chat.id # Ignore command if sent in private chat directly if chat.type == 'private': await update.message.reply_text("Usa /sorteo en el grupo donde viste el anuncio del sorteo.") return # Check if the chat ID is one of the configured channels # This ensures /sorteo only works in the designated raffle groups if str(chat_id) not in REVERSE_CHANNELS: # REVERSE_CHANNELS maps ID -> alias logger.warning(f"/sorteo used in unconfigured group {chat_id} by {user.id}") # Optionally send a message back to the group, or just ignore silently # await update.message.reply_text("Este grupo no está configurado para sorteos.") # Delete the user's /sorteo command try: await context.bot.delete_message(chat_id=chat_id, message_id=update.message.message_id) except Exception as e: logger.warning(f"Could not delete /sorteo command in group {chat_id}: {e}") return raffles = get_active_raffles_in_channel(chat_id) logger.info(f"User {user.id} ({user.username}) used /sorteo in channel {chat_id} ({REVERSE_CHANNELS.get(str(chat_id))})") if not raffles: logger.info(f"No active raffles found for channel {chat_id}") msg = await update.message.reply_text("No hay sorteos activos en este momento en este grupo.") # Consider deleting the bot's message and the user's command after a delay # (Requires scheduling, more complex) # Delete the user's /sorteo command try: await context.bot.delete_message(chat_id=chat_id, message_id=update.message.message_id) except Exception as e: logger.warning(f"Could not delete /sorteo command in group {chat_id}: {e}") return joinable_raffles = [] for raffle_data in raffles: raffle_id = raffle_data['id'] # Assuming 'id' is the key for raffle ID remaining_count = get_remaining_numbers_amount(raffle_id) if remaining_count > 0: joinable_raffles.append(raffle_data) # Keep the original raffle_data object else: logger.info(f"Raffle ID {raffle_id} in channel {chat_id} has no remaining numbers. Skipping.") if not joinable_raffles: logger.info(f"No active raffles with available numbers found for channel {chat_id} for user {user.id}.") try: # Inform the user that all active raffles are full await update.message.reply_text("Todos los sorteos activos en este grupo están completos (sin números disponibles). ¡Prueba más tarde!") except Exception as e: logger.warning(f"Error replying/deleting for no joinable raffles in {chat_id}: {e}") return keyboard = generate_raffle_selection_keyboard(chat_id) try: # Send instructions to user's private chat context.user_data['raffle_join_origin_channel_id'] = str(update.message.chat.id) await context.bot.send_message( user.id, "Has iniciado el proceso para unirte a un sorteo.\n\n" "Por favor, selecciona el sorteo al que quieres unirte:", reply_markup=keyboard ) logger.info(f"Sent raffle selection keyboard to user {user.id}") # Delete the user's /sorteo command from the group chat try: await context.bot.delete_message(chat_id=chat_id, message_id=update.message.message_id) logger.info(f"Deleted /sorteo command from user {user.id} in group {chat_id}") except Forbidden: logger.warning(f"Cannot delete message in group {chat_id}. Bot might lack permissions.") except Exception as e: logger.error(f"Error deleting message {update.message.message_id} in group {chat_id}: {e}") except Forbidden: logger.warning(f"Cannot send private message to user {user.id} ({user.username}). User might have blocked the bot.") # Send a temporary message in the group tagging the user try: await update.message.reply_text( f"@{user.username}, no puedo enviarte mensajes privados. " f"Por favor, inicia una conversación conmigo [@{context.bot.username}] y vuelve a intentarlo.", disable_notification=False # Try to notify the user ) except Exception as e: logger.error(f"Failed to send block notice message in group {chat_id}: {e}") async def raffle_selected(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handles user selecting a specific raffle from the private chat keyboard.""" query = update.callback_query await query.answer() user_id = query.from_user.id username = query.from_user.username or query.from_user.first_name # Use username or first name origin_channel_id = context.user_data.get('raffle_join_origin_channel_id') if not origin_channel_id: await query.edit_message_text("Error: No se pudo determinar el canal de origen. Intenta /sorteo de nuevo en el grupo.") return try: raffle_id = int(query.data.split(":")[1]) except (IndexError, ValueError): logger.error(f"Invalid callback data received in raffle_selected: {query.data}") await query.edit_message_text("Error: Selección de sorteo inválida.") return # Prevent joining if already waiting payment for *another* raffle (optional, but good practice) existing_waiting_raffle = get_raffle_by_user_id_waiting_payment(user_id) if existing_waiting_raffle and existing_waiting_raffle != raffle_id: await query.edit_message_text(f"Ya tienes una selección de números pendiente de pago para otro sorteo. Por favor, completa o cancela esa selección primero.") return # Prevent joining if already fully completed participation in *this* raffle (optional) # if is_participant_in_raffle(user_id, raffle_id): # Assumes this function checks for 'completed' status # await query.edit_message_text("Ya estás participando en este sorteo.") # return raffle_info = get_raffle(raffle_id) if not raffle_info or not raffle_info['active']: logger.warning(f"User {user_id} selected inactive/invalid raffle ID {raffle_id}") await query.edit_message_text("Este sorteo ya no está activo o no existe.") return raffle_name = raffle_info["name"] raffle_description = raffle_info["description"] price_for_this_channel = get_price_for_raffle_in_channel(raffle_id, origin_channel_id) image_file_id = raffle_info["image_file_id"] # Get image ID # Cancel any previous "waiting_for_payment" state for *this specific raffle* for this user # This allows users to restart their number selection easily cancel_reserved_numbers(user_id, raffle_id) logger.info(f"Cleared any previous pending reservation for user {user_id} in raffle {raffle_id}") keyboard = generate_numbers_keyboard(raffle_id, user_id, page=0) # Start at page 0 logger.info(f"User {user_id} ({username}) selected raffle {raffle_id} ('{raffle_name}')") # Edit the previous message to show raffle details and number keyboard selection_message_text = ( f"Has escogido el sorteo: **{raffle_name}**\n\n" f"{raffle_description}\n\n" f"Donación mínima: **{price_for_this_channel}€**\n\n" f"👇 **Selecciona tus números abajo:** 👇\n" f"(🟢=Tuyo Pagado, 🔒=Tuyo Reservado, ❌=Ocupado, ☑️=Libre)" ) try: # Try sending photo with caption first, then edit text if it fails # Note: Editing message with media requires specific handling or might not be possible directly. # Simplest approach: delete old message, send new one with photo + keyboard. # 1. Delete the old message (which just had the raffle selection buttons) await query.delete_message() if image_file_id: # If image_file_id exists, send photo with caption and keyboard await context.bot.send_photo( chat_id=user_id, photo=image_file_id, caption=selection_message_text, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN ) logger.info(f"Sent photo + details + keyboard for raffle {raffle_id} to user {user_id}.") else: # If image_file_id is missing (NULL or empty), send text message only logger.warning(f"Raffle {raffle_id} has no image_file_id. Sending text fallback to user {user_id}.") await context.bot.send_message( chat_id=user_id, text=selection_message_text, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN ) logger.info(f"Sent text details + keyboard for raffle {raffle_id} to user {user_id}.") except Forbidden: logger.error(f"Forbidden: Cannot interact with user {user_id} in raffle_selected.") # Cannot recover if blocked. except BadRequest as e: # Handle potential errors like message not found to delete, or bad request sending photo/text logger.error(f"BadRequest during raffle_selected display for user {user_id}, raffle {raffle_id}: {e}") # Attempt to send a simple text message as a last resort if deletion/sending failed try: await context.bot.send_message(user_id, "Hubo un error al mostrar los detalles del sorteo. Inténtalo de nuevo.") except Exception as final_e: logger.error(f"Also failed to send final error message to user {user_id}: {final_e}") except Exception as e: logger.error(f"Unexpected error in raffle_selected for user {user_id}, raffle {raffle_id}: {e}") # Attempt to send a simple text message as a last resort try: await context.bot.send_message(user_id, "Ocurrió un error inesperado. Por favor, intenta de nuevo.") except Exception as final_e: logger.error(f"Also failed to send final error message to user {user_id}: {final_e}") # 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 username = query.from_user.username or query.from_user.first_name 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 = "" origin_channel_id = context.user_data.get('raffle_join_origin_channel_id') if not origin_channel_id: await query.answer("Error: Canal de origen no encontrado. Reintenta /sorteo.", show_alert=True) return 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("Número no válido.") 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 números 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("Número aleatorio no válido.") 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 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 del número {number_string}.") logger.info(f"User {user_id} 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 el número {number_string} (pagado).") 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 número {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 = "reservado" if participant_step == "waiting_for_payment" else "comprado" await query.answer(f"El número {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, origin_channel_id) await query.answer(f"Número {number_string} reservado para ti. Confirma tu selección cuando termines.") logger.info(f"User {user_id} 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 ningún número nuevo para confirmar.") return await query.answer("Procesando confirmació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 los números de nuevo.", reply_markup=None) except BadRequest: await query.edit_message_text("Error: No se encontró tu registro. Selecciona los números de nuevo.", reply_markup=None) return participant_db_id = participant['id'] origin_channel_id_for_payment = participant['origin_channel_id'] if not origin_channel_id_for_payment: # This is a critical error, means origin_channel_id wasn't saved with participant logger.error(f"CRITICAL: origin_channel_id missing for participant {participant['id']} during payment confirmation.") await query.answer("Error interno al procesar el pago. Contacta al admin.", show_alert=True) return price_per_number = get_price_for_raffle_in_channel(raffle_id, origin_channel_id_for_payment) if price_per_number is None: logger.error(f"Price not found for raffle {raffle_id} in channel {origin_channel_id_for_payment} during confirmation for user {user_id}") await query.answer("Error: Ha habido un problema desconocido, contacta con el administrador.", show_alert=True) return total_price = len(reserved_numbers) * price_per_number # Generate a unique invoice ID for PayPal invoice_id = str(uuid.uuid4()) current_timestamp = time.time() mark_reservation_pending(participant_db_id, invoice_id, current_timestamp) # Construct PayPal link # Using _xclick for simple payments. Consider PayPal REST API for more robust integration if needed. paypal_link = ( f"https://www.paypal.com/cgi-bin/webscr?" f"cmd=_xclick&business={PAYPAL_EMAIL}" f"&item_name=Numeros Sorteo con ID: {raffle_info['id']} ({', '.join(reserved_numbers)})" # Item name for clarity f"&amount={total_price:.2f}" # Format price to 2 decimal places f"¤cy_code=EUR" f"&invoice={invoice_id}" # CRITICAL: This links the payment back f"&verify_url={WEBHOOK_URL}" # IMPORTANT: Set your actual webhook URL here! # custom field can be used for extra data if needed, e.g., participant_db_id again f"&custom={participant_db_id}" f"&return=https://t.me/{BOT_NAME}" # Optional: URL after successful payment f"&cancel_return=https://t.me/{BOT_NAME}" # Optional: URL if user cancels on PayPal ) # 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 = "Pagar 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"Números reservados: {', '.join(reserved_numbers)}\n" f"Total a pagar: {total_price:.2f}€\n\n" f"Pulsa el botón de abajo para completar el pago vía PayPal:\n" # Adjusted instruction # Link is now in the button below f"⚠️ Tienes {RESERVATION_TIMEOUT_MINUTES} minutos para completar el pago antes de que los números se liberen.\n\n" f"Una vez completado el pago, el bot te notificará aquí." ) # Alternative simpler link (less info, relies on user manually adding notes?) # paypal_link = f"https://paypal.me/{PAYPAL_HANDLE}/{total_price:.2f}" # Remove the number keyboard, show only the payment link/info # Try editing caption first, fall back to editing text try: await query.edit_message_caption( caption=payment_message, reply_markup=payment_keyboard, # Use the keyboard with the button parse_mode=ParseMode.MARKDOWN # Use Markdown for formatting ) logger.debug(f"Edited message caption for user {user_id} with PayPal button.") except BadRequest as e: if "caption" in str(e).lower() or "edit" in str(e).lower() or "MESSAGE_NOT_MODIFIED" in str(e).upper(): logger.warning(f"Failed to edit caption (Error: {e}). Falling back to edit_message_text for user {user_id}") 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) else: logger.error(f"Unexpected BadRequest editing message for user {user_id}: {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) except Exception as e: logger.error(f"Unexpected error editing message for user {user_id} in confirm_callback: {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 números 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 números reservados para cancelar.", reply_markup=None) except BadRequest: # If message was text, not photo caption await query.edit_message_text("No hay números reservados 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. Números liberados: {', '.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 números ha sido cancelada y los números han sido liberados.", reply_markup=None) except BadRequest: await query.edit_message_text("Tu selección de números ha sido cancelada y los números han sido liberados.", 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 # --- Payment Handling (Triggered by PayPal Webhook via paypal_processor.py) --- # The actual logic is in paypal_processor.py, which uses the database functions. # No direct handler needed here unless you implement manual transaction ID confirmation. # --- Helper Function --- (Keep get_winners in helpers.py) # --- Image Generation --- (Keep generate_table_image in helpers.py) # --- 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 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"Números ganadores: **{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!" else: announcement += "No hubo ganadores para estos números." # --- CORRECTED PART --- # Get the list of channel IDs where this raffle was active channel_ids_to_announce_in = get_raffle_channel_ids(raffle_id) # This returns a list of string IDs # --- END CORRECTION --- success_channels_announced = [] failed_channels_announce = [] if channel_ids_to_announce_in: for channel_id_str in channel_ids_to_announce_in: channel_alias = REVERSE_CHANNELS.get(channel_id_str, f"ID:{channel_id_str}") try: await context.bot.send_message(int(channel_id_str), announcement, parse_mode=ParseMode.MARKDOWN) logger.info(f"Announced winners for raffle {raffle_id} in channel {channel_alias} (ID: {channel_id_str})") success_channels_announced.append(channel_alias) except Forbidden: logger.error(f"Permission error announcing winners in channel {channel_alias} (ID: {channel_id_str}).") failed_channels_announce.append(f"{channel_alias} (Permiso Denegado)") except BadRequest as e: logger.error(f"Bad request announcing winners in channel {channel_alias} (ID: {channel_id_str}): {e}") failed_channels_announce.append(f"{channel_alias} (Error: {e.message[:30]})") except Exception as e: logger.error(f"Failed to announce winners in channel {channel_alias} (ID: {channel_id_str}): {e}") failed_channels_announce.append(f"{channel_alias} (Error Desconocido)") # Report back to admin msg_to_admin = f"✅ Sorteo '{raffle_name}' terminado.\n" if success_channels_announced: msg_to_admin += f"📢 Resultados anunciados en: {', '.join(success_channels_announced)}\n" if failed_channels_announce: msg_to_admin += f"⚠️ Fallo al anunciar resultados en: {', '.join(failed_channels_announce)}" try: await context.bot.send_message(admin_user_id, msg_to_admin, parse_mode=ParseMode.MARKDOWN) except Exception as e: logger.error(f"Failed to send end confirmation to admin {admin_user_id}: {e}") else: logger.warning(f"Raffle {raffle_id} ('{raffle_name}') ended, but no channels found associated with it in raffle_channel_prices to announce winners.") try: await context.bot.send_message(admin_user_id, f"✅ Sorteo '{raffle_name}' terminado. No se encontraron canales asociados para anunciar los resultados (revisar `raffle_channel_prices`).") except Exception as e: logger.error(f"Failed to send no-channel (end) confirmation to admin {admin_user_id}: {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.MARKDOWN) 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, por favor, 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 Activos**\n\nNo hay sorteos activos." await query.edit_message_text(message_text, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) 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.MARKDOWN) # --- Raffle Specific Actions --- elif data.startswith(ADMIN_VIEW_RAFFLE_PREFIX): try: 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.MARKDOWN) 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_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 los **números ganadores** separados por espacios (ej: `7 23 81`).", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN ) 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.MARKDOWN) 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"❌ Números inválidos: {e}\n\n" "Por favor, envía los números ganadores (0-99) separados por espacios (ej: `7 23 81`).", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN ) # 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 números: {', '.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 sorteo ID {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'] # Get description for caption channels_and_prices = get_raffle_channels_and_prices(raffle_id) # List of {'channel_id', 'price'} if not channels_and_prices: logger.warning(f"Admin {admin_user_id} tried to announce raffle {raffle_id} ('{raffle_name}') but it has no channels configured.") try: await context.bot.send_message(admin_user_id, f"El sorteo '{raffle_name}' no tiene canales asignados para anunciar.") except Exception as e: logger.error(f"Failed to send no-channel info to admin {admin_user_id}: {e}") return False success_channels_sent = [] success_channels_pinned = [] failed_channels_send = [] failed_channels_pin = [] # 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}')") for item in channels_and_prices: channel_id_str = item['channel_id'] price_for_this_channel = item['price'] 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"💰 **Donación mínima:** {price_for_this_channel}€\n" f"🔢 **Números disponibles:** {remaining_count if remaining_count >= 0 else 'N/A'}\n\n" f"Normas y condiciones: {TYC_URL} \n\n" f"👇 ¡Pulsa /sorteo en este chat para participar! 👇" ) message_args = {"parse_mode": ParseMode.MARKDOWN} if image_file_id: message_args["photo"] = image_file_id message_args["caption"] = announce_caption send_method = context.bot.send_photo else: message_args["text"] = announce_caption 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) success_channels_sent.append(channel_alias) 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 ) success_channels_pinned.append(channel_alias) 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}") failed_channels_pin.append(f"{channel_alias} (Permiso de Fijar Denegado)") 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}") failed_channels_pin.append(f"{channel_alias} (Error al Fijar)") 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}") failed_channels_pin.append(f"{channel_alias} (Error al Fijar Desconocido)") except Forbidden: logger.error(f"Permission error: Cannot send announcement to channel {channel_alias} (ID: {channel_id_str}).") failed_channels_send.append(f"{channel_alias} (Permiso de Envío Denegado)") except BadRequest as e: logger.error(f"Bad request sending announcement to channel {channel_alias} (ID: {channel_id_str}): {e}") failed_channels_send.append(f"{channel_alias} (Error de Envío: {e.message[:30]}...)") # Shorten error except Exception as e: logger.error(f"Failed to send announcement to channel {channel_alias} (ID: {channel_id_str}): {e}") failed_channels_send.append(f"{channel_alias} (Error de Envío Desconocido)") # Report back to admin msg_to_admin = f"📢 Resultados del {'anuncio inicial' if initial_announcement else 're-anuncio'} para '{raffle_name}':\n\n" if success_channels_sent: msg_to_admin += f"✅ Enviado con éxito a: {', '.join(success_channels_sent)}\n" if failed_channels_send: msg_to_admin += f"❌ Fallo al enviar a: {', '.join(failed_channels_send)}\n" if success_channels_sent: # Only report on pinning if messages were sent if success_channels_pinned: msg_to_admin += f"📌 Fijado con éxito en: {', '.join(success_channels_pinned)}\n" if failed_channels_pin: msg_to_admin += f"⚠️ Fallo al fijar en: {', '.join(failed_channels_pin)}\n" if not success_channels_pinned and not failed_channels_pin and success_channels_sent: # All sent, no pins attempted or all failed silently pass # Avoid saying "no pins" if none were expected or all silently failed without specific error if not success_channels_sent and not failed_channels_send: msg_to_admin += "No se procesó ningún canal (esto no debería ocurrir si la lista de canales no estaba vacía)." try: await context.bot.send_message(admin_user_id, msg_to_admin, parse_mode=ParseMode.MARKDOWN) except Exception as e: logger.error(f"Failed to send announcement summary to admin {admin_user_id}: {e}") return True ## Edit commands async def admin_edit_select_raffle(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Admin selects a raffle to edit (add channels).""" query = update.callback_query # Assuming this comes from a button click user_id = query.from_user.id await query.answer() active_raffles = get_active_raffles() # Gets basic info if not active_raffles: await query.edit_message_text("No hay sorteos activos para editar.", reply_markup=generate_admin_main_menu_keyboard()) return ConversationHandler.END buttons = [] for r in active_raffles: buttons.append([InlineKeyboardButton(r['name'], callback_data=f"{ADMIN_EDIT_RAFFLE_PREFIX}{r['id']}")]) buttons.append([InlineKeyboardButton("⬅️ Volver al Menú", callback_data=ADMIN_MENU_BACK_MAIN)]) await query.edit_message_text("Selecciona el sorteo al que quieres añadir canales:", reply_markup=InlineKeyboardMarkup(buttons)) return EDIT_SELECT_RAFFLE # This state waits for a raffle selection callback async def admin_edit_selected_raffle(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Raffle selected for editing. Now ask for new channels.""" query = update.callback_query await query.answer() raffle_id_to_edit = int(query.data[len(ADMIN_EDIT_RAFFLE_PREFIX):]) context.user_data['edit_raffle'] = { 'raffle_id': raffle_id_to_edit, 'new_channels': set(), 'new_channel_prices': {} } raffle_name = get_raffle_name(raffle_id_to_edit) existing_channel_ids = get_raffle_channel_ids(raffle_id_to_edit) # Returns list of strings existing_aliases = [REVERSE_CHANNELS.get(ch_id, f"ID:{ch_id}") for ch_id in existing_channel_ids] message = f"Editando sorteo: **{raffle_name}**\n" message += f"Canales actuales: {', '.join(existing_aliases) or 'Ninguno'}\n\n" message += "Selecciona los **nuevos** canales a añadir:" keyboard = generate_channel_selection_keyboard_for_edit(set(existing_channel_ids)) # Pass existing as set await query.edit_message_text(message, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) return EDIT_SELECT_NEW_CHANNELS async def admin_edit_select_new_channels(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Handles selection of new channels to add during edit.""" query = update.callback_query await query.answer() edit_data = context.user_data['edit_raffle'] selected_new_channels = edit_data['new_channels'] existing_channel_ids = set(get_raffle_channel_ids(edit_data['raffle_id'])) callback_data = query.data if callback_data == f"{SELECT_CHANNEL_PREFIX}done": if not selected_new_channels: await context.bot.send_message(query.from_user.id, "Debes seleccionar al menos un nuevo canal para añadir.") return EDIT_SELECT_NEW_CHANNELS else: # Proceed to ask prices for these new channels edit_data['channel_price_iterator'] = iter(list(selected_new_channels)) await _ask_next_price_for_edit(query.message, context) # Use query.message to send reply return EDIT_TYPING_PRICE_FOR_NEW_CHANNELS elif callback_data == f"{SELECT_CHANNEL_PREFIX}cancel_edit": await query.edit_message_text("Edición de sorteo cancelada.") context.user_data.pop('edit_raffle', None) return ConversationHandler.END # Or back to main admin menu elif callback_data.startswith(SELECT_CHANNEL_PREFIX): channel_id = callback_data[len(SELECT_CHANNEL_PREFIX):] if channel_id in selected_new_channels: selected_new_channels.remove(channel_id) else: selected_new_channels.add(channel_id) edit_data['new_channels'] = selected_new_channels keyboard = generate_channel_selection_keyboard_for_edit(existing_channel_ids, selected_new_channels) await query.edit_message_reply_markup(reply_markup=keyboard) return EDIT_SELECT_NEW_CHANNELS async def _ask_next_price_for_edit(message_to_reply_to, context: ContextTypes.DEFAULT_TYPE): """Helper to ask price for the next new channel during edit.""" edit_data = context.user_data['edit_raffle'] try: current_new_channel_id = next(edit_data['channel_price_iterator']) edit_data['current_channel_for_price'] = current_new_channel_id # Re-use this key channel_alias = REVERSE_CHANNELS.get(current_new_channel_id, f"ID:{current_new_channel_id}") await message_to_reply_to.reply_text( # Use reply_text from the message object f"Introduce el precio por número para el nuevo canal: **{channel_alias}** (ej: 5).", parse_mode=ParseMode.MARKDOWN ) except StopIteration: # All new channels processed edit_data.pop('current_channel_for_price', None) edit_data.pop('channel_price_iterator', None) # All prices for new channels collected, move to edit confirmation await _show_edit_confirmation(message_to_reply_to, context) return EDIT_CONFIRM return EDIT_TYPING_PRICE_FOR_NEW_CHANNELS async def admin_edit_receive_price_for_new_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Receives price for a new channel during edit.""" price_text = update.message.text.strip() edit_data = context.user_data['edit_raffle'] current_new_channel_id = edit_data.get('current_channel_for_price') # ... (validation for price, same as creation) ... try: price = int(price_text) if not (0 <= price <= 999): raise ValueError("Price out of range.") except ValueError: await update.message.reply_text("Precio inválido. Inténtalo de nuevo.") return EDIT_TYPING_PRICE_FOR_NEW_CHANNELS edit_data['new_channel_prices'][current_new_channel_id] = price logger.info(f"Edit: Price for new channel {current_new_channel_id} set to {price}") next_state_or_value = await _ask_next_price_for_edit(update.message, context) return next_state_or_value async def _show_edit_confirmation(message_to_reply_to, context: ContextTypes.DEFAULT_TYPE): """Shows final confirmation for editing the raffle.""" edit_data = context.user_data['edit_raffle'] raffle_name = get_raffle_name(edit_data['raffle_id']) new_channel_prices = edit_data.get('new_channel_prices', {}) prices_str_parts = [] for ch_id, price in new_channel_prices.items(): alias = REVERSE_CHANNELS.get(ch_id, f"ID:{ch_id}") prices_str_parts.append(f"- {alias}: {price}€") new_prices_display = "\n".join(prices_str_parts) if prices_str_parts else "Ninguno" confirmation_text = ( f"✏️ **Confirmar Cambios para Sorteo: {raffle_name}** ✏️\n\n" f"Se añadirán los siguientes canales con sus precios:\n{new_prices_display}\n\n" "¿Confirmas estos cambios? El sorteo se anunciará en los nuevos canales." ) keyboard = InlineKeyboardMarkup([ # Simpler Yes/No for edit [InlineKeyboardButton("✅ Sí, Guardar y Anunciar", callback_data="confirm_edit_action")], [InlineKeyboardButton("❌ No, Cancelar Edición", callback_data="cancel_edit_action")] ]) # Use message_to_reply_to.reply_text or send_message as appropriate. # For simplicity, let's send a new message for confirmation await context.bot.send_message( chat_id=message_to_reply_to.chat_id, text=confirmation_text, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN ) async def admin_confirm_edit_action(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Handles final Yes/No for edit confirmation.""" query = update.callback_query await query.answer() edit_data = context.user_data.get('edit_raffle') if not edit_data: await query.edit_message_text("Error: No hay datos de edición. Cancela y reintenta.", reply_markup=None) return ConversationHandler.END if query.data == "confirm_edit_action": await query.edit_message_text("Guardando cambios y anunciando en nuevos canales...", reply_markup=None) raffle_id = edit_data['raffle_id'] new_channel_prices_to_add = edit_data['new_channel_prices'] # { 'channel_id': price } if not new_channel_prices_to_add: await context.bot.send_message(query.from_user.id, "No se seleccionaron nuevos canales para añadir.") else: added_db_channels = add_channels_to_raffle(raffle_id, new_channel_prices_to_add) if added_db_channels: await context.bot.send_message(query.from_user.id, f"Canales {', '.join(added_db_channels)} añadidos con sus precios.") # Announce ONLY in the newly added channels # We need a way to announce to specific channels if the helper is general # For now, let's assume _announce_raffle_in_channels can be adapted or a new one is made # Here, we'll just log for simplicity of this example logger.info(f"TODO: Announce raffle {raffle_id} in newly added channels: {added_db_channels}") # Simple announcement for now: raffle_name_for_announce = get_raffle_name(raffle_id) raffle_basic_info = get_raffle(raffle_id) # image_id, description for ch_id in added_db_channels: price = new_channel_prices_to_add[ch_id] caption = ( f"🎉 **¡Sorteo '{raffle_name_for_announce}' ahora disponible en este canal!** 🎉\n\n" f"{raffle_basic_info['description']}\n\n" f"💰 **Donación mínima:** {price}€\n" f"👇 ¡Pulsa /sorteo para participar! 👇") try: if raffle_basic_info['image_file_id']: await context.bot.send_photo(int(ch_id), raffle_basic_info['image_file_id'], caption=caption, parse_mode=ParseMode.MARKDOWN) else: await context.bot.send_message(int(ch_id), caption, parse_mode=ParseMode.MARKDOWN) except Exception as e: logger.error(f"Error announcing edited raffle in new channel {ch_id}: {e}") else: await context.bot.send_message(query.from_user.id, "No se pudieron añadir los nuevos canales (posiblemente ya existían o error de DB).") elif query.data == "cancel_edit_action": await query.edit_message_text("Edición cancelada.", reply_markup=None) context.user_data.pop('edit_raffle', None) return ConversationHandler.END # Ensure cancel_edit_command correctly clears 'edit_raffle' from user_data async def cancel_edit_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: if 'edit_raffle' in context.user_data: context.user_data.pop('edit_raffle') await update.message.reply_text("Edición de sorteo cancelada.") return ConversationHandler.END # Fallback to check raffle creation cancellation return await cancel_creation_command(update, context)