Mi asistente de calendario: cómo construí una alternativa a DOLA.AI con n8n e Inteligencia artificial

Mi asistente de calendario: cómo construí una alternativa a DOLA.AI con n8n e Inteligencia Artificial

Hace unos meses descubrí DOLA.AI. Un bot de Telegram que gestiona tu calendario con lenguaje natural. Le escribes «ponme una reunión mañana a las 10» y lo hace. Brillante. Lo probé durante una semana y me pareció genuinamente útil.

Pero sinceramente, su plan gratuito deja mucho que desear en cuanto a usabilidad y funciones.

Fue entonces cuando me hice la pregunta que siempre acaba metiéndome en problemas: ¿podría construir esto yo mismo?

La respuesta corta es sí. La respuesta larga es este artículo, donde voy a desgranar paso a paso cómo he montado un asistente de calendario que no solo replica las funcionalidades de DOLA, sino que las supera en los aspectos que a mí me importan. Con código, explicaciones técnicas y reflexiones sobre el proceso.


Por qué reinventar la rueda

Vivimos en una época curiosa. Tenemos acceso a herramientas que hace diez años habrían parecido ciencia ficción, y sin embargo seguimos con la misma mentalidad de «si funciona, no lo toques». DOLA funciona. ¿Por qué complicarse?

Hay varias razones, pero la principal es una que rara vez se menciona: el control.

Cuando usas DOLA, dependes de su servicio, sus decisiones de producto, sus cambios de precio. Si mañana deciden subir la suscripción, cambiar funcionalidades o cerrar, te quedas sin nada. Y sí, tus datos pasan por sus servidores —igual que los míos pasan por OpenRouter y Google cuando uso Gemini, seamos honestos—. Pero al menos yo controlo la arquitectura: puedo cambiar de modelo, añadir funcionalidades, o migrar a un LLM local cuando me apetezca. Esa flexibilidad es lo que me importa.

La segunda razón es más prosaica: DOLA no hace exactamente lo que yo quiero. Responde a mis preguntas, sí. Pero no me envía un resumen cada mañana con los eventos del día. No me dice qué tiempo va a hacer. No me avisa 5 minutos antes de cada reunión. No distingue automáticamente entre mi calendario personal y el de trabajo sin que tenga que especificarlo cada vez.

Y la tercera razón, la más honesta: me apetecía construirlo. Hay un placer particular en crear herramientas que usas a diario. Es como cocinar tu propia comida en vez de pedir a domicilio. El resultado puede ser similar, pero el proceso te enseña cosas que nunca aprenderías de otra forma.


Qué hace este asistente

Antes de entrar en los detalles técnicos, dejadme explicar qué hace este workflow desde la perspectiva del usuario:

  1. Gestiona eventos de Google Calendar mediante texto o voz desde Telegram
  2. Envía resúmenes automáticos cada mañana, tarde y semanalmente
  3. Incluye previsión meteorológica de AEMET en cada resumen
  4. Me avisa antes de cada evento (60 minutos y 5 minutos antes)
  5. Distingue automáticamente entre calendarios personales y de trabajo
  6. Mantiene conversaciones contextuales gracias a memoria de sesión

La arquitectura: tres cerebros independientes

El workflow está construido en n8n y se divide en tres flujos que operan de forma independiente pero complementaria. Pensad en ellos como sistemas que comparten recursos pero no se estorban.

Flujo 1: El asistente interactivo

Este es el que responde cuando le escribo:

Usuario (Telegram) 
    → Verificación de seguridad
    → Detección texto/audio
    → [Si audio] Transcripción Whisper
    → Agente IA (Gemini)
    → Herramientas Google Calendar
    → Respuesta al usuario

Flujo 2: Los resúmenes automáticos

Este funciona solo, sin que yo haga nada:

Trigger CRON (5:45h / 19:00h / Domingo 12:00h)
    → Configuración temporal
    → Consulta calendarios (×3)
    → Formateo de eventos
    → Consulta AEMET (predicción + avisos)
    → Generación resumen meteorológico (LLM)
    → Fusión calendario + tiempo
    → Envío por Telegram

Flujo 3: Los recordatorios

Este me avisa antes de cada evento:

Trigger CRON (cada minuto)
    → Configuración (60m o 5m antes)
    → Consulta calendarios (×3)
    → Filtro + deduplicación
    → Envío alerta por Telegram

El stack tecnológico

