From d3b4cd7eaad4e50568762e64ff08aa54d3333e6d Mon Sep 17 00:00:00 2001 From: Joan Date: Fri, 5 Sep 2025 12:27:45 +0200 Subject: [PATCH] First commit --- .gitignore | 2 + README.md | 91 +--- app/Dockerfile | 10 + app/Dockerfile.paypal_processor | 9 + app/app.py | 200 +++++++ app/config.py | 52 ++ app/database.py | 601 ++++++++++++++++++++ app/handlers.py | 938 ++++++++++++++++++++++++++++++++ app/helpers.py | 441 +++++++++++++++ app/keyboards.py | 163 ++++++ app/paypal_processor.py | 402 ++++++++++++++ app/requirements.txt | 8 + docker-compose.yml | 62 +++ example.env | 11 + 14 files changed, 2900 insertions(+), 90 deletions(-) create mode 100644 .gitignore create mode 100644 app/Dockerfile create mode 100644 app/Dockerfile.paypal_processor create mode 100644 app/app.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/handlers.py create mode 100644 app/helpers.py create mode 100644 app/keyboards.py create mode 100644 app/paypal_processor.py create mode 100644 app/requirements.txt create mode 100644 docker-compose.yml create mode 100644 example.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c06317a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +/data \ No newline at end of file diff --git a/README.md b/README.md index b82f5b8..cbfba3c 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,3 @@ # telerifas - - -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin http://gitlab.kingstudio.es/jocaru/telerifas.git -git branch -M main -git push -uf origin main -``` - -## Integrate with your tools - -- [ ] [Set up project integrations](http://gitlab.kingstudio.es/jocaru/telerifas/-/settings/integrations) - -## Collaborate with your team - -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. - -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. - -## License -For open source projects, say how it is licensed. - -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +TO BE WRITTEN \ No newline at end of file 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..6590e0f --- /dev/null +++ b/app/app.py @@ -0,0 +1,200 @@ +# app.py +import logging +import time +from datetime import datetime +from datetime import time as dtime +import pytz +from bs4 import BeautifulSoup +from telegram import Update +from telegram.ext import ( + Application, + CommandHandler, + CallbackQueryHandler, + MessageHandler, + ConversationHandler, + filters, + ContextTypes, +) +from telegram.error import Forbidden, BadRequest +# REMOVE: from apscheduler.schedulers.asyncio import AsyncIOScheduler + +# 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 +) +logging.getLogger("httpx").setLevel(logging.WARNING) +# REMOVE: logging.getLogger("apscheduler").setLevel(logging.WARNING) # No longer needed + +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"Las papeletas `{numbers}` que tenías reservadas para la rifa **{raffle_name}** han sido liberadas.\n\n" + f"Puedes volver a reservarlas, ¡pero tienes {RESERVATION_TIMEOUT_MINUTES} minutos para completar el pago!." + ) + 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.") + +async def check_winner_numbers(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. + logger.info(f"Running check_winner_numbers)") + + # will check winner number comparing to ONCE's website depending on the day + # and only if a raffle is already with all numbers sold + + raffles = get_active_raffles() + + for raffle in raffles: + if get_remaining_numbers_amount(raffle['id']) == 0: + weekday = datetime.today().weekday() + if weekday <= 3: + draw_name = DRAW_MAPPING['weekday'] + elif weekday == 4: + draw_name = DRAW_MAPPING['friday'] + else: + draw_name = DRAW_MAPPING['weekend'] + + response = requests.get(JUEGOS_ONCE_URL, timeout=10) + response.raise_for_status() + soup = BeautifulSoup(response.text, 'html.parser') + + blocks = soup.find_all("div", class_=draw_name) + for block in blocks: + num = block.find(class_="num") + winner_num = num.get_text(strip=True) if num else None + + if winner_num: + logger.info(f"Winner number for {raffle['name']} is {winner_num}") + await end_raffle_logic(context, raffle['id'], [int(winner_num)%100], ADMIN_IDS[0]) + +# --- 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, + name="expire_check_job" + ) + logger.info(f"Scheduled reservation check job to run every {job_interval_seconds} seconds.") + + madrid_tz = pytz.timezone("Europe/Madrid") + job_queue.run_daily( + callback=check_winner_numbers, + time=dtime(hour=21, minute=45, tzinfo=madrid_tz), + name="winner_check_job" + ) + logger.info("Scheduled winner check job every day at 21:45 Madrid time.") + + # --- Handlers (Remain the same) --- + # 1. Raffle Creation Conversation Handler + raffle_creation_conv = ConversationHandler( + entry_points=[CommandHandler("crear_rifa", new_raffle_start)], + states={ + SELECTING_CHANNEL: [CallbackQueryHandler(select_channel, 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_CHANNEL: [ + 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) + + 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})$" + ) + + 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)) + + # 5. User Callbacks + 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..70a0989 --- /dev/null +++ b/app/config.py @@ -0,0 +1,52 @@ +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") +WEBHOOK_ID = os.getenv("WEBHOOK_ID") +RESERVATION_TIMEOUT_MINUTES = 15 +TYC_DOCUMENT_URL = os.getenv("TYC_DOCUMENT_URL") + +# Conversation States for Raffle Creation +(SELECTING_CHANNEL, TYPING_TITLE, TYPING_DESCRIPTION, TYPING_PRICE_FOR_CHANNEL, 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 +# --- End Admin Menu --- + +DRAW_MAPPING = { + 'weekday': 'ORD', # Mon–Thu + 'friday': 'VIE', # Fri + 'weekend': 'DOM' # Sat–Sun +} +JUEGOS_ONCE_URL = "https://www.juegosonce.es/resultados-ultimos-sorteos-once" \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..d14ae5f --- /dev/null +++ b/app/database.py @@ -0,0 +1,601 @@ +# 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, + price INTEGER NOT NULL, + channel_id TEXT NOT NULL, + main_message_id INTEGER, + update_message_id INTEGER, + active INTEGER DEFAULT 1 + ) + """) + + 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, + step TEXT NOT NULL, + invoice_id TEXT, + reservation_timestamp INTEGER, + completion_timestamp INTEGER, + UNIQUE(user_id, raffle_id, invoice_id), + FOREIGN KEY (raffle_id) REFERENCES raffles(id) ON DELETE CASCADE + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS paypal_token ( + id INTEGER PRIMARY KEY, + access_token TEXT, + expires_at INTEGER + ) + """) + + # Indexes + 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)") + + 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(name, description, price, image_file_id, channel_id): + """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, channel_id) VALUES (?, ?, ?, ?, ?)", + (name, description, price, image_file_id, channel_id) + ) + raffle_id = cur.lastrowid + conn.commit() + logging.info(f"Created raffle '{name}' (ID: {raffle_id}) for channel: {channel_id}") + 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 * 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_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_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): + """Adds or updates a participant's reserved numbers.""" + 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)) + cur.execute("UPDATE participants SET numbers=? WHERE id=?", + (numbers_str, participant_id)) + else: + cur.execute( + "INSERT INTO participants (user_id, user_name, raffle_id, numbers, step) VALUES (?, ?, ?, ?, ?)", + (user_id, user_name, raffle_id, str(number), "waiting_for_payment") + ) + conn.commit() + except Exception as e: + logger.error(f"Error reserving number {number} for user {user_id} in raffle {raffle_id}: {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, invoice_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', + invoice_id=?, + reservation_timestamp=NULL, + completion_timestamp=? + WHERE user_id=? AND raffle_id=? AND step='waiting_for_payment'""", + (invoice_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, n=4): + """ + 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' + ORDER BY completion_timestamp DESC + LIMIT ?""", + (raffle_id, n) + ) + rows = cur.fetchall() + skip_first = True + for row in rows: + if skip_first: + skip_first = False + continue + 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: + cur.execute("SELECT id, raffle_id, numbers, user_name 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 * FROM participants WHERE invoice_id=? AND step='waiting_for_payment'", (invoice_id,)) + data = cur.fetchone() + return data + 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() + +def store_main_message_id(raffle_id, message_id): + """Updates the main_message_id for a given raffle.""" + conn = connect_db() + cur = conn.cursor() + cur.execute("UPDATE raffles SET main_message_id=? WHERE id=?", (message_id, raffle_id)) + conn.commit() + conn.close() + logger.info(f"Updated main_message_id for raffle {raffle_id} to {message_id}") + +def get_main_message_id(raffle_id): + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + "SELECT main_message_id FROM raffles WHERE id=?", + (raffle_id,) + ) + result = cur.fetchone() + return result['main_message_id'] if result else None + except Exception as e: + logger.error(f"Error getting main_message_id for raffle {raffle_id}: {e}") + return None + finally: + conn.close() + +def store_update_message_id(raffle_id, message_id): + """Updates the update_message_id for a given raffle.""" + conn = connect_db() + cur = conn.cursor() + cur.execute("UPDATE raffles SET update_message_id=? WHERE id=?", (message_id, raffle_id)) + conn.commit() + conn.close() + logger.info(f"Updated update_message_id for raffle {raffle_id} to {message_id}") + +def get_update_message_id(raffle_id): + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + "SELECT update_message_id FROM raffles WHERE id=?", + (raffle_id,) + ) + result = cur.fetchone() + return result['update_message_id'] if result else None + except Exception as e: + logger.error(f"Error getting update_message_id for raffle {raffle_id}: {e}") + return None + finally: + conn.close() + +def get_paypal_access_token_db(): + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + "SELECT * FROM paypal_token ORDER BY id DESC LIMIT 1" + ) + result = cur.fetchone() + if result: + # Check if the token is still valid + if int(result['expires_at']) > int(time.time()): + return result['access_token'] + except Exception as e: + logger.error(f"Error getting PayPal access token from DB: {e}") + return None + finally: + conn.close() + +def store_paypal_access_token(access_token, expires_in): + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + "INSERT INTO paypal_token (access_token, expires_at) VALUES (?, ?)", + (access_token, int(time.time()) + expires_in) + ) + conn.commit() + except Exception as e: + logger.error(f"Error storing PayPal access token in DB: {e}") + finally: + conn.close() diff --git a/app/handlers.py b/app/handlers.py new file mode 100644 index 0000000..751530a --- /dev/null +++ b/app/handlers.py @@ -0,0 +1,938 @@ +import logging +import time +import random +from telegram import Update +from telegram.ext import ( + ContextTypes, + CallbackContext, + ConversationHandler, +) +from telegram.constants import ParseMode +from telegram.error import Forbidden, BadRequest + +from database import * +from config import * +from helpers import * +from keyboards import * + +import pytz +from datetime import datetime +from datetime import time as dtime + +logger = logging.getLogger(__name__) + +# --- Conversation Handler for Raffle Creation --- + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): + user = update.message.from_user + args = context.args + if args and args[0].startswith("join_"): + try: + raffle_id = int(args[0].split("_")[1]) + raffle = get_raffle(raffle_id) # Get raffle details + remaining_numbers_amount = get_remaining_numbers_amount(raffle_id) + if raffle and raffle['active'] and remaining_numbers_amount > 0: + # Check what time is it, if it's between 20:55 and 22:00, users can't join + madrid_tz = pytz.timezone("Europe/Madrid") + current_time = datetime.now(madrid_tz) + if current_time.time() >= dtime(20, 55) and current_time.time() <= dtime(22, 0): + await update.message.reply_text("No puedes unirte a la rifa en este momento.") + return + + # The user wants to join this raffle. + # Start the private conversation for number selection. + logger.info(f"User {user.id} started bot with join link for raffle {raffle_id}") + context.user_data['joining_raffle_id'] = raffle_id + keyboard = generate_numbers_keyboard(raffle_id, user.id) + await update.message.reply_text( + f"¡Hola! Vamos a unirnos a la rifa '{raffle['name']}'.\n\n" + f"El precio por papeleta es de {raffle['price']}€.\n\n" + "Por favor, selecciona tus números:", + reply_markup=keyboard + ) + else: + await update.message.reply_text("Esta rifa ya no está activa o no tiene números disponibles.") + except (ValueError, IndexError): + await update.message.reply_text("Enlace de participación inválido.") + else: + # Generic start message + await update.message.reply_text("Hola, soy el bot de rifas. Puedes participar desde los anuncios en los canales.") + +async def new_raffle_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Starts the conversation to create a new raffle. Asks for channels.""" + user_id = update.message.from_user.id + if user_id not in ADMIN_IDS: + await update.message.reply_text("No tienes permiso para crear rifas.") + return ConversationHandler.END + + if not CHANNELS: + await update.message.reply_text("No hay canales configurados. Añade CHANNEL_IDS al .env") + return ConversationHandler.END + + context.user_data['new_raffle'] = {'channel': ""} # Initialize data for this user + keyboard = generate_channel_selection_keyboard() + await update.message.reply_text( + "Vamos a crear una nueva rifa.\n\n" + "**Paso 1:** Selecciona el canal donde se publicará la rifa.", + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + return SELECTING_CHANNEL + +async def select_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Handles channel selection or finishing selection.""" + query = update.callback_query + await query.answer() + + callback_data = query.data + + if callback_data.startswith(SELECT_CHANNEL_PREFIX): + channel_id = callback_data[len(SELECT_CHANNEL_PREFIX):] + context.user_data['new_raffle']['channel'] = channel_id + + await query.edit_message_text( + "Canal seleccionad. Ahora, por favor, envía el **título** de la rifa.", + parse_mode=ParseMode.MARKDOWN + ) + return TYPING_TITLE + + # Should not happen, but good practice + await context.bot.send_message(chat_id=query.from_user.id, text="Opción inválida.") + return SELECTING_CHANNEL + +async def receive_title(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Receives the raffle title and asks for description.""" + title = update.message.text.strip() + if not title: + await update.message.reply_text("El título no puede estar vacío. Inténtalo de nuevo.") + return TYPING_TITLE + + context.user_data['new_raffle']['title'] = title + await update.message.reply_text("Título guardado. Ahora envía la **descripción** de la rifa.", 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 la rifa.", 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 + + await update.message.reply_text( + "Imagen guardada.\nAhora, introduce el precio por número para el canal seleccionado (solo el número, ej: 5).", + parse_mode=ParseMode.MARKDOWN + ) + return TYPING_PRICE_FOR_CHANNEL + +async def receive_price_for_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Receives price for the current channel, stores it, and asks for next or confirms.""" + price_text = update.message.text.strip() + channel_id = context.user_data['new_raffle']['channel'] + + try: + price = int(price_text) + if not (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(channel_id, f"ID:{channel_id}") + await update.message.reply_text(f"Precio inválido para {channel_alias}. Debe ser un número (ej: 5). Inténtalo de nuevo.") + return TYPING_PRICE_FOR_CHANNEL # Stay in this state + + context.user_data['new_raffle']['price'] = price + logger.info(f"Price for channel {channel_id} set to {price}") + + await _show_creation_confirmation(update, context) + return CONFIRMING_CREATION + +async def _show_creation_confirmation(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Shows the final confirmation message before creating the raffle.""" + raffle_data = context.user_data['new_raffle'] + channel_id = raffle_data.get('channel', "") + price = raffle_data.get('price', 0) + + confirmation_text = ( + "¡Perfecto! Revisa los datos de la rifa:\n\n" + f"📌 **Título:** {raffle_data.get('title', 'N/A')}\n" + f"📝 **Descripción:** {raffle_data.get('description', 'N/A')}\n" + f"📺 **Canal:** {REVERSE_CHANNELS.get(channel_id, channel_id)}\n" + f"💶 **Precio:** {price}€\n" + f"🖼️ **Imagen:** (Adjunta)\n\n" + "¿Confirmas la creación de esta rifa?" + ) + 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') + price = user_data.get('price') + image_file_id = user_data.get('image_file_id') + channel_id = user_data.get('channel') + + if not all([name, description, image_file_id, price]): + 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(name, description, price, image_file_id, channel_id) + + if raffle_id: + await context.bot.send_message(query.from_user.id, f"✅ ¡Rifa '{name}' creada con éxito!") + await _announce_raffle_in_channels(context, raffle_id, query.from_user.id, initial_announcement=True) + else: + await context.bot.send_message(query.from_user.id, f"❌ Error al guardar la rifa. 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 rifa 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 rifa 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 rifa" + 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 rifa": + if current_state == SENDING_IMAGE: + await update.message.reply_text( + "Por favor, envía una IMAGEN para la rifa, 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 de la rifa. 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 de la rifa. Usa /cancelar para salir.") + return TYPING_DESCRIPTION + elif current_state == TYPING_PRICE_FOR_CHANNEL: + channel_id_for_price = current_conversation_data.get('current_channel_for_price') + channel_alias = REVERSE_CHANNELS.get(channel_id_for_price, f"ID:{channel_id_for_price}") if channel_id_for_price else "el canal actual" + await update.message.reply_text( + f"Por favor, envía un NÚMERO para el precio de la rifa en {channel_alias}.\n" + "Usa /cancelar para salir." + ) + return TYPING_PRICE_FOR_CHANNEL + # Add more states if needed (e.g., if SELECTING_CHANNELS expects only callbacks) + + # Generic fallback message if specific state isn't handled above for some reason + await update.message.reply_text( + "Entrada no válida para este paso de la conversación.\n" + "Si estás creando una rifa, usa /cancelar para salir.\n" + "Si estás editando una rifa, usa /cancelar_edicion para salir." + ) + return current_state # Return to the state the conversation was in + +# Handle number selection callback +async def number_callback(update: Update, context: CallbackContext): + """Handles clicks on the number buttons in the private chat.""" + query = update.callback_query + user_id = query.from_user.id + 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 = "" + 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("Papeleta no válida.") + return + elif action == "random_num": + # Handle random number selection + try: + remaining_free_numbers = get_remaining_numbers(raffle_id) + if not remaining_free_numbers: + await query.answer("No hay papeletas 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("Papeleta aleatoria 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 de la papeleta {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 la papeleta {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 papeleta {number_string}.") + logger.warning(f"User {user_id} clicked number {number_string} with unexpected step {participant_step}") + + else: + # User clicked a number taken by someone else + status_msg = "reservada" if participant_step == "waiting_for_payment" else "comprada" + await query.answer(f"La papeleta {number_string} ya ha sido {status_msg} por otro usuario.") + logger.debug(f"User {user_id} clicked number {number_string} taken by user {participant_user_id}") + + else: + # Number is free -> Reserve it for the user + reserve_number(user_id, username, raffle_id, number_string) + await query.answer(f"Papeleta {number_string} reservada 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 ninguna papeleta nueva para confirmar.") + return + + await query.answer("Generando enlace de pago...") # 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 de la rifa.", reply_markup=None) + except BadRequest: + await query.edit_message_text("Error: No se pudo encontrar la información de la rifa.", reply_markup=None) + return + + # Get participant DB ID (needed for setting invoice ID) + # We assume reserve_number created the row if it didn't exist + participant = get_participant_by_user_id_and_step(user_id, "waiting_for_payment") + if not participant: + logger.error(f"Cannot find participant record for user {user_id}, raffle {raffle_id} in 'waiting_for_payment' step during confirmation.") + try: + await query.edit_message_caption("Error: No se encontró tu registro. Selecciona las papeletas de nuevo.", reply_markup=None) + except BadRequest: + await query.edit_message_text("Error: No se encontró tu registro. Selecciona las papeletas de nuevo.", reply_markup=None) + return + participant_db_id = participant['id'] + + price_per_number = raffle_info['price'] + if price_per_number is None: + logger.error(f"Price not found for raffle {raffle_id} during confirmation for user {user_id}") + await query.answer("Error: Ha habido un problema desconocido, contacta con el administrador.", show_alert=True) + return + + total_price = len(reserved_numbers) * price_per_number + + # Generate a unique invoice ID for PayPal + #invoice_id = str(uuid.uuid4()) + + current_timestamp = time.time() + paypal_link, invoice_id = create_paypal_order(get_paypal_access_token(), total_price) + 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://sandbox.paypal.com/cgi-bin/webscr?" + # f"cmd=_xclick&business={PAYPAL_EMAIL}" + # f"&item_name=Numeros Rifa 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"Papeletas reservadas: {', '.join(reserved_numbers)}\n" + f"Precio total: {total_price:.2f}€\n\n" + f"El ID de la factura es: {invoice_id}\n" + f"Pulsa el botón para completar el pago en PayPal:\n" # Adjusted instruction + # Link is now in the button below + f"⚠️ Tienes {RESERVATION_TIMEOUT_MINUTES} minutos para pagar antes de que las papeletas se liberen.\n\n" + f"Una vez hayas pagado, se te notificará aquí. El pago puede tardar hasta 5 minutos en procesarse, sé paciente." + ) + + try: + await query.edit_message_text( + text=payment_message, + reply_markup=payment_keyboard, # Use the keyboard with the button + disable_web_page_preview=True # Preview not needed as link is in button + # No parse_mode needed + ) + logger.debug(f"Edited message text (fallback) for user {user_id} with PayPal button.") + except Exception as text_e: + logger.error(f"Failed to edit message text as fallback for user {user_id}: {text_e}") + # Send new message with the button + await context.bot.send_message(user_id, payment_message, reply_markup=payment_keyboard, disable_web_page_preview=True) + +async def cancel_callback(update: Update, context: CallbackContext): + """Handles the 'Cancelar Selección' button click.""" + query = update.callback_query + user_id = query.from_user.id + + try: + raffle_id = int(query.data.split(":")[1]) + except (IndexError, ValueError): + logger.error(f"Invalid callback data received in cancel_callback: {query.data}") + await query.answer("Error: Acción inválida.") + return + + # Get currently reserved (waiting_for_payment) numbers for this user/raffle + reserved_numbers = get_reserved_numbers(user_id, raffle_id) + + if not reserved_numbers: + await query.answer("No tienes ninguna selección de papeletas 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 papeletas reservadas para cancelar.", reply_markup=None) + except BadRequest: # If message was text, not photo caption + await query.edit_message_text("No hay papeletas reservadas para cancelar.", reply_markup=None) + return + + # Cancel the reservation in the database + cancelled = cancel_reserved_numbers(user_id, raffle_id) # This function deletes the 'waiting_for_payment' row + + if cancelled: # Check if the function indicated success (e.g., return True or affected rows) + await query.answer(f"Selección cancelada. Papeletas liberadas: {', '.join(reserved_numbers)}") + logger.info(f"User {user_id} cancelled reservation for raffle {raffle_id}. Numbers: {reserved_numbers}") + # Edit the message to confirm cancellation + try: + await query.edit_message_caption("Tu selección de papeletas ha sido cancelada y las papeletas han sido liberadas.", reply_markup=None) + except BadRequest: + await query.edit_message_text("Tu selección de papeletas ha sido cancelada y las papeletas han sido liberadas.", reply_markup=None) + # Optionally, you could send the user back to the raffle selection list or number grid. + # For simplicity, we just end the interaction here. + else: + logger.error(f"Failed to cancel reservation in DB for user {user_id}, raffle {raffle_id}.") + await query.answer("Error al cancelar la reserva. Contacta con un administrador.") + # Don't change the message, let the user know there was an error + +# --- Helper Function for Ending Raffle (Refactored Logic) --- + +async def end_raffle_logic(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, winner_numbers: list[int], admin_user_id: int): + """Core logic to end a raffle, find winners, and announce.""" + raffle_details = get_raffle(raffle_id) # Gets basic info (name, description, active status) + if not raffle_details: + logger.error(f"Attempted to end non-existent raffle ID {raffle_id}") + try: + await context.bot.send_message(admin_user_id, f"Error: No se encontró la rifa 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"La rifa '{raffle_name}' ya estaba terminada.") + 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 la rifa '{raffle_name}' como terminada en la base de datos.") + except Exception as e: + logger.error(f"Failed to send DB error message to admin {admin_user_id}: {e}") + return False + + logger.info(f"Raffle '{raffle_name}' (ID: {raffle_id}) marked as ended by admin {admin_user_id}.") + + # Get winners and format announcement + channel_id_str = raffle_details['channel_id'] + channel_alias = REVERSE_CHANNELS.get(channel_id_str, f"ID:{channel_id_str}") + + winners_str = get_winners(raffle_id, winner_numbers) + formatted_winner_numbers = ", ".join(f"{n:02}" for n in sorted(winner_numbers)) + announcement = f"🎯🏆🎯 **¡Resultados de la Rifa '{raffle_name}'!** 🎯🏆🎯\n\n" + announcement += f"Detalles de la rifa: https://t.me/{channel_alias}/{get_main_message_id(raffle_id)}\n" + announcement += f"Papeletas ganadoras: **{formatted_winner_numbers}**\n\n" if len(winner_numbers) > 1 else f"Papeleta ganadora: **{formatted_winner_numbers}**\n\n" + if winners_str: # Ensure winners_str is not empty or a "no winners" message itself + announcement += f"Ganadores:\n{winners_str}\n\n¡Felicidades!" if len(winner_numbers) > 1 else f"Ganador:\n{winners_str}\n\n¡Felicidades!" + else: + announcement += "No hubo ganadores para estas papeletas." if len(winner_numbers) > 1 else "No hubo ganador para esta papeleta." + announcement += f"\nPuedes comprobar los resultados en {JUEGOS_ONCE_URL}" + announcement += "\n\nGracias a todos por participar. Mantente atento a futuras rifas." + + main_announcement = f"🎯🏆🎯 **Rifa '{raffle_name}' Terminada** 🎯🏆🎯\n\n" + main_announcement += f"{raffle_details['description']}\n\n" + main_announcement += f"💵 **Precio por papeleta:** {raffle_details['price']}€\n" + main_announcement += f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" + + main_message_id = get_main_message_id(raffle_id) + try: + await context.bot.send_message(chat_id=int(channel_id_str), text=announcement, parse_mode=ParseMode.MARKDOWN) + await context.bot.edit_message_caption(chat_id=int(channel_id_str), message_id=main_message_id, caption=main_announcement, reply_markup=None, parse_mode=ParseMode.MARKDOWN) + logger.info(f"Announced winners for raffle {raffle_id} in channel {channel_alias} (ID: {channel_id_str})") + except Forbidden: + logger.error(f"Permission error announcing winners in channel {channel_alias} (ID: {channel_id_str}).") + except BadRequest as e: + logger.error(f"Bad request announcing winners in channel {channel_alias} (ID: {channel_id_str}): {e}") + except Exception as e: + logger.error(f"Failed to announce winners in channel {channel_alias} (ID: {channel_id_str}): {e}") + + 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 una nueva rifa, por favor, inicia la conversación usando el comando:\n\n" + "/crear_rifa\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 = "**Rifas Activas**\n\nSelecciona una rifa para ver detalles, anunciar o terminar:" if active_raffles else "**Rifas Activas**\n\nNo hay rifas activas." + 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 rifa 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 la rifa {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 rifas:", reply_markup=keyboard) + except (ValueError, IndexError): + logger.error(f"Invalid callback data for announce raffle: {data}") + await query.edit_message_text("Error: ID de rifa 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 rifa 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("Esta rifa 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 la rifa: **{raffle['name']}**\n\n" + "Por favor, envía ahora las **papeletas ganadoras** separadas 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("**Rifas Activas**\n\nSelecciona una rifa para terminarla:", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) + + elif data == ADMIN_NO_OP: + # Just ignore clicks on placeholder buttons like "No hay rifas activas" + 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"❌ Papeletas inválidas: {e}\n\n" + "Por favor, envía las papeletas ganadoras (0-99) separadas 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 papeletas: {', '.join(f'{n:02}' for n in winner_numbers)}...") + success = await end_raffle_logic(context, raffle_id_to_end, winner_numbers, user_id) + + # Send admin back to the main menu after processing + keyboard = generate_admin_main_menu_keyboard() + await context.bot.send_message(chat_id=user_id, text="Volviendo al Menú Principal...", reply_markup=keyboard) + +async def _announce_raffle_in_channels(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, admin_user_id: int, initial_announcement: bool = False): + """Fetches raffle details and sends announcement to its configured channels.""" + raffle = get_raffle(raffle_id) + if not raffle: + logger.warning(f"Admin {admin_user_id} tried to announce non-existent raffle {raffle_id}.") + try: + await context.bot.send_message(admin_user_id, f"Error: El ID de la rifa {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: La rifa '{raffle['name']}' (ID {raffle_id}) no está activa 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 + price = raffle['price'] + channel_id_str = raffle['channel_id'] + + # Get remaining numbers ONCE before the loop for efficiency + remaining_count = get_remaining_numbers_amount(raffle_id) + + logger.info(f"Admin {admin_user_id} initiating {'initial ' if initial_announcement else 're-'}announcement for raffle {raffle_id} ('{raffle_name}')") + + channel_alias = REVERSE_CHANNELS.get(channel_id_str, f"ID:{channel_id_str}") + + announce_caption = ( + f"🏆 **¡{'Nueva ' if initial_announcement else ''}Rifa Disponible!** 🏆\n\n" + f"🌟 **{raffle_name}** 🌟\n\n" + f"{raffle_description}\n\n" + f"💵 **Precio por papeleta:** {price}€\n" + f"🎟️ **Papeletas disponibles:** {remaining_count if remaining_count >= 0 else 'N/A'}\n\n" + f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" + ) + + message_args = {"parse_mode": ParseMode.MARKDOWN} + if image_file_id: + message_args["photo"] = image_file_id + message_args["caption"] = announce_caption + message_args["reply_markup"] = generate_channel_participate_keyboard(raffle_id) + send_method = context.bot.send_photo + else: + message_args["text"] = announce_caption + message_args["reply_markup"] = generate_channel_participate_keyboard(raffle_id) + send_method = context.bot.send_message + + sent_message = None # Initialize sent_message variable + try: + # --- 1. Send the message --- + sent_message = await send_method(chat_id=int(channel_id_str), **message_args) + store_main_message_id(raffle_id, sent_message.message_id) + logger.info(f"Announcement sent to channel {channel_alias} (ID: {channel_id_str}) for raffle {raffle_id}.") + + # --- 2. Attempt to pin the sent message --- + try: + # Disable notification for re-announcements or if initial is false + # Enable notification for the very first announcement. + disable_pin_notification = not initial_announcement + await context.bot.pin_chat_message( + chat_id=int(channel_id_str), + message_id=sent_message.message_id, + disable_notification=disable_pin_notification + ) + logger.info(f"Pinned announcement message {sent_message.message_id} in channel {channel_alias}.") + except Forbidden as pin_e_forbidden: + logger.warning(f"Could not pin message in channel {channel_alias} (Forbidden): {pin_e_forbidden}") + except BadRequest as pin_e_bad_request: + logger.warning(f"Could not pin message in channel {channel_alias} (Bad Request, e.g. no messages to pin): {pin_e_bad_request}") + except Exception as pin_e: + logger.warning(f"Could not pin message {sent_message.message_id if sent_message else 'N/A'} in channel {channel_alias}: {pin_e}") + + except Forbidden: + logger.error(f"Permission error: Cannot send announcement to channel {channel_alias} (ID: {channel_id_str}).") + except BadRequest as e: + logger.error(f"Bad request sending announcement to channel {channel_alias} (ID: {channel_id_str}): {e}") + except Exception as e: + logger.error(f"Failed to send announcement to channel {channel_alias} (ID: {channel_id_str}): {e}") + + try: + msg_to_admin = "Anuncio enviado con éxito." + await context.bot.send_message(admin_user_id, msg_to_admin, parse_mode=ParseMode.MARKDOWN) + except Exception as e: + logger.error(f"Failed to send announcement summary to admin {admin_user_id}: {e}") + + return True diff --git a/app/helpers.py b/app/helpers.py new file mode 100644 index 0000000..1842e8d --- /dev/null +++ b/app/helpers.py @@ -0,0 +1,441 @@ +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 +from requests.auth import HTTPBasicAuth + +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ó la rifa." + + details = ( + f"ℹ️ **Detalles de la rifa** ℹ️\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" + f"**Precio por número (canal principal):** {raffle['price']}€\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 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: Rifa {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"Rifa: {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 generate_table_image(raffle_id): + """Generates a fancier 10x10 raffle grid image with participant names and legend.""" + + # Parameters + cols, rows = 10, 10 + cell_width, cell_height = 120, 60 + title_height_space = 90 + title_bottom_padding = 30 # extra space between title and grid + legend_height_space = 80 + margin_x = 40 # left/right margin + image_width = cols * cell_width + margin_x * 2 + image_height = rows * cell_height + title_height_space + title_bottom_padding + legend_height_space + background_color = "#fdfdfd" + grid_line_color = "#666666" + free_bg_color = "#e8f0ff" + reserved_bg_color = "#ffe8e8" + taken_bg_color = "#e8ffe8" + font_size = 16 + title_font_size = 28 + legend_font_size = 18 + + # Create base image + img = Image.new("RGB", (image_width, image_height), background_color) + draw = ImageDraw.Draw(img) + + # Load fonts + try: + font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" + if not os.path.exists(font_path): + font_path = "arial.ttf" + font = ImageFont.truetype(font_path, font_size) + title_font = ImageFont.truetype(font_path, title_font_size) + legend_font = ImageFont.truetype(font_path, legend_font_size) + except IOError: + font = ImageFont.load_default() + title_font = ImageFont.load_default() + legend_font = ImageFont.load_default() + + # --- Title Bar --- + raffle_details = get_raffle(raffle_id) + if not raffle_details: + draw.text((10, 10), f"Error: Rifa {raffle_id} no encontrada", fill="red", font=title_font) + img.save(f"/app/data/raffles/raffle_table_{raffle_id}_error.png") + return False + + raffle_name = raffle_details['name'] + title_text = f"Rifa: {raffle_name}" + + # Draw title bar (full width) + title_bar_color = "#4a90e2" + draw.rectangle([0, 0, image_width, title_height_space], fill=title_bar_color) + + # Centered title text + 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] + title_x = (image_width - title_width) / 2 + title_y = (title_height_space - title_height) / 2 + draw.text((title_x, title_y), title_text, fill="white", font=title_font) + + # --- Participants --- + participants = get_participants(raffle_id) + number_status = {} + + 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']}" + status = p['step'] + 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) + + # --- Grid --- + grid_top = title_height_space + title_bottom_padding + for i in range(rows): + for j in range(cols): + num = i * cols + j + x1 = margin_x + j * cell_width + y1 = grid_top + i * cell_height + x2 = x1 + cell_width + y2 = y1 + cell_height + + # Background color + if num in number_status: + owner, status = number_status[num] + bg_color = reserved_bg_color if status == "waiting_for_payment" else taken_bg_color + text_color = "#000000" + else: + owner, bg_color, text_color = "", free_bg_color, "#1a4db3" + + # Rounded rectangle cell + radius = 12 + draw.rounded_rectangle([x1+1, y1+1, x2-1, y2-1], radius, outline=grid_line_color, fill=bg_color) + + # Draw number + number_text = f"{num:02}" + draw.text((x1+10, y1+8), number_text, fill=text_color, font=font) + + # Draw owner + if owner: + max_name_len = 12 + owner_text = owner[:max_name_len] + ('…' if len(owner) > max_name_len else '') + draw.text((x1+10, y1+8+font_size+4), owner_text, fill=text_color, font=font) + + # --- Legend --- + legend_y = image_height - legend_height_space + 20 + legend_items = [ + ("Libre", free_bg_color), + ("Reservado", reserved_bg_color), + ("Pagado", taken_bg_color) + ] + + spacing = 280 # more spacing between legend items + start_x = (image_width - (spacing * (len(legend_items)-1) + 140)) / 2 + + for i, (label, color) in enumerate(legend_items): + box_x = start_x + i * spacing + box_y = legend_y + box_w, box_h = 34, 34 + + # Color box + draw.rounded_rectangle([box_x, box_y, box_x+box_w, box_y+box_h], 6, fill=color, outline=grid_line_color) + + # Label text + draw.text((box_x + box_w + 14, box_y + 6), label, fill="black", font=legend_font) + + # Save + os.makedirs("/app/data/raffles", exist_ok=True) + image_path = f"/app/data/raffles/raffle_table_{raffle_id}.png" + img.save(image_path) + return True + + +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 la papeleta: {num_list[0]}" + else: + line = f" - {escape_markdown_v2_chars_for_username(user_name)}, con las papeletas: {', '.join(num_list)}" + formatted_lines.append(line) + + return "\n".join(formatted_lines) # Add a trailing newline + +def get_paypal_access_token(): + old_token = get_paypal_access_token_db() + if old_token: + logger.info(f"Using cached PayPal access token") + return old_token + logger.info("Fetching new PayPal access token") + url = "https://api-m.sandbox.paypal.com/v1/oauth2/token" + headers = {"Accept": "application/json", "Accept-Language": "en_US"} + data = {"grant_type": "client_credentials"} + + response = requests.post(url, headers=headers, data=data, + auth=HTTPBasicAuth(PAYPAL_CLIENT_ID, PAYPAL_SECRET)) + response.raise_for_status() + store_paypal_access_token(response.json()["access_token"], response.json()["expires_in"]) + return response.json()["access_token"] + +def create_paypal_order(access_token, value): + url = "https://api-m.sandbox.paypal.com/v2/checkout/orders" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}" + } + payload = { + "intent": "CAPTURE", + "purchase_units": [ + { + "amount": {"currency_code": "EUR", "value": f"{value:.2f}"} + } + ], + "application_context": { + "return_url": f"https://t.me/{BOT_NAME}", + "cancel_url": f"https://t.me/{BOT_NAME}" + } + } + + response = requests.post(url, headers=headers, json=payload) + response.raise_for_status() + order = response.json() + + # Extract the approval link + approval_url = next(link["href"] for link in order["links"] if link["rel"] == "approve") + return approval_url, order["id"] diff --git a/app/keyboards.py b/app/keyboards.py new file mode 100644 index 0000000..0f0db5e --- /dev/null +++ b/app/keyboards.py @@ -0,0 +1,163 @@ +from telegram import InlineKeyboardMarkup, InlineKeyboardButton +from database import get_participants, get_active_raffles +from config import * + +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 Nueva Rifa", callback_data=ADMIN_MENU_CREATE)], + [InlineKeyboardButton("📋 Listar/Gestionar Rifas", callback_data=ADMIN_MENU_LIST)], + ] + return InlineKeyboardMarkup(keyboard) + + +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 rifas activas.", 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 Rifa", 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(): + """Generates keyboard for admin to select target channels.""" + + buttons = [] + for alias, channel_id in CHANNELS.items(): + text = alias + buttons.append([InlineKeyboardButton(text, callback_data=f"{SELECT_CHANNEL_PREFIX}{channel_id}")]) + + return InlineKeyboardMarkup(buttons) + +def generate_confirmation_keyboard(): + """Generates Yes/No keyboard for final confirmation.""" + keyboard = [ + [ + InlineKeyboardButton("✅ Sí, crear rifa", callback_data=CONFIRM_CREATION_CALLBACK), + InlineKeyboardButton("❌ No, cancelar", callback_data=CANCEL_CREATION_CALLBACK), + ] + ] + return InlineKeyboardMarkup(keyboard) + +def generate_channel_participate_keyboard(raffle_id): + # The deep link URL opens a private chat with the bot + url = f"https://t.me/{BOT_NAME }?start=join_{raffle_id}" + keyboard = [[InlineKeyboardButton("✅ ¡Participar Ahora! ✅", url=url)]] + 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..2bb81bb --- /dev/null +++ b/app/paypal_processor.py @@ -0,0 +1,402 @@ +# paypal_processor.py + +from flask import Flask, request +import logging +import requests +import json +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, get_paypal_access_token +from database import ( + get_user_by_invoice_id, confirm_reserved_numbers, + get_raffle_name, get_raffle, + get_remaining_numbers_amount, + store_main_message_id, get_main_message_id, + store_update_message_id, get_update_message_id, + get_last_n_other_participants, get_remaining_numbers +) +from config import BOT_TOKEN, BOT_NAME, WEBHOOK_ID, TYC_DOCUMENT_URL, REVERSE_CHANNELS + +app = Flask(__name__) + +# Enable logging +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO +) +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, keyboard=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 keyboard: # Add inline keyboard if provided + data['reply_markup'] = json.dumps(keyboard) + 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, payment_status, payment_amount_str): + """Processes verified PayPal payment data.""" + logger.info(f"Processing payment for Invoice: {invoice_id}, Status: {payment_status}, Amount: {payment_amount_str}") + + # 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'] + raffle_details = get_raffle(raffle_id) + price_per_number = raffle_details['price'] + channel_id_to_announce = raffle_details['channel_id'] + update_message_id = raffle_details['update_message_id'] + + 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"La rifa 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, invoice_id): + logger.info(f"Successfully confirmed numbers {numbers} for user {user_id} (Name: {current_user_name}), raffle {raffle_id} with Invoice {invoice_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 a la rifa '{raffle_name}' con las papeletas: {', '.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) + + # If it's the last number, update the main message and delete the participate button + if remaining_numbers_amount == 0: + keyboard = None + main_announcement = f"🎯🏆🎯 **Rifa '{raffle_name}' Terminada** 🎯🏆🎯\n\n" + main_announcement += f"{raffle_details['description']}\n\n" + main_announcement += f"💵 **Precio por papeleta:** {raffle_details['price']}€\n" + main_announcement += f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" + main_message_id = get_main_message_id(raffle_id) + requests.post(f"{TELEGRAM_API_URL}/editMessageCaption", json={ + "chat_id": channel_id_to_announce, + "message_id": main_message_id, + "caption": main_announcement, + "reply_markup": keyboard, + "parse_mode": "Markdown" + }) + else: + url = f"https://t.me/{BOT_NAME}?start=join_{raffle_id}" + keyboard = { + "inline_keyboard": [ + [ + {"text": "✅ ¡Participar Ahora! ✅", "url": url} + ] + ] + } + main_announcement = f"🏆 Rifa '{raffle_name}' en progreso 🏆\n\n" + main_announcement += f"{raffle_details['description']}\n\n" + main_announcement += f"💵 **Precio por papeleta:** {raffle_details['price']}€\n" + main_announcement += f"🗒️ Quedan {remaining_numbers_amount} papeletas disponibles. ¡Date prisa! 🗒️\n\n" + main_announcement += f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" + main_message_id = get_main_message_id(raffle_id) + requests.post(f"{TELEGRAM_API_URL}/editMessageCaption", json={ + "chat_id": channel_id_to_announce, + "message_id": main_message_id, + "caption": main_announcement, + "reply_markup": keyboard, + "parse_mode": "Markdown" + }) + + last_other_participants = get_last_n_other_participants(raffle_id, n=4) + last_participants_text = format_last_participants_list(last_other_participants) + + escaped_current_user_name = escape_markdown_v2_chars_for_username(current_user_name) + + numbers_text = "" + if len(numbers) > 1: + numbers_text = f"con las papeletas: {', '.join(numbers)}" + else: + numbers_text = f"con la papeleta: {', '.join(numbers)}" + + new_participant_line = f"🗳️ @{escaped_current_user_name} se ha unido a la rifa {numbers_text}. ¡Mucha suerte! 🗳️" + + remaining_numbers_text = "" + if remaining_numbers_amount > 10: + remaining_numbers_text = f"🗒️ Todavía hay {remaining_numbers_amount} papeletas. 🗒️" + elif remaining_numbers_amount == 1: + remaining_numbers = get_remaining_numbers(raffle_id) + remaining_numbers_text = f"⏰⏰⏰ ¡Última papeleta! ⏰⏰⏰\n\n" + remaining_numbers_text += f"Queda la papeleta: {remaining_numbers[0]}" + elif remaining_numbers_amount == 0: + remaining_numbers_text = "⌛ ¡Ya no hay papeletas! ⌛\n\n" + remaining_numbers_text += "¡El resultado de la rifa se dará a conocer a las 21:45h!" + else: + remaining_numbers = get_remaining_numbers(raffle_id) + remaining_numbers_text = f"🔔🔔🔔 ¡Últimas {remaining_numbers_amount} papeletas disponibles! 🔔🔔🔔\n\n" + remaining_numbers_text += f"Quedan las papeletas: {', '.join(remaining_numbers)}" + + caption = ( + f"{new_participant_line}\n\n" + f"{last_participants_text}\n\n" + f"{remaining_numbers_text}\n\n" + f"{raffle_description_for_announce}\n\n" + f"🔎 Ver detalles: https://t.me/{REVERSE_CHANNELS.get(channel_id_to_announce)}/{get_main_message_id(raffle_id)}\n\n" + f"💵 Precio por papeleta: {price_per_number}€\n\n" # Use the specific price + f"📜 Normas y condiciones: {TYC_DOCUMENT_URL} \n\n" + ) + + + update_message_id = get_update_message_id(raffle_id) + sent_or_edited_message_id = None + + if update_message_id: + logger.info(f"Attempting to edit message {update_message_id} in channel {channel_id_to_announce}") + + # Try deleting old message first + try: + delete_payload = {'chat_id': channel_id_to_announce, 'message_id': update_message_id} + delete_response = requests.post(f"{TELEGRAM_API_URL}/deleteMessage", data=delete_payload) + if delete_response.status_code == 200: + logger.info(f"Successfully deleted old message {update_message_id} in channel {channel_id_to_announce}") + else: + logger.warning(f"Failed to delete old message {update_message_id} in channel {channel_id_to_announce}: {delete_response.text}. Will send new.") + except Exception as e_del: + logger.warning(f"Error deleting old message {update_message_id}: {e_del}. Will send new.") + + # Always send new photo after delete attempt, ensures updated image is shown + new_msg_info = send_telegram_photo(channel_id_to_announce, image_path, caption=caption, keyboard=keyboard, parse_mode='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, keyboard=keyboard, 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_update_message_id(raffle_id, sent_or_edited_message_id) + + # Send image confirmation to user (price not needed in this caption) + user_caption = f"¡Apuntado satisfactoriamente a la rifa '{raffle_name}'! Tus números son: {', '.join(numbers)}" + send_telegram_photo(user_id, image_path, caption=user_caption) + + else: + logger.error(f"Failed to generate raffle table image for {raffle_id} after payment.") + + 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"Error al procesar la factura {invoice_id}. " + f"Por favor, contacta con un administrador y dale 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(): + # 1. Raw JSON body + body = request.get_data(as_text=True) + + # 2. Required headers + headers = { + "paypal-auth-algo": request.headers.get("PAYPAL-AUTH-ALGO"), + "paypal-cert-url": request.headers.get("PAYPAL-CERT-URL"), + "paypal-transmission-id": request.headers.get("PAYPAL-TRANSMISSION-ID"), + "paypal-transmission-sig": request.headers.get("PAYPAL-TRANSMISSION-SIG"), + "paypal-transmission-time": request.headers.get("PAYPAL-TRANSMISSION-TIME"), + } + + # 3. Verify signature + access_token = get_paypal_access_token() + verify_url = "https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature" + + payload = { + "auth_algo": headers["paypal-auth-algo"], + "cert_url": headers["paypal-cert-url"], + "transmission_id": headers["paypal-transmission-id"], + "transmission_sig": headers["paypal-transmission-sig"], + "transmission_time": headers["paypal-transmission-time"], + "webhook_id": WEBHOOK_ID, + "webhook_event": request.json + } + + response = requests.post(verify_url, json=payload, + headers={"Content-Type": "application/json", + "Authorization": f"Bearer {access_token}"}) + response.raise_for_status() + verification_status = response.json()["verification_status"] + + if verification_status == "SUCCESS": + event = request.json + event_type = event["event_type"] + logger.info(f"EVENT DATA: {event}") + if event_type == "CHECKOUT.ORDER.APPROVED": + # process approved order + logger.info(f"✅ Order approved: {event['resource']['id']}") + resource = event["resource"] + invoice_id = resource.get("id") # capture ID + payment_status = resource.get("status") # e.g. COMPLETED + payment_amount = resource["purchase_units"][0]["amount"]["value"] + + if not all([invoice_id, payment_status, payment_amount]): + logger.warning(f"Missing one or more required fields in VERIFIED IPN data: {resource}") + return "Missing Fields", 200 # Acknowledge receipt but don't process + + # Process the valid payment + receive_paypal_payment(invoice_id, payment_status, payment_amount) + elif event_type == "PAYMENT.CAPTURE.COMPLETED": + logger.info(f"✅ Payment completed: {event['resource']['id']}") + # Extract key fields (adjust keys based on your PayPal setup/IPN variables) + else: + logger.info(f"ℹ️ Received event: {event_type}") + else: + logger.info("⚠️ Webhook verification failed") + + return "", 200 + +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..4db9ce2 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,8 @@ +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 +pytz==2025.2 +beautifulsoup4==4.13.5 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e2bbb65 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + telerifas: + build: + context: app + dockerfile: Dockerfile + image: telerifas + container_name: telerifas + 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} + - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID} + - PAYPAL_SECRET=${PAYPAL_SECRET} + - WEBHOOK_URL=${WEBHOOK_URL} + - WEBHOOK_ID=${WEBHOOK_ID} + - TYC_DOCUMENT_URL=${TYC_DOCUMENT_URL} + telerifas_paypal_processor: + build: + context: app + dockerfile: Dockerfile.paypal_processor + image: telerifas_paypal_processor + container_name: telerifas_paypal_processor + volumes: + - ./data:/app/data + restart: unless-stopped + environment: + - TZ="Europe/Madrid" + - BOT_TOKEN=${BOT_TOKEN} + - BOT_NAME=${BOT_NAME} + - CHANNEL_IDS=${CHANNEL_IDS} + - PAYPAL_EMAIL=${PAYPAL_EMAIL} + - PAYPAL_HANDLE=${PAYPAL_HANDLE} + - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID} + - PAYPAL_SECRET=${PAYPAL_SECRET} + - WEBHOOK_URL=${WEBHOOK_URL} + - WEBHOOK_ID=${WEBHOOK_ID} + - TYC_DOCUMENT_URL=${TYC_DOCUMENT_URL} + networks: + - traefik + labels: + - traefik.enable=true + - traefik.http.routers.telerifas-http.entrypoints=web + - traefik.http.routers.telerifas-http.rule=Host(`telerifas.patacuack.net`) + - traefik.http.routers.telerifas-http.middlewares=https-redirect@file + - traefik.http.routers.telerifas.entrypoints=websecure + - traefik.http.routers.telerifas.rule=Host(`telerifas.patacuack.net`) + - traefik.http.routers.telerifas.tls=true + - traefik.http.routers.telerifas.tls.certResolver=production + - traefik.http.services.telerifas.loadbalancer.server.port=5000 + +networks: + traefik: + external: true diff --git a/example.env b/example.env new file mode 100644 index 0000000..1333c8e --- /dev/null +++ b/example.env @@ -0,0 +1,11 @@ +BOT_TOKEN= +BOT_NAME= +ADMIN_IDS= +CHANNEL_IDS= +PAYPAL_EMAIL= +PAYPAL_HANDLE= +PAYPAL_CLIENT_ID= +PAYPAL_SECRET= +WEBHOOK_URL= +WEBHOOK_ID= +TYC_DOCUMENT_URL= \ No newline at end of file