#!/usr/bin/env python3 """Dashboard principal del Proyecto Global.""" from __future__ import annotations import datetime import json import os import shutil import queue import random import socket import subprocess import sys import tempfile import threading import time import traceback import webbrowser from dataclasses import dataclass from typing import Any, Callable import tkinter as tk from tkinter import filedialog, messagebox, scrolledtext, ttk from tkinter import font as tkfont # -------------------- dependencias opcionales -------------------- try: import psutil # type: ignore except Exception: # pragma: no cover psutil = None # type: ignore try: import matplotlib matplotlib.use('TkAgg') from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure MATPLOTLIB_AVAILABLE = True except Exception: # pragma: no cover MATPLOTLIB_AVAILABLE = False Figure = None FigureCanvasTkAgg = None try: import pygame # type: ignore pygame_available = True except Exception: # pragma: no cover pygame_available = False try: import requests # type: ignore REQUESTS_AVAILABLE = True except Exception: # pragma: no cover requests = None # type: ignore REQUESTS_AVAILABLE = False try: from bs4 import BeautifulSoup BEAUTIFULSOUP_AVAILABLE = True except Exception: # pragma: no cover BEAUTIFULSOUP_AVAILABLE = False SERVER_HOST_DEFAULT = '127.0.0.1' SERVER_PORT_DEFAULT = 3333 # Clave predeterminada facilitada por el usuario para OpenWeather. # Puede sobrescribirse exportando OPENWEATHER_API_KEY en el entorno. OPENWEATHER_FALLBACK_API_KEY = os.environ.get( 'OPENWEATHER_FALLBACK_API_KEY', '431239407e3628578c83e67180cf720f' ).strip() _OPENWEATHER_ENV_KEY = os.environ.get('OPENWEATHER_API_KEY', '').strip() OPENWEATHER_API_KEY = _OPENWEATHER_ENV_KEY or OPENWEATHER_FALLBACK_API_KEY JAVEA_LATITUDE = 38.789166 JAVEA_LONGITUDE = 0.163055 # -------------------- paleta visual -------------------- PRIMARY_BG = '#f4f5fb' PANEL_BG = '#ffffff' SECONDARY_BG = '#eef2ff' ACCENT_COLOR = '#ff6b6b' ACCENT_DARK = '#c44569' TEXT_COLOR = '#1f2d3d' SUBTEXT_COLOR = '#67708a' BUTTON_BG = '#e7ecff' BUTTON_ACTIVE_BG = '#ffd6d1' def _env_float(name: str, default: float) -> float: value = os.environ.get(name) if value is None: return default try: return float(value) except ValueError: return default WALLAPOP_DEVICE_ID = os.environ.get('WALLAPOP_DEVICE_ID', '48ca24ec-2e8c-4dbb-9f0b-6b4f99194626').strip() WALLAPOP_APP_VERSION = os.environ.get('WALLAPOP_APP_VERSION', '814060').strip() WALLAPOP_MPID = os.environ.get('WALLAPOP_MPID', '6088013701866267655').strip() WALLAPOP_DEVICE_OS = os.environ.get('WALLAPOP_DEVICE_OS', '0').strip() WALLAPOP_USER_AGENT = os.environ.get( 'WALLAPOP_USER_AGENT', 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) ' 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Mobile Safari/537.36' ).strip() WALLAPOP_SEC_CH_UA = os.environ.get('WALLAPOP_SEC_CH_UA', '"Not_A Brand";v="99", "Chromium";v="142"') WALLAPOP_SEC_CH_UA_MOBILE = os.environ.get('WALLAPOP_SEC_CH_UA_MOBILE', '?1') WALLAPOP_SEC_CH_UA_PLATFORM = os.environ.get('WALLAPOP_SEC_CH_UA_PLATFORM', '"Android"') WALLAPOP_LATITUDE = _env_float('WALLAPOP_LATITUDE', 40.416775) WALLAPOP_LONGITUDE = _env_float('WALLAPOP_LONGITUDE', -3.70379) WALLAPOP_COUNTRY_CODE = os.environ.get('WALLAPOP_COUNTRY_CODE', 'ES').strip() or 'ES' WALLAPOP_LANGUAGE = os.environ.get('WALLAPOP_LANGUAGE', 'es').strip() or 'es' WALLAPOP_CATEGORY_ID = os.environ.get('WALLAPOP_CATEGORY_ID', '').strip() WALLAPOP_REFERER = os.environ.get('WALLAPOP_REFERER', 'https://es.wallapop.com/').strip() WALLAPOP_HEADERS = { 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'es,es-ES;q=0.9', 'Referer': WALLAPOP_REFERER, 'User-Agent': WALLAPOP_USER_AGENT, 'x-deviceid': WALLAPOP_DEVICE_ID, 'x-deviceos': WALLAPOP_DEVICE_OS, 'deviceos': WALLAPOP_DEVICE_OS, 'x-appversion': WALLAPOP_APP_VERSION, 'mpid': WALLAPOP_MPID, 'sec-ch-ua': WALLAPOP_SEC_CH_UA, 'sec-ch-ua-mobile': WALLAPOP_SEC_CH_UA_MOBILE, 'sec-ch-ua-platform': WALLAPOP_SEC_CH_UA_PLATFORM } class GameClient: """Cliente TCP para el juego Minesweeper.""" def __init__(self, on_message: Callable[[dict], None], on_disconnect: Callable[[], None]): self._on_message = on_message self._on_disconnect = on_disconnect self._sock: socket.socket | None = None self._lock = threading.Lock() self._connected = False def connect(self, host: str, port: int) -> bool: with self._lock: if self._connected: return True try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) self._sock = sock self._connected = True # Guardar nuestra dirección local self.my_address = str(sock.getsockname()) print(f"[CLIENT] Mi dirección: {self.my_address}") threading.Thread(target=self._recv_loop, daemon=True).start() return True except Exception as exc: print(f"[ERROR] Conexión fallida: {exc}") return False def _recv_loop(self): try: while self._connected and self._sock: data = self._sock.recv(4096) if not data: print("[CLIENT] No data received, connection closed") break try: text_chunk = data.decode('utf-8', errors='replace') print(f"[CLIENT] Received: {text_chunk[:200]}") # Debug: primeros 200 chars # Split by newline for robust framing lines = text_chunk.split('\n') # Note: this simple split might break if a message is split across recv calls. # For a robust production app we need a buffer. # Assuming short JSONs for now. for line in lines: line = line.strip() if not line: continue try: msg = json.loads(line) print(f"[CLIENT] Parsed message: {msg.get('type')}") self._on_message(msg) except json.JSONDecodeError as e: print(f"[CLIENT] JSON decode error: {e}, line: {line[:100]}") except Exception as e: print(f"[CLIENT] Exception in recv: {e}") except Exception as exc: print(f"[ERROR] Recv: {exc}") finally: with self._lock: self._connected = False if self._sock: try: self._sock.close() except Exception: pass self._sock = None print("[CLIENT] Calling _on_disconnect") self._on_disconnect() def send(self, data: dict) -> bool: with self._lock: if not self._connected or not self._sock: return False try: msg = json.dumps(data) + '\n' self._sock.sendall(msg.encode('utf-8')) return True except Exception: self._connected = False return False def close(self): with self._lock: self._connected = False if self._sock: try: self._sock.close() except Exception: pass self._sock = None class DashboardApp(tk.Tk): def __init__(self) -> None: super().__init__() self.title('Panel de laboratorio - Proyecto Global') self.geometry('1220x740') self.minsize(1024, 660) self.configure(bg=PRIMARY_BG) self._setup_fonts() self._maximize_with_borders() self.columnconfigure(0, weight=0) self.columnconfigure(1, weight=1) self.columnconfigure(2, weight=0) self.rowconfigure(0, weight=0) self.rowconfigure(1, weight=1) self.rowconfigure(2, weight=0) self._running = True self._game_queue: 'queue.Queue[dict]' = queue.Queue() self._scraping_queue: 'queue.Queue[tuple[str, ...]]' = queue.Queue() self._traffic_last = psutil.net_io_counters() if psutil else None self._resource_history = {'cpu': [], 'mem': [], 'threads': []} self._resource_poll_job: str | None = None self.game_client = GameClient(self._enqueue_game_message, self._on_game_disconnect) self._game_phase = 'LOBBY' # LOBBY, PLACING, PLAYING self.grid_buttons = {} # (x, y) -> tk.Button self.alarm_counter = 1 self.active_alarms: list[dict[str, datetime.datetime | str]] = [] self.game_window_canvas = None self.game_window_status = None self.game_move_queue: queue.Queue | None = None self.game_queue_processor_active = False self.game_done_event: threading.Event | None = None self.game_finish_line = 0 self.game_racer_names: list[str] = [] self.game_camel_count = 3 self.game_speed_factor = 1.0 self.music_temp_file: str | None = None self.weather_api_key = OPENWEATHER_API_KEY self.results_area: tk.Frame | None = None self.results_title: tk.Label | None = None self.scraping_popup: tk.Toplevel | None = None self.simple_scraping_popup: tk.Toplevel | None = None self.weather_popup: tk.Toplevel | None = None self.chart_canvas = None self.ax_cpu = None self.ax_mem = None self.ax_threads = None self.line_cpu = None self.line_mem = None self.mem_fill = None self.thread_bars = None if psutil: try: self._ps_process = psutil.Process(os.getpid()) except Exception: self._ps_process = None else: self._ps_process = None self._build_header() self._build_left_panel() self._build_center_panel() self._build_right_panel() self._build_status_bar() self._update_clock() if psutil: self.after(1000, self._update_traffic) try: psutil.cpu_percent(interval=None) except Exception: pass self._resource_poll_job = self.after(1000, self._resource_poll_tick) threading.Thread(target=self._game_loop, daemon=True).start() self.after(100, self._process_scraping_queue) self.after(1000, self._refresh_alarms_loop) # ------------------------ UI ------------------------ def _maximize_with_borders(self) -> None: def _apply_zoom(): # Intentar diferentes atributos para maximizar/pantalla completa en diferentes OS for attr in ('-zoomed',): try: self.attributes(attr, True) return except tk.TclError: continue try: self.state('zoomed') except tk.TclError: pass self.after(0, _apply_zoom) def _setup_fonts(self) -> None: base_family = 'Segoe UI' self.font_title = tkfont.Font(family=base_family, size=16, weight='bold') self.font_header = tkfont.Font(family=base_family, size=13, weight='bold') self.font_body = tkfont.Font(family=base_family, size=11) self.font_small = tkfont.Font(family=base_family, size=10) def _build_header(self) -> None: header = tk.Frame(self, bg=PANEL_BG, bd=0, highlightthickness=0) header.grid(row=0, column=0, columnspan=3, sticky='new') header.grid_columnconfigure(tuple(range(7)), weight=1) tabs = [ ('T1. Procesos', '#4c6ef5'), ('T2. Threads', '#ff6b6b'), ('T3. Sockets', '#ffa94d'), ('T4. Servicios', '#51cf66'), ('T5. Seguridad', '#845ef7'), ('Configuración', '#2f3545') ] for idx, (text, color) in enumerate(tabs): badge = tk.Label( header, text=text, fg=color, bg=PANEL_BG, font=self.font_title, padx=16, pady=8 ) badge.grid(row=0, column=idx, padx=6, pady=8) def _build_left_panel(self) -> None: panel = tk.Frame(self, width=220, bg=SECONDARY_BG, bd=0, highlightthickness=0) panel.grid(row=1, column=0, sticky='nsw', padx=6, pady=(50,6)) panel.grid_propagate(False) self.left_panel = panel self._left_section('Acciones') self._left_button('Analizar Wallapop', self._open_wallapop_popup) self._left_button('Scraping simple', self._open_simple_scraping_popup) self._left_button('Navegar', lambda: self._open_web('https://www.google.com')) self._left_button('API Tiempo', self._show_weather_popup) self._left_section('Aplicaciones') self._left_button('Visual Code', lambda: self._launch_process(['code'])) self._left_button('Camellos', self._open_game_window) self._left_button('App3', lambda: self._launch_process(['firefox'])) self._left_section('Procesos batch') self._left_button('Copias de seguridad', self._run_backup_script) def _left_section(self, text: str) -> None: tk.Label(self.left_panel, text=text.upper(), bg=SECONDARY_BG, fg=SUBTEXT_COLOR, font=self.font_small).pack(anchor='w', padx=16, pady=(16,4)) def _left_button(self, text: str, command) -> None: btn = tk.Button( self.left_panel, text=text, width=20, bg=BUTTON_BG, activebackground=BUTTON_ACTIVE_BG, relief='flat', command=command ) btn.pack(fill='x', padx=16, pady=4) def _build_center_panel(self) -> None: center = tk.Frame(self, bg=PANEL_BG, bd=0) center.grid(row=1, column=1, sticky='nsew', padx=6, pady=(50,6)) center.rowconfigure(1, weight=1) center.columnconfigure(0, weight=1) self.center_panel = center self._build_notebook() self._build_notes_panel() def _build_notebook(self) -> None: style = ttk.Style() style.configure('Custom.TNotebook', background=PANEL_BG, borderwidth=0) style.configure('Custom.TNotebook.Tab', padding=(16, 8), font=self.font_body) style.map('Custom.TNotebook.Tab', background=[('selected', '#dde2ff')]) notebook = ttk.Notebook(self.center_panel, style='Custom.TNotebook') notebook.grid(row=0, column=0, sticky='nsew', padx=4, pady=4) self.notebook = notebook self.tab_resultados = tk.Frame(notebook, bg='white') self.tab_navegador = tk.Frame(notebook, bg='white') self.tab_correos = tk.Frame(notebook, bg='white') self.tab_tareas = tk.Frame(notebook, bg='white') self.tab_alarmas = tk.Frame(notebook, bg='white') self.tab_enlaces = tk.Frame(notebook, bg='white') self.tab_bloc = tk.Frame(notebook, bg='white') notebook.add(self.tab_resultados, text='Resultados') notebook.add(self.tab_navegador, text='Navegador') notebook.add(self.tab_correos, text='Correos') notebook.add(self.tab_bloc, text='Bloc de notas') notebook.add(self.tab_tareas, text='Tareas') notebook.add(self.tab_alarmas, text='Alarmas') notebook.add(self.tab_enlaces, text='Enlaces') self._build_tab_resultados() self._build_tab_navegador() self._build_tab_correos() self._build_tab_bloc_notas() self._build_tab_tareas() self._build_tab_alarmas() self._build_tab_enlaces() def _build_tab_resultados(self) -> None: wrapper = tk.Frame(self.tab_resultados, bg=PANEL_BG) wrapper.pack(fill='both', expand=True) self.results_title = tk.Label(wrapper, text='Resultados de actividades', font=self.font_header, bg=PANEL_BG, fg=TEXT_COLOR) self.results_title.pack(pady=(12, 4)) toolbar = tk.Frame(wrapper, bg=PANEL_BG) toolbar.pack(fill='x', padx=12, pady=(0, 4)) tk.Button(toolbar, text='Ver monitor del sistema', command=self._show_resource_monitor, bg=BUTTON_BG, relief='flat').pack(side='left', padx=(0, 8)) tk.Button(toolbar, text='Limpiar resultados', command=self._reset_results_view, bg=BUTTON_BG, relief='flat').pack(side='left') self.results_area = tk.Frame(wrapper, bg='#f4f7ff', bd=1, relief='solid') self.results_area.pack(fill='both', expand=True, padx=12, pady=(0, 12)) self._reset_results_view() def _prepare_results_area(self) -> None: self._stop_game_queue_processor() if not self.results_area: return for child in self.results_area.winfo_children(): child.destroy() self.chart_canvas = None self.ax_cpu = None self.ax_mem = None self.ax_threads = None self.line_cpu = None self.line_mem = None self.mem_fill = None self.thread_bars = None self.game_window_canvas = None self.game_window_status = None def _reset_results_view(self) -> None: self._prepare_results_area() if self.results_title: self.results_title.config(text='Resultados de actividades') if self.results_area: tk.Label( self.results_area, text='Ejecute una actividad para ver aquí sus resultados más recientes.', bg='#f4f7ff', fg=SUBTEXT_COLOR, font=self.font_body, wraplength=520 ).pack(expand=True, padx=12, pady=12) def _render_results_view(self, title: str, builder: Callable[[tk.Frame], None]) -> None: if self.results_title: self.results_title.config(text=f'Resultados • {title}') self._prepare_results_area() if not self.results_area: return builder(self.results_area) def _show_resource_monitor(self) -> None: def builder(parent: tk.Frame) -> None: if not (MATPLOTLIB_AVAILABLE and psutil): tk.Label( parent, text='Instale matplotlib + psutil para habilitar el monitor del sistema.', fg=ACCENT_COLOR, bg='#f4f7ff', font=self.font_body ).pack(fill='both', expand=True, padx=20, pady=20) return chart_frame = tk.Frame(parent, bg=PANEL_BG) chart_frame.pack(fill='both', expand=True, padx=12, pady=12) fig = Figure(figsize=(7.2, 5.6), dpi=100) self.ax_cpu = fig.add_subplot(311) self.ax_mem = fig.add_subplot(312) self.ax_threads = fig.add_subplot(313) self.ax_cpu.set_title('CPU (línea)') self.ax_cpu.set_ylim(0, 100) self.ax_cpu.set_ylabel('%') self.ax_cpu.grid(True, alpha=0.25) self.line_cpu, = self.ax_cpu.plot([], [], label='CPU', color='#ff6b6b', linewidth=2) self.ax_mem.set_title('Memoria (área)') self.ax_mem.set_ylim(0, 100) self.ax_mem.set_ylabel('%') self.ax_mem.grid(True, alpha=0.2) self.line_mem, = self.ax_mem.plot([], [], color='#4ecdc4', linewidth=1.5) self.ax_threads.set_title('Hilos del proceso (barras)') self.ax_threads.set_ylabel('Hilos') self.ax_threads.grid(True, axis='y', alpha=0.2) fig.tight_layout(pad=2.2) self.chart_canvas = FigureCanvasTkAgg(fig, master=chart_frame) self.chart_canvas.get_tk_widget().pack(fill='both', expand=True) self.ax_cpu.set_xlim(0, 40) self.ax_mem.set_xlim(0, 40) self.ax_threads.set_xlim(-0.5, 39.5) self.chart_canvas.draw_idle() self._render_results_view('Monitoreo del sistema', builder) def _show_text_result(self, title: str, text: str) -> None: def builder(parent: tk.Frame) -> None: frame = tk.Frame(parent, bg='white') frame.pack(fill='both', expand=True, padx=12, pady=12) box = scrolledtext.ScrolledText(frame, height=12) box.pack(fill='both', expand=True) box.insert('1.0', text) box.config(state='disabled') self._render_results_view(title, builder) def _format_weather_summary(self, data: dict[str, Any]) -> str: main = data.get('main', {}) weather = data.get('weather', [{}])[0] wind = data.get('wind', {}) temp = main.get('temp') feels = main.get('feels_like') humidity = main.get('humidity') desc = weather.get('description', '').capitalize() wind_speed = wind.get('speed') timestamp = data.get('dt') updated = datetime.datetime.fromtimestamp(timestamp).strftime('%d/%m %H:%M') if timestamp else 'N/D' summary = [ f'Descripción : {desc or "N/D"}', f'Temperatura : {temp:.1f}°C' if temp is not None else 'Temperatura : N/D', f'Sensación : {feels:.1f}°C' if feels is not None else 'Sensación : N/D', f'Humedad : {humidity}%' if humidity is not None else 'Humedad : N/D', f'Veloc. viento: {wind_speed:.1f} m/s' if wind_speed is not None else 'Veloc. viento: N/D', f'Actualizado : {updated}' ] return '\n'.join(summary) def _get_javea_weather_snapshot(self) -> tuple[str, str, bool]: try: data = self._fetch_javea_weather() summary = self._format_weather_summary(data) timestamp = data.get('dt') status = 'Última lectura: ' status += datetime.datetime.fromtimestamp(timestamp).strftime('%d/%m %H:%M') if timestamp else 'N/D' return summary, status, False except Exception as exc: return str(exc), 'No se pudo obtener el clima', True def _show_weather_popup(self) -> None: self._log('Abriendo ventana del tiempo para Jávea') text, status, is_error = self._get_javea_weather_snapshot() if self.weather_popup and self.weather_popup.winfo_exists(): self.weather_popup.destroy() popup = tk.Toplevel(self) popup.title('Tiempo en Jávea') popup.geometry('760x500') popup.minsize(760, 500) popup.resizable(False, False) popup.configure(bg='white') popup.transient(self) popup.grab_set() self.weather_popup = popup header = tk.Label(popup, text='OpenWeather • Jávea, España', font=self.font_header, bg='white', fg=TEXT_COLOR) header.pack(pady=(12, 6)) info_label = tk.Label( popup, text=text, justify='left', font=('Consolas', 12), anchor='nw', bg='white', fg='red' if is_error else TEXT_COLOR, padx=6, pady=6 ) info_label.pack(fill='both', expand=True, padx=18, pady=8) status_label = tk.Label(popup, text=status, font=self.font_small, bg='white', fg=SUBTEXT_COLOR) status_label.pack(pady=(0, 8)) def close_popup() -> None: if popup.winfo_exists(): popup.destroy() if self.weather_popup is popup: self.weather_popup = None def refresh() -> None: new_text, new_status, new_is_error = self._get_javea_weather_snapshot() info_label.config(text=new_text, fg='red' if new_is_error else '#1f2d3d') status_label.config(text=new_status) buttons = tk.Frame(popup, bg='white') buttons.pack(pady=(4, 14)) tk.Button(buttons, text='Actualizar', command=refresh, bg=BUTTON_BG, relief='flat').pack(side='left', padx=10) tk.Button(buttons, text='Cerrar', command=close_popup, bg=BUTTON_BG, relief='flat').pack(side='left', padx=10) popup.protocol('WM_DELETE_WINDOW', close_popup) def _build_tab_navegador(self) -> None: tk.Label(self.tab_navegador, text='Abrir URL en navegador externo', bg='white').pack(pady=6) frame = tk.Frame(self.tab_navegador, bg='white') frame.pack(pady=6) self.url_entry = tk.Entry(frame, width=48) self.url_entry.insert(0, 'https://www.python.org') self.url_entry.pack(side='left', padx=4) tk.Button(frame, text='Abrir', command=lambda: self._open_web(self.url_entry.get())).pack(side='left', padx=4) def _build_tab_correos(self) -> None: """Construye la interfaz completa del cliente de correo - Diseño mejorado""" self.tab_correos.configure(bg='#f0f2f5') # Variables de estado del correo self.mail_connected = False self.imap_connection = None self.current_mailbox = 'INBOX' self.mail_list = [] self.mail_attachments = [] self.unread_count = 0 self.unread_label = None self.mail_filter_unread = False # Filtro para mostrar solo no leídos # Panel de conexión con diseño moderno conn_frame = tk.LabelFrame(self.tab_correos, text='⚙️ Configuración del servidor', bg='#ffffff', font=('Arial', 12, 'bold'), fg='#1a73e8', padx=15, pady=12, relief='solid', bd=1) conn_frame.pack(fill='x', padx=15, pady=12) # Grid para campos de configuración config_grid = tk.Frame(conn_frame, bg='#ffffff') config_grid.pack(fill='x', pady=5) # Fila 1: IMAP tk.Label(config_grid, text='📥 IMAP:', bg='#ffffff', font=('Arial', 10, 'bold'), fg='#5f6368', width=12, anchor='e').grid(row=0, column=0, padx=8, pady=6, sticky='e') self.mail_imap_host = tk.Entry(config_grid, width=18, font=('Arial', 10), relief='solid', bd=1, bg='#f8f9fa') self.mail_imap_host.insert(0, '10.10.0.101') self.mail_imap_host.grid(row=0, column=1, padx=5, pady=6, sticky='w') tk.Label(config_grid, text='Puerto:', bg='#ffffff', font=('Arial', 10), fg='#5f6368').grid(row=0, column=2, padx=5, pady=6, sticky='e') self.mail_imap_port = tk.Entry(config_grid, width=8, font=('Arial', 10), relief='solid', bd=1, bg='#f8f9fa') self.mail_imap_port.insert(0, '143') self.mail_imap_port.grid(row=0, column=3, padx=5, pady=6, sticky='w') # Fila 2: SMTP tk.Label(config_grid, text='📤 SMTP:', bg='#ffffff', font=('Arial', 10, 'bold'), fg='#5f6368', width=12, anchor='e').grid(row=1, column=0, padx=8, pady=6, sticky='e') self.mail_smtp_host = tk.Entry(config_grid, width=18, font=('Arial', 10), relief='solid', bd=1, bg='#f8f9fa') self.mail_smtp_host.insert(0, '10.10.0.101') self.mail_smtp_host.grid(row=1, column=1, padx=5, pady=6, sticky='w') tk.Label(config_grid, text='Puerto:', bg='#ffffff', font=('Arial', 10), fg='#5f6368').grid(row=1, column=2, padx=5, pady=6, sticky='e') self.mail_smtp_port = tk.Entry(config_grid, width=8, font=('Arial', 10), relief='solid', bd=1, bg='#f8f9fa') self.mail_smtp_port.insert(0, '25') self.mail_smtp_port.grid(row=1, column=3, padx=5, pady=6, sticky='w') # Fila 3: Credenciales tk.Label(config_grid, text='👤 Usuario:', bg='#ffffff', font=('Arial', 10, 'bold'), fg='#5f6368', width=12, anchor='e').grid(row=2, column=0, padx=8, pady=6, sticky='e') self.mail_username = tk.Entry(config_grid, width=35, font=('Arial', 10), relief='solid', bd=1, bg='#f8f9fa') self.mail_username.grid(row=2, column=1, columnspan=3, padx=5, pady=6, sticky='ew') tk.Label(config_grid, text='🔒 Contraseña:', bg='#ffffff', font=('Arial', 10, 'bold'), fg='#5f6368', width=12, anchor='e').grid(row=3, column=0, padx=8, pady=6, sticky='e') self.mail_password = tk.Entry(config_grid, width=35, font=('Arial', 10), show='•', relief='solid', bd=1, bg='#f8f9fa') self.mail_password.grid(row=3, column=1, columnspan=3, padx=5, pady=6, sticky='ew') # Checkbox para recordar credenciales remember_frame = tk.Frame(config_grid, bg='#ffffff') remember_frame.grid(row=4, column=1, columnspan=3, padx=5, pady=8, sticky='w') self.mail_remember_var = tk.BooleanVar(value=True) tk.Checkbutton(remember_frame, text='💾 Recordar credenciales', variable=self.mail_remember_var, bg='#ffffff', font=('Arial', 9), fg='#5f6368', selectcolor='#ffffff', activebackground='#ffffff', cursor='hand2').pack(side='left') # Cargar credenciales guardadas self._load_mail_credentials() # Botones de conexión con diseño mejorado btn_row = tk.Frame(conn_frame, bg='#ffffff') btn_row.pack(fill='x', pady=12) self.btn_mail_connect = tk.Button(btn_row, text='🔗 Conectar', command=self._connect_mail_server, bg='#1a73e8', fg='white', relief='flat', font=('Arial', 11, 'bold'), padx=20, pady=8, cursor='hand2', activebackground='#1557b0') self.btn_mail_connect.pack(side='left', padx=8) self.btn_mail_disconnect = tk.Button(btn_row, text='⚠️ Desconectar', command=self._disconnect_mail_server, bg='#dc3545', fg='white', relief='flat', font=('Arial', 11, 'bold'), padx=20, pady=8, state='disabled', cursor='hand2', activebackground='#bd2130') self.btn_mail_disconnect.pack(side='left', padx=8) # Estado con diseño más llamativo status_frame = tk.Frame(btn_row, bg='#fef3cd', relief='solid', bd=1, padx=12, pady=6) status_frame.pack(side='left', padx=15) self.mail_status_label = tk.Label(status_frame, text='⚫ Desconectado', bg='#fef3cd', fg='#856404', font=('Arial', 10, 'bold')) self.mail_status_label.pack() # Panel principal dividido con mejor diseño main_panel = tk.PanedWindow(self.tab_correos, orient='horizontal', bg='#f0f2f5', sashwidth=8, sashrelief='raised', bd=0) main_panel.pack(fill='both', expand=True, padx=15, pady=(0, 15)) # ========== Panel izquierdo: Lista de correos ========== left_panel = tk.Frame(main_panel, bg='#ffffff', relief='solid', bd=1) main_panel.add(left_panel, minsize=350) # Header de la lista con gradiente visual toolbar = tk.Frame(left_panel, bg='#1a73e8', height=50) toolbar.pack(fill='x') toolbar_content = tk.Frame(toolbar, bg='#1a73e8') toolbar_content.pack(fill='both', expand=True, padx=12, pady=10) # Label del buzón actual self.mail_folder_label = tk.Label(toolbar_content, text='📬 Bandeja de entrada', bg='#1a73e8', fg='white', font=('Arial', 13, 'bold')) self.mail_folder_label.pack(side='left') # Botones de carpetas btn_folders = tk.Frame(toolbar_content, bg='#1a73e8') btn_folders.pack(side='left', padx=15) tk.Button(btn_folders, text='📬 Entrada', command=self._show_inbox, bg='#5f9cf4', fg='white', relief='flat', font=('Arial', 9, 'bold'), padx=10, pady=5, cursor='hand2', activebackground='#4285f4').pack(side='left', padx=2) tk.Button(btn_folders, text='📤 Enviados', command=self._show_sent, bg='#5f9cf4', fg='white', relief='flat', font=('Arial', 9, 'bold'), padx=10, pady=5, cursor='hand2', activebackground='#4285f4').pack(side='left', padx=2) # Botón de filtro "Sin leer" self.btn_filter_unread = tk.Button(btn_folders, text='🔵 Sin leer', command=self._toggle_filter_unread, bg='#7c8691', fg='white', relief='flat', font=('Arial', 9, 'bold'), padx=10, pady=5, cursor='hand2', activebackground='#5f6368') self.btn_filter_unread.pack(side='left', padx=2) # Botones de acción con estilo btn_actions = tk.Frame(toolbar_content, bg='#1a73e8') btn_actions.pack(side='right') tk.Button(btn_actions, text='✉️ Nuevo correo', command=self._open_compose_window, bg='#34a853', fg='white', relief='flat', font=('Arial', 10, 'bold'), padx=15, pady=6, cursor='hand2', activebackground='#2d8e47').pack(side='right', padx=3) # Lista de correos mejorada list_frame = tk.Frame(left_panel, bg='#ffffff') list_frame.pack(fill='both', expand=True, padx=2, pady=2) scrollbar = tk.Scrollbar(list_frame, width=14) scrollbar.pack(side='right', fill='y') self.mail_listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar.set, font=('Segoe UI', 10), height=20, selectbackground='#e8f0fe', selectforeground='#1a73e8', bg='#ffffff', fg='#202124', relief='flat', activestyle='none', highlightthickness=0) self.mail_listbox.pack(side='left', fill='both', expand=True) scrollbar.config(command=self.mail_listbox.yview) self.mail_listbox.bind('<>', self._on_mail_select) # ========== Panel derecho: Visor de correo ========== right_panel = tk.Frame(main_panel, bg='#ffffff', relief='solid', bd=1) main_panel.add(right_panel, minsize=500) # Header del correo con diseño atractivo mail_header = tk.Frame(right_panel, bg='#f8f9fa', relief='solid', bd=1) mail_header.pack(fill='x', padx=0, pady=0) headers_content = tk.Frame(mail_header, bg='#f8f9fa') headers_content.pack(fill='x', padx=20, pady=15) # Asunto destacado subject_frame = tk.Frame(headers_content, bg='#f8f9fa') subject_frame.pack(fill='x', pady=(0, 10)) self.mail_subject_label = tk.Label(subject_frame, text='', bg='#f8f9fa', font=('Arial', 14, 'bold'), anchor='w', fg='#202124', wraplength=600) self.mail_subject_label.pack(fill='x') # Info del remitente from_frame = tk.Frame(headers_content, bg='#f8f9fa') from_frame.pack(fill='x', pady=3) tk.Label(from_frame, text='De:', bg='#f8f9fa', font=('Arial', 10, 'bold'), fg='#5f6368', width=10, anchor='w').pack(side='left') self.mail_from_label = tk.Label(from_frame, text='', bg='#f8f9fa', font=('Arial', 10), anchor='w', fg='#1a73e8') self.mail_from_label.pack(side='left', fill='x', expand=True) # Fecha date_frame = tk.Frame(headers_content, bg='#f8f9fa') date_frame.pack(fill='x', pady=3) tk.Label(date_frame, text='Fecha:', bg='#f8f9fa', font=('Arial', 10, 'bold'), fg='#5f6368', width=10, anchor='w').pack(side='left') self.mail_date_label = tk.Label(date_frame, text='', bg='#f8f9fa', font=('Arial', 10), anchor='w', fg='#5f6368') self.mail_date_label.pack(side='left', fill='x', expand=True) # Separador elegante tk.Frame(right_panel, height=2, bg='#dadce0').pack(fill='x') # Frame para cuerpo y adjuntos content_frame = tk.Frame(right_panel, bg='#ffffff') content_frame.pack(fill='both', expand=True) # Cuerpo del correo con mejor tipografía - usando Text para soportar imágenes inline text_scroll_frame = tk.Frame(content_frame, bg='#ffffff') text_scroll_frame.pack(fill='both', expand=True) mail_body_scrollbar = tk.Scrollbar(text_scroll_frame) mail_body_scrollbar.pack(side='right', fill='y') self.mail_body_text = tk.Text(text_scroll_frame, wrap='word', font=('Segoe UI', 11), bg='#ffffff', fg='#202124', relief='flat', padx=15, pady=15, spacing1=3, spacing3=5, yscrollcommand=mail_body_scrollbar.set) self.mail_body_text.pack(side='left', fill='both', expand=True) mail_body_scrollbar.config(command=self.mail_body_text.yview) self.mail_body_text.config(state='disabled') # Lista para almacenar referencias de imágenes inline (evitar garbage collection) self.mail_inline_images = [] # Frame para mostrar adjuntos (imágenes) self.mail_attachments_frame = tk.Frame(content_frame, bg='#f8f9fa') # No se empaqueta hasta que haya adjuntos def _build_tab_bloc_notas(self) -> None: tk.Label(self.tab_bloc, text='Bloc de notas', bg='white', font=('Arial', 12, 'bold')).pack(pady=6) toolbar = tk.Frame(self.tab_bloc, bg='white') toolbar.pack(fill='x') tk.Button(toolbar, text='Abrir', command=self._open_text).pack(side='left', padx=4, pady=4) tk.Button(toolbar, text='Guardar', command=self._save_text).pack(side='left', padx=4, pady=4) self.editor = scrolledtext.ScrolledText(self.tab_bloc, wrap='word') self.editor.pack(fill='both', expand=True, padx=6, pady=6) def _build_tab_tareas(self) -> None: container = tk.Frame(self.tab_tareas, bg='white') container.pack(fill='both', expand=True) scraping_frame = tk.Frame(container, bg='white', padx=10, pady=10) scraping_frame.pack(side='left', fill='both', expand=True) tk.Label(scraping_frame, text='Análisis de enlaces Wallapop', bg='white', font=('Arial', 12, 'bold')).pack(pady=(12,4)) if not (REQUESTS_AVAILABLE and BEAUTIFULSOUP_AVAILABLE): tk.Label( scraping_frame, text='Instala requests y beautifulsoup4 para habilitar el análisis de enlaces.', bg='white', fg='red', wraplength=260, justify='left' ).pack(fill='x', padx=10, pady=10) else: tk.Label( scraping_frame, text='Pulsa el botón "Analizar Wallapop" en el panel izquierdo para introducir la URL de un anuncio. ' 'Aquí verás notas generales o recordatorios.', bg='white', wraplength=260, justify='left' ).pack(fill='x', padx=10, pady=10) game_frame = tk.Frame(container, bg='white') game_frame.pack(side='left', fill='both', expand=True, padx=10) tk.Label(game_frame, text='Juego de camellos (ventana dedicada)', bg='white', font=('Arial', 12, 'bold')).pack(pady=(6,2)) tk.Label( game_frame, text='Abre el simulador en una ventana independiente para ver la carrera a pantalla completa.', wraplength=260, justify='left', bg='white' ).pack(padx=8, pady=8) tk.Button(game_frame, text='Abrir juego', command=self._open_game_window).pack(pady=6) def _build_tab_alarmas(self) -> None: self.tab_alarmas.configure(bg=PANEL_BG) card = tk.Frame(self.tab_alarmas, bg='#fef9f4', bd=0, padx=18, pady=18) card.pack(pady=16, padx=20, fill='both', expand=True) tk.Label(card, text='Programar nuevas alarmas', font=self.font_header, bg='#fef9f4', fg=TEXT_COLOR).pack(pady=(0,8)) form = tk.Frame(card, bg='#fef9f4') form.pack(pady=6) tk.Label(form, text='Minutos', bg='#fef9f4', font=self.font_body).grid(row=0, column=0, padx=6) self.alarm_minutes = tk.Spinbox(form, from_=1, to=240, width=6, justify='center') self.alarm_minutes.grid(row=0, column=1, padx=6) tk.Label(form, text='Título', bg='#fef9f4', font=self.font_body).grid(row=0, column=2, padx=6) self.alarm_title = tk.Entry(form, width=24) self.alarm_title.grid(row=0, column=3, padx=6) tk.Button(form, text='Agregar alarma', bg=BUTTON_BG, relief='flat', command=self._start_alarm).grid(row=0, column=4, padx=10) tk.Label(card, text='Alarmas activas', font=self.font_body, bg='#fef9f4', fg=SUBTEXT_COLOR).pack(pady=(18,6)) self.alarm_list = tk.Listbox(card, height=8, width=50, bd=0, highlightthickness=1, highlightbackground='#e3e5f0') self.alarm_list.pack(pady=4, fill='x') tk.Button(card, text='Cancelar seleccionada', command=self._cancel_selected_alarm, bg=BUTTON_BG, relief='flat').pack(pady=(10,4)) self.alarm_status = tk.Label(card, text='Sin alarmas programadas', bg='#fef9f4', fg=SUBTEXT_COLOR, font=self.font_small) self.alarm_status.pack(pady=4) def _build_tab_enlaces(self) -> None: tk.Label(self.tab_enlaces, text='Enlaces útiles', bg='white', font=('Arial', 12, 'bold')).pack(pady=6) for text, url in [ ('Documentación Tkinter', 'https://docs.python.org/3/library/tk.html'), ('psutil', 'https://psutil.readthedocs.io'), ('Matplotlib', 'https://matplotlib.org') ]: tk.Button(self.tab_enlaces, text=text, command=lambda u=url: self._open_web(u)).pack(pady=3) def _build_notes_panel(self) -> None: notes = tk.LabelFrame(self.center_panel, text='Panel de notas e hilos', bg='#eefee8') notes.grid(row=1, column=0, sticky='nsew', padx=6, pady=(4,6)) self.notes = scrolledtext.ScrolledText(notes, height=5) self.notes.pack(fill='both', expand=True, padx=6, pady=6) def _build_right_panel(self) -> None: right = tk.Frame(self, width=700, bg=PANEL_BG, bd=0) right.grid(row=1, column=2, sticky='nse', padx=6, pady=(50,6)) right.grid_propagate(False) # Header Juego self.game_header = tk.Frame(right, bg=ACCENT_COLOR, pady=8) self.game_header.pack(fill='x') self.game_title_label = tk.Label(self.game_header, text='🎮 Minesweeper Multijugador', font=('Arial', 18, 'bold'), fg='white', bg=ACCENT_COLOR) self.game_title_label.pack() # Panel de Estado (Vidas, Ronda) stats = tk.Frame(right, bg='white', pady=8) stats.pack(pady=6, fill='x', padx=10) self.lbl_round = tk.Label(stats, text='Ronda: -', font=('Arial', 14, 'bold'), bg='white', fg=TEXT_COLOR) self.lbl_round.pack(side='left', padx=10) self.lbl_bombs = tk.Label(stats, text='💣 Bombas: -', font=('Arial', 14, 'bold'), fg='#e67e22', bg='white') self.lbl_bombs.pack(side='left', padx=10) self.lbl_lives = tk.Label(stats, text='❤️ Vidas: 3', font=('Arial', 14, 'bold'), fg='#c44569', bg='white') self.lbl_lives.pack(side='right', padx=10) # Panel de Juego (Grid) - MÁS GRANDE self.game_frame = tk.Frame(right, bg='#eeeeee', bd=3, relief='sunken') self.game_frame.pack(fill='both', expand=True, padx=10, pady=10) # Mensaje inicial en el grid tk.Label(self.game_frame, text='Conecta al servidor\npara comenzar', font=('Arial', 14), bg='#eeeeee', fg=SUBTEXT_COLOR).place(relx=0.5, rely=0.5, anchor='center') # Botones de Acción - MÁS GRANDES actions = tk.Frame(right, bg='white', pady=6) actions.pack(pady=6, fill='x', padx=10) self.btn_game_action = tk.Button(actions, text='🎯 Iniciar Juego', bg='#90ee90', font=('Arial', 12, 'bold'), command=self._start_game_req, relief='raised', bd=3, padx=15, pady=8, state='disabled') self.btn_game_action.pack(side='left', padx=5, expand=True, fill='x') # Check Done Button self.btn_check_done = tk.Button(actions, text='✓ Zona Despejada', bg='#fff3cd', font=('Arial', 12, 'bold'), command=self._check_dungeon_cleared, relief='raised', bd=3, padx=15, pady=8, state='disabled') self.btn_check_done.pack(side='left', padx=5, expand=True, fill='x') # Log de juego - MÁS GRANDE log_frame = tk.LabelFrame(right, text='📋 Log del Juego', bg='white', font=('Arial', 11, 'bold'), fg=TEXT_COLOR) log_frame.pack(padx=10, pady=6, fill='both', expand=False) self.game_log = scrolledtext.ScrolledText(log_frame, width=50, height=8, state='normal', font=('Consolas', 10), bg='#f8f9fa', fg=TEXT_COLOR) self.game_log.pack(padx=6, pady=6, fill='both', expand=True) self.game_log.insert('end', "🎮 Bienvenido al Minesweeper Multijugador\n") self.game_log.insert('end', "━" * 50 + "\n") self.game_log.insert('end', "1. Conecta al servidor\n") self.game_log.insert('end', "2. Espera a que otro jugador se conecte\n") self.game_log.insert('end', "3. Haz clic en 'Iniciar Juego'\n") self.game_log.insert('end', "━" * 50 + "\n") self.game_log.config(state='disabled') # Conexión - MEJORADO conn_frame = tk.LabelFrame(right, text='🔌 Conexión al Servidor', bg='white', font=('Arial', 11, 'bold'), fg=TEXT_COLOR) conn_frame.pack(pady=6, fill='x', padx=10) conn = tk.Frame(conn_frame, bg='white') conn.pack(pady=8, padx=10) tk.Label(conn, text='Host:', bg='white', font=('Arial', 11)).grid(row=0, column=0, sticky='e', padx=5) self.host_entry = tk.Entry(conn, width=15, font=('Arial', 11)) self.host_entry.insert(0, SERVER_HOST_DEFAULT) self.host_entry.grid(row=0, column=1, padx=5) tk.Label(conn, text='Puerto:', bg='white', font=('Arial', 11)).grid(row=1, column=0, sticky='e', padx=5) self.port_entry = tk.Entry(conn, width=8, font=('Arial', 11)) self.port_entry.insert(0, str(SERVER_PORT_DEFAULT)) self.port_entry.grid(row=1, column=1, padx=5, sticky='w') tk.Button(conn, text='🔗 Conectar', command=self._connect_game, bg='#4CAF50', fg='white', font=('Arial', 11, 'bold'), relief='raised', bd=3, padx=20, pady=5).grid(row=0, column=2, rowspan=2, padx=10) # Reproductor música (mini) player = tk.Frame(right, bg='#fdf5f5', bd=1, relief='flat', padx=4, pady=4) player.pack(fill='x', padx=8, pady=(4,6)) tk.Label(player, text='🎵 Música', font=self.font_small, bg='#fdf5f5').pack(side='left') tk.Button(player, text='▶', command=self._resume_music, width=3).pack(side='left', padx=2) tk.Button(player, text='||', command=self._pause_music, width=3).pack(side='left', padx=2) tk.Button(player, text='📁', command=self._select_music, width=3).pack(side='left', padx=2) # ------------------ Lógica Juego ------------------ def _enqueue_game_message(self, msg: dict): self._game_queue.put(msg) def _on_game_disconnect(self): self._game_queue.put({"type": "DISCONNECT"}) def _game_loop(self): while self._running: try: msg = self._game_queue.get(timeout=0.1) self.after(0, self._process_game_message, msg) except queue.Empty: continue def _connect_game(self): """Conecta al servidor de juego""" host = self.host_entry.get().strip() if not host: messagebox.showerror("Error", "Debes especificar un host") return try: port = int(self.port_entry.get()) if port < 1 or port > 65535: raise ValueError("Puerto fuera de rango") except ValueError: messagebox.showerror("Error", "Puerto inválido (debe ser 1-65535)") return self._log_game(f"Conectando a {host}:{port}...") try: if self.game_client.connect(host, port): self._log_game("✓ Conectado al servidor exitosamente") self.btn_game_action.config(state='normal') messagebox.showinfo("Conectado", f"Conectado a {host}:{port}") else: self._log_game("✗ Error al conectar") messagebox.showerror("Error", "No se pudo conectar al servidor") except Exception as e: self._log_game(f"✗ Excepción: {e}") messagebox.showerror("Error", f"Error de conexión: {e}") def _start_game_req(self): print("[CLIENT] Enviando START_GAME al servidor") self.game_client.send({"type": "START_GAME"}) self._log_game("Solicitando inicio de juego...") def _check_dungeon_cleared(self): self.game_client.send({"type": "CHECK_DUNGEON_CLEARED"}) def _log_game(self, text): self.game_log.config(state='normal') self.game_log.insert('end', f"> {text}\n") self.game_log.see('end') self.game_log.config(state='disabled') def _process_game_message(self, msg: dict): mtype = msg.get('type') if mtype == 'DISCONNECT': self._log_game("Desconectado del servidor.") self._game_phase = 'LOBBY' return if mtype == 'NEW_ROUND': r = msg.get('round') size = msg.get('grid_size') status = msg.get('status') bombs_per_player = msg.get('total_bombs_per_player', 3) # Guardar bombas restantes para el contador self._bombs_remaining = bombs_per_player self.lbl_bombs.config(text=f"💣 Bombas: {self._bombs_remaining}") self.lbl_round.config(text=f"Ronda: {r}") self._log_game(f"=== Ronda {r} ({size}x{size}) ===") self._log_game(f"Cada jugador debe poner {bombs_per_player} bombas") self._log_game(status) self._build_grid(size) self._game_phase = 'PLACING' self.btn_game_action.config(state='disabled') self.btn_check_done.config(state='disabled') elif mtype == 'TURN_NOTIFY': player = msg.get('active_player') text = msg.get('msg') self._log_game(f"🎯 {text}") # Cambiar color del header si es mi turno if hasattr(self.game_client, 'my_address') and player in self.game_client.my_address: self.game_header.config(bg='#4CAF50') # Verde self.game_title_label.config(bg='#4CAF50', text='🎮 ¡TU TURNO! - Coloca Bombas') else: self.game_header.config(bg=ACCENT_COLOR) # Rojo self.game_title_label.config(bg=ACCENT_COLOR, text='🎮 Esperando Turno...') elif mtype == 'BOMB_flash': x, y = msg.get('x'), msg.get('y') who = msg.get('who') self._log_game(f"💣 {who} colocó una bomba") # Decrementar contador de bombas SI es mi turno (mi bomba) if hasattr(self.game_client, 'my_address') and who in self.game_client.my_address: if hasattr(self, '_bombs_remaining') and self._bombs_remaining > 0: self._bombs_remaining -= 1 self.lbl_bombs.config(text=f"💣 Bombas: {self._bombs_remaining}") btn = self._get_grid_btn(x, y) if btn: # Mostrar bomba temporalmente orig_bg = btn.cget('bg') btn.config(bg='#ff6b6b', text='💣', fg='white', font=('Arial', 14, 'bold')) # Ocultar en 400ms (más rápido) def hide_bomb(b=btn, original=orig_bg): try: b.config(bg=original, text='', fg='black') except Exception: pass self.after(400, hide_bomb) elif mtype == 'PHASE_PLAY': self._log_game("⚔️ " + msg.get('msg', 'Fase de búsqueda iniciada')) self.btn_check_done.config(state='normal') self._game_phase = 'PLAYING' elif mtype == 'SEARCH_TURN': player = msg.get('active_player') text = msg.get('msg') self._log_game(f"{text}") # Cambiar color del header si es mi turno if hasattr(self.game_client, 'my_address') and player in self.game_client.my_address: self.game_header.config(bg='#4CAF50') # Verde self.game_title_label.config(bg='#4CAF50', text='🎮 ¡TU TURNO! - Excava') else: self.game_header.config(bg=ACCENT_COLOR) # Rojo self.game_title_label.config(bg=ACCENT_COLOR, text='🎮 Esperando Turno...') elif mtype == 'EXPLOSION': x, y = msg.get('x'), msg.get('y') lives = msg.get('lives') who = msg.get('who') player_addr = msg.get('player_addr', who) self._log_game(f"💥 BOOM! {who} pisó una bomba (Vidas: {lives})") btn = self._get_grid_btn(x, y) if btn: btn.config(bg='#c44569', text='💥', fg='white', font=('Arial', 16, 'bold'), state='disabled', relief='sunken') # SOLO actualizar vidas si es el jugador local (comparación con my_address) if hasattr(self.game_client, 'my_address') and player_addr in self.game_client.my_address: try: self.lbl_lives.config(text=f"❤️ Vidas: {lives}") if lives <= 0: self._log_game("☠️ ¡Sin vidas! Game Over") except Exception: pass # App cerrándose elif mtype == 'SAFE': x, y = msg.get('x'), msg.get('y') self._log_game(f"✓ Celda ({x},{y}) segura") btn = self._get_grid_btn(x, y) if btn: btn.config(bg='#90ee90', text='✓', fg='#2d5016', font=('Arial', 12, 'bold'), state='disabled', relief='sunken') elif mtype == 'WARNING': msg_text = msg.get('msg', '') self._log_game(f"⚠️ {msg_text}") messagebox.showwarning("Advertencia", msg_text) elif mtype == 'ROUND_WIN': msg_text = msg.get('msg', '¡Ronda completada!') self._log_game(f"🏆 {msg_text}") messagebox.showinfo("¡Ronda Ganada!", msg_text) elif mtype == 'ROUND_ADVANCE': msg_text = msg.get('msg', 'Pasando a la siguiente ronda...') self._log_game(f"⏭️ {msg_text}") # Cambiar header a naranja mientras transiciona self.game_header.config(bg='#FF9800') self.game_title_label.config(bg='#FF9800', text='⏳ Siguiente Ronda...') elif mtype == 'GAME_WIN': self._log_game("👑 ¡VICTORIA! ¡Juego completado!") messagebox.showinfo("¡Victoria Total!", "¡Has completado todas las rondas!") self._game_phase = 'LOBBY' self.btn_game_action.config(state='normal') elif mtype == 'GAME_OVER': loser = msg.get('loser', '') msg_text = msg.get('msg', '¡Juego terminado!') self._log_game(f"💀 {msg_text}") # Verificar si el perdedor soy yo if hasattr(self.game_client, 'my_address') and loser in self.game_client.my_address: self.game_header.config(bg='#c0392b') # Rojo oscuro self.game_title_label.config(bg='#c0392b', text='💀 HAS PERDIDO') messagebox.showerror("¡DERROTA!", "¡Has perdido todas tus vidas!") else: self.game_header.config(bg='#27ae60') # Verde self.game_title_label.config(bg='#27ae60', text='🏆 ¡HAS GANADO!') messagebox.showinfo("¡VICTORIA!", "¡Tu oponente ha perdido todas sus vidas!") self._game_phase = 'LOBBY' self.btn_game_action.config(state='normal') def _build_grid(self, size): """Construye el grid de juego dinámicamente según el tamaño de la ronda""" # Limpiar grid anterior for child in self.game_frame.winfo_children(): child.destroy() # Resetear configuraciones de filas/columnas anteriores (máximo 14x14) for i in range(14): self.game_frame.columnconfigure(i, weight=0, uniform='') self.game_frame.rowconfigure(i, weight=0, uniform='') # Configurar grid nuevo for i in range(size): self.game_frame.columnconfigure(i, weight=1, uniform='cell') self.game_frame.rowconfigure(i, weight=1, uniform='cell') # Calcular tamaño de botón según grid # Grids más grandes = botones más pequeños if size <= 3: btn_font = ('Arial', 14, 'bold') btn_width = 3 btn_height = 1 elif size <= 5: btn_font = ('Arial', 12, 'bold') btn_width = 2 btn_height = 1 elif size <= 9: btn_font = ('Arial', 10, 'bold') btn_width = 2 btn_height = 1 else: # 12x12 btn_font = ('Arial', 8, 'bold') btn_width = 1 btn_height = 1 self.grid_buttons = {} for r in range(size): for c in range(size): btn = tk.Button( self.game_frame, bg='#e0e0e0', activebackground='#d0d0d0', relief='raised', bd=2, font=btn_font, width=btn_width, height=btn_height, cursor='hand2' ) btn.grid(row=r, column=c, sticky='nsew', padx=1, pady=1) # Click handler btn.config(command=lambda x=c, y=r: self._on_grid_click(x, y)) self.grid_buttons[(c, r)] = btn self._log_game(f"Grid {size}x{size} creado ({size*size} celdas)") def _on_grid_click(self, x, y): """Maneja clicks en el grid según la fase del juego""" # Verificar que estamos conectados if not self.game_client._connected: messagebox.showwarning("No conectado", "Debes conectarte al servidor primero") return btn = self._get_grid_btn(x, y) if not btn: return # No permitir clicks en celdas ya procesadas if btn.cget('state') == 'disabled': return if self._game_phase == 'PLACING': # Colocar bomba self.game_client.send({"type": "PLACE_BOMB", "x": x, "y": y}) elif self._game_phase == 'PLAYING': # Buscar/revelar celda self.game_client.send({"type": "CLICK_CELL", "x": x, "y": y}) else: # En LOBBY, no hacer nada pass def _get_grid_btn(self, x, y): return self.grid_buttons.get((x, y)) def _build_status_bar(self) -> None: status = tk.Frame(self, bg='#f1f1f1', bd=2, relief='ridge') status.grid(row=2, column=0, columnspan=3, sticky='ew') for idx in range(3): status.columnconfigure(idx, weight=1) self.unread_label = tk.Label(status, text='Correos sin leer: 0', font=('Arial', 11, 'bold'), bg='#f1f1f1') self.unread_label.grid(row=0, column=0, padx=16, pady=6, sticky='w') self.traffic_label = tk.Label( status, text='Red: ↑ --.- KB/s ↓ --.- KB/s', font=('Arial', 11, 'bold'), bg='#f1f1f1' ) self.traffic_label.grid(row=0, column=1, padx=16, pady=6) self.clock_label = tk.Label(status, text='--:--:--', font=('Arial', 12, 'bold'), bg='#f1f1f1') self.clock_label.grid(row=0, column=2, padx=16, pady=6, sticky='e') # ------------------------ acciones ------------------------ def _open_web(self, url: str) -> None: webbrowser.open(url) def _launch_process(self, cmd: list[str]) -> None: try: subprocess.Popen(cmd) self._log(f'Proceso lanzado: {cmd}') self._show_text_result('Aplicaciones', f'Se lanzó el proceso: {" ".join(cmd)}') except Exception as exc: messagebox.showerror('Error', f'No se pudo lanzar {cmd}: {exc}') def _run_backup_script(self) -> None: source = filedialog.askdirectory(title='Selecciona la carpeta a respaldar') if not source: return destination = filedialog.askdirectory(title='Selecciona la carpeta destino para la copia') if not destination: return ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') src_name = os.path.basename(os.path.normpath(source)) or 'backup' target = os.path.join(destination, f'{src_name}_{ts}') if os.path.exists(target): messagebox.showerror('Backup', f'Ya existe la carpeta destino:\n{target}') return self._log(f'Iniciando copia de seguridad de {source} a {target}') self._show_text_result('Copias de seguridad', f'Copiando archivos...\nOrigen: {source}\nDestino: {target}') self.notebook.select(self.tab_resultados) def worker() -> None: try: copied_files, copied_bytes, skipped = self._copy_directory_with_progress(source, target) except Exception as exc: self._scraping_queue.put(('error', f'Backup: {exc}')) return summary = ( f'Copia completada correctamente.\n\n' f'Origen: {source}\nDestino: {target}\n' f'Archivos copiados: {copied_files}\n' f'Tamaño total: {copied_bytes / (1024*1024):.2f} MB' ) if skipped: summary += f"\nArchivos omitidos: {skipped} (consulta el panel de notas)" self._scraping_queue.put(('backup_success', summary, target)) threading.Thread(target=worker, daemon=True).start() def _copy_directory_with_progress(self, source: str, target: str) -> tuple[int, int, int]: copied_files = 0 copied_bytes = 0 skipped = 0 for root, dirs, files in os.walk(source): rel = os.path.relpath(root, source) dest_dir = os.path.join(target, rel) if rel != '.' else target os.makedirs(dest_dir, exist_ok=True) for file in files: src_path = os.path.join(root, file) dest_path = os.path.join(dest_dir, file) try: shutil.copy2(src_path, dest_path) copied_files += 1 try: copied_bytes += os.path.getsize(dest_path) except OSError: pass except Exception as exc: skipped += 1 self._log(f'Archivo omitido ({src_path}): {exc}') continue if copied_files % 20 == 0: self._log(f'Copia en progreso: {copied_files} archivos...') return copied_files, copied_bytes, skipped def _open_saved_file(self, path: str) -> None: if not path or not os.path.exists(path): messagebox.showwarning('Archivo', 'El archivo indicado ya no existe.') return try: if sys.platform.startswith('win'): os.startfile(path) # type: ignore[attr-defined] elif sys.platform == 'darwin': subprocess.Popen(['open', path]) else: subprocess.Popen(['xdg-open', path]) except Exception as exc: messagebox.showerror('Archivo', f'No se pudo abrir el archivo: {exc}') def _show_file_popup(self, path: str) -> None: popup = tk.Toplevel(self) popup.title('Archivo generado') popup.configure(bg='white', padx=16, pady=16) popup.resizable(False, False) popup.transient(self) popup.grab_set() tk.Label(popup, text='Se creó el archivo con los datos:', font=('Arial', 12, 'bold'), bg='white').pack(anchor='w') entry = tk.Entry(popup, width=60) entry.pack(fill='x', pady=8) entry.insert(0, path) entry.config(state='readonly') buttons = tk.Frame(popup, bg='white') buttons.pack(fill='x', pady=(8,0)) tk.Button(buttons, text='Abrir archivo', command=lambda p=path: self._open_saved_file(p)).pack(side='left', padx=4) tk.Button(buttons, text='Cerrar', command=popup.destroy).pack(side='right', padx=4) def _open_text(self) -> None: path = filedialog.askopenfilename(filetypes=[('Texto', '*.txt'), ('Todos', '*.*')]) if not path: return with open(path, 'r', encoding='utf-8', errors='ignore') as fh: self.editor.delete('1.0', 'end') self.editor.insert('1.0', fh.read()) self._log(f'Abriste {path}') def _save_text(self) -> None: path = filedialog.asksaveasfilename(defaultextension='.txt') if not path: return with open(path, 'w', encoding='utf-8') as fh: fh.write(self.editor.get('1.0', 'end')) self._log(f'Guardado en {path}') # -------------------- Funciones de correo electrónico -------------------- def _load_mail_credentials(self) -> None: """Carga las credenciales guardadas del archivo de configuración""" try: import base64 config_file = os.path.join(os.path.dirname(__file__), '.mail_config.json') if not os.path.exists(config_file): return with open(config_file, 'r', encoding='utf-8') as f: config = json.load(f) # Cargar configuración del servidor self.mail_imap_host.delete(0, 'end') self.mail_imap_host.insert(0, config.get('imap_host', '10.10.0.101')) self.mail_imap_port.delete(0, 'end') self.mail_imap_port.insert(0, config.get('imap_port', '143')) self.mail_smtp_host.delete(0, 'end') self.mail_smtp_host.insert(0, config.get('smtp_host', '10.10.0.101')) self.mail_smtp_port.delete(0, 'end') self.mail_smtp_port.insert(0, config.get('smtp_port', '25')) # Cargar credenciales (contraseña codificada en base64 para ofuscación básica) self.mail_username.delete(0, 'end') self.mail_username.insert(0, config.get('username', '')) if 'password' in config and config['password']: try: # Decodificar contraseña password_encoded = config['password'] password = base64.b64decode(password_encoded).decode('utf-8') self.mail_password.delete(0, 'end') self.mail_password.insert(0, password) except Exception: pass self._log('Credenciales de correo cargadas') except Exception as e: self._log(f'No se pudieron cargar credenciales: {e}') def _save_mail_credentials(self) -> None: """Guarda las credenciales en un archivo de configuración""" if not self.mail_remember_var.get(): # Si no está marcado "recordar", eliminar el archivo de configuración try: config_file = os.path.join(os.path.dirname(__file__), '.mail_config.json') if os.path.exists(config_file): os.remove(config_file) self._log('Credenciales eliminadas') except Exception: pass return try: import base64 config = { 'imap_host': self.mail_imap_host.get().strip(), 'imap_port': self.mail_imap_port.get().strip(), 'smtp_host': self.mail_smtp_host.get().strip(), 'smtp_port': self.mail_smtp_port.get().strip(), 'username': self.mail_username.get().strip(), 'password': base64.b64encode(self.mail_password.get().encode('utf-8')).decode('utf-8') } config_file = os.path.join(os.path.dirname(__file__), '.mail_config.json') with open(config_file, 'w', encoding='utf-8') as f: json.dump(config, f, indent=2) self._log('Credenciales guardadas correctamente') except Exception as e: self._log(f'Error al guardar credenciales: {e}') def _connect_mail_server(self) -> None: """Conecta al servidor IMAP para leer correos""" import imaplib host = self.mail_imap_host.get().strip() port = self.mail_imap_port.get().strip() username = self.mail_username.get().strip() password = self.mail_password.get() if not host or not port or not username or not password: messagebox.showerror('Error', 'Todos los campos son obligatorios') return try: port_num = int(port) self._log(f'Conectando a {host}:{port_num}...') # Conectar a IMAP self.imap_connection = imaplib.IMAP4(host, port_num) self.imap_connection.login(username, password) self.imap_connection.select('INBOX') self.mail_connected = True self.mail_status_label.config(text='🟢 Conectado', fg='#137333', bg='#ceead6') self.btn_mail_connect.config(state='disabled') self.btn_mail_disconnect.config(state='normal') self._log('Conexión establecida correctamente') # Listar carpetas IMAP disponibles para debugging try: status, folders = self.imap_connection.list() if status == 'OK': self._log('=== Carpetas IMAP disponibles en el servidor ===') for folder in folders: folder_str = folder.decode() if isinstance(folder, bytes) else str(folder) self._log(f' - {folder_str}') self._log('=' * 50) except Exception as e: self._log(f'No se pudieron listar carpetas: {e}') messagebox.showinfo('✅ Éxito', 'Conectado al servidor de correo') # Guardar credenciales si está marcado "recordar" self._save_mail_credentials() # Cargar lista de correos self._refresh_mail_list() except Exception as exc: self._log(f'Error al conectar: {exc}') messagebox.showerror('❌ Error de conexión', f'No se pudo conectar al servidor:\n{exc}') self.imap_connection = None def _disconnect_mail_server(self) -> None: """Desconecta del servidor IMAP""" if self.imap_connection: try: self.imap_connection.close() self.imap_connection.logout() except Exception: pass self.imap_connection = None self.mail_connected = False self.mail_status_label.config(text='⚫ Desconectado', fg='#856404', bg='#fef3cd') self.btn_mail_connect.config(state='normal') self.btn_mail_disconnect.config(state='disabled') self.mail_listbox.delete(0, 'end') self.mail_list = [] self._clear_mail_display() self._log('Desconectado del servidor de correo') def _refresh_mail_list(self) -> None: """Actualiza la lista de correos desde el servidor""" if not self.mail_connected or not self.imap_connection: messagebox.showwarning('Advertencia', 'Primero debes conectarte al servidor') return try: import email from email.header import decode_header self.mail_listbox.delete(0, 'end') self.mail_list = [] # Buscar todos los correos en el buzón actual self.imap_connection.select(self.current_mailbox) status, messages = self.imap_connection.search(None, 'ALL') if status != 'OK': messagebox.showerror('Error', 'No se pudieron obtener los correos') return mail_ids = messages[0].split() # Ordenar del más reciente al más antiguo mail_ids = list(reversed(mail_ids)) # Limitar a los últimos 50 correos mail_ids = mail_ids[:50] self._log(f'Cargando {len(mail_ids)} correos...') for mail_id in mail_ids: # Obtenemos el correo con BODY.PEEK[] en lugar de RFC822 para NO marcarlo como leído status, msg_data = self.imap_connection.fetch(mail_id, '(BODY.PEEK[] FLAGS)') if status != 'OK': continue # Extraer flags correctamente desde la respuesta IMAP is_seen = False flags_debug = "" try: # La respuesta IMAP tiene formato: [(b'1 (FLAGS (\\Seen) BODY[] {size}', b'email_data'), b')'] # Buscamos el flag \Seen en toda la respuesta for item in msg_data: if isinstance(item, tuple): for part in item: if isinstance(part, bytes): part_str = part.decode('utf-8', errors='ignore') # Buscar específicamente la sección FLAGS entre paréntesis if 'FLAGS (' in part_str: flags_debug = part_str # Extraer solo la parte de FLAGS (...) import re flags_match = re.search(r'FLAGS \(([^)]+)\)', part_str) if flags_match: flags_content = flags_match.group(1) # Verificar si contiene \Seen if '\\Seen' in flags_content: is_seen = True break elif isinstance(item, bytes): item_str = item.decode('utf-8', errors='ignore') if 'FLAGS (' in item_str: flags_debug = item_str import re flags_match = re.search(r'FLAGS \(([^)]+)\)', item_str) if flags_match: flags_content = flags_match.group(1) if '\\Seen' in flags_content: is_seen = True break if is_seen: break # Log de debug para ver qué flags se detectaron if flags_debug: self._log(f'DEBUG FLAGS correo {mail_id.decode()}: {flags_debug[:200]} -> is_seen={is_seen}') except Exception as e: # En caso de error, asumimos NO leído para evitar marcar incorrectamente self._log(f'DEBUG: Error parseando flags del correo {mail_id}: {e}') is_seen = False # Parsear el correo - buscar el RFC822 en la respuesta msg = None try: # La respuesta tiene formato: [(b'1 (FLAGS (...) RFC822 {size}', email_bytes), b')'] # Buscamos la parte que contiene los bytes del email for item in msg_data: if isinstance(item, tuple): # item[0] es la cabecera, item[1] son los bytes del email if len(item) >= 2 and isinstance(item[1], bytes): msg = email.message_from_bytes(item[1]) break if msg is None: # Intento alternativo: a veces viene en msg_data[0][1] if len(msg_data) > 0 and isinstance(msg_data[0], tuple) and len(msg_data[0]) > 1: msg = email.message_from_bytes(msg_data[0][1]) except Exception as e: self._log(f'Error parseando correo {mail_id}: {e}') continue if msg is None: self._log(f'No se pudo parsear el correo {mail_id}') continue # Extraer asunto subject = msg.get('Subject', 'Sin asunto') if subject: decoded_parts = decode_header(subject) subject_parts = [] for part, encoding in decoded_parts: if isinstance(part, bytes): subject_parts.append(part.decode(encoding or 'utf-8', errors='ignore')) else: subject_parts.append(part) subject = ''.join(subject_parts) # Extraer remitente from_addr = msg.get('From', 'Desconocido') # Extraer fecha date_str = msg.get('Date', '') # Guardar información del correo self.mail_list.append({ 'id': mail_id.decode(), 'subject': subject, 'from': from_addr, 'date': date_str, 'msg': msg, 'is_seen': is_seen }) # Aplicar filtro si está activo if self.mail_filter_unread and is_seen: # Si el filtro está activo y el correo está leído, no lo mostramos continue # Mostrar en la lista con indicador visual if is_seen: # Correo leído: texto normal en gris display_text = f' {from_addr[:27]} - {subject[:37]}' else: # Correo NO leído: texto en negrita con indicador display_text = f'🔵 {from_addr[:27]} - {subject[:37]}' self.mail_listbox.insert('end', display_text) # Aplicar color según estado idx = self.mail_listbox.size() - 1 if is_seen: self.mail_listbox.itemconfig(idx, fg='#888888', selectforeground='#666666') else: self.mail_listbox.itemconfig(idx, fg='#000000', selectforeground='#1a73e8') # Contar correos sin leer self.unread_count = sum(1 for m in self.mail_list if not m.get('is_seen', False)) if self.unread_label: self.unread_label.config(text=f'Correos sin leer: {self.unread_count}') self._log(f'{len(mail_ids)} correos cargados ({self.unread_count} sin leer)') except Exception as exc: self._log(f'Error al cargar correos: {exc}') messagebox.showerror('Error', f'Error al cargar correos:\n{exc}') def _show_inbox(self) -> None: """Muestra la bandeja de entrada""" if not self.mail_connected or not self.imap_connection: messagebox.showwarning('Advertencia', 'Primero debes conectarte al servidor') return self.current_mailbox = 'INBOX' self.mail_folder_label.config(text='📬 Bandeja de entrada') self._refresh_mail_list() def _toggle_filter_unread(self) -> None: """Activa/desactiva el filtro de correos sin leer""" self.mail_filter_unread = not self.mail_filter_unread # Cambiar el aspecto del botón según el estado if self.mail_filter_unread: self.btn_filter_unread.config(bg='#ea4335', activebackground='#c5221f') # Rojo activo self._log('Filtro activado: mostrando solo correos sin leer') else: self.btn_filter_unread.config(bg='#7c8691', activebackground='#5f6368') # Gris inactivo self._log('Filtro desactivado: mostrando todos los correos') # Recargar la lista con el nuevo filtro self._refresh_mail_list() def _show_sent(self) -> None: """Muestra la carpeta de correos enviados""" if not self.mail_connected or not self.imap_connection: messagebox.showwarning('Advertencia', 'Primero debes conectarte al servidor') return try: import email from email.header import decode_header # Intentar acceder a la carpeta de enviados con diferentes nombres posibles sent_folders = ['Sent', 'INBOX.Sent', 'Enviados', 'INBOX.Enviados', 'Sent Items'] mailbox_found = False for folder in sent_folders: try: status, _ = self.imap_connection.select(folder) if status == 'OK': self.current_mailbox = folder mailbox_found = True self._log(f'Accediendo a carpeta: {folder}') break except Exception: continue if not mailbox_found: messagebox.showwarning('Advertencia', 'No se encontró la carpeta de enviados en el servidor') return self.mail_folder_label.config(text='📤 Correos enviados') # Limpiar la lista actual self.mail_listbox.delete(0, 'end') self.mail_list = [] # Buscar todos los correos status, messages = self.imap_connection.search(None, 'ALL') if status != 'OK': messagebox.showerror('Error', 'No se pudieron obtener los correos enviados') return mail_ids = messages[0].split() # Ordenar del más reciente al más antiguo mail_ids = list(reversed(mail_ids)) # Limitar a los últimos 50 correos mail_ids = mail_ids[:50] self._log(f'Cargando {len(mail_ids)} correos enviados...') for mail_id in mail_ids: # Obtenemos el correo con BODY.PEEK[] en lugar de RFC822 status, msg_data = self.imap_connection.fetch(mail_id, '(BODY.PEEK[] FLAGS)') if status != 'OK': continue # Extraer flags correctamente desde la respuesta IMAP is_seen = False try: for item in msg_data: if isinstance(item, tuple): for part in item: if isinstance(part, bytes): part_str = part.decode('utf-8', errors='ignore') # Buscar específicamente la sección FLAGS entre paréntesis if 'FLAGS (' in part_str: import re flags_match = re.search(r'FLAGS \(([^)]+)\)', part_str) if flags_match: flags_content = flags_match.group(1) if '\\Seen' in flags_content: is_seen = True break elif isinstance(item, bytes): item_str = item.decode('utf-8', errors='ignore') if 'FLAGS (' in item_str: import re flags_match = re.search(r'FLAGS \(([^)]+)\)', item_str) if flags_match: flags_content = flags_match.group(1) if '\\Seen' in flags_content: is_seen = True break if is_seen: break except Exception as e: self._log(f'DEBUG: Error parseando flags del correo enviado {mail_id}: {e}') is_seen = False # Parsear el correo - buscar el RFC822 en la respuesta msg = None try: for item in msg_data: if isinstance(item, tuple): if len(item) >= 2 and isinstance(item[1], bytes): msg = email.message_from_bytes(item[1]) break if msg is None: if len(msg_data) > 0 and isinstance(msg_data[0], tuple) and len(msg_data[0]) > 1: msg = email.message_from_bytes(msg_data[0][1]) except Exception as e: self._log(f'Error parseando correo enviado {mail_id}: {e}') continue if msg is None: self._log(f'No se pudo parsear el correo enviado {mail_id}') continue # Extraer asunto subject = msg.get('Subject', 'Sin asunto') if subject: decoded_parts = decode_header(subject) subject_parts = [] for part, encoding in decoded_parts: if isinstance(part, bytes): subject_parts.append(part.decode(encoding or 'utf-8', errors='ignore')) else: subject_parts.append(part) subject = ''.join(subject_parts) # Extraer destinatario (To en lugar de From) to_addr = msg.get('To', 'Desconocido') # Extraer fecha date_str = msg.get('Date', '') # Guardar información del correo self.mail_list.append({ 'id': mail_id.decode(), 'subject': subject, 'to': to_addr, 'from': msg.get('From', 'Desconocido'), 'date': date_str, 'msg': msg, 'is_seen': is_seen }) # Mostrar en la lista - Para enviados mostramos "Para: destinatario" display_text = f'Para: {to_addr[:25]} - {subject[:35]}' self.mail_listbox.insert('end', display_text) # Para enviados NO actualizamos el contador (solo cuenta los de INBOX) # El contador mantiene el valor de la bandeja de entrada self._log(f'{len(mail_ids)} correos enviados cargados') except Exception as exc: self._log(f'Error al cargar correos enviados: {exc}') messagebox.showerror('Error', f'Error al cargar correos enviados:\n{exc}') def _on_mail_select(self, event) -> None: """Maneja la selección de un correo en la lista""" try: print('\n' + '='*80) print('DEBUG: _on_mail_select() EJECUTADO') print('='*80) self._log('DEBUG: _on_mail_select() EJECUTADO') selection = self.mail_listbox.curselection() print(f'DEBUG: selection = {selection}') self._log(f'DEBUG: selection = {selection}') if not selection: print('DEBUG: No hay selección, retornando') self._log('DEBUG: No hay selección, retornando') return index = selection[0] print(f'DEBUG: index = {index}, len(mail_list) = {len(self.mail_list)}') self._log(f'DEBUG: index = {index}, len(mail_list) = {len(self.mail_list)}') if index >= len(self.mail_list): print('DEBUG: Índice fuera de rango') self._log('DEBUG: Índice fuera de rango') return mail_info = self.mail_list[index] print(f'=== CORREO SELECCIONADO #{index}: {mail_info["subject"]} ===') self._log(f'=== CORREO SELECCIONADO #{index}: {mail_info["subject"]} ===') # Marcar como leído en el servidor si estamos en INBOX if self.mail_connected and self.imap_connection and self.current_mailbox == 'INBOX': try: mail_id = mail_info['id'] # Solo marcar si aún no está leído if not mail_info.get('is_seen', False): # Marcar el correo como leído (añadir flag \Seen) self.imap_connection.store(mail_id, '+FLAGS', '\\Seen') # Actualizar el flag local mail_info['is_seen'] = True # Actualizar visualmente el item en la lista from_addr = mail_info['from'] subject = mail_info['subject'] display_text = f' {from_addr[:27]} - {subject[:37]}' self.mail_listbox.delete(index) self.mail_listbox.insert(index, display_text) self.mail_listbox.itemconfig(index, fg='#888888', selectforeground='#666666') self.mail_listbox.selection_set(index) # Recalcular contador de sin leer self.unread_count = sum(1 for m in self.mail_list if not m.get('is_seen', False)) if self.unread_label: self.unread_label.config(text=f'Correos sin leer: {self.unread_count}') print(f'DEBUG: Correo marcado como leído') self._log(f'Correo marcado como leído en el servidor') except Exception as mark_error: print(f'DEBUG: Error al marcar como leído: {mark_error}') self._log(f'Error al marcar correo como leído: {mark_error}') print('DEBUG: Llamando a _display_mail()...') self._log('DEBUG: Llamando a _display_mail()...') self._display_mail(mail_info) print('DEBUG: _display_mail() completado') self._log('DEBUG: _display_mail() completado') except Exception as e: error_msg = f'ERROR CRÍTICO en _on_mail_select: {e}' print(error_msg) print(f'Traceback: {traceback.format_exc()}') self._log(error_msg) messagebox.showerror('Error', f'Error al seleccionar correo:\n{e}') def _display_mail(self, mail_info: dict) -> None: """Muestra el contenido de un correo seleccionado con soporte para imágenes""" try: self._log(f'>>> Iniciando _display_mail para: {mail_info["subject"]}') print(f'>>> Iniciando _display_mail para: {mail_info["subject"]}') import email from email.header import decode_header import io # Intentar importar PIL/Pillow (opcional) try: from PIL import Image, ImageTk PIL_AVAILABLE = True print('>>> PIL disponible - se mostrarán miniaturas de imágenes') except ImportError: PIL_AVAILABLE = False print('>>> PIL NO disponible - se mostrarán iconos en lugar de imágenes') self._log('ADVERTENCIA: PIL/Pillow no está instalado. Las imágenes se mostrarán como iconos.') msg = mail_info['msg'] # Actualizar encabezados self._log('>>> Actualizando encabezados') print('>>> Actualizando encabezados') self.mail_from_label.config(text=mail_info['from']) self.mail_subject_label.config(text=mail_info['subject']) self.mail_date_label.config(text=mail_info['date']) self._log(f'>>> Encabezados actualizados: From={mail_info["from"][:30]}') print(f'>>> Encabezados actualizados') # Extraer cuerpo del correo y adjuntos body = '' attachments = [] # Todos los adjuntos (imágenes, PDFs, etc.) self._log(f'>>> Procesando correo: {mail_info["subject"]}') print(f'DEBUG: Procesando correo: {mail_info["subject"]}') # Debug extra if msg.is_multipart(): for part in msg.walk(): content_type = part.get_content_type() content_disposition = str(part.get('Content-Disposition')) self._log(f' Part: {content_type}, disposition: {content_disposition[:50]}') # Saltar contenedores multipart if content_type.startswith('multipart/'): self._log(f' -> Saltando contenedor multipart') continue # Procesar texto if content_type == 'text/plain' and 'attachment' not in content_disposition: try: payload = part.get_payload(decode=True) if payload: body = payload.decode('utf-8', errors='ignore') self._log(f'Texto plano encontrado: {len(body)} caracteres') except Exception as e: self._log(f'Error al decodificar texto plano: {e}') pass elif content_type == 'text/html' and not body and 'attachment' not in content_disposition: try: payload = part.get_payload(decode=True) if payload: body = payload.decode('utf-8', errors='ignore') self._log(f'HTML encontrado: {len(body)} caracteres') except Exception as e: self._log(f'Error al decodificar HTML: {e}') pass # Procesar archivos adjuntos (imágenes, PDFs, documentos, etc.) else: # Detectar adjuntos: si tiene filename o si es attachment explícito filename = part.get_filename() is_attachment = 'attachment' in content_disposition.lower() # También considerar imágenes y PDFs como adjuntos potenciales if filename or is_attachment or content_type.startswith('image/') or content_type.startswith('application/'): try: file_data = part.get_payload(decode=True) if file_data: if not filename: # Generar nombre según el tipo if content_type.startswith('image/'): ext = content_type.split('/')[-1] filename = f'imagen_{len(attachments) + 1}.{ext}' elif content_type == 'application/pdf': filename = f'documento_{len(attachments) + 1}.pdf' elif 'word' in content_type: filename = f'documento_{len(attachments) + 1}.docx' elif 'excel' in content_type or 'spreadsheet' in content_type: filename = f'hoja_{len(attachments) + 1}.xlsx' else: filename = f'archivo_{len(attachments) + 1}' attachments.append({ 'data': file_data, 'filename': filename, 'content_type': content_type, 'is_image': content_type.startswith('image/') }) self._log(f'Adjunto detectado: {filename} ({content_type})') except Exception as exc: self._log(f'Error al procesar adjunto: {exc}') else: try: payload = msg.get_payload(decode=True) if payload: body = payload.decode('utf-8', errors='ignore') else: body = str(msg.get_payload()) self._log(f'Correo simple: {len(body)} caracteres') except Exception as e: body = str(msg.get_payload()) self._log(f'Error al decodificar correo simple: {e}') # Mostrar el cuerpo self._log(f'Mostrando cuerpo: {len(body)} caracteres') print(f'>>> Mostrando cuerpo: {len(body)} caracteres') # Limpiar imágenes inline previas self.mail_inline_images = [] self.mail_body_text.config(state='normal') self.mail_body_text.delete('1.0', 'end') # Insertar texto del cuerpo if body: self.mail_body_text.insert('1.0', body) else: self.mail_body_text.insert('1.0', '[Sin contenido de texto]') # Separar imágenes de otros adjuntos images_to_show = [] other_attachments = [] for att in attachments: if att['is_image']: images_to_show.append(att) else: other_attachments.append(att) # Mostrar imágenes inline en el cuerpo del correo if images_to_show and PIL_AVAILABLE: self.mail_body_text.insert('end', '\n\n' + '─' * 60 + '\n') self.mail_body_text.insert('end', '📷 Imágenes adjuntas:\n\n') for idx, att_info in enumerate(images_to_show): try: # Cargar imagen image = Image.open(io.BytesIO(att_info['data'])) # Redimensionar para mostrar inline (max 500px de ancho) max_width = 500 if image.width > max_width: ratio = max_width / image.width new_height = int(image.height * ratio) image = image.resize((max_width, new_height), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(image) self.mail_inline_images.append(photo) # Guardar referencia # Insertar nombre del archivo self.mail_body_text.insert('end', f"📎 {att_info['filename']}\n") # Insertar imagen self.mail_body_text.image_create('end', image=photo) self.mail_body_text.insert('end', '\n\n') print(f'Imagen inline insertada: {att_info["filename"]}') self._log(f'Imagen inline mostrada: {att_info["filename"]}') except Exception as e: print(f'Error al mostrar imagen inline: {e}') self.mail_body_text.insert('end', f"[Error al cargar {att_info['filename']}]\n\n") self.mail_body_text.config(state='disabled') # Limpiar frame de adjuntos previo for widget in self.mail_attachments_frame.winfo_children(): widget.destroy() # Mostrar sección de adjuntos solo para archivos NO-imagen (PDFs, docs, etc.) self._log(f'Total de adjuntos: {len(attachments)} ({len(images_to_show)} imágenes, {len(other_attachments)} otros)') if other_attachments: # Solo mostrar esta sección si hay archivos que no son imágenes self.mail_attachments_frame.pack(fill='x', padx=5, pady=10, before=self.mail_body_text.master) tk.Label(self.mail_attachments_frame, text='📎 Otros archivos adjuntos:', bg='#f8f9fa', font=('Arial', 10, 'bold'), fg='#5f6368', anchor='w').pack(fill='x', padx=10, pady=(10, 5)) # Frame con scroll para los adjuntos attachments_container = tk.Frame(self.mail_attachments_frame, bg='#f8f9fa') attachments_container.pack(fill='x', padx=10, pady=5) for idx, att_info in enumerate(other_attachments): try: # Frame para cada adjunto att_frame = tk.Frame(attachments_container, bg='#ffffff', relief='solid', bd=1, padx=10, pady=10) att_frame.pack(side='left', padx=5, pady=5) # Mostrar icono según tipo de archivo icon = '📄' if att_info['content_type'] == 'application/pdf' else '📎' if 'word' in att_info['content_type'] or att_info['filename'].endswith(('.doc', '.docx')): icon = '📝' elif 'excel' in att_info['content_type'] or att_info['filename'].endswith(('.xls', '.xlsx')): icon = '📊' elif att_info['filename'].endswith(('.zip', '.rar', '.7z')): icon = '📦' tk.Label(att_frame, text=icon, bg='#ffffff', font=('Arial', 48)).pack(pady=20) # Nombre del archivo tk.Label(att_frame, text=att_info['filename'], bg='#ffffff', fg='#5f6368', font=('Arial', 9), wraplength=200).pack(pady=(5, 0)) # Tamaño del archivo file_size = len(att_info['data']) size_str = f"{file_size / 1024:.1f} KB" if file_size < 1024*1024 else f"{file_size / (1024*1024):.1f} MB" tk.Label(att_frame, text=size_str, bg='#ffffff', fg='#888', font=('Arial', 8)).pack() # Botón para guardar el archivo def save_file(data=att_info['data'], name=att_info['filename'], is_img=att_info['is_image']): # Determinar extensión por defecto if is_img: default_ext = '.jpg' filetypes = [('Imágenes', '*.png *.jpg *.jpeg *.gif *.bmp'), ('Todos los archivos', '*.*')] elif name.endswith('.pdf'): default_ext = '.pdf' filetypes = [('PDF', '*.pdf'), ('Todos los archivos', '*.*')] else: default_ext = os.path.splitext(name)[1] or '.dat' filetypes = [('Todos los archivos', '*.*')] save_path = filedialog.asksaveasfilename( defaultextension=default_ext, initialfile=name, filetypes=filetypes ) if save_path: try: with open(save_path, 'wb') as f: f.write(data) messagebox.showinfo('✅ Éxito', f'Archivo guardado en:\n{save_path}') except Exception as e: messagebox.showerror('❌ Error', f'No se pudo guardar el archivo:\n{e}') tk.Button(att_frame, text='💾 Guardar', command=save_file, bg='#4285f4', fg='white', relief='flat', font=('Arial', 9, 'bold'), cursor='hand2', padx=10, pady=3).pack(pady=(5, 0)) except Exception as exc: self._log(f'Error al mostrar adjunto: {exc}') else: # Ocultar frame si no hay adjuntos self.mail_attachments_frame.pack_forget() # Debugging final: verificar estado de widgets print('\n>>> DEBUG FINAL DE WIDGETS:') print(f' mail_from_label.text = {self.mail_from_label.cget("text")}') print(f' mail_subject_label.text = {self.mail_subject_label.cget("text")}') print(f' mail_date_label.text = {self.mail_date_label.cget("text")}') print(f' mail_body_text visible = {self.mail_body_text.winfo_viewable()}') print(f' mail_body_text width = {self.mail_body_text.winfo_width()}') print(f' mail_body_text height = {self.mail_body_text.winfo_height()}') body_content = self.mail_body_text.get('1.0', 'end') print(f' mail_body_text contenido (primeros 100 chars) = {body_content[:100]}') print(f' Adjuntos mostrados: {len(attachments)}') self._log(f'>>> _display_mail COMPLETADO OK - Body: {len(body)} chars, Adjuntos: {len(attachments)}') print(f'>>> _display_mail COMPLETADO OK') except Exception as e: error_msg = f'ERROR CRÍTICO en _display_mail: {e}' print(error_msg) print(f'Traceback: {traceback.format_exc()}') self._log(error_msg) messagebox.showerror('Error', f'Error al mostrar correo:\n{e}\n\nVer terminal para más detalles') def _clear_mail_display(self) -> None: """Limpia la visualización del correo""" self.mail_from_label.config(text='') self.mail_subject_label.config(text='') self.mail_date_label.config(text='') self.mail_body_text.config(state='normal') self.mail_body_text.delete('1.0', 'end') self.mail_body_text.config(state='disabled') # Limpiar adjuntos for widget in self.mail_attachments_frame.winfo_children(): widget.destroy() self.mail_attachments_frame.pack_forget() def _open_compose_window(self) -> None: """Abre una ventana mejorada para redactar un nuevo correo con soporte para imágenes""" if not self.mail_connected: messagebox.showwarning('⚠️ Advertencia', 'Primero debes conectarte al servidor') return # Ventana más grande y moderna (casi fullscreen) compose_window = tk.Toplevel(self) compose_window.title('✉️ Redactar nuevo correo') # Obtener dimensiones de la pantalla y hacer la ventana casi fullscreen screen_width = compose_window.winfo_screenwidth() screen_height = compose_window.winfo_screenheight() window_width = int(screen_width * 0.85) # 85% del ancho de pantalla window_height = int(screen_height * 0.85) # 85% del alto de pantalla # Centrar la ventana x = (screen_width - window_width) // 2 y = (screen_height - window_height) // 2 compose_window.geometry(f'{window_width}x{window_height}+{x}+{y}') compose_window.minsize(900, 650) compose_window.configure(bg='#f0f2f5') compose_window.transient(self) compose_window.grab_set() # Lista para almacenar rutas de archivos adjuntos attachments = [] attachment_labels = [] # ========== HEADER ========== header = tk.Frame(compose_window, bg='#1a73e8', height=60) header.pack(fill='x') header.pack_propagate(False) tk.Label(header, text='✍️ Nuevo mensaje', bg='#1a73e8', fg='white', font=('Arial', 16, 'bold')).pack(side='left', padx=25, pady=15) # ========== CAMPOS DEL CORREO ========== fields_container = tk.Frame(compose_window, bg='#ffffff', relief='solid', bd=1) fields_container.pack(fill='x', padx=20, pady=(20, 10)) fields_frame = tk.Frame(fields_container, bg='#ffffff', padx=25, pady=20) fields_frame.pack(fill='x') # Para (destinatarios múltiples) to_frame = tk.Frame(fields_frame, bg='#ffffff') to_frame.pack(fill='x', pady=8) tk.Label(to_frame, text='Para:', bg='#ffffff', font=('Arial', 11, 'bold'), fg='#5f6368', width=10, anchor='w').pack(side='left', padx=(0, 10)) # Frame para entry y label de ayuda to_input_frame = tk.Frame(to_frame, bg='#ffffff') to_input_frame.pack(side='left', fill='x', expand=True) to_entry = tk.Entry(to_input_frame, font=('Arial', 11), relief='solid', bd=1, bg='#f8f9fa', fg='#202124') to_entry.pack(fill='x') # Label de ayuda para múltiples destinatarios tk.Label(to_input_frame, text='💡 Separa múltiples destinatarios con comas o punto y coma', bg='#ffffff', fg='#5f6368', font=('Arial', 8, 'italic'), anchor='w').pack(fill='x', pady=(2, 0)) to_entry.focus() # Asunto subject_frame = tk.Frame(fields_frame, bg='#ffffff') subject_frame.pack(fill='x', pady=8) tk.Label(subject_frame, text='Asunto:', bg='#ffffff', font=('Arial', 11, 'bold'), fg='#5f6368', width=10, anchor='w').pack(side='left', padx=(0, 10)) subject_entry = tk.Entry(subject_frame, font=('Arial', 11), relief='solid', bd=1, bg='#f8f9fa', fg='#202124') subject_entry.pack(side='left', fill='x', expand=True) # Separador tk.Frame(fields_frame, height=1, bg='#dadce0').pack(fill='x', pady=10) # ========== BARRA DE HERRAMIENTAS ========== toolbar = tk.Frame(fields_container, bg='#f8f9fa', relief='solid', bd=1) toolbar.pack(fill='x', padx=0, pady=0) toolbar_content = tk.Frame(toolbar, bg='#f8f9fa') toolbar_content.pack(fill='x', padx=15, pady=10) tk.Label(toolbar_content, text='📎 Adjuntar:', bg='#f8f9fa', font=('Arial', 10, 'bold'), fg='#5f6368').pack(side='left', padx=(0, 10)) def get_file_icon(file_path: str) -> str: """Devuelve el emoji apropiado según el tipo de archivo""" ext = os.path.splitext(file_path)[1].lower() if ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']: return '🖼️' elif ext == '.pdf': return '📄' elif ext in ['.doc', '.docx']: return '📝' elif ext in ['.xls', '.xlsx']: return '📊' elif ext in ['.zip', '.rar', '.7z']: return '📦' elif ext in ['.txt', '.log']: return '📃' else: return '📎' def attach_file(file_type: str = 'all'): """Adjunta un archivo al correo""" if file_type == 'image': title = 'Seleccionar imagen' filetypes = [ ('Imágenes', '*.png *.jpg *.jpeg *.gif *.bmp *.webp'), ('Todos los archivos', '*.*') ] elif file_type == 'pdf': title = 'Seleccionar PDF' filetypes = [ ('Documentos PDF', '*.pdf'), ('Todos los archivos', '*.*') ] else: title = 'Seleccionar archivo' filetypes = [ ('Todos los archivos', '*.*'), ('Documentos PDF', '*.pdf'), ('Imágenes', '*.png *.jpg *.jpeg *.gif *.bmp'), ('Documentos Word', '*.doc *.docx'), ('Hojas de cálculo', '*.xls *.xlsx'), ('Archivos comprimidos', '*.zip *.rar *.7z'), ('Archivos de texto', '*.txt *.log') ] file_path = filedialog.askopenfilename(title=title, filetypes=filetypes) if file_path: attachments.append(file_path) file_name = os.path.basename(file_path) file_icon = get_file_icon(file_path) # Crear label para mostrar el archivo adjunto att_frame = tk.Frame(attachments_list, bg='#e8f0fe', relief='solid', bd=1) att_frame.pack(fill='x', padx=5, pady=3) tk.Label(att_frame, text=f'{file_icon} {file_name}', bg='#e8f0fe', fg='#1a73e8', font=('Arial', 9), anchor='w').pack(side='left', padx=10, pady=5, fill='x', expand=True) def remove_attachment(frame=att_frame, path=file_path): attachments.remove(path) frame.destroy() tk.Button(att_frame, text='✕', command=remove_attachment, bg='#dc3545', fg='white', relief='flat', font=('Arial', 9, 'bold'), cursor='hand2', padx=8, pady=2).pack(side='right', padx=5, pady=2) attachment_labels.append(att_frame) self._log(f'Archivo adjunto: {file_name}') # Botones de adjuntar tk.Button(toolbar_content, text='🖼️ Imagen', command=lambda: attach_file('image'), bg='#4285f4', fg='white', relief='flat', font=('Arial', 10, 'bold'), padx=15, pady=6, cursor='hand2', activebackground='#3367d6').pack(side='left', padx=3) tk.Button(toolbar_content, text='📄 PDF', command=lambda: attach_file('pdf'), bg='#ea4335', fg='white', relief='flat', font=('Arial', 10, 'bold'), padx=15, pady=6, cursor='hand2', activebackground='#c5362d').pack(side='left', padx=3) tk.Button(toolbar_content, text='📎 Otro archivo', command=lambda: attach_file('all'), bg='#fbbc04', fg='#202124', relief='flat', font=('Arial', 10, 'bold'), padx=15, pady=6, cursor='hand2', activebackground='#f9ab00').pack(side='left', padx=3) tk.Label(toolbar_content, text='💡 Puedes adjuntar imágenes, PDFs y otros documentos', bg='#f8f9fa', fg='#5f6368', font=('Arial', 9)).pack(side='left', padx=15) # ========== CONTENEDOR PRINCIPAL SCROLLABLE ========== # Frame contenedor que se expandirá main_content_frame = tk.Frame(compose_window, bg='#f0f2f5') main_content_frame.pack(fill='both', expand=True, padx=20, pady=(0, 10)) # Canvas con scrollbar para todo el contenido canvas = tk.Canvas(main_content_frame, bg='#f0f2f5', highlightthickness=0) scrollbar = tk.Scrollbar(main_content_frame, orient='vertical', command=canvas.yview) scrollable_frame = tk.Frame(canvas, bg='#f0f2f5') scrollable_frame.bind( '', lambda e: canvas.configure(scrollregion=canvas.bbox('all')) ) canvas.create_window((0, 0), window=scrollable_frame, anchor='nw') canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side='left', fill='both', expand=True) scrollbar.pack(side='right', fill='y') # ========== LISTA DE ADJUNTOS ========== attachments_frame = tk.Frame(scrollable_frame, bg='#ffffff', relief='solid', bd=1) attachments_frame.pack(fill='x', pady=(0, 10)) att_header = tk.Frame(attachments_frame, bg='#f8f9fa') att_header.pack(fill='x') tk.Label(att_header, text='📋 Archivos adjuntos', bg='#f8f9fa', font=('Arial', 10, 'bold'), fg='#5f6368').pack(side='left', padx=15, pady=8) # Scrollable frame para adjuntos att_canvas_frame = tk.Frame(attachments_frame, bg='#ffffff', height=100) att_canvas_frame.pack(fill='both', expand=True) att_canvas_frame.pack_propagate(False) attachments_list = tk.Frame(att_canvas_frame, bg='#ffffff') attachments_list.pack(fill='both', expand=True, padx=10, pady=10) # ========== CUERPO DEL MENSAJE ========== body_container = tk.Frame(scrollable_frame, bg='#ffffff', relief='solid', bd=1) body_container.pack(fill='both', expand=True, pady=(0, 10)) body_header = tk.Frame(body_container, bg='#f8f9fa') body_header.pack(fill='x') tk.Label(body_header, text='✏️ Mensaje (Ctrl+V para pegar imágenes)', bg='#f8f9fa', font=('Arial', 11, 'bold'), fg='#5f6368').pack(anchor='w', padx=20, pady=10) # Usar Text en lugar de ScrolledText para soportar imágenes inline text_frame = tk.Frame(body_container, bg='#ffffff') text_frame.pack(fill='both', expand=True, padx=5, pady=(0, 5)) body_text_scroll = tk.Scrollbar(text_frame) body_text_scroll.pack(side='right', fill='y') body_text = tk.Text(text_frame, wrap='word', font=('Segoe UI', 11), bg='#ffffff', fg='#202124', relief='flat', padx=20, pady=15, spacing1=3, spacing3=5, height=15, yscrollcommand=body_text_scroll.set) body_text.pack(side='left', fill='both', expand=True) body_text_scroll.config(command=body_text.yview) # Lista para guardar referencias a PhotoImage (evitar garbage collection) inline_images = [] # Lista para guardar las imágenes como datos (para enviar) inline_images_data = [] def paste_image(event=None): """Pega una imagen desde el portapapeles al cuerpo del correo""" try: from PIL import ImageGrab, ImageTk, Image import io # Intentar obtener imagen del portapapeles img = ImageGrab.grabclipboard() if img is not None and isinstance(img, Image.Image): # Guardar imagen original para envío img_buffer = io.BytesIO() img.save(img_buffer, format='PNG') img_data = img_buffer.getvalue() # Generar nombre único para la imagen img_name = f'imagen_pegada_{len(inline_images_data) + 1}.png' inline_images_data.append({ 'data': img_data, 'name': img_name, 'pil_image': img.copy() }) # Redimensionar para mostrar (max 600px de ancho) max_width = 600 if img.width > max_width: ratio = max_width / img.width new_height = int(img.height * ratio) img = img.resize((max_width, new_height), Image.Resampling.LANCZOS) # Convertir a PhotoImage photo = ImageTk.PhotoImage(img) inline_images.append(photo) # Guardar referencia # Insertar en el Text widget body_text.image_create('insert', image=photo) body_text.insert('insert', '\n') # Nueva línea después de la imagen self._log(f'Imagen pegada en el correo: {img_name}') print(f'Imagen pegada y guardada para envío: {img_name}') return 'break' # Prevenir el comportamiento por defecto elif isinstance(img, list): # Si es una lista de archivos (copiar archivos desde explorador) for file_path in img: if os.path.isfile(file_path): ext = os.path.splitext(file_path)[1].lower() if ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']: # Cargar imagen desde archivo img_file = Image.open(file_path) # Guardar para envío img_buffer = io.BytesIO() img_file.save(img_buffer, format='PNG') img_data = img_buffer.getvalue() img_name = f'imagen_pegada_{len(inline_images_data) + 1}.png' inline_images_data.append({ 'data': img_data, 'name': img_name, 'pil_image': img_file.copy() }) # Redimensionar si es muy grande max_width = 600 if img_file.width > max_width: ratio = max_width / img_file.width new_height = int(img_file.height * ratio) img_file = img_file.resize((max_width, new_height), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(img_file) inline_images.append(photo) body_text.image_create('insert', image=photo) body_text.insert('insert', '\n') self._log(f'Imagen pegada: {os.path.basename(file_path)}') return 'break' except Exception as e: print(f'Error al pegar imagen: {e}') # Si falla, permitir pegado normal de texto pass return None # Permitir comportamiento por defecto para texto # Vincular Ctrl+V body_text.bind('', paste_image) body_text.bind('', paste_image) # ========== BOTONES DE ACCIÓN (FIJOS EN LA PARTE INFERIOR) ========== btn_frame = tk.Frame(compose_window, bg='#ffffff', relief='solid', bd=2) btn_frame.pack(side='bottom', fill='x', padx=0, pady=0) # Contenedor interno con padding btn_container = tk.Frame(btn_frame, bg='#ffffff') btn_container.pack(fill='x', padx=20, pady=15) def send_mail(): to_addr_raw = to_entry.get().strip() subject = subject_entry.get().strip() body = body_text.get('1.0', 'end').strip() if not to_addr_raw: messagebox.showwarning('⚠️ Advertencia', 'Debes especificar al menos un destinatario') to_entry.focus() return # Separar múltiples destinatarios (por coma o punto y coma) import re recipients = re.split(r'[;,]\s*', to_addr_raw) recipients = [r.strip() for r in recipients if r.strip()] # Validar que haya al menos un destinatario if not recipients: messagebox.showwarning('⚠️ Advertencia', 'Debes especificar al menos un destinatario válido') to_entry.focus() return # Validar formato de emails email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' invalid_emails = [email for email in recipients if not re.match(email_pattern, email)] if invalid_emails: messagebox.showwarning('⚠️ Advertencia', f'Los siguientes emails no son válidos:\n{", ".join(invalid_emails)}') to_entry.focus() return if not subject: messagebox.showwarning('⚠️ Advertencia', 'Debes especificar un asunto') subject_entry.focus() return if not body: messagebox.showwarning('⚠️ Advertencia', 'El mensaje no puede estar vacío') body_text.focus() return # Mostrar confirmación si hay múltiples destinatarios if len(recipients) > 1: confirm = messagebox.askyesno('📧 Múltiples destinatarios', f'¿Enviar correo a {len(recipients)} destinatarios?\n\n' + '\n'.join(f' • {email}' for email in recipients)) if not confirm: return # Combinar adjuntos de archivos con imágenes pegadas all_attachments = attachments.copy() # Archivos adjuntados con botones # Guardar imágenes pegadas como archivos temporales temp_files = [] for img_data in inline_images_data: try: # Crear archivo temporal temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png', prefix='pasted_img_') temp_file.write(img_data['data']) temp_file.close() temp_files.append(temp_file.name) all_attachments.append(temp_file.name) print(f"Imagen pegada guardada temporalmente: {temp_file.name}") except Exception as e: print(f"Error al guardar imagen temporal: {e}") # Enviar con todos los adjuntos a múltiples destinatarios self._send_mail_with_attachments(recipients, subject, body, all_attachments, compose_window) # Limpiar archivos temporales después de enviar for temp_file in temp_files: try: if os.path.exists(temp_file): os.unlink(temp_file) print(f"Archivo temporal eliminado: {temp_file}") except Exception as e: print(f"Error al eliminar temporal: {e}") # Botón Enviar (destacado y más grande) send_btn = tk.Button(btn_container, text='📤 ENVIAR CORREO', command=send_mail, bg='#34a853', fg='white', relief='raised', bd=2, font=('Arial', 13, 'bold'), padx=40, pady=15, cursor='hand2', activebackground='#2d8e47') send_btn.pack(side='left', padx=(0, 15)) # Botón Cancelar cancel_btn = tk.Button(btn_container, text='❌ CANCELAR', command=compose_window.destroy, bg='#dc3545', fg='white', relief='raised', bd=2, font=('Arial', 12, 'bold'), padx=35, pady=13, cursor='hand2', activebackground='#b02a37') cancel_btn.pack(side='left', padx=5) # Información adicional info_label = tk.Label(btn_container, text='💡 Tip: Puedes adjuntar varias imágenes a tu correo', bg='#ffffff', fg='#5f6368', font=('Arial', 10)) info_label.pack(side='right', padx=10) def _send_mail(self, to_addr: str, subject: str, body: str, window: tk.Toplevel) -> None: """Envía un correo electrónico usando SMTP""" import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart smtp_host = self.mail_smtp_host.get().strip() smtp_port = self.mail_smtp_port.get().strip() username = self.mail_username.get().strip() password = self.mail_password.get() try: smtp_port_num = int(smtp_port) self._log(f'Enviando correo a {to_addr}...') # Crear el mensaje msg = MIMEMultipart() msg['From'] = username msg['To'] = to_addr msg['Subject'] = subject msg.attach(MIMEText(body, 'plain', 'utf-8')) # Conectar y enviar with smtplib.SMTP(smtp_host, smtp_port_num, timeout=10) as server: # Nota: Si el servidor requiere autenticación TLS, descomentar: # server.starttls() # server.login(username, password) server.send_message(msg) self._log('Correo enviado correctamente') # Guardar copia en carpeta de enviados del servidor IMAP self._save_to_sent_folder(msg) # Si estamos viendo la carpeta de enviados, actualizar la lista if self.current_mailbox != 'INBOX': self._show_sent() messagebox.showinfo('✅ Éxito', 'Correo enviado correctamente') window.destroy() except Exception as exc: self._log(f'Error al enviar correo: {exc}') messagebox.showerror('❌ Error', f'No se pudo enviar el correo:\n{exc}') def _send_mail_with_attachments(self, to_addrs, subject: str, body: str, attachments: list, window: tk.Toplevel) -> None: """Envía un correo electrónico con adjuntos a uno o múltiples destinatarios usando SMTP Args: to_addrs: String con un email o lista de emails subject: Asunto del correo body: Cuerpo del mensaje attachments: Lista de rutas de archivos adjuntos window: Ventana de composición a cerrar tras enviar """ import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.mime.image import MIMEImage from email.mime.application import MIMEApplication from email.mime.base import MIMEBase from email import encoders smtp_host = self.mail_smtp_host.get().strip() smtp_port = self.mail_smtp_port.get().strip() username = self.mail_username.get().strip() password = self.mail_password.get() # Convertir a lista si es un string if isinstance(to_addrs, str): recipients = [to_addrs] else: recipients = to_addrs try: smtp_port_num = int(smtp_port) recipients_str = ', '.join(recipients) self._log(f'Enviando correo con {len(attachments)} adjunto(s) a {len(recipients)} destinatario(s)...') # Crear el mensaje multipart msg = MIMEMultipart() msg['From'] = username msg['To'] = recipients_str # Todos los destinatarios separados por comas msg['Subject'] = subject # Adjuntar el cuerpo del mensaje msg.attach(MIMEText(body, 'plain', 'utf-8')) # Adjuntar los archivos for file_path in attachments: try: file_name = os.path.basename(file_path) file_ext = os.path.splitext(file_path)[1].lower() with open(file_path, 'rb') as file: file_data = file.read() # Determinar el tipo de archivo y usar el MIME apropiado if file_ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']: # Imágenes image = MIMEImage(file_data, name=file_name) image.add_header('Content-Disposition', 'attachment', filename=file_name) msg.attach(image) self._log(f'Imagen adjuntada: {file_name}') elif file_ext == '.pdf': # PDFs pdf = MIMEApplication(file_data, _subtype='pdf') pdf.add_header('Content-Disposition', 'attachment', filename=file_name) msg.attach(pdf) self._log(f'PDF adjuntado: {file_name}') elif file_ext in ['.doc', '.docx']: # Documentos Word part = MIMEApplication(file_data, _subtype='msword' if file_ext == '.doc' else 'vnd.openxmlformats-officedocument.wordprocessingml.document') part.add_header('Content-Disposition', 'attachment', filename=file_name) msg.attach(part) self._log(f'Documento Word adjuntado: {file_name}') elif file_ext in ['.xls', '.xlsx']: # Hojas de cálculo Excel part = MIMEApplication(file_data, _subtype='vnd.ms-excel' if file_ext == '.xls' else 'vnd.openxmlformats-officedocument.spreadsheetml.sheet') part.add_header('Content-Disposition', 'attachment', filename=file_name) msg.attach(part) self._log(f'Hoja de cálculo adjuntada: {file_name}') elif file_ext in ['.zip', '.rar', '.7z']: # Archivos comprimidos part = MIMEApplication(file_data, _subtype='zip') part.add_header('Content-Disposition', 'attachment', filename=file_name) msg.attach(part) self._log(f'Archivo comprimido adjuntado: {file_name}') elif file_ext in ['.txt', '.log']: # Archivos de texto part = MIMEText(file_data.decode('utf-8', errors='ignore'), 'plain', 'utf-8') part.add_header('Content-Disposition', 'attachment', filename=file_name) msg.attach(part) self._log(f'Archivo de texto adjuntado: {file_name}') else: # Para otros tipos de archivos (genérico) part = MIMEBase('application', 'octet-stream') part.set_payload(file_data) encoders.encode_base64(part) part.add_header('Content-Disposition', f'attachment; filename= {file_name}') msg.attach(part) self._log(f'Archivo adjuntado: {file_name}') except Exception as exc: self._log(f'Error al adjuntar {file_name}: {exc}') messagebox.showwarning('⚠️ Advertencia', f'No se pudo adjuntar {file_name}:\n{exc}') # Conectar y enviar a todos los destinatarios with smtplib.SMTP(smtp_host, smtp_port_num, timeout=15) as server: # Nota: Si el servidor requiere autenticación TLS, descomentar: # server.starttls() # server.login(username, password) # send_message maneja automáticamente múltiples destinatarios server.send_message(msg, to_addrs=recipients) # Mensaje de éxito if len(recipients) == 1: success_msg = f'Correo enviado correctamente con {len(attachments)} adjunto(s)' else: success_msg = f'Correo enviado correctamente a {len(recipients)} destinatarios con {len(attachments)} adjunto(s)' self._log(success_msg) # Guardar copia en carpeta de enviados del servidor IMAP self._save_to_sent_folder(msg) # Si estamos viendo la carpeta de enviados, actualizar la lista if self.current_mailbox != 'INBOX': self._show_sent() messagebox.showinfo('✅ Éxito', success_msg) window.destroy() except Exception as exc: self._log(f'Error al enviar correo: {exc}') messagebox.showerror('❌ Error', f'No se pudo enviar el correo:\n{exc}') def _save_to_sent_folder(self, msg) -> None: """Guarda una copia del correo enviado en la carpeta Sent del servidor IMAP""" if not self.mail_connected or not self.imap_connection: self._log('No se puede guardar en carpeta Sent: no hay conexión IMAP') return try: import imaplib import time self._log('Intentando guardar correo en carpeta de enviados del servidor...') # Convertir el mensaje a bytes (formato RFC822) msg_bytes = msg.as_bytes() # Intentar diferentes nombres de carpeta de enviados sent_folders = ['Sent', 'INBOX.Sent', 'Enviados', 'INBOX.Enviados', 'Sent Items'] saved = False for folder in sent_folders: try: self._log(f'Intentando guardar en carpeta: {folder}') # Intentar agregar el mensaje a la carpeta # Usar el flag \Seen para marcarlo como leído date = imaplib.Time2Internaldate(time.time()) result = self.imap_connection.append(folder, '\\Seen', date, msg_bytes) if result[0] == 'OK': self._log(f'✓ Correo guardado exitosamente en carpeta: {folder}') self._log(f'✓ El correo debería aparecer en Webmin en la carpeta {folder}') saved = True break else: self._log(f'Respuesta del servidor: {result}') except Exception as e: # Si esta carpeta no existe, intentar con la siguiente self._log(f'Carpeta {folder} no disponible: {e}') continue if not saved: # Si no se pudo guardar en ninguna carpeta, intentar crear "Sent" try: self._log('Ninguna carpeta de enviados encontrada. Intentando crear "Sent"...') self.imap_connection.create('Sent') self._log('Carpeta "Sent" creada exitosamente') date = imaplib.Time2Internaldate(time.time()) result = self.imap_connection.append('Sent', '\\Seen', date, msg_bytes) if result[0] == 'OK': self._log('✓ Carpeta "Sent" creada y correo guardado') self._log('✓ El correo debería aparecer en Webmin en la carpeta Sent') saved = True else: self._log(f'Error al guardar en carpeta creada: {result}') except Exception as e: self._log(f'✗ No se pudo crear carpeta Sent ni guardar correo: {e}') if not saved: self._log('⚠ ADVERTENCIA: El correo se envió pero NO se guardó en el servidor') self._log('⚠ No aparecerá en Webmin ni en otros clientes de correo') except Exception as exc: self._log(f'✗ Error crítico al guardar en carpeta de enviados: {exc}') import traceback self._log(f'Traceback: {traceback.format_exc()}') def _open_simple_scraping_popup(self) -> None: if not (REQUESTS_AVAILABLE and BEAUTIFULSOUP_AVAILABLE): messagebox.showerror('Scraping', 'Instala requests y beautifulsoup4 para usar esta función.') return if self.simple_scraping_popup and tk.Toplevel.winfo_exists(self.simple_scraping_popup): self.simple_scraping_popup.lift() return popup = tk.Toplevel(self) popup.title('Scraping simple') popup.configure(bg='white', padx=18, pady=18) popup.resizable(False, False) popup.transient(self) popup.grab_set() tk.Label( popup, text='Introduce una o varias URL completas (una por línea):', bg='white', font=('Arial', 11, 'bold'), justify='left' ).pack(anchor='w') url_box = scrolledtext.ScrolledText(popup, width=60, height=5) url_box.pack(pady=(8, 4)) url_box.insert('1.0', 'https://es.wallapop.com\nhttps://www.wikipedia.org') error_label = tk.Label(popup, text='', fg='red', bg='white', wraplength=420, justify='left') error_label.pack(anchor='w') btn_frame = tk.Frame(popup, bg='white') btn_frame.pack(fill='x', pady=(10, 0)) def _close_popup() -> None: if self.simple_scraping_popup: try: self.simple_scraping_popup.destroy() except Exception: pass self.simple_scraping_popup = None def _start(_: object | None = None) -> None: raw = url_box.get('1.0', 'end').strip() urls = [line.strip() for line in raw.splitlines() if line.strip()] if not urls: error_label.config(text='Añade al menos una URL completa.') return invalid = [u for u in urls if not u.startswith(('http://', 'https://'))] if invalid: error_label.config(text=f'Las URL deben empezar por http:// o https:// (revisa: {invalid[0]})') return _close_popup() self._start_simple_scraping(urls) tk.Button(btn_frame, text='Iniciar', command=_start, bg='#d6f2ce').pack(side='left', padx=4) tk.Button(btn_frame, text='Cancelar', command=_close_popup).pack(side='right', padx=4) url_box.bind('', _start) popup.bind('', _start) popup.protocol('WM_DELETE_WINDOW', _close_popup) self.simple_scraping_popup = popup url_box.focus_set() def _start_simple_scraping(self, urls: list[str]) -> None: if not (REQUESTS_AVAILABLE and BEAUTIFULSOUP_AVAILABLE): messagebox.showerror('Scraping', 'Instala requests y beautifulsoup4 para usar esta función.') return cleaned = [url.strip() for url in urls if url.strip()] if not cleaned: messagebox.showinfo('Scraping', 'No hay URL válidas para analizar.') return self._log(f'Scraping simple para {len(cleaned)} URL(s)') preview = 'Analizando las siguientes URL:\n' + '\n'.join(f'• {url}' for url in cleaned) self._show_text_result('Scraping simple', preview) self.notebook.select(self.tab_resultados) threading.Thread(target=self._simple_scraping_runner, args=(cleaned,), daemon=True).start() def _simple_scraping_runner(self, urls: list[str]) -> None: results: list[dict[str, str]] = [] lock = threading.Lock() threads: list[threading.Thread] = [] for url in urls: t = threading.Thread(target=self._simple_scraping_worker, args=(url, results, lock), daemon=True) t.start() threads.append(t) for thread in threads: thread.join() self._scraping_queue.put(('generic_success', urls, results)) def _simple_scraping_worker(self, url: str, results: list[dict[str, str]], lock: threading.Lock) -> None: timestamp = datetime.datetime.now().strftime('%H:%M:%S') if not (requests and BEAUTIFULSOUP_AVAILABLE): data = { 'url': url, 'error': 'Dependencias de scraping no disponibles', 'timestamp': timestamp } else: try: time.sleep(1) headers = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' } response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() soup = BeautifulSoup(response.text, 'html.parser') title = soup.title.string.strip() if soup.title and soup.title.string else 'Sin título' num_links = len(soup.find_all('a')) num_images = len(soup.find_all('img')) num_paragraphs = len(soup.find_all('p')) meta_desc = '' meta_tag = soup.find('meta', attrs={'name': 'description'}) if meta_tag and meta_tag.get('content'): content = meta_tag['content'] meta_desc = content[:100] + '...' if len(content) > 100 else content data = { 'url': url, 'titulo': title, 'descripcion': meta_desc, 'num_links': num_links, 'num_imagenes': num_images, 'num_parrafos': num_paragraphs, 'longitud': len(response.text), 'status_code': response.status_code, 'timestamp': timestamp } except Exception as exc: data = { 'url': url, 'error': str(exc), 'timestamp': timestamp } with lock: results.append(data) # --- Funcionalidad de Web Scraping (Wallapop) --- def _open_wallapop_popup(self) -> None: if not (REQUESTS_AVAILABLE and BEAUTIFULSOUP_AVAILABLE): messagebox.showerror('Wallapop', 'Instala requests y beautifulsoup4 para analizar enlaces.') return if self.scraping_popup and tk.Toplevel.winfo_exists(self.scraping_popup): self.scraping_popup.lift() return popup = tk.Toplevel(self) popup.title('Búsqueda Wallapop') popup.configure(bg='white', padx=18, pady=18) popup.resizable(False, False) popup.transient(self) popup.grab_set() tk.Label( popup, text='Escribe el producto que quieres buscar en Wallapop:', bg='white', font=('Arial', 11, 'bold') ).pack(anchor='w') entry = tk.Entry(popup, width=60) entry.pack(pady=(8, 4)) entry.insert(0, 'PlayStation 5') controls = tk.Frame(popup, bg='white') controls.pack(fill='x', pady=(2, 0)) tk.Label(controls, text='Resultados a mostrar:', bg='white').pack(side='left') results_spin = tk.Spinbox(controls, from_=1, to=20, width=5, justify='center') results_spin.pack(side='left', padx=(6, 0)) results_spin.delete(0, 'end') results_spin.insert(0, '5') error_label = tk.Label(popup, text='', fg='red', bg='white', wraplength=380, justify='left') error_label.pack(anchor='w') btn_frame = tk.Frame(popup, bg='white') btn_frame.pack(fill='x', pady=(10, 0)) def _close_popup() -> None: if self.scraping_popup: try: self.scraping_popup.destroy() except Exception: pass self.scraping_popup = None def _start(_: object | None = None) -> None: query = entry.get().strip() if not query: error_label.config(text='Necesitas introducir un producto a buscar.') return try: limit = max(1, min(20, int(results_spin.get()))) except ValueError: error_label.config(text='Selecciona un número válido de resultados.') return _close_popup() self._start_wallapop_search(query, limit) tk.Button(btn_frame, text='Buscar', command=_start, bg='#d6f2ce').pack(side='left', padx=4) tk.Button(btn_frame, text='Cancelar', command=_close_popup).pack(side='right', padx=4) entry.bind('', _start) popup.protocol('WM_DELETE_WINDOW', _close_popup) self.scraping_popup = popup entry.focus_set() def _start_wallapop_search(self, query: str, limit: int) -> None: if not (REQUESTS_AVAILABLE and BEAUTIFULSOUP_AVAILABLE): messagebox.showerror('Wallapop', 'Instala requests y beautifulsoup4 para usar esta función.') return clean_query = query.strip() if not clean_query: messagebox.showerror('Wallapop', 'Escribe el producto que quieres buscar.') return max_results = max(1, min(20, int(limit))) self._log(f'Buscando en Wallapop: "{clean_query}" (máx. {max_results})') self._show_text_result( 'Wallapop', f'Buscando "{clean_query}" en Wallapop...\nMostrando hasta {max_results} resultados.' ) self.notebook.select(self.tab_resultados) threading.Thread( target=self._wallapop_search_worker, args=(clean_query, max_results), daemon=True ).start() def _wallapop_search_worker(self, query: str, limit: int) -> None: try: results = self._fetch_wallapop_search_results(query, limit) if not results: raise ValueError('No se encontraron anuncios para tu búsqueda.') self._scraping_queue.put(('search_success', query, results)) except Exception as exc: self._scraping_queue.put(('search_error', str(exc))) def _fetch_wallapop_search_results(self, query: str, limit: int) -> list[dict[str, str]]: errors: list[str] = [] for fetcher in (self._fetch_wallapop_search_via_api, self._fetch_wallapop_search_via_html): try: results = fetcher(query, limit) if results: return results[:limit] except Exception as exc: errors.append(str(exc)) if errors: raise ValueError('No se pudieron obtener resultados de Wallapop.\n' + '\n'.join(errors)) return [] def _fetch_wallapop_search_via_api(self, query: str, limit: int) -> list[dict[str, str]]: if not requests: return [] def _base_params() -> dict[str, object]: params: dict[str, object] = { 'keywords': query, 'source': 'search_box', 'filters_source': 'quick_filters', 'order_by': 'most_relevance', 'latitude': f'{WALLAPOP_LATITUDE:.6f}', 'longitude': f'{WALLAPOP_LONGITUDE:.6f}', 'country_code': WALLAPOP_COUNTRY_CODE, 'language': WALLAPOP_LANGUAGE } if WALLAPOP_CATEGORY_ID: params['category_id'] = WALLAPOP_CATEGORY_ID return params results: list[dict[str, str]] = [] seen_ids: set[str] = set() next_page: str | None = None attempts = 0 while len(results) < limit and attempts < 6: params = {'next_page': next_page} if next_page else _base_params() resp = requests.get( 'https://api.wallapop.com/api/v3/search', params=params, headers=WALLAPOP_HEADERS, timeout=20 ) try: resp.raise_for_status() except requests.HTTPError as exc: # type: ignore[attr-defined] if resp.status_code == 403: raise ValueError( 'Wallapop devolvió 403. Asegúrate de definir WALLAPOP_DEVICE_ID/WALLAPOP_MPID ' 'con valores obtenidos desde tu navegador.' ) from exc raise payload = resp.json() items = self._extract_wallapop_items(payload) if not items: break for entry in items: if not isinstance(entry, dict): continue item_id = entry.get('id') or entry.get('itemId') or entry.get('item_id') if not item_id: continue item_id = str(item_id) if item_id in seen_ids: continue seen_ids.add(item_id) slug = entry.get('web_slug') or entry.get('slug') or item_id seller_info = entry.get('seller') if isinstance(entry.get('seller'), dict) else None detail = self._format_wallapop_item(entry, self._build_wallapop_url(str(slug)), seller_info) results.append(detail) if len(results) >= limit: break next_page = self._extract_wallapop_next_page(payload) if not next_page: break attempts += 1 return results def _fetch_wallapop_search_via_html(self, query: str, limit: int) -> list[dict[str, str]]: if not (requests and BEAUTIFULSOUP_AVAILABLE): return [] params = {'keywords': query} resp = requests.get( 'https://es.wallapop.com/app/search', params=params, headers=WALLAPOP_HEADERS, timeout=20 ) resp.raise_for_status() soup = BeautifulSoup(resp.text, 'html.parser') script = soup.find('script', id='__NEXT_DATA__') if not script or not script.string: return [] data = json.loads(script.string) page_props = data.get('props', {}).get('pageProps', {}) items: list[dict[str, object]] = [] for key in ('searchResults', 'results', 'items'): candidate = page_props.get(key) if isinstance(candidate, list): items = candidate break if not items: initial_state = page_props.get('initialState', {}) if isinstance(initial_state, dict): for key in ('items', 'results'): candidate = initial_state.get(key) if isinstance(candidate, list): items = candidate break if not items: search_state = initial_state.get('search') if isinstance(search_state, dict): candidate = search_state.get('items') if isinstance(candidate, list): items = candidate elif isinstance(search_state.get('search'), dict): nested = search_state['search'].get('items') if isinstance(nested, list): items = nested if not items: return [] results: list[dict[str, str]] = [] seen: set[str] = set() for entry in items: if not isinstance(entry, dict): continue slug = ( entry.get('slug') or entry.get('webSlug') or entry.get('web_slug') or entry.get('shareLink') or entry.get('share_link') or entry.get('url') ) url = self._build_wallapop_url(str(slug)) if slug else '' item_id = entry.get('id') or entry.get('itemId') or entry.get('item_id') entry_key = str(item_id or url) if not entry_key or entry_key in seen: continue seen.add(entry_key) detail: dict[str, str] | None = None if url: try: detail = self._fetch_wallapop_item(url) except Exception: detail = None if not detail and item_id: try: detail = self._fetch_wallapop_item_from_api(str(item_id)) except Exception: detail = None if detail: if url and not detail.get('link'): detail['link'] = url results.append(detail) if len(results) >= limit: break return results def _extract_wallapop_items(self, payload: dict[str, object]) -> list[dict[str, object]]: items: list[dict[str, object]] = [] data_block = payload.get('data') if isinstance(data_block, dict): section = data_block.get('section') if isinstance(section, dict): section_payload = section.get('payload') if isinstance(section_payload, dict): raw_items = section_payload.get('items') or section_payload.get('section_items') if isinstance(raw_items, list): items.extend(item for item in raw_items if isinstance(item, dict)) elif isinstance(data_block.get('items'), list): items.extend(item for item in data_block['items'] if isinstance(item, dict)) if not items: web_results = payload.get('web_search_results') if isinstance(web_results, dict): sections = web_results.get('sections') if isinstance(sections, list): for section in sections: if not isinstance(section, dict): continue raw_items = section.get('items') or section.get('section_items') if isinstance(raw_items, list): items.extend(item for item in raw_items if isinstance(item, dict)) return items def _extract_wallapop_next_page(self, payload: dict[str, object]) -> str | None: meta = payload.get('meta') if isinstance(meta, dict): token = meta.get('next_page') if isinstance(token, str) and token: return token headers = payload.get('headers') if isinstance(headers, dict): token = headers.get('X-NextPage') or headers.get('x-nextpage') if isinstance(token, str) and token: return token return None def _fetch_wallapop_item(self, url: str) -> dict[str, str]: if not requests or not BEAUTIFULSOUP_AVAILABLE: raise RuntimeError('Dependencias de scraping no disponibles') resp = requests.get(url, headers=WALLAPOP_HEADERS, timeout=20) resp.raise_for_status() soup = BeautifulSoup(resp.text, 'html.parser') script = soup.find('script', id='__NEXT_DATA__') if not script or not script.string: raise ValueError('No se pudo encontrar la información del anuncio (sin datos Next.js).') data = json.loads(script.string) page_props = data.get('props', {}).get('pageProps', {}) item = page_props.get('item') or page_props.get('itemInfo', {}).get('item') if not item: raise ValueError('El formato de la página cambió y no se pudo leer el anuncio.') seller_info = page_props.get('itemSeller') if isinstance(page_props.get('itemSeller'), dict) else None return self._format_wallapop_item(item, url, seller_info) def _fetch_wallapop_item_from_api(self, item_id: str) -> dict[str, str]: if not requests: raise RuntimeError('Dependencias de scraping no disponibles') api_url = f'https://api.wallapop.com/api/v3/items/{item_id}' resp = requests.get(api_url, headers=WALLAPOP_HEADERS, timeout=15) resp.raise_for_status() data = resp.json() item = data.get('item') if isinstance(data.get('item'), dict) else data if not isinstance(item, dict): raise ValueError('Respuesta del API sin datos del artículo.') slug = item.get('web_slug') or item.get('slug') or item.get('share_link') link = self._build_wallapop_url(str(slug)) if slug else f'https://es.wallapop.com/item/{item_id}' seller_info = data.get('seller') if isinstance(data.get('seller'), dict) else item.get('seller') seller_dict = seller_info if isinstance(seller_info, dict) else None return self._format_wallapop_item(item, link, seller_dict) def _build_wallapop_url(self, slug_or_url: str) -> str: if not slug_or_url: return '' text = slug_or_url.strip() if text.startswith('http://') or text.startswith('https://'): return text text = text.strip('/') if not text.startswith('item/'): text = f'item/{text}' return f'https://es.wallapop.com/{text}' def _format_wallapop_item( self, item: dict[str, object], url: str, seller_info: dict[str, object] | None = None ) -> dict[str, str]: title_field = item.get('title') if isinstance(title_field, dict): title = title_field.get('original') or title_field.get('translated') or '(Sin título)' elif isinstance(title_field, str): title = title_field or '(Sin título)' else: title = '(Sin título)' price_text = 'Precio no disponible' price_data = item.get('price') price_cash = None if isinstance(price_data, dict): if isinstance(price_data.get('cash'), dict): price_cash = price_data['cash'] else: price_cash = price_data if isinstance(price_cash, dict): amount = price_cash.get('amount') currency = price_cash.get('currency') or 'EUR' if amount is not None: price_text = f'{amount} {currency}'.strip() elif isinstance(price_data, (int, float, str)): price_text = f'{price_data} EUR' desc_field = item.get('description') if isinstance(desc_field, dict): description = (desc_field.get('original') or desc_field.get('translated') or '').strip() elif isinstance(desc_field, str): description = desc_field.strip() else: description = '' location = item.get('location') if isinstance(item.get('location'), dict) else {} location_parts = [] if isinstance(location, dict): for part in (location.get('city'), location.get('postalCode'), location.get('regionName')): if part: location_parts.append(part) location_text = ', '.join(location_parts) if location_parts else 'Ubicación no disponible' seller_name = '' for candidate in (seller_info, item.get('seller'), item.get('user'), item.get('userInfo')): if isinstance(candidate, dict): seller_name = ( candidate.get('microName') or candidate.get('name') or candidate.get('userName') or '' ) if seller_name: break if not seller_name: seller_name = 'Vendedor no disponible' categories = ', '.join( cat.get('name') for cat in item.get('taxonomies', []) if isinstance(cat, dict) and cat.get('name') ) or 'Sin categoría' car_info = item.get('carInfo') if isinstance(item.get('carInfo'), dict) else {} car_bits: list[str] = [] if isinstance(car_info, dict): for key in ('year', 'km', 'engine', 'gearBox'): field = car_info.get(key) text = None if isinstance(field, dict): text = field.get('text') or field.get('iconText') elif isinstance(field, str): text = field if text: car_bits.append(text) extra = ', '.join(car_bits) images = item.get('images') if isinstance(item.get('images'), list) else [] image_url = '' for img in images: if not isinstance(img, dict): continue urls = img.get('urls') if isinstance(img.get('urls'), dict) else {} if isinstance(urls, dict): image_url = urls.get('medium') or urls.get('big') or urls.get('small') or '' else: image_url = img.get('url', '') if isinstance(img.get('url'), str) else '' if image_url: break return { 'title': str(title), 'price': price_text, 'description': description, 'link': url, 'location': location_text, 'seller': seller_name, 'category': categories, 'extra': extra, 'image': image_url } def _process_scraping_queue(self) -> None: if not self._running: return try: while True: status, *payload = self._scraping_queue.get_nowait() if status == 'error': message = payload[0] if payload else 'Error desconocido' self._handle_scraping_error(str(message)) elif status == 'search_success': query, data = payload self._handle_wallapop_search_success(str(query), list(data)) elif status == 'search_error': message = payload[0] if payload else 'Error desconocido' self._handle_scraping_error(str(message)) elif status == 'generic_success': urls, data = payload self._handle_generic_scraping_success(list(urls), list(data)) elif status == 'backup_success': summary, target = payload self._handle_backup_success(str(summary), str(target)) except queue.Empty: pass finally: if self._running: self.after(100, self._process_scraping_queue) def _handle_wallapop_search_success(self, query: str, results: list[dict[str, str]]) -> None: count = len(results) self._log(f'Se encontraron {count} anuncio(s) para "{query}" en Wallapop') def builder(parent: tk.Frame) -> None: frame = tk.Frame(parent, bg='white') frame.pack(fill='both', expand=True, padx=12, pady=12) summary = tk.Label( frame, text=f'{count} resultado(s) para "{query}"', font=('Arial', 14, 'bold'), bg='white', fg='#2c3e50' ) summary.pack(anchor='w', pady=(0, 6)) box = scrolledtext.ScrolledText(frame, wrap='word') box.pack(fill='both', expand=True) blocks: list[str] = [] for idx, item in enumerate(results, start=1): block_lines = [ f'{idx}. {item.get("title", "Sin título")}', f'Precio: {item.get("price", "N/D")}', f'Vendedor: {item.get("seller", "N/D")}', f'Ubicación: {item.get("location", "N/D")}', f'Categoría: {item.get("category", "N/D")}' ] extra = item.get('extra', '').strip() if extra: block_lines.append(f'Detalles: {extra}') description = (item.get('description') or '').strip() if description: block_lines.append('Descripción:') block_lines.append(description) block_lines.append(f'Enlace: {item.get("link", "N/D")}') block_lines.append('-' * 60) blocks.append('\n'.join(block_lines)) box.insert('1.0', '\n\n'.join(blocks)) box.config(state='disabled') self._render_results_view('Resultados Wallapop', builder) self.notebook.select(self.tab_resultados) try: path = self._save_wallapop_results_to_file(query, results) except Exception as exc: self._log(f'[ERROR] No se pudo guardar el archivo: {exc}') messagebox.showerror('Wallapop', f'No se pudo guardar el archivo: {exc}') return message = ( f'Se guardó el archivo con los resultados en:\n{path}\n\n' '¿Quieres abrirlo ahora?' ) if messagebox.askyesno('Wallapop', message): self._open_saved_file(path) else: self._log(f'Archivo de resultados guardado en {path}') def _handle_generic_scraping_success(self, urls: list[str], results: list[dict[str, str]]) -> None: self._log(f'Scraping simple completado para {len(results)} URL(s)') order = {url: idx for idx, url in enumerate(urls)} ordered = sorted(results, key=lambda item: order.get(item.get('url', ''), len(order))) def builder(parent: tk.Frame) -> None: frame = tk.Frame(parent, bg='white') frame.pack(fill='both', expand=True, padx=12, pady=12) summary = tk.Label( frame, text='Resultados de scraping simple', font=('Arial', 14, 'bold'), bg='white' ) summary.pack(anchor='w', pady=(0, 6)) box = scrolledtext.ScrolledText(frame, wrap='word') box.pack(fill='both', expand=True) blocks: list[str] = [] for idx, item in enumerate(ordered, start=1): lines = [f'{idx}. {item.get("url", "URL desconocida")}', f"Hora: {item.get('timestamp', 'N/D')}" ] if 'error' in item: lines.append(f"Error: {item.get('error')}") else: lines.extend([ f"Título: {item.get('titulo', 'Sin título')}", f"Descripción: {item.get('descripcion', '')}", f"Estado HTTP: {item.get('status_code', 'N/D')}", f"Longitud HTML: {item.get('longitud', 0)} caracteres", f"Links: {item.get('num_links', 0)} | Imágenes: {item.get('num_imagenes', 0)} | Párrafos: {item.get('num_parrafos', 0)}" ]) lines.append('-' * 60) blocks.append('\n'.join(lines)) box.insert('1.0', '\n\n'.join(blocks) if blocks else 'No se generaron resultados.') box.config(state='disabled') self._render_results_view('Scraping simple', builder) self.notebook.select(self.tab_resultados) def _handle_backup_success(self, summary: str, target: str) -> None: self._log('Copia de seguridad finalizada') self._show_text_result('Copias de seguridad', summary) self.notebook.select(self.tab_resultados) if messagebox.askyesno('Backup', f'{summary}\n\n¿Quieres abrir la carpeta destino?'): self._open_saved_file(target) def _save_wallapop_results_to_file(self, query: str, results: list[dict[str, str]]) -> str: timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') filename = f'wallapop_search_{timestamp}.txt' path = os.path.join(tempfile.gettempdir(), filename) lines = [ f'Consulta: {query}', f'Resultados: {len(results)}', f'Generado: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}', '' ] for idx, item in enumerate(results, start=1): lines.append(f'Resultado {idx}') lines.append(f'Título: {item.get("title", "Sin título")}') lines.append(f'Precio: {item.get("price", "N/D")}') lines.append(f'Vendedor: {item.get("seller", "N/D")}') lines.append(f'Ubicación: {item.get("location", "N/D")}') lines.append(f'Categoría: {item.get("category", "N/D")}') if item.get('extra'): lines.append(f'Detalles: {item.get("extra")}') description = (item.get('description') or '').strip() if description: lines.append('Descripción:') lines.append(description) lines.append(f'Enlace: {item.get("link", "N/D")}') if item.get('image'): lines.append(f'Imagen: {item.get("image")}') lines.append('-' * 60) with open(path, 'w', encoding='utf-8') as fh: fh.write('\n'.join(lines)) return path def _handle_scraping_error(self, message: str) -> None: self._log(f'[ERROR] Wallapop: {message}') messagebox.showerror('Wallapop', message) # --- Fin Web Scraping --- def _open_game_window(self) -> None: self._render_results_view('Carrera de camellos', self._build_camel_race_view) def _build_camel_race_view(self, parent: tk.Frame) -> None: container = tk.Frame(parent, bg='white') container.pack(fill='both', expand=True) tk.Label( container, text='Juego de camellos (sincronización de hilos)', font=('Arial', 18, 'bold'), bg='white' ).pack(pady=(16, 8)) canvas = tk.Canvas(container, height=380, bg='#fdfdfd', bd=2, relief='sunken') canvas.pack(fill='both', expand=True, padx=16, pady=12) status = tk.Label(container, text='Listo para correr', font=('Arial', 14, 'bold'), bg='white', fg='#2c3e50') status.pack(pady=(0, 10)) controls = tk.Frame(container, bg='white') controls.pack(pady=(0, 10)) tk.Button( controls, text='Iniciar carrera', font=('Arial', 13, 'bold'), width=16, command=lambda: self._start_race(canvas, status) ).grid(row=0, column=0, padx=12, pady=4) tk.Button( controls, text='Volver a resultados', font=('Arial', 13), width=16, command=self._reset_results_view ).grid(row=0, column=1, padx=12, pady=4) tk.Label(controls, text='Número de camellos:', bg='white', font=('Arial', 12)).grid(row=1, column=0, pady=6) camel_spin = tk.Spinbox( controls, from_=3, to=8, width=5, justify='center', font=('Arial', 12), command=lambda: self._set_camel_count(int(camel_spin.get())) ) camel_spin.grid(row=1, column=1, pady=6) camel_spin.delete(0, 'end') camel_spin.insert(0, str(self.game_camel_count)) tk.Label(controls, text='Velocidad (1-5):', bg='white', font=('Arial', 12)).grid(row=2, column=0, pady=6) speed_scale = tk.Scale( controls, from_=1, to=5, orient='horizontal', resolution=0.5, length=180, bg='white', command=lambda value: self._set_speed_factor(float(value)) ) speed_scale.grid(row=2, column=1, pady=6) speed_scale.set(self.game_speed_factor) self.game_window_canvas = canvas self.game_window_status = status def _set_camel_count(self, value: int) -> None: self.game_camel_count = max(3, min(8, int(value))) def _set_speed_factor(self, value: float) -> None: self.game_speed_factor = max(1.0, min(5.0, float(value))) def _start_race(self, canvas=None, status_label=None) -> None: target_canvas = canvas or self.game_window_canvas if target_canvas is None: messagebox.showinfo('Juego', 'Abre el juego para iniciar la carrera.') return self._stop_game_queue_processor() target_canvas.delete('all') target_canvas.update_idletasks() width = target_canvas.winfo_width() or int(target_canvas.cget('width')) height = target_canvas.winfo_height() or int(target_canvas.cget('height')) finish = width - 40 camel_count = max(3, min(8, int(self.game_camel_count))) spacing = height / (camel_count + 1) palette = ['#ff7675', '#74b9ff', '#55efc4', '#f1c40f', '#9b59b6', '#1abc9c', '#e67e22', '#95a5a6'] racers = [] for idx in range(camel_count): y = spacing * (idx + 1) target_canvas.create_line(20, y, finish, y, dash=(3,4)) color = palette[idx % len(palette)] rect = target_canvas.create_rectangle(30, y-22, 130, y+22, fill=color) racers.append(rect) status = status_label or self.game_window_status if status: status.config(text='¡Carrera en curso!') self.game_move_queue = queue.Queue() self.game_done_event = threading.Event() self.game_finish_line = finish self.game_racer_names = [f'Camello {i+1}' for i in range(camel_count)] self.game_queue_processor_active = True self.after(30, self._process_game_queue) indices = list(range(camel_count)) random.shuffle(indices) for idx in indices: rect = racers[idx] threading.Thread(target=self._race_worker, args=(idx, rect), daemon=True).start() def _race_worker(self, index: int, rect_id: int) -> None: while True: if not self.game_queue_processor_active or not self.game_move_queue or not self.game_done_event: return if self.game_done_event.is_set(): return base_min = 3 base_max = 9 speed = max(1.0, float(self.game_speed_factor)) step = random.randint(int(base_min * speed), max(int(base_max * speed), int(base_min * speed) + 1)) self.game_move_queue.put(('move', (rect_id, step, index))) sleep_time = max(0.02, 0.08 / speed) time.sleep(random.uniform(sleep_time * 0.5, sleep_time * 1.2)) def _process_game_queue(self) -> None: if not self.game_queue_processor_active or not self.game_move_queue: return canvas = self.game_window_canvas if not canvas: return try: while True: action, payload = self.game_move_queue.get_nowait() if action == 'move': rect_id, step, idx = payload try: canvas.move(rect_id, step, 0) except Exception: continue coords = canvas.coords(rect_id) if coords and coords[2] >= self.game_finish_line and self.game_done_event and not self.game_done_event.is_set(): self.game_done_event.set() if self.game_window_status: self.game_window_status.config(text=f'{self.game_racer_names[idx]} ganó la carrera') else: continue except queue.Empty: pass if not self.game_done_event or not self.game_done_event.is_set(): if self.game_queue_processor_active: self.after(30, self._process_game_queue) else: self.game_queue_processor_active = False def _stop_game_queue_processor(self) -> None: self.game_queue_processor_active = False self.game_move_queue = None if self.game_done_event: try: self.game_done_event.set() except Exception: pass self.game_done_event = None def _start_alarm(self) -> None: try: minutes = max(1, int(self.alarm_minutes.get())) except ValueError: messagebox.showwarning('Alarmas', 'Ingresa un número válido de minutos.') return title = self.alarm_title.get().strip() or f'Alarma {self.alarm_counter}' end_time = datetime.datetime.now() + datetime.timedelta(minutes=minutes) alarm = {'id': self.alarm_counter, 'title': title, 'end': end_time} self.alarm_counter += 1 self.active_alarms.append(alarm) self.alarm_title.delete(0, 'end') self._update_alarm_list() self._log(f'Alarma "{title}" programada para las {end_time.strftime("%H:%M:%S")}') def _refresh_alarms_loop(self) -> None: if not self._running: return now = datetime.datetime.now() triggered: list[dict[str, datetime.datetime | str]] = [] for alarm in list(self.active_alarms): remaining = (alarm['end'] - now).total_seconds() # type: ignore[arg-type] if remaining <= 0: triggered.append(alarm) for alarm in triggered: self.active_alarms.remove(alarm) self._trigger_alarm(alarm) self._update_alarm_list() self.after(1000, self._refresh_alarms_loop) def _update_alarm_list(self) -> None: if not hasattr(self, 'alarm_list'): return selection = self.alarm_list.curselection() selected_id = None if selection: idx = selection[0] if idx < len(self.active_alarms): selected_id = self.active_alarms[idx]['id'] self.alarm_list.delete(0, 'end') if not self.active_alarms: self.alarm_status.config(text='Sin alarmas programadas') return now = datetime.datetime.now() for index, alarm in enumerate(self.active_alarms): remaining = max(0, int((alarm['end'] - now).total_seconds())) # type: ignore[arg-type] minutes, seconds = divmod(remaining, 60) self.alarm_list.insert('end', f"{alarm['title']} - {minutes:02d}:{seconds:02d}") if selected_id is not None and alarm['id'] == selected_id: self.alarm_list.selection_set(index) self.alarm_status.config(text=f"{len(self.active_alarms)} alarma(s) activas") def _trigger_alarm(self, alarm: dict[str, datetime.datetime | str]) -> None: self._log(f"Alarma '{alarm['title']}' activada") try: self.bell() except Exception: pass self._show_alarm_popup(str(alarm['title'])) def _show_alarm_popup(self, title: str) -> None: popup = tk.Toplevel(self) popup.title('Alarma activa') popup.configure(bg='#ffeaea', padx=20, pady=20) popup.transient(self) popup.grab_set() tk.Label(popup, text='¡Tiempo cumplido!', font=('Arial', 16, 'bold'), fg='#c0392b', bg='#ffeaea').pack(pady=6) tk.Label(popup, text=title, font=('Arial', 13), bg='#ffeaea').pack(pady=4) tk.Button(popup, text='Detener alarma', command=popup.destroy, bg='#f9dede').pack(pady=10) def _cancel_selected_alarm(self) -> None: if not self.active_alarms: messagebox.showinfo('Alarmas', 'No hay alarmas para cancelar.') return selection = self.alarm_list.curselection() if not selection: messagebox.showinfo('Alarmas', 'Selecciona una alarma en la lista.') return idx = selection[0] if idx >= len(self.active_alarms): messagebox.showwarning('Alarmas', 'La selección ya no es válida.') return alarm = self.active_alarms.pop(idx) self._update_alarm_list() self._log(f"Alarma '{alarm['title']}' cancelada") def _select_music(self) -> None: if not pygame_available: messagebox.showwarning('Música', 'Instale pygame para reproducir audio.') return path = filedialog.askopenfilename( filetypes=[ ('Audio/Video', '*.mp3 *.wav *.ogg *.mp4 *.mkv *.mov *.avi *.m4a *.webm'), ('Todos', '*.*') ] ) if not path: return self._cleanup_temp_audio() ext = os.path.splitext(path)[1].lower() direct_audio = {'.mp3', '.wav', '.ogg', '.m4a'} playable_path = path temp_path = None if ext not in direct_audio: tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.wav') temp_path = tmp.name tmp.close() cmd = ['ffmpeg', '-y', '-i', path, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2', temp_path] try: # Usar subprocess.run para esperar la finalización de ffmpeg subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) playable_path = temp_path except FileNotFoundError: if os.path.exists(temp_path): os.unlink(temp_path) messagebox.showerror('Música', 'ffmpeg no está instalado. Instálalo para extraer el audio de videos.') return except subprocess.CalledProcessError as exc: if os.path.exists(temp_path): os.unlink(temp_path) messagebox.showerror('Música', f'No se pudo extraer el audio: {exc}') return pygame.mixer.init() pygame.mixer.music.load(playable_path) pygame.mixer.music.play(-1) self.music_temp_file = temp_path self._log(f'Reproduciendo {os.path.basename(path)}') def _stop_music(self) -> None: if pygame_available and pygame.mixer.get_init(): pygame.mixer.music.stop() self._cleanup_temp_audio() def _pause_music(self) -> None: if pygame_available and pygame.mixer.get_init(): pygame.mixer.music.pause() def _resume_music(self) -> None: if pygame_available and pygame.mixer.get_init(): pygame.mixer.music.unpause() def _cleanup_temp_audio(self) -> None: if self.music_temp_file and os.path.exists(self.music_temp_file): try: os.remove(self.music_temp_file) except OSError: pass self.music_temp_file = None def _resolve_openweather_key(self) -> str: key = (self.weather_api_key or '').strip() if not key: raise RuntimeError('Configure OPENWEATHER_API_KEY u OPENWEATHER_FALLBACK_API_KEY') return key def _fetch_weather_by_coordinates(self, lat: float, lon: float) -> dict[str, Any]: if not REQUESTS_AVAILABLE: raise RuntimeError('Instale requests para consultar el clima') key = self._resolve_openweather_key() params = { 'lat': lat, 'lon': lon, 'appid': key, 'units': 'metric', 'lang': 'es' } resp = requests.get('https://api.openweathermap.org/data/2.5/weather', params=params, timeout=8) resp.raise_for_status() data = resp.json() if 'main' not in data or 'weather' not in data: raise ValueError('Respuesta incompleta de OpenWeatherMap') return data def _fetch_weather_data(self, city_query: str) -> dict[str, Any]: if not REQUESTS_AVAILABLE: raise RuntimeError('Instale requests para consultar el clima') key = self._resolve_openweather_key() params = { 'q': city_query, 'appid': key, 'units': 'metric', 'lang': 'es' } resp = requests.get('https://api.openweathermap.org/data/2.5/weather', params=params, timeout=8) resp.raise_for_status() data = resp.json() if 'main' not in data or 'weather' not in data: raise ValueError('Respuesta incompleta de OpenWeatherMap') return data def _fetch_javea_weather(self) -> dict[str, Any]: return self._fetch_weather_by_coordinates(JAVEA_LATITUDE, JAVEA_LONGITUDE) # ------------------------ chat ------------------------ def _connect_chat(self) -> None: host = self.host_entry.get().strip() or SERVER_HOST_DEFAULT try: port = int(self.port_entry.get().strip()) except ValueError: port = SERVER_PORT_DEFAULT if self.chat_client.connect(host, port): self._enqueue_chat_message('[INFO] Conectado al servidor') def _send_chat(self) -> None: text = self.message_entry.get('1.0', 'end').strip() if not text: return if self.chat_client.send(text + '\n'): self._enqueue_chat_message('Yo: ' + text) self.message_entry.delete('1.0', 'end') else: messagebox.showwarning('Chat', 'No hay conexión activa.') def _enqueue_chat_message(self, text: str) -> None: self._chat_queue.put(text) def _chat_loop(self) -> None: while self._running: try: msg = self._chat_queue.get(timeout=0.5) except queue.Empty: continue self.chat_display.after(0, lambda m=msg: self._append_chat(m)) def _append_chat(self, text: str) -> None: self.chat_display.config(state='normal') self.chat_display.insert('end', text + '\n') self.chat_display.see('end') self.chat_display.config(state='disabled') # ------------------------ hilos auxiliares ------------------------ def _update_clock(self) -> None: if not self._running: return now = datetime.datetime.now().strftime('%d/%m/%Y %H:%M:%S') self.clock_label.config(text=now) self.after(1000, self._update_clock) def _update_traffic(self) -> None: if not (self._running and psutil): return if self._traffic_last is None: try: self._traffic_last = psutil.net_io_counters() except Exception as exc: self._log(f'Tráfico: psutil no disponible ({exc})') self.traffic_label.config(text='Red: N/D') return try: current = psutil.net_io_counters() sent = (current.bytes_sent - self._traffic_last.bytes_sent) / 1024 recv = (current.bytes_recv - self._traffic_last.bytes_recv) / 1024 self._traffic_last = current self.traffic_label.config(text=f'Red: ↑ {sent:.1f} KB/s ↓ {recv:.1f} KB/s') except Exception as exc: self._log(f'Tráfico: {exc}') self.traffic_label.config(text='Red: N/D') finally: if self._running: self.after(1000, self._update_traffic) def _resource_poll_tick(self) -> None: if not (self._running and psutil): return try: cpu = psutil.cpu_percent(interval=None) mem = psutil.virtual_memory().percent except Exception as exc: self._log(f'Recursos: {exc}') if self._running: self._resource_poll_job = self.after(2000, self._resource_poll_tick) return threads = 0 if self._ps_process: try: threads = self._ps_process.num_threads() except Exception: threads = 0 self._resource_history['cpu'].append(cpu) self._resource_history['mem'].append(mem) self._resource_history['threads'].append(threads) self._resource_history['cpu'] = self._resource_history['cpu'][-40:] self._resource_history['mem'] = self._resource_history['mem'][-40:] self._resource_history['threads'] = self._resource_history['threads'][-40:] self._update_chart() if self._running: self._resource_poll_job = self.after(1000, self._resource_poll_tick) def _update_chart(self) -> None: if not ( MATPLOTLIB_AVAILABLE and self.chart_canvas and self.line_cpu and self.line_mem and self.ax_cpu and self.ax_mem ): return x = list(range(len(self._resource_history['cpu']))) self.line_cpu.set_data(x, self._resource_history['cpu']) self.line_mem.set_data(x, self._resource_history['mem']) max_x = max(40, len(x)) self.ax_cpu.set_xlim(0, max_x) self.ax_mem.set_xlim(0, max_x) self.ax_cpu.set_ylim(0, 100) self.ax_mem.set_ylim(0, 100) if self.mem_fill: try: self.mem_fill.remove() except Exception: pass if self.ax_mem and x: self.mem_fill = self.ax_mem.fill_between( x, self._resource_history['mem'], color='#4ecdc4', alpha=0.3 ) if self.ax_threads: threads = self._resource_history['threads'] if threads: if self.thread_bars and len(self.thread_bars) == len(threads): for rect, height in zip(self.thread_bars, threads): rect.set_height(height) else: if self.thread_bars: for rect in self.thread_bars: rect.remove() self.thread_bars = self.ax_threads.bar(x, threads, color='#ffa726') if x else None if threads: max_threads = max(threads) self.ax_threads.set_ylim(0, max(max_threads * 1.2, 1)) self.ax_threads.set_xlim(-0.5, max(39.5, len(x) - 0.5)) if self.chart_canvas: self.chart_canvas.draw_idle() def _log(self, text: str) -> None: if threading.current_thread() is not threading.main_thread(): self.after(0, lambda t=text: self._log(t)) return # Verificar si el widget notes existe antes de intentar usarlo if not hasattr(self, 'notes') or self.notes is None: # Si no existe, simplemente imprimir en consola durante inicialización print(f'[LOG] {text}') return timestamp = datetime.datetime.now().strftime('%H:%M:%S') self.notes.insert('end', f'[{timestamp}] {text}\n') self.notes.see('end') def on_close(self) -> None: """Cierra la aplicación correctamente""" print("Cerrando aplicación...") self._running = False # Cancelar jobs programados if self._resource_poll_job is not None: try: self.after_cancel(self._resource_poll_job) except Exception: pass self._resource_poll_job = None # Cerrar cliente de juego try: if hasattr(self, 'game_client'): self.game_client.close() except Exception as e: print(f"Error cerrando game_client: {e}") # Detener música si está sonando try: if pygame and pygame.mixer.get_init(): pygame.mixer.music.stop() pygame.mixer.quit() except Exception: pass # Destruir ventana try: self.destroy() except Exception: pass # Forzar salida si es necesario import sys sys.exit(0) def main() -> None: app = DashboardApp() app.protocol('WM_DELETE_WINDOW', app.on_close) app.mainloop() if __name__ == '__main__': main()