Initial commit

This commit is contained in:
Joan
2026-02-22 12:56:42 +01:00
commit 50eb4f7eca
11 changed files with 3455 additions and 0 deletions

10
app/Dockerfile Normal file
View File

@@ -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" ]

View File

@@ -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" ]

213
app/app.py Normal file
View File

@@ -0,0 +1,213 @@
# app.py
import logging
import time
# REMOVE: from datetime import timedelta # Not needed directly for run_repeating interval
from telegram import Update
from telegram.ext import (
Application,
CommandHandler,
CallbackQueryHandler,
MessageHandler,
ConversationHandler,
filters,
ContextTypes,
)
from telegram.error import Forbidden, BadRequest
from newrelic_telemetry_sdk import Log, LogClient
# Import handlers and db/config
from handlers import *
from database import init_db, get_expired_reservations, cancel_reservation_by_id
from config import *
# Logging setup
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO
)
# Suppress overly verbose logs from libraries
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("apscheduler").setLevel(logging.WARNING)
client = LogClient(license_key=NEWRELIC_API_KEY, host="log-api.eu.newrelic.com")
class NewRelicHandler(logging.Handler):
def emit(self, record):
try:
log = Log(
message=self.format(record),
level=record.levelname,
timestamp_ms=int(record.created * 1000),
attributes={
"logger": record.name,
"app_name": BOT_NAME,
"docker_container": "telerifas"
}
)
client.send(log)
except Exception:
self.handleError(record)
nr_handler = NewRelicHandler()
nr_handler.setFormatter(logging.Formatter("%(message)s"))
root_logger = logging.getLogger()
root_logger.addHandler(nr_handler)
logger = logging.getLogger(__name__)
# Scheduler Job (Callback for PTB Job Queue)
async def check_expired_reservations(context: ContextTypes.DEFAULT_TYPE):
"""Job callback to cancel expired reservations and notify users."""
# context is automatically provided by PTB's Job Queue
# No need to get app from context explicitly here for bot access,
# context.bot can be used directly.
now = time.time()
timeout_seconds = RESERVATION_TIMEOUT_MINUTES * 60
expiry_threshold = now - timeout_seconds
logger.info(f"Running check_expired_reservations (Threshold: {expiry_threshold})")
expired_list = get_expired_reservations(expiry_threshold) # DB function remains the same
if not expired_list:
logger.info("No expired reservations found.")
return
logger.info(f"Found {len(expired_list)} expired reservations to cancel.")
cancelled_count = 0
for reservation in expired_list:
participant_id = reservation['id']
user_id = reservation['user_id']
raffle_id = reservation['raffle_id']
raffle_name = reservation['raffle_name']
numbers = reservation['numbers']
logger.warning(f"Expiring reservation for User ID: {user_id}, Raffle: {raffle_name} ({raffle_id}), Numbers: {numbers}")
# Cancel the reservation in the database first
if cancel_reservation_by_id(participant_id):
cancelled_count += 1
# Try to notify the user using context.bot
notification_text = (
f"⚠️ Tu reserva para el sorteo **{raffle_name}** ha expirado.\n\n"
f"Los números `{numbers}` han sido liberados porque el pago no se completó a tiempo.\n\n"
f"Puedes volver a intentarlo usando /sorteo en el grupo correspondiente."
)
try:
await context.bot.send_message(chat_id=user_id, text=notification_text, parse_mode=ParseMode.MARKDOWN)
logger.info(f"Notified user {user_id} about expired reservation.")
except Forbidden:
logger.warning(f"Cannot notify user {user_id} (Forbidden). Reservation cancelled anyway.")
except BadRequest as e:
logger.error(f"BadRequest notifying user {user_id}: {e}")
except Exception as e:
logger.error(f"Failed to notify user {user_id} about expiry: {e}")
else:
logger.error(f"Failed to cancel reservation ID {participant_id} in DB (might have been processed already).")
logger.info(f"Finished check_expired_reservations. Cancelled {cancelled_count} reservations.")
# --- Main Function ---
def main():
init_db()
app = Application.builder().token(BOT_TOKEN).build()
logger.info("Bot application built.")
# --- Use PTB's Job Queue ---
job_queue = app.job_queue
job_interval_seconds = 5 * 60 # Check every 5 minutes
# Add the repeating job
job_queue.run_repeating(
callback=check_expired_reservations,
interval=job_interval_seconds,
first=10, # Start after 10 seconds (optional)
name="expire_check_job" # Optional name for the job
)
logger.info(f"Scheduled reservation check job to run every {job_interval_seconds} seconds.")
# --- Handlers (Remain the same) ---
# 1. Raffle Creation Conversation Handler
raffle_creation_conv = ConversationHandler(
entry_points=[CommandHandler("crear_sorteo", new_raffle_start)],
states={
SELECTING_CHANNELS: [CallbackQueryHandler(select_channels, pattern=f"^{SELECT_CHANNEL_PREFIX}.*")],
TYPING_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, receive_title)],
TYPING_DESCRIPTION: [MessageHandler(filters.TEXT & ~filters.COMMAND, receive_description)],
SENDING_IMAGE: [
MessageHandler(filters.PHOTO, receive_image),
MessageHandler(filters.TEXT & ~filters.COMMAND, incorrect_input_type)
],
TYPING_PRICE_FOR_CHANNELS: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_price_for_channel)
],
CONFIRMING_CREATION: [
CallbackQueryHandler(confirm_creation, pattern=f"^{CONFIRM_CREATION_CALLBACK}$"),
CallbackQueryHandler(confirm_creation, pattern=f"^{CANCEL_CREATION_CALLBACK}$"),
],
},
fallbacks=[
CommandHandler("cancelar", cancel_creation_command),
MessageHandler(filters.TEXT & ~filters.COMMAND, incorrect_input_type),
],
)
app.add_handler(raffle_creation_conv)
# --- Raffle Edit Conversation Handler ---
raffle_edit_conv = ConversationHandler(
entry_points=[CallbackQueryHandler(admin_edit_select_raffle, pattern=f"^{ADMIN_EDIT_RAFFLE_SELECT}$")], # Triggered by main menu
states={
EDIT_SELECT_RAFFLE: [ # State after clicking "Edit Raffle"
CallbackQueryHandler(admin_edit_selected_raffle, pattern=f"^{ADMIN_EDIT_RAFFLE_PREFIX}\d+$")
],
EDIT_SELECT_NEW_CHANNELS: [
CallbackQueryHandler(admin_edit_select_new_channels, pattern=f"^{SELECT_CHANNEL_PREFIX}.*")
],
EDIT_TYPING_PRICE_FOR_NEW_CHANNELS: [
MessageHandler(filters.TEXT & ~filters.COMMAND, admin_edit_receive_price_for_new_channel)
],
EDIT_CONFIRM: [
CallbackQueryHandler(admin_confirm_edit_action, pattern="^(confirm_edit_action|cancel_edit_action)$")
],
},
fallbacks=[CommandHandler("cancelar_edicion", cancel_edit_command), # Specific cancel for this flow
CommandHandler("cancelar", cancel_edit_command)], # General cancel
map_to_parent={ # Example: If edit is cancelled, go back to main admin menu flow if possible
# ConversationHandler.END: ADMIN_MENU_LIST # Or handle return state differently
}
)
app.add_handler(raffle_edit_conv)
app.add_handler(CommandHandler("start", start, filters=filters.ChatType.PRIVATE))
# 2. Admin Menu Handlers
app.add_handler(CommandHandler("menu", admin_menu, filters=filters.ChatType.PRIVATE))
# Refined pattern to avoid clash with user number selection if prefixes overlap heavily
admin_pattern = (
f"^({ADMIN_MENU_CREATE}|{ADMIN_MENU_LIST}|{ADMIN_MENU_BACK_MAIN}|"
f"{ADMIN_VIEW_RAFFLE_PREFIX}\d+|{ADMIN_ANNOUNCE_RAFFLE_PREFIX}\d+|"
f"{ADMIN_END_RAFFLE_PROMPT_PREFIX}\d+|{ADMIN_CANCEL_END_PROCESS}|{ADMIN_NO_OP}|"
f"{ADMIN_EDIT_RAFFLE_SELECT})$"
)
app.add_handler(CallbackQueryHandler(admin_menu_callback, pattern=admin_pattern))
app.add_handler(MessageHandler(filters.TEXT & filters.ChatType.PRIVATE & ~filters.COMMAND, admin_receive_winner_numbers))
# 4. User command
app.add_handler(CommandHandler("sorteo", enter_raffle)) # Add filters=filters.ChatType.GROUPS if desired
# 5. User Callbacks
app.add_handler(CallbackQueryHandler(raffle_selected, pattern=r"^join:\d+$"))
app.add_handler(CallbackQueryHandler(number_callback, pattern=r"^(number:\d+:(\d+|prev|next)|random_num:\d+)$"))
#app.add_handler(CallbackQueryHandler(number_callback, pattern=r"^number:\d+:(?:\d{1,2}|prev|next)$"))
app.add_handler(CallbackQueryHandler(confirm_callback, pattern=r"^confirm:\d+$"))
app.add_handler(CallbackQueryHandler(cancel_callback, pattern=r"^cancel:\d+$"))
# --- Start Bot ---
# REMOVE: scheduler.start() # No longer needed
logger.info("Starting bot polling...")
app.run_polling(allowed_updates=Update.ALL_TYPES)
logger.info("Bot stopped.")
if __name__ == "__main__":
main()

47
app/config.py Normal file
View File