ComponenteTecnologíaFunción
Orquestaciónn8nMotor de workflows
InterfazTelegram Bot APIEntrada/salida usuario
CalendarioGoogle Calendar APIGestión de eventos
IA PrincipalGemini 2.0 Flash LiteComprensión lenguaje natural
IA MeteorologíaGemini 2.0 Flash LiteGeneración resumen tiempo
TranscripciónWhisperVoz a texto
MeteorologíaAEMET OpenData APIPredicción y avisos

Flujo 1: El asistente interactivo, paso a paso

Vamos a desgranar cada nodo del flujo interactivo. Aquí es donde ocurre la «magia» de entender lo que digo y ejecutar acciones.

Paso 1: El Telegram Trigger

Todo comienza con un trigger que escucha mensajes y callback queries:

{
  "updates": ["message", "callback_query"],
  "additionalFields": {}
}

Este trigger captura cualquier interacción: mensajes de texto, notas de voz, y respuestas a botones inline si los hubiera. Es la puerta de entrada.

Paso 2: Verificación de seguridad

Aquí está una de las decisiones más importantes del diseño. Un bot de calendario tiene acceso a información sensible. Sin verificación, cualquier persona que descubra el bot podría consultar o modificar mis eventos. La solución es simple:

// Condición de verificación
$json.message?.from?.id === 220342838  // Tu user_id

Si el user_id no coincide con el mío, el workflow se detiene. Sin excepciones.

¿Cómo obtener tu user_id? Envía un mensaje a @userinfobot en Telegram. Te responde inmediatamente con tu ID.

Paso 3: Detectar texto o audio

Un nodo IF decide si el mensaje es texto o nota de voz:

// Detecta si existe file_id de audio
$json.message.voice.file_id !== ""
  • Si es audio: continúa hacia la transcripción
  • Si es texto: salta directamente al procesamiento

Esta bifurcación es necesaria porque el audio requiere un paso adicional antes de poder procesarlo.

Paso 4: Transcripción con Whisper

Para las notas de voz, el workflow descarga el archivo de los servidores de Telegram y lo envía a Whisper para transcripción:

{
  "method": "POST",
  "url": "http://whisper:9000/asr",
  "queryParameters": {
    "task": "transcribe",
    "language": "es",
    "output": "json",
    "encode": "true"
  },
  "bodyParameters": {
    "audio_file": "[binary data]"
  }
}

Uso una instancia self-hosted de Whisper (el contenedor onerahmet/openai-whisper-asr-webservice). Alternativas incluyen la API de OpenAI o servicios como AssemblyAI, pero prefiero tenerlo en mi servidor. Cuestión de control, otra vez.

Paso 5: Unificar el formato

Tanto si el mensaje viene de texto como de audio transcrito, necesito unificarlo en un formato común:

{
  "user_id": Number,
  "chat_id": Number,
  "text": String,        // El mensaje o la transcripción
  "sessionId": String,   // Para memoria conversacional
  "now_iso": String      // Fecha/hora actual en Europe/Madrid
}

El sessionId combina chat_id y user_id para crear sesiones únicas por usuario. Esto será importante más adelante para la memoria conversacional.

Paso 6: Confirmación al usuario

Antes de procesar la petición, envío un mensaje rápido:

"Perfecto, dame un momento."

¿Por qué? Porque las consultas a la IA pueden tardar 2-3 segundos, y sin feedback el usuario podría pensar que el bot no funciona. Es un detalle pequeño que mejora mucho la experiencia.

Paso 7: El agente IA (el corazón del sistema)

Aquí está donde ocurre la interpretación del lenguaje natural. El nodo Gestor Calendario es un agente de n8n que utiliza Gemini 2.0 Flash Lite a través de OpenRouter. La clave está en el prompt del sistema:

Eres un asistente de calendario. Hoy es ${u.now_iso}. Zona horaria: Europe/Madrid.

OBJETIVO
Interpretar el mensaje del usuario y ejecutar acciones sobre Google Calendar 
usando las herramientas disponibles.

CALENDARIOS DISPONIBLES
- personal (DEFAULT)
- trabajo

REGLA DE SELECCIÓN DE CALENDARIO (OBLIGATORIA)
1) Si el usuario menciona explícitamente "trabajo", "curro", "oficina", 
   "reunión de trabajo" o equivalente → usa el calendario de trabajo.
2) Si el usuario menciona explícitamente "personal", "mi calendario" 
   o equivalente → usa el personal.
3) En cualquier otro caso → usa SIEMPRE el personal y NO preguntes en qué calendario.

REGLAS DE ACCIÓN
- Ver/consultar/listar eventos → usa herramientas de consulta.
- Crear evento → usa herramienta de crear.
- Borrar/cancelar → primero consulta para obtener el event_id, luego borra.
- Modificar → primero consulta para obtener el event_id, luego actualiza.
- Si hay ambigüedad real (2+ eventos candidatos para borrar/modificar) → pregunta cuál.

