First commit

This commit is contained in:
Joan
2025-09-05 12:27:45 +02:00
parent d18c2fbf7b
commit d3b4cd7eaa
14 changed files with 2900 additions and 90 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
/data

View File

@@ -1,92 +1,3 @@
# telerifas
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin http://gitlab.kingstudio.es/jocaru/telerifas.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
- [ ] [Set up project integrations](http://gitlab.kingstudio.es/jocaru/telerifas/-/settings/integrations)
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
TO BE WRITTEN

10
app/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.11
RUN mkdir /app
ADD . /app
RUN pip install -r /app/requirements.txt
RUN apt-get update && apt-get install -y fonts-dejavu
WORKDIR /app
CMD [ "python", "/app/app.py" ]

View File

@@ -0,0 +1,9 @@
FROM python:3.11
RUN mkdir /app
ADD . /app
RUN pip install -r /app/requirements.txt
WORKDIR /app
CMD [ "python", "/app/paypal_processor.py" ]

200
app/app.py Normal file
View 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
View 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', # MonThu
'friday': 'VIE', # Fri
'weekend': 'DOM' # SatSun
}
JUEGOS_ONCE_URL = "https://www.juegosonce.es/resultados-ultimos-sorteos-once"

601
app/database.py Normal file
View 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
View 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"&currency_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
View 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:** {'' 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
View 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
View 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
View 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

62
docker-compose.yml Normal file
View File

@@ -0,0 +1,62 @@
services:
telerifas:
build:
context: app
dockerfile: Dockerfile
image: telerifas
container_name: telerifas
volumes:
- ./data:/app/data
dns:
- 8.8.8.8
restart: unless-stopped
environment:
- TZ="Europe/Madrid"
- BOT_TOKEN=${BOT_TOKEN}
- BOT_NAME=${BOT_NAME}
- ADMIN_IDS=${ADMIN_IDS}
- CHANNEL_IDS=${CHANNEL_IDS}
- PAYPAL_EMAIL=${PAYPAL_EMAIL}
- PAYPAL_HANDLE=${PAYPAL_HANDLE}
- PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}
- PAYPAL_SECRET=${PAYPAL_SECRET}
- WEBHOOK_URL=${WEBHOOK_URL}
- WEBHOOK_ID=${WEBHOOK_ID}
- TYC_DOCUMENT_URL=${TYC_DOCUMENT_URL}
telerifas_paypal_processor:
build:
context: app
dockerfile: Dockerfile.paypal_processor
image: telerifas_paypal_processor
container_name: telerifas_paypal_processor
volumes:
- ./data:/app/data
restart: unless-stopped
environment:
- TZ="Europe/Madrid"
- BOT_TOKEN=${BOT_TOKEN}
- BOT_NAME=${BOT_NAME}
- CHANNEL_IDS=${CHANNEL_IDS}
- PAYPAL_EMAIL=${PAYPAL_EMAIL}
- PAYPAL_HANDLE=${PAYPAL_HANDLE}
- PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}
- PAYPAL_SECRET=${PAYPAL_SECRET}
- WEBHOOK_URL=${WEBHOOK_URL}
- WEBHOOK_ID=${WEBHOOK_ID}
- TYC_DOCUMENT_URL=${TYC_DOCUMENT_URL}
networks:
- traefik
labels:
- traefik.enable=true
- traefik.http.routers.telerifas-http.entrypoints=web
- traefik.http.routers.telerifas-http.rule=Host(`telerifas.patacuack.net`)
- traefik.http.routers.telerifas-http.middlewares=https-redirect@file
- traefik.http.routers.telerifas.entrypoints=websecure
- traefik.http.routers.telerifas.rule=Host(`telerifas.patacuack.net`)
- traefik.http.routers.telerifas.tls=true
- traefik.http.routers.telerifas.tls.certResolver=production
- traefik.http.services.telerifas.loadbalancer.server.port=5000
networks:
traefik:
external: true

11
example.env Normal file
View File

@@ -0,0 +1,11 @@
BOT_TOKEN=
BOT_NAME=
ADMIN_IDS=
CHANNEL_IDS=
PAYPAL_EMAIL=
PAYPAL_HANDLE=
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
WEBHOOK_URL=
WEBHOOK_ID=
TYC_DOCUMENT_URL=