Files
EmailManager/email_assistant.py

1806 lines
79 KiB
Python

"""
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"[^>]*>(.*?)</a>', html, re.DOTALL)
snippets = re.findall(r'class="result__snippet">(.*?)</a>', 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'<b>{title}</b>'
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'<b>{title}</b>\n'
f'En <b>{mins} minutos</b> — {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'<b>{watch["nombre"]} nuevos en Craigslist</b> ({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'<b>{item["title"][:80]}</b>\n'
f'Precio: {precio_str} | {item["city"]}\n\n'
f'{analisis[:600]}\n\n'
f'<a href="{item["link"]}">Ver en Craigslist</a>'
)
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'- <b>{item["sender"][:40]}</b>: {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 += '\n<b>Agenda de hoy:</b>\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 = '🌙 <b>Buenas noches Alvaro</b>\n\n'
if events_tom:
msg += '<b>Mañana tienes:</b>\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 += '\n<b>Próxima semana:</b>\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": "<texto original>"}
- modificar_evento: cambiar hora, mover, actualizar o editar un evento existente
params: {"texto": "<texto original>"}
- borrar_evento : borrar, eliminar, quitar o cancelar un evento existente
params: {"texto": "<texto original>"}
- 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": "<instrucción adicional o vacío>"}
- 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: <i>{cats_str}</i>\nZona: <i>{area_lugar}</i> · {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'<b>{area_lugar}</b> — {len(lugares)} lugares\n'
lineas = [header]
for i, p in enumerate(lugares, 1):
cat_tag = f' <i>({p["_cat"]})</i>' if p.get('_cat') else ''
link_tag = f'\n <a href="{p["_link"]}">Ver en CL</a>' if p.get('_link') else ''
lineas.append(f'<b>{i}. {p["name"]}</b>{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 <b>{info["sender"][:60]}</b> 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 <b>{info["sender"][:60]}</b>.')
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 <b>{pr["to"]}</b>.' 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'<b>{titulo}:</b>\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<b>{event.get("summary")}</b>\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<b>{event.get("summary")}</b>\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: <b>{nombre}</b>')
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 <b>veleros</b> para escanear Craigslist.')
else:
lineas = [f'<b>Lugares detectados en CL ({len(venues)}):</b>\n']
for v in venues:
lineas.append(f'• <b>{v["name"]}</b> — {v["city"]}\n'
f' <a href="{v["link"]}">Ver listing</a>')
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 <b>{to_addr}</b>:\n\n<i>{draft[:800]}</i>\n\nDi <b>enviar</b> o <b>cancelar</b>.')
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: <b>{e["subject"][:80]}</b>\nA: {to_addr} | CV: <b>{cv_name}</b>\n\n'
f'<i>{draft[:600]}</i>\n\nDi <b>enviar</b> o <b>cancelar</b>.')
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'<b>Correo importante</b>\n'
f'<b>Cuenta:</b> {email}\n'
f'<b>De:</b> {sender[:80]}\n'
f'<b>Asunto:</b> {subject[:100]}\n'
f'<b>Resumen:</b> {resumen[:200]}\n'
f'<b>Por que:</b> {razon[:150]}\n\n')
alerta += ('Di <b>aplica</b> para enviar tu CV.'
if es_trabajo else
'Di <b>responder</b> 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'<b>De:</b> {sender[:80]}\n'
f'<b>Asunto:</b> {subject[:100]}\n'
f'<b>Resumen:</b> {resumen[:200]}\n'
f'<b>Mis dudas:</b> {razon}')
if senales:
alerta += f'\n<b>Senales:</b> {senales[:200]}'
alerta += '\n\n<i>Di "borrar", "dejar", o explicame que es.</i>'
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'<b>{title}</b>\n'
f'En <b>{mins} minutos</b> — {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(
'<b>Asistente Personal v3 activo</b>\n\n'
'<b>Email:</b> revisar | estado | pendientes | cvs\n'
'<b>Respuestas:</b> responder | aplica | enviar | cancelar\n'
'<b>Agenda:</b> agenda | mañana | semana | agendar [evento]\n'
'<b>Craigslist:</b> 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 <b>detenido</b>.')