IMPORTANTE
- NO pidas el calendario salvo que el usuario lo haya indicado explícitamente.
- Después de ejecutar la acción, responde con un resumen breve del resultado.

Hay varios puntos clave en este prompt que me costó afinar:

  1. Calendario por defecto: La regla de «usa siempre personal y NO preguntes» fue un añadido tardío. Después de una semana de responder «personal» cada vez que el bot preguntaba, me harté. Ahora asume por defecto.
  2. Detección de contexto: Palabras como «trabajo», «curro», «oficina» activan automáticamente el calendario laboral. No es perfecto, pero funciona el 95% de las veces.
  3. Flujo de modificación/borrado: Obliga al agente a consultar primero para obtener el event_id correcto. Sin esto, intentaba borrar eventos que no existían.
  4. Manejo de ambigüedad: Solo pregunta cuando realmente hay múltiples candidatos. Si digo «cancela la reunión de mañana» y solo hay una, la cancela directamente.

El prompt del usuario es más simple:

(() => {
  const u = $node["Unificar formato"].json;
  return [
    `Fecha y hora actual: ${u.now_iso} (Europe/Madrid)`,
    `Calendario por defecto si no se especifica: personal`,
    ``,
    `Mensaje del usuario: ${u.text}`
  ].join("\n");
})()

Paso 8: Las herramientas del agente

El agente tiene acceso a 8 herramientas de Google Calendar:

HerramientaCalendarioFunción
consultarPersonalListar eventos
ConsultarTrabajoListar eventos
CreatePersonalCrear evento
createTrabajoCrear evento
UpdatePersonalModificar evento
UpdateTrabajoModificar evento
deletePersonalEliminar evento
deleteTrabajoEliminar evento

Cada herramienta está duplicada porque necesito acceso independiente a cada calendario. Podría haberlo hecho con una sola herramienta y un parámetro, pero esto es más explícito y menos propenso a errores.

El problema de las fechas

Las fechas son una pesadilla en programación. Zonas horarias, formatos, eventos de día completo versus eventos con hora específica… cada caso tiene sus particularidades.

El agente de IA a veces devuelve fechas en formato ISO, a veces solo la fecha sin hora, a veces con zona horaria y a veces sin ella. Tuve que escribir lógica para normalizar todos estos casos:

(() => {
  const startRaw = String($fromAI('Start', '', 'string') || '').trim();
  const endRaw = String($fromAI('End', '', 'string') || '').trim();

  const norm = (v) => {
    if (!v) return '';
    // Detecta formato solo fecha (evento de día completo)
    if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
    // Convierte a ISO con zona horaria
    const dt = DateTime.fromISO(v, { zone: 'Europe/Madrid' });
    return dt.isValid ? dt.toISO() : v;
  };

  if (endRaw) return norm(endRaw);

  // Fallback: si no viene End, calcula automáticamente
  if (/^\d{4}-\d{2}-\d{2}$/.test(startRaw)) {
    // Evento de día completo: End = día siguiente
    const d = DateTime.fromISO(startRaw, { zone: 'Europe/Madrid' }).plus({ days: 1 });
    return d.toISODate();
  }

  // Evento con hora: End = Start + 1 hora
  const d = DateTime.fromISO(startRaw, { zone: 'Europe/Madrid' }).plus({ hours: 1 });
  return d.isValid ? d.toISO() : '';
})()

Esta lógica resuelve varios problemas comunes:

  • Eventos de día completo: Se detectan por formato YYYY-MM-DD y se procesan correctamente
  • Zona horaria: Todas las conversiones usan Europe/Madrid
  • Duración por defecto: Si no se especifica hora de fin, asume 1 hora
  • Validación: Si Luxon no puede parsear la fecha, la pasa tal cual (fallback)

Paso 9: Memoria conversacional

El agente incluye un nodo Simple Memory que permite conversaciones multi-turno:

{
  "sessionIdType": "customKey",
  "sessionKey": "{{ chat_id }}:{{ user_id }}"
}

Esto permite intercambios como:

Usuario: "¿Qué tengo mañana?"
Bot: "Tienes 3 eventos: Reunión a las 10, Dentista a las 17..."
Usuario: "Cancela el dentista"
Bot: "He cancelado el evento 'Dentista' del día..."

El bot «recuerda» que estábamos hablando de los eventos de mañana. Sin esta memoria, habría respondido «¿Qué dentista?».

Paso 10: Limpieza de respuesta

Antes de enviar la respuesta a Telegram, limpio caracteres problemáticos:

