Initial commit

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

382
app/paypal_processor.py Normal file
View File

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