""" 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.')