const raw = String($input.first().json.output ?? "");

const cleaned = raw
  .replace(/\r/g, "")           // Elimina retornos de carro
  .replaceAll("*", "•")         // Markdown → bullets compatibles
  .replaceAll("_", " ")         // Evita cursiva accidental
  .replaceAll("`", "'");        // Evita código inline

return [{ json: { text: cleaned } }];

Telegram tiene su propio formato de Markdown que no siempre coincide con el que genera el LLM. Esta limpieza evita que los mensajes aparezcan con formato roto.


Flujo 2: Los resúmenes automáticos

Este flujo funciona sin intervención. Se activa solo y me envía información sin que tenga que pedirla. Es la funcionalidad que más uso en el día a día.

Los tres triggers CRON

El workflow incluye tres triggers programados:

TriggerHorarioContenido
CRON 5:45 (Hoy)Cada día 5:45hEventos del día actual
CRON 19:00 (Mañana)Cada día 19:00hEventos del día siguiente
CRON Domingo 12:00Domingos 12:00hEventos de la semana siguiente

¿Por qué 5:45? Porque me levanto temprano y quiero ver mis eventos antes de empezar el día. ¿Por qué 19:00? Porque a esa hora ya estoy pensando en el día siguiente. ¿Por qué domingo a mediodía? Porque es cuando planifico la semana.

Estos horarios son arbitrarios. Lo importante es que puedes ponerlos cuando quieras.

Configuración temporal

Cada trigger alimenta un nodo de configuración que define los parámetros. Ejemplo para el resumen de «mañana»:

{
  "timeMin": "{{ $now.setZone('Europe/Madrid').plus({days:1}).startOf('day').toISO() }}",
  "timeMax": "{{ $now.setZone('Europe/Madrid').plus({days:1}).endOf('day').toISO() }}",
  "emoji": "📅",
  "titulo": "{{ 'Eventos de mañana (' + $now.setZone('Europe/Madrid').plus({days:1}).toFormat('dd/LL/yyyy') + ')' }}",
  "chat_id": 220342838
}

n8n incluye Luxon para manipulación de fechas. Las funciones setZone, plus, startOf y endOf permiten cálculos precisos respetando la zona horaria.

Consulta a múltiples calendarios

El workflow consulta tres calendarios en paralelo:

  1. Personal
  2. Profesional
  3. Master IA (calendario compartido adicional)
{
  "operation": "getAll",
  "calendar": "personal@gmail.com",
  "returnAll": true,
  "timeMin": "={{ $json.timeMin }}",
  "timeMax": "={{ $json.timeMax }}"
}

Un nodo Merge con numberInputs: 3 combina los eventos de los tres calendarios en un único flujo de datos.

Formateo del resumen

El nodo Formatear Resumen es un bloque de código JavaScript que ordena, agrupa y formatea los eventos:

const tz = 'Europe/Madrid';

// Filtrar eventos válidos
const events = items
    .map(i => i.json)
    .filter(e => e && e.start && (e.start.dateTime || e.start.date));

// Funciones auxiliares
function getStart(e) { return e?.start?.dateTime || e?.start?.date || null; }
function isAllDay(e) { return !!e?.start?.date && !e?.start?.dateTime; }

function fmtTime(iso) {
    const d = new Date(iso);
    return d.toLocaleTimeString('es-ES', {
        timeZone: tz,
        hour12: false,
        hour: '2-digit',
        minute: '2-digit',
    });
}

function fmtDay(iso) {
    const d = toDate(iso);
    const weekday = d.toLocaleDateString('es-ES', { timeZone: tz, weekday: 'long' });
    const date = d.toLocaleDateString('es-ES', { timeZone: tz, day: '2-digit', month: '2-digit' });
    return `${weekday} ${date}`;
}

// Ordenar por inicio
events.sort((a, b) => toDate(getStart(a))?.getTime() - toDate(getStart(b))?.getTime());

// Construir texto
let text = `${emoji} ${titulo}\n`;

if (events.length === 0) {
    text += '✅ No tienes eventos.';
    return [{ json: { chat_id, text } }];
}

// Agrupar por día
const byDay = new Map();
for (const e of events) {
    const key = fmtDay(getStart(e));
    if (!byDay.has(key)) byDay.set(key, []);
    byDay.get(key).push(e);
}

// Generar salida
for (const [day, list] of byDay.entries()) {
    if (byDay.size > 1) text += `\n${day}:\n`;
    
    for (const e of list) {
        const title = (e.summary || 'Sin título').trim();
        if (isAllDay(e)) {
            text += `• Todo el día - ${title}\n`;
        } else {
            const start = fmtTime(getStart(e));
            const end = fmtTime(getEnd(e));
            text += `• ${start}-${end}  ${title}\n`;
        }
    }
}

