First commit
This commit is contained in:
10
app/Dockerfile
Normal file
10
app/Dockerfile
Normal 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" ]
|
||||
9
app/Dockerfile.paypal_processor
Normal file
9
app/Dockerfile.paypal_processor
Normal 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" ]
|
||||
200
app/app.py
Normal file
200
app/app.py
Normal file
@@ -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()
|
||||
52
app/config.py
Normal file
52
app/config.py
Normal file
@@ -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"
|
||||
601
app/database.py
Normal file
601
app/database.py
Normal file
@@ -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()
|
||||
938
app/handlers.py
Normal file
938
app/handlers.py
Normal file
@@ -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 '<Not Text>'}")
|
||||
|
||||
# --- 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
|
||||
441
app/helpers.py
Normal file
441
app/helpers.py
Normal file
@@ -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"]
|
||||
163
app/keyboards.py
Normal file
163
app/keyboards.py
Normal file
@@ -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)
|
||||
402
app/paypal_processor.py
Normal file
402
app/paypal_processor.py
Normal file
@@ -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
|
||||
8
app/requirements.txt
Normal file
8
app/requirements.txt
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user