1806 lines
79 KiB
Python
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>.')
|