return [{ json: { chat_id, text: text.trim() } }];

El resultado es algo así:

📅 Eventos de mañana (24/01/2025)

• 09:00-10:00  Reunión equipo
• 10:30-11:00  Call cliente
• Todo el día - Cumpleaños Ana
• 17:30-18:00  Dentista

Para el resumen semanal, agrupa por día automáticamente.


Flujo 3: Los recordatorios

Esta fue la última funcionalidad que añadí, y reconozco que es la que más me ha cambiado el día a día. Ya no llego tarde a reuniones porque «se me olvidó mirar el calendario».

La idea

Quiero que el bot me avise 60 minutos antes de cada evento (tiempo suficiente para prepararme) y 5 minutos antes (el «ya deberías estar ahí»). Dos avisos por evento, ni más ni menos.

La arquitectura

El flujo es más simple que el de resúmenes porque no necesita generar texto con IA ni consultar el tiempo:

CRON (cada minuto)
    → Config (timeMin = ahora + X minutos)
    → Consulta calendarios (×3)
    → Merge
    → Filtrar + deduplicar
    → Enviar alerta

El trigger: cada minuto

A diferencia de los resúmenes que se ejecutan a horas fijas, los recordatorios necesitan un trigger que corra cada minuto:

{
  "rule": {
    "interval": [
      {
        "field": "minutes",
        "minutesInterval": 1
      }
    ]
  }
}

Hay dos triggers idénticos: uno para el aviso de 60 minutos y otro para el de 5 minutos.

La configuración temporal

Para el aviso de 5 minutos, la ventana de búsqueda es exactamente el minuto que está a 5 minutos de distancia:

{
  "timeMin": "={{ $now.setZone('Europe/Madrid').plus({minutes:5}).startOf('minute').toISO() }}",
  "timeMax": "={{ $now.setZone('Europe/Madrid').plus({minutes:5}).endOf('minute').toISO() }}",
  "chat_id": 220342838
}

Para el de 60 minutos, lo mismo pero con plus({minutes:60}).

El problema de la deduplicación

Aquí está el truco más importante del flujo. Como el CRON corre cada minuto, si un evento empieza a las 10:00 y busco eventos que empiezan «dentro de 5 minutos», a las 9:55 encontraré ese evento. Perfecto.

Pero ¿qué pasa si el workflow tarda unos segundos en ejecutarse y vuelve a correr a las 9:55:30? Encontraría el mismo evento otra vez. Y me enviaría dos alertas idénticas.

La solución es usar persistencia de estado con deduplicación:

const tz = 'Europe/Madrid';
const offsetMinutes = 5;          // 5 para este nodo, 60 para el otro
const chat_id = 220342838;

// Persistencia (dedupe)
const staticData = $getWorkflowStaticData('global');
staticData.sentReminders ??= {};
const store = staticData.sentReminders;

const nowMs = Date.now();
const ttlMs = 48 * 60 * 60 * 1000; // guarda dedupe 48h

// Neteja claus velles
for (const [k, v] of Object.entries(store)) {
  if (!v || typeof v.ts !== 'number' || (nowMs - v.ts) > ttlMs) delete store[k];
}

