diff --git a/app/app.py b/app/app.py index 0651f5d..e2ac0b7 100644 --- a/app/app.py +++ b/app/app.py @@ -144,6 +144,41 @@ async def check_winner_numbers(context: ContextTypes.DEFAULT_TYPE): logger.info(f"Winner number for {raffle['name']} is {winner_num}") await end_raffle_logic(context, raffle['id'], [int(winner_num)%100], ADMIN_IDS[0]) +async def announce_reminder_active_raffles(context: ContextTypes.DEFAULT_TYPE): + """Job callback to announce active raffles in announce channels.""" + # context is automatically provided by PTB's Job Queue + # No need to get app from context explicitly here for bot access, + # context.bot can be used directly. + logger.info(f"Running announce_reminder_active_raffles") + + raffles = get_active_raffles() + + if not raffles: + logger.info("No active raffles to announce.") + return + + message_lines = ["🎉 Sorteos Activos 🎉\n"] + for raffle in raffles: + remaining = get_remaining_numbers_amount(raffle['id']) + message_lines.append( + f"• {raffle['name']}\n" + f" Donación mínima: {raffle['price']}€\n" + f" Participaciones restantes: {remaining}\n" + f" https://t.me/{REVERSE_CHANNELS.get(raffle['channel_id'])}/{get_main_message_id(raffle['id'])}\n" + ) + message_text = "\n".join(message_lines) + + for alias, channel_id in ANNOUNCE_CHANNELS.items(): + try: + await context.bot.send_message(chat_id=channel_id, text=message_text, parse_mode=ParseMode.HTML) + logger.info(f"Announced active raffles in channel {alias} ({channel_id}).") + except Forbidden: + logger.warning(f"Cannot send announcement to channel {alias} ({channel_id}) (Forbidden).") + except BadRequest as e: + logger.error(f"BadRequest sending announcement to channel {alias} ({channel_id}): {e}") + except Exception as e: + logger.error(f"Failed to send announcement to channel {alias} ({channel_id}): {e}") + # --- Main Function --- def main(): init_db() @@ -171,6 +206,13 @@ def main(): ) logger.info("Scheduled winner check job every day at 21:45 Madrid time.") + job_queue.run_daily( + callback=announce_reminder_active_raffles, + time=dtime(hour=13, minute=0, tzinfo=madrid_tz), + name="announce_active_raffles_job" + ) + logger.info("Scheduled announce active raffles job every day at 13:00 Madrid time.") + # --- Handlers (Remain the same) --- # 1. Raffle Creation Conversation Handler raffle_creation_conv = ConversationHandler( diff --git a/app/config.py b/app/config.py index 34a7388..205da30 100644 --- a/app/config.py +++ b/app/config.py @@ -10,6 +10,8 @@ ADMIN_IDS = list(map(int, os.getenv("ADMIN_IDS", "1").split(','))) # Comma-sepa CHANNELS_IDS = list(os.getenv("CHANNEL_IDS", "1/test").split(',')) # Comma-separated channel IDs # Create a dictionary { 'channel_alias': 'channel_id' } CHANNELS = {channel.split('/')[1]: channel.split('/')[0] for channel in CHANNELS_IDS} +ANNOUNCE_CHANNEL_IDS = list(os.getenv("ANNOUNCE_CHANNEL_IDS", "").split(',')) # Comma-separated announce channel IDs +ANNOUNCE_CHANNELS = {channel.split('/')[1]: channel.split('/')[0] for channel in ANNOUNCE_CHANNEL_IDS if '/' in channel} # Create a reverse dictionary { 'channel_id': 'channel_alias' } for display/lookup REVERSE_CHANNELS = {v: k for k, v in CHANNELS.items()} DATABASE_PATH = "/app/data/raffles.db" @@ -45,6 +47,7 @@ ADMIN_END_RAFFLE_PROMPT_PREFIX = "admin_end_prompt:" # + raffle_id ADMIN_CANCEL_END_PROCESS = "admin_cancel_end" ADMIN_VIEW_RAFFLE_PREFIX = "admin_view_raffle:" # + raffle_id (NEW) ADMIN_ANNOUNCE_RAFFLE_PREFIX = "admin_announce_raffle:" # + raffle_id (NEW) +ADMIN_UPDATE_IMAGE_PREFIX = "admin_update_image:" # + raffle_id (NEW) ADMIN_NO_OP = "admin_no_op" # Placeholder for buttons that do nothing on click # --- End Admin Menu --- @@ -53,4 +56,10 @@ DRAW_MAPPING = { 'friday': 'VIE', # Fri 'weekend': 'DOM' # Sat–Sun } -JUEGOS_ONCE_URL = "https://www.juegosonce.es/resultados-ultimos-sorteos-once" \ No newline at end of file +JUEGOS_ONCE_URL = "https://www.juegosonce.es/resultados-ultimos-sorteos-once" + +# Homelabs API Configuration +HOMELABS_API_TOKEN = os.getenv("HOMELABS_API_TOKEN") +HOMELABS_API_URL = os.getenv("HOMELABS_API_URL", "http://user_membership_api:8000") + +VIP_DISCOUNT_PER_NUMBER = float(os.getenv("VIP_DISCOUNT_PER_NUMBER", "1.0")) # Default 1 EUR discount per number for VIP members \ No newline at end of file diff --git a/app/database.py b/app/database.py index 270c3da..2f7b25c 100644 --- a/app/database.py +++ b/app/database.py @@ -131,8 +131,7 @@ def get_active_raffles(): conn = connect_db() cur = conn.cursor() try: - # No price here, as it's per-channel - cur.execute("SELECT id, name, description, image_file_id FROM raffles WHERE active=1") + cur.execute("SELECT * FROM raffles WHERE active=1") raffles = cur.fetchall() return raffles except Exception as e: diff --git a/app/handlers.py b/app/handlers.py index 4af518a..0e6dbb0 100644 --- a/app/handlers.py +++ b/app/handlers.py @@ -33,11 +33,11 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): raffle = get_raffle(raffle_id) # Get raffle details remaining_numbers_amount = get_remaining_numbers_amount(raffle_id) if raffle and raffle['active'] and remaining_numbers_amount > 0: - # Check what time is it, if it's between 20:55 and 22:00, users can't join + # Check what time is it, if it's between 21:15 and 22:00, users can't join madrid_tz = pytz.timezone("Europe/Madrid") current_time = datetime.now(madrid_tz) - if current_time.time() >= dtime(20, 55) and current_time.time() <= dtime(22, 0): - await update.message.reply_text("Lo siento, no puedes unirte a sorteos entre las 20:55 y las 22:00 (hora de España) para evitar conflictos con el sorteo en directo. Inténtalo de nuevo más tarde.") + if current_time.time() >= dtime(21, 15) and current_time.time() <= dtime(22, 0): + await update.message.reply_text("Lo siento, no puedes unirte a sorteos entre las 21:15 y las 22:00 (hora de España) para evitar conflictos con el sorteo en directo. Inténtalo de nuevo más tarde.") return if len(get_reserved_numbers(user.id, raffle_id)) > 0: await update.message.reply_text("Ya tienes participaciones reservadas para este sorteo. Por favor, completa la donación o espera a que caduquen antes de unirte de nuevo.") @@ -48,13 +48,23 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): logger.info(f"User {user.id} started bot with join link for raffle {raffle_id}") context.user_data['joining_raffle_id'] = raffle_id keyboard = generate_numbers_keyboard(raffle_id, user.id) - await update.message.reply_text( - f"¡Hola! Vamos a unirnos al sorteo '{raffle['name']}'.\n\n" - f"🌍 Envío internacional: {'Sí ✅' if raffle['international_shipping'] else 'No ❌'}\n" - f"La donación mínima es de {raffle['price']}€.\n\n" - "Por favor, selecciona tus números:", - reply_markup=keyboard - ) + is_vip = is_vip_member_of_homelabs(user.id) + if is_vip: + await update.message.reply_text( + f"¡Hola usuario VIP! Vamos a unirnos al sorteo '{raffle['name']}'.\n\n" + f"🌍 Envío internacional: {'Sí ✅' if raffle['international_shipping'] else 'No ❌'}\n" + f"La donación mínima es de {raffle['price'] - VIP_DISCOUNT_PER_NUMBER}€.\n\n" + "Por favor, selecciona tus números:", + reply_markup=keyboard + ) + else: + await update.message.reply_text( + f"¡Hola! Vamos a unirnos al sorteo '{raffle['name']}'.\n\n" + f"🌍 Envío internacional: {'Sí ✅' if raffle['international_shipping'] else 'No ❌'}\n" + f"La donación mínima es de {raffle['price']}€.\n\n" + "Por favor, selecciona tus números:", + reply_markup=keyboard + ) else: await update.message.reply_text("Este sorteo ya no está activo o no tiene números disponibles.") except (ValueError, IndexError): @@ -225,8 +235,24 @@ async def confirm_creation(update: Update, context: ContextTypes.DEFAULT_TYPE) - channel_id = user_data.get('channel') international_shipping = user_data.get('international_shipping', 0) - if not all([name, description, image_file_id, price, international_shipping]): - await context.bot.send_message(query.from_user.id, "Faltan datos. Creación cancelada.") + exists_international_shipping = False + if international_shipping != None: + exists_international_shipping = True + + if not all([name, description, image_file_id, price, exists_international_shipping]): + missing_fields = [] + if not name: + missing_fields.append("título") + if not description: + missing_fields.append("descripción") + if not image_file_id: + missing_fields.append("imagen") + if not price: + missing_fields.append("precio") + if not exists_international_shipping: + missing_fields.append("envío internacional") + + await context.bot.send_message(query.from_user.id, f"Faltan datos: {', '.join(missing_fields)}. Creación cancelada.") context.user_data.pop('new_raffle', None) return ConversationHandler.END @@ -528,6 +554,12 @@ async def confirm_callback(update: Update, context: CallbackContext): return user_name = participant['user_name'] + + is_vip = is_vip_member_of_homelabs(user_id) + if is_vip: + price_per_number -= VIP_DISCOUNT_PER_NUMBER + if price_per_number < 1: + price_per_number = 1 # Minimum price total_price = len(reserved_numbers) * price_per_number paypal_link, invoice_id = create_paypal_order(get_paypal_access_token(), total_price, raffle_info['id'], reserved_numbers, user_name) @@ -680,16 +712,19 @@ async def end_raffle_logic(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, w # Notify admin of success try: + number_of_participations = get_total_participations(raffle_id) + price_per_number = raffle_details['price'] + total_gross = 0 + total_net = 0 + total_fees = 0 + invoice_ids = get_all_invoice_ids(raffle_id) + for inv_id in invoice_ids: + gross, net, fees = get_paypal_amounts_for_invoice(inv_id) + total_gross += gross + total_net += net + total_fees += fees for admin_user_id in ADMIN_IDS: await context.bot.send_message(admin_user_id, f"Sorteo '{raffle_name}' terminado y ganadores anunciados.") - number_of_participations = get_total_participations(raffle_id) - price_per_number = raffle_details['price'] - invoice_ids = get_all_invoice_ids(raffle_id) - for inv_id in invoice_ids: - gross, net, fees = get_paypal_amounts_for_invoice(inv_id) - total_gross += gross - total_net += net - total_fees += fees await context.bot.send_message( admin_user_id, f"Resumen del sorteo '{raffle_name}':\n" @@ -788,6 +823,26 @@ async def admin_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE logger.error(f"Invalid callback data for announce raffle: {data}") await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_list_raffles_keyboard()) + elif data.startswith(ADMIN_UPDATE_IMAGE_PREFIX): + try: + raffle_id = int(data[len(ADMIN_UPDATE_IMAGE_PREFIX):]) + logger.info(f"Admin {user_id} requested image update for raffle {raffle_id}") + await query.edit_message_text(f"🔄 Actualizando imagen del sorteo {get_raffle_name(raffle_id)}...", reply_markup=None) + + # Use the extracted function to send/update the raffle image + if send_raffle_update_image(raffle_id, bot_token=context.bot.token): + await context.bot.send_message(user_id, f"✅ Imagen actualizada correctamente para el sorteo {get_raffle_name(raffle_id)}.") + else: + await context.bot.send_message(user_id, f"❌ Error al actualizar la imagen para el sorteo {get_raffle_name(raffle_id)}.") + + # Go back to raffle details + details_text = format_raffle_details(raffle_id) + details_keyboard = generate_admin_raffle_details_keyboard(raffle_id) + await context.bot.send_message(user_id, details_text, reply_markup=details_keyboard, parse_mode=ParseMode.HTML) + except (ValueError, IndexError): + logger.error(f"Invalid callback data for update image: {data}") + await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_list_raffles_keyboard()) + elif data.startswith(ADMIN_END_RAFFLE_PROMPT_PREFIX): try: raffle_id = int(data[len(ADMIN_END_RAFFLE_PROMPT_PREFIX):]) @@ -902,7 +957,7 @@ async def _announce_raffle_in_channels(context: ContextTypes.DEFAULT_TYPE, raffl raffle_name = raffle['name'] image_file_id = raffle['image_file_id'] - raffle_description = raffle['description'] # Get description for caption + raffle_description = raffle['description'][:350] # Get description for caption price = raffle['price'] channel_id_str = raffle['channel_id'] diff --git a/app/helpers.py b/app/helpers.py index 1bd8400..f866efc 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -1,3 +1,4 @@ +import datetime import logging import requests from database import * # Import all DB functions @@ -389,9 +390,9 @@ def format_last_participants_list(participants_list: list) -> str: if numbers_str: num_list = numbers_str.split(',') if len(num_list) == 1: - line = f" - {user_name}, con la papeleta: {num_list[0]}" + line = f" - {user_name}, con la participación: {num_list[0]}" else: - line = f" - {user_name}, con las papeletas: {', '.join(num_list)}" + line = f" - {user_name}, con las participaciones: {', '.join(num_list)}" formatted_lines.append(line) return "\n".join(formatted_lines) # Add a trailing newline @@ -482,3 +483,245 @@ def get_paypal_amounts_for_invoice(invoice_id): net_amount = float(breakdown.get("net_amount", {}).get("value", gross_amount)) return gross_amount, net_amount, fee_amount + +def is_vip_member_of_homelabs(user_id): + """Checks if a Telegram user ID is a VIP member of HomeLabs via the Homelabs API.""" + if not HOMELABS_API_TOKEN or not HOMELABS_API_URL: + logger.warning("Homelabs API configuration is missing.") + return False # If not configured, treat as non-member + + url = f"{HOMELABS_API_URL}/users/{user_id}" + headers = { + "X-API-Key": HOMELABS_API_TOKEN, + "Accept": "application/json", + } + + try: + response = requests.get(url, headers=headers, timeout=5) + if response.status_code == 200: + data = response.json() + if data.get("is_lifetime") == True: + return True + if data.get("member_until") >= datetime.datetime.now(datetime.timezone.utc).date().isoformat(): + return True + return False + elif response.status_code == 404: + return False # User not found, hence not a member + else: + logger.error(f"Unexpected response from Homelabs API: {response.status_code} - {response.text}") + return False + except requests.RequestException as e: + logger.error(f"Error connecting to Homelabs API: {e}") + return False + +def send_raffle_update_image(raffle_id, current_user_name=None, numbers=None, bot_token=None): + """ + Sends or updates the raffle table image to the appropriate channel. + Can be used for payment confirmations or manual updates from admin. + + Args: + raffle_id: The ID of the raffle + current_user_name: Name of the user who just joined (optional, for payment updates) + numbers: List of numbers the user selected (optional, for payment updates) + bot_token: Telegram bot token for API calls + + Returns: + bool: True if successful, False otherwise + """ + try: + # Import here to avoid circular imports + import json + + if not bot_token: + bot_token = BOT_TOKEN + + TELEGRAM_API_URL = f"https://api.telegram.org/bot{bot_token}" + + # Generate table image + if not generate_table_image(raffle_id): + logger.error(f"Failed to generate raffle table image for {raffle_id}") + return False + + image_path = f"/app/data/raffles/raffle_table_{raffle_id}.png" + + # Get raffle details + raffle_details = get_raffle(raffle_id) + if not raffle_details: + logger.error(f"Could not fetch raffle details for ID {raffle_id}") + return False + + raffle_name = raffle_details['name'] + raffle_description_for_announce = raffle_details['description'][:350] + channel_id_to_announce = raffle_details['channel_id'] + original_price_per_number = raffle_details['price'] + remaining_numbers_amount = get_remaining_numbers_amount(raffle_id) + + # Determine keyboard and caption based on remaining numbers + keyboard = None + if remaining_numbers_amount == 0: + # Raffle is complete + main_announcement = f"🎯🏆🎯 Sorteo '{raffle_name}' terminado 🎯🏆🎯\n\n" + main_announcement += f"{raffle_description_for_announce}\n\n" + main_announcement += f"🌍 Envío internacional: {'Sí ✅' if raffle_details['international_shipping'] else 'No ❌'}\n" + main_announcement += f"💵 Donación mínima: {original_price_per_number}€\n" + main_announcement += f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" + + # Update main message to remove participate button + main_message_id = get_main_message_id(raffle_id) + if main_message_id: + requests.post(f"{TELEGRAM_API_URL}/editMessageCaption", json={ + "chat_id": channel_id_to_announce, + "message_id": main_message_id, + "caption": main_announcement, + "reply_markup": None, + "parse_mode": "HTML" + }) + else: + # Raffle still in progress + url = f"https://t.me/{BOT_NAME}?start=join_{raffle_id}" + keyboard = { + "inline_keyboard": [ + [ + {"text": "✅ ¡Participar Ahora! ✅", "url": url} + ] + ] + } + main_announcement = f"🏆 Sorteo '{raffle_name}' en progreso 🏆\n\n" + main_announcement += f"{raffle_description_for_announce}\n\n" + main_announcement += f"🌍 Envío internacional: {'Sí ✅' if raffle_details['international_shipping'] else 'No ❌'}\n" + main_announcement += f"💵 Donación mínima: {original_price_per_number}€\n" + main_announcement += f"🗒️ Quedan {remaining_numbers_amount} participaciones disponibles. ¡Date prisa! 🗒️\n\n" + main_announcement += f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" + + # Update main message + main_message_id = get_main_message_id(raffle_id) + if main_message_id: + requests.post(f"{TELEGRAM_API_URL}/editMessageCaption", json={ + "chat_id": channel_id_to_announce, + "message_id": main_message_id, + "caption": main_announcement, + "reply_markup": keyboard, + "parse_mode": "HTML" + }) + + # Build caption for update message + caption = "" + + # Add participant info if provided (for payment confirmations) + if current_user_name and numbers: + escaped_current_user_name = current_user_name + numbers_text = "" + if len(numbers) > 1: + numbers_text = f"con las participaciones: {', '.join(numbers)}" + else: + numbers_text = f"con la participación: {', '.join(numbers)}" + + new_participant_line = f"🗳️ @{escaped_current_user_name} se ha unido al sorteo {numbers_text}. ¡Mucha suerte! 🗳️" + caption += f"{new_participant_line}\n\n" + + # Add last participants + last_other_participants = get_last_n_other_participants(raffle_id, n=4) + last_participants_text = format_last_participants_list(last_other_participants) + caption += f"{last_participants_text}\n\n" + + # Add remaining numbers info + remaining_numbers_text = "" + if remaining_numbers_amount > 10: + remaining_numbers_text = f"🗒️ Todavía hay {remaining_numbers_amount} participaciones disponibles. 🗒️" + elif remaining_numbers_amount == 1: + remaining_numbers = get_remaining_numbers(raffle_id) + remaining_numbers_text = f"⏰⏰⏰ ¡Última participación! ⏰⏰⏰\n\n" + remaining_numbers_text += f"Queda la participación: {remaining_numbers[0]}" + elif remaining_numbers_amount == 0: + remaining_numbers_text = "⌛ ¡Ya no hay participaciones! ⌛\n\n" + remaining_numbers_text += "¡El resultado del sorteo se dará a conocer a las 21:45h!" + else: + remaining_numbers = get_remaining_numbers(raffle_id) + remaining_numbers_text = f"🔔🔔🔔 ¡Últimas {remaining_numbers_amount} participaciones disponibles! 🔔🔔🔔\n\n" + remaining_numbers_text += f"Quedan las participaciones: {', '.join(remaining_numbers)}" + + caption += f"{remaining_numbers_text}\n\n" + + # Add raffle description and details + caption += ( + f"{raffle_description_for_announce}\n\n" + f"🔎 Ver detalles: https://t.me/{REVERSE_CHANNELS.get(channel_id_to_announce)}/{get_main_message_id(raffle_id)}\n\n" + f"🌍 Envío internacional: {'Sí ✅' if raffle_details['international_shipping'] else 'No ❌'}\n" + f"💵 Donación mínima: {original_price_per_number}€\n\n" + f"📜 Normas y condiciones: {TYC_DOCUMENT_URL} \n\n" + ) + + # Send or update the image message + update_message_id = get_update_message_id(raffle_id) + sent_or_edited_message_id = None + + if update_message_id: + logger.info(f"Attempting to delete old message {update_message_id} and send new one in channel {channel_id_to_announce}") + + # Try deleting old message first + try: + delete_payload = {'chat_id': channel_id_to_announce, 'message_id': update_message_id} + delete_response = requests.post(f"{TELEGRAM_API_URL}/deleteMessage", data=delete_payload) + if delete_response.status_code == 200: + logger.info(f"Successfully deleted old message {update_message_id} in channel {channel_id_to_announce}") + else: + logger.warning(f"Failed to delete old message {update_message_id} in channel {channel_id_to_announce}: {delete_response.text}") + except Exception as e_del: + logger.warning(f"Error deleting old message {update_message_id}: {e_del}") + + # Always send new photo after delete attempt + files = {'photo': open(image_path, 'rb')} + data = { + 'chat_id': channel_id_to_announce, + 'caption': caption, + 'parse_mode': 'HTML' + } + if keyboard: + data['reply_markup'] = json.dumps(keyboard) + + try: + response = requests.post(f"{TELEGRAM_API_URL}/sendPhoto", data=data, files=files) + response.raise_for_status() + logger.info(f"Sent new photo to channel {channel_id_to_announce}") + message_data = response.json().get('result') + if message_data and 'message_id' in message_data: + sent_or_edited_message_id = message_data['message_id'] + except requests.exceptions.RequestException as e: + logger.error(f"Error sending new photo to channel {channel_id_to_announce}: {e}") + return False + finally: + files['photo'].close() + else: + # No previous message, send new + logger.info(f"No previous message found for raffle {raffle_id} in channel {channel_id_to_announce}. Sending new.") + files = {'photo': open(image_path, 'rb')} + data = { + 'chat_id': channel_id_to_announce, + 'caption': caption, + 'parse_mode': 'HTML' + } + if keyboard: + data['reply_markup'] = json.dumps(keyboard) + + try: + response = requests.post(f"{TELEGRAM_API_URL}/sendPhoto", data=data, files=files) + response.raise_for_status() + logger.info(f"Sent photo to channel {channel_id_to_announce}") + message_data = response.json().get('result') + if message_data and 'message_id' in message_data: + sent_or_edited_message_id = message_data['message_id'] + except requests.exceptions.RequestException as e: + logger.error(f"Error sending photo to channel {channel_id_to_announce}: {e}") + return False + finally: + files['photo'].close() + + # Store the new message ID for future updates + if sent_or_edited_message_id: + store_update_message_id(raffle_id, sent_or_edited_message_id) + + return True + + except Exception as e: + logger.error(f"Error in send_raffle_update_image for raffle {raffle_id}: {e}") + return False \ No newline at end of file diff --git a/app/keyboards.py b/app/keyboards.py index 2633efd..2577640 100644 --- a/app/keyboards.py +++ b/app/keyboards.py @@ -122,6 +122,7 @@ def generate_admin_raffle_details_keyboard(raffle_id): keyboard = [ # Add relevant actions here if needed later, e.g., edit description? [InlineKeyboardButton("📢 Anunciar de Nuevo", callback_data=f"{ADMIN_ANNOUNCE_RAFFLE_PREFIX}{raffle_id}")], + [InlineKeyboardButton("🔄 Actualizar Imagen", callback_data=f"{ADMIN_UPDATE_IMAGE_PREFIX}{raffle_id}")], [InlineKeyboardButton("🏁 Terminar Sorteo", callback_data=f"{ADMIN_END_RAFFLE_PROMPT_PREFIX}{raffle_id}")], [InlineKeyboardButton("⬅️ Volver a la Lista", callback_data=ADMIN_MENU_LIST)] # Back to list view ] diff --git a/app/paypal_processor.py b/app/paypal_processor.py index fa33513..cc4c526 100644 --- a/app/paypal_processor.py +++ b/app/paypal_processor.py @@ -9,7 +9,7 @@ import os # Import os to get BOT_TOKEN # Import necessary functions from your project structure # Adjust the path if paypal_processor.py is not in the root 'app' directory # Assuming it can access the other modules directly as in the docker setup: -from helpers import generate_table_image, format_last_participants_list, get_paypal_access_token +from helpers import generate_table_image, format_last_participants_list, get_paypal_access_token, is_vip_member_of_homelabs, send_raffle_update_image from database import ( get_user_by_invoice_id, confirm_reserved_numbers, get_raffle_name, get_raffle, @@ -19,7 +19,7 @@ from database import ( get_last_n_other_participants, get_remaining_numbers, delete_reservation_timestamp, cancel_reservation_by_id ) -from config import BOT_TOKEN, BOT_NAME, WEBHOOK_ID, TYC_DOCUMENT_URL, REVERSE_CHANNELS, NEWRELIC_API_KEY, PAYPAL_URL, ADMIN_IDS +from config import BOT_TOKEN, BOT_NAME, WEBHOOK_ID, TYC_DOCUMENT_URL, REVERSE_CHANNELS, NEWRELIC_API_KEY, PAYPAL_URL, ADMIN_IDS, VIP_DISCOUNT_PER_NUMBER from newrelic_telemetry_sdk import Log, LogClient app = Flask(__name__) @@ -126,7 +126,6 @@ def send_telegram_photo(chat_id, photo_path, caption=None, keyboard=None, parse_ if 'photo' in files and files['photo']: files['photo'].close() -# --- CORRECTED FUNCTION --- def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): """Processes verified PayPal payment data.""" logger.info(f"Processing payment for Invoice: {invoice_id}, Status: {payment_status}, Amount: {payment_amount_str}") @@ -147,7 +146,13 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): raffle_id = participant_data['raffle_id'] numbers_str = participant_data['numbers'] raffle_details = get_raffle(raffle_id) - price_per_number = raffle_details['price'] + original_price_per_number = raffle_details['price'] + is_vip = is_vip_member_of_homelabs(user_id) + if is_vip: + logger.info(f"User {user_id} ({current_user_name}) is a VIP member. Applying discount.") + price_per_number = max(1, raffle_details['price'] - VIP_DISCOUNT_PER_NUMBER) # Ensure price doesn't go negative + else: + price_per_number = raffle_details['price'] channel_id_to_announce = raffle_details['channel_id'] update_message_id = raffle_details['update_message_id'] @@ -206,144 +211,15 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): # Raffle name can be added here if desired, requires one more DB call or adding it to get_user_by_invoice_id ) - # Generate table image - if generate_table_image(raffle_id): - image_path = f"/app/data/raffles/raffle_table_{raffle_id}.png" - - # Get general raffle details (like description) for announcement caption - raffle_details_general = get_raffle(raffle_id) # Fetches name, description, image_id - if not raffle_details_general: - logger.error(f"Could not fetch general raffle details for ID {raffle_id} after payment confirmation.") - return # Or handle more gracefully - - raffle_description_for_announce = raffle_details_general['description'] - remaining_numbers_amount = get_remaining_numbers_amount(raffle_id) - - # If it's the last number, update the main message and delete the participate button - if remaining_numbers_amount == 0: - keyboard = None - main_announcement = f"🎯🏆🎯 Sorteo '{raffle_name}' terminado 🎯🏆🎯\n\n" - main_announcement += f"{raffle_details['description']}\n\n" - main_announcement += f"🌍 Envío internacional: {'Sí ✅' if raffle_details['international_shipping'] else 'No ❌'}\n" - main_announcement += f"💵 Donación mínima: {raffle_details['price']}€\n" - main_announcement += f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" - main_message_id = get_main_message_id(raffle_id) - requests.post(f"{TELEGRAM_API_URL}/editMessageCaption", json={ - "chat_id": channel_id_to_announce, - "message_id": main_message_id, - "caption": main_announcement, - "reply_markup": keyboard, - "parse_mode": "HTML" - }) - else: - url = f"https://t.me/{BOT_NAME}?start=join_{raffle_id}" - keyboard = { - "inline_keyboard": [ - [ - {"text": "✅ ¡Participar Ahora! ✅", "url": url} - ] - ] - } - main_announcement = f"🏆 Sorteo '{raffle_name}' en progreso 🏆\n\n" - main_announcement += f"{raffle_details['description']}\n\n" - main_announcement += f"🌍 Envío internacional: {'Sí ✅' if raffle_details['international_shipping'] else 'No ❌'}\n" - main_announcement += f"💵 Donación mínima: {raffle_details['price']}€\n" - main_announcement += f"🗒️ Quedan {remaining_numbers_amount} participaciones disponibles. ¡Date prisa! 🗒️\n\n" - main_announcement += f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" - main_message_id = get_main_message_id(raffle_id) - requests.post(f"{TELEGRAM_API_URL}/editMessageCaption", json={ - "chat_id": channel_id_to_announce, - "message_id": main_message_id, - "caption": main_announcement, - "reply_markup": keyboard, - "parse_mode": "HTML" - }) - - last_other_participants = get_last_n_other_participants(raffle_id, n=4) - last_participants_text = format_last_participants_list(last_other_participants) - - escaped_current_user_name = current_user_name - - numbers_text = "" - if len(numbers) > 1: - numbers_text = f"con las participaciones: {', '.join(numbers)}" - else: - numbers_text = f"con la participación: {', '.join(numbers)}" - - new_participant_line = f"🗳️ @{escaped_current_user_name} se ha unido al sorteo {numbers_text}. ¡Mucha suerte! 🗳️" - - remaining_numbers_text = "" - if remaining_numbers_amount > 10: - remaining_numbers_text = f"🗒️ Todavía hay {remaining_numbers_amount} participaciones disponibles. 🗒️" - elif remaining_numbers_amount == 1: - remaining_numbers = get_remaining_numbers(raffle_id) - remaining_numbers_text = f"⏰⏰⏰ ¡Última participación! ⏰⏰⏰\n\n" - remaining_numbers_text += f"Queda la participación: {remaining_numbers[0]}" - elif remaining_numbers_amount == 0: - remaining_numbers_text = "⌛ ¡Ya no hay participaciones! ⌛\n\n" - remaining_numbers_text += "¡El resultado del sorteo se dará a conocer a las 21:45h!" - else: - remaining_numbers = get_remaining_numbers(raffle_id) - remaining_numbers_text = f"🔔🔔🔔 ¡Últimas {remaining_numbers_amount} participaciones disponibles! 🔔🔔🔔\n\n" - remaining_numbers_text += f"Quedan las participaciones: {', '.join(remaining_numbers)}" - - caption = ( - f"{new_participant_line}\n\n" - f"{last_participants_text}\n\n" - f"{remaining_numbers_text}\n\n" - f"{raffle_description_for_announce}\n\n" - f"🔎 Ver detalles: https://t.me/{REVERSE_CHANNELS.get(channel_id_to_announce)}/{get_main_message_id(raffle_id)}\n\n" - f"🌍 Envío internacional: {'Sí ✅' if raffle_details['international_shipping'] else 'No ❌'}\n" - f"💵 Donación mínima: {price_per_number}€\n\n" - f"📜 Normas y condiciones: {TYC_DOCUMENT_URL} \n\n" - ) - - - update_message_id = get_update_message_id(raffle_id) - sent_or_edited_message_id = None - - if update_message_id: - logger.info(f"Attempting to edit message {update_message_id} in channel {channel_id_to_announce}") - - # Try deleting old message first - try: - delete_payload = {'chat_id': channel_id_to_announce, 'message_id': update_message_id} - delete_response = requests.post(f"{TELEGRAM_API_URL}/deleteMessage", data=delete_payload) - if delete_response.status_code == 200: - logger.info(f"Successfully deleted old message {update_message_id} in channel {channel_id_to_announce}") - else: - logger.warning(f"Failed to delete old message {update_message_id} in channel {channel_id_to_announce}: {delete_response.text}. Will send new.") - except Exception as e_del: - logger.warning(f"Error deleting old message {update_message_id}: {e_del}. Will send new.") - - # Always send new photo after delete attempt, ensures updated image is shown - new_msg_info = send_telegram_photo(channel_id_to_announce, image_path, caption=caption, keyboard=keyboard, parse_mode='HTML') - if new_msg_info and isinstance(new_msg_info, dict) and 'message_id' in new_msg_info: # If send_telegram_photo returns message object - sent_or_edited_message_id = new_msg_info['message_id'] - elif isinstance(new_msg_info, bool) and new_msg_info is True: # If it just returns True/False - # We can't get message_id this way. Need send_telegram_photo to return it. - logger.warning("send_telegram_photo did not return message_id, cannot store for future edits.") - else: # Sending new failed - logger.error(f"Failed to send new photo to channel {channel_id_to_announce} after deleting old.") - - else: # No previous message, send new - logger.info(f"No previous message found for raffle {raffle_id} in channel {channel_id_to_announce}. Sending new.") - new_msg_info = send_telegram_photo(channel_id_to_announce, image_path, caption=caption, keyboard=keyboard, parse_mode='HTML') - # Similar logic to get sent_or_edited_message_id as above - if new_msg_info and isinstance(new_msg_info, dict) and 'message_id' in new_msg_info: - sent_or_edited_message_id = new_msg_info['message_id'] - elif isinstance(new_msg_info, bool) and new_msg_info is True: - logger.warning("send_telegram_photo did not return message_id for new message.") - - if sent_or_edited_message_id: - store_update_message_id(raffle_id, sent_or_edited_message_id) - - # Send image confirmation to user (price not needed in this caption) - user_caption = f"¡Apuntado satisfactoriamente al sorteo '{raffle_name}'! Tus participaciones son: {', '.join(numbers)}" - send_telegram_photo(user_id, image_path, caption=user_caption) - + # Send raffle update image with participant info + if send_raffle_update_image(raffle_id, current_user_name, numbers, BOT_TOKEN): + # Send image confirmation to user + if generate_table_image(raffle_id): + image_path = f"/app/data/raffles/raffle_table_{raffle_id}.png" + user_caption = f"¡Apuntado satisfactoriamente al sorteo '{raffle_name}'! Tus participaciones son: {', '.join(numbers)}" + send_telegram_photo(user_id, image_path, caption=user_caption) else: - logger.error(f"Failed to generate raffle table image for {raffle_id} after payment.") + logger.error(f"Failed to send raffle update image for {raffle_id} after payment.") else: # This case means the DB update failed, which is serious if payment was valid. @@ -358,6 +234,22 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): # admin_chat_id = "YOUR_ADMIN_CHAT_ID" # send_telegram_message(admin_chat_id, f"CRITICAL DB Error: Failed to confirm numbers for invoice {invoice_id}, user {user_id}, raffle {raffle_id}. Payment was valid. Manual check needed.") +def notify_user_of_pending_review(invoice_id, payment_status, status_details): + """Notify user that their payment is pending review.""" + participant_data = get_user_by_invoice_id(invoice_id) + if not participant_data: + logger.warning(f"No participant found for Invoice ID: {invoice_id}. Cannot notify about pending review.") + return + + user_id = participant_data['user_id'] + send_telegram_message( + user_id, + f"⚠️ Tu pago para la factura {invoice_id} está pendiente de revisión por PayPal.\n" + f"El estado actual es '{payment_status}' con detalles: '{status_details}'.\n" + f"El sorteo solo se confirma con pagos 'Completed'. Cuando el pago sea confirmado, recibirás una notificación.\n" + f"Tus números reservados se mantendrán durante este proceso." + ) + def capture_order(order_id, access_token): url = f"{PAYPAL_URL}/v2/checkout/orders/{order_id}/capture" headers = { @@ -442,6 +334,7 @@ def paypal_webhook(): status_details = resource["status_details"]["reason"] delete_reservation_timestamp(invoice_id) + notify_user_of_pending_review(invoice_id, payment_status, status_details) if status_details == "PENDING_REVIEW": logger.info(f"Payment for invoice {invoice_id} is pending review. Notifying admins.") @@ -498,4 +391,4 @@ if __name__ == "__main__": exit(1) TELEGRAM_API_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" # Set global URL - app.run(port=5000, debug=False, host="0.0.0.0") # Disable debug in production \ No newline at end of file + app.run(port=5000, debug=False, host="0.0.0.0") # Disable debug in production diff --git a/docker-compose.yml b/docker-compose.yml index f4f6d31..45f5ee5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,12 @@ services: - PAYPAL_PERCENTAGE_FEE=${PAYPAL_PERCENTAGE_FEE} - PAYPAL_FIXED_FEE=${PAYPAL_FIXED_FEE} - PAYPAL_URL=${PAYPAL_URL} + - ANNOUNCE_CHANNEL_IDS=${ANNOUNCE_CHANNEL_IDS} + - HOMELABS_API_TOKEN=${HOMELABS_API_TOKEN} + - HOMELABS_API_URL=${HOMELABS_API_URL} + - VIP_DISCOUNT_PER_NUMBER=${VIP_DISCOUNT_PER_NUMBER} + networks: + - traefik telerifas_paypal_processor: build: context: app @@ -40,6 +46,7 @@ services: - TZ="Europe/Madrid" - BOT_TOKEN=${BOT_TOKEN} - BOT_NAME=${BOT_NAME} + - ADMIN_IDS=${ADMIN_IDS} - CHANNEL_IDS=${CHANNEL_IDS} - PAYPAL_EMAIL=${PAYPAL_EMAIL} - PAYPAL_HANDLE=${PAYPAL_HANDLE} @@ -50,6 +57,9 @@ services: - TYC_DOCUMENT_URL=${TYC_DOCUMENT_URL} - NEWRELIC_API_KEY=${NEWRELIC_API_KEY} - PAYPAL_URL=${PAYPAL_URL} + - HOMELABS_API_TOKEN=${HOMELABS_API_TOKEN} + - HOMELABS_API_URL=${HOMELABS_API_URL} + - VIP_DISCOUNT_PER_NUMBER=${VIP_DISCOUNT_PER_NUMBER} networks: - traefik labels: