Files
homelabs-raffle/app/handlers.py
2026-02-22 12:56:42 +01:00

1505 lines
78 KiB
Python

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 '<Not Text>'}")
# --- 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"&currency_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)