@@ -0,0 +1,47 @@
from dotenv import load_dotenv
import os
import logging
# Load environment variables
load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN")
BOT_NAME = os.getenv("BOT_NAME")
ADMIN_IDS = list(map(int, os.getenv("ADMIN_IDS", "1").split(','))) # Comma-separated list of admin IDs
CHANNELS_IDS = list(os.getenv("CHANNEL_IDS", "1/test").split(',')) # Comma-separated channel IDs
# Create a dictionary { 'channel_alias': 'channel_id' }
CHANNELS = {channel.split('/')[1]: channel.split('/')[0] for channel in CHANNELS_IDS}
# Create a reverse dictionary { 'channel_id': 'channel_alias' } for display/lookup
REVERSE_CHANNELS = {v: k for k, v in CHANNELS.items()}
DATABASE_PATH = "/app/data/raffles.db"
PAYPAL_EMAIL = os.getenv("PAYPAL_EMAIL")
PAYPAL_CLIENT_ID = os.getenv("PAYPAL_CLIENT_ID")
PAYPAL_SECRET = os.getenv("PAYPAL_SECRET")
PAYPAL_HANDLE = os.getenv("PAYPAL_HANDLE")
WEBHOOK_URL = os.getenv("WEBHOOK_URL")
RESERVATION_TIMEOUT_MINUTES = 30 # e.g., 30 minutes
TYC_URL = os.getenv("TYC_URL")
NEWRELIC_API_KEY = os.getenv("NEWRELIC_API_KEY")
# Conversation States for Raffle Creation
(SELECTING_CHANNELS, TYPING_TITLE, TYPING_DESCRIPTION, TYPING_PRICE_FOR_CHANNELS, SENDING_IMAGE, CONFIRMING_CREATION) = range(6)
# Conversation States for Editing Raffles
(EDIT_SELECT_RAFFLE, EDIT_SELECT_NEW_CHANNELS, EDIT_TYPING_PRICE_FOR_NEW_CHANNELS, EDIT_CONFIRM) = range(6, 10)
# Callback Data Prefixes
SELECT_CHANNEL_PREFIX = "select_channel_"
CONFIRM_CREATION_CALLBACK = "confirm_creation"
CANCEL_CREATION_CALLBACK = "cancel_creation"
# --- Admin Menu Callback Data ---
ADMIN_MENU_CREATE = "admin_create_raffle"
ADMIN_MENU_LIST = "admin_list_raffles"
ADMIN_MENU_BACK_MAIN = "admin_back_to_main_menu"
ADMIN_END_RAFFLE_PROMPT_PREFIX = "admin_end_prompt:" # + raffle_id
ADMIN_CANCEL_END_PROCESS = "admin_cancel_end"
ADMIN_VIEW_RAFFLE_PREFIX = "admin_view_raffle:" # + raffle_id (NEW)
ADMIN_ANNOUNCE_RAFFLE_PREFIX = "admin_announce_raffle:" # + raffle_id (NEW)
ADMIN_NO_OP = "admin_no_op" # Placeholder for buttons that do nothing on click
ADMIN_EDIT_RAFFLE_SELECT = "admin_edit_select" # New: Select raffle to edit
ADMIN_EDIT_RAFFLE_PREFIX = "admin_edit_raffle:" # New: Prefix for raffle ID to edit
# --- End Admin Menu ---

746
app/database.py Normal file
View File

