From 8e3216957cbba141ab22ef46d5a687a00ba8792e Mon Sep 17 00:00:00 2001 From: Alvaro Romero Date: Fri, 3 Jul 2026 12:15:46 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20EmailManager=20initial=20commit=20?= =?UTF-8?q?=E2=80=94=20Python=20Google=20APIs=20(Gmail,=20Calendar),=20Tel?= =?UTF-8?q?egram=20Bot=20API,=20Ollama=20(local=20LLM),=20Craigslist=20RSS?= =?UTF-8?q?,=20OpenStreetMap/Overpass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 + .gitignore | 51 ++ EmailAssistant.vbs | 3 + Modelfile.asistente | 31 + RestartEmailAssistant.vbs | 12 + analyze_senders.py | 134 +++ email_assistant.py | 1805 +++++++++++++++++++++++++++++++++++++ email_manager.py | 342 +++++++ recordatorio_jaime.py | 20 + 9 files changed, 2407 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 EmailAssistant.vbs create mode 100644 Modelfile.asistente create mode 100644 RestartEmailAssistant.vbs create mode 100644 analyze_senders.py create mode 100644 email_assistant.py create mode 100644 email_manager.py create mode 100644 recordatorio_jaime.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e235aa --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Copy this file to .env and fill in real values. +# NEVER commit .env to git. + +# Telegram bot credentials +TELEGRAM_TOKEN=your_telegram_bot_token_here +TELEGRAM_CHAT_ID=your_telegram_chat_id_here + +# Google Places API key (currently unused — app uses OSM/Overpass) +GOOGLE_PLACES_KEY=your_google_places_api_key_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d61aa7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyc +*.pyd +.Python +*.egg +*.egg-info/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Environment / secrets — NEVER commit these +.env +.env.* +!.env.example + +# Google OAuth credentials and tokens — contain secrets +credentials.json +token_*.pickle + +# State / cache files +last_check.json + +# Backup files +*.bak +*.bak2 + +# Logs and output +*.log +*.txt +!requirements.txt + +# Build / dist +build/ +dist/ +output/ + +# Type checking / linting caches +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# OS artifacts +.DS_Store +Thumbs.db +desktop.ini diff --git a/EmailAssistant.vbs b/EmailAssistant.vbs new file mode 100644 index 0000000..ed2c3da --- /dev/null +++ b/EmailAssistant.vbs @@ -0,0 +1,3 @@ +Set WshShell = CreateObject("WScript.Shell") +WshShell.Run "python ""D:\Proyectos Software\EmailManager\email_assistant.py""", 0, False +Set WshShell = Nothing diff --git a/Modelfile.asistente b/Modelfile.asistente new file mode 100644 index 0000000..c6a07e9 --- /dev/null +++ b/Modelfile.asistente @@ -0,0 +1,31 @@ +FROM llama3.1:8b + +SYSTEM """ +Eres el asistente personal de Alvaro Romero. Siempre respondes en español, de forma concisa y útil. + +PERFIL DE ALVARO: +- Busca trabajo activamente en sector marítimo e ingeniería +- Conductor Uber y Lyft (tiene Checkr, Lyft Direct/Payfare) +- Bancos USA: Chase, Wells Fargo, Citi, Discover, Space Coast Credit Union +- Pagos: PayPal, Stripe, Global66 (transferencias a Colombia) +- Inversiones: Interactive Brokers, OANDA, Alpaca Markets +- Tiendas: Amazon, Alibaba, AliExpress, Temu, Walmart, Costco, Home Depot, Vevor, Harbor Freight +- Empleo: Indeed, smaritime y plataformas marítimas similares +- Colombia: Colsanitas, EPS Sanitas, Tigo, Telefonica, ePayco, Registraduría Nacional +- Proyecto propio: AutoBooking (app de transporte en WordPress) + +CLASIFICACIÓN DE CORREOS — criterios: +IMPORTANTE: transacciones bancarias reales, pedidos/envíos con número de orden, ofertas de trabajo legítimas, correos de salud, documentos legales, personas reales conocidas, plataformas conocidas con contenido relevante para Alvaro +DUDOSO: dominio sospechoso, urgencia exagerada, pide contraseñas o datos personales, no puedes verificar el origen +BASURA: publicidad pura, newsletters, promociones sin transacción activa, spam + +REGLAS ESTRICTAS: +- Responder SIEMPRE en español +- Cuando se pida JSON: responder SOLO el JSON, sin texto antes ni después +- Ser conciso — máximo 3 líneas para respuestas generales +- No inventar información que no esté en el contexto +""" + +PARAMETER temperature 0.15 +PARAMETER num_ctx 4096 +PARAMETER num_predict 512 diff --git a/RestartEmailAssistant.vbs b/RestartEmailAssistant.vbs new file mode 100644 index 0000000..7ba216f --- /dev/null +++ b/RestartEmailAssistant.vbs @@ -0,0 +1,12 @@ +Dim WshShell +Set WshShell = CreateObject("WScript.Shell") + +' Matar solo el proceso python que corre email_assistant.py +WshShell.Run "wmic process where ""name='python.exe' and commandline like '%email_assistant%'"" call terminate", 0, True + +WScript.Sleep 2000 + +' Arrancar de nuevo +WshShell.Run "python ""D:\Proyectos Software\EmailManager\email_assistant.py""", 0, False + +Set WshShell = Nothing diff --git a/analyze_senders.py b/analyze_senders.py new file mode 100644 index 0000000..52b4b57 --- /dev/null +++ b/analyze_senders.py @@ -0,0 +1,134 @@ +""" +Fase 1: Analiza remitentes sin borrar nada. +Genera report.txt con todos los senders agrupados por categoria. +""" +import os +import sys +import re +import pickle +from collections import defaultdict +from google.auth.transport.requests import Request +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build + +sys.stdout.reconfigure(encoding='utf-8') + +SCOPES = ['https://www.googleapis.com/auth/gmail.readonly'] +CREDENTIALS_FILE = os.path.join(os.path.dirname(__file__), 'credentials.json') + + +def authenticate(account_name): + token_file = os.path.join(os.path.dirname(__file__), f'token_{account_name}.pickle') + creds = None + if os.path.exists(token_file): + with open(token_file, 'rb') as f: + creds = pickle.load(f) + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_FILE, SCOPES) + creds = flow.run_local_server(port=0) + with open(token_file, 'wb') as f: + pickle.dump(creds, f) + return build('gmail', 'v1', credentials=creds) + + +def get_header(headers, name): + for h in headers: + if h['name'].lower() == name.lower(): + return h['value'] + return '' + + +def extract_domain(sender): + match = re.search(r'@([\w.\-]+)', sender) + return match.group(1).lower() if match else sender.lower() + + +def analyze_account(account_name, email): + print(f"\nConectando {email}...") + service = authenticate(account_name) + print(f"Autenticado. Leyendo correos...") + + senders = defaultdict(int) # domain -> count + sender_names = {} # domain -> full sender example + + queries = [ + ('Promotions', 'category:promotions'), + ('Updates', 'category:updates'), + ('Inbox', 'in:inbox'), + ] + + for category, query in queries: + print(f" Escaneando: {category}...") + page_token = None + count = 0 + while True: + kwargs = {'userId': 'me', 'q': query, 'maxResults': 500} + if page_token: + kwargs['pageToken'] = page_token + result = service.users().messages().list(**kwargs).execute() + messages = result.get('messages', []) + if not messages: + break + for msg_ref in messages: + try: + msg = service.users().messages().get( + userId='me', id=msg_ref['id'], + format='metadata', + metadataHeaders=['From'] + ).execute() + headers = msg.get('payload', {}).get('headers', []) + sender = get_header(headers, 'From') + domain = extract_domain(sender) + senders[domain] += 1 + if domain not in sender_names: + sender_names[domain] = sender + count += 1 + except Exception: + pass + page_token = result.get('nextPageToken') + if not page_token: + break + print(f" {count} mensajes procesados") + + return senders, sender_names + + +def main(): + accounts = [ + ('alro65', 'alro65@gmail.com'), + ('alro65usa', 'alro65usa@gmail.com'), + ] + + report_lines = [] + + for account_name, email in accounts: + senders, sender_names = analyze_account(account_name, email) + + report_lines.append(f"\n{'='*60}") + report_lines.append(f"CUENTA: {email}") + report_lines.append(f"{'='*60}") + report_lines.append(f"Total remitentes unicos: {len(senders)}") + report_lines.append(f"\nRemitentes ordenados por cantidad de correos:\n") + + for domain, count in sorted(senders.items(), key=lambda x: -x[1]): + full_sender = sender_names.get(domain, domain) + report_lines.append(f" {count:5d} {domain:<40} {full_sender[:60]}") + + report_path = os.path.join(os.path.dirname(__file__), 'report.txt') + with open(report_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(report_lines)) + + print(f"\nReporte guardado en: {report_path}") + print("Revisalo y dime que hacer con cada remitente.") + + # Also print top 30 + print("\n--- TOP senders (preview) ---") + for line in report_lines[-50:]: + print(line) + + +if __name__ == '__main__': + main() diff --git a/email_assistant.py b/email_assistant.py new file mode 100644 index 0000000..1db4a29 --- /dev/null +++ b/email_assistant.py @@ -0,0 +1,1805 @@ +""" +Asistente Personal de Alvaro v3 +- Gmail: clasifica, organiza etiquetas, resumen 7 AM, borradores con confirmacion +- Google Calendar: agenda del dia, recordatorios 1h y 30min antes, crear eventos por Telegram +- Craigslist: vigila veleros 36-50ft/$25k y herramientas, cada 6h si hay nuevos +- Todo por Telegram bidireccional con Ollama local (AsistentePersonal — base llama3.1:8b) +""" +import os, sys, re, pickle, time, json, base64 +import urllib.request, urllib.parse, threading +import xml.etree.ElementTree as ET +from datetime import datetime, date, timedelta +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders + +sys.stdout.reconfigure(encoding='utf-8') + +from google.auth.transport.requests import Request +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +# ── Config general ───────────────────────────────────────────────────────────── +CREDENTIALS_FILE = os.path.join(os.path.dirname(__file__), 'credentials.json') +STATE_FILE = os.path.join(os.path.dirname(__file__), 'last_check.json') + +TELEGRAM_TOKEN = os.environ.get('TELEGRAM_TOKEN', '') +TELEGRAM_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '') +OLLAMA_URL = 'http://localhost:11434/api/generate' +OLLAMA_MODEL = 'qwen2.5:14b' # correos, análisis, respuestas (mejor calidad) +OLLAMA_MODEL_CALENDAR = 'llama3.2:latest' # parseo fechas/calendario (liviano, 2 GB) +CV_DIRECTORY = r'D:\CVs' +SUMMARY_HOUR = 7 +EVENING_HOUR = 21 # 9pm — recordatorio de eventos del día siguiente +EMAIL_CHECK_INTERVAL = 3600 # 1 hora entre revisiones de correo +CALENDAR_CHECK_INTERVAL = 900 # 15 min para recordatorios de citas + +# ── Config Gmail ─────────────────────────────────────────────────────────────── +GMAIL_ACCOUNTS = [ + ('alro65', 'alro65@gmail.com'), + ('alro65usa', 'alro65usa@gmail.com'), +] +GMAIL_SCOPES = ['https://www.googleapis.com/auth/gmail.modify'] + +# ── Config Calendar ──────────────────────────────────────────────────────────── +CALENDAR_ACCOUNT = 'alro65' # cuenta con el calendario principal +CALENDAR_TOKEN = os.path.join(os.path.dirname(__file__), 'token_calendar.pickle') +CALENDAR_SCOPES = ['https://www.googleapis.com/auth/calendar'] +CALENDAR_TZ = 'America/New_York' # Florida = Eastern +REMINDER_MINUTES = [120, 60, 30] # avisar 2 horas, 1 hora y 30 min antes + +# ── Config Google Places ─────────────────────────────────────────────────────── +GOOGLE_PLACES_KEY = os.environ.get('GOOGLE_PLACES_KEY', '') # unused — app uses OSM/Overpass +USER_DEFAULT_LOCATION = 'Brevard County, FL' # Space Coast — cambiar si te mudas + +# ── Config Craigslist ────────────────────────────────────────────────────────── +CL_CHECK_HOURS = 6 +CL_REGIONS = [ + ('Miami', 'miami'), + ('Fort Lauderdale','broward'), + ('Florida Keys', 'keys'), + ('West Palm Beach','westpalmbeach'), + ('Treasure Coast', 'treasure'), + ('Tampa', 'tampa'), + ('Orlando', 'orlando'), + ('Jacksonville', 'jacksonville'), + ('Savannah GA', 'savannah'), + ('Charleston SC', 'charleston'), + ('Norfolk VA', 'norfolk'), +] +CL_WATCHES = [ + {'id': 'veleros', 'nombre': 'Veleros', 'category': 'boo', + 'query': 'sailboat', 'max_price': 25000}, + {'id': 'herramientas', 'nombre': 'Herramientas', 'category': 'tls', + 'query': 'marine tools boat','max_price': 2000}, +] + +# ── Gmail Labels ─────────────────────────────────────────────────────────────── +JOB_KEYWORDS = ['indeed','ziprecruiter','glassdoor','monster','smaritime', + 'maritime','hiring','recruiter','job offer','career','vacancy', + 'position','empleo','trabajo','oferta laboral'] +LABEL_RULES = { + 'Trabajo-Empleo': JOB_KEYWORDS + ['linkedin'], + 'Bancos': ['chase','wellsfargo','citibank','citi.com','discover', + 'spacecoast','paypal','stripe','zelle'], + 'Colombia': ['global66','colsanitas','sanitas','tigo','telefonica', + 'epayco','registraduria'], + 'Compras': ['amazon','alibaba','aliexpress','temu','walmart', + 'costco','homedepot','vevor','harborfreight'], + 'Inversiones': ['interactivebrokers','oanda','alpaca'], + 'Uber-Lyft': ['uber.com','lyft.com','checkr','payfare'], +} + +# ── Estado compartido ────────────────────────────────────────────────────────── +pending_decisions = {} +pending_replies = {} +pending_lock = threading.Lock() +last_update_id = 0 +_cal_reminders_sent = set() +_cal_reminders_lock = threading.Lock() +gmail_services = {} +label_id_cache = {} +calendar_service = None +last_important_email = None +last_job_email = None +conversation_history = [] # historial chat para el catch-all conversacional +CONV_MAX_TURNS = 12 # turnos a conservar (6 intercambios) + + +# ══════════════════════════════════════════════════════════════════════════════ +# AUTH +# ══════════════════════════════════════════════════════════════════════════════ + +def authenticate_gmail(account_name): + token_file = os.path.join(os.path.dirname(__file__), f'token_{account_name}.pickle') + creds = None + if os.path.exists(token_file): + with open(token_file, 'rb') as f: + creds = pickle.load(f) + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_FILE, GMAIL_SCOPES) + creds = flow.run_local_server(port=0) + with open(token_file, 'wb') as f: + pickle.dump(creds, f) + return build('gmail', 'v1', credentials=creds) + + +def authenticate_calendar(): + creds = None + if os.path.exists(CALENDAR_TOKEN): + with open(CALENDAR_TOKEN, 'rb') as f: + creds = pickle.load(f) + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file( + CREDENTIALS_FILE, CALENDAR_SCOPES) + creds = flow.run_local_server(port=0) + with open(CALENDAR_TOKEN, 'wb') as f: + pickle.dump(creds, f) + return build('calendar', 'v3', credentials=creds) + + +# ══════════════════════════════════════════════════════════════════════════════ +# TELEGRAM +# ══════════════════════════════════════════════════════════════════════════════ + +def send_telegram(message, urgent=False): + """urgent=True usa formato de alerta maxima para recordatorios.""" + try: + text = str(message)[:4000] + url = f'https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage' + params = { + 'chat_id': TELEGRAM_CHAT_ID, + 'text': text, + 'parse_mode': 'HTML', + 'disable_web_page_preview': 'false', + } + # urgent = no silenciar nunca (notification_sound no existe en Bot API + # pero disable_notification=false asegura que siempre suene) + if urgent: + params['disable_notification'] = 'false' + data = urllib.parse.urlencode(params).encode() + req = urllib.request.Request(url, data=data) + resp = urllib.request.urlopen(req, timeout=10) + return json.loads(resp.read()).get('result', {}).get('message_id') + except Exception as e: + print(f' [Telegram error] {e}') + return None + + +def get_telegram_updates(offset=0): + try: + url = (f'https://api.telegram.org/bot{TELEGRAM_TOKEN}' + f'/getUpdates?offset={offset}&timeout=5') + resp = urllib.request.urlopen(urllib.request.Request(url), timeout=10) + return json.loads(resp.read()).get('result', []) + except Exception: + return [] + + +# ══════════════════════════════════════════════════════════════════════════════ +# OLLAMA +# ══════════════════════════════════════════════════════════════════════════════ + +def ask_ollama(prompt, temperature=0.3, model=None): + try: + payload = json.dumps({ + 'model': model or OLLAMA_MODEL, 'prompt': prompt, + 'stream': False, 'options': {'temperature': temperature}, + 'keep_alive': '5m' + }).encode() + req = urllib.request.Request(OLLAMA_URL, data=payload, + headers={'Content-Type': 'application/json'}) + resp = urllib.request.urlopen(req, timeout=90) + return json.loads(resp.read()).get('response', '').strip() + except Exception as e: + return f'Error: {e}' + + +def ask_ollama_chat(user_message, system_prompt=None, temperature=0.4): + """Llama a Ollama en modo chat manteniendo historial de conversación.""" + global conversation_history + CHAT_URL = 'http://localhost:11434/api/chat' + + conversation_history.append({'role': 'user', 'content': user_message}) + + messages = [] + if system_prompt: + messages.append({'role': 'system', 'content': system_prompt}) + messages.extend(conversation_history[-CONV_MAX_TURNS:]) + + try: + payload = json.dumps({ + 'model': OLLAMA_MODEL, + 'messages': messages, + 'stream': False, + 'options': {'temperature': temperature}, + 'keep_alive': '5m' + }).encode() + req = urllib.request.Request(CHAT_URL, data=payload, + headers={'Content-Type': 'application/json'}) + resp = urllib.request.urlopen(req, timeout=90) + reply = json.loads(resp.read()).get('message', {}).get('content', '').strip() + conversation_history.append({'role': 'assistant', 'content': reply}) + # Recortar historial para no crecer indefinidamente + if len(conversation_history) > CONV_MAX_TURNS * 2: + conversation_history = conversation_history[-(CONV_MAX_TURNS * 2):] + return reply + except Exception as e: + conversation_history.pop() # revertir el mensaje del usuario si falló + return f'Error: {e}' + + +def web_search(query, max_results=5): + """Busca en DuckDuckGo y devuelve snippets reales para dar contexto a Ollama.""" + url = 'https://html.duckduckgo.com/html/' + data = urllib.parse.urlencode({'q': query, 'kl': 'us-en'}).encode() + req = urllib.request.Request(url, data=data, headers={ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Content-Type': 'application/x-www-form-urlencoded', + }) + try: + with urllib.request.urlopen(req, timeout=12) as r: + html = r.read().decode('utf-8', errors='ignore') + titles = re.findall(r'class="result__a"[^>]*>(.*?)', html, re.DOTALL) + snippets = re.findall(r'class="result__snippet">(.*?)', html, re.DOTALL) + results = [] + for t, s in zip(titles[:max_results], snippets[:max_results]): + t = re.sub(r'<[^>]+>', '', t).strip() + s = re.sub(r'<[^>]+>', '', s).strip() + if t and s: + results.append(f'• {t}: {s}') + return results + except Exception as e: + print(f'[WebSearch] {e}') + return [] + + +# ══════════════════════════════════════════════════════════════════════════════ +# GOOGLE CALENDAR +# ══════════════════════════════════════════════════════════════════════════════ + +def cal_now_rfc(): + """Retorna ahora mismo en formato RFC3339 con offset -04:00 (EDT).""" + now = datetime.now() + return now.strftime('%Y-%m-%dT%H:%M:%S-04:00') + + +def cal_date_rfc(dt: datetime): + return dt.strftime('%Y-%m-%dT%H:%M:%S-04:00') + + +def cal_parse_dt(event_dt: dict): + """Parsea start/end de un evento Calendar, retorna datetime.""" + if 'dateTime' in event_dt: + s = event_dt['dateTime'] + # Normalizar offset a formato compatible + s = re.sub(r'([+-]\d{2}):(\d{2})$', r'\1\2', s) + try: + return datetime.strptime(s, '%Y-%m-%dT%H:%M:%S%z').replace(tzinfo=None) + except Exception: + return None + elif 'date' in event_dt: + try: + return datetime.strptime(event_dt['date'], '%Y-%m-%d') + except Exception: + return None + return None + + +def cal_format_event(event, show_date=False): + """Formatea un evento para Telegram.""" + title = event.get('summary', '(sin titulo)') + start = cal_parse_dt(event.get('start', {})) + end = cal_parse_dt(event.get('end', {})) + loc = event.get('location', '') + + if start: + if 'date' in event.get('start', {}): + time_str = 'Todo el dia' + else: + time_str = start.strftime('%I:%M %p') + if end: + time_str += ' - ' + end.strftime('%I:%M %p') + if show_date: + time_str = start.strftime('%a %d %b') + ' ' + time_str + else: + time_str = '' + + line = f'{title}' + if time_str: + line += f' — {time_str}' + if loc: + line += f'\n {loc}' + return line + + +def cal_get_events(start_dt: datetime, end_dt: datetime): + """Retorna lista de eventos en el rango dado.""" + if not calendar_service: + return [] + try: + result = calendar_service.events().list( + calendarId='primary', + timeMin=cal_date_rfc(start_dt), + timeMax=cal_date_rfc(end_dt), + singleEvents=True, + orderBy='startTime', + maxResults=20, + ).execute() + return result.get('items', []) + except Exception as e: + print(f' [Calendar error] {e}') + return [] + + +def _normalize_time_text(text): + """Convierte expresiones de hora en español a formato numérico antes de enviar a Ollama.""" + replacements = [ + (r'\bmedio\s*d[ií]a\b', 'las 12:00'), + (r'\bmediod[ií]a\b', 'las 12:00'), + (r'\bal\s*medio\s*d[ií]a\b', 'a las 12:00'), + (r'\ba\s*la\s*medianoche\b', 'a las 00:00'), + (r'\bmedianoche\b', 'las 00:00'), + (r'\ben\s*la\s*ma[ñn]ana\b', 'a las 09:00'), + (r'\bpor\s*la\s*ma[ñn]ana\b', 'a las 09:00'), + (r'\bpor\s*la\s*tarde\b', 'a las 15:00'), + (r'\ben\s*la\s*tarde\b', 'a las 15:00'), + (r'\bpor\s*la\s*noche\b', 'a las 20:00'), + (r'\ben\s*la\s*noche\b', 'a las 20:00'), + (r'\bde\s*madrugada\b', 'a las 02:00'), + (r'\bal\s*amanecer\b', 'a las 06:00'), + ] + result = text + for pattern, replacement in replacements: + result = re.sub(pattern, replacement, result, flags=re.IGNORECASE) + return result + + +def cal_create_event_from_text(text): + """Usa Ollama para parsear texto natural y crea el evento en Calendar.""" + if not calendar_service: + return None, 'Calendario no disponible.' + + text = _normalize_time_text(text) + now_str = datetime.now().strftime('%Y-%m-%d %H:%M (%A)') + prompt = f"""Hoy es {now_str}. Alvaro quiere agendar: "{text}" + +Extrae la informacion del evento. Reglas: +- Si no se especifica duración, asume 1 hora. +- Si no se especifica fecha, asume mañana. +- Horas en formato 24h. +- Expresiones de hora en español: + "medio día" o "mediodía" = 12:00 + "mañana" (hora) = 09:00 + "tarde" = 15:00 + "noche" = 20:00 + "madrugada" = 02:00 + "al amanecer" = 06:00 + "a las X" = hora X en 24h + +Responde SOLO este JSON sin texto extra: +{{"titulo": "nombre del evento", "fecha": "YYYY-MM-DD", "hora_inicio": "HH:MM", "hora_fin": "HH:MM", "lugar": "lugar o vacio", "notas": "descripcion adicional o vacio"}}""" + + raw = ask_ollama(prompt, temperature=0.1, model=OLLAMA_MODEL_CALENDAR) + m = re.search(r'\{.*\}', raw, re.DOTALL) + if not m: + return None, 'No pude entender la fecha/hora del evento.' + + try: + d = json.loads(m.group()) + event_body = { + 'summary': d.get('titulo', 'Evento'), + 'location': d.get('lugar', ''), + 'description': d.get('notas', ''), + 'start': { + 'dateTime': f"{d['fecha']}T{d['hora_inicio']}:00", + 'timeZone': CALENDAR_TZ, + }, + 'end': { + 'dateTime': f"{d['fecha']}T{d['hora_fin']}:00", + 'timeZone': CALENDAR_TZ, + }, + 'reminders': {'useDefault': False, 'overrides': []}, + } + created = calendar_service.events().insert( + calendarId='primary', body=event_body + ).execute() + return created, None + except Exception as e: + return None, f'Error al crear evento: {e}' + + +def _build_conv_ctx(): + """Devuelve las últimas 4 líneas del historial de chat como string de contexto.""" + if not conversation_history: + return '' + turns = conversation_history[-4:] + return 'Conversación previa:\n' + '\n'.join( + f'{"Alvaro" if m["role"]=="user" else "Asistente"}: {m["content"][:200]}' + for m in turns + ) + '\n\n' + + +def cal_delete_event_from_text(text): + """Busca un evento y lo elimina.""" + if not calendar_service: + return None, 'Calendario no disponible.' + + now = datetime.now() + candidates = cal_get_events(now, now + timedelta(days=30)) + if not candidates: + return None, 'No encontré eventos próximos.' + + lista = '\n'.join( + f'{i+1}. {ev.get("summary","?")} — {cal_parse_dt(ev.get("start",{}))}' + for i, ev in enumerate(candidates[:10]) + ) + prompt = (f'{_build_conv_ctx()}Alvaro dice: "{text}"\n\nEventos próximos:\n{lista}\n\n' + f'¿Cuál evento quiere borrar? Responde SOLO el número (ej: 2)') + raw = ask_ollama(prompt, temperature=0.0, model=OLLAMA_MODEL).strip() + m = re.search(r'\d+', raw) + idx = (int(m.group()) - 1) if m else 0 + idx = max(0, min(idx, len(candidates) - 1)) + + event = candidates[idx] + try: + calendar_service.events().delete( + calendarId='primary', eventId=event['id'] + ).execute() + return event.get('summary', 'Evento'), None + except Exception as e: + return None, f'Error al borrar: {e}' + + +def cal_modify_event_from_text(text): + """Busca un evento existente y lo modifica según instrucción en texto natural.""" + if not calendar_service: + return None, 'Calendario no disponible.' + text = _normalize_time_text(text) + + now = datetime.now() + # Buscar eventos en los próximos 30 días para encontrar cuál modificar + candidates = cal_get_events(now, now + timedelta(days=30)) + if not candidates: + return None, 'No encontré eventos próximos en tu calendario.' + + lista = '\n'.join( + f'{i+1}. {ev.get("summary","?")} — {cal_parse_dt(ev.get("start",{}))}' + for i, ev in enumerate(candidates[:10]) + ) + now_str = now.strftime('%Y-%m-%d %H:%M (%A)') + + prompt = f"""{_build_conv_ctx()}Hoy es {now_str}. Alvaro dice: "{text}" + +Eventos próximos: +{lista} + +Identifica cuál evento modificar y qué campos cambiar. +IMPORTANTE: "titulo" solo cambia si el usuario pide un nombre nuevo. Si no, pon null. +Si cambia la hora, hora_fin = hora_inicio + 1h (a menos que el usuario indique otra duración). + +Responde SOLO este JSON (null en campos que NO cambian): +{{"indice": 1, "titulo": null, "fecha": null, "hora_inicio": "HH:MM", "hora_fin": "HH:MM", "lugar": null, "notas": null}}""" + + raw = ask_ollama(prompt, temperature=0.0, model=OLLAMA_MODEL) + m = re.search(r'\{.*\}', raw, re.DOTALL) + if not m: + return None, 'No pude identificar qué evento modificar.' + + try: + d = json.loads(m.group()) + idx = int(d.get('indice', 1)) - 1 + if idx < 0 or idx >= len(candidates): + idx = 0 + event = candidates[idx] + eid = event['id'] + + # Construir el cuerpo de actualización con solo los campos que cambian + patch = {} + if d.get('titulo'): + patch['summary'] = d['titulo'] + if d.get('lugar'): + patch['location'] = d['lugar'] + if d.get('notas'): + patch['description'] = d['notas'] + + fecha = d.get('fecha') or (cal_parse_dt(event['start']).strftime('%Y-%m-%d') + if cal_parse_dt(event['start']) else None) + if d.get('hora_inicio') and fecha: + patch['start'] = {'dateTime': f"{fecha}T{d['hora_inicio']}:00", + 'timeZone': CALENDAR_TZ} + if d.get('hora_fin') and fecha: + patch['end'] = {'dateTime': f"{fecha}T{d['hora_fin']}:00", + 'timeZone': CALENDAR_TZ} + + updated = calendar_service.events().patch( + calendarId='primary', eventId=eid, body=patch + ).execute() + return updated, None + except Exception as e: + return None, f'Error al modificar evento: {e}' + + +def check_calendar_reminders(state): + """Envia recordatorios 60 y 30 minutos antes de cada evento.""" + if not calendar_service: + return + + now = datetime.now() + sent = state.setdefault('reminders_sent', {}) + + # Limpiar recordatorios de dias anteriores + today_prefix = now.strftime('%Y%m%d') + state['reminders_sent'] = {k: v for k, v in sent.items() + if k.startswith(today_prefix)} + sent = state['reminders_sent'] + + # Buscar eventos en las proximas 65 minutos + events = cal_get_events(now, now + timedelta(minutes=65)) + + for event in events: + start = cal_parse_dt(event.get('start', {})) + if not start or 'date' in event.get('start', {}): + continue # saltar eventos de todo el dia + + eid = event.get('id', '') + title = event.get('summary', 'Evento') + delta = int((start - now).total_seconds() / 60) + + for mins in REMINDER_MINUTES: + key = f"{today_prefix}_{eid}_{mins}" + if key in sent: + continue + # Avisar si estamos dentro de la ventana del recordatorio + if abs(delta - mins) <= 3: + sent[key] = True + hora = start.strftime('%I:%M %p') + msg = ( + f'\U0001F514\U0001F514\U0001F514 RECORDATORIO\n\n' + f'{title}\n' + f'En {mins} minutos — {hora}' + ) + if event.get('location'): + msg += f'\nLugar: {event["location"]}' + if event.get('description'): + msg += f'\n{event["description"][:100]}' + send_telegram(msg, urgent=True) + print(f' [Reminder] {mins}min antes: {title}') + + +# ══════════════════════════════════════════════════════════════════════════════ +# GMAIL HELPERS +# ══════════════════════════════════════════════════════════════════════════════ + +def get_header(headers, name): + for h in headers: + if h['name'].lower() == name.lower(): + return h['value'] + return '' + + +def extract_body(payload): + text = '' + if payload.get('mimeType', '').startswith('text/plain'): + data = payload.get('body', {}).get('data', '') + if data: + text = base64.urlsafe_b64decode(data + '==').decode('utf-8', errors='ignore') + elif 'parts' in payload: + for part in payload['parts']: + text += extract_body(part) + return text[:3000] + + +def is_job_email(sender, subject): + text = (sender + ' ' + subject).lower() + return any(kw in text for kw in JOB_KEYWORDS) + + +def detect_label(sender, subject): + text = (sender + ' ' + subject).lower() + for label_name, keywords in LABEL_RULES.items(): + if any(kw in text for kw in keywords): + return label_name + return None + + +def get_or_create_label(service, account_name, label_name): + if account_name not in label_id_cache: + label_id_cache[account_name] = {} + if label_name in label_id_cache[account_name]: + return label_id_cache[account_name][label_name] + try: + existing = service.users().labels().list(userId='me').execute() + for lbl in existing.get('labels', []): + if lbl['name'] == label_name: + label_id_cache[account_name][label_name] = lbl['id'] + return lbl['id'] + result = service.users().labels().create( + userId='me', + body={'name': label_name, 'labelListVisibility': 'labelShow', + 'messageListVisibility': 'show'} + ).execute() + label_id_cache[account_name][label_name] = result['id'] + return result['id'] + except Exception: + return None + + +def assign_label(service, account_name, msg_id, label_name): + lid = get_or_create_label(service, account_name, label_name) + if lid: + try: + service.users().messages().modify( + userId='me', id=msg_id, body={'addLabelIds': [lid]} + ).execute() + except Exception: + pass + + +def classify_email(sender, subject, body, account_email): + prompt = f"""Eres el asistente personal de Alvaro, correo {account_email}. + +REMITENTE: {sender} +ASUNTO: {subject} +CUERPO: {body[:2000]} + +CONTEXTO: +- Busca trabajo activo. Indeed y plataformas maritimas son IMPORTANTES. +- Bancos USA: Chase, Wells Fargo, Citi, Discover, Space Coast CU. +- Pagos: PayPal, Stripe, Global66. Conductor Uber/Lyft. Checkr, Payfare. +- Inversiones: Interactive Brokers, OANDA, Alpaca. +- Tiendas: Amazon, Alibaba, AliExpress, Temu, Walmart, Costco, Home Depot, Vevor, Harbor Freight. + Pedidos/envios = IMPORTANTES. Solo promociones = BASURA. +- Colombia: Colsanitas, EPS Sanitas, Tigo/Telefonica, ePayco, Registraduria. + +IMPORTANTE: transaccion, pedido, trabajo, salud, legal, persona real, plataforma con contenido relevante +DUDOSO: dominio raro, urgencia, pide datos, no puedes confirmar +BASURA: publicidad, newsletters, promociones + +Responde SOLO JSON sin texto extra: +{{"decision":"IMPORTANTE" o "DUDOSO" o "BASURA","razon":"una linea","resumen":"de que trata","senales_alerta":"si DUDOSO que te genero duda, sino vacio"}}""" + try: + payload = json.dumps({ + 'model': OLLAMA_MODEL, 'prompt': prompt, + 'stream': False, 'options': {'temperature': 0.1}, + 'keep_alive': '1m' + }).encode() + req = urllib.request.Request(OLLAMA_URL, data=payload, + headers={'Content-Type': 'application/json'}) + resp = urllib.request.urlopen(req, timeout=60) + text = json.loads(resp.read()).get('response', '').strip() + m = re.search(r'\{.*\}', text, re.DOTALL) + if m: + d = json.loads(m.group()) + return (d.get('decision','BASURA'), d.get('razon',''), + d.get('resumen', subject), d.get('senales_alerta','')) + except Exception as e: + print(f' [Ollama error] {e}') + return 'BASURA', 'Error', subject, '' + + +def send_gmail(service, to, subject, body, cv_path=None): + try: + if cv_path and os.path.exists(cv_path): + msg = MIMEMultipart() + msg.attach(MIMEText(body, 'plain', 'utf-8')) + with open(cv_path, 'rb') as f: + part = MIMEBase('application', 'octet-stream') + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header('Content-Disposition', + f'attachment; filename="{os.path.basename(cv_path)}"') + msg.attach(part) + else: + msg = MIMEText(body, 'plain', 'utf-8') + msg['to'] = to + msg['subject'] = subject + raw = base64.urlsafe_b64encode(msg.as_bytes()).decode() + service.users().messages().send(userId='me', body={'raw': raw}).execute() + return True + except Exception as e: + print(f' [Send error] {e}') + return False + + +def list_cvs(): + if not os.path.exists(CV_DIRECTORY): + return [] + return [f for f in os.listdir(CV_DIRECTORY) + if f.lower().endswith(('.pdf', '.docx', '.doc'))] + + +def pick_best_cv(job_info): + cvs = list_cvs() + if not cvs: + return None + if len(cvs) == 1: + return cvs[0] + prompt = f"""CVs de Alvaro:\n{chr(10).join(f'- {cv}' for cv in cvs)} +Trabajo: {job_info} +Cual CV es mas apropiado? Responde SOLO el nombre exacto del archivo.""" + result = ask_ollama(prompt).strip() + for cv in cvs: + if cv.lower() in result.lower() or result.lower() in cv.lower(): + return cv + return cvs[0] + + +# ══════════════════════════════════════════════════════════════════════════════ +# CRAIGSLIST +# ══════════════════════════════════════════════════════════════════════════════ + +def cl_fetch_rss(city_code, category, query, max_price): + url = (f'https://{city_code}.craigslist.org/search/{category}' + f'?query={urllib.parse.quote(query)}' + f'&max_price={max_price}&sort=date&format=rss') + try: + req = urllib.request.Request(url, headers={ + 'User-Agent': 'Mozilla/5.0 (compatible; bot/1.0)'}) + resp = urllib.request.urlopen(req, timeout=15) + root = ET.fromstring(resp.read()) + items = [] + for item in root.findall('.//item'): + title = item.findtext('title', '').strip() + link = item.findtext('link', '').strip() + desc = re.sub(r'<[^>]+>', ' ', item.findtext('description', '')) + desc = re.sub(r'\s+', ' ', desc).strip() + price_m = re.search(r'\$([\d,]+)', title) + price = int(price_m.group(1).replace(',','')) if price_m else 0 + lid_m = re.search(r'/(\d+)\.html', link) + lid = lid_m.group(1) if lid_m else link[-16:] + items.append({'id': lid, 'title': title, 'link': link, + 'desc': desc[:600], 'price': price}) + return items + except Exception as e: + print(f' [CL {city_code}] {e}') + return [] + + +def cl_is_sailboat_in_range(title, desc): + text = (title + ' ' + desc).lower() + return bool(re.search(r'\b(3[6-9]|4[0-9]|50)\s*(?:ft|feet|foot|\'|footer)', text)) + + +def cl_analyze(watch_id, title, price, desc, city): + precio_str = f'${price:,}' if price else 'no indicado' + if watch_id == 'veleros': + prompt = f"""Alvaro busca velero 36-50 pies, presupuesto $25,000 para regatear. +Listing en {city}: {title} | Precio: {precio_str} +{desc[:700]} +Responde en espanol breve: 1) Es velero de 36-50 pies? 2) Precio razonable? 3) Vale mirarlo? (si/no + una linea)""" + else: + prompt = f"""Alvaro es marino y mecanico. Le interesan herramientas marinas y de taller. +Listing en {city}: {title} | Precio: {precio_str} +{desc[:700]} +Responde en espanol breve: 1) Que es? 2) Util para marino/taller? 3) Merece atencion? (si/no)""" + return ask_ollama(prompt, temperature=0.2) + + +def cl_extract_venue(title, desc, city_name): + """Usa Ollama para detectar si el listing menciona una marina, astillero o patio por nombre.""" + raw = ask_ollama( + f'Analiza este anuncio de Craigslist de un bote en {city_name}.\n' + f'Título: {title}\n' + f'Descripción: {desc[:500]}\n\n' + f'¿Menciona explícitamente el NOMBRE de una marina, astillero (boatyard/shipyard), ' + f'patio de botes, marina seca, boat dealer o yacht club?\n' + f'Si SÍ → responde SOLO el nombre exacto del lugar (ej: "Dinner Key Marina").\n' + f'Si NO → responde solo la palabra: null', + temperature=0.0, model=OLLAMA_MODEL_CALENDAR + ).strip().strip('"\'').split('\n')[0][:80] + return None if raw.lower() in ('null', 'no', 'none', '', 'no menciona') else raw + + +def run_cl_scrape(state, watch_id_filter=None, notify_empty=True): + seen = set(state.get('cl_seen', [])) + found_any = False + + for watch in CL_WATCHES: + if watch_id_filter and watch['id'] != watch_id_filter: + continue + new_items = [] + print(f'\n[CL] Buscando {watch["nombre"]}...') + + for city_name, city_code in CL_REGIONS: + items = cl_fetch_rss(city_code, watch['category'], + watch['query'], watch['max_price']) + for item in items: + uid = f"{watch['id']}_{item['id']}" + if uid in seen: + continue + seen.add(uid) + if watch['id'] == 'veleros': + if not cl_is_sailboat_in_range(item['title'], item['desc']): + continue + if item['price'] and item['price'] > watch['max_price']: + continue + item['city'] = city_name + new_items.append(item) + time.sleep(1.5) + + if new_items: + found_any = True + # Extraer venues de marinas/astilleros de los listings de veleros + if watch['id'] == 'veleros': + known_venues = state.setdefault('boat_venues', []) + known_names = {v['name'].lower() for v in known_venues} + for item in new_items: + venue = cl_extract_venue(item['title'], item['desc'], item['city']) + if venue and venue.lower() not in known_names: + known_venues.append({'name': venue, 'city': item['city'], + 'link': item['link']}) + known_names.add(venue.lower()) + print(f' [CL] Venue nuevo: {venue} ({item["city"]})') + state['boat_venues'] = known_venues[-100:] + + send_telegram(f'{watch["nombre"]} nuevos en Craigslist ({len(new_items)})') + for item in new_items[:8]: + analisis = cl_analyze(watch['id'], item['title'], + item['price'], item['desc'], item['city']) + precio_str = f'${item["price"]:,}' if item['price'] else 'Precio no indicado' + send_telegram( + f'{item["title"][:80]}\n' + f'Precio: {precio_str} | {item["city"]}\n\n' + f'{analisis[:600]}\n\n' + f'Ver en Craigslist' + ) + time.sleep(0.5) + + state['cl_seen'] = list(seen)[-1000:] + if not found_any and notify_empty: + send_telegram('No hay listings nuevos en este momento.') + + +def check_cl_watches(state): + now = time.time() + last = state.get('cl_last_check', 0) + if now - last < CL_CHECK_HOURS * 3600: + return + state['cl_last_check'] = now + run_cl_scrape(state, notify_empty=False) + + +# ══════════════════════════════════════════════════════════════════════════════ +# RESUMEN MATUTINO +# ══════════════════════════════════════════════════════════════════════════════ + +def check_morning_summary(state): + now = datetime.now() + today = str(date.today()) + if now.hour != SUMMARY_HOUR or state.get('summary_date') == today: + return + state['summary_date'] = today + + stats = state.get('daily_stats', {'importantes': 0, 'basura': 0, 'dudosos': 0}) + recents = state.get('recent_importantes', []) + + msg = (f'Buenos dias Alvaro!\n\n' + f'Correos de ayer:\n' + f'Importantes: {stats.get("importantes",0)}\n' + f'Dudosos: {stats.get("dudosos",0)}\n' + f'Spam eliminado: {stats.get("basura",0)}\n') + + if recents: + msg += '\nImportantes recientes:\n' + for item in recents[-5:]: + msg += f'- {item["sender"][:40]}: {item["subject"][:55]}\n' + + # Agenda de hoy + today_start = datetime.now().replace(hour=0, minute=0, second=0) + today_end = datetime.now().replace(hour=23, minute=59, second=59) + events = cal_get_events(today_start, today_end) + if events: + msg += '\nAgenda de hoy:\n' + for ev in events: + msg += cal_format_event(ev) + '\n' + else: + msg += '\nAgenda de hoy: sin eventos\n' + + msg += '\nComandos: revisar | agenda | mañana | semana | estado | veleros | herramientas' + send_telegram(msg) + + state['daily_stats'] = {'importantes': 0, 'basura': 0, 'dudosos': 0} + state['recent_importantes'] = [] + + +def check_evening_summary(state): + """Cada noche a las 9pm: eventos de mañana. Los sábados además la semana completa.""" + now = datetime.now() + today = str(date.today()) + + if now.hour != EVENING_HOUR or state.get('evening_date') == today: + return + state['evening_date'] = today + + # Eventos de mañana + tom = now + timedelta(days=1) + tom_s = tom.replace(hour=0, minute=0, second=0) + tom_e = tom.replace(hour=23, minute=59, second=59) + events_tom = cal_get_events(tom_s, tom_e) + + msg = '🌙 Buenas noches Alvaro\n\n' + if events_tom: + msg += 'Mañana tienes:\n' + for ev in events_tom: + msg += cal_format_event(ev) + '\n' + else: + msg += 'Mañana no tienes eventos agendados.\n' + + # Si es sábado (weekday 5), agregar resumen de toda la semana + if now.weekday() == 5: + week_s = (now + timedelta(days=2)).replace(hour=0, minute=0, second=0) # lunes + week_e = week_s + timedelta(days=6, hours=23, minutes=59) + events_week = cal_get_events(week_s, week_e) + if events_week: + msg += '\nPróxima semana:\n' + for ev in events_week: + msg += cal_format_event(ev, show_date=True) + '\n' + else: + msg += '\nPróxima semana: sin eventos.\n' + + send_telegram(msg) + + +# ══════════════════════════════════════════════════════════════════════════════ +# TELEGRAM LISTENER +# ══════════════════════════════════════════════════════════════════════════════ + +def telegram_listener(): + global last_update_id + print('[Telegram] Escuchando...') + while True: + try: + updates = get_telegram_updates(offset=last_update_id + 1) + for update in updates: + last_update_id = update['update_id'] + msg = update.get('message', {}) + text = msg.get('text', '').strip() + chat = str(msg.get('chat', {}).get('id', '')) + if chat != TELEGRAM_CHAT_ID or not text: + continue + print(f'[Telegram] Alvaro: {text}') + handle_user_message(text) + except Exception as e: + print(f'[Telegram error] {e}') + time.sleep(2) + + +def search_gmail(query, max_results=5): + """Busca correos en Gmail con query libre y retorna resumen.""" + encontrados = [] + for account_name, email in GMAIL_ACCOUNTS: + service = gmail_services.get(account_name) + if not service: + continue + try: + resp = service.users().messages().list( + userId='me', q=query, maxResults=max_results).execute() + for msg_ref in resp.get('messages', []): + msg = service.users().messages().get( + userId='me', id=msg_ref['id'], format='full').execute() + headers = msg.get('payload', {}).get('headers', []) + sender = get_header(headers, 'From') + subject = get_header(headers, 'Subject') or '(sin asunto)' + date = get_header(headers, 'Date') + body = extract_body(msg.get('payload', {})) + encontrados.append({ + 'cuenta': email, 'de': sender, + 'asunto': subject, 'fecha': date, 'cuerpo': body[:600] + }) + except Exception as e: + print(f' [Search Gmail error {email}] {e}') + return encontrados + + +_OSM_TAGS = { + # Marítimo / botes + 'marina': [('leisure', 'marina')], + 'astillero': [('waterway', 'boatyard')], + 'boatyard': [('waterway', 'boatyard')], + 'shipyard': [('waterway', 'boatyard')], + 'patio de botes': [('waterway', 'boatyard'), ('leisure', 'marina')], + 'boat dealer': [('shop', 'boat')], + 'tienda de botes': [('shop', 'boat')], + 'yacht club': [('amenity', 'yacht_club'), ('leisure', 'marina')], + 'sailing club': [('amenity', 'yacht_club'), ('leisure', 'marina')], + 'yacht broker': [('shop', 'boat'), ('leisure', 'marina')], + 'marine supply': [('shop', 'boat'), ('shop', 'marine')], + # Ferretería / herramientas + 'hardware store': [('shop', 'doityourself'), ('shop', 'hardware')], + 'ferreteria': [('shop', 'doityourself'), ('shop', 'hardware')], + # Auto / mecánica + 'auto parts store': [('shop', 'car_parts')], + 'repuestos': [('shop', 'car_parts')], + 'taller': [('shop', 'car_repair')], + # Servicios generales + 'supermarket': [('shop', 'supermarket')], + 'supermercado': [('shop', 'supermarket')], + 'pharmacy': [('amenity', 'pharmacy')], + 'farmacia': [('amenity', 'pharmacy')], + 'gas station': [('amenity', 'fuel')], + 'gasolinera': [('amenity', 'fuel')], + 'restaurant': [('amenity', 'restaurant')], + 'restaurante': [('amenity', 'restaurant')], + 'hospital': [('amenity', 'hospital')], + 'bank': [('amenity', 'bank')], + 'banco': [('amenity', 'bank')], +} + +def _osm_tags_for_tipo(tipo): + tl = tipo.lower().strip() + if tl in _OSM_TAGS: + return _OSM_TAGS[tl] + for key, tags in _OSM_TAGS.items(): + if key in tl or tl in key: + return tags + return None + +def _geocode_area(area): + """Retorna (lat, lon) para un área, o None si falla.""" + q = urllib.parse.quote(area) + url = f'https://nominatim.openstreetmap.org/search?q={q}&format=json&limit=1' + req = urllib.request.Request(url, headers={'User-Agent': 'AlvaroAssistant/1.0 alro65@gmail.com'}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + data = json.loads(r.read().decode()) + if data: + return float(data[0]['lat']), float(data[0]['lon']) + except Exception: + pass + return None + +def search_places(tipo, area=None, radio_km=50): + """Busca POIs con Overpass API (OSM) — geocodifica el área y busca por etiquetas reales.""" + zona = area or USER_DEFAULT_LOCATION + coords = _geocode_area(zona) + if not coords: + return [], f'No pude geolocalizar: {zona}' + lat, lon = coords + radius = radio_km * 1000 + tag_pairs = _osm_tags_for_tipo(tipo) + + if tag_pairs: + filters = ''.join( + f'node["{k}"="{v}"](around:{radius},{lat},{lon});' + f'way["{k}"="{v}"](around:{radius},{lat},{lon});' + for k, v in tag_pairs + ) + else: + # Búsqueda por nombre cuando no hay tag conocido + safe = tipo.replace('"', '') + filters = ( + f'node["name"~"{safe}",i](around:{radius},{lat},{lon});' + f'way["name"~"{safe}",i](around:{radius},{lat},{lon});' + ) + + overpass_q = f'[out:json][timeout:20];({filters});out body 15;' + url = 'https://overpass-api.de/api/interpreter' + data = urllib.parse.urlencode({'data': overpass_q}).encode() + req = urllib.request.Request(url, data=data, headers={ + 'User-Agent': 'AlvaroAssistant/1.0 alro65@gmail.com', + 'Content-Type': 'application/x-www-form-urlencoded', + }) + try: + with urllib.request.urlopen(req, timeout=25) as r: + result = json.loads(r.read().decode('utf-8')) + elements = result.get('elements', []) + seen, results = set(), [] + for el in elements: + tags = el.get('tags', {}) + name = tags.get('name', '') + if not name or name in seen: + continue + seen.add(name) + parts = [] + if tags.get('addr:housenumber'): parts.append(tags['addr:housenumber']) + if tags.get('addr:street'): parts.append(tags['addr:street']) + if tags.get('addr:city'): parts.append(tags['addr:city']) + if tags.get('addr:state'): parts.append(tags['addr:state']) + address = ', '.join(parts) if parts else zona + results.append({'name': name, 'address': address, 'rating': None, 'estado': ''}) + return results[:8], None + except Exception as e: + return [], str(e) + + +def search_places_multi(tipos, area=None, radio_km=50): + """Busca varios tipos de lugar con Overpass y combina resultados sin duplicados.""" + zona = area or USER_DEFAULT_LOCATION + all_results, seen = [], set() + for tipo in tipos[:6]: + results, _ = search_places(tipo, zona, radio_km) + if results: + for r in results: + if r['name'] not in seen: + seen.add(r['name']) + r['_cat'] = tipo + all_results.append(r) + time.sleep(1) # respetar rate limit de Overpass + return all_results + + +_INTENT_PROMPT = """\ +Eres el clasificador de intents del asistente personal de Alvaro Romero. +Analiza el mensaje y responde SOLO con un JSON válido. Sin texto adicional. + +INTENTS: +- resumen_correos : ver correos, qué llegó, dame un resumen, correos de X empresa + params: {"filtro": "Chase" | null, "periodo": "24h" | "12h" | "7d"} +- buscar_lugar : encontrar marinas, ferreterías, tiendas, negocios, lugares físicos + params: {"tipos": ["marina","boatyard"], "area": "Miami, FL", "radio_km": 50} + radio_km → "cerca"=20, normal=50, "toda la zona/estado"=150 +- crear_evento : agendar, anotar, crear, apuntar, programar una cita o evento nuevo + params: {"texto": ""} +- modificar_evento: cambiar hora, mover, actualizar o editar un evento existente + params: {"texto": ""} +- borrar_evento : borrar, eliminar, quitar o cancelar un evento existente + params: {"texto": ""} +- ver_agenda : ver agenda de hoy / mañana / próxima semana + params: {"cuando": "hoy" | "manana" | "semana"} +- buscar_craigslist : buscar veleros o herramientas en Craigslist + params: {"categoria": "veleros" | "herramientas"} +- ver_venues_cl : ver marinas/astilleros descubiertos en Craigslist + params: {} +- revisar_correos : revisar correos ahora mismo + params: {} +- ver_estado : estadísticas del día + params: {} +- ver_cvs : listar mis CVs disponibles + params: {} +- responder_correo: redactar respuesta al último correo importante + params: {"instruccion": ""} +- aplicar_trabajo : enviar mi CV a la última oferta de trabajo + params: {} +- conversacion : cualquier otra pregunta, charla, información general + params: {} + +EJEMPLOS: +"dame un resumen de mis correos" → {"intent":"resumen_correos","params":{"filtro":null,"periodo":"24h"}} +"qué llegó anoche" → {"intent":"resumen_correos","params":{"filtro":null,"periodo":"12h"}} +"correos de Chase esta semana" → {"intent":"resumen_correos","params":{"filtro":"Chase","periodo":"7d"}} +"busca marinas cerca de Miami" → {"intent":"buscar_lugar","params":{"tipos":["marina","boatyard","yacht club"],"area":"Miami, FL","radio_km":20}} +"agenda cita médica mañana 3pm" → {"intent":"crear_evento","params":{"texto":"agenda cita médica mañana 3pm"}} +"anota una cita para el domingo a las 10am" → {"intent":"crear_evento","params":{"texto":"anota una cita para el domingo a las 10am"}} +"apunta una reunión el viernes en la tarde" → {"intent":"crear_evento","params":{"texto":"apunta una reunión el viernes en la tarde"}} +"ponme una reunión mañana con Juan a las 3pm" → {"intent":"crear_evento","params":{"texto":"ponme una reunión mañana con Juan a las 3pm"}} +"cambia la cita para medio día" → {"intent":"modificar_evento","params":{"texto":"cambia la cita para medio día"}} +"mueve la reunión al jueves" → {"intent":"modificar_evento","params":{"texto":"mueve la reunión al jueves"}} +"borra ese evento y ponlo al medio día" → {"intent":"modificar_evento","params":{"texto":"borra ese evento y ponlo al medio día"}} +"ese evento muévelo para las 3pm" → {"intent":"modificar_evento","params":{"texto":"ese evento muévelo para las 3pm"}} +"no, cámbialo para el mediodía de mañana" → {"intent":"modificar_evento","params":{"texto":"no, cámbialo para el mediodía de mañana"}} +"quita esa cita y ponla mañana a las 2" → {"intent":"modificar_evento","params":{"texto":"quita esa cita y ponla mañana a las 2"}} +"cambia ese evento para mañana al medio día" → {"intent":"modificar_evento","params":{"texto":"cambia ese evento para mañana al medio día"}} +"borra ese evento" → {"intent":"borrar_evento","params":{"texto":"borra ese evento"}} +"elimina esa cita" → {"intent":"borrar_evento","params":{"texto":"elimina esa cita"}} +"cancela el evento de mañana" → {"intent":"borrar_evento","params":{"texto":"cancela el evento de mañana"}} +"qué tengo hoy" → {"intent":"ver_agenda","params":{"cuando":"hoy"}} +"veleros" → {"intent":"buscar_craigslist","params":{"categoria":"veleros"}} +"cómo estás" → {"intent":"conversacion","params":{}} +""" + + +def _do_lugar(params, tl): + tipos_lugar = params.get('tipos', []) + area_lugar = params.get('area', USER_DEFAULT_LOCATION) + radio_km = int(params.get('radio_km', 50)) + if not tipos_lugar: + tipos_lugar = ['place'] + + cats_str = ', '.join(tipos_lugar) + send_telegram(f'Buscando: {cats_str}\nZona: {area_lugar} · {radio_km} km...') + lugares = search_places_multi(tipos_lugar, area_lugar, radio_km) + + _BOAT_TERMS = {'boat','sailboat','velero','bote','yate','yacht','marina', + 'boatyard','shipyard','marine','vessel'} + if any(t in ' '.join(tipos_lugar).lower() for t in _BOAT_TERMS) or \ + any(t in tl for t in ['bote','velero','yate','barco','embarcacion']): + cl_venues = load_state().get('boat_venues', []) + if cl_venues: + lugares = [{'name': v['name'], 'address': v['city'], + '_cat': 'CL ★', '_link': v['link']} + for v in cl_venues] + lugares + + if not lugares: + send_telegram(f'No encontré resultados para "{cats_str}" en {area_lugar}.') + return + + header = f'{area_lugar} — {len(lugares)} lugares\n' + lineas = [header] + for i, p in enumerate(lugares, 1): + cat_tag = f' ({p["_cat"]})' if p.get('_cat') else '' + link_tag = f'\n Ver en CL' if p.get('_link') else '' + lineas.append(f'{i}. {p["name"]}{cat_tag}\n {p["address"]}{link_tag}') + msg = '\n'.join(lineas) + if len(msg) > 3800: + mid = len(lineas) // 2 + send_telegram('\n'.join(lineas[:mid])) + send_telegram('\n'.join(lineas[mid:])) + else: + send_telegram(msg) + + +def handle_user_message(text): + global last_important_email, last_job_email + tl = text.lower().strip() + + # ── Prioridad 1: correo dudoso pendiente de decisión ────────────────────── + with pending_lock: + if pending_decisions: + mid = next(iter(pending_decisions)) + info = pending_decisions[mid] + if any(w in tl for w in ['borrar','borra','eliminar','basura','spam','no','delete']): + try: + info['service'].users().messages().trash( + userId='me', id=info['gmail_msg_id']).execute() + send_telegram(f'Correo de {info["sender"][:60]} eliminado.') + except Exception as e: + send_telegram(f'Error: {e}') + del pending_decisions[mid] + return + elif any(w in tl for w in ['dejar','deja','guardar','si','ok','real','legitimo','keep']): + send_telegram(f'Listo, dejo el correo de {info["sender"][:60]}.') + del pending_decisions[mid] + return + else: + respuesta = ask_ollama( + f'Alvaro dijo: "{text}"\n' + f'Correo dudoso — De: {info["sender"]}, Asunto: {info["subject"]}\n' + f'¿Lo dejamos o lo borramos? Responde brevemente en español.', + temperature=0.2 + ) + send_telegram(respuesta) + if any(w in respuesta.lower() for w in ['borro','elimino','descarto']): + try: + info['service'].users().messages().trash( + userId='me', id=info['gmail_msg_id']).execute() + except Exception: + pass + del pending_decisions[mid] + elif any(w in respuesta.lower() for w in ['dejo','guardo','mantengo']): + del pending_decisions[mid] + return + + # ── Prioridad 2: borrador pendiente de envío ────────────────────────────── + with pending_lock: + if pending_replies: + if any(w in tl for w in ['enviar','envia','manda','confirmo','si','ok']): + mid = next(iter(pending_replies)) + pr = pending_replies[mid] + svc = gmail_services.get(pr['account']) + cv_path = os.path.join(CV_DIRECTORY, pr['cv_name']) if pr.get('cv_name') else None + if svc: + ok = send_gmail(svc, pr['to'], pr['subject'], pr['body'], cv_path) + send_telegram(f'Correo enviado a {pr["to"]}.' if ok else 'Error al enviar.') + del pending_replies[mid] + return + elif any(w in tl for w in ['cancelar','cancela','no']): + mid = next(iter(pending_replies)) + send_telegram('Cancelado.') + del pending_replies[mid] + return + + # ── Detección de intent via Ollama (con historial de conversación) ─────── + recent_ctx = '' + if conversation_history: + turns = conversation_history[-6:] + recent_ctx = 'Conversación reciente:\n' + '\n'.join( + f'{"Alvaro" if m["role"] == "user" else "Asistente"}: {m["content"][:300]}' + for m in turns + ) + '\n\n' + + raw = ask_ollama( + _INTENT_PROMPT + f'\n{recent_ctx}Mensaje del usuario: "{text}"\n\nJSON:', + temperature=0.0, model=OLLAMA_MODEL + ).strip() + + intent, params = 'conversacion', {} + m_j = re.search(r'\{.*\}', raw, re.DOTALL) + if m_j: + try: + parsed = json.loads(m_j.group()) + intent = parsed.get('intent', 'conversacion') + params = parsed.get('params', {}) + except Exception: + pass + + print(f' [Intent] {intent} | {params}') + + # ── Router ──────────────────────────────────────────────────────────────── + + if intent == 'revisar_correos': + send_telegram('Revisando correos ahora...') + state = load_state() + for account_name, email in GMAIL_ACCOUNTS: + try: + process_account(account_name, email, state) + except Exception as e: + send_telegram(f'Error en {email}: {e}') + save_state(state) + + elif intent == 'ver_estado': + state = load_state() + stats = state.get('daily_stats', {}) + cl_hace = int((time.time() - state.get('cl_last_check', 0)) / 60) + send_telegram( + f'Hoy — Importantes: {stats.get("importantes",0)} | ' + f'Dudosos: {stats.get("dudosos",0)} | Spam: {stats.get("basura",0)}\n' + f'Último scan CL: hace {cl_hace} min' + ) + + elif intent == 'ver_cvs': + cvs = list_cvs() + send_telegram('CVs en D:\\CVs:\n' + '\n'.join(f'- {cv}' for cv in cvs) + if cvs else 'No encontré CVs en D:\\CVs') + + elif intent == 'ver_agenda': + cuando = params.get('cuando', 'hoy') + now = datetime.now() + if cuando == 'manana': + s = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0) + e = (now + timedelta(days=1)).replace(hour=23, minute=59, second=59) + titulo = 'Agenda de mañana' + elif cuando == 'semana': + s, e = now, now + timedelta(days=7) + titulo = 'Próximos 7 días' + else: + s = now.replace(hour=0, minute=0, second=0) + e = now.replace(hour=23, minute=59, second=59) + titulo = 'Agenda de hoy' + events = cal_get_events(s, e) + show_date = (cuando in ['manana', 'semana']) + msg = (f'{titulo}:\n' + '\n'.join(cal_format_event(ev, show_date) for ev in events) + if events else f'No tienes eventos ({cuando}).') + send_telegram(msg) + + elif intent == 'crear_evento': + event, err = cal_create_event_from_text(params.get('texto', text)) + if err: + send_telegram(f'No pude crear el evento: {err}') + else: + start = cal_parse_dt(event.get('start', {})) + hora = start.strftime('%A %d %b a las %I:%M %p') if start else '' + send_telegram(f'Evento creado:\n{event.get("summary")}\n{hora}\nRecordatorios: 2h, 1h y 30min antes.') + + elif intent == 'modificar_evento': + event, err = cal_modify_event_from_text(params.get('texto', text)) + if err: + send_telegram(f'No pude modificar el evento: {err}') + else: + start = cal_parse_dt(event.get('start', {})) + hora = start.strftime('%A %d %b a las %I:%M %p') if start else '' + send_telegram(f'Listo, evento actualizado:\n{event.get("summary")}\n{hora}') + + elif intent == 'borrar_evento': + nombre, err = cal_delete_event_from_text(params.get('texto', text)) + if err: + send_telegram(f'No pude borrar el evento: {err}') + else: + send_telegram(f'Evento eliminado: {nombre}') + + elif intent == 'buscar_craigslist': + cat = params.get('categoria', 'veleros') + send_telegram(f'Buscando {cat} en Craigslist...') + state = load_state() + run_cl_scrape(state, watch_id_filter=cat, notify_empty=True) + save_state(state) + + elif intent == 'ver_venues_cl': + state = load_state() + venues = state.get('boat_venues', []) + if not venues: + send_telegram('Aún no tengo venues detectados.\nUsa veleros para escanear Craigslist.') + else: + lineas = [f'Lugares detectados en CL ({len(venues)}):\n'] + for v in venues: + lineas.append(f'• {v["name"]} — {v["city"]}\n' + f' Ver listing') + msg = '\n'.join(lineas) + if len(msg) > 3800: + mid = len(lineas) // 2 + send_telegram('\n'.join(lineas[:mid])) + send_telegram('\n'.join(lineas[mid:])) + else: + send_telegram(msg) + + elif intent == 'buscar_lugar': + _do_lugar(params, tl) + + elif intent == 'responder_correo': + if not last_important_email: + send_telegram('No recuerdo el último correo importante.') + return + e = last_important_email + ins = params.get('instruccion', '') or text + draft = ask_ollama( + f'Redacta respuesta al correo:\nDe: {e["sender"]}\nAsunto: {e["subject"]}\n' + f'Cuerpo: {e["body"][:1500]}\nInstrucción: "{ins}"\n' + f'Mismo idioma del correo. Firma: Alvaro Romero. Solo el texto.' + ) + m_addr = re.search(r'<(.+?)>', e['sender']) + to_addr = m_addr.group(1) if m_addr else e['sender'] + tid = send_telegram(f'Borrador para {to_addr}:\n\n{draft[:800]}\n\nDi enviar o cancelar.') + if tid: + with pending_lock: + pending_replies[tid] = {'account': e['account'], 'to': to_addr, + 'subject': 'Re: ' + e['subject'], + 'body': draft, 'cv_name': None} + + elif intent == 'aplicar_trabajo': + if not last_job_email: + send_telegram('No recuerdo ninguna oferta reciente.') + return + cvs = list_cvs() + if not cvs: + send_telegram('No encontré CVs en D:\\CVs.') + return + e = last_job_email + cv_name = pick_best_cv(e['subject'] + ' ' + e['body'][:500]) + draft = ask_ollama( + f'Email de aplicación para:\nEmpresa: {e["sender"]}\nOferta: {e["subject"]}\n' + f'{e["body"][:1000]}\nProfesional, breve. Adjuntas CV. Firma: Alvaro Romero.' + ) + m_addr = re.search(r'<(.+?)>', e['sender']) + to_addr = m_addr.group(1) if m_addr else e['sender'] + tid = send_telegram( + f'Aplicación: {e["subject"][:80]}\nA: {to_addr} | CV: {cv_name}\n\n' + f'{draft[:600]}\n\nDi enviar o cancelar.') + if tid: + with pending_lock: + pending_replies[tid] = {'account': e['account'], 'to': to_addr, + 'subject': 'Application - ' + e['subject'], + 'body': draft, 'cv_name': cv_name} + + elif intent == 'resumen_correos': + filtro = params.get('filtro') + periodo = params.get('periodo', '24h') + tiempo = f'newer_than:{periodo}' + query = f'{filtro} {tiempo}' if filtro else tiempo + label = f'"{filtro}"' if filtro else 'correos recientes' + + send_telegram(f'Buscando {label} ({periodo})...') + correos = search_gmail(query, max_results=8) + if not correos: + send_telegram(f'No hay {label} en las últimas {periodo}.') + return + + now = datetime.now() + eventos_hoy = cal_get_events(now, now.replace(hour=23, minute=59)) + agenda_str = ('Agenda hoy: ' + ' | '.join(ev.get('summary','') for ev in eventos_hoy) + if eventos_hoy else '') + detalle = '\n\n'.join( + f"CORREO {i+1}:\nDe: {c['de']}\nAsunto: {c['asunto']}\nContenido: {c['cuerpo'][:500]}" + for i, c in enumerate(correos) + ) + resumen = ask_ollama( + f'Eres el asistente de Alvaro Romero. Analiza estos correos.\n' + f'PERFIL: Trabajo marítimo activo. Uber/Lyft. Bancos: Chase, WF, Citi, Discover, SCCU. ' + f'Pagos: PayPal, Stripe, Global66. Tiendas: Amazon, Temu, Walmart. ' + f'Inversiones: IBKR, OANDA, Alpaca. Colombia: Colsanitas, Tigo.\n' + f'{agenda_str}\n\nCORREOS:\n{detalle}\n\n' + f'Para cada correo importante: qué es, por qué importa, acción recomendada, urgencia. ' + f'Responde en español, directo.', + temperature=0.3 + ) + send_telegram(resumen) + + else: + # conversacion — usa historial de chat + state_ctx = load_state() + stats_ctx = state_ctx.get('daily_stats', {}) + ctx_lines = [ + f"Procesados hoy — Importantes: {stats_ctx.get('importantes',0)}, " + f"Dudosos: {stats_ctx.get('dudosos',0)}, Spam: {stats_ctx.get('basura',0)}" + ] + if last_important_email: + ctx_lines.append(f"Último correo: de {last_important_email['sender'][:60]}, " + f"asunto: {last_important_email['subject'][:80]}") + if last_job_email: + ctx_lines.append(f"Última oferta: {last_job_email['subject'][:80]}") + now_ctx = datetime.now() + eventos_ctx = cal_get_events(now_ctx, now_ctx.replace(hour=23, minute=59)) + if eventos_ctx: + ctx_lines.append("Agenda hoy: " + " | ".join( + e.get('summary','') for e in eventos_ctx[:3])) + + _WEB_TRIGGERS = ['qué es','que es','cómo','como','cuánto','cuanto', + 'precio','noticias','clima','weather'] + hits = web_search(text) if any(w in tl for w in _WEB_TRIGGERS) else [] + web_ctx = ('Información web:\n' + '\n'.join(hits) + '\n\n') if hits else '' + + system_prompt = ( + 'Eres el asistente personal de Alvaro Romero. Español, directo y conversacional.\n\n' + 'CAPACIDADES: Gmail, Google Calendar, Craigslist (veleros/herramientas Florida), ' + 'buscar negocios/lugares, redactar y enviar correos, aplicar a trabajos.\n' + 'REGLA: NUNCA digas "busca en Google/Maps". TÚ lo haces.\n\n' + 'CONTEXTO:\n' + '\n'.join(ctx_lines) + ) + send_telegram(ask_ollama_chat(web_ctx + text, system_prompt=system_prompt)) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ESTADO +# ══════════════════════════════════════════════════════════════════════════════ + +def load_state(): + if os.path.exists(STATE_FILE): + with open(STATE_FILE, 'r') as f: + return json.load(f) + return { + 'processed': [], + 'daily_stats': {'importantes': 0, 'basura': 0, 'dudosos': 0}, + 'recent_importantes': [], + 'cl_seen': [], 'cl_last_check': 0, + 'reminders_sent': {}, + } + + +def save_state(state): + state['processed'] = state['processed'][-2000:] + with open(STATE_FILE, 'w') as f: + json.dump(state, f) + + +# ══════════════════════════════════════════════════════════════════════════════ +# PROCESAR CORREOS +# ══════════════════════════════════════════════════════════════════════════════ + +def process_account(account_name, email, state): + global last_important_email, last_job_email + print(f'\n[{email}] Revisando...') + service = gmail_services.get(account_name) + if not service: + return + try: + result = service.users().messages().list( + userId='me', q='is:unread in:inbox', maxResults=30).execute() + except HttpError as e: + print(f' Error: {e}') + return + + messages = result.get('messages', []) + nuevos = [m for m in messages if m['id'] not in state['processed']] + if not nuevos: + print(' Sin correos nuevos.') + return + + print(f' {len(nuevos)} correos nuevos...') + daily = state.setdefault('daily_stats', {'importantes': 0, 'basura': 0, 'dudosos': 0}) + recents = state.setdefault('recent_importantes', []) + + for msg_ref in nuevos: + state['processed'].append(msg_ref['id']) + try: + msg = service.users().messages().get( + userId='me', id=msg_ref['id'], format='full').execute() + headers = msg.get('payload', {}).get('headers', []) + sender = get_header(headers, 'From') + subject = get_header(headers, 'Subject') or '(sin asunto)' + body = extract_body(msg.get('payload', {})) + + print(f' Analizando: {subject[:55]}') + decision, razon, resumen, senales = classify_email(sender, subject, body, email) + + label = detect_label(sender, subject) + if label: + assign_label(service, account_name, msg_ref['id'], label) + + es_trabajo = is_job_email(sender, subject) + + if decision == 'IMPORTANTE': + daily['importantes'] = daily.get('importantes', 0) + 1 + print(f' -> IMPORTANTE: {razon}') + last_important_email = {'account': account_name, 'sender': sender, + 'subject': subject, 'body': body, + 'msg_id': msg_ref['id']} + if es_trabajo: + last_job_email = last_important_email + recents.append({'sender': sender[:40], 'subject': subject[:60]}) + alerta = (f'Correo importante\n' + f'Cuenta: {email}\n' + f'De: {sender[:80]}\n' + f'Asunto: {subject[:100]}\n' + f'Resumen: {resumen[:200]}\n' + f'Por que: {razon[:150]}\n\n') + alerta += ('Di aplica para enviar tu CV.' + if es_trabajo else + 'Di responder si quieres que redacte una respuesta.') + send_telegram(alerta) + + elif decision == 'DUDOSO': + daily['dudosos'] = daily.get('dudosos', 0) + 1 + print(f' -> DUDOSO: {razon}') + alerta = (f'Alvaro, llego un correo que no puedo determinar si es real o scam\n\n' + f'De: {sender[:80]}\n' + f'Asunto: {subject[:100]}\n' + f'Resumen: {resumen[:200]}\n' + f'Mis dudas: {razon}') + if senales: + alerta += f'\nSenales: {senales[:200]}' + alerta += '\n\nDi "borrar", "dejar", o explicame que es.' + tid = send_telegram(alerta) + if tid: + with pending_lock: + pending_decisions[tid] = { + 'service': service, 'gmail_msg_id': msg_ref['id'], + 'sender': sender, 'subject': subject, + 'resumen': resumen, 'email': email} + + else: + daily['basura'] = daily.get('basura', 0) + 1 + print(f' -> BASURA: {razon}') + # Archivar (quitar de inbox) sin borrar permanentemente + service.users().messages().modify( + userId='me', id=msg_ref['id'], + body={'removeLabelIds': ['INBOX']} + ).execute() + + except Exception as e: + print(f' Error: {e}') + time.sleep(0.5) + + +# ══════════════════════════════════════════════════════════════════════════════ +# HILO DEDICADO DE RECORDATORIOS (independiente del ciclo de correo) +# ══════════════════════════════════════════════════════════════════════════════ + +def calendar_reminder_loop(): + """Revisa recordatorios cada CALENDAR_CHECK_INTERVAL seg sin esperar el ciclo de correo.""" + global _cal_reminders_sent + print(f'[Reminders] Hilo activo — revisando cada {CALENDAR_CHECK_INTERVAL//60} min.') + while True: + try: + if calendar_service: + now = datetime.now() + today_prefix = now.strftime('%Y%m%d') + with _cal_reminders_lock: + _cal_reminders_sent = {k for k in _cal_reminders_sent + if k.startswith(today_prefix)} + events = cal_get_events(now, now + timedelta(minutes=125)) + for event in events: + start = cal_parse_dt(event.get('start', {})) + if not start or 'date' in event.get('start', {}): + continue + eid = event.get('id', '') + title = event.get('summary', 'Evento') + delta = int((start - now).total_seconds() / 60) + for mins in REMINDER_MINUTES: + key = f"{today_prefix}_{eid}_{mins}" + with _cal_reminders_lock: + already = key in _cal_reminders_sent + if already: + continue + if abs(delta - mins) <= 3: + with _cal_reminders_lock: + _cal_reminders_sent.add(key) + hora = start.strftime('%I:%M %p') + msg = (f'\U0001F514\U0001F514\U0001F514 RECORDATORIO\n\n' + f'{title}\n' + f'En {mins} minutos — {hora}') + if event.get('location'): + msg += f'\nLugar: {event["location"]}' + if event.get('description'): + msg += f'\n{event["description"][:100]}' + send_telegram(msg, urgent=True) + print(f' [Reminder] {mins}min antes: {title}') + except Exception as e: + print(f'[Reminders error] {e}') + time.sleep(CALENDAR_CHECK_INTERVAL) + + +# ══════════════════════════════════════════════════════════════════════════════ +# MAIN +# ══════════════════════════════════════════════════════════════════════════════ + +def main(): + global gmail_services, calendar_service + + print('=' * 55) + print(' Asistente Personal de Alvaro v3') + print(' Gmail + Calendar + Craigslist + Telegram') + print('=' * 55) + + # Gmail + for account_name, email in GMAIL_ACCOUNTS: + print(f'Autenticando Gmail {email}...') + gmail_services[account_name] = authenticate_gmail(account_name) + + # Calendar + print('Autenticando Google Calendar...') + try: + calendar_service = authenticate_calendar() + print(' Calendar OK') + except Exception as e: + print(f' Calendar error: {e} (continuando sin calendario)') + + send_telegram( + 'Asistente Personal v3 activo\n\n' + 'Email: revisar | estado | pendientes | cvs\n' + 'Respuestas: responder | aplica | enviar | cancelar\n' + 'Agenda: agenda | mañana | semana | agendar [evento]\n' + 'Craigslist: veleros | herramientas\n' + '(o escribe lo que quieras)' + ) + + threading.Thread(target=telegram_listener, daemon=True).start() + threading.Thread(target=calendar_reminder_loop, daemon=True).start() + + mins_email = EMAIL_CHECK_INTERVAL // 60 + cycle = 0 + while True: + cycle += 1 + print(f'\n[{datetime.now().strftime("%H:%M:%S")}] Ciclo #{cycle}') + state = load_state() + + check_morning_summary(state) + check_evening_summary(state) + check_cl_watches(state) + + for account_name, email in GMAIL_ACCOUNTS: + try: + process_account(account_name, email, state) + except Exception as e: + print(f' Error en {email}: {e}') + + save_state(state) + print(f' Esperando {mins_email} minutos...') + time.sleep(EMAIL_CHECK_INTERVAL) + + +def run_once(): + """Corre una sola vez y sale. Ideal para Task Scheduler de Windows.""" + global gmail_services, calendar_service + + print('=' * 55) + print(' Asistente Personal — Ciclo unico') + print('=' * 55) + + for account_name, email in GMAIL_ACCOUNTS: + print(f'Autenticando Gmail {email}...') + gmail_services[account_name] = authenticate_gmail(account_name) + + print('Autenticando Calendar...') + try: + calendar_service = authenticate_calendar() + except Exception as e: + print(f' Calendar no disponible: {e}') + + state = load_state() + + check_morning_summary(state) + check_calendar_reminders(state) + check_cl_watches(state) + + for account_name, email in GMAIL_ACCOUNTS: + try: + process_account(account_name, email, state) + except Exception as e: + print(f' Error en {email}: {e}') + + save_state(state) + print('Ciclo completo. Saliendo.') + + +if __name__ == '__main__': + import sys as _sys + if '--once' in _sys.argv: + # Modo Task Scheduler: corre y sale + try: + run_once() + except Exception as e: + print(f'Error: {e}') + else: + # Modo continuo con listener Telegram + try: + main() + except KeyboardInterrupt: + print('\nAsistente detenido.') + send_telegram('Asistente detenido.') diff --git a/email_manager.py b/email_manager.py new file mode 100644 index 0000000..49471f1 --- /dev/null +++ b/email_manager.py @@ -0,0 +1,342 @@ +""" +EmailManager — Gmail cleanup & organization +Accounts: alro65@gmail.com, alro65usa@gmail.com +""" + +import os +import sys +import base64 +import re +import json +import pickle +import time +import urllib.request + +sys.stdout.reconfigure(encoding='utf-8') +from datetime import datetime +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +SCOPES = [ + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', +] + +CREDENTIALS_FILE = os.path.join(os.path.dirname(__file__), 'credentials.json') + +# ── Label structure ──────────────────────────────────────────────────────────── +LABELS_TO_CREATE = [ + "Bancos/Extractos", + "Bancos/Promo", + "Trabajo", + "AutoBooking", + "Recibos", + "Newsletters", +] + +# ── Bank senders (promo goes to Bancos/Promo, statements to Bancos/Extractos) ─ +BANK_DOMAINS = [ + 'bancolombia', 'davivienda', 'bbva', 'scotiabank', 'citibank', + 'hsbc', 'santander', 'chase', 'wellsfargo', 'bankofamerica', + 'capitalone', 'discover', 'americanexpress', 'amex', 'nequi', + 'daviplata', 'bold', 'bancodeoccidente', 'bancopopular', + 'coopcentral', 'ing.', 'paypal', 'stripe', +] + +BANK_STATEMENT_KEYWORDS = [ + 'estado de cuenta', 'extracto', 'resumen de cuenta', 'account statement', + 'transaction alert', 'alerta de transacción', 'compra realizada', + 'pago recibido', 'transferencia', 'your statement', +] + +# ── Spam/promo keywords in subject or sender ────────────────────────────────── +SPAM_KEYWORDS = [ + 'insurance quote', 'cotización de seguro', 'auto insurance', + 'car insurance', 'life insurance', 'health insurance', + 'get a quote', 'free quote', 'save on insurance', + 'compare rates', 'lowest rates', 'best rates', + 'final notice', 'last chance', 'act now', 'limited time', + 'you\'ve been selected', 'congratulations you won', + 'unclaimed reward', 'claim your prize', +] + +PROMO_CATEGORIES = ['CATEGORY_PROMOTIONS', 'CATEGORY_UPDATES'] + + +def authenticate(account_name: str) -> object: + token_file = os.path.join(os.path.dirname(__file__), f'token_{account_name}.pickle') + creds = None + + if os.path.exists(token_file): + with open(token_file, 'rb') as f: + creds = pickle.load(f) + + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_FILE, SCOPES) + creds = flow.run_local_server(port=0) + with open(token_file, 'wb') as f: + pickle.dump(creds, f) + + return build('gmail', 'v1', credentials=creds) + + +def get_or_create_label(service, name: str, label_cache: dict) -> str: + if name in label_cache: + return label_cache[name] + + labels = service.users().labels().list(userId='me').execute().get('labels', []) + for lbl in labels: + if lbl['name'].lower() == name.lower(): + label_cache[name] = lbl['id'] + return lbl['id'] + + # Create nested labels parent-first + parts = name.split('/') + for i in range(1, len(parts) + 1): + partial = '/'.join(parts[:i]) + if partial not in label_cache: + exists = next((l for l in labels if l['name'].lower() == partial.lower()), None) + if exists: + label_cache[partial] = exists['id'] + else: + body = { + 'name': partial, + 'labelListVisibility': 'labelShow', + 'messageListVisibility': 'show', + } + new = service.users().labels().create(userId='me', body=body).execute() + label_cache[partial] = new['id'] + print(f" [+] Label creado: {partial}") + + return label_cache[name] + + +def get_header(headers: list, name: str) -> str: + for h in headers: + if h['name'].lower() == name.lower(): + return h['value'] + return '' + + +def extract_unsubscribe_url(headers: list) -> str | None: + raw = get_header(headers, 'List-Unsubscribe') + if not raw: + return None + # Prefer HTTPS link over mailto + urls = re.findall(r'<(https?://[^>]+)>', raw) + return urls[0] if urls else None + + +def try_unsubscribe(url: str) -> bool: + # Guard: only follow HTTPS unsubscribe links to avoid HTTP downgrade / SSRF + if not url.startswith('https://'): + return False + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + urllib.request.urlopen(req, timeout=10) + return True + except Exception: + return False + + +def is_bank_email(sender: str, subject: str) -> tuple[bool, bool]: + """Returns (is_bank, is_statement).""" + combined = (sender + ' ' + subject).lower() + if not any(b in combined for b in BANK_DOMAINS): + return False, False + is_statement = any(k in combined for k in BANK_STATEMENT_KEYWORDS) + return True, is_statement + + +def is_spam(sender: str, subject: str) -> bool: + combined = (sender + ' ' + subject).lower() + return any(k in combined for k in SPAM_KEYWORDS) + + +def process_account(account_name: str): + print(f"\n{'='*60}") + print(f" Procesando: {account_name}") + print(f"{'='*60}") + + service = authenticate(account_name) + label_cache = {} + + # Ensure all labels exist + print("\n[1] Creando estructura de labels...") + for lbl in LABELS_TO_CREATE: + get_or_create_label(service, lbl, label_cache) + + stats = { + 'unsubscribed': 0, + 'deleted_spam': 0, + 'archived_bank_promo': 0, + 'archived_bank_statement': 0, + 'errors': 0, + } + + # ── Pass 1: Promotions category ──────────────────────────────────────────── + print("\n[2] Analizando correos promocionales...") + query = 'category:promotions OR category:updates' + page_token = None + + while True: + try: + kwargs = {'userId': 'me', 'q': query, 'maxResults': 500} + if page_token: + kwargs['pageToken'] = page_token + result = service.users().messages().list(**kwargs).execute() + except HttpError as e: + print(f" Error listando mensajes: {e}") + break + + messages = result.get('messages', []) + if not messages: + break + + print(f" Encontrados {len(messages)} mensajes en esta página...") + + for msg_ref in messages: + try: + msg = service.users().messages().get( + userId='me', id=msg_ref['id'], + format='metadata', + metadataHeaders=['From', 'Subject', 'List-Unsubscribe'] + ).execute() + + headers = msg.get('payload', {}).get('headers', []) + sender = get_header(headers, 'From') + subject = get_header(headers, 'Subject') + labels = msg.get('labelIds', []) + + is_bank, is_statement = is_bank_email(sender, subject) + + if is_bank: + if is_statement: + lbl_id = get_or_create_label(service, 'Bancos/Extractos', label_cache) + action = 'archive_statement' + else: + lbl_id = get_or_create_label(service, 'Bancos/Promo', label_cache) + action = 'archive_bank_promo' + + service.users().messages().modify( + userId='me', id=msg_ref['id'], + body={'addLabelIds': [lbl_id], 'removeLabelIds': ['INBOX']} + ).execute() + + if action == 'archive_statement': + stats['archived_bank_statement'] += 1 + else: + stats['archived_bank_promo'] += 1 + + else: + # Non-bank promo → unsubscribe + delete + unsub_url = extract_unsubscribe_url(headers) + if unsub_url: + ok = try_unsubscribe(unsub_url) + if ok: + stats['unsubscribed'] += 1 + print(f" [OK] Unsubscribe: {sender[:60]}") + + service.users().messages().trash(userId='me', id=msg_ref['id']).execute() + stats['deleted_spam'] += 1 + + except HttpError as e: + stats['errors'] += 1 + if e.resp.status == 429: + print(" Rate limit — esperando 5s...") + time.sleep(5) + + page_token = result.get('nextPageToken') + if not page_token: + break + + # ── Pass 2: Explicit spam keywords in inbox ──────────────────────────────── + print("\n[3] Buscando spam por keywords en inbox...") + spam_query = ' OR '.join(f'subject:"{k}"' for k in SPAM_KEYWORDS[:8]) # Gmail query limit + + try: + result = service.users().messages().list( + userId='me', q=f'in:inbox ({spam_query})', maxResults=500 + ).execute() + + messages = result.get('messages', []) + print(f" Encontrados {len(messages)} mensajes spam por keyword...") + + for msg_ref in messages: + try: + msg = service.users().messages().get( + userId='me', id=msg_ref['id'], + format='metadata', + metadataHeaders=['From', 'Subject', 'List-Unsubscribe'] + ).execute() + + headers = msg.get('payload', {}).get('headers', []) + sender = get_header(headers, 'From') + subject = get_header(headers, 'Subject') + + unsub_url = extract_unsubscribe_url(headers) + if unsub_url: + ok = try_unsubscribe(unsub_url) + if ok: + stats['unsubscribed'] += 1 + + service.users().messages().trash(userId='me', id=msg_ref['id']).execute() + stats['deleted_spam'] += 1 + + except HttpError: + stats['errors'] += 1 + + except HttpError as e: + print(f" Error en búsqueda spam: {e}") + + # ── Summary ──────────────────────────────────────────────────────────────── + print(f"\n{'─'*40}") + print(f" RESUMEN — {account_name}") + print(f" Unsubscribes realizados : {stats['unsubscribed']}") + print(f" Correos eliminados : {stats['deleted_spam']}") + print(f" Bancos/Promo : {stats['archived_bank_promo']}") + print(f" Bancos/Extractos : {stats['archived_bank_statement']}") + print(f" Errores : {stats['errors']}") + print(f"{'─'*40}") + + return stats + + +def main(): + accounts = ['alro65', 'alro65usa'] # token files: token_alro65.pickle, token_alro65usa.pickle + + print("=" * 40) + print(" EmailManager -- Limpieza Gmail") + print("=" * 40) + print("\nSe procesaran:") + print(" - alro65@gmail.com") + print(" - alro65usa@gmail.com") + print("\nPara cada cuenta se abrirá el navegador para autenticación OAuth.") + print("Usa ventana incógnito si es necesario.\n") + + input("Presiona ENTER para comenzar...") + + total_deleted = 0 + total_unsub = 0 + + for acc in accounts: + stats = process_account(acc) + total_deleted += stats['deleted_spam'] + total_unsub += stats['unsubscribed'] + + print(f"\n{'='*40}") + print(f" TOTAL GENERAL") + print(f" Unsubscribes : {total_unsub}") + print(f" Eliminados : {total_deleted}") + print(f"{'='*40}") + print("\nListo. Vacía la papelera en Gmail para liberar espacio.") + + +if __name__ == '__main__': + main() diff --git a/recordatorio_jaime.py b/recordatorio_jaime.py new file mode 100644 index 0000000..123d6fe --- /dev/null +++ b/recordatorio_jaime.py @@ -0,0 +1,20 @@ +import urllib.request, urllib.parse, json, os + +TELEGRAM_TOKEN = os.environ.get('TELEGRAM_TOKEN', '') +TELEGRAM_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '') + +msg = ( + '\U0001F514 RECORDATORIO\n\n' + 'Esperar llamada de Jaime Otero\n' + 'Hora: 12:00 PM' +) + +params = { + 'chat_id': TELEGRAM_CHAT_ID, + 'text': msg, + 'parse_mode': 'HTML', + 'disable_notification': 'false', +} +data = urllib.parse.urlencode(params).encode() +url = f'https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage' +urllib.request.urlopen(urllib.request.Request(url, data=data), timeout=10)