function escapeHtml(s) {
  return String(s ?? '')
    .replaceAll('&', '&')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

function pickId(e) {
  // iCalUID es estable (igual aunque venga de calendarios diferentes)
  return e?.iCalUID || e?.id || e?.etag || `${e?.summary || 'sin-titulo'}`;
}

function isAllDay(e) {
  return !!e?.start?.date && !e?.start?.dateTime;
}

function startToMs(e) {
  const s = e?.start?.dateTime;
  if (!s) return null;
  const t = new Date(s).getTime();
  return Number.isFinite(t) ? t : null;
}

function fmtWhen(ms) {
  const d = new Date(ms);
  const date = d.toLocaleDateString('es-ES', {
    timeZone: tz,
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
  });
  const time = d.toLocaleTimeString('es-ES', {
    timeZone: tz,
    hour12: false,
    hour: '2-digit',
    minute: '2-digit',
  });
  return `${date}, ${time}`;
}

const out = [];

for (const item of items) {
  const e = item.json;

  // Ignorar eventos de todo el día
  if (isAllDay(e)) continue;

  const startMs = startToMs(e);
  if (!startMs) continue;

  // No avisar si ya ha comenzado
  if (startMs < nowMs) continue;

  // ---- FILTRO MINUTO EXACTO ----
  const nowMinute = Math.floor(nowMs / 60000);
  const startMinute = Math.floor(startMs / 60000);
  const diff = startMinute - nowMinute;

  // Solo cuando faltan exactamente X minutos (a nivel de minuto)
  if (diff !== offsetMinutes) continue;
  // ------------------------------

  // Dedup: clave = offset + id estable + minuto de inicio
  const id = pickId(e);
  const key = `${offsetMinutes}|${id}|${startMinute}`;

  if (store[key]) continue;
  store[key] = { ts: nowMs };

  const summary = e.summary || 'Sin título';
  const when = fmtWhen(startMs);
  const link = e.htmlLink;

  const text =
    `⏰ <b>En ${offsetMinutes} minutos</b>: <b>${escapeHtml(summary)}</b>\n` +
    `🕒 ${escapeHtml(when)}\n` +
    (link ? `🔗 <a href="${escapeHtml(link)}">Abrir evento</a>` : '');

  out.push({ json: { chat_id, text } });
}

return out;

Puntos clave de este código:

  1. $getWorkflowStaticData('global'): Esta función de n8n permite guardar datos entre ejecuciones. Es la clave de todo.
  2. TTL de 48 horas: Limpio las claves viejas para que el store no crezca infinitamente.
  3. Clave de deduplicación: Combino el offset (5 o 60), el ID del evento y el minuto de inicio. Así cada combinación única solo puede avisar una vez.
  4. Filtro de minuto exacto: Aunque la consulta ya filtra por ventana temporal, añado una segunda comprobación por si hay eventos en el borde.
  5. Ignoro eventos de día completo: No tiene sentido avisar «en 5 minutos empieza: Cumpleaños de Ana».

El resultado

Cada vez que tengo un evento programado, recibo dos mensajes:

⏰ En 60 minutos: Reunión con cliente
🕒 25/01/2025, 10:00
🔗 Abrir evento

Y después:

⏰ En 5 minutos: Reunión con cliente
🕒 25/01/2025, 10:00
🔗 Abrir evento

El enlace lleva directamente al evento en Google Calendar por si necesito ver detalles.


La integración con AEMET: el tiempo en cada resumen

Esta es la funcionalidad que DOLA no tiene y que para mí marca la diferencia. Cada resumen incluye la previsión del tiempo de mi pueblo.

Arquitectura del flujo meteorológico

Oratge Context (configuración)
    → Secrets AEMET (API key)
    → AEMET predicció meta (URL datos)
    → AEMET predicció dades (JSON predicción)
    → AEMET CAP meta (URL avisos)
    → AEMET CAP dades (XML avisos)
    → LLM resum oratge (generación texto)
    → Edit Fields (formateo)
    → Merge con calendario

Configuración de contexto

El nodo Oratge Context define la localización:

{
  "chat_id": input.chat_id,
  "tz": "Europe/Madrid",
  "place": "Benicolet",
  "aemet_municipio": "46057",      // Código INE del municipio
  "aemet_zona": "774603",          // Zona de avisos CAP
  "lat": 38.9191,
  "lon": -0.34697,
  "mode": "mañana",                // hoy | mañana | semanal
  "start_date": "2025-01-24",
  "date_label": "divendres, 24 de gener"
}

¿Cómo obtener estos códigos?

  • aemet_municipio: Código INE de 5 dígitos. Se obtiene del listado de municipios de AEMET
  • aemet_zona: Código de zona para avisos CAP. Formato PPCCMM donde PP=provincia, CC=comarca, MM=municipio

Consultas a AEMET

La API de AEMET requiere dos llamadas para cada consulta. Primero obtienes una URL temporal, luego descargas los datos:

Predicción municipal:

// Primera llamada: obtener URL de datos
GET https://opendata.aemet.es/opendata/api/prediccion/especifica/municipio/diaria/{municipio}
    ?api_key={aemet_api_key}

// Respuesta:
{
  "datos": "https://opendata.aemet.es/opendata/sh/...",
  "metadatos": "..."
}

// Segunda llamada: obtener predicción
GET {url_datos}

// Respuesta: JSON con predicción por días y periodos

Avisos CAP:

// Primera llamada: obtener URL
GET https://opendata.aemet.es/opendata/api/avisos_cap/ultimoelaborado/area/esp
    ?api_key={aemet_api_key}

// Segunda llamada: obtener XML
GET {url_datos}

// Respuesta: XML con avisos activos

Procesamiento de datos

Los datos de AEMET vienen en JSON y XML crudos. Un bloque de código los estructura:

// Extraer día específico de la predicción
const target = String(ctx.start_date || "");
const d = dies.find(x => String(x?.fecha ?? "").startsWith(target));

// Extraer datos por periodo
const byPeriodo = (arr, periodo) => {
    return arr.find(x => String(x?.periodo ?? "") === periodo) ?? null;
};

// Construir objeto estructurado
const row = {
    date: String(d.fecha).slice(0, 10),
    date_label: ctx.date_label,
    tmin: d?.temperatura?.minima ?? "n/d",
    tmax: d?.temperatura?.maxima ?? "n/d",
    p0012: valOrND(byPeriodo(d?.probPrecipitacion, "00-12")?.value),
    p1224: valOrND(byPeriodo(d?.probPrecipitacion, "12-24")?.value),
    cel0012: celDesc(d?.estadoCielo, "00-12"),
    cel1224: celDesc(d?.estadoCielo, "12-24"),
    cel_all: d.estadoCielo.map(x => x?.descripcion).filter(Boolean)
};

Generación del resumen meteorológico

Un segundo LLM (Gemini 2.0 Flash) transforma esos datos en un resumen legible. El prompt define el formato exacto:

NOMÉS AEMET. NO codi, NO JSON, NO HTML.

Context: este missatge és l'oratge de HUI (un sol dia).
Idioma: valencià.

FORMAT:
──────────────
🌦️ ORATGE · Benicolet
🗓️ {DATE_LABEL}

🌡️ Temperatura • Mín {TMIN}°C · Màx {TMAX}°C

🌧️ Pluja (AEMET) • 06-12 [{P0612}] · 12-18 [{P1218}] · 18-24 [{P1824}]

☁️ Cel (AEMET) • 06-12 {CEL0612} • 12-18 {CEL1218} • 18-24 {CEL1824}

⛈️ Risc de tronada • {Alt/Mitjà/Baix}

⚠️ Avisos AEMET {LLISTA_O_CAP}

🔎 Fiabilitat • {alta/mitjana/baixa}

Lo tengo en valenciano porque es mi lengua materna y porque puedo. El formato es consistente cada día y me da toda la información que necesito de un vistazo.

Fusión final

El nodo Adjuntar Oratge combina el resumen de calendario con el meteorológico:

const resumen = items.find(i => i.json?.text && i.json?.chat_id);
const meteo = items.find(i => i.json?.weather_text);

let finalText = resumen.json.text;
if (meteo?.json?.weather_text) {
    finalText = `${finalText}\n\n${meteo.json.weather_text}`;
}

return [{ json: { chat_id: resumen.json.chat_id, text: finalText } }];

El resultado es un mensaje completo con eventos y tiempo que me llega cada mañana sin hacer nada.


Configuración e instalación

Si quieres replicar esto, aquí está todo lo que necesitas.

Requisitos previos

ServicioTipoCoste
n8nSelf-hosted o CloudGratis / desde 20€/mes
Telegram Bot@BotFatherGratis
Google CalendarOAuth2Gratis
OpenRouterAPI~$0.01/1000 tokens
AEMETAPI keyGratis
WhisperSelf-hosted / APIVariable

Paso 1: Crear bot de Telegram

  1. Abre Telegram y busca @BotFather
  2. Envía /newbot
  3. Sigue las instrucciones para nombrar el bot
  4. Guarda el token que te proporciona

Paso 2: Configurar Google Calendar OAuth

  1. En n8n, ve a CredentialsNewGoogle Calendar OAuth2
  2. Sigue el flujo de autorización de Google
  3. Asegúrate de autorizar los calendarios que quieres gestionar

Paso 3: Obtener API de OpenRouter

  1. Regístrate en openrouter.ai
  2. Genera una API key
  3. En n8n: CredentialsNewOpenRouter

Paso 4: Obtener API de AEMET

  1. Regístrate en opendata.aemet.es
  2. Solicita una API key (proceso automático)
  3. Modifica el nodo Secrets AEMET con tu clave

Paso 5: Configurar Whisper (opcional)

Opción A: Self-hosted con Docker

docker run -d -p 9000:9000 onerahmet/openai-whisper-asr-webservice:latest

Opción B: API de OpenAI

Modifica el nodo de transcripción para usar la API de OpenAI en lugar del endpoint local.

Paso 6: Personalización

  1. Tu user_id: Modifica el nodo «Verificar Usuario Autorizado»
  2. Tus calendarios: Cambia los emails en todos los nodos de Google Calendar
  3. Tu localización: En «Oratge Context», cambia:
    • place: Nombre de tu localidad
    • aemet_municipio: Tu código de municipio
    • aemet_zona: Tu zona de avisos
    • lat y lon: Tus coordenadas

Paso 7: Activar y probar

  1. Activa el workflow
  2. Envía un mensaje de prueba a tu bot: «¿Qué tengo hoy?»
  3. Verifica que responde correctamente

Consideraciones de seguridad

Datos sensibles

  • API Keys: Nunca expongas las claves en código público
  • Verificación de usuario: Mantén siempre el check de user_id
  • OAuth tokens: Google Calendar usa refresh tokens; n8n los gestiona automáticamente

Privacidad

Seamos honestos: esto no es privacidad total. Cada mensaje que envío pasa por OpenRouter y llega a los servidores de Google (Gemini). Si escribo «ponme una cita con el dentista mañana», Google lo ve.

Entonces, ¿en qué es mejor que DOLA?

  • No hay cuenta centralizada con mi email, historial de uso y patrones de comportamiento
  • Yo decido qué modelo uso y puedo cambiarlo mañana por un LLM local (Ollama + Llama, por ejemplo)
  • Los eventos del calendario los consulto directamente a Google, que ya los tiene de todos modos
  • n8n no almacena nada a menos que yo lo configure explícitamente

Es una cuestión de grados. No es privacidad absoluta, pero sí más control sobre dónde van mis datos y con quién los comparto. Si quisiera privacidad real, tendría que correr un modelo local en mi servidor. Es factible —n8n tiene nodo de Ollama— pero no lo he implementado todavía.

Recomendaciones

  1. Usa n8n self-hosted si la privacidad es crítica
  2. Considera cifrar las credenciales a nivel de sistema
  3. Revisa periódicamente los permisos de la app de Google

Posibles mejoras y extensiones

Una vez que tienes la infraestructura montada, añadir funcionalidades es relativamente trivial. Algunas ideas:

Funcionalidades adicionales

  • Integración con Notion/Todoist: Sincronizar tareas
  • Análisis de productividad: Resumen semanal de tiempo en reuniones
  • Comandos rápidos: Botones inline para acciones frecuentes
  • Recordatorios personalizables: Permitir configurar los minutos de antelación

Mejoras técnicas

  • Rate limiting: Proteger contra spam
  • Logs estructurados: Para debugging
  • Métricas: Dashboard de uso con Grafana
  • Backup: Exportación periódica de eventos

Integraciones adicionales

  • CalDAV: Soporte para calendarios no-Google
  • Outlook: Microsoft Graph API
  • iCloud: Vía CalDAV con token de app

Reflexión final

¿Merece la pena montar todo esto versus pagar una suscripción a DOLA?

Depende de qué valores.

Si lo que quieres es una solución que funcione sin complicaciones, DOLA está bien. La uso y la recomiendo para quien no quiera cacharrear.

Pero si eres como yo —si disfrutas construyendo herramientas, si te importa el control sobre tus datos, si quieres funcionalidades que ningún servicio comercial ofrece— entonces merece absolutamente la pena.

El coste de mantener esto es mínimo: unos céntimos al mes en llamadas a la API de OpenRouter. El servidor ya lo tenía para otras cosas. Y el tiempo invertido en construirlo se amortiza cada mañana cuando recibo mi resumen personalizado con el tiempo que va a hacer, y cada vez que me llega un aviso 5 minutos antes de una reunión que había olvidado.

Hay algo profundamente satisfactorio en usar herramientas que has construido tú mismo. No es solo el resultado —que es útil—, sino el proceso de creación. Cada problema que resuelves te enseña algo. Cada detalle que pulieras te da una pequeña dosis de orgullo.

Vivimos rodeados de servicios que hacen las cosas «por nosotros». Y está bien. No podemos construirlo todo. Pero de vez en cuando merece la pena preguntarse: ¿podría hacer esto yo mismo? ¿Qué aprendería en el proceso? ¿Qué ganaría en control, en personalización, en comprensión?

A veces la respuesta es que no merece la pena. Otras veces, como en este caso, descubres que sí.

Mi asistente de calendario no es perfecto. Tiene bugs que voy corrigiendo sobre la marcha. A veces interpreta mal una petición ambigua. Pero es mío. Lo entiendo de arriba a abajo. Y cada mañana, cuando me llega ese mensaje con mis eventos y el tiempo que va a hacer, me recuerda por qué disfruto construyendo cosas.


¿Te interesa el workflow completo? Conecta en LinkedIn y hablamos.

Deja un comentario

¡Únete a mi comunidad y optimiza tus RRHH con IA!

Suscríbete para recibir contenido exclusivo sobre cómo la inteligencia artificial puede revolucionar tus procesos de RRHH, y accede a mi guía gratuita de bienvenida.