commit 50eb4f7eca0de7aa33fe60d7034950296e840d3f Author: Joan Date: Sun Feb 22 12:56:42 2026 +0100 Initial commit diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..74b5b6b --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11 + +RUN mkdir /app +ADD . /app +RUN pip install -r /app/requirements.txt +RUN apt-get update && apt-get install -y fonts-dejavu + +WORKDIR /app + +CMD [ "python", "/app/app.py" ] \ No newline at end of file diff --git a/app/Dockerfile.paypal_processor b/app/Dockerfile.paypal_processor new file mode 100644 index 0000000..1788c42 --- /dev/null +++ b/app/Dockerfile.paypal_processor @@ -0,0 +1,9 @@ +FROM python:3.11 + +RUN mkdir /app +ADD . /app +RUN pip install -r /app/requirements.txt + +WORKDIR /app + +CMD [ "python", "/app/paypal_processor.py" ] \ No newline at end of file diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..e8c25ff --- /dev/null +++ b/app/app.py @@ -0,0 +1,213 @@ +# app.py +import logging +import time +# REMOVE: from datetime import timedelta # Not needed directly for run_repeating interval +from telegram import Update +from telegram.ext import ( + Application, + CommandHandler, + CallbackQueryHandler, + MessageHandler, + ConversationHandler, + filters, + ContextTypes, +) +from telegram.error import Forbidden, BadRequest +from newrelic_telemetry_sdk import Log, LogClient + +# Import handlers and db/config +from handlers import * +from database import init_db, get_expired_reservations, cancel_reservation_by_id +from config import * + +# Logging setup +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO +) + +# Suppress overly verbose logs from libraries +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("apscheduler").setLevel(logging.WARNING) + +client = LogClient(license_key=NEWRELIC_API_KEY, host="log-api.eu.newrelic.com") + +class NewRelicHandler(logging.Handler): + def emit(self, record): + try: + log = Log( + message=self.format(record), + level=record.levelname, + timestamp_ms=int(record.created * 1000), + attributes={ + "logger": record.name, + "app_name": BOT_NAME, + "docker_container": "telerifas" + } + ) + client.send(log) + except Exception: + self.handleError(record) + +nr_handler = NewRelicHandler() +nr_handler.setFormatter(logging.Formatter("%(message)s")) + +root_logger = logging.getLogger() +root_logger.addHandler(nr_handler) + +logger = logging.getLogger(__name__) + +# Scheduler Job (Callback for PTB Job Queue) +async def check_expired_reservations(context: ContextTypes.DEFAULT_TYPE): + """Job callback to cancel expired reservations and notify users.""" + # 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. + now = time.time() + timeout_seconds = RESERVATION_TIMEOUT_MINUTES * 60 + expiry_threshold = now - timeout_seconds + logger.info(f"Running check_expired_reservations (Threshold: {expiry_threshold})") + + expired_list = get_expired_reservations(expiry_threshold) # DB function remains the same + + if not expired_list: + logger.info("No expired reservations found.") + return + + logger.info(f"Found {len(expired_list)} expired reservations to cancel.") + cancelled_count = 0 + for reservation in expired_list: + participant_id = reservation['id'] + user_id = reservation['user_id'] + raffle_id = reservation['raffle_id'] + raffle_name = reservation['raffle_name'] + numbers = reservation['numbers'] + + logger.warning(f"Expiring reservation for User ID: {user_id}, Raffle: {raffle_name} ({raffle_id}), Numbers: {numbers}") + + # Cancel the reservation in the database first + if cancel_reservation_by_id(participant_id): + cancelled_count += 1 + # Try to notify the user using context.bot + notification_text = ( + f"⚠️ Tu reserva para el sorteo **{raffle_name}** ha expirado.\n\n" + f"Los números `{numbers}` han sido liberados porque el pago no se completó a tiempo.\n\n" + f"Puedes volver a intentarlo usando /sorteo en el grupo correspondiente." + ) + try: + await context.bot.send_message(chat_id=user_id, text=notification_text, parse_mode=ParseMode.MARKDOWN) + logger.info(f"Notified user {user_id} about expired reservation.") + except Forbidden: + logger.warning(f"Cannot notify user {user_id} (Forbidden). Reservation cancelled anyway.") + except BadRequest as e: + logger.error(f"BadRequest notifying user {user_id}: {e}") + except Exception as e: + logger.error(f"Failed to notify user {user_id} about expiry: {e}") + else: + logger.error(f"Failed to cancel reservation ID {participant_id} in DB (might have been processed already).") + + logger.info(f"Finished check_expired_reservations. Cancelled {cancelled_count} reservations.") + + +# --- Main Function --- +def main(): + init_db() + + app = Application.builder().token(BOT_TOKEN).build() + logger.info("Bot application built.") + + # --- Use PTB's Job Queue --- + job_queue = app.job_queue + job_interval_seconds = 5 * 60 # Check every 5 minutes + # Add the repeating job + job_queue.run_repeating( + callback=check_expired_reservations, + interval=job_interval_seconds, + first=10, # Start after 10 seconds (optional) + name="expire_check_job" # Optional name for the job + ) + logger.info(f"Scheduled reservation check job to run every {job_interval_seconds} seconds.") + + # --- Handlers (Remain the same) --- + # 1. Raffle Creation Conversation Handler + raffle_creation_conv = ConversationHandler( + entry_points=[CommandHandler("crear_sorteo", new_raffle_start)], + states={ + SELECTING_CHANNELS: [CallbackQueryHandler(select_channels, pattern=f"^{SELECT_CHANNEL_PREFIX}.*")], + TYPING_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, receive_title)], + TYPING_DESCRIPTION: [MessageHandler(filters.TEXT & ~filters.COMMAND, receive_description)], + SENDING_IMAGE: [ + MessageHandler(filters.PHOTO, receive_image), + MessageHandler(filters.TEXT & ~filters.COMMAND, incorrect_input_type) + ], + TYPING_PRICE_FOR_CHANNELS: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, receive_price_for_channel) + ], + CONFIRMING_CREATION: [ + CallbackQueryHandler(confirm_creation, pattern=f"^{CONFIRM_CREATION_CALLBACK}$"), + CallbackQueryHandler(confirm_creation, pattern=f"^{CANCEL_CREATION_CALLBACK}$"), + ], + }, + fallbacks=[ + CommandHandler("cancelar", cancel_creation_command), + MessageHandler(filters.TEXT & ~filters.COMMAND, incorrect_input_type), + ], + ) + app.add_handler(raffle_creation_conv) + + # --- Raffle Edit Conversation Handler --- + raffle_edit_conv = ConversationHandler( + entry_points=[CallbackQueryHandler(admin_edit_select_raffle, pattern=f"^{ADMIN_EDIT_RAFFLE_SELECT}$")], # Triggered by main menu + states={ + EDIT_SELECT_RAFFLE: [ # State after clicking "Edit Raffle" + CallbackQueryHandler(admin_edit_selected_raffle, pattern=f"^{ADMIN_EDIT_RAFFLE_PREFIX}\d+$") + ], + EDIT_SELECT_NEW_CHANNELS: [ + CallbackQueryHandler(admin_edit_select_new_channels, pattern=f"^{SELECT_CHANNEL_PREFIX}.*") + ], + EDIT_TYPING_PRICE_FOR_NEW_CHANNELS: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, admin_edit_receive_price_for_new_channel) + ], + EDIT_CONFIRM: [ + CallbackQueryHandler(admin_confirm_edit_action, pattern="^(confirm_edit_action|cancel_edit_action)$") + ], + }, + fallbacks=[CommandHandler("cancelar_edicion", cancel_edit_command), # Specific cancel for this flow + CommandHandler("cancelar", cancel_edit_command)], # General cancel + map_to_parent={ # Example: If edit is cancelled, go back to main admin menu flow if possible + # ConversationHandler.END: ADMIN_MENU_LIST # Or handle return state differently + } + ) + app.add_handler(raffle_edit_conv) + + app.add_handler(CommandHandler("start", start, filters=filters.ChatType.PRIVATE)) + # 2. Admin Menu Handlers + app.add_handler(CommandHandler("menu", admin_menu, filters=filters.ChatType.PRIVATE)) + # Refined pattern to avoid clash with user number selection if prefixes overlap heavily + admin_pattern = ( + f"^({ADMIN_MENU_CREATE}|{ADMIN_MENU_LIST}|{ADMIN_MENU_BACK_MAIN}|" + f"{ADMIN_VIEW_RAFFLE_PREFIX}\d+|{ADMIN_ANNOUNCE_RAFFLE_PREFIX}\d+|" + f"{ADMIN_END_RAFFLE_PROMPT_PREFIX}\d+|{ADMIN_CANCEL_END_PROCESS}|{ADMIN_NO_OP}|" + f"{ADMIN_EDIT_RAFFLE_SELECT})$" + ) + + app.add_handler(CallbackQueryHandler(admin_menu_callback, pattern=admin_pattern)) + app.add_handler(MessageHandler(filters.TEXT & filters.ChatType.PRIVATE & ~filters.COMMAND, admin_receive_winner_numbers)) + + # 4. User command + app.add_handler(CommandHandler("sorteo", enter_raffle)) # Add filters=filters.ChatType.GROUPS if desired + + # 5. User Callbacks + app.add_handler(CallbackQueryHandler(raffle_selected, pattern=r"^join:\d+$")) + app.add_handler(CallbackQueryHandler(number_callback, pattern=r"^(number:\d+:(\d+|prev|next)|random_num:\d+)$")) + #app.add_handler(CallbackQueryHandler(number_callback, pattern=r"^number:\d+:(?:\d{1,2}|prev|next)$")) + app.add_handler(CallbackQueryHandler(confirm_callback, pattern=r"^confirm:\d+$")) + app.add_handler(CallbackQueryHandler(cancel_callback, pattern=r"^cancel:\d+$")) + + # --- Start Bot --- + # REMOVE: scheduler.start() # No longer needed + logger.info("Starting bot polling...") + app.run_polling(allowed_updates=Update.ALL_TYPES) + logger.info("Bot stopped.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..ea274f7 --- /dev/null +++ b/app/config.py @@ -0,0 +1,47 @@ +from dotenv import load_dotenv +import os +import logging + +# Load environment variables +load_dotenv() +BOT_TOKEN = os.getenv("BOT_TOKEN") +BOT_NAME = os.getenv("BOT_NAME") +ADMIN_IDS = list(map(int, os.getenv("ADMIN_IDS", "1").split(','))) # Comma-separated list of admin IDs +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} +# 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" +PAYPAL_EMAIL = os.getenv("PAYPAL_EMAIL") +PAYPAL_CLIENT_ID = os.getenv("PAYPAL_CLIENT_ID") +PAYPAL_SECRET = os.getenv("PAYPAL_SECRET") +PAYPAL_HANDLE = os.getenv("PAYPAL_HANDLE") +WEBHOOK_URL = os.getenv("WEBHOOK_URL") +RESERVATION_TIMEOUT_MINUTES = 30 # e.g., 30 minutes +TYC_URL = os.getenv("TYC_URL") +NEWRELIC_API_KEY = os.getenv("NEWRELIC_API_KEY") + +# Conversation States for Raffle Creation +(SELECTING_CHANNELS, TYPING_TITLE, TYPING_DESCRIPTION, TYPING_PRICE_FOR_CHANNELS, SENDING_IMAGE, CONFIRMING_CREATION) = range(6) + +# Conversation States for Editing Raffles +(EDIT_SELECT_RAFFLE, EDIT_SELECT_NEW_CHANNELS, EDIT_TYPING_PRICE_FOR_NEW_CHANNELS, EDIT_CONFIRM) = range(6, 10) + +# Callback Data Prefixes +SELECT_CHANNEL_PREFIX = "select_channel_" +CONFIRM_CREATION_CALLBACK = "confirm_creation" +CANCEL_CREATION_CALLBACK = "cancel_creation" + +# --- Admin Menu Callback Data --- +ADMIN_MENU_CREATE = "admin_create_raffle" +ADMIN_MENU_LIST = "admin_list_raffles" +ADMIN_MENU_BACK_MAIN = "admin_back_to_main_menu" +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_NO_OP = "admin_no_op" # Placeholder for buttons that do nothing on click +ADMIN_EDIT_RAFFLE_SELECT = "admin_edit_select" # New: Select raffle to edit +ADMIN_EDIT_RAFFLE_PREFIX = "admin_edit_raffle:" # New: Prefix for raffle ID to edit +# --- End Admin Menu --- \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..4417e2e --- /dev/null +++ b/app/database.py @@ -0,0 +1,746 @@ +# database.py +import sqlite3 +import logging +import time # Import time for timestamps +from config import DATABASE_PATH + +logger = logging.getLogger(__name__) + +# --- Database Initialization --- +def init_db(): + conn = sqlite3.connect(DATABASE_PATH) + cur = conn.cursor() + # ... (raffles table definition remains the same) ... + cur.execute(""" + CREATE TABLE IF NOT EXISTS raffles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + image_file_id TEXT, + active INTEGER DEFAULT 1 + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS raffle_channel_prices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + raffle_id INTEGER NOT NULL, + channel_id TEXT NOT NULL, -- Storing channel ID as TEXT + price INTEGER NOT NULL, + FOREIGN KEY (raffle_id) REFERENCES raffles(id) ON DELETE CASCADE, + UNIQUE (raffle_id, channel_id) + ) + """) + + # Add reservation_timestamp to participants table + cur.execute(""" + CREATE TABLE IF NOT EXISTS participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + user_name TEXT, + raffle_id INTEGER NOT NULL, + numbers TEXT, + transaction_id TEXT, + step TEXT NOT NULL, + invoice_id TEXT, + reservation_timestamp INTEGER, + origin_channel_id TEXT, + completion_timestamp INTEGER, + UNIQUE(user_id, raffle_id, transaction_id), + FOREIGN KEY (raffle_id) REFERENCES raffles(id) ON DELETE CASCADE + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS raffle_channel_announcements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + raffle_id INTEGER NOT NULL, + channel_id TEXT NOT NULL, + message_id INTEGER NOT NULL, -- Telegram message ID of the announcement + last_updated_ts INTEGER, -- Timestamp of when this message was last updated + FOREIGN KEY (raffle_id) REFERENCES raffles(id) ON DELETE CASCADE, + UNIQUE (raffle_id, channel_id) -- Only one "main" announcement message per raffle per channel + ) + """) + + # Indexes + cur.execute("CREATE INDEX IF NOT EXISTS idx_raffle_channel_prices_raffle_id ON raffle_channel_prices (raffle_id)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_participants_step_timestamp ON participants (step, reservation_timestamp)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_participants_user_raffle_step ON participants (user_id, raffle_id, step)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_raffles_active ON raffles (active)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_participants_completion_ts ON participants (raffle_id, completion_timestamp DESC, step)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_raffle_channel_announcements_raffle_channel ON raffle_channel_announcements (raffle_id, channel_id)") + + conn.commit() + conn.close() + logger.info("Database initialized (ensuring participants.reservation_timestamp exists).") + +# --- Database Connection --- (remains the same) +def connect_db(): + conn = sqlite3.connect(DATABASE_PATH) + conn.row_factory = sqlite3.Row + return conn + +# --- Raffle Management --- (remains the same) +# ... create_raffle, end_raffle, get_raffle, etc. ... +def create_raffle_with_channel_prices(name, description, image_file_id, channels_with_prices: dict): + """ + Creates a new raffle and its associated channel prices. + channels_with_prices: A dictionary { 'channel_id_str': price_int } + """ + conn = connect_db() + cur = conn.cursor() + try: + # Insert into raffles table + cur.execute( + "INSERT INTO raffles (name, description, image_file_id) VALUES (?, ?, ?)", + (name, description, image_file_id) + ) + raffle_id = cur.lastrowid + if not raffle_id: + raise Exception("Failed to create raffle entry.") + + # Insert into raffle_channel_prices + for channel_id, price in channels_with_prices.items(): + cur.execute( + "INSERT INTO raffle_channel_prices (raffle_id, channel_id, price) VALUES (?, ?, ?)", + (raffle_id, str(channel_id), price) # Ensure channel_id is string + ) + conn.commit() + logger.info(f"Created raffle '{name}' (ID: {raffle_id}) with prices for channels: {list(channels_with_prices.keys())}") + return raffle_id + except sqlite3.IntegrityError as e: + logger.error(f"Error creating raffle: Name '{name}' likely already exists or channel price conflict. {e}") + conn.rollback() + return None + except Exception as e: + logger.error(f"Error creating raffle '{name}' with channel prices: {e}") + conn.rollback() + return None + finally: + conn.close() + +def create_raffle(name, description, price, image_file_id, channels_str): + """Creates a new raffle in the database.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + "INSERT INTO raffles (name, description, price, image_file_id, channels) VALUES (?, ?, ?, ?, ?)", + (name, description, price, image_file_id, channels_str) + ) + raffle_id = cur.lastrowid + conn.commit() + logging.info(f"Created raffle '{name}' (ID: {raffle_id}) for channels: {channels_str}") + return raffle_id + except sqlite3.IntegrityError as e: + logging.error(f"Error creating raffle: Name '{name}' likely already exists. {e}") + return None + except Exception as e: + logging.error(f"Error creating raffle '{name}': {e}") + conn.rollback() # Rollback on error + return None + finally: + conn.close() + +def end_raffle(raffle_id): + """Marks a raffle as inactive.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("UPDATE raffles SET active=0 WHERE id=? AND active=1", (raffle_id,)) + updated = conn.total_changes > 0 + conn.commit() + if updated: + logging.info(f"Ended raffle ID: {raffle_id}") + else: + logging.warning(f"Attempted to end raffle ID {raffle_id}, but it was not found or already inactive.") + return updated + except Exception as e: + logging.error(f"Error ending raffle ID {raffle_id}: {e}") + conn.rollback() + return False + finally: + conn.close() + +def get_raffle(raffle_id): + """Gets basic raffle details by ID.""" + conn = connect_db() + cur = conn.cursor() + try: + # Does not include price directly, price is per channel + cur.execute("SELECT id, name, description, image_file_id, active FROM raffles WHERE id=?", (raffle_id,)) + raffle = cur.fetchone() + return raffle + except Exception as e: + logger.error(f"Error getting raffle ID {raffle_id}: {e}") + return None + finally: + conn.close() + +def get_raffle_channels_and_prices(raffle_id): + """Gets all channel IDs and their prices for a given raffle.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT channel_id, price FROM raffle_channel_prices WHERE raffle_id=?", (raffle_id,)) + # Returns a list of Row objects, each with 'channel_id' and 'price' + return cur.fetchall() + except Exception as e: + logger.error(f"Error getting channels and prices for raffle {raffle_id}: {e}") + return [] + finally: + conn.close() + +def get_price_for_raffle_in_channel(raffle_id, channel_id): + """Gets the specific price for a raffle in a given channel.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + "SELECT price FROM raffle_channel_prices WHERE raffle_id=? AND channel_id=?", + (raffle_id, str(channel_id)) + ) + result = cur.fetchone() + return result['price'] if result else None + except Exception as e: + logger.error(f"Error getting price for raffle {raffle_id} in channel {channel_id}: {e}") + return None + finally: + conn.close() + +def get_raffle_id(name): + """Gets raffle ID by name.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT id FROM raffles WHERE name=?", (name,)) + raffle_id = cur.fetchone() + return raffle_id[0] if raffle_id else None + except Exception as e: + logging.error(f"Error getting raffle ID for name '{name}': {e}") + return None + finally: + conn.close() + +def get_raffle_channels(raffle_id): + """Gets the comma-separated channel IDs for a raffle.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT channels FROM raffles WHERE id=?", (raffle_id,)) + channels = cur.fetchone() + return channels[0] if channels else None + except Exception as e: + logging.error(f"Error getting channels for raffle ID {raffle_id}: {e}") + return None + finally: + conn.close() + +def get_active_raffles(): + """Gets all active raffles (basic info).""" + 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") + raffles = cur.fetchall() + return raffles + except Exception as e: + logger.error(f"Error getting active raffles: {e}") + return [] + finally: + conn.close() + +def get_raffle_channel_ids(raffle_id): + """Gets a list of channel IDs where the raffle is active.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT channel_id FROM raffle_channel_prices WHERE raffle_id=?", (raffle_id,)) + # Fetches a list of Row objects, extract the 'channel_id' from each + return [row['channel_id'] for row in cur.fetchall()] + except Exception as e: + logger.error(f"Error getting channel IDs for raffle {raffle_id}: {e}") + return [] + finally: + conn.close() + +def get_active_raffles_in_channel(channel_id): + """Gets active raffles available in a specific channel.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + """SELECT r.id, r.name, r.description, r.image_file_id, rcp.price + FROM raffles r + JOIN raffle_channel_prices rcp ON r.id = rcp.raffle_id + WHERE r.active=1 AND rcp.channel_id=?""", + (str(channel_id),) + ) + raffles = cur.fetchall() # List of Row objects, each including the price for that channel + return raffles + except Exception as e: + logger.error(f"Error getting active raffles for channel {channel_id}: {e}") + return [] + finally: + conn.close() + +def add_channels_to_raffle(raffle_id, channels_with_prices: dict): + """Adds new channels and their prices to an existing raffle.""" + conn = connect_db() + cur = conn.cursor() + added_channels = [] + try: + for channel_id, price in channels_with_prices.items(): + try: + cur.execute( + "INSERT INTO raffle_channel_prices (raffle_id, channel_id, price) VALUES (?, ?, ?)", + (raffle_id, str(channel_id), price) + ) + added_channels.append(str(channel_id)) + except sqlite3.IntegrityError: + logger.warning(f"Channel {channel_id} already exists for raffle {raffle_id} or other integrity error. Skipping.") + # Optionally, update the price if it already exists: + # cur.execute("UPDATE raffle_channel_prices SET price=? WHERE raffle_id=? AND channel_id=?", (price, raffle_id, str(channel_id))) + conn.commit() + logger.info(f"Added/updated channels {added_channels} with prices to raffle {raffle_id}.") + return added_channels + except Exception as e: + logger.error(f"Error adding channels to raffle {raffle_id}: {e}") + conn.rollback() + return [] + finally: + conn.close() + +def get_raffle_name(raffle_id): + """Gets raffle name by ID.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT name FROM raffles WHERE id=?", (raffle_id,)) + name = cur.fetchone() + return name[0] if name else None + except Exception as e: + logging.error(f"Error getting raffle name for ID {raffle_id}: {e}") + return None + finally: + conn.close() + +# --- Participant Management --- + +def reserve_number(user_id, user_name, raffle_id, number, origin_channel_id_str): # Added origin_channel_id + """Adds or updates a participant's reserved numbers, including origin channel.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT id, numbers FROM participants WHERE raffle_id=? AND user_id=? AND step='waiting_for_payment'", (raffle_id, user_id)) + existing_reservation = cur.fetchone() + + if existing_reservation: + participant_id = existing_reservation['id'] + numbers = existing_reservation['numbers'] + numbers_list = numbers.split(',') if numbers else [] + if str(number) not in numbers_list: + numbers_list.append(str(number)) + numbers_str = ','.join(sorted(numbers_list)) + # Update origin_channel_id as well if it's the first number added to this reservation cycle + cur.execute("UPDATE participants SET numbers=?, origin_channel_id=? WHERE id=?", + (numbers_str, origin_channel_id_str, participant_id)) + # If number already there, no change needed to numbers or origin_channel_id + else: + cur.execute( + "INSERT INTO participants (user_id, user_name, raffle_id, numbers, step, origin_channel_id) VALUES (?, ?, ?, ?, ?, ?)", + (user_id, user_name, raffle_id, str(number), "waiting_for_payment", origin_channel_id_str) + ) + conn.commit() + except Exception as e: + logger.error(f"Error reserving number {number} for user {user_id} in raffle {raffle_id} from channel {origin_channel_id_str}: {e}") + conn.rollback() + finally: + conn.close() + +def remove_reserved_number(participant_id, number): + """Removes a specific number from a 'waiting_for_payment' reservation.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT numbers FROM participants WHERE id=? AND step='waiting_for_payment'", (participant_id,)) + result = cur.fetchone() + if result and result['numbers']: + numbers = result['numbers'].split(',') + if str(number) in numbers: + numbers.remove(str(number)) + if numbers: # If list is not empty after removal + numbers_str = ','.join(sorted(numbers)) + cur.execute("UPDATE participants SET numbers=? WHERE id=?", (numbers_str, participant_id)) + else: + # If no numbers left, delete the entire reservation row + logger.info(f"No numbers left for participant {participant_id}, deleting reservation.") + cur.execute("DELETE FROM participants WHERE id=?", (participant_id,)) + conn.commit() + return True + else: + logger.warning(f"Attempted to remove number {number} from non-existent or empty reservation {participant_id}") + return False # Indicate nothing changed + except Exception as e: + logger.error(f"Error removing reserved number {number} for participant {participant_id}: {e}") + conn.rollback() + return False + finally: + conn.close() + +def mark_reservation_pending(participant_id, invoice_id, timestamp): + """Sets the invoice ID and reservation timestamp for a participant moving to pending payment.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + "UPDATE participants SET invoice_id=?, reservation_timestamp=? WHERE id=? AND step='waiting_for_payment'", + (invoice_id, int(timestamp), participant_id) + ) + conn.commit() + logger.info(f"Marked reservation pending for participant {participant_id} with invoice {invoice_id} at {timestamp}") + except Exception as e: + logger.error(f"Error marking reservation pending for participant {participant_id}: {e}") + conn.rollback() + finally: + conn.close() + +def get_expired_reservations(expiry_threshold_timestamp): + """Finds participants whose reservations have expired.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + """SELECT p.id, p.user_id, p.user_name, p.raffle_id, p.numbers, r.name as raffle_name + FROM participants p + JOIN raffles r ON p.raffle_id = r.id + WHERE p.step = 'waiting_for_payment' + AND p.reservation_timestamp IS NOT NULL + AND p.reservation_timestamp < ?""", + (int(expiry_threshold_timestamp),) + ) + expired = cur.fetchall() + return expired # Returns list of Row objects + except Exception as e: + logger.error(f"Error fetching expired reservations: {e}") + return [] + finally: + conn.close() + +def cancel_reserved_numbers(user_id, raffle_id): + """Deletes a 'waiting_for_payment' reservation for a user/raffle.""" + conn = connect_db() + cur = conn.cursor() + deleted_count = 0 + try: + cur.execute("DELETE FROM participants WHERE user_id=? AND raffle_id=? AND step='waiting_for_payment'", (user_id, raffle_id)) + deleted_count = conn.total_changes + conn.commit() + if deleted_count > 0: + logger.info(f"Cancelled reservation for user {user_id}, raffle {raffle_id}.") + else: + logger.debug(f"No reservation found to cancel for user {user_id}, raffle {raffle_id}.") + except Exception as e: + logger.error(f"Error cancelling reservation for user {user_id}, raffle {raffle_id}: {e}") + conn.rollback() + finally: + conn.close() + return deleted_count > 0 # Return True if something was deleted + +def cancel_reservation_by_id(participant_id): + """Deletes a reservation using its specific participant ID.""" + conn = connect_db() + cur = conn.cursor() + deleted_count = 0 + try: + # Ensure we only delete if it's still in the correct state + cur.execute("DELETE FROM participants WHERE id=? AND step='waiting_for_payment'", (participant_id,)) + deleted_count = conn.total_changes + conn.commit() + if deleted_count > 0: + logger.info(f"Cancelled reservation by participant ID: {participant_id}.") + else: + logger.warning(f"Attempted to cancel reservation by ID {participant_id}, but it was not found or not in 'waiting_for_payment' state.") + except Exception as e: + logger.error(f"Error cancelling reservation by ID {participant_id}: {e}") + conn.rollback() + finally: + conn.close() + return deleted_count > 0 + +def confirm_reserved_numbers(user_id, raffle_id, transaction_id): + """Confirms payment, sets step to completed, removes reservation timestamp, and sets completion_timestamp.""" + conn = connect_db() + cur = conn.cursor() + try: + current_completion_timestamp = int(time.time()) # Timestamp for completion + cur.execute( + """UPDATE participants SET + step='completed', + transaction_id=?, + reservation_timestamp=NULL, + origin_channel_id=NULL, + completion_timestamp=? + WHERE user_id=? AND raffle_id=? AND step='waiting_for_payment'""", + (transaction_id, current_completion_timestamp, user_id, raffle_id) + ) + updated_count = conn.total_changes + conn.commit() + return updated_count > 0 + except Exception as e: + logger.error(f"Error confirming reserved numbers for user {user_id}, raffle {raffle_id}: {e}") + conn.rollback() + return False + finally: + conn.close() + +def get_last_n_other_participants(raffle_id, current_participant_user_id, n=3): + """ + Gets the last N "completed" participants for a raffle, excluding the current one. + Returns a list of dictionaries [{'user_name', 'numbers'}]. + """ + conn = connect_db() + cur = conn.cursor() + participants_info = [] + try: + # Fetch N+1 recent completed participants, then filter out the current one in code + # if they are among them, ensuring we get N *other* participants. + # Order by completion_timestamp DESC. + cur.execute( + """SELECT user_name, numbers + FROM participants + WHERE raffle_id = ? AND step = 'completed' AND user_id != ? + ORDER BY completion_timestamp DESC + LIMIT ?""", + (raffle_id, current_participant_user_id, n) + ) + rows = cur.fetchall() + for row in rows: + participants_info.append({'user_name': row['user_name'], 'numbers': row['numbers']}) + # The list is already ordered newest first by the SQL query. + return participants_info # Newest other participants first + except Exception as e: + logger.error(f"Error getting last N other participants for raffle {raffle_id}: {e}") + return [] + finally: + conn.close() + + +# --- Other participant functions (get_participant, get_participants, etc.) remain largely the same --- +# Ensure they exist as in your previous version. Add them back here if needed. + +def get_participant_by_user_id_and_step(user_id, step): + conn = connect_db() + cur = conn.cursor() + try: + # Fetch origin_channel_id for payment calculation + cur.execute("SELECT id, raffle_id, numbers, user_name, origin_channel_id FROM participants WHERE user_id=? AND step=?", (user_id, step)) + participant = cur.fetchone() + return participant + except Exception as e: + logger.error(f"Error getting participant by user ID {user_id} and step {step}: {e}") + return None + finally: + conn.close() + +def get_reserved_numbers(user_id, raffle_id): + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT numbers FROM participants WHERE user_id=? AND raffle_id=? AND step='waiting_for_payment'", (user_id, raffle_id)) + numbers = cur.fetchone() + return numbers['numbers'].split(',') if numbers and numbers['numbers'] else [] + except Exception as e: + logger.error(f"Error getting reserved numbers for user {user_id}, raffle {raffle_id}: {e}") + return [] + finally: + conn.close() + +def get_participants(raffle_id): + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT id, user_id, user_name, numbers, step FROM participants WHERE raffle_id=?", (raffle_id,)) + participants = cur.fetchall() + return participants + except Exception as e: + logging.error(f"Error getting participants for raffle {raffle_id}: {e}") + return [] + finally: + conn.close() + +# Add any other necessary participant functions from your original code here +def get_participant_by_number(raffle_id, number): + conn = connect_db() + cur = conn.cursor() + try: + # Check both waiting and completed steps + cur.execute( + "SELECT id, user_id, user_name, numbers, step FROM participants WHERE raffle_id=? AND numbers LIKE ? AND (step='waiting_for_payment' OR step='completed')", + (raffle_id, f"%{number}%") + ) + # This LIKE might match partial numbers if not careful (e.g., 1 matches 10). + # A better approach is to fetch all and check in Python, or use JSON functions if SQLite version supports it. + # For simplicity, let's refine the query slightly assuming numbers are comma-separated: + cur.execute( + """SELECT id, user_id, user_name, numbers, step FROM participants + WHERE raffle_id=? AND (step='waiting_for_payment' OR step='completed') + AND (numbers = ? OR numbers LIKE ? OR numbers LIKE ? OR numbers LIKE ?)""", + (raffle_id, number, f"{number},%", f"%,{number},%", f"%,{number}") + ) + participant = cur.fetchone() # Get the first match + # Verify the number actually exists in the list + if participant and participant['numbers']: + if str(number) in participant['numbers'].split(','): + return participant + else: # False positive from LIKE, try fetching next + # This gets complex quickly. Fetching all and filtering might be easier. + # Let's assume for now the refined LIKE works reasonably well for 00-99. + return participant # Return the first match found by SQL for now + return None # No match found + except Exception as e: + logger.error(f"Error getting participant by number {number} for raffle {raffle_id}: {e}") + return None + finally: + conn.close() + +def get_raffle_by_user_id_waiting_payment(user_id): + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT raffle_id FROM participants WHERE user_id=? AND step='waiting_for_payment'", (user_id,)) + raffle_id = cur.fetchone() + return raffle_id[0] if raffle_id else None + except Exception as e: + logger.error(f"Error checking waiting payment raffle for user {user_id}: {e}") + return None + finally: + conn.close() + +def get_user_by_invoice_id(invoice_id): + conn = connect_db() + cur = conn.cursor() + try: + # No need to join with raffles for price here, get price from raffle_channel_prices + cur.execute( + """SELECT p.user_id, p.user_name, p.raffle_id, p.numbers, p.origin_channel_id + FROM participants p + WHERE p.invoice_id=? AND p.step='waiting_for_payment'""", + (invoice_id,) + ) + data = cur.fetchone() + if data: + # Fetch the price for the specific origin_channel_id + price = get_price_for_raffle_in_channel(data['raffle_id'], data['origin_channel_id']) + if price is not None: + # Convert Row to dict and add price. This makes it mutable. + participant_dict = dict(data) + participant_dict['price_per_number'] = price + return participant_dict # Return dict with price included + else: + logger.error(f"Could not fetch price for raffle {data['raffle_id']} in channel {data['origin_channel_id']} for invoice {invoice_id}") + return None + except Exception as e: + logger.error(f"Error getting user/raffle by invoice ID {invoice_id}: {e}") + return None + finally: + conn.close() + +def get_remaining_numbers_amount(raffle_id): + conn = connect_db() + cur = conn.cursor() + try: + # Count numbers from both 'waiting_for_payment' and 'completed' steps + cur.execute("SELECT numbers FROM participants WHERE raffle_id=? AND (step='waiting_for_payment' OR step='completed')", (raffle_id,)) + all_numbers_rows = cur.fetchall() + taken_count = 0 + for row in all_numbers_rows: + if row['numbers']: + try: + taken_count += len(row['numbers'].split(',')) + except: + logger.warning(f"Invalid numbers format '{row['numbers']}' while counting remaining for raffle {raffle_id}") + return 100 - taken_count + except Exception as e: + logger.error(f"Error calculating remaining numbers for raffle {raffle_id}: {e}") + return -1 # Indicate error + finally: + conn.close() + +def get_remaining_numbers(raffle_id): + """Gets a list of all remaining numbers for a raffle, formatted as two digits.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + "SELECT numbers FROM participants WHERE raffle_id=? AND (step='waiting_for_payment' OR step='completed')", + (raffle_id,) + ) + all_numbers_rows = cur.fetchall() + taken_numbers_str_set = set() # Store taken numbers as strings (potentially "01", "10", etc.) + + for row in all_numbers_rows: + if row['numbers']: # Ensure 'numbers' column is not None + # Split the comma-separated string and add individual numbers to the set + # Assumes numbers in DB are stored like "01,05,10" + taken_numbers_str_set.update(num.strip() for num in row['numbers'].split(',') if num.strip()) + + logger.debug(f"Raffle {raffle_id} - Taken numbers (string set): {taken_numbers_str_set}") + + remaining_numbers_formatted = [] + for num_int in range(100): # Iterate 0 through 99 as integers + # Format the integer as a two-digit string (e.g., 0 -> "00", 5 -> "05", 12 -> "12") + num_str_formatted = f"{num_int:02}" + + # Check if this formatted string representation is in the set of taken numbers + if num_str_formatted not in taken_numbers_str_set: + remaining_numbers_formatted.append(num_str_formatted) + + logger.debug(f"Raffle {raffle_id} - Remaining numbers formatted: {remaining_numbers_formatted}") + return remaining_numbers_formatted + except Exception as e: + logger.error(f"Error getting remaining numbers for raffle {raffle_id}: {e}") + return [] + finally: + conn.close() + +# --- Raffle Announcement Message Tracking --- +def store_announcement_message_id(raffle_id, channel_id, message_id): + conn = connect_db() + cur = conn.cursor() + current_ts = int(time.time()) + try: + # Upsert: Insert or replace if raffle_id and channel_id combo exists + cur.execute( + """INSERT INTO raffle_channel_announcements (raffle_id, channel_id, message_id, last_updated_ts) + VALUES (?, ?, ?, ?) + ON CONFLICT(raffle_id, channel_id) DO UPDATE SET + message_id = excluded.message_id, + last_updated_ts = excluded.last_updated_ts""", + (raffle_id, str(channel_id), message_id, current_ts) + ) + conn.commit() + logger.info(f"Stored/Updated announcement message_id {message_id} for raffle {raffle_id} in channel {channel_id}") + except Exception as e: + logger.error(f"Error storing announcement message_id for raffle {raffle_id}, channel {channel_id}: {e}") + conn.rollback() + finally: + conn.close() + +def get_announcement_message_id(raffle_id, channel_id): + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + "SELECT message_id FROM raffle_channel_announcements WHERE raffle_id=? AND channel_id=?", + (raffle_id, str(channel_id)) + ) + result = cur.fetchone() + return result['message_id'] if result else None + except Exception as e: + logger.error(f"Error getting announcement message_id for raffle {raffle_id}, channel {channel_id}: {e}") + return None + finally: + conn.close() \ No newline at end of file diff --git a/app/handlers.py b/app/handlers.py new file mode 100644 index 0000000..957c0f0 --- /dev/null +++ b/app/handlers.py @@ -0,0 +1,1504 @@ +import logging +import re +import uuid +import time +import random +from telegram import Update, InputMediaPhoto +from telegram.ext import ( + ContextTypes, + CallbackContext, + ConversationHandler, + CommandHandler, + MessageHandler, + CallbackQueryHandler, + filters, +) +from telegram.constants import ParseMode +from telegram.error import Forbidden, BadRequest + +from database import * +from config import * +from helpers import * +from keyboards import * + +logger = logging.getLogger(__name__) + +# --- Conversation Handler for Raffle Creation --- + +# Start command for users. Send message that they can join raffles now sending the command in the group. +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handles the /start command.""" + chat = update.message.chat + + # Ignore /start in private chats, only allow in groups + if chat.type == 'private': + await update.message.reply_text("¡Hola! Soy el bot de sorteos.\n\n" + "Para participar en un sorteo, usa el comando /sorteo en el grupo donde se anunció el sorteo.") + return + +async def new_raffle_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Starts the conversation to create a new raffle. Asks for channels.""" + user_id = update.message.from_user.id + if user_id not in ADMIN_IDS: + await update.message.reply_text("No tienes permiso para crear sorteos.") + return ConversationHandler.END + + if not CHANNELS: + await update.message.reply_text("No hay canales configurados. Añade CHANNEL_IDS al .env") + return ConversationHandler.END + + context.user_data['new_raffle'] = {'channels': set()} # Initialize data for this user + keyboard = generate_channel_selection_keyboard() + await update.message.reply_text( + "Vamos a crear un nuevo sorteo.\n\n" + "**Paso 1:** Selecciona los canales donde se publicará el sorteo. Pulsa 'Continuar' cuando termines.", + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + return SELECTING_CHANNELS + +async def select_channels(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Handles channel selection or finishing selection.""" + query = update.callback_query + await query.answer() + user_data = context.user_data.get('new_raffle', {}) + selected_channels = user_data.get('channels', set()) + + callback_data = query.data + + if callback_data == f"{SELECT_CHANNEL_PREFIX}done": + if not selected_channels: + await context.bot.send_message(chat_id=query.from_user.id, text="Debes seleccionar al menos un canal.") + return SELECTING_CHANNELS # Stay in the same state + else: + await query.edit_message_text( + "Canales seleccionados. Ahora, por favor, envía el **título** del sorteo.", + parse_mode=ParseMode.MARKDOWN + ) + return TYPING_TITLE + elif callback_data == f"{SELECT_CHANNEL_PREFIX}cancel": + await query.edit_message_text("Creación de sorteo cancelada.") + context.user_data.pop('new_raffle', None) # Clean up user data + return ConversationHandler.END + elif callback_data.startswith(SELECT_CHANNEL_PREFIX): + channel_id = callback_data[len(SELECT_CHANNEL_PREFIX):] + if channel_id in selected_channels: + selected_channels.remove(channel_id) + else: + selected_channels.add(channel_id) + + user_data['channels'] = selected_channels + keyboard = generate_channel_selection_keyboard(selected_channels) + await query.edit_message_reply_markup(reply_markup=keyboard) + return SELECTING_CHANNELS # Stay in the same state + + # Should not happen, but good practice + await context.bot.send_message(chat_id=query.from_user.id, text="Opción inválida.") + return SELECTING_CHANNELS + + +async def receive_title(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Receives the raffle title and asks for description.""" + title = update.message.text.strip() + if not title: + await update.message.reply_text("El título no puede estar vacío. Inténtalo de nuevo.") + return TYPING_TITLE + + context.user_data['new_raffle']['title'] = title + await update.message.reply_text("Título guardado. Ahora envía la **descripción** del sorteo.", parse_mode=ParseMode.MARKDOWN) + return TYPING_DESCRIPTION + +async def receive_description(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Receives the raffle description and asks for price.""" + description = update.message.text.strip() + if not description: + await update.message.reply_text("La descripción no puede estar vacía. Inténtalo de nuevo.") + return TYPING_DESCRIPTION + + context.user_data['new_raffle']['description'] = description + await update.message.reply_text("Descripción guardada. Ahora envía la **imagen** para el sorteo.", parse_mode=ParseMode.MARKDOWN) + return SENDING_IMAGE + +async def receive_image(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Receives image, then asks for prices for selected channels.""" + if not update.message.photo: + await update.message.reply_text("Por favor, envía una imagen.") + return SENDING_IMAGE + + photo_file_id = update.message.photo[-1].file_id + context.user_data['new_raffle']['image_file_id'] = photo_file_id + context.user_data['new_raffle']['channel_prices'] = {} # Initialize dict for prices + context.user_data['new_raffle']['channel_price_iterator'] = None # For iterating + + selected_channel_ids = list(context.user_data['new_raffle']['channels']) # Get the set, convert to list + if not selected_channel_ids: + await update.message.reply_text("Error: no se seleccionaron canales. Cancela y empieza de nuevo.", reply_markup=generate_confirmation_keyboard()) # Should not happen + return ConversationHandler.END + + context.user_data['new_raffle']['channel_price_iterator'] = iter(selected_channel_ids) + await _ask_next_channel_price(update, context) # Helper to ask for first/next price + return TYPING_PRICE_FOR_CHANNELS + +async def _ask_next_channel_price(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Helper to ask for price for the next channel in the iterator.""" + try: + current_channel_id = next(context.user_data['new_raffle']['channel_price_iterator']) + context.user_data['new_raffle']['current_channel_for_price'] = current_channel_id + channel_alias = REVERSE_CHANNELS.get(current_channel_id, f"ID:{current_channel_id}") + await update.message.reply_text( + f"Imagen guardada.\nAhora, introduce el precio por número para el canal: **{channel_alias}** (solo el número, ej: 5).", + parse_mode=ParseMode.MARKDOWN + ) + except StopIteration: # All channels processed + context.user_data['new_raffle'].pop('current_channel_for_price', None) + context.user_data['new_raffle'].pop('channel_price_iterator', None) + # All prices collected, move to confirmation + await _show_creation_confirmation(update, context) + return CONFIRMING_CREATION + return TYPING_PRICE_FOR_CHANNELS + +async def receive_price_for_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Receives price for the current channel, stores it, and asks for next or confirms.""" + price_text = update.message.text.strip() + current_channel_id = context.user_data['new_raffle'].get('current_channel_for_price') + + if not current_channel_id: # Should not happen + await update.message.reply_text("Error interno. Por favor, /cancelar y reintentar.") + return ConversationHandler.END + + try: + price = int(price_text) + if not (0 <= price <= 999): # Allow higher prices maybe + raise ValueError("El precio debe ser un número entre 0 y 999.") + except ValueError: + channel_alias = REVERSE_CHANNELS.get(current_channel_id, f"ID:{current_channel_id}") + await update.message.reply_text(f"Precio inválido para {channel_alias}. Debe ser un número (ej: 5). Inténtalo de nuevo.") + return TYPING_PRICE_FOR_CHANNELS # Stay in this state + + context.user_data['new_raffle']['channel_prices'][current_channel_id] = price + logger.info(f"Price for channel {current_channel_id} set to {price}") + + # Ask for the next channel's price or proceed to confirmation + next_state_or_value = await _ask_next_channel_price(update, context) + return next_state_or_value + +async def _show_creation_confirmation(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Shows the final confirmation message before creating the raffle.""" + raffle_data = context.user_data['new_raffle'] + selected_channel_ids = raffle_data.get('channels', set()) + channel_prices = raffle_data.get('channel_prices', {}) + + prices_str_parts = [] + for ch_id in selected_channel_ids: # Iterate in the order they were selected or a sorted order + alias = REVERSE_CHANNELS.get(ch_id, f"ID:{ch_id}") + price = channel_prices.get(ch_id, "N/A") + prices_str_parts.append(f"- {alias}: {price}€") + prices_display = "\n".join(prices_str_parts) if prices_str_parts else "Ninguno" + + confirmation_text = ( + "¡Perfecto! Revisa los datos del sorteo:\n\n" + f"📌 **Título:** {raffle_data.get('title', 'N/A')}\n" + f"📝 **Descripción:** {raffle_data.get('description', 'N/A')}\n" + f"💶 **Donaciones por Canal:**\n{prices_display}\n" + f"🖼️ **Imagen:** (Adjunta abajo)\n\n" + "¿Confirmas la creación de este sorteo?" + ) + keyboard = generate_confirmation_keyboard() + # Message from which confirmation is triggered is the last price input message. + # We need to send the photo with this as caption. + await context.bot.send_photo( + chat_id=update.message.chat_id, # Send to the admin's chat + photo=raffle_data['image_file_id'], + caption=confirmation_text, + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + +async def confirm_creation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + query = update.callback_query + await query.answer() + user_data = context.user_data.get('new_raffle') + + if not user_data: # Should not happen + await query.edit_message_caption("Error: No se encontraron datos. Empieza de nuevo.", reply_markup=None) + return ConversationHandler.END + + if query.data == CONFIRM_CREATION_CALLBACK: + await query.edit_message_caption("Confirmado. Creando y anunciando...", reply_markup=None) + + name = user_data.get('title') + description = user_data.get('description') + image_file_id = user_data.get('image_file_id') + channel_prices = user_data.get('channel_prices') # This is { 'channel_id': price } + + if not all([name, description, image_file_id, channel_prices]): + await context.bot.send_message(query.from_user.id, "Faltan datos. Creación cancelada.") + context.user_data.pop('new_raffle', None) + return ConversationHandler.END + + raffle_id = create_raffle_with_channel_prices(name, description, image_file_id, channel_prices) + + if raffle_id: + await context.bot.send_message(query.from_user.id, f"✅ ¡Sorteo '{name}' creado con éxito!") + # Announce in channels (needs to be adapted for price per channel) + await _announce_raffle_in_channels(context, raffle_id, query.from_user.id, initial_announcement=True) + else: + await context.bot.send_message(query.from_user.id, f"❌ Error al guardar el sorteo. Nombre '{name}' podría existir.") + + elif query.data == CANCEL_CREATION_CALLBACK: + await query.edit_message_caption("Creación cancelada.", reply_markup=None) + + context.user_data.pop('new_raffle', None) + return ConversationHandler.END + +async def cancel_creation_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Handles /cancel command during the conversation.""" + user_id = update.message.from_user.id + if 'new_raffle' in context.user_data: + context.user_data.pop('new_raffle', None) + await update.message.reply_text("Creación de sorteo cancelada.") + logger.info(f"Admin {user_id} cancelled raffle creation via /cancel.") + return ConversationHandler.END + else: + await update.message.reply_text("No hay ninguna creación de sorteo en curso para cancelar.") + return ConversationHandler.END # Or return current state if applicable + +# ... (other handlers and functions) ... + +async def incorrect_input_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """ + Handles messages that are not of the expected type in the current conversation state. + This function tries to determine which conversation (creation or edit) is active. + """ + user_id = update.message.from_user.id + current_conversation_data = None + current_state_key = None # To store the key like '_new_raffle_conv_state' + conversation_type = "desconocida" # For logging + + # Try to determine which conversation is active by checking user_data + if 'new_raffle' in context.user_data and '_new_raffle_conv_state' in context.user_data: # Assuming PTB stores state like this + current_conversation_data = context.user_data['new_raffle'] + current_state_key = '_new_raffle_conv_state' + current_state = context.user_data[current_state_key] + conversation_type = "creación de sorteo" + elif 'edit_raffle' in context.user_data and '_raffle_edit_conv_state' in context.user_data: # Check for edit state key + current_conversation_data = context.user_data['edit_raffle'] + current_state_key = '_raffle_edit_conv_state' + current_state = context.user_data[current_state_key] + conversation_type = "edición de sorteo" + else: + # Not in a known conversation, or state tracking key is different. + # This message might be outside any conversation this handler is for. + # logger.debug(f"User {user_id} sent unexpected message, but not in a tracked conversation state for incorrect_input_type.") + # For safety, if it's a fallback in a ConversationHandler, returning the current state (if known) or END is best. + # If this is a global fallback, it shouldn't interfere. + # If it's a fallback *within* a ConversationHandler, PTB should handle current state. + # For this specific function, we assume it's called as a fallback in one of the convs. + active_conv_state = context.user_data.get(ConversationHandler.STATE) # More generic way to get current state of active conv + if active_conv_state: + await update.message.reply_text( + "Entrada no válida para este paso. " + "Usa /cancelar o /cancelar_edicion si quieres salir del proceso actual." + ) + return active_conv_state # Return to the current state of the conversation + else: + # logger.debug("No active conversation detected by ConversationHandler.STATE for incorrect_input_type.") + return ConversationHandler.END # Or simply don't reply if it's truly unexpected + + + logger.warning(f"User {user_id} sent incorrect input type during {conversation_type} (State: {current_state}). Message: {update.message.text or ''}") + + # --- Handle incorrect input for RAFFLE CREATION states --- + if conversation_type == "creación de sorteo": + if current_state == SENDING_IMAGE: + await update.message.reply_text( + "Por favor, envía una IMAGEN para el sorteo, no texto u otro tipo de archivo.\n" + "Si quieres cancelar, usa /cancelar." + ) + return SENDING_IMAGE # Stay in the image sending state + elif current_state == TYPING_TITLE: + await update.message.reply_text("Por favor, envía TEXTO para el título del sorteo. Usa /cancelar para salir.") + return TYPING_TITLE + elif current_state == TYPING_DESCRIPTION: + await update.message.reply_text("Por favor, envía TEXTO para la descripción del sorteo. Usa /cancelar para salir.") + return TYPING_DESCRIPTION + elif current_state == TYPING_PRICE_FOR_CHANNELS: + channel_id_for_price = current_conversation_data.get('current_channel_for_price') + channel_alias = REVERSE_CHANNELS.get(channel_id_for_price, f"ID:{channel_id_for_price}") if channel_id_for_price else "el canal actual" + await update.message.reply_text( + f"Por favor, envía un NÚMERO para el precio del sorteo en {channel_alias}.\n" + "Usa /cancelar para salir." + ) + return TYPING_PRICE_FOR_CHANNELS + # Add more states if needed (e.g., if SELECTING_CHANNELS expects only callbacks) + + # --- Handle incorrect input for RAFFLE EDITING states --- + elif conversation_type == "edición de sorteo": + if current_state == EDIT_TYPING_PRICE_FOR_NEW_CHANNELS: + channel_id_for_price = current_conversation_data.get('current_channel_for_price') + channel_alias = REVERSE_CHANNELS.get(channel_id_for_price, f"ID:{channel_id_for_price}") if channel_id_for_price else "el nuevo canal actual" + await update.message.reply_text( + f"Por favor, envía un NÚMERO para el precio del nuevo canal {channel_alias}.\n" + "Usa /cancelar_edicion para salir." + ) + return EDIT_TYPING_PRICE_FOR_NEW_CHANNELS + # Add more states if needed for edit flow + + # Generic fallback message if specific state isn't handled above for some reason + await update.message.reply_text( + "Entrada no válida para este paso de la conversación.\n" + "Si estás creando un sorteo, usa /cancelar para salir.\n" + "Si estás editando un sorteo, usa /cancelar_edicion para salir." + ) + return current_state # Return to the state the conversation was in + + +# --- Existing Handlers (Review and Adapt if Necessary) --- + +async def enter_raffle(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handles the /sorteo command from users in groups.""" + user = update.message.from_user + chat = update.message.chat # Use chat object + chat_id = chat.id + + # Ignore command if sent in private chat directly + if chat.type == 'private': + await update.message.reply_text("Usa /sorteo en el grupo donde viste el anuncio del sorteo.") + return + + # Check if the chat ID is one of the configured channels + # This ensures /sorteo only works in the designated raffle groups + if str(chat_id) not in REVERSE_CHANNELS: # REVERSE_CHANNELS maps ID -> alias + logger.warning(f"/sorteo used in unconfigured group {chat_id} by {user.id}") + # Optionally send a message back to the group, or just ignore silently + # await update.message.reply_text("Este grupo no está configurado para sorteos.") + # Delete the user's /sorteo command + try: + await context.bot.delete_message(chat_id=chat_id, message_id=update.message.message_id) + except Exception as e: + logger.warning(f"Could not delete /sorteo command in group {chat_id}: {e}") + return + + + raffles = get_active_raffles_in_channel(chat_id) + + logger.info(f"User {user.id} ({user.username}) used /sorteo in channel {chat_id} ({REVERSE_CHANNELS.get(str(chat_id))})") + + if not raffles: + logger.info(f"No active raffles found for channel {chat_id}") + msg = await update.message.reply_text("No hay sorteos activos en este momento en este grupo.") + # Consider deleting the bot's message and the user's command after a delay + # (Requires scheduling, more complex) + # Delete the user's /sorteo command + try: + await context.bot.delete_message(chat_id=chat_id, message_id=update.message.message_id) + except Exception as e: + logger.warning(f"Could not delete /sorteo command in group {chat_id}: {e}") + return + + joinable_raffles = [] + for raffle_data in raffles: + raffle_id = raffle_data['id'] # Assuming 'id' is the key for raffle ID + remaining_count = get_remaining_numbers_amount(raffle_id) + if remaining_count > 0: + joinable_raffles.append(raffle_data) # Keep the original raffle_data object + else: + logger.info(f"Raffle ID {raffle_id} in channel {chat_id} has no remaining numbers. Skipping.") + + if not joinable_raffles: + logger.info(f"No active raffles with available numbers found for channel {chat_id} for user {user.id}.") + try: + # Inform the user that all active raffles are full + await update.message.reply_text("Todos los sorteos activos en este grupo están completos (sin números disponibles). ¡Prueba más tarde!") + except Exception as e: + logger.warning(f"Error replying/deleting for no joinable raffles in {chat_id}: {e}") + return + + keyboard = generate_raffle_selection_keyboard(chat_id) + + try: + # Send instructions to user's private chat + context.user_data['raffle_join_origin_channel_id'] = str(update.message.chat.id) + await context.bot.send_message( + user.id, + "Has iniciado el proceso para unirte a un sorteo.\n\n" + "Por favor, selecciona el sorteo al que quieres unirte:", + reply_markup=keyboard + ) + logger.info(f"Sent raffle selection keyboard to user {user.id}") + # Delete the user's /sorteo command from the group chat + try: + await context.bot.delete_message(chat_id=chat_id, message_id=update.message.message_id) + logger.info(f"Deleted /sorteo command from user {user.id} in group {chat_id}") + except Forbidden: + logger.warning(f"Cannot delete message in group {chat_id}. Bot might lack permissions.") + except Exception as e: + logger.error(f"Error deleting message {update.message.message_id} in group {chat_id}: {e}") + + except Forbidden: + logger.warning(f"Cannot send private message to user {user.id} ({user.username}). User might have blocked the bot.") + # Send a temporary message in the group tagging the user + try: + await update.message.reply_text( + f"@{user.username}, no puedo enviarte mensajes privados. " + f"Por favor, inicia una conversación conmigo [@{context.bot.username}] y vuelve a intentarlo.", + disable_notification=False # Try to notify the user + ) + except Exception as e: + logger.error(f"Failed to send block notice message in group {chat_id}: {e}") + + +async def raffle_selected(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handles user selecting a specific raffle from the private chat keyboard.""" + query = update.callback_query + await query.answer() + user_id = query.from_user.id + username = query.from_user.username or query.from_user.first_name # Use username or first name + origin_channel_id = context.user_data.get('raffle_join_origin_channel_id') + if not origin_channel_id: + await query.edit_message_text("Error: No se pudo determinar el canal de origen. Intenta /sorteo de nuevo en el grupo.") + return + + try: + raffle_id = int(query.data.split(":")[1]) + except (IndexError, ValueError): + logger.error(f"Invalid callback data received in raffle_selected: {query.data}") + await query.edit_message_text("Error: Selección de sorteo inválida.") + return + + # Prevent joining if already waiting payment for *another* raffle (optional, but good practice) + existing_waiting_raffle = get_raffle_by_user_id_waiting_payment(user_id) + if existing_waiting_raffle and existing_waiting_raffle != raffle_id: + await query.edit_message_text(f"Ya tienes una selección de números pendiente de pago para otro sorteo. Por favor, completa o cancela esa selección primero.") + return + # Prevent joining if already fully completed participation in *this* raffle (optional) + # if is_participant_in_raffle(user_id, raffle_id): # Assumes this function checks for 'completed' status + # await query.edit_message_text("Ya estás participando en este sorteo.") + # return + + + raffle_info = get_raffle(raffle_id) + + if not raffle_info or not raffle_info['active']: + logger.warning(f"User {user_id} selected inactive/invalid raffle ID {raffle_id}") + await query.edit_message_text("Este sorteo ya no está activo o no existe.") + return + + raffle_name = raffle_info["name"] + raffle_description = raffle_info["description"] + price_for_this_channel = get_price_for_raffle_in_channel(raffle_id, origin_channel_id) + image_file_id = raffle_info["image_file_id"] # Get image ID + + # Cancel any previous "waiting_for_payment" state for *this specific raffle* for this user + # This allows users to restart their number selection easily + cancel_reserved_numbers(user_id, raffle_id) + logger.info(f"Cleared any previous pending reservation for user {user_id} in raffle {raffle_id}") + + + keyboard = generate_numbers_keyboard(raffle_id, user_id, page=0) # Start at page 0 + + logger.info(f"User {user_id} ({username}) selected raffle {raffle_id} ('{raffle_name}')") + + # Edit the previous message to show raffle details and number keyboard + selection_message_text = ( + f"Has escogido el sorteo: **{raffle_name}**\n\n" + f"{raffle_description}\n\n" + f"Donación mínima: **{price_for_this_channel}€**\n\n" + f"👇 **Selecciona tus números abajo:** 👇\n" + f"(🟢=Tuyo Pagado, 🔒=Tuyo Reservado, ❌=Ocupado, ☑️=Libre)" + ) + + try: + # Try sending photo with caption first, then edit text if it fails + # Note: Editing message with media requires specific handling or might not be possible directly. + # Simplest approach: delete old message, send new one with photo + keyboard. + + # 1. Delete the old message (which just had the raffle selection buttons) + await query.delete_message() + + if image_file_id: + # If image_file_id exists, send photo with caption and keyboard + await context.bot.send_photo( + chat_id=user_id, + photo=image_file_id, + caption=selection_message_text, + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + logger.info(f"Sent photo + details + keyboard for raffle {raffle_id} to user {user_id}.") + else: + # If image_file_id is missing (NULL or empty), send text message only + logger.warning(f"Raffle {raffle_id} has no image_file_id. Sending text fallback to user {user_id}.") + await context.bot.send_message( + chat_id=user_id, + text=selection_message_text, + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + logger.info(f"Sent text details + keyboard for raffle {raffle_id} to user {user_id}.") + + except Forbidden: + logger.error(f"Forbidden: Cannot interact with user {user_id} in raffle_selected.") + # Cannot recover if blocked. + except BadRequest as e: + # Handle potential errors like message not found to delete, or bad request sending photo/text + logger.error(f"BadRequest during raffle_selected display for user {user_id}, raffle {raffle_id}: {e}") + # Attempt to send a simple text message as a last resort if deletion/sending failed + try: + await context.bot.send_message(user_id, "Hubo un error al mostrar los detalles del sorteo. Inténtalo de nuevo.") + except Exception as final_e: + logger.error(f"Also failed to send final error message to user {user_id}: {final_e}") + except Exception as e: + logger.error(f"Unexpected error in raffle_selected for user {user_id}, raffle {raffle_id}: {e}") + # Attempt to send a simple text message as a last resort + try: + await context.bot.send_message(user_id, "Ocurrió un error inesperado. Por favor, intenta de nuevo.") + except Exception as final_e: + logger.error(f"Also failed to send final error message to user {user_id}: {final_e}") + +# Handle number selection callback +async def number_callback(update: Update, context: CallbackContext): + """Handles clicks on the number buttons in the private chat.""" + query = update.callback_query + user_id = query.from_user.id + username = query.from_user.username or query.from_user.first_name + + try: + data = query.data.split(':') + action = data[0] # "number" + raffle_id = int(data[1]) + if action != "random_num": + value = data[2] # Can be number string or "next"/"prev" + else: + value = "" + origin_channel_id = context.user_data.get('raffle_join_origin_channel_id') + if not origin_channel_id: + await query.answer("Error: Canal de origen no encontrado. Reintenta /sorteo.", show_alert=True) + return + except (IndexError, ValueError): + logger.error(f"Invalid callback data in number_callback: {query.data}") + await query.answer("Error: Acción inválida.") + return + + # --- Handle Paging --- + if value == "next": + # Determine current page (requires inspecting the current keyboard, which is complex) + # Easier: Store current page in user_data or derive from button structure if possible. + # Simplest robust approach: Assume the keyboard generation knows the max pages. + # Let's try generating the next page's keyboard. + # We need the *current* page to calculate the *next* page. + # Hacky way: find the "next" button in the *current* keyboard's markup. If it exists, assume page 0. + current_page = 0 # Default assumption + if query.message.reply_markup: + for row in query.message.reply_markup.inline_keyboard: + for button in row: + if button.callback_data == f"number:{raffle_id}:prev": + current_page = 1 # If prev exists, we must be on page 1 + break + next_page = current_page + 1 + # Basic check: Assume max 2 pages (0-49, 50-99) + if next_page <= 1: + keyboard = generate_numbers_keyboard(raffle_id, user_id, page=next_page) + await query.edit_message_reply_markup(reply_markup=keyboard) + await query.answer(f"Mostrando página {next_page + 1}") + else: + await query.answer("Ya estás en la última página.") + return + + elif value == "prev": + # Similar logic to find current page. + current_page = 1 # Default assumption if prev is clicked + if query.message.reply_markup: + has_next = False + for row in query.message.reply_markup.inline_keyboard: + for button in row: + if button.callback_data == f"number:{raffle_id}:next": + has_next = True + break + if not has_next: # If no "next" button, we must be on page 1 + current_page = 1 + else: # If "next" exists, we must be on page 0 (edge case, shouldn't happen if prev was clicked) + current_page = 0 # Should logically be 1 if prev was clicked. + + prev_page = current_page - 1 + if prev_page >= 0: + keyboard = generate_numbers_keyboard(raffle_id, user_id, page=prev_page) + await query.edit_message_reply_markup(reply_markup=keyboard) + await query.answer(f"Mostrando página {prev_page + 1}") + else: + await query.answer("Ya estás en la primera página.") + return + + # --- Handle Number Selection/Deselection --- + if action == "number": + try: + number = int(value) + if not (0 <= number <= 99): + raise ValueError("Number out of range") + number_string = f"{number:02}" + # Determine page number for refresh + page = 0 if number < 50 else 1 + except ValueError: + logger.warning(f"Invalid number value in callback: {value}") + await query.answer("Número no válido.") + return + elif action == "random_num": + # Handle random number selection + try: + remaining_free_numbers = get_remaining_numbers(raffle_id) + if not remaining_free_numbers: + await query.answer("No hay números disponibles para seleccionar aleatoriamente.") + return + else: + # Select a random number from the available ones + number_string = random.choice(remaining_free_numbers) + if not (0 <= int(number_string) <= 99): + raise ValueError("Random number out of range") + page = 0 if int(number_string) < 50 else 1 + except ValueError: + logger.warning(f"Invalid random number value in callback: {value}") + await query.answer("Número aleatorio no válido.") + return + + logger.debug(f"User {user_id} interacted with number {number_string} for raffle {raffle_id}") + + # Check the status of the number + participant_data = get_participant_by_number(raffle_id, number_string) # Check anyone holding this number + + if participant_data: + participant_user_id = participant_data['user_id'] + participant_step = participant_data['step'] + participant_db_id = participant_data['id'] # The ID from the participants table + + if participant_user_id == user_id: + # User clicked a number they already interact with + if participant_step == "waiting_for_payment": + # User clicked a number they have reserved -> Deselect it + remove_reserved_number(participant_db_id, number_string) + await query.answer(f"Has quitado la reserva del número {number_string}.") + logger.info(f"User {user_id} deselected reserved number {number_string} for raffle {raffle_id}") + # Refresh keyboard + keyboard = generate_numbers_keyboard(raffle_id, user_id, page=page) + await query.edit_message_reply_markup(reply_markup=keyboard) + elif participant_step == "completed": + # User clicked a number they have paid for -> Inform them + await query.answer(f"Ya tienes el número {number_string} (pagado).") + logger.debug(f"User {user_id} clicked their own completed number {number_string}") + else: + # Should not happen with current steps, but catch just in case + await query.answer(f"Estado desconocido para tu número {number_string}.") + logger.warning(f"User {user_id} clicked number {number_string} with unexpected step {participant_step}") + + else: + # User clicked a number taken by someone else + status_msg = "reservado" if participant_step == "waiting_for_payment" else "comprado" + await query.answer(f"El número {number_string} ya ha sido {status_msg} por otro usuario.") + logger.debug(f"User {user_id} clicked number {number_string} taken by user {participant_user_id}") + + else: + # Number is free -> Reserve it for the user + reserve_number(user_id, username, raffle_id, number_string, origin_channel_id) + await query.answer(f"Número {number_string} reservado para ti. Confirma tu selección cuando termines.") + logger.info(f"User {user_id} reserved number {number_string} for raffle {raffle_id}") + # Refresh keyboard to show the lock icon + keyboard = generate_numbers_keyboard(raffle_id, user_id, page=page) + await query.edit_message_reply_markup(reply_markup=keyboard) + + +async def confirm_callback(update: Update, context: CallbackContext): + """Handles the 'Confirmar Selección' button click.""" + query = update.callback_query + user_id = query.from_user.id + + try: + raffle_id = int(query.data.split(":")[1]) + except (IndexError, ValueError): + logger.error(f"Invalid callback data received in confirm_callback: {query.data}") + await query.answer("Error: Acción inválida.") + return + + # Get numbers reserved by this user for this raffle + reserved_numbers = get_reserved_numbers(user_id, raffle_id) # Returns a list of strings + + if not reserved_numbers: + await query.answer("No has seleccionado ningún número nuevo para confirmar.") + return + + await query.answer("Procesando confirmación...") # Give feedback + + raffle_info = get_raffle(raffle_id) + if not raffle_info: + logger.error(f"Cannot find raffle {raffle_id} during confirmation for user {user_id}") + # Use query.edit_message_caption or text depending on what was sent before + try: + await query.edit_message_caption("Error: No se pudo encontrar la información del sorteo.", reply_markup=None) + except BadRequest: + await query.edit_message_text("Error: No se pudo encontrar la información del sorteo.", reply_markup=None) + return + + # Get participant DB ID (needed for setting invoice ID) + # We assume reserve_number created the row if it didn't exist + participant = get_participant_by_user_id_and_step(user_id, "waiting_for_payment") + if not participant: + logger.error(f"Cannot find participant record for user {user_id}, raffle {raffle_id} in 'waiting_for_payment' step during confirmation.") + try: + await query.edit_message_caption("Error: No se encontró tu registro. Selecciona los números de nuevo.", reply_markup=None) + except BadRequest: + await query.edit_message_text("Error: No se encontró tu registro. Selecciona los números de nuevo.", reply_markup=None) + return + participant_db_id = participant['id'] + + origin_channel_id_for_payment = participant['origin_channel_id'] + if not origin_channel_id_for_payment: + # This is a critical error, means origin_channel_id wasn't saved with participant + logger.error(f"CRITICAL: origin_channel_id missing for participant {participant['id']} during payment confirmation.") + await query.answer("Error interno al procesar el pago. Contacta al admin.", show_alert=True) + return + + price_per_number = get_price_for_raffle_in_channel(raffle_id, origin_channel_id_for_payment) + if price_per_number is None: + logger.error(f"Price not found for raffle {raffle_id} in channel {origin_channel_id_for_payment} during confirmation for user {user_id}") + await query.answer("Error: Ha habido un problema desconocido, contacta con el administrador.", show_alert=True) + return + + total_price = len(reserved_numbers) * price_per_number + + # Generate a unique invoice ID for PayPal + invoice_id = str(uuid.uuid4()) + + current_timestamp = time.time() + mark_reservation_pending(participant_db_id, invoice_id, current_timestamp) + + # Construct PayPal link + # Using _xclick for simple payments. Consider PayPal REST API for more robust integration if needed. + paypal_link = ( + f"https://www.paypal.com/cgi-bin/webscr?" + f"cmd=_xclick&business={PAYPAL_EMAIL}" + f"&item_name=Numeros Sorteo con ID: {raffle_info['id']} ({', '.join(reserved_numbers)})" # Item name for clarity + f"&amount={total_price:.2f}" # Format price to 2 decimal places + f"¤cy_code=EUR" + f"&invoice={invoice_id}" # CRITICAL: This links the payment back + f"&verify_url={WEBHOOK_URL}" # IMPORTANT: Set your actual webhook URL here! + # custom field can be used for extra data if needed, e.g., participant_db_id again + f"&custom={participant_db_id}" + f"&return=https://t.me/{BOT_NAME}" # Optional: URL after successful payment + f"&cancel_return=https://t.me/{BOT_NAME}" # Optional: URL if user cancels on PayPal + ) + + # Log the PayPal link for debugging + logger.info(f"Generated PayPal link for user {user_id}: {paypal_link}") + # Define the button text and create the URL button + paypal_button_text = "Pagar con PayPal 💶" + paypal_button = InlineKeyboardButton(paypal_button_text, url=paypal_link) + + # Create the InlineKeyboardMarkup containing the button + payment_keyboard = InlineKeyboardMarkup([[paypal_button]]) + + # Modify the message text - remove the link placeholder, adjust instruction + payment_message = ( + f"👍 **Selección Confirmada** 👍\n\n" + f"Números reservados: {', '.join(reserved_numbers)}\n" + f"Total a pagar: {total_price:.2f}€\n\n" + f"Pulsa el botón de abajo para completar el pago vía PayPal:\n" # Adjusted instruction + # Link is now in the button below + f"⚠️ Tienes {RESERVATION_TIMEOUT_MINUTES} minutos para completar el pago antes de que los números se liberen.\n\n" + f"Una vez completado el pago, el bot te notificará aquí." + ) + + # Alternative simpler link (less info, relies on user manually adding notes?) + # paypal_link = f"https://paypal.me/{PAYPAL_HANDLE}/{total_price:.2f}" + + # Remove the number keyboard, show only the payment link/info + # Try editing caption first, fall back to editing text + try: + await query.edit_message_caption( + caption=payment_message, + reply_markup=payment_keyboard, # Use the keyboard with the button + parse_mode=ParseMode.MARKDOWN # Use Markdown for formatting + ) + logger.debug(f"Edited message caption for user {user_id} with PayPal button.") + except BadRequest as e: + if "caption" in str(e).lower() or "edit" in str(e).lower() or "MESSAGE_NOT_MODIFIED" in str(e).upper(): + logger.warning(f"Failed to edit caption (Error: {e}). Falling back to edit_message_text for user {user_id}") + try: + await query.edit_message_text( + text=payment_message, + reply_markup=payment_keyboard, # Use the keyboard with the button + disable_web_page_preview=True # Preview not needed as link is in button + # No parse_mode needed + ) + logger.debug(f"Edited message text (fallback) for user {user_id} with PayPal button.") + except Exception as text_e: + logger.error(f"Failed to edit message text as fallback for user {user_id}: {text_e}") + # Send new message with the button + await context.bot.send_message(user_id, payment_message, reply_markup=payment_keyboard, disable_web_page_preview=True) + else: + logger.error(f"Unexpected BadRequest editing message for user {user_id}: {e}") + # Send new message with the button + await context.bot.send_message(user_id, payment_message, reply_markup=payment_keyboard, disable_web_page_preview=True) + except Exception as e: + logger.error(f"Unexpected error editing message for user {user_id} in confirm_callback: {e}") + # Send new message with the button + await context.bot.send_message(user_id, payment_message, reply_markup=payment_keyboard, disable_web_page_preview=True) + + + +async def cancel_callback(update: Update, context: CallbackContext): + """Handles the 'Cancelar Selección' button click.""" + query = update.callback_query + user_id = query.from_user.id + + try: + raffle_id = int(query.data.split(":")[1]) + except (IndexError, ValueError): + logger.error(f"Invalid callback data received in cancel_callback: {query.data}") + await query.answer("Error: Acción inválida.") + return + + # Get currently reserved (waiting_for_payment) numbers for this user/raffle + reserved_numbers = get_reserved_numbers(user_id, raffle_id) + + if not reserved_numbers: + await query.answer("No tienes ninguna selección de números pendiente para cancelar.") + # Optionally, revert the message back to the initial raffle selection prompt or just inform the user. + # Let's just edit the message to say nothing is pending. + try: + await query.edit_message_caption("No hay números reservados para cancelar.", reply_markup=None) + except BadRequest: # If message was text, not photo caption + await query.edit_message_text("No hay números reservados para cancelar.", reply_markup=None) + return + + # Cancel the reservation in the database + cancelled = cancel_reserved_numbers(user_id, raffle_id) # This function deletes the 'waiting_for_payment' row + + if cancelled: # Check if the function indicated success (e.g., return True or affected rows) + await query.answer(f"Selección cancelada. Números liberados: {', '.join(reserved_numbers)}") + logger.info(f"User {user_id} cancelled reservation for raffle {raffle_id}. Numbers: {reserved_numbers}") + # Edit the message to confirm cancellation + try: + await query.edit_message_caption("Tu selección de números ha sido cancelada y los números han sido liberados.", reply_markup=None) + except BadRequest: + await query.edit_message_text("Tu selección de números ha sido cancelada y los números han sido liberados.", reply_markup=None) + # Optionally, you could send the user back to the raffle selection list or number grid. + # For simplicity, we just end the interaction here. + else: + logger.error(f"Failed to cancel reservation in DB for user {user_id}, raffle {raffle_id}.") + await query.answer("Error al cancelar la reserva. Contacta con un administrador.") + # Don't change the message, let the user know there was an error + + +# --- Payment Handling (Triggered by PayPal Webhook via paypal_processor.py) --- +# The actual logic is in paypal_processor.py, which uses the database functions. +# No direct handler needed here unless you implement manual transaction ID confirmation. + + +# --- Helper Function --- (Keep get_winners in helpers.py) +# --- Image Generation --- (Keep generate_table_image in helpers.py) + +# --- Helper Function for Ending Raffle (Refactored Logic) --- + +async def _end_raffle_logic(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, winner_numbers: list[int], admin_user_id: int): + """Core logic to end a raffle, find winners, and announce.""" + raffle_details = get_raffle(raffle_id) # Gets basic info (name, description, active status) + if not raffle_details: + logger.error(f"Attempted to end non-existent raffle ID {raffle_id}") + try: + await context.bot.send_message(admin_user_id, f"Error: No se encontró el sorteo con ID {raffle_id}.") + except Exception as e: + logger.error(f"Failed to send error message to admin {admin_user_id}: {e}") + return False + + raffle_name = raffle_details['name'] + + if not raffle_details['active']: + logger.warning(f"Admin {admin_user_id} tried to end already inactive raffle '{raffle_name}' (ID: {raffle_id})") + try: + await context.bot.send_message(admin_user_id, f"El sorteo '{raffle_name}' ya estaba terminado.") + except Exception as e: + logger.error(f"Failed to send 'already inactive' message to admin {admin_user_id}: {e}") + return False + + # End the raffle in DB + if not end_raffle(raffle_id): + logger.error(f"Failed to mark raffle ID {raffle_id} as inactive in the database.") + try: + await context.bot.send_message(admin_user_id, f"Error al marcar el sorteo '{raffle_name}' como terminado en la base de datos.") + except Exception as e: + logger.error(f"Failed to send DB error message to admin {admin_user_id}: {e}") + return False + + logger.info(f"Raffle '{raffle_name}' (ID: {raffle_id}) marked as ended by admin {admin_user_id}.") + + # Get winners and format announcement + winners_str = get_winners(raffle_id, winner_numbers) + formatted_winner_numbers = ", ".join(f"{n:02}" for n in sorted(winner_numbers)) + announcement = f"🏆 **¡Resultados del Sorteo '{raffle_name}'!** 🏆\n\n" + announcement += f"Números ganadores: **{formatted_winner_numbers}**\n\n" + if winners_str: # Ensure winners_str is not empty or a "no winners" message itself + announcement += f"Ganadores:\n{winners_str}\n\n¡Felicidades!" + else: + announcement += "No hubo ganadores para estos números." + + # --- CORRECTED PART --- + # Get the list of channel IDs where this raffle was active + channel_ids_to_announce_in = get_raffle_channel_ids(raffle_id) # This returns a list of string IDs + # --- END CORRECTION --- + + success_channels_announced = [] + failed_channels_announce = [] + + if channel_ids_to_announce_in: + for channel_id_str in channel_ids_to_announce_in: + channel_alias = REVERSE_CHANNELS.get(channel_id_str, f"ID:{channel_id_str}") + try: + await context.bot.send_message(int(channel_id_str), announcement, parse_mode=ParseMode.MARKDOWN) + logger.info(f"Announced winners for raffle {raffle_id} in channel {channel_alias} (ID: {channel_id_str})") + success_channels_announced.append(channel_alias) + except Forbidden: + logger.error(f"Permission error announcing winners in channel {channel_alias} (ID: {channel_id_str}).") + failed_channels_announce.append(f"{channel_alias} (Permiso Denegado)") + except BadRequest as e: + logger.error(f"Bad request announcing winners in channel {channel_alias} (ID: {channel_id_str}): {e}") + failed_channels_announce.append(f"{channel_alias} (Error: {e.message[:30]})") + except Exception as e: + logger.error(f"Failed to announce winners in channel {channel_alias} (ID: {channel_id_str}): {e}") + failed_channels_announce.append(f"{channel_alias} (Error Desconocido)") + + # Report back to admin + msg_to_admin = f"✅ Sorteo '{raffle_name}' terminado.\n" + if success_channels_announced: + msg_to_admin += f"📢 Resultados anunciados en: {', '.join(success_channels_announced)}\n" + if failed_channels_announce: + msg_to_admin += f"⚠️ Fallo al anunciar resultados en: {', '.join(failed_channels_announce)}" + try: + await context.bot.send_message(admin_user_id, msg_to_admin, parse_mode=ParseMode.MARKDOWN) + except Exception as e: + logger.error(f"Failed to send end confirmation to admin {admin_user_id}: {e}") + else: + logger.warning(f"Raffle {raffle_id} ('{raffle_name}') ended, but no channels found associated with it in raffle_channel_prices to announce winners.") + try: + await context.bot.send_message(admin_user_id, f"✅ Sorteo '{raffle_name}' terminado. No se encontraron canales asociados para anunciar los resultados (revisar `raffle_channel_prices`).") + except Exception as e: + logger.error(f"Failed to send no-channel (end) confirmation to admin {admin_user_id}: {e}") + + return True + + +# --- Admin Menu Handlers --- + +async def admin_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handles the /menu command for admins in private chat.""" + user = update.message.from_user + chat = update.message.chat + + if user.id not in ADMIN_IDS: + # Ignore silently if not admin + return + + if chat.type != 'private': + try: + await update.message.reply_text("El comando /menu solo funciona en chat privado conmigo.") + # Optionally delete the command from the group + await context.bot.delete_message(chat.id, update.message.message_id) + except Exception as e: + logger.warning(f"Could not reply/delete admin /menu command in group {chat.id}: {e}") + return + + logger.info(f"Admin {user.id} accessed /menu") + keyboard = generate_admin_main_menu_keyboard() + await update.message.reply_text("🛠️ **Menú de Administrador** 🛠️", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) + +async def admin_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handles callbacks from the admin menus.""" + query = update.callback_query + user_id = query.from_user.id + + # Ensure the callback is from an admin + if user_id not in ADMIN_IDS: + await query.answer("No tienes permiso.", show_alert=True) + return + + await query.answer() # Acknowledge callback + data = query.data + + if data == ADMIN_MENU_CREATE: + # Guide admin to use the conversation command + await query.edit_message_text( + "Para crear un nuevo sorteo, por favor, inicia la conversación usando el comando:\n\n" + "/crear_sorteo\n\n" + "Pulsa el botón abajo para volver al menú principal.", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Volver al Menú", callback_data=ADMIN_MENU_BACK_MAIN)]]) + ) + + elif data == ADMIN_MENU_LIST: + logger.info(f"Admin {user_id} requested raffle list.") + keyboard = generate_admin_list_raffles_keyboard() + active_raffles = get_active_raffles() + message_text = "**Sorteos Activos**\n\nSelecciona un sorteo para ver detalles, anunciar o terminar:" if active_raffles else "**Sorteos Activos**\n\nNo hay sorteos activos." + await query.edit_message_text(message_text, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) + + elif data == ADMIN_MENU_BACK_MAIN: + keyboard = generate_admin_main_menu_keyboard() + await query.edit_message_text("🛠️ **Menú de Administrador** 🛠️", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) + + # --- Raffle Specific Actions --- + elif data.startswith(ADMIN_VIEW_RAFFLE_PREFIX): + try: + raffle_id = int(data[len(ADMIN_VIEW_RAFFLE_PREFIX):]) + logger.info(f"Admin {user_id} requested details for raffle {raffle_id}") + details_text = format_raffle_details(raffle_id) # Use helper + details_keyboard = generate_admin_raffle_details_keyboard(raffle_id) + await query.edit_message_text(details_text, reply_markup=details_keyboard, parse_mode=ParseMode.MARKDOWN) + except (ValueError, IndexError): + logger.error(f"Invalid callback data for view raffle: {data}") + await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_list_raffles_keyboard()) + + elif data.startswith(ADMIN_ANNOUNCE_RAFFLE_PREFIX): + try: + raffle_id = int(data[len(ADMIN_ANNOUNCE_RAFFLE_PREFIX):]) + logger.info(f"Admin {user_id} requested re-announce for raffle {raffle_id}") + await query.edit_message_text(f"📢 Re-anunciando el sorteo {get_raffle_name(raffle_id)}...", reply_markup=None) # Give feedback + await _announce_raffle_in_channels(context, raffle_id, user_id) + # Optionally go back to the list or details view after announcing + keyboard = generate_admin_list_raffles_keyboard() # Go back to list + await context.bot.send_message(user_id, "Volviendo a la lista de sorteos:", reply_markup=keyboard) + except (ValueError, IndexError): + logger.error(f"Invalid callback data for announce raffle: {data}") + await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_list_raffles_keyboard()) + + elif data.startswith(ADMIN_END_RAFFLE_PROMPT_PREFIX): + try: + raffle_id = int(data[len(ADMIN_END_RAFFLE_PROMPT_PREFIX):]) + except (ValueError, IndexError): + logger.error(f"Invalid callback data for end raffle prompt: {data}") + await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_main_menu_keyboard()) + return + + raffle = get_raffle(raffle_id) + if not raffle or not raffle['active']: + await query.edit_message_text("Este sorteo no existe o ya ha terminado.", reply_markup=generate_admin_list_raffles_keyboard()) + return + + # Store raffle ID and set flag to expect winner numbers + context.user_data['admin_ending_raffle_id'] = raffle_id + context.user_data['expecting_winners_for_raffle'] = raffle_id # Store ID for context check + logger.info(f"Admin {user_id} prompted to end raffle {raffle_id} ('{raffle['name']}'). Expecting winner numbers.") + + keyboard = generate_admin_cancel_end_keyboard() + await query.edit_message_text( + f"Vas a terminar el sorteo: **{raffle['name']}**\n\n" + "Por favor, envía ahora los **números ganadores** separados por espacios (ej: `7 23 81`).", + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + elif data == ADMIN_CANCEL_END_PROCESS: + # Clear the flags + context.user_data.pop('admin_ending_raffle_id', None) + context.user_data.pop('expecting_winners_for_raffle', None) + logger.info(f"Admin {user_id} cancelled the raffle end process.") + # Go back to the raffle list + keyboard = generate_admin_list_raffles_keyboard() + await query.edit_message_text("**Sorteos Activos**\n\nSelecciona un sorteo para terminarlo:", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) + + elif data == ADMIN_NO_OP: + # Just ignore clicks on placeholder buttons like "No hay sorteos activos" + pass + +async def admin_receive_winner_numbers(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handles the text message containing winner numbers from admin.""" + user_id = update.message.from_user.id + chat_id = update.message.chat_id + + # Basic checks: admin, private chat, expecting numbers + if user_id not in ADMIN_IDS or update.message.chat.type != 'private': + return # Ignore irrelevant messages + + expecting_raffle_id = context.user_data.get('expecting_winners_for_raffle') + ending_raffle_id = context.user_data.get('admin_ending_raffle_id') + + # Check if we are actually expecting numbers for the specific raffle ID + if not expecting_raffle_id or expecting_raffle_id != ending_raffle_id or not ending_raffle_id: + # Not expecting input, or state mismatch. Could be a normal message. + # logger.debug(f"Received text from admin {user_id} but not expecting winners or mismatch.") + return + + logger.info(f"Admin {user_id} submitted winner numbers for raffle {ending_raffle_id}: {update.message.text}") + + # Parse and validate winner numbers + try: + numbers_text = update.message.text.strip() + if not numbers_text: + raise ValueError("Input is empty.") + # Split by space, convert to int, filter range, remove duplicates, sort + winner_numbers = sorted(list(set( + int(n) for n in numbers_text.split() if n.isdigit() and 0 <= int(n) <= 99 + ))) + if not winner_numbers: + raise ValueError("No valid numbers between 0 and 99 found.") + except ValueError as e: + logger.warning(f"Invalid winner numbers format from admin {user_id}: {e}") + keyboard = generate_admin_cancel_end_keyboard() + await update.message.reply_text( + f"❌ Números inválidos: {e}\n\n" + "Por favor, envía los números ganadores (0-99) separados por espacios (ej: `7 23 81`).", + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + # Keep expecting input + return + + # Clear the expectation flags *before* processing + raffle_id_to_end = ending_raffle_id # Store locally before clearing + context.user_data.pop('expecting_winners_for_raffle', None) + context.user_data.pop('admin_ending_raffle_id', None) + + # Call the refactored ending logic + await update.message.reply_text(f"Procesando finalización con números: {', '.join(f'{n:02}' for n in winner_numbers)}...") + success = await _end_raffle_logic(context, raffle_id_to_end, winner_numbers, user_id) + + # Send admin back to the main menu after processing + keyboard = generate_admin_main_menu_keyboard() + await context.bot.send_message(chat_id=user_id, text="Volviendo al Menú Principal...", reply_markup=keyboard) + +async def _announce_raffle_in_channels(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, admin_user_id: int, initial_announcement: bool = False): + """Fetches raffle details and sends announcement to its configured channels.""" + raffle = get_raffle(raffle_id) + if not raffle: + logger.warning(f"Admin {admin_user_id} tried to announce non-existent raffle {raffle_id}.") + try: + await context.bot.send_message(admin_user_id, f"Error: El sorteo ID {raffle_id} no existe.") + except Exception as e: + logger.error(f"Failed to send 'raffle not found' error to admin {admin_user_id}: {e}") + return False + if not initial_announcement and not raffle['active']: # For re-announcements, it must be active + logger.warning(f"Admin {admin_user_id} tried to re-announce inactive raffle {raffle_id} ('{raffle['name']}').") + try: + await context.bot.send_message(admin_user_id, f"Error: El sorteo '{raffle['name']}' (ID {raffle_id}) no está activo y no se puede re-anunciar.") + except Exception as e: + logger.error(f"Failed to send 'raffle inactive' error to admin {admin_user_id}: {e}") + return False + + raffle_name = raffle['name'] + image_file_id = raffle['image_file_id'] + raffle_description = raffle['description'] # Get description for caption + + channels_and_prices = get_raffle_channels_and_prices(raffle_id) # List of {'channel_id', 'price'} + + if not channels_and_prices: + logger.warning(f"Admin {admin_user_id} tried to announce raffle {raffle_id} ('{raffle_name}') but it has no channels configured.") + try: + await context.bot.send_message(admin_user_id, f"El sorteo '{raffle_name}' no tiene canales asignados para anunciar.") + except Exception as e: + logger.error(f"Failed to send no-channel info to admin {admin_user_id}: {e}") + return False + + + success_channels_sent = [] + success_channels_pinned = [] + failed_channels_send = [] + failed_channels_pin = [] + + # Get remaining numbers ONCE before the loop for efficiency + remaining_count = get_remaining_numbers_amount(raffle_id) + + logger.info(f"Admin {admin_user_id} initiating {'initial ' if initial_announcement else 're-'}announcement for raffle {raffle_id} ('{raffle_name}')") + + for item in channels_and_prices: + channel_id_str = item['channel_id'] + price_for_this_channel = item['price'] + channel_alias = REVERSE_CHANNELS.get(channel_id_str, f"ID:{channel_id_str}") + + announce_caption = ( + f"🎉 **¡{'Nuevo ' if initial_announcement else ''}Sorteo Disponible!** 🎉\n\n" + f"✨ **{raffle_name}** ✨\n\n" + f"{raffle_description}\n\n" + f"💰 **Donación mínima:** {price_for_this_channel}€\n" + f"🔢 **Números disponibles:** {remaining_count if remaining_count >= 0 else 'N/A'}\n\n" + f"Normas y condiciones: {TYC_URL} \n\n" + f"👇 ¡Pulsa /sorteo en este chat para participar! 👇" + ) + + message_args = {"parse_mode": ParseMode.MARKDOWN} + if image_file_id: + message_args["photo"] = image_file_id + message_args["caption"] = announce_caption + send_method = context.bot.send_photo + else: + message_args["text"] = announce_caption + send_method = context.bot.send_message + + sent_message = None # Initialize sent_message variable + try: + # --- 1. Send the message --- + sent_message = await send_method(chat_id=int(channel_id_str), **message_args) + success_channels_sent.append(channel_alias) + logger.info(f"Announcement sent to channel {channel_alias} (ID: {channel_id_str}) for raffle {raffle_id}.") + + # --- 2. Attempt to pin the sent message --- + try: + # Disable notification for re-announcements or if initial is false + # Enable notification for the very first announcement. + disable_pin_notification = not initial_announcement + await context.bot.pin_chat_message( + chat_id=int(channel_id_str), + message_id=sent_message.message_id, + disable_notification=disable_pin_notification + ) + success_channels_pinned.append(channel_alias) + logger.info(f"Pinned announcement message {sent_message.message_id} in channel {channel_alias}.") + except Forbidden as pin_e_forbidden: + logger.warning(f"Could not pin message in channel {channel_alias} (Forbidden): {pin_e_forbidden}") + failed_channels_pin.append(f"{channel_alias} (Permiso de Fijar Denegado)") + except BadRequest as pin_e_bad_request: + logger.warning(f"Could not pin message in channel {channel_alias} (Bad Request, e.g. no messages to pin): {pin_e_bad_request}") + failed_channels_pin.append(f"{channel_alias} (Error al Fijar)") + except Exception as pin_e: + logger.warning(f"Could not pin message {sent_message.message_id if sent_message else 'N/A'} in channel {channel_alias}: {pin_e}") + failed_channels_pin.append(f"{channel_alias} (Error al Fijar Desconocido)") + + except Forbidden: + logger.error(f"Permission error: Cannot send announcement to channel {channel_alias} (ID: {channel_id_str}).") + failed_channels_send.append(f"{channel_alias} (Permiso de Envío Denegado)") + except BadRequest as e: + logger.error(f"Bad request sending announcement to channel {channel_alias} (ID: {channel_id_str}): {e}") + failed_channels_send.append(f"{channel_alias} (Error de Envío: {e.message[:30]}...)") # Shorten error + except Exception as e: + logger.error(f"Failed to send announcement to channel {channel_alias} (ID: {channel_id_str}): {e}") + failed_channels_send.append(f"{channel_alias} (Error de Envío Desconocido)") + + + # Report back to admin + msg_to_admin = f"📢 Resultados del {'anuncio inicial' if initial_announcement else 're-anuncio'} para '{raffle_name}':\n\n" + if success_channels_sent: + msg_to_admin += f"✅ Enviado con éxito a: {', '.join(success_channels_sent)}\n" + if failed_channels_send: + msg_to_admin += f"❌ Fallo al enviar a: {', '.join(failed_channels_send)}\n" + + if success_channels_sent: # Only report on pinning if messages were sent + if success_channels_pinned: + msg_to_admin += f"📌 Fijado con éxito en: {', '.join(success_channels_pinned)}\n" + if failed_channels_pin: + msg_to_admin += f"⚠️ Fallo al fijar en: {', '.join(failed_channels_pin)}\n" + if not success_channels_pinned and not failed_channels_pin and success_channels_sent: # All sent, no pins attempted or all failed silently + pass # Avoid saying "no pins" if none were expected or all silently failed without specific error + + if not success_channels_sent and not failed_channels_send: + msg_to_admin += "No se procesó ningún canal (esto no debería ocurrir si la lista de canales no estaba vacía)." + + + try: + await context.bot.send_message(admin_user_id, msg_to_admin, parse_mode=ParseMode.MARKDOWN) + except Exception as e: + logger.error(f"Failed to send announcement summary to admin {admin_user_id}: {e}") + + return True + +## Edit commands + +async def admin_edit_select_raffle(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Admin selects a raffle to edit (add channels).""" + query = update.callback_query # Assuming this comes from a button click + user_id = query.from_user.id + await query.answer() + + active_raffles = get_active_raffles() # Gets basic info + if not active_raffles: + await query.edit_message_text("No hay sorteos activos para editar.", reply_markup=generate_admin_main_menu_keyboard()) + return ConversationHandler.END + + buttons = [] + for r in active_raffles: + buttons.append([InlineKeyboardButton(r['name'], callback_data=f"{ADMIN_EDIT_RAFFLE_PREFIX}{r['id']}")]) + buttons.append([InlineKeyboardButton("⬅️ Volver al Menú", callback_data=ADMIN_MENU_BACK_MAIN)]) + await query.edit_message_text("Selecciona el sorteo al que quieres añadir canales:", reply_markup=InlineKeyboardMarkup(buttons)) + return EDIT_SELECT_RAFFLE # This state waits for a raffle selection callback + +async def admin_edit_selected_raffle(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Raffle selected for editing. Now ask for new channels.""" + query = update.callback_query + await query.answer() + raffle_id_to_edit = int(query.data[len(ADMIN_EDIT_RAFFLE_PREFIX):]) + context.user_data['edit_raffle'] = { + 'raffle_id': raffle_id_to_edit, + 'new_channels': set(), + 'new_channel_prices': {} + } + + raffle_name = get_raffle_name(raffle_id_to_edit) + existing_channel_ids = get_raffle_channel_ids(raffle_id_to_edit) # Returns list of strings + existing_aliases = [REVERSE_CHANNELS.get(ch_id, f"ID:{ch_id}") for ch_id in existing_channel_ids] + + message = f"Editando sorteo: **{raffle_name}**\n" + message += f"Canales actuales: {', '.join(existing_aliases) or 'Ninguno'}\n\n" + message += "Selecciona los **nuevos** canales a añadir:" + + keyboard = generate_channel_selection_keyboard_for_edit(set(existing_channel_ids)) # Pass existing as set + await query.edit_message_text(message, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) + return EDIT_SELECT_NEW_CHANNELS + +async def admin_edit_select_new_channels(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Handles selection of new channels to add during edit.""" + query = update.callback_query + await query.answer() + edit_data = context.user_data['edit_raffle'] + selected_new_channels = edit_data['new_channels'] + existing_channel_ids = set(get_raffle_channel_ids(edit_data['raffle_id'])) + + callback_data = query.data + + if callback_data == f"{SELECT_CHANNEL_PREFIX}done": + if not selected_new_channels: + await context.bot.send_message(query.from_user.id, "Debes seleccionar al menos un nuevo canal para añadir.") + return EDIT_SELECT_NEW_CHANNELS + else: + # Proceed to ask prices for these new channels + edit_data['channel_price_iterator'] = iter(list(selected_new_channels)) + await _ask_next_price_for_edit(query.message, context) # Use query.message to send reply + return EDIT_TYPING_PRICE_FOR_NEW_CHANNELS + + elif callback_data == f"{SELECT_CHANNEL_PREFIX}cancel_edit": + await query.edit_message_text("Edición de sorteo cancelada.") + context.user_data.pop('edit_raffle', None) + return ConversationHandler.END # Or back to main admin menu + + elif callback_data.startswith(SELECT_CHANNEL_PREFIX): + channel_id = callback_data[len(SELECT_CHANNEL_PREFIX):] + if channel_id in selected_new_channels: + selected_new_channels.remove(channel_id) + else: + selected_new_channels.add(channel_id) + edit_data['new_channels'] = selected_new_channels + keyboard = generate_channel_selection_keyboard_for_edit(existing_channel_ids, selected_new_channels) + await query.edit_message_reply_markup(reply_markup=keyboard) + return EDIT_SELECT_NEW_CHANNELS + +async def _ask_next_price_for_edit(message_to_reply_to, context: ContextTypes.DEFAULT_TYPE): + """Helper to ask price for the next new channel during edit.""" + edit_data = context.user_data['edit_raffle'] + try: + current_new_channel_id = next(edit_data['channel_price_iterator']) + edit_data['current_channel_for_price'] = current_new_channel_id # Re-use this key + channel_alias = REVERSE_CHANNELS.get(current_new_channel_id, f"ID:{current_new_channel_id}") + await message_to_reply_to.reply_text( # Use reply_text from the message object + f"Introduce el precio por número para el nuevo canal: **{channel_alias}** (ej: 5).", + parse_mode=ParseMode.MARKDOWN + ) + except StopIteration: # All new channels processed + edit_data.pop('current_channel_for_price', None) + edit_data.pop('channel_price_iterator', None) + # All prices for new channels collected, move to edit confirmation + await _show_edit_confirmation(message_to_reply_to, context) + return EDIT_CONFIRM + return EDIT_TYPING_PRICE_FOR_NEW_CHANNELS + +async def admin_edit_receive_price_for_new_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Receives price for a new channel during edit.""" + price_text = update.message.text.strip() + edit_data = context.user_data['edit_raffle'] + current_new_channel_id = edit_data.get('current_channel_for_price') + + # ... (validation for price, same as creation) ... + try: + price = int(price_text) + if not (0 <= price <= 999): raise ValueError("Price out of range.") + except ValueError: + await update.message.reply_text("Precio inválido. Inténtalo de nuevo.") + return EDIT_TYPING_PRICE_FOR_NEW_CHANNELS + + edit_data['new_channel_prices'][current_new_channel_id] = price + logger.info(f"Edit: Price for new channel {current_new_channel_id} set to {price}") + + next_state_or_value = await _ask_next_price_for_edit(update.message, context) + return next_state_or_value + +async def _show_edit_confirmation(message_to_reply_to, context: ContextTypes.DEFAULT_TYPE): + """Shows final confirmation for editing the raffle.""" + edit_data = context.user_data['edit_raffle'] + raffle_name = get_raffle_name(edit_data['raffle_id']) + new_channel_prices = edit_data.get('new_channel_prices', {}) + + prices_str_parts = [] + for ch_id, price in new_channel_prices.items(): + alias = REVERSE_CHANNELS.get(ch_id, f"ID:{ch_id}") + prices_str_parts.append(f"- {alias}: {price}€") + new_prices_display = "\n".join(prices_str_parts) if prices_str_parts else "Ninguno" + + confirmation_text = ( + f"✏️ **Confirmar Cambios para Sorteo: {raffle_name}** ✏️\n\n" + f"Se añadirán los siguientes canales con sus precios:\n{new_prices_display}\n\n" + "¿Confirmas estos cambios? El sorteo se anunciará en los nuevos canales." + ) + keyboard = InlineKeyboardMarkup([ # Simpler Yes/No for edit + [InlineKeyboardButton("✅ Sí, Guardar y Anunciar", callback_data="confirm_edit_action")], + [InlineKeyboardButton("❌ No, Cancelar Edición", callback_data="cancel_edit_action")] + ]) + # Use message_to_reply_to.reply_text or send_message as appropriate. + # For simplicity, let's send a new message for confirmation + await context.bot.send_message( + chat_id=message_to_reply_to.chat_id, + text=confirmation_text, + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + + +async def admin_confirm_edit_action(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Handles final Yes/No for edit confirmation.""" + query = update.callback_query + await query.answer() + edit_data = context.user_data.get('edit_raffle') + + if not edit_data: + await query.edit_message_text("Error: No hay datos de edición. Cancela y reintenta.", reply_markup=None) + return ConversationHandler.END + + if query.data == "confirm_edit_action": + await query.edit_message_text("Guardando cambios y anunciando en nuevos canales...", reply_markup=None) + + raffle_id = edit_data['raffle_id'] + new_channel_prices_to_add = edit_data['new_channel_prices'] # { 'channel_id': price } + + if not new_channel_prices_to_add: + await context.bot.send_message(query.from_user.id, "No se seleccionaron nuevos canales para añadir.") + else: + added_db_channels = add_channels_to_raffle(raffle_id, new_channel_prices_to_add) + if added_db_channels: + await context.bot.send_message(query.from_user.id, f"Canales {', '.join(added_db_channels)} añadidos con sus precios.") + # Announce ONLY in the newly added channels + # We need a way to announce to specific channels if the helper is general + # For now, let's assume _announce_raffle_in_channels can be adapted or a new one is made + # Here, we'll just log for simplicity of this example + logger.info(f"TODO: Announce raffle {raffle_id} in newly added channels: {added_db_channels}") + # Simple announcement for now: + raffle_name_for_announce = get_raffle_name(raffle_id) + raffle_basic_info = get_raffle(raffle_id) # image_id, description + for ch_id in added_db_channels: + price = new_channel_prices_to_add[ch_id] + caption = ( f"🎉 **¡Sorteo '{raffle_name_for_announce}' ahora disponible en este canal!** 🎉\n\n" + f"{raffle_basic_info['description']}\n\n" + f"💰 **Donación mínima:** {price}€\n" + f"👇 ¡Pulsa /sorteo para participar! 👇") + try: + if raffle_basic_info['image_file_id']: + await context.bot.send_photo(int(ch_id), raffle_basic_info['image_file_id'], caption=caption, parse_mode=ParseMode.MARKDOWN) + else: + await context.bot.send_message(int(ch_id), caption, parse_mode=ParseMode.MARKDOWN) + except Exception as e: + logger.error(f"Error announcing edited raffle in new channel {ch_id}: {e}") + else: + await context.bot.send_message(query.from_user.id, "No se pudieron añadir los nuevos canales (posiblemente ya existían o error de DB).") + + elif query.data == "cancel_edit_action": + await query.edit_message_text("Edición cancelada.", reply_markup=None) + + context.user_data.pop('edit_raffle', None) + return ConversationHandler.END + +# Ensure cancel_edit_command correctly clears 'edit_raffle' from user_data +async def cancel_edit_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if 'edit_raffle' in context.user_data: + context.user_data.pop('edit_raffle') + await update.message.reply_text("Edición de sorteo cancelada.") + return ConversationHandler.END + # Fallback to check raffle creation cancellation + return await cancel_creation_command(update, context) diff --git a/app/helpers.py b/app/helpers.py new file mode 100644 index 0000000..0e35d84 --- /dev/null +++ b/app/helpers.py @@ -0,0 +1,293 @@ +import logging +import requests +from database import * # Import all DB functions +from config import * # Import constants if needed (like BOT_TOKEN for direct API calls, although better passed) +from PIL import Image, ImageDraw, ImageFont +import os + +logger = logging.getLogger(__name__) + +def format_raffle_details(raffle_id): + """Fetches and formats raffle details for display, including multi-channel prices.""" + raffle = get_raffle(raffle_id) # Fetches basic info from 'raffles' table + if not raffle: + return "Error: No se encontró el sorteo." + + details = ( + f"ℹ️ **Detalles del Sorteo** ℹ️\n\n" + f"**ID:** `{raffle['id']}`\n" + f"**Nombre:** {raffle['name']}\n" + f"**Descripción:**\n{raffle['description']}\n\n" + f"**Activo:** {'Sí' if raffle['active'] else 'No (Terminado)'}\n" + ) + + # Get and Format Channels and their Prices + channels_and_prices = get_raffle_channels_and_prices(raffle_id) # List of Row objects {'channel_id', 'price'} + + if channels_and_prices: + details += "**Canales y Precios:**\n" + for item in channels_and_prices: + channel_id_str = item['channel_id'] + price = item['price'] + channel_alias = REVERSE_CHANNELS.get(str(channel_id_str), f"ID:{channel_id_str}") # Ensure lookup with string ID + details += f"- {channel_alias}: {price}€\n" + details += "\n" # Add a newline after the list + else: + details += "**Canales y Precios:** Ninguno asignado\n\n" + + + # Image ID (optional display) + if raffle['image_file_id']: + details += f"**ID Imagen:** (Presente)\n" + else: + details += f"**ID Imagen:** (No asignada)\n" + + # Add participant count and remaining numbers + participants = get_participants(raffle_id) # Fetches list of Rows + completed_participants_count = 0 + # pending_participants_count = 0 # If you want to show pending + if participants: # Check if participants list is not None or empty + completed_participants_count = sum(1 for p in participants if p['step'] == 'completed') + # pending_participants_count = sum(1 for p in participants if p['step'] == 'waiting_for_payment') + + + details += f"\n**Participantes Confirmados:** {completed_participants_count}\n" + # details += f"**Reservas Pendientes:** {pending_participants_count}\n" + + remaining_count = get_remaining_numbers_amount(raffle_id) + details += f"**Números Disponibles:** {remaining_count if remaining_count >= 0 else 'Error al calcular'}\n" + + return details + +def build_raffle_announcement_caption(raffle_id): + """Builds the standard announcement caption text.""" + raffle = get_raffle(raffle_id) + if not raffle: + return None + + remaining_count = get_remaining_numbers_amount(raffle_id) + + caption = ( + f"🎉 **¡Sorteo Activo!** 🎉\n\n" + f"✨ **{raffle['name']}** ✨\n\n" + f"{raffle['description']}\n\n" + f"💰 **Donación mínima:** {raffle['price']}€\n" + f"🔢 **Números disponibles:** {remaining_count if remaining_count >= 0 else 'N/A'}\n\n" + f"👇 ¡Pulsa /sorteo en este chat para participar! 👇" + ) + return caption + +def get_winners(raffle_id, winner_numbers_int): + """Finds winners based on chosen numbers.""" + participants = get_participants(raffle_id) # Gets all participants for the raffle + winners = {} # { user_name: [list_of_winning_numbers_they_had] } + + if not participants: + return "" # No participants, no winners + + winner_numbers_set = set(winner_numbers_int) + + for participant in participants: + # Only consider completed participations as potential winners + if participant['step'] != 'completed' or not participant['numbers']: + continue + + user_id = participant['user_id'] + user_name = escape_markdown_v2_chars_for_username(participant['user_name']) or f"User_{user_id}" # Fallback name + numbers_str = participant['numbers'] + + try: + participant_numbers_set = {int(n) for n in numbers_str.split(',') if n.isdigit()} + except ValueError: + logger.warning(f"Invalid number format for participant {user_id} in raffle {raffle_id}: {numbers_str}") + continue # Skip participant with bad data + + # Find the intersection + won_numbers = winner_numbers_set.intersection(participant_numbers_set) + + if won_numbers: + # Store the winning numbers (as strings, sorted) for this user + won_numbers_str_sorted = sorted([f"{n:02}" for n in won_numbers]) + if user_name not in winners: + winners[user_name] = [] + winners[user_name].extend(won_numbers_str_sorted) # Add potentially multiple matches + + if not winners: + return "No hubo ganadores con esos números." + + # Format the output string + winners_message_parts = [] + for user_name, numbers in winners.items(): + # Ensure numbers are unique in the final output per user + unique_numbers_str = ", ".join(sorted(list(set(numbers)))) + winners_message_parts.append(f"- @{escape_markdown_v2_chars_for_username(user_name)} acertó: **{unique_numbers_str}**") + + return "\n".join(winners_message_parts) + + +def generate_table_image(raffle_id): + """Generates the 10x10 grid image showing number status.""" + # Define image parameters + cols, rows = 10, 10 + cell_width, cell_height = 120, 50 + title_height_space = 70 + image_width = cols * cell_width + image_height = rows * cell_height + title_height_space + background_color = "white" + line_color = "black" + font_size = 16 + title_font_size = 24 + + # Create image + img = Image.new("RGB", (image_width, image_height), background_color) + draw = ImageDraw.Draw(img) + + # Load fonts (handle potential errors) + try: + # Ensure the font file exists or provide a fallback path + font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" # Example Linux path + if not os.path.exists(font_path): + font_path = "arial.ttf" # Try common Windows font + font = ImageFont.truetype(font_path, font_size) + title_font = ImageFont.truetype(font_path, title_font_size) + except IOError: + logger.warning("Specific font not found, using default PIL font.") + font = ImageFont.load_default() + # Adjust size for default font if needed, default doesn't take size arg directly + # title_font = ImageFont.truetype(font_path, title_font_size) # Need a default large font method + title_font = ImageFont.load_default() # Revert to default for title too for simplicity + + # Draw Title + raffle_details = get_raffle(raffle_id) + if not raffle_details: + logger.error(f"Cannot generate image: Raffle {raffle_id} not found.") + # Draw error message on image + draw.text((10, 10), f"Error: Sorteo {raffle_id} no encontrado", fill="red", font=title_font) + img.save(f"/app/data/raffles/raffle_table_{raffle_id}_error.png") + return False # Indicate failure + + raffle_name = raffle_details['name'] + title_text = f"Sorteo: {raffle_name}" + # Calculate text bounding box for centering + try: + # Use textbbox for more accurate centering + title_bbox = draw.textbbox((0, 0), title_text, font=title_font) + title_width = title_bbox[2] - title_bbox[0] + # title_height = title_bbox[3] - title_bbox[1] # Not needed for x centering + title_x = (image_width - title_width) / 2 + title_y = 10 # Padding from top + draw.text((title_x, title_y), title_text, fill=line_color, font=title_font) + except AttributeError: # Handle older PIL versions that might not have textbbox + # Fallback using textlength (less accurate) + title_width = draw.textlength(title_text, font=title_font) + title_x = (image_width - title_width) / 2 + title_y = 10 + draw.text((title_x, title_y), title_text, fill=line_color, font=title_font) + + + # Get participant data + participants = get_participants(raffle_id) + number_status = {} # { num_int: (user_name, status_color) } + + if participants: + for p in participants: + if not p['numbers'] or p['step'] not in ['waiting_for_payment', 'completed']: + continue + user_name = p['user_name'] or f"User_{p['user_id']}" # Fallback name + status_color = "red" if p['step'] == 'waiting_for_payment' else "black" # Red=Reserved, Black=Completed + + try: + nums = {int(n) for n in p['numbers'].split(',') if n.isdigit()} + for num in nums: + if 0 <= num <= 99: + number_status[num] = (user_name, status_color) + except ValueError: + logger.warning(f"Skipping invalid numbers '{p['numbers']}' for user {p['user_id']} in image generation.") + continue + + # Draw Grid and Fill Numbers + for i in range(rows): + for j in range(cols): + num = i * cols + j + x1 = j * cell_width + y1 = i * cell_height + title_height_space + x2 = x1 + cell_width + y2 = y1 + cell_height + + # Draw cell rectangle + draw.rectangle([x1, y1, x2, y2], outline=line_color) + + # Prepare text and color + number_text = f"{num:02}" + text_fill = "blue" # Default color for free numbers + owner_text = "" + + if num in number_status: + owner, status_color = number_status[num] + text_fill = status_color + # Truncate long usernames + max_name_len = 12 + owner_text = owner[:max_name_len] + ('…' if len(owner) > max_name_len else '') + + + # Position text within the cell + text_x = x1 + 10 # Padding from left + text_y_num = y1 + 5 # Padding for number line + text_y_owner = y1 + 5 + font_size + 2 # Padding for owner line (below number) + + draw.text((text_x, text_y_num), number_text, fill=text_fill, font=font) + if owner_text: + draw.text((text_x, text_y_owner), owner_text, fill=text_fill, font=font) + + # Ensure data directory exists + os.makedirs("/app/data/raffles", exist_ok=True) + + # Save the image + image_path = f"/app/data/raffles/raffle_table_{raffle_id}.png" + try: + img.save(image_path) + logger.info(f"Generated raffle table image: {image_path}") + return True # Indicate success + except Exception as e: + logger.error(f"Failed to save raffle table image {image_path}: {e}") + return False # Indicate failure + +def escape_markdown_v2_chars_for_username(text: str) -> str: + """Escapes characters for MarkdownV2, specifically for usernames.""" + # For usernames, usually only _ and * are problematic if not part of actual formatting + # Other MarkdownV2 special characters: `[` `]` `(` `)` `~` `>` `#` `+` `-` `=` `|` `{` `}` `.` `!` + # We are most concerned with _ in @user_name context. + # A more comprehensive list of characters to escape for general text: + # escape_chars = r'_*[]()~`>#+-=|{}.!' + # For just usernames in this context, focus on what breaks @user_name + escape_chars = r'_*`[' # Adding ` and [ just in case they appear in odd usernames + + # Python's re.escape escapes all non-alphanumerics. + # We only want to escape specific markdown control characters within the username. + # For usernames, simply escaping '_' is often enough for the @mention issue. + return "".join(['\\' + char if char in escape_chars else char for char in text]) + +def format_last_participants_list(participants_list: list) -> str: + """ + Formats the list of last participants for the announcement message. + participants_list is a list of dicts: [{'user_name', 'numbers'}] + """ + if not participants_list: + return "" # Return empty string if no other recent participants + + # Reverse the list so the oldest of the "last N" appears first in the formatted string + # as per the example "nick1, nick2, nick3" implies chronological order of joining. + # The DB query already returns newest first, so we reverse it for display. + formatted_lines = ["Los últimos participantes en unirse (además del más reciente) han sido:"] + for p_info in reversed(participants_list): # Display oldest of this batch first + user_name = p_info.get('user_name', 'Usuario Anónimo') + numbers_str = p_info.get('numbers', '') + if numbers_str: + num_list = numbers_str.split(',') + if len(num_list) == 1: + line = f" - {escape_markdown_v2_chars_for_username(user_name)}, con el número: {num_list[0]}" + else: + line = f" - {escape_markdown_v2_chars_for_username(user_name)}, con los números: {', '.join(num_list)}" + formatted_lines.append(line) + + return "\n".join(formatted_lines) # Add a trailing newline \ No newline at end of file diff --git a/app/keyboards.py b/app/keyboards.py new file mode 100644 index 0000000..5d88c99 --- /dev/null +++ b/app/keyboards.py @@ -0,0 +1,188 @@ +from telegram import InlineKeyboardMarkup, InlineKeyboardButton +from database import get_participants, get_active_raffles_in_channel, get_active_raffles, get_raffle_name +from config import * + +def generate_raffle_selection_keyboard(channel_id): + """Generates keyboard for users to select an active raffle in a channel.""" + raffles = get_active_raffles_in_channel(channel_id) + keyboard = [[InlineKeyboardButton(r["name"], callback_data=f"join:{r['id']}")] for r in raffles] + return InlineKeyboardMarkup(keyboard) + +def generate_numbers_keyboard(raffle_id, user_id, page=0): + """Generates the 10x10 number selection keyboard with status icons and paging.""" + participants = get_participants(raffle_id) + taken_numbers = {} + user_reserved = set() + user_taken = set() + + # Process participants to determine number statuses + if participants: + for participant in participants: + participant_id = participant['user_id'] + numbers = participant['numbers'] + step = participant['step'] + + if numbers: # Ensure numbers is not None or empty + try: + numbers_set = {int(n) for n in numbers.split(',') if n.isdigit()} # Safer conversion + + if participant_id == user_id: + if step == "waiting_for_payment": + user_reserved.update(numbers_set) + elif step == "completed": + user_taken.update(numbers_set) + + for num in numbers_set: + # Store only if not already taken by the current user (avoids overwriting user status) + if num not in user_taken and num not in user_reserved: + taken_numbers[num] = participant_id # Track who took it generally + except ValueError: + logging.warning(f"Invalid number format in participant data for raffle {raffle_id}: {numbers}") + continue # Skip this participant's numbers if format is wrong + + # Build the keyboard grid + keyboard = [] + rows = 10 + cols = 5 + start = page * rows * cols + end = start + rows * cols + + for i in range(rows): + row_buttons = [] + for j in range(cols): + num = start + i * cols + j + if num >= 100: # Ensure we don't go beyond 99 + break + num_str = f"{num:02}" + + # Determine icon based on status + if num in user_taken: + icon = "🟢" # Taken (Paid) by this user + elif num in user_reserved: + icon = "🔒" # Reserved (Not paid) by this user + elif num in taken_numbers: # Check general taken status *after* specific user status + icon = "❌" # Taken by someone else + else: + icon = "☑️" # Free + + row_buttons.append(InlineKeyboardButton(f"{icon} {num_str}", callback_data=f"number:{raffle_id}:{num}")) + if row_buttons: # Only add row if it contains buttons + keyboard.append(row_buttons) + + # Add Paging Buttons + paging_buttons = [] + if page > 0: + paging_buttons.append(InlineKeyboardButton("⬅️ Anterior", callback_data=f"number:{raffle_id}:prev")) + if end < 100: # Only show next if there are more numbers + paging_buttons.append(InlineKeyboardButton("Siguiente ➡️", callback_data=f"number:{raffle_id}:next")) + if paging_buttons: + keyboard.append(paging_buttons) + + action_buttons_row = [ + InlineKeyboardButton("✨ Número Aleatorio ✨", callback_data=f"random_num:{raffle_id}") + ] + keyboard.append(action_buttons_row) + + # Add Confirm/Cancel Buttons + confirm_cancel_row = [ + InlineKeyboardButton("👍 Confirmar Selección", callback_data=f"confirm:{raffle_id}"), + InlineKeyboardButton("❌ Cancelar Selección", callback_data=f"cancel:{raffle_id}") + ] + keyboard.append(confirm_cancel_row) + + return InlineKeyboardMarkup(keyboard) + +# --- Admin Menu Keyboards --- + +def generate_admin_main_menu_keyboard(): + keyboard = [ + [InlineKeyboardButton("➕ Crear Nuevo Sorteo", callback_data=ADMIN_MENU_CREATE)], + [InlineKeyboardButton("📋 Listar/Gestionar Sorteos", callback_data=ADMIN_MENU_LIST)], + [InlineKeyboardButton("✏️ Editar Sorteo (Añadir Canales)", callback_data=ADMIN_EDIT_RAFFLE_SELECT)], # New option + ] + return InlineKeyboardMarkup(keyboard) + +def generate_channel_selection_keyboard_for_edit(existing_channel_ids, selected_new_channels_ids=None): + """Generates channel selection keyboard for editing, excluding existing ones.""" + if selected_new_channels_ids is None: + selected_new_channels_ids = set() + buttons = [] + # CHANNELS is { 'alias': 'id' } + for alias, channel_id_str in CHANNELS.items(): + if channel_id_str not in existing_channel_ids: # Only show channels NOT already in the raffle + text = f"✅ {alias}" if channel_id_str in selected_new_channels_ids else f"⬜️ {alias}" + buttons.append([InlineKeyboardButton(text, callback_data=f"{SELECT_CHANNEL_PREFIX}{channel_id_str}")]) + + if selected_new_channels_ids: + buttons.append([InlineKeyboardButton("➡️ Continuar con precios", callback_data=f"{SELECT_CHANNEL_PREFIX}done")]) + buttons.append([InlineKeyboardButton("❌ Cancelar Edición", callback_data=f"{SELECT_CHANNEL_PREFIX}cancel_edit")]) + return InlineKeyboardMarkup(buttons) + + +def generate_admin_list_raffles_keyboard(): + """Generates keyboard listing active raffles with management buttons.""" + active_raffles = get_active_raffles() + keyboard = [] + + if not active_raffles: + keyboard.append([InlineKeyboardButton("No hay sorteos activos.", callback_data=ADMIN_NO_OP)]) + else: + for raffle in active_raffles: + raffle_id = raffle['id'] + raffle_name = raffle['name'] + # Row for each raffle: Name Button (View Details), Announce Button, End Button + keyboard.append([ + InlineKeyboardButton(f"ℹ️ {raffle_name}", callback_data=f"{ADMIN_VIEW_RAFFLE_PREFIX}{raffle_id}"), + InlineKeyboardButton("📢 Anunciar", callback_data=f"{ADMIN_ANNOUNCE_RAFFLE_PREFIX}{raffle_id}"), + InlineKeyboardButton("🏁 Terminar", callback_data=f"{ADMIN_END_RAFFLE_PROMPT_PREFIX}{raffle_id}") + ]) + + keyboard.append([InlineKeyboardButton("⬅️ Volver al Menú Principal", callback_data=ADMIN_MENU_BACK_MAIN)]) + return InlineKeyboardMarkup(keyboard) + +def generate_admin_raffle_details_keyboard(raffle_id): + """Generates keyboard for the raffle detail view.""" + 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("🏁 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 + ] + return InlineKeyboardMarkup(keyboard) + +def generate_admin_cancel_end_keyboard(): + """Generates a simple cancel button during the end process.""" + keyboard = [ + [InlineKeyboardButton("❌ Cancelar Finalización", callback_data=ADMIN_CANCEL_END_PROCESS)] + ] + return InlineKeyboardMarkup(keyboard) + +# --- Keyboards for Raffle Creation Conversation --- + +def generate_channel_selection_keyboard(selected_channels_ids=None): + """Generates keyboard for admin to select target channels.""" + if selected_channels_ids is None: + selected_channels_ids = set() + + buttons = [] + for alias, channel_id in CHANNELS.items(): + # Mark selected channels + text = f"✅ {alias}" if channel_id in selected_channels_ids else f"⬜️ {alias}" + buttons.append([InlineKeyboardButton(text, callback_data=f"{SELECT_CHANNEL_PREFIX}{channel_id}")]) + + # Add Done button only if at least one channel is selected + if selected_channels_ids: + buttons.append([InlineKeyboardButton("➡️ Continuar", callback_data=f"{SELECT_CHANNEL_PREFIX}done")]) + buttons.append([InlineKeyboardButton("❌ Cancelar Creación", callback_data=f"{SELECT_CHANNEL_PREFIX}cancel")]) + + return InlineKeyboardMarkup(buttons) + +def generate_confirmation_keyboard(): + """Generates Yes/No keyboard for final confirmation.""" + keyboard = [ + [ + InlineKeyboardButton("✅ Sí, crear sorteo", callback_data=CONFIRM_CREATION_CALLBACK), + InlineKeyboardButton("❌ No, cancelar", callback_data=CANCEL_CREATION_CALLBACK), + ] + ] + return InlineKeyboardMarkup(keyboard) \ No newline at end of file diff --git a/app/paypal_processor.py b/app/paypal_processor.py new file mode 100644 index 0000000..4ed993e --- /dev/null +++ b/app/paypal_processor.py @@ -0,0 +1,382 @@ +# paypal_processor.py + +from flask import Flask, request +import logging +import requests +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, escape_markdown_v2_chars_for_username +from database import ( + get_user_by_invoice_id, confirm_reserved_numbers, + get_raffle_name, get_raffle, + get_raffle_channels_and_prices, get_remaining_numbers_amount, + store_announcement_message_id, get_announcement_message_id, + get_last_n_other_participants, get_remaining_numbers +) +from config import BOT_TOKEN, TYC_URL, NEWRELIC_API_KEY, BOT_NAME +from newrelic_telemetry_sdk import Log, LogClient + +app = Flask(__name__) + +# Enable logging +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO +) +client = LogClient(license_key=NEWRELIC_API_KEY, host="log-api.eu.newrelic.com") + +# Define a handler that sends records to New Relic +class NewRelicHandler(logging.Handler): + def emit(self, record): + try: + log = Log( + message=self.format(record), + level=record.levelname, + timestamp_ms=int(record.created * 1000), + attributes={ + "logger": record.name, + "app_name": BOT_NAME, + "docker_container": "paypal_processor" + } + ) + client.send(log) + except Exception: + self.handleError(record) + +nr_handler = NewRelicHandler() +nr_handler.setFormatter(logging.Formatter("%(message)s")) + +root_logger = logging.getLogger() +root_logger.addHandler(nr_handler) + +logger = logging.getLogger(__name__) + +# Define the Telegram API URL base +TELEGRAM_API_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" + +def edit_telegram_message_caption(chat_id, message_id, caption, photo_path=None, parse_mode=None, **kwargs): + """ + Helper to edit message caption. If photo_path is provided, it attempts to + re-send the photo with the new caption (Telegram doesn't directly support + editing media content of a message, only its caption or reply_markup). + However, for this use case, we are editing the caption of an *existing* photo. + """ + payload = {'chat_id': chat_id, 'message_id': message_id, 'caption': caption} + if parse_mode: + payload['parse_mode'] = parse_mode + # For inline keyboards, add reply_markup=json.dumps(keyboard_dict) + payload.update(kwargs) + + try: + response = requests.post(f"{TELEGRAM_API_URL}/editMessageCaption", data=payload) + response.raise_for_status() + logger.info(f"Edited caption for message {message_id} in chat {chat_id}") + return response.json().get('result', {}).get('message_id', message_id) # Return new or old message_id + except requests.exceptions.RequestException as e: + logger.error(f"Error editing caption for message {message_id} in chat {chat_id}: {e}") + if e.response is not None: + logger.error(f"Edit caption Response status: {e.response.status_code}, body: {e.response.text}") + return None # Indicate failure + +def send_telegram_message(chat_id, text, **kwargs): + """Helper to send text messages via requests.""" + payload = {'chat_id': chat_id, 'text': text, **kwargs} + try: + response = requests.post(f"{TELEGRAM_API_URL}/sendMessage", data=payload) + response.raise_for_status() + logger.info(f"Sent message to chat_id {chat_id}") + return True + except requests.exceptions.RequestException as e: + logger.error(f"Error sending message to chat_id {chat_id}: {e}") + if e.response is not None: + logger.error(f"Response status: {e.response.status_code}, body: {e.response.text}") + return False + +def send_telegram_photo(chat_id, photo_path, caption=None, parse_mode=None, **kwargs): + """Helper to send photos via requests.""" + files = {'photo': open(photo_path, 'rb')} + data = {'chat_id': chat_id} + if caption: + data['caption'] = caption + if parse_mode: # Add parse_mode to data if provided + data['parse_mode'] = parse_mode + data.update(kwargs) + + try: + response = requests.post(f"{TELEGRAM_API_URL}/sendPhoto", data=data, files=files) + response.raise_for_status() + logger.info(f"Sent photo to chat_id {chat_id}") + message_data = response.json().get('result') + return message_data + except requests.exceptions.RequestException as e: + logger.error(f"Error sending photo to chat_id {chat_id}: {e}") + if e.response is not None: + logger.error(f"Response status: {e.response.status_code}, body: {e.response.text}") + return False + finally: + # Ensure file is closed even if request fails + if 'photo' in files and files['photo']: + files['photo'].close() + +# --- CORRECTED FUNCTION --- +def receive_paypal_payment(invoice_id, transaction_id, payment_status, payment_amount_str): + """Processes verified PayPal payment data.""" + logger.info(f"Processing payment for Invoice: {invoice_id}, TXN: {transaction_id}, Status: {payment_status}, Amount: {payment_amount_str}") + + # 1. Get participant data using the invoice ID + participant_data = get_user_by_invoice_id(invoice_id) + + # 2. Check if participant data exists and is pending + if not participant_data: + # This means the invoice ID wasn't found OR the step wasn't 'waiting_for_payment' + # It could be already completed, cancelled, or expired. Ignore the webhook. + logger.warning(f"No pending participant found for Invoice ID: {invoice_id}. Payment ignored.") + return + + # 3. Extract data directly from the fetched Row object + user_id = participant_data['user_id'] + current_user_name = participant_data['user_name'] or f"User_{user_id}" # Use fallback name + raffle_id = participant_data['raffle_id'] + numbers_str = participant_data['numbers'] + price_per_number = participant_data['price_per_number'] # Price is already fetched + + numbers = numbers_str.split(',') if numbers_str else [] + if not numbers: + logger.error(f"Invoice ID {invoice_id} found, but participant {user_id} has no numbers associated. Skipping.") + # Maybe notify admin? + return + + # 4. Validate Payment Status + if payment_status != "Completed": + logger.warning(f"Payment status for Invoice ID {invoice_id} is '{payment_status}', not 'Completed'. Payment not processed.") + # Optionally notify the user that payment is pending/failed + send_telegram_message( + user_id, + f"El estado de tu pago para la factura {invoice_id} es '{payment_status}'. " + f"El sorteo solo se confirma con pagos 'Completed'. Contacta con un administrador si crees que es un error." + ) + return + + # 5. Validate Payment Amount + try: + payment_amount = float(payment_amount_str) + except (ValueError, TypeError): + logger.error(f"Invalid payment amount received for Invoice ID {invoice_id}: '{payment_amount_str}'. Cannot validate.") + send_telegram_message( + user_id, + f"Error procesando el pago para la factura {invoice_id}. El monto recibido ('{payment_amount_str}') no es válido. " + f"Por favor, contacta con un administrador." + ) + return + + expected_amount = len(numbers) * price_per_number + # Use a small tolerance for float comparison + if abs(payment_amount - expected_amount) > 0.01: + logger.error(f"Payment amount mismatch for Invoice ID {invoice_id}. Expected: {expected_amount:.2f}, Received: {payment_amount:.2f}") + send_telegram_message( + user_id, + f"⚠️ La cantidad pagada ({payment_amount:.2f}€) para la factura {invoice_id} no coincide con el total esperado ({expected_amount:.2f}€) para los números {', '.join(numbers)}. " + f"Por favor, contacta con un administrador." + ) + # Do NOT confirm the numbers if amount is wrong + return + + # 6. Confirm the numbers in the database + if confirm_reserved_numbers(user_id, raffle_id, transaction_id): + logger.info(f"Successfully confirmed numbers {numbers} for user {user_id} (Name: {current_user_name}), raffle {raffle_id} with TXN {transaction_id}.") + + raffle_name = get_raffle_name(raffle_id) # Get raffle name for user message + + # Send confirmation to user + send_telegram_message( + user_id, + f"✅ ¡Pago confirmado para la factura {invoice_id}!\n\n" + f"Te has apuntado con éxito al sorteo '{raffle_name}' con los números: {', '.join(numbers)}." + # 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) + + last_other_participants = get_last_n_other_participants(raffle_id, user_id, n=3) + last_participants_text = format_last_participants_list(last_other_participants) + + channels_with_their_prices = get_raffle_channels_and_prices(raffle_id) + + if channels_with_their_prices: + for channel_price_info in channels_with_their_prices: + channel_id_to_announce = channel_price_info['channel_id'] + price_for_this_channel_announce = channel_price_info['price'] # <<< PRICE FOR THIS SPECIFIC CHANNEL + + escaped_current_user_name = escape_markdown_v2_chars_for_username(current_user_name) + + numbers_text = "" + if len(numbers) > 1: + numbers_text = f"con los números: {', '.join(numbers)}" + else: + numbers_text = f"con el número: {', '.join(numbers)}" + + new_participant_line = f"❗️ ¡Nuevo participante! @{escaped_current_user_name} se unió {numbers_text}. ❗️" + + remaining_numbers_text = "" + if remaining_numbers_amount > 10: + remaining_numbers_text = f"ℹ️ Quedan {remaining_numbers_amount} números disponibles. ℹ️" + elif remaining_numbers_amount == 1: + remaining_numbers = get_remaining_numbers(raffle_id) + remaining_numbers_text = f"ℹ️💥💥💥 ¡Último número disponible! 💥💥💥ℹ️\n\n" + remaining_numbers_text += f"Queda el número: {remaining_numbers[0]}" + elif remaining_numbers_amount == 0: + remaining_numbers_text = "ℹ️ ¡Ya no quedan números disponibles! ℹ️" + else: + remaining_numbers = get_remaining_numbers(raffle_id) + remaining_numbers_text = f"ℹ️💥 ¡Últimos {remaining_numbers_amount} números disponibles! 💥ℹ️\n\n" + remaining_numbers_text += f"Quedan los números: {', '.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"💶 **Donación mínima:** {price_for_this_channel_announce}€\n\n" # Use the specific price + f"Normas y condiciones: {TYC_URL} \n\n" + f"¡Apúntate con el comando /sorteo !" + ) + + previous_message_id = get_announcement_message_id(raffle_id, channel_id_to_announce) + sent_or_edited_message_id = None + + if previous_message_id: + logger.info(f"Attempting to edit message {previous_message_id} in channel {channel_id_to_announce}") + # We need to re-send the photo to "edit" it with a new caption effectively, + # or just edit caption if the photo is guaranteed to be the same. + # For simplicity, let's try to just edit the caption of the existing photo. + # If the image itself needs to update (e.g. number grid), then delete + send new. + # Assuming the photo (raffle_table_{raffle_id}.png) is updated by generate_table_image + # We should delete old and send new. + + # Try deleting old message first + try: + delete_payload = {'chat_id': channel_id_to_announce, 'message_id': previous_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 {previous_message_id} in channel {channel_id_to_announce}") + else: + logger.warning(f"Failed to delete old message {previous_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 {previous_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, parse_mode='Markdown') + 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, parse_mode='Markdown') + # 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_announcement_message_id(raffle_id, channel_id_to_announce, sent_or_edited_message_id) + else: + logger.warning(f"No channels found to announce participation for raffle {raffle_id}") + + # Send image confirmation to user (price not needed in this caption) + user_caption = f"Te has apuntado al sorteo '{raffle_name}' con los números: {', '.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.") + + else: + # This case means the DB update failed, which is serious if payment was valid. + logger.critical(f"CRITICAL: Failed to execute confirm_reserved_numbers for user {user_id}, raffle {raffle_id}, invoice {invoice_id} AFTER successful payment validation.") + # Notify admin and possibly the user about the inconsistency + send_telegram_message( + user_id, + f"Hubo un error interno al confirmar tu participación para la factura {invoice_id} después de validar tu pago. " + f"Por favor, contacta con un administrador y proporciónales tu ID de factura." + ) + # Notify admin (replace ADMIN_CHAT_ID with actual ID or list) + # 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.") + + +@app.route("/paypal-webhook", methods=["POST"]) +def paypal_webhook(): + """Webhook to listen for PayPal IPN messages.""" + logging.info("Received request on /paypal-webhook") + raw_data = request.get_data() # Get raw data for verification + # logging.debug(f"Raw PayPal data: {raw_data.decode('utf-8')}") # Decode for debug logging if needed + + # Verify the IPN message with PayPal + verify_url = "https://ipnpb.paypal.com/cgi-bin/webscr" # Use sandbox URL for testing if needed + # verify_url = "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr" + verify_data = b"cmd=_notify-validate&" + raw_data # Prepend the validation command + + try: + response = requests.post(verify_url, data=verify_data, timeout=30) # Add timeout + response.raise_for_status() # Raise HTTP errors + except requests.exceptions.RequestException as e: + logger.error(f"Error verifying IPN with PayPal: {e}") + return "IPN Verification Error", 500 # Return error status + + if response.text == "VERIFIED": + # --- Extract needed data from the FORM data (not raw_data) --- + data = request.form.to_dict() + logger.info(f"VERIFIED IPN received. Data: {data}") + + # Extract key fields (adjust keys based on your PayPal setup/IPN variables) + invoice_id = data.get("invoice") + transaction_id = data.get("txn_id") # Use PayPal's transaction ID + payment_status = data.get("payment_status") + payment_amount = data.get("mc_gross") # Total amount paid + # custom_id = data.get("custom") # If you passed participant ID here + + if not all([invoice_id, transaction_id, payment_status, payment_amount]): + logger.warning(f"Missing one or more required fields in VERIFIED IPN data: {data}") + return "Missing Fields", 200 # Acknowledge receipt but don't process + + # Process the valid payment + receive_paypal_payment(invoice_id, transaction_id, payment_status, payment_amount) + + elif response.text == "INVALID": + logger.warning(f"INVALID IPN received. Raw data: {raw_data.decode('utf-8')}") + # Consider logging more details from request.form if needed + else: + logger.warning(f"Unexpected response from PayPal verification: {response.text}") + + return "", 200 # Always return 200 OK to PayPal IPN + + +if __name__ == "__main__": + # Make sure BOT_TOKEN is loaded if running directly (e.g., via dotenv) + # from dotenv import load_dotenv + # load_dotenv() + # BOT_TOKEN = os.getenv("BOT_TOKEN") # Ensure BOT_TOKEN is available + if not BOT_TOKEN: + print("Error: BOT_TOKEN environment variable not set.") + 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 diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..f176c98 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,7 @@ +python-telegram-bot[ext]==21.1.1 # Use specific version, ensure [ext] is included +python-dotenv==1.0.1 +pillow==10.2.0 # Use specific version +requests==2.31.0 # Use specific version +Flask==3.0.0 # Use specific version +APScheduler==3.10.4 # Add APScheduler +newrelic-telemetry-sdk==0.8.0 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0f8cecd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +services: + homelabs_raffle: + build: + context: app + dockerfile: Dockerfile + image: homelabs_raffle + container_name: homelabs_raffle + volumes: + - ./data:/app/data + dns: + - 8.8.8.8 + restart: unless-stopped + environment: + - 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} + - WEBHOOK_URL=${WEBHOOK_URL} + - TYC_URL=${TYC_URL} + - NEWRELIC_API_KEY=${NEWRELIC_API_KEY} + homelabs_raffle_paypal_processor: + build: + context: app + dockerfile: Dockerfile.paypal_processor + image: homelabs_raffle_paypal_processor + container_name: homelabs_raffle_paypal_processor + volumes: + - ./data:/app/data + restart: unless-stopped + environment: + - TZ="Europe/Madrid" + - BOT_TOKEN=${BOT_TOKEN} + - BOT_NAME=${BOT_NAME} + - PAYPAL_EMAIL=${PAYPAL_EMAIL} + - PAYPAL_HANDLE=${PAYPAL_HANDLE} + - TYC_URL=${TYC_URL} + - NEWRELIC_API_KEY=${NEWRELIC_API_KEY} + networks: + - traefik + labels: + - traefik.enable=true + - traefik.http.routers.raffle-homelabs-http.entrypoints=web + - traefik.http.routers.raffle-homelabs-http.rule=Host(`raffle-homelabs.patacuack.net`) + - traefik.http.routers.raffle-homelabs-http.middlewares=https-redirect@file + - traefik.http.routers.raffle-homelabs.entrypoints=websecure + - traefik.http.routers.raffle-homelabs.rule=Host(`raffle-homelabs.patacuack.net`) + - traefik.http.routers.raffle-homelabs.tls=true + - traefik.http.routers.raffle-homelabs.tls.certResolver=production + - traefik.http.services.raffle-homelabs.loadbalancer.server.port=5000 + +networks: + traefik: + external: true