@@ -0,0 +1,746 @@
# database.py
import sqlite3
import logging
import time # Import time for timestamps
from config import DATABASE_PATH
logger = logging.getLogger(__name__)
# --- Database Initialization ---
def init_db():
conn = sqlite3.connect(DATABASE_PATH)
cur = conn.cursor()
# ... (raffles table definition remains the same) ...
cur.execute("""
CREATE TABLE IF NOT EXISTS raffles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
image_file_id TEXT,
active INTEGER DEFAULT 1
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS raffle_channel_prices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raffle_id INTEGER NOT NULL,
channel_id TEXT NOT NULL, -- Storing channel ID as TEXT
price INTEGER NOT NULL,
FOREIGN KEY (raffle_id) REFERENCES raffles(id) ON DELETE CASCADE,
UNIQUE (raffle_id, channel_id)
)
""")
# Add reservation_timestamp to participants table
cur.execute("""
CREATE TABLE IF NOT EXISTS participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
user_name TEXT,
raffle_id INTEGER NOT NULL,
numbers TEXT,
transaction_id TEXT,
step TEXT NOT NULL,
invoice_id TEXT,
reservation_timestamp INTEGER,
origin_channel_id TEXT,
completion_timestamp INTEGER,
UNIQUE(user_id, raffle_id, transaction_id),
FOREIGN KEY (raffle_id) REFERENCES raffles(id) ON DELETE CASCADE
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS raffle_channel_announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raffle_id INTEGER NOT NULL,
channel_id TEXT NOT NULL,
message_id INTEGER NOT NULL, -- Telegram message ID of the announcement
last_updated_ts INTEGER, -- Timestamp of when this message was last updated
FOREIGN KEY (raffle_id) REFERENCES raffles(id) ON DELETE CASCADE,
UNIQUE (raffle_id, channel_id) -- Only one "main" announcement message per raffle per channel
)
""")
# Indexes
cur.execute("CREATE INDEX IF NOT EXISTS idx_raffle_channel_prices_raffle_id ON raffle_channel_prices (raffle_id)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_participants_step_timestamp ON participants (step, reservation_timestamp)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_participants_user_raffle_step ON participants (user_id, raffle_id, step)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_raffles_active ON raffles (active)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_participants_completion_ts ON participants (raffle_id, completion_timestamp DESC, step)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_raffle_channel_announcements_raffle_channel ON raffle_channel_announcements (raffle_id, channel_id)")
conn.commit()
conn.close()
logger.info("Database initialized (ensuring participants.reservation_timestamp exists).")
# --- Database Connection --- (remains the same)
def connect_db():
conn = sqlite3.connect(DATABASE_PATH)
conn.row_factory = sqlite3.Row
return conn
# --- Raffle Management --- (remains the same)
# ... create_raffle, end_raffle, get_raffle, etc. ...
def create_raffle_with_channel_prices(name, description, image_file_id, channels_with_prices: dict):
"""
Creates a new raffle and its associated channel prices.
channels_with_prices: A dictionary { 'channel_id_str': price_int }
"""
conn = connect_db()
cur = conn.cursor()
try:
# Insert into raffles table
cur.execute(
"INSERT INTO raffles (name, description, image_file_id) VALUES (?, ?, ?)",
(name, description, image_file_id)
)
raffle_id = cur.lastrowid
if not raffle_id:
raise Exception("Failed to create raffle entry.")
# Insert into raffle_channel_prices
for channel_id, price in channels_with_prices.items():
cur.execute(
"INSERT INTO raffle_channel_prices (raffle_id, channel_id, price) VALUES (?, ?, ?)",
(raffle_id, str(channel_id), price) # Ensure channel_id is string
)
conn.commit()
logger.info(f"Created raffle '{name}' (ID: {raffle_id}) with prices for channels: {list(channels_with_prices.keys())}")
return raffle_id
except sqlite3.IntegrityError as e:
logger.error(f"Error creating raffle: Name '{name}' likely already exists or channel price conflict. {e}")
conn.rollback()
return None
except Exception as e:
logger.error(f"Error creating raffle '{name}' with channel prices: {e}")
conn.rollback()
return None
finally:
conn.close()
def create_raffle(name, description, price, image_file_id, channels_str):
"""Creates a new raffle in the database."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute(
"INSERT INTO raffles (name, description, price, image_file_id, channels) VALUES (?, ?, ?, ?, ?)",
(name, description, price, image_file_id, channels_str)
)
raffle_id = cur.lastrowid
conn.commit()
logging.info(f"Created raffle '{name}' (ID: {raffle_id}) for channels: {channels_str}")
return raffle_id
except sqlite3.IntegrityError as e:
logging.error(f"Error creating raffle: Name '{name}' likely already exists. {e}")
return None
except Exception as e:
logging.error(f"Error creating raffle '{name}': {e}")
conn.rollback() # Rollback on error
return None
finally:
conn.close()
def end_raffle(raffle_id):
"""Marks a raffle as inactive."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute("UPDATE raffles SET active=0 WHERE id=? AND active=1", (raffle_id,))
updated = conn.total_changes > 0
conn.commit()
if updated:
logging.info(f"Ended raffle ID: {raffle_id}")
else:
logging.warning(f"Attempted to end raffle ID {raffle_id}, but it was not found or already inactive.")
return updated
except Exception as e:
logging.error(f"Error ending raffle ID {raffle_id}: {e}")
conn.rollback()
return False
finally:
conn.close()
def get_raffle(raffle_id):
"""Gets basic raffle details by ID."""
conn = connect_db()
cur = conn.cursor()
try:
# Does not include price directly, price is per channel
cur.execute("SELECT id, name, description, image_file_id, active FROM raffles WHERE id=?", (raffle_id,))
raffle = cur.fetchone()
return raffle
except Exception as e:
logger.error(f"Error getting raffle ID {raffle_id}: {e}")
return None
finally:
conn.close()
def get_raffle_channels_and_prices(raffle_id):
"""Gets all channel IDs and their prices for a given raffle."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute("SELECT channel_id, price FROM raffle_channel_prices WHERE raffle_id=?", (raffle_id,))
# Returns a list of Row objects, each with 'channel_id' and 'price'
return cur.fetchall()
except Exception as e:
logger.error(f"Error getting channels and prices for raffle {raffle_id}: {e}")
return []
finally:
conn.close()
def get_price_for_raffle_in_channel(raffle_id, channel_id):
"""Gets the specific price for a raffle in a given channel."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute(
"SELECT price FROM raffle_channel_prices WHERE raffle_id=? AND channel_id=?",
(raffle_id, str(channel_id))
)
result = cur.fetchone()
return result['price'] if result else None
except Exception as e:
logger.error(f"Error getting price for raffle {raffle_id} in channel {channel_id}: {e}")
return None
finally:
conn.close()
def get_raffle_id(name):
"""Gets raffle ID by name."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute("SELECT id FROM raffles WHERE name=?", (name,))
raffle_id = cur.fetchone()
return raffle_id[0] if raffle_id else None
except Exception as e:
logging.error(f"Error getting raffle ID for name '{name}': {e}")
return None
finally:
conn.close()
def get_raffle_channels(raffle_id):
"""Gets the comma-separated channel IDs for a raffle."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute("SELECT channels FROM raffles WHERE id=?", (raffle_id,))
channels = cur.fetchone()
return channels[0] if channels else None
except Exception as e:
logging.error(f"Error getting channels for raffle ID {raffle_id}: {e}")
return None
finally:
conn.close()
def get_active_raffles():
"""Gets all active raffles (basic info)."""
conn = connect_db()
cur = conn.cursor()
try:
# No price here, as it's per-channel
cur.execute("SELECT id, name, description, image_file_id FROM raffles WHERE active=1")
raffles = cur.fetchall()
return raffles
except Exception as e:
logger.error(f"Error getting active raffles: {e}")
return []
finally:
conn.close()
def get_raffle_channel_ids(raffle_id):
"""Gets a list of channel IDs where the raffle is active."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute("SELECT channel_id FROM raffle_channel_prices WHERE raffle_id=?", (raffle_id,))
# Fetches a list of Row objects, extract the 'channel_id' from each
return [row['channel_id'] for row in cur.fetchall()]
except Exception as e:
logger.error(f"Error getting channel IDs for raffle {raffle_id}: {e}")
return []
finally:
conn.close()
def get_active_raffles_in_channel(channel_id):
"""Gets active raffles available in a specific channel."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute(
"""SELECT r.id, r.name, r.description, r.image_file_id, rcp.price
FROM raffles r
JOIN raffle_channel_prices rcp ON r.id = rcp.raffle_id
WHERE r.active=1 AND rcp.channel_id=?""",
(str(channel_id),)
)
raffles = cur.fetchall() # List of Row objects, each including the price for that channel
return raffles
except Exception as e:
logger.error(f"Error getting active raffles for channel {channel_id}: {e}")
return []
finally:
conn.close()
def add_channels_to_raffle(raffle_id, channels_with_prices: dict):
"""Adds new channels and their prices to an existing raffle."""
conn = connect_db()
cur = conn.cursor()
added_channels = []
try:
for channel_id, price in channels_with_prices.items():
try:
cur.execute(
"INSERT INTO raffle_channel_prices (raffle_id, channel_id, price) VALUES (?, ?, ?)",
(raffle_id, str(channel_id), price)
)
added_channels.append(str(channel_id))
except sqlite3.IntegrityError:
logger.warning(f"Channel {channel_id} already exists for raffle {raffle_id} or other integrity error. Skipping.")
# Optionally, update the price if it already exists:
# cur.execute("UPDATE raffle_channel_prices SET price=? WHERE raffle_id=? AND channel_id=?", (price, raffle_id, str(channel_id)))
conn.commit()
logger.info(f"Added/updated channels {added_channels} with prices to raffle {raffle_id}.")
return added_channels
except Exception as e:
logger.error(f"Error adding channels to raffle {raffle_id}: {e}")
conn.rollback()
return []
finally:
conn.close()
def get_raffle_name(raffle_id):
"""Gets raffle name by ID."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute("SELECT name FROM raffles WHERE id=?", (raffle_id,))
name = cur.fetchone()
return name[0] if name else None
except Exception as e:
logging.error(f"Error getting raffle name for ID {raffle_id}: {e}")
return None
finally:
conn.close()
# --- Participant Management ---
def reserve_number(user_id, user_name, raffle_id, number, origin_channel_id_str): # Added origin_channel_id
"""Adds or updates a participant's reserved numbers, including origin channel."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute("SELECT id, numbers FROM participants WHERE raffle_id=? AND user_id=? AND step='waiting_for_payment'", (raffle_id, user_id))
existing_reservation = cur.fetchone()
if existing_reservation:
participant_id = existing_reservation['id']
numbers = existing_reservation['numbers']
numbers_list = numbers.split(',') if numbers else []
if str(number) not in numbers_list:
numbers_list.append(str(number))
numbers_str = ','.join(sorted(numbers_list))
# Update origin_channel_id as well if it's the first number added to this reservation cycle
cur.execute("UPDATE participants SET numbers=?, origin_channel_id=? WHERE id=?",
(numbers_str, origin_channel_id_str, participant_id))
# If number already there, no change needed to numbers or origin_channel_id
else:
cur.execute(
"INSERT INTO participants (user_id, user_name, raffle_id, numbers, step, origin_channel_id) VALUES (?, ?, ?, ?, ?, ?)",
(user_id, user_name, raffle_id, str(number), "waiting_for_payment", origin_channel_id_str)
)
conn.commit()
except Exception as e:
logger.error(f"Error reserving number {number} for user {user_id} in raffle {raffle_id} from channel {origin_channel_id_str}: {e}")
conn.rollback()
finally:
conn.close()
def remove_reserved_number(participant_id, number):
"""Removes a specific number from a 'waiting_for_payment' reservation."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute("SELECT numbers FROM participants WHERE id=? AND step='waiting_for_payment'", (participant_id,))
result = cur.fetchone()
if result and result['numbers']:
numbers = result['numbers'].split(',')
if str(number) in numbers:
numbers.remove(str(number))
if numbers: # If list is not empty after removal
numbers_str = ','.join(sorted(numbers))
cur.execute("UPDATE participants SET numbers=? WHERE id=?", (numbers_str, participant_id))
else:
# If no numbers left, delete the entire reservation row
logger.info(f"No numbers left for participant {participant_id}, deleting reservation.")
cur.execute("DELETE FROM participants WHERE id=?", (participant_id,))
conn.commit()
return True
else:
logger.warning(f"Attempted to remove number {number} from non-existent or empty reservation {participant_id}")
return False # Indicate nothing changed
except Exception as e:
logger.error(f"Error removing reserved number {number} for participant {participant_id}: {e}")
conn.rollback()
return False
finally:
conn.close()
def mark_reservation_pending(participant_id, invoice_id, timestamp):
"""Sets the invoice ID and reservation timestamp for a participant moving to pending payment."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute(
"UPDATE participants SET invoice_id=?, reservation_timestamp=? WHERE id=? AND step='waiting_for_payment'",
(invoice_id, int(timestamp), participant_id)
)
conn.commit()
logger.info(f"Marked reservation pending for participant {participant_id} with invoice {invoice_id} at {timestamp}")
except Exception as e:
logger.error(f"Error marking reservation pending for participant {participant_id}: {e}")
conn.rollback()
finally:
conn.close()
def get_expired_reservations(expiry_threshold_timestamp):
"""Finds participants whose reservations have expired."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute(
"""SELECT p.id, p.user_id, p.user_name, p.raffle_id, p.numbers, r.name as raffle_name
FROM participants p
JOIN raffles r ON p.raffle_id = r.id
WHERE p.step = 'waiting_for_payment'
AND p.reservation_timestamp IS NOT NULL
AND p.reservation_timestamp < ?""",
(int(expiry_threshold_timestamp),)
)
expired = cur.fetchall()
return expired # Returns list of Row objects
except Exception as e:
logger.error(f"Error fetching expired reservations: {e}")
return []
finally:
conn.close()
def cancel_reserved_numbers(user_id, raffle_id):
"""Deletes a 'waiting_for_payment' reservation for a user/raffle."""
conn = connect_db()
cur = conn.cursor()
deleted_count = 0
try:
cur.execute("DELETE FROM participants WHERE user_id=? AND raffle_id=? AND step='waiting_for_payment'", (user_id, raffle_id))
deleted_count = conn.total_changes
conn.commit()
if deleted_count > 0:
logger.info(f"Cancelled reservation for user {user_id}, raffle {raffle_id}.")
else:
logger.debug(f"No reservation found to cancel for user {user_id}, raffle {raffle_id}.")
except Exception as e:
logger.error(f"Error cancelling reservation for user {user_id}, raffle {raffle_id}: {e}")
conn.rollback()
finally:
conn.close()
return deleted_count > 0 # Return True if something was deleted
def cancel_reservation_by_id(participant_id):
"""Deletes a reservation using its specific participant ID."""
conn = connect_db()
cur = conn.cursor()
deleted_count = 0
try:
# Ensure we only delete if it's still in the correct state
cur.execute("DELETE FROM participants WHERE id=? AND step='waiting_for_payment'", (participant_id,))
deleted_count = conn.total_changes
conn.commit()
if deleted_count > 0:
logger.info(f"Cancelled reservation by participant ID: {participant_id}.")
else:
logger.warning(f"Attempted to cancel reservation by ID {participant_id}, but it was not found or not in 'waiting_for_payment' state.")
except Exception as e:
logger.error(f"Error cancelling reservation by ID {participant_id}: {e}")
conn.rollback()
finally:
conn.close()
return deleted_count > 0
def confirm_reserved_numbers(user_id, raffle_id, transaction_id):
"""Confirms payment, sets step to completed, removes reservation timestamp, and sets completion_timestamp."""
conn = connect_db()
cur = conn.cursor()
try:
current_completion_timestamp = int(time.time()) # Timestamp for completion
cur.execute(
"""UPDATE participants SET
step='completed',
transaction_id=?,
reservation_timestamp=NULL,
origin_channel_id=NULL,
completion_timestamp=?
WHERE user_id=? AND raffle_id=? AND step='waiting_for_payment'""",
(transaction_id, current_completion_timestamp, user_id, raffle_id)
)
updated_count = conn.total_changes
conn.commit()
return updated_count > 0
except Exception as e:
logger.error(f"Error confirming reserved numbers for user {user_id}, raffle {raffle_id}: {e}")
conn.rollback()
return False
finally:
conn.close()
def get_last_n_other_participants(raffle_id, current_participant_user_id, n=3):
"""
Gets the last N "completed" participants for a raffle, excluding the current one.
Returns a list of dictionaries [{'user_name', 'numbers'}].
"""
conn = connect_db()
cur = conn.cursor()
participants_info = []
try:
# Fetch N+1 recent completed participants, then filter out the current one in code
# if they are among them, ensuring we get N *other* participants.
# Order by completion_timestamp DESC.
cur.execute(
"""SELECT user_name, numbers
FROM participants
WHERE raffle_id = ? AND step = 'completed' AND user_id != ?
ORDER BY completion_timestamp DESC
LIMIT ?""",
(raffle_id, current_participant_user_id, n)
)
rows = cur.fetchall()
for row in rows:
participants_info.append({'user_name': row['user_name'], 'numbers': row['numbers']})
# The list is already ordered newest first by the SQL query.
return participants_info # Newest other participants first
except Exception as e:
logger.error(f"Error getting last N other participants for raffle {raffle_id}: {e}")
return []
finally:
conn.close()
# --- Other participant functions (get_participant, get_participants, etc.) remain largely the same ---
# Ensure they exist as in your previous version. Add them back here if needed.
def get_participant_by_user_id_and_step(user_id, step):
conn = connect_db()
cur = conn.cursor()
try:
# Fetch origin_channel_id for payment calculation
cur.execute("SELECT id, raffle_id, numbers, user_name, origin_channel_id FROM participants WHERE user_id=? AND step=?", (user_id, step))
participant = cur.fetchone()
return participant
except Exception as e:
logger.error(f"Error getting participant by user ID {user_id} and step {step}: {e}")
return None
finally:
conn.close()
def get_reserved_numbers(user_id, raffle_id):
conn = connect_db()
cur = conn.cursor()
try:
cur.execute("SELECT numbers FROM participants WHERE user_id=? AND raffle_id=? AND step='waiting_for_payment'", (user_id, raffle_id))
numbers = cur.fetchone()
return numbers['numbers'].split(',') if numbers and numbers['numbers'] else []
except Exception as e:
logger.error(f"Error getting reserved numbers for user {user_id}, raffle {raffle_id}: {e}")
return []
finally:
conn.close()
def get_participants(raffle_id):
conn = connect_db()
cur = conn.cursor()
try:
cur.execute("SELECT id, user_id, user_name, numbers, step FROM participants WHERE raffle_id=?", (raffle_id,))
participants = cur.fetchall()
return participants
except Exception as e:
logging.error(f"Error getting participants for raffle {raffle_id}: {e}")
return []
finally:
conn.close()
# Add any other necessary participant functions from your original code here
def get_participant_by_number(raffle_id, number):
conn = connect_db()
cur = conn.cursor()
try:
# Check both waiting and completed steps
cur.execute(
"SELECT id, user_id, user_name, numbers, step FROM participants WHERE raffle_id=? AND numbers LIKE ? AND (step='waiting_for_payment' OR step='completed')",
(raffle_id, f"%{number}%")
)
# This LIKE might match partial numbers if not careful (e.g., 1 matches 10).
# A better approach is to fetch all and check in Python, or use JSON functions if SQLite version supports it.
# For simplicity, let's refine the query slightly assuming numbers are comma-separated:
cur.execute(
"""SELECT id, user_id, user_name, numbers, step FROM participants
WHERE raffle_id=? AND (step='waiting_for_payment' OR step='completed')
AND (numbers = ? OR numbers LIKE ? OR numbers LIKE ? OR numbers LIKE ?)""",
(raffle_id, number, f"{number},%", f"%,{number},%", f"%,{number}")
)
participant = cur.fetchone() # Get the first match
# Verify the number actually exists in the list
if participant and participant['numbers']:
if str(number) in participant['numbers'].split(','):
return participant
else: # False positive from LIKE, try fetching next
# This gets complex quickly. Fetching all and filtering might be easier.
# Let's assume for now the refined LIKE works reasonably well for 00-99.
return participant # Return the first match found by SQL for now
return None # No match found
except Exception as e:
logger.error(f"Error getting participant by number {number} for raffle {raffle_id}: {e}")
return None
finally:
conn.close()
def get_raffle_by_user_id_waiting_payment(user_id):
conn = connect_db()
cur = conn.cursor()
try:
cur.execute("SELECT raffle_id FROM participants WHERE user_id=? AND step='waiting_for_payment'", (user_id,))
raffle_id = cur.fetchone()
return raffle_id[0] if raffle_id else None
except Exception as e:
logger.error(f"Error checking waiting payment raffle for user {user_id}: {e}")
return None
finally:
conn.close()
def get_user_by_invoice_id(invoice_id):
conn = connect_db()
cur = conn.cursor()
try:
# No need to join with raffles for price here, get price from raffle_channel_prices
cur.execute(
"""SELECT p.user_id, p.user_name, p.raffle_id, p.numbers, p.origin_channel_id
FROM participants p
WHERE p.invoice_id=? AND p.step='waiting_for_payment'""",
(invoice_id,)
)
data = cur.fetchone()
if data:
# Fetch the price for the specific origin_channel_id
price = get_price_for_raffle_in_channel(data['raffle_id'], data['origin_channel_id'])
if price is not None:
# Convert Row to dict and add price. This makes it mutable.
participant_dict = dict(data)
participant_dict['price_per_number'] = price
return participant_dict # Return dict with price included
else:
logger.error(f"Could not fetch price for raffle {data['raffle_id']} in channel {data['origin_channel_id']} for invoice {invoice_id}")
return None
except Exception as e:
logger.error(f"Error getting user/raffle by invoice ID {invoice_id}: {e}")
return None
finally:
conn.close()
def get_remaining_numbers_amount(raffle_id):
conn = connect_db()
cur = conn.cursor()
try:
# Count numbers from both 'waiting_for_payment' and 'completed' steps
cur.execute("SELECT numbers FROM participants WHERE raffle_id=? AND (step='waiting_for_payment' OR step='completed')", (raffle_id,))
all_numbers_rows = cur.fetchall()
taken_count = 0
for row in all_numbers_rows:
if row['numbers']:
try:
taken_count += len(row['numbers'].split(','))
except:
logger.warning(f"Invalid numbers format '{row['numbers']}' while counting remaining for raffle {raffle_id}")
return 100 - taken_count
except Exception as e:
logger.error(f"Error calculating remaining numbers for raffle {raffle_id}: {e}")
return -1 # Indicate error
finally:
conn.close()
def get_remaining_numbers(raffle_id):
"""Gets a list of all remaining numbers for a raffle, formatted as two digits."""
conn = connect_db()
cur = conn.cursor()
try:
cur.execute(
"SELECT numbers FROM participants WHERE raffle_id=? AND (step='waiting_for_payment' OR step='completed')",
(raffle_id,)
)
all_numbers_rows = cur.fetchall()
taken_numbers_str_set = set() # Store taken numbers as strings (potentially "01", "10", etc.)
for row in all_numbers_rows:
if row['numbers']: # Ensure 'numbers' column is not None
# Split the comma-separated string and add individual numbers to the set
# Assumes numbers in DB are stored like "01,05,10"
taken_numbers_str_set.update(num.strip() for num in row['numbers'].split(',') if num.strip())
logger.debug(f"Raffle {raffle_id} - Taken numbers (string set): {taken_numbers_str_set}")
remaining_numbers_formatted = []
for num_int in range(100): # Iterate 0 through 99 as integers
# Format the integer as a two-digit string (e.g., 0 -> "00", 5 -> "05", 12 -> "12")
num_str_formatted = f"{num_int:02}"
# Check if this formatted string representation is in the set of taken numbers
if num_str_formatted not in taken_numbers_str_set:
remaining_numbers_formatted.append(num_str_formatted)
logger.debug(f"Raffle {raffle_id} - Remaining numbers formatted: {remaining_numbers_formatted}")
return remaining_numbers_formatted
except Exception as e:
logger.error(f"Error getting remaining numbers for raffle {raffle_id}: {e}")
return []
finally:
conn.close()
# --- Raffle Announcement Message Tracking ---
def store_announcement_message_id(raffle_id, channel_id, message_id):
conn = connect_db()
cur = conn.cursor()
current_ts = int(time.time())
try:
# Upsert: Insert or replace if raffle_id and channel_id combo exists
cur.execute(
"""INSERT INTO raffle_channel_announcements (raffle_id, channel_id, message_id, last_updated_ts)
VALUES (?, ?, ?, ?)
ON CONFLICT(raffle_id, channel_id) DO UPDATE SET
message_id = excluded.message_id,
last_updated_ts = excluded.last_updated_ts""",
(raffle_id, str(channel_id), message_id, current_ts)
)
conn.commit()
logger.info(f"Stored/Updated announcement message_id {message_id} for raffle {raffle_id} in channel {channel_id}")
except Exception as e:
logger.error(f"Error storing announcement message_id for raffle {raffle_id}, channel {channel_id}: {e}")
conn.rollback()
finally:
conn.close()
def get_announcement_message_id(raffle_id, channel_id):
conn = connect_db()
cur = conn.cursor()
try:
cur.execute(
"SELECT message_id FROM raffle_channel_announcements WHERE raffle_id=? AND channel_id=?",
(raffle_id, str(channel_id))
)
result = cur.fetchone()
return result['message_id'] if result else None
except Exception as e:
logger.error(f"Error getting announcement message_id for raffle {raffle_id}, channel {channel_id}: {e}")
return None
finally:
conn.close()

1504
app/handlers.py Normal file

File diff suppressed because it is too large Load Diff

293
app/helpers.py Normal file
View File

@@ -0,0 +1,293 @@
import logging
import requests
from database import * # Import all DB functions
from config import * # Import constants if needed (like BOT_TOKEN for direct API calls, although better passed)
from PIL import Image, ImageDraw, ImageFont
import os
logger = logging.getLogger(__name__)
def format_raffle_details(raffle_id):
"""Fetches and formats raffle details for display, including multi-channel prices."""
raffle = get_raffle(raffle_id) # Fetches basic info from 'raffles' table
if not raffle:
return "Error: No se encontró el sorteo."
details = (
f" **Detalles del Sorteo** \n\n"
f"**ID:** `{raffle['id']}`\n"
f"**Nombre:** {raffle['name']}\n"
f"**Descripción:**\n{raffle['description']}\n\n"
f"**Activo:** {'' if raffle['active'] else 'No (Terminado)'}\n"
)
# Get and Format Channels and their Prices
channels_and_prices = get_raffle_channels_and_prices(raffle_id) # List of Row objects {'channel_id', 'price'}
if channels_and_prices:
details += "**Canales y Precios:**\n"
for item in channels_and_prices:
channel_id_str = item['channel_id']
price = item['price']
channel_alias = REVERSE_CHANNELS.get(str(channel_id_str), f"ID:{channel_id_str}") # Ensure lookup with string ID
details += f"- {channel_alias}: {price}\n"
details += "\n" # Add a newline after the list
else:
details += "**Canales y Precios:** Ninguno asignado\n\n"
# Image ID (optional display)
if raffle['image_file_id']:
details += f"**ID Imagen:** (Presente)\n"
else:
details += f"**ID Imagen:** (No asignada)\n"
# Add participant count and remaining numbers
participants = get_participants(raffle_id) # Fetches list of Rows
completed_participants_count = 0
# pending_participants_count = 0 # If you want to show pending
if participants: # Check if participants list is not None or empty
completed_participants_count = sum(1 for p in participants if p['step'] == 'completed')
# pending_participants_count = sum(1 for p in participants if p['step'] == 'waiting_for_payment')
details += f"\n**Participantes Confirmados:** {completed_participants_count}\n"
# details += f"**Reservas Pendientes:** {pending_participants_count}\n"
remaining_count = get_remaining_numbers_amount(raffle_id)
details += f"**Números Disponibles:** {remaining_count if remaining_count >= 0 else 'Error al calcular'}\n"
return details
def build_raffle_announcement_caption(raffle_id):
"""Builds the standard announcement caption text."""
raffle = get_raffle(raffle_id)
if not raffle:
return None
remaining_count = get_remaining_numbers_amount(raffle_id)
caption = (
f"🎉 **¡Sorteo Activo!** 🎉\n\n"
f"✨ **{raffle['name']}** ✨\n\n"
f"{raffle['description']}\n\n"
f"💰 **Donación mínima:** {raffle['price']}\n"
f"🔢 **Números disponibles:** {remaining_count if remaining_count >= 0 else 'N/A'}\n\n"
f"👇 ¡Pulsa /sorteo en este chat para participar! 👇"
)
return caption
def get_winners(raffle_id, winner_numbers_int):
"""Finds winners based on chosen numbers."""
participants = get_participants(raffle_id) # Gets all participants for the raffle
winners = {} # { user_name: [list_of_winning_numbers_they_had] }
if not participants:
return "" # No participants, no winners
winner_numbers_set = set(winner_numbers_int)
for participant in participants:
# Only consider completed participations as potential winners
if participant['step'] != 'completed' or not participant['numbers']:
continue
user_id = participant['user_id']
user_name = escape_markdown_v2_chars_for_username(participant['user_name']) or f"User_{user_id}" # Fallback name
numbers_str = participant['numbers']
try:
participant_numbers_set = {int(n) for n in numbers_str.split(',') if n.isdigit()}
except ValueError:
logger.warning(f"Invalid number format for participant {user_id} in raffle {raffle_id}: {numbers_str}")
continue # Skip participant with bad data
# Find the intersection
won_numbers = winner_numbers_set.intersection(participant_numbers_set)
if won_numbers:
# Store the winning numbers (as strings, sorted) for this user
won_numbers_str_sorted = sorted([f"{n:02}" for n in won_numbers])
if user_name not in winners:
winners[user_name] = []
winners[user_name].extend(won_numbers_str_sorted) # Add potentially multiple matches
if not winners:
return "No hubo ganadores con esos números."
# Format the output string
winners_message_parts = []
for user_name, numbers in winners.items():
# Ensure numbers are unique in the final output per user
unique_numbers_str = ", ".join(sorted(list(set(numbers))))
winners_message_parts.append(f"- @{escape_markdown_v2_chars_for_username(user_name)} acertó: **{unique_numbers_str}**")
return "\n".join(winners_message_parts)
def generate_table_image(raffle_id):
"""Generates the 10x10 grid image showing number status."""
# Define image parameters
cols, rows = 10, 10
cell_width, cell_height = 120, 50
title_height_space = 70
image_width = cols * cell_width
image_height = rows * cell_height + title_height_space
background_color = "white"
line_color = "black"
font_size = 16
title_font_size = 24
# Create image
img = Image.new("RGB", (image_width, image_height), background_color)
draw = ImageDraw.Draw(img)
# Load fonts (handle potential errors)
try:
# Ensure the font file exists or provide a fallback path
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" # Example Linux path
if not os.path.exists(font_path):
font_path = "arial.ttf" # Try common Windows font
font = ImageFont.truetype(font_path, font_size)
title_font = ImageFont.truetype(font_path, title_font_size)
except IOError:
logger.warning("Specific font not found, using default PIL font.")
font = ImageFont.load_default()
# Adjust size for default font if needed, default doesn't take size arg directly
# title_font = ImageFont.truetype(font_path, title_font_size) # Need a default large font method
title_font = ImageFont.load_default() # Revert to default for title too for simplicity
# Draw Title
raffle_details = get_raffle(raffle_id)
if not raffle_details:
logger.error(f"Cannot generate image: Raffle {raffle_id} not found.")
# Draw error message on image
draw.text((10, 10), f"Error: Sorteo {raffle_id} no encontrado", fill="red", font=title_font)
img.save(f"/app/data/raffles/raffle_table_{raffle_id}_error.png")
return False # Indicate failure
raffle_name = raffle_details['name']
title_text = f"Sorteo: {raffle_name}"
# Calculate text bounding box for centering
try:
# Use textbbox for more accurate centering
title_bbox = draw.textbbox((0, 0), title_text, font=title_font)
title_width = title_bbox[2] - title_bbox[0]
# title_height = title_bbox[3] - title_bbox[1] # Not needed for x centering
title_x = (image_width - title_width) / 2
title_y = 10 # Padding from top
draw.text((title_x, title_y), title_text, fill=line_color, font=title_font)
except AttributeError: # Handle older PIL versions that might not have textbbox
# Fallback using textlength (less accurate)
title_width = draw.textlength(title_text, font=title_font)
title_x = (image_width - title_width) / 2
title_y = 10
draw.text((title_x, title_y), title_text, fill=line_color, font=title_font)
# Get participant data
participants = get_participants(raffle_id)
number_status = {} # { num_int: (user_name, status_color) }
if participants:
for p in participants:
if not p['numbers'] or p['step'] not in ['waiting_for_payment', 'completed']:
continue
user_name = p['user_name'] or f"User_{p['user_id']}" # Fallback name
status_color = "red" if p['step'] == 'waiting_for_payment' else "black" # Red=Reserved, Black=Completed
try:
nums = {int(n) for n in p['numbers'].split(',') if n.isdigit()}
for num in nums:
if 0 <= num <= 99:
number_status[num] = (user_name, status_color)
except ValueError:
logger.warning(f"Skipping invalid numbers '{p['numbers']}' for user {p['user_id']} in image generation.")
continue
# Draw Grid and Fill Numbers
for i in range(rows):
for j in range(cols):
num = i * cols + j
x1 = j * cell_width
y1 = i * cell_height + title_height_space
x2 = x1 + cell_width
y2 = y1 + cell_height
# Draw cell rectangle
draw.rectangle([x1, y1, x2, y2], outline=line_color)
# Prepare text and color
number_text = f"{num:02}"
text_fill = "blue" # Default color for free numbers
owner_text = ""
if num in number_status:
owner, status_color = number_status[num]
text_fill = status_color
# Truncate long usernames
max_name_len = 12
owner_text = owner[:max_name_len] + ('' if len(owner) > max_name_len else '')
# Position text within the cell
text_x = x1 + 10 # Padding from left
text_y_num = y1 + 5 # Padding for number line
text_y_owner = y1 + 5 + font_size + 2 # Padding for owner line (below number)
draw.text((text_x, text_y_num), number_text, fill=text_fill, font=font)
if owner_text:
draw.text((text_x, text_y_owner), owner_text, fill=text_fill, font=font)
# Ensure data directory exists
os.makedirs("/app/data/raffles", exist_ok=True)
# Save the image
image_path = f"/app/data/raffles/raffle_table_{raffle_id}.png"
try:
img.save(image_path)
logger.info(f"Generated raffle table image: {image_path}")
return True # Indicate success
except Exception as e:
logger.error(f"Failed to save raffle table image {image_path}: {e}")
return False # Indicate failure
def escape_markdown_v2_chars_for_username(text: str) -> str:
"""Escapes characters for MarkdownV2, specifically for usernames."""
# For usernames, usually only _ and * are problematic if not part of actual formatting
# Other MarkdownV2 special characters: `[` `]` `(` `)` `~` `>` `#` `+` `-` `=` `|` `{` `}` `.` `!`
# We are most concerned with _ in @user_name context.
# A more comprehensive list of characters to escape for general text:
# escape_chars = r'_*[]()~`>#+-=|{}.!'
# For just usernames in this context, focus on what breaks @user_name
escape_chars = r'_*`[' # Adding ` and [ just in case they appear in odd usernames
# Python's re.escape escapes all non-alphanumerics.
# We only want to escape specific markdown control characters within the username.
# For usernames, simply escaping '_' is often enough for the @mention issue.
return "".join(['\\' + char if char in escape_chars else char for char in text])
def format_last_participants_list(participants_list: list) -> str:
"""
Formats the list of last participants for the announcement message.
participants_list is a list of dicts: [{'user_name', 'numbers'}]
"""
if not participants_list:
return "" # Return empty string if no other recent participants
# Reverse the list so the oldest of the "last N" appears first in the formatted string
# as per the example "nick1, nick2, nick3" implies chronological order of joining.
# The DB query already returns newest first, so we reverse it for display.
formatted_lines = ["Los últimos participantes en unirse (además del más reciente) han sido:"]
for p_info in reversed(participants_list): # Display oldest of this batch first
user_name = p_info.get('user_name', 'Usuario Anónimo')
numbers_str = p_info.get('numbers', '')
if numbers_str:
num_list = numbers_str.split(',')
if len(num_list) == 1:
line = f" - {escape_markdown_v2_chars_for_username(user_name)}, con el número: {num_list[0]}"
else:
line = f" - {escape_markdown_v2_chars_for_username(user_name)}, con los números: {', '.join(num_list)}"
formatted_lines.append(line)
return "\n".join(formatted_lines) # Add a trailing newline

188
app/keyboards.py Normal file
View File

@@ -0,0 +1,188 @@
from telegram import InlineKeyboardMarkup, InlineKeyboardButton
from database import get_participants, get_active_raffles_in_channel, get_active_raffles, get_raffle_name
from config import *
def generate_raffle_selection_keyboard(channel_id):
"""Generates keyboard for users to select an active raffle in a channel."""
raffles = get_active_raffles_in_channel(channel_id)
keyboard = [[InlineKeyboardButton(r["name"], callback_data=f"join:{r['id']}")] for r in raffles]
return InlineKeyboardMarkup(keyboard)
def generate_numbers_keyboard(raffle_id, user_id, page=0):
"""Generates the 10x10 number selection keyboard with status icons and paging."""
participants = get_participants(raffle_id)
taken_numbers = {}
user_reserved = set()
user_taken = set()
# Process participants to determine number statuses
if participants:
for participant in participants:
participant_id = participant['user_id']
numbers = participant['numbers']
step = participant['step']
if numbers: # Ensure numbers is not None or empty
try:
numbers_set = {int(n) for n in numbers.split(',') if n.isdigit()} # Safer conversion
if participant_id == user_id:
if step == "waiting_for_payment":
user_reserved.update(numbers_set)
elif step == "completed":
user_taken.update(numbers_set)
for num in numbers_set:
# Store only if not already taken by the current user (avoids overwriting user status)
if num not in user_taken and num not in user_reserved:
taken_numbers[num] = participant_id # Track who took it generally
except ValueError:
logging.warning(f"Invalid number format in participant data for raffle {raffle_id}: {numbers}")
continue # Skip this participant's numbers if format is wrong
# Build the keyboard grid
keyboard = []
rows = 10
cols = 5
start = page * rows * cols
end = start + rows * cols
for i in range(rows):
row_buttons = []
for j in range(cols):
num = start + i * cols + j
if num >= 100: # Ensure we don't go beyond 99
break
num_str = f"{num:02}"
# Determine icon based on status
if num in user_taken:
icon = "🟢" # Taken (Paid) by this user
elif num in user_reserved:
icon = "🔒" # Reserved (Not paid) by this user
elif num in taken_numbers: # Check general taken status *after* specific user status
icon = "" # Taken by someone else
else:
icon = "☑️" # Free
row_buttons.append(InlineKeyboardButton(f"{icon} {num_str}", callback_data=f"number:{raffle_id}:{num}"))
if row_buttons: # Only add row if it contains buttons
keyboard.append(row_buttons)
# Add Paging Buttons
paging_buttons = []
if page > 0:
paging_buttons.append(InlineKeyboardButton("⬅️ Anterior", callback_data=f"number:{raffle_id}:prev"))
if end < 100: # Only show next if there are more numbers
paging_buttons.append(InlineKeyboardButton("Siguiente ➡️", callback_data=f"number:{raffle_id}:next"))
if paging_buttons:
keyboard.append(paging_buttons)
action_buttons_row = [
InlineKeyboardButton("✨ Número Aleatorio ✨", callback_data=f"random_num:{raffle_id}")
]
keyboard.append(action_buttons_row)
# Add Confirm/Cancel Buttons
confirm_cancel_row = [
InlineKeyboardButton("👍 Confirmar Selección", callback_data=f"confirm:{raffle_id}"),
InlineKeyboardButton("❌ Cancelar Selección", callback_data=f"cancel:{raffle_id}")
]
keyboard.append(confirm_cancel_row)
return InlineKeyboardMarkup(keyboard)
# --- Admin Menu Keyboards ---
def generate_admin_main_menu_keyboard():
keyboard = [
[InlineKeyboardButton(" Crear Nuevo Sorteo", callback_data=ADMIN_MENU_CREATE)],
[InlineKeyboardButton("📋 Listar/Gestionar Sorteos", callback_data=ADMIN_MENU_LIST)],
[InlineKeyboardButton("✏️ Editar Sorteo (Añadir Canales)", callback_data=ADMIN_EDIT_RAFFLE_SELECT)], # New option
]
return InlineKeyboardMarkup(keyboard)
def generate_channel_selection_keyboard_for_edit(existing_channel_ids, selected_new_channels_ids=None):
"""Generates channel selection keyboard for editing, excluding existing ones."""
if selected_new_channels_ids is None:
selected_new_channels_ids = set()
buttons = []
# CHANNELS is { 'alias': 'id' }
for alias, channel_id_str in CHANNELS.items():
if channel_id_str not in existing_channel_ids: # Only show channels NOT already in the raffle
text = f"{alias}" if channel_id_str in selected_new_channels_ids else f"⬜️ {alias}"
buttons.append([InlineKeyboardButton(text, callback_data=f"{SELECT_CHANNEL_PREFIX}{channel_id_str}")])
if selected_new_channels_ids:
buttons.append([InlineKeyboardButton("➡️ Continuar con precios", callback_data=f"{SELECT_CHANNEL_PREFIX}done")])
buttons.append([InlineKeyboardButton("❌ Cancelar Edición", callback_data=f"{SELECT_CHANNEL_PREFIX}cancel_edit")])
return InlineKeyboardMarkup(buttons)
def generate_admin_list_raffles_keyboard():
"""Generates keyboard listing active raffles with management buttons."""
active_raffles = get_active_raffles()
keyboard = []
if not active_raffles:
keyboard.append([InlineKeyboardButton("No hay sorteos activos.", callback_data=ADMIN_NO_OP)])
else:
for raffle in active_raffles:
raffle_id = raffle['id']
raffle_name = raffle['name']
# Row for each raffle: Name Button (View Details), Announce Button, End Button
keyboard.append([
InlineKeyboardButton(f" {raffle_name}", callback_data=f"{ADMIN_VIEW_RAFFLE_PREFIX}{raffle_id}"),
InlineKeyboardButton("📢 Anunciar", callback_data=f"{ADMIN_ANNOUNCE_RAFFLE_PREFIX}{raffle_id}"),
InlineKeyboardButton("🏁 Terminar", callback_data=f"{ADMIN_END_RAFFLE_PROMPT_PREFIX}{raffle_id}")
])
keyboard.append([InlineKeyboardButton("⬅️ Volver al Menú Principal", callback_data=ADMIN_MENU_BACK_MAIN)])
return InlineKeyboardMarkup(keyboard)
def generate_admin_raffle_details_keyboard(raffle_id):
"""Generates keyboard for the raffle detail view."""
keyboard = [
# Add relevant actions here if needed later, e.g., edit description?
[InlineKeyboardButton("📢 Anunciar de Nuevo", callback_data=f"{ADMIN_ANNOUNCE_RAFFLE_PREFIX}{raffle_id}")],
[InlineKeyboardButton("🏁 Terminar Sorteo", callback_data=f"{ADMIN_END_RAFFLE_PROMPT_PREFIX}{raffle_id}")],
[InlineKeyboardButton("⬅️ Volver a la Lista", callback_data=ADMIN_MENU_LIST)] # Back to list view
]
return InlineKeyboardMarkup(keyboard)
def generate_admin_cancel_end_keyboard():
"""Generates a simple cancel button during the end process."""
keyboard = [
[InlineKeyboardButton("❌ Cancelar Finalización", callback_data=ADMIN_CANCEL_END_PROCESS)]
]
return InlineKeyboardMarkup(keyboard)
# --- Keyboards for Raffle Creation Conversation ---
def generate_channel_selection_keyboard(selected_channels_ids=None):
"""Generates keyboard for admin to select target channels."""
if selected_channels_ids is None:
selected_channels_ids = set()
buttons = []
for alias, channel_id in CHANNELS.items():
# Mark selected channels
text = f"{alias}" if channel_id in selected_channels_ids else f"⬜️ {alias}"
buttons.append([InlineKeyboardButton(text, callback_data=f"{SELECT_CHANNEL_PREFIX}{channel_id}")])
# Add Done button only if at least one channel is selected
if selected_channels_ids:
buttons.append([InlineKeyboardButton("➡️ Continuar", callback_data=f"{SELECT_CHANNEL_PREFIX}done")])
buttons.append([InlineKeyboardButton("❌ Cancelar Creación", callback_data=f"{SELECT_CHANNEL_PREFIX}cancel")])
return InlineKeyboardMarkup(buttons)
def generate_confirmation_keyboard():
"""Generates Yes/No keyboard for final confirmation."""
keyboard = [
[
InlineKeyboardButton("✅ Sí, crear sorteo", callback_data=CONFIRM_CREATION_CALLBACK),
InlineKeyboardButton("❌ No, cancelar", callback_data=CANCEL_CREATION_CALLBACK),
]
]
return InlineKeyboardMarkup(keyboard)

382
app/paypal_processor.py Normal file
View File

@@ -0,0 +1,382 @@
# paypal_processor.py
from flask import Flask, request
import logging
import requests
import os # Import os to get BOT_TOKEN
# Import necessary functions from your project structure
# Adjust the path if paypal_processor.py is not in the root 'app' directory
# Assuming it can access the other modules directly as in the docker setup:
from helpers import generate_table_image, format_last_participants_list, escape_markdown_v2_chars_for_username
from database import (
get_user_by_invoice_id, confirm_reserved_numbers,
get_raffle_name, get_raffle,
get_raffle_channels_and_prices, get_remaining_numbers_amount,
store_announcement_message_id, get_announcement_message_id,
get_last_n_other_participants, get_remaining_numbers
)
from config import BOT_TOKEN, TYC_URL, NEWRELIC_API_KEY, BOT_NAME
from newrelic_telemetry_sdk import Log, LogClient
app = Flask(__name__)
# Enable logging
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO
)
client = LogClient(license_key=NEWRELIC_API_KEY, host="log-api.eu.newrelic.com")
# Define a handler that sends records to New Relic
class NewRelicHandler(logging.Handler):
def emit(self, record):
try:
log = Log(
message=self.format(record),
level=record.levelname,
timestamp_ms=int(record.created * 1000),
attributes={
"logger": record.name,
"app_name": BOT_NAME,
"docker_container": "paypal_processor"
}
)
client.send(log)
except Exception:
self.handleError(record)
nr_handler = NewRelicHandler()
nr_handler.setFormatter(logging.Formatter("%(message)s"))
root_logger = logging.getLogger()
root_logger.addHandler(nr_handler)
logger = logging.getLogger(__name__)
# Define the Telegram API URL base
TELEGRAM_API_URL = f"https://api.telegram.org/bot{BOT_TOKEN}"
def edit_telegram_message_caption(chat_id, message_id, caption, photo_path=None, parse_mode=None, **kwargs):
"""
Helper to edit message caption. If photo_path is provided, it attempts to
re-send the photo with the new caption (Telegram doesn't directly support
editing media content of a message, only its caption or reply_markup).
However, for this use case, we are editing the caption of an *existing* photo.
"""
payload = {'chat_id': chat_id, 'message_id': message_id, 'caption': caption}
if parse_mode:
payload['parse_mode'] = parse_mode
# For inline keyboards, add reply_markup=json.dumps(keyboard_dict)
payload.update(kwargs)
try:
response = requests.post(f"{TELEGRAM_API_URL}/editMessageCaption", data=payload)
response.raise_for_status()
logger.info(f"Edited caption for message {message_id} in chat {chat_id}")
return response.json().get('result', {}).get('message_id', message_id) # Return new or old message_id
except requests.exceptions.RequestException as e:
logger.error(f"Error editing caption for message {message_id} in chat {chat_id}: {e}")
if e.response is not None:
logger.error(f"Edit caption Response status: {e.response.status_code}, body: {e.response.text}")
return None # Indicate failure
def send_telegram_message(chat_id, text, **kwargs):
"""Helper to send text messages via requests."""
payload = {'chat_id': chat_id, 'text': text, **kwargs}
try:
response = requests.post(f"{TELEGRAM_API_URL}/sendMessage", data=payload)
response.raise_for_status()
logger.info(f"Sent message to chat_id {chat_id}")
return True
except requests.exceptions.RequestException as e:
logger.error(f"Error sending message to chat_id {chat_id}: {e}")
if e.response is not None:
logger.error(f"Response status: {e.response.status_code}, body: {e.response.text}")
return False
def send_telegram_photo(chat_id, photo_path, caption=None, parse_mode=None, **kwargs):
"""Helper to send photos via requests."""
files = {'photo': open(photo_path, 'rb')}
data = {'chat_id': chat_id}
if caption:
data['caption'] = caption
if parse_mode: # Add parse_mode to data if provided
data['parse_mode'] = parse_mode
data.update(kwargs)
try:
response = requests.post(f"{TELEGRAM_API_URL}/sendPhoto", data=data, files=files)
response.raise_for_status()
logger.info(f"Sent photo to chat_id {chat_id}")
message_data = response.json().get('result')
return message_data
except requests.exceptions.RequestException as e:
logger.error(f"Error sending photo to chat_id {chat_id}: {e}")
if e.response is not None:
logger.error(f"Response status: {e.response.status_code}, body: {e.response.text}")
return False
finally:
# Ensure file is closed even if request fails
if 'photo' in files and files['photo']:
files['photo'].close()
# --- CORRECTED FUNCTION ---
def receive_paypal_payment(invoice_id, transaction_id, payment_status, payment_amount_str):
"""Processes verified PayPal payment data."""
logger.info(f"Processing payment for Invoice: {invoice_id}, TXN: {transaction_id}, Status: {payment_status}, Amount: {payment_amount_str}")
# 1. Get participant data using the invoice ID
participant_data = get_user_by_invoice_id(invoice_id)
# 2. Check if participant data exists and is pending
if not participant_data:
# This means the invoice ID wasn't found OR the step wasn't 'waiting_for_payment'
# It could be already completed, cancelled, or expired. Ignore the webhook.
logger.warning(f"No pending participant found for Invoice ID: {invoice_id}. Payment ignored.")
return
# 3. Extract data directly from the fetched Row object
user_id = participant_data['user_id']
current_user_name = participant_data['user_name'] or f"User_{user_id}" # Use fallback name
raffle_id = participant_data['raffle_id']
numbers_str = participant_data['numbers']
price_per_number = participant_data['price_per_number'] # Price is already fetched
numbers = numbers_str.split(',') if numbers_str else []
if not numbers:
logger.error(f"Invoice ID {invoice_id} found, but participant {user_id} has no numbers associated. Skipping.")
# Maybe notify admin?
return
# 4. Validate Payment Status
if payment_status != "Completed":
logger.warning(f"Payment status for Invoice ID {invoice_id} is '{payment_status}', not 'Completed'. Payment not processed.")
# Optionally notify the user that payment is pending/failed
send_telegram_message(
user_id,
f"El estado de tu pago para la factura {invoice_id} es '{payment_status}'. "
f"El sorteo solo se confirma con pagos 'Completed'. Contacta con un administrador si crees que es un error."
)
return
# 5. Validate Payment Amount
try:
payment_amount = float(payment_amount_str)
except (ValueError, TypeError):
logger.error(f"Invalid payment amount received for Invoice ID {invoice_id}: '{payment_amount_str}'. Cannot validate.")
send_telegram_message(
user_id,
f"Error procesando el pago para la factura {invoice_id}. El monto recibido ('{payment_amount_str}') no es válido. "
f"Por favor, contacta con un administrador."
)
return
expected_amount = len(numbers) * price_per_number
# Use a small tolerance for float comparison
if abs(payment_amount - expected_amount) > 0.01:
logger.error(f"Payment amount mismatch for Invoice ID {invoice_id}. Expected: {expected_amount:.2f}, Received: {payment_amount:.2f}")
send_telegram_message(
user_id,
f"⚠️ La cantidad pagada ({payment_amount:.2f}€) para la factura {invoice_id} no coincide con el total esperado ({expected_amount:.2f}€) para los números {', '.join(numbers)}. "
f"Por favor, contacta con un administrador."
)
# Do NOT confirm the numbers if amount is wrong
return
# 6. Confirm the numbers in the database
if confirm_reserved_numbers(user_id, raffle_id, transaction_id):
logger.info(f"Successfully confirmed numbers {numbers} for user {user_id} (Name: {current_user_name}), raffle {raffle_id} with TXN {transaction_id}.")
raffle_name = get_raffle_name(raffle_id) # Get raffle name for user message
# Send confirmation to user
send_telegram_message(
user_id,
f"✅ ¡Pago confirmado para la factura {invoice_id}!\n\n"
f"Te has apuntado con éxito al sorteo '{raffle_name}' con los números: {', '.join(numbers)}."
# Raffle name can be added here if desired, requires one more DB call or adding it to get_user_by_invoice_id
)
# Generate table image
if generate_table_image(raffle_id):
image_path = f"/app/data/raffles/raffle_table_{raffle_id}.png"
# Get general raffle details (like description) for announcement caption
raffle_details_general = get_raffle(raffle_id) # Fetches name, description, image_id
if not raffle_details_general:
logger.error(f"Could not fetch general raffle details for ID {raffle_id} after payment confirmation.")
return # Or handle more gracefully
raffle_description_for_announce = raffle_details_general['description']
remaining_numbers_amount = get_remaining_numbers_amount(raffle_id)
last_other_participants = get_last_n_other_participants(raffle_id, user_id, n=3)
last_participants_text = format_last_participants_list(last_other_participants)
channels_with_their_prices = get_raffle_channels_and_prices(raffle_id)
if channels_with_their_prices:
for channel_price_info in channels_with_their_prices:
channel_id_to_announce = channel_price_info['channel_id']
price_for_this_channel_announce = channel_price_info['price'] # <<< PRICE FOR THIS SPECIFIC CHANNEL
escaped_current_user_name = escape_markdown_v2_chars_for_username(current_user_name)
numbers_text = ""
if len(numbers) > 1:
numbers_text = f"con los números: {', '.join(numbers)}"
else:
numbers_text = f"con el número: {', '.join(numbers)}"
new_participant_line = f"❗️ ¡Nuevo participante! @{escaped_current_user_name} se unió {numbers_text}. ❗️"
remaining_numbers_text = ""
if remaining_numbers_amount > 10:
remaining_numbers_text = f" Quedan {remaining_numbers_amount} números disponibles. "
elif remaining_numbers_amount == 1:
remaining_numbers = get_remaining_numbers(raffle_id)
remaining_numbers_text = f"ℹ️💥💥💥 ¡Último número disponible! 💥💥💥ℹ️\n\n"
remaining_numbers_text += f"Queda el número: {remaining_numbers[0]}"
elif remaining_numbers_amount == 0:
remaining_numbers_text = " ¡Ya no quedan números disponibles! "
else:
remaining_numbers = get_remaining_numbers(raffle_id)
remaining_numbers_text = f"ℹ️💥 ¡Últimos {remaining_numbers_amount} números disponibles! 💥ℹ️\n\n"
remaining_numbers_text += f"Quedan los números: {', '.join(remaining_numbers)}"
caption = (
f"{new_participant_line}\n\n"
f"{last_participants_text}\n\n"
f"{remaining_numbers_text}\n\n"
f"{raffle_description_for_announce}\n\n"
f"💶 **Donación mínima:** {price_for_this_channel_announce}\n\n" # Use the specific price
f"Normas y condiciones: {TYC_URL} \n\n"
f"¡Apúntate con el comando /sorteo !"
)
previous_message_id = get_announcement_message_id(raffle_id, channel_id_to_announce)
sent_or_edited_message_id = None
if previous_message_id:
logger.info(f"Attempting to edit message {previous_message_id} in channel {channel_id_to_announce}")
# We need to re-send the photo to "edit" it with a new caption effectively,
# or just edit caption if the photo is guaranteed to be the same.
# For simplicity, let's try to just edit the caption of the existing photo.
# If the image itself needs to update (e.g. number grid), then delete + send new.
# Assuming the photo (raffle_table_{raffle_id}.png) is updated by generate_table_image
# We should delete old and send new.
# Try deleting old message first
try:
delete_payload = {'chat_id': channel_id_to_announce, 'message_id': previous_message_id}
delete_response = requests.post(f"{TELEGRAM_API_URL}/deleteMessage", data=delete_payload)
if delete_response.status_code == 200:
logger.info(f"Successfully deleted old message {previous_message_id} in channel {channel_id_to_announce}")
else:
logger.warning(f"Failed to delete old message {previous_message_id} in channel {channel_id_to_announce}: {delete_response.text}. Will send new.")
except Exception as e_del:
logger.warning(f"Error deleting old message {previous_message_id}: {e_del}. Will send new.")
# Always send new photo after delete attempt, ensures updated image is shown
new_msg_info = send_telegram_photo(channel_id_to_announce, image_path, caption=caption, parse_mode='Markdown')
if new_msg_info and isinstance(new_msg_info, dict) and 'message_id' in new_msg_info: # If send_telegram_photo returns message object
sent_or_edited_message_id = new_msg_info['message_id']
elif isinstance(new_msg_info, bool) and new_msg_info is True: # If it just returns True/False
# We can't get message_id this way. Need send_telegram_photo to return it.
logger.warning("send_telegram_photo did not return message_id, cannot store for future edits.")
else: # Sending new failed
logger.error(f"Failed to send new photo to channel {channel_id_to_announce} after deleting old.")
else: # No previous message, send new
logger.info(f"No previous message found for raffle {raffle_id} in channel {channel_id_to_announce}. Sending new.")
new_msg_info = send_telegram_photo(channel_id_to_announce, image_path, caption=caption, parse_mode='Markdown')
# Similar logic to get sent_or_edited_message_id as above
if new_msg_info and isinstance(new_msg_info, dict) and 'message_id' in new_msg_info:
sent_or_edited_message_id = new_msg_info['message_id']
elif isinstance(new_msg_info, bool) and new_msg_info is True:
logger.warning("send_telegram_photo did not return message_id for new message.")
if sent_or_edited_message_id:
store_announcement_message_id(raffle_id, channel_id_to_announce, sent_or_edited_message_id)
else:
logger.warning(f"No channels found to announce participation for raffle {raffle_id}")
# Send image confirmation to user (price not needed in this caption)
user_caption = f"Te has apuntado al sorteo '{raffle_name}' con los números: {', '.join(numbers)}"
send_telegram_photo(user_id, image_path, caption=user_caption)
else:
logger.error(f"Failed to generate raffle table image for {raffle_id} after payment.")
else:
# This case means the DB update failed, which is serious if payment was valid.
logger.critical(f"CRITICAL: Failed to execute confirm_reserved_numbers for user {user_id}, raffle {raffle_id}, invoice {invoice_id} AFTER successful payment validation.")
# Notify admin and possibly the user about the inconsistency
send_telegram_message(
user_id,
f"Hubo un error interno al confirmar tu participación para la factura {invoice_id} después de validar tu pago. "
f"Por favor, contacta con un administrador y proporciónales tu ID de factura."
)
# Notify admin (replace ADMIN_CHAT_ID with actual ID or list)
# admin_chat_id = "YOUR_ADMIN_CHAT_ID"
# send_telegram_message(admin_chat_id, f"CRITICAL DB Error: Failed to confirm numbers for invoice {invoice_id}, user {user_id}, raffle {raffle_id}. Payment was valid. Manual check needed.")
@app.route("/paypal-webhook", methods=["POST"])
def paypal_webhook():
"""Webhook to listen for PayPal IPN messages."""
logging.info("Received request on /paypal-webhook")
raw_data = request.get_data() # Get raw data for verification
# logging.debug(f"Raw PayPal data: {raw_data.decode('utf-8')}") # Decode for debug logging if needed
# Verify the IPN message with PayPal
verify_url = "https://ipnpb.paypal.com/cgi-bin/webscr" # Use sandbox URL for testing if needed
# verify_url = "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr"
verify_data = b"cmd=_notify-validate&" + raw_data # Prepend the validation command
try:
response = requests.post(verify_url, data=verify_data, timeout=30) # Add timeout
response.raise_for_status() # Raise HTTP errors
except requests.exceptions.RequestException as e:
logger.error(f"Error verifying IPN with PayPal: {e}")
return "IPN Verification Error", 500 # Return error status
if response.text == "VERIFIED":
# --- Extract needed data from the FORM data (not raw_data) ---
data = request.form.to_dict()
logger.info(f"VERIFIED IPN received. Data: {data}")
# Extract key fields (adjust keys based on your PayPal setup/IPN variables)
invoice_id = data.get("invoice")
transaction_id = data.get("txn_id") # Use PayPal's transaction ID
payment_status = data.get("payment_status")
payment_amount = data.get("mc_gross") # Total amount paid
# custom_id = data.get("custom") # If you passed participant ID here
if not all([invoice_id, transaction_id, payment_status, payment_amount]):
logger.warning(f"Missing one or more required fields in VERIFIED IPN data: {data}")
return "Missing Fields", 200 # Acknowledge receipt but don't process
# Process the valid payment
receive_paypal_payment(invoice_id, transaction_id, payment_status, payment_amount)
elif response.text == "INVALID":
logger.warning(f"INVALID IPN received. Raw data: {raw_data.decode('utf-8')}")
# Consider logging more details from request.form if needed
else:
logger.warning(f"Unexpected response from PayPal verification: {response.text}")
return "", 200 # Always return 200 OK to PayPal IPN
if __name__ == "__main__":
# Make sure BOT_TOKEN is loaded if running directly (e.g., via dotenv)
# from dotenv import load_dotenv
# load_dotenv()
# BOT_TOKEN = os.getenv("BOT_TOKEN") # Ensure BOT_TOKEN is available
if not BOT_TOKEN:
print("Error: BOT_TOKEN environment variable not set.")
exit(1)
TELEGRAM_API_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" # Set global URL
app.run(port=5000, debug=False, host="0.0.0.0") # Disable debug in production

7
app/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
python-telegram-bot[ext]==21.1.1 # Use specific version, ensure [ext] is included
python-dotenv==1.0.1
pillow==10.2.0 # Use specific version
requests==2.31.0 # Use specific version
Flask==3.0.0 # Use specific version
APScheduler==3.10.4 # Add APScheduler
newrelic-telemetry-sdk==0.8.0