# 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 from newrelic_telemetry_sdk import Log, LogClient # Import handlers and db/config from handlers import * from database import init_db, get_expired_reservations, cancel_reservation_by_id from config import * # Logging setup logging.basicConfig( format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO ) # Suppress overly verbose logs from libraries logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("apscheduler").setLevel(logging.WARNING) client = LogClient(license_key=NEWRELIC_API_KEY, host="log-api.eu.newrelic.com") class NewRelicHandler(logging.Handler): def emit(self, record): try: log = Log( message=self.format(record), level=record.levelname, timestamp_ms=int(record.created * 1000), attributes={ "logger": record.name, "app_name": BOT_NAME, "docker_container": "telerifas" } ) client.send(log) except Exception: self.handleError(record) nr_handler = NewRelicHandler() nr_handler.setFormatter(logging.Formatter("%(message)s")) root_logger = logging.getLogger() root_logger.addHandler(nr_handler) logger = logging.getLogger(__name__) # Scheduler Job (Callback for PTB Job Queue) async def check_expired_reservations(context: ContextTypes.DEFAULT_TYPE): """Job callback to cancel expired reservations and notify users.""" # context is automatically provided by PTB's Job Queue # No need to get app from context explicitly here for bot access, # context.bot can be used directly. now = time.time() timeout_seconds = RESERVATION_TIMEOUT_MINUTES * 60 expiry_threshold = now - timeout_seconds logger.info(f"Running check_expired_reservations (Threshold: {expiry_threshold})") expired_list = get_expired_reservations(expiry_threshold) # DB function remains the same if not expired_list: logger.info("No expired reservations found.") return logger.info(f"Found {len(expired_list)} expired reservations to cancel.") cancelled_count = 0 for reservation in expired_list: participant_id = reservation['id'] user_id = reservation['user_id'] raffle_id = reservation['raffle_id'] raffle_name = reservation['raffle_name'] numbers = reservation['numbers'] logger.warning(f"Expiring reservation for User ID: {user_id}, Raffle: {raffle_name} ({raffle_id}), Numbers: {numbers}") # Cancel the reservation in the database first if cancel_reservation_by_id(participant_id): cancelled_count += 1 # Try to notify the user using context.bot notification_text = ( f"Las participaciones {numbers} que tenías reservadas para el sorteo {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.HTML) logger.info(f"Notified user {user_id} (Name: {reservation['user_name']}) 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]) async def announce_reminder_active_raffles(context: ContextTypes.DEFAULT_TYPE): """Job callback to announce active raffles in announce channels.""" # 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 announce_reminder_active_raffles") raffles = get_active_raffles() if not raffles: logger.info("No active raffles to announce.") return message_lines = ["🎉 Sorteos Activos 🎉\n"] for raffle in raffles: remaining = get_remaining_numbers_amount(raffle['id']) message_lines.append( f"• {raffle['name']}\n" f" Donación mínima: {raffle['price']}€\n" f" Participaciones restantes: {remaining}\n" f" https://t.me/{REVERSE_CHANNELS.get(raffle['channel_id'])}/{get_main_message_id(raffle['id'])}\n" ) message_text = "\n".join(message_lines) for alias, channel_id in ANNOUNCE_CHANNELS.items(): try: await context.bot.send_message(chat_id=channel_id, text=message_text, parse_mode=ParseMode.HTML) logger.info(f"Announced active raffles in channel {alias} ({channel_id}).") except Forbidden: logger.warning(f"Cannot send announcement to channel {alias} ({channel_id}) (Forbidden).") except BadRequest as e: logger.error(f"BadRequest sending announcement to channel {alias} ({channel_id}): {e}") except Exception as e: logger.error(f"Failed to send announcement to channel {alias} ({channel_id}): {e}") # --- 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.") job_queue.run_daily( callback=announce_reminder_active_raffles, time=dtime(hour=13, minute=0, tzinfo=madrid_tz), name="announce_active_raffles_job" ) logger.info("Scheduled announce active raffles job every day at 13:00 Madrid time.") # --- Handlers (Remain the same) --- # 1. Raffle Creation Conversation Handler raffle_creation_conv = ConversationHandler( entry_points=[CommandHandler("crear_sorteo", 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) ], INTERNATIONAL_SHIPPING: [ MessageHandler(filters.TEXT & ~filters.COMMAND, receive_international_shipping) ], 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()