Por qué no bastan los temporizadores en memoria
setTimeout, asyncio.sleep y los programadores dentro del proceso (APScheduler con almacenamiento en memoria, el JobQueue de python-telegram-bot, node-cron) sirven para prototipos. En producción pierden todas las tareas pendientes cuando el proceso se reinicia, y cada despliegue es un reinicio. Además se ejecutan dos veces si alguna vez arrancas dos instancias.
La solución es hacer de la base de datos la fuente de verdad y tratar el programador del proceso, como mucho, como una optimización.
La tabla de tareas
CREATE TABLE scheduled_messages (
id BIGSERIAL PRIMARY KEY,
platform TEXT NOT NULL, -- 'telegram', 'discord', 'whatsapp'
chat_id TEXT NOT NULL,
body TEXT NOT NULL,
due_at TIMESTAMPTZ NOT NULL, -- siempre en UTC
tz TEXT NOT NULL, -- zona IANA del usuario, p. ej. 'Europe/Madrid'
rrule TEXT, -- NULL si es única, o una expresión cron
status TEXT NOT NULL DEFAULT 'pending', -- pending | sending | sent | failed
attempts INT NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX ON scheduled_messages (status, due_at);
Guarda los instantes en UTC y la zona horaria IANA del usuario por separado. La zona hace falta para calcular la siguiente ejecución de tareas recurrentes y para mostrar las horas al usuario.
El revisor
Un ciclo (o un cron trigger) corre cada 30-60 segundos, reclama las tareas vencidas y las envía:
-- PostgreSQL: reclama hasta 50 tareas vencidas sin chocar con otros workers
UPDATE scheduled_messages SET status = 'sending', attempts = attempts + 1
WHERE id IN (
SELECT id FROM scheduled_messages
WHERE status = 'pending' AND due_at <= now()
ORDER BY due_at
LIMIT 50
FOR UPDATE SKIP LOCKED
)
RETURNING *;
async def tick():
for job in await claim_due_jobs():
try:
await send(job.platform, job.chat_id, job.body)
if job.rrule:
await reschedule(job.id, next_run(job.rrule, job.tz)) # vuelve a pending
else:
await mark(job.id, "sent")
except RetryableError as e: # 429, 5xx, timeouts
await retry_later(job.id, backoff(job.attempts), str(e))
except Exception as e: # bloqueado por el usuario, chat borrado
await mark(job.id, "failed", str(e))
FOR UPDATE SKIP LOCKED permite correr varios workers con seguridad. Las tareas atoradas en sending tras una caída pueden volver a pending después de un tiempo; eso implica que un duplicado ocasional es posible, así que haz que repetir un envío sea seguro donde importe. Elige los tiempos de reintento con la calculadora de backoff. SQLite sirve para un solo worker; quita SKIP LOCKED y corre un solo revisor. Más sobre almacenamiento en bases de datos y estado para bots.
Interpretar lo que escribe el usuario
Los usuarios escriben "en 10m", "mañana 9:00" o "cada lunes a las 8". Mantén la gramática pequeña y confirma la interpretación:
Recordatorio para el mar 28 oct, 09:00 (Europe/Madrid). [Cancelar]
Pide la zona horaria una vez al inicio (ofrece opciones comunes con botones) y guárdala. Nunca asumas la zona del servidor. El planificador de zonas horarias y el conversor de timestamps ayudan a revisar resultados.
Recurrencias, zonas horarias y horario de verano
Para "todos los días a las 08:00" calcula cada siguiente ejecución en la zona del usuario y luego conviértela a UTC:
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
def next_daily(hour: int, minute: int, tz: str, now_utc: datetime) -> datetime:
zone = ZoneInfo(tz)
local_now = now_utc.astimezone(zone)
candidate = local_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if candidate <= local_now:
candidate = (candidate + timedelta(days=1)).replace(hour=hour, minute=minute)
return candidate.astimezone(ZoneInfo("UTC"))
Si sumas 24 horas en UTC, la hora se corre una hora cuando empieza o termina el horario de verano. Decide también qué pasa con un recordatorio a las 02:30 un día en que esa hora no existe localmente (normalmente: enviarlo a la siguiente hora válida). Para reglas tipo cron, librerías como croniter (Python) o cron-parser (Node.js) calculan las siguientes ejecuciones con zona horaria; prueba expresiones en el constructor de cron.
Serverless: cron triggers de Cloudflare Workers
En Workers no hay un ciclo de larga duración. Un cron trigger llama a tu handler scheduled:
# wrangler.toml
[triggers]
crons = ["* * * * *"] # cada minuto, evaluado en UTC
export default {
async scheduled(controller, env, ctx) {
const { results } = await env.DB.prepare(
"SELECT * FROM scheduled_messages WHERE status = 'pending' AND due_at <= ?1 LIMIT 50"
).bind(new Date().toISOString()).all();
for (const job of results) ctx.waitUntil(sendAndMark(env, job));
},
};
Con D1 (SQLite), reclama tareas con un UPDATE ... WHERE id = ? AND status = 'pending' condicional y comprueba que cambió una fila antes de enviar. Mira bots en Cloudflare Workers.
Reglas de cada plataforma que afectan la programación
- Telegram: el FAQ de bots sugiere no pasar de unos 30 mensajes por segundo en total y alrededor de 1 mensaje por segundo por chat (20 por minuto en un grupo). Reparte los resúmenes grandes en el tiempo en vez de enviarlos en el mismo segundo.
- Discord: hay límites por ruta; las librerías encolan solas, pero una ráfaga grande igual llega tarde. Mejor un mensaje en un canal que cientos de DMs.
- WhatsApp: fuera de la ventana de atención de 24 horas solo puedes enviar plantillas aprobadas, y las plantillas se cobran. Un recordatorio diario a alguien que no ha escrito recientemente debe ser una plantilla. Estima el costo con la calculadora de costos de WhatsApp.
- Usuarios que bloquearon el bot: Telegram devuelve 403 y WhatsApp un código de error; marca esas tareas como fallidas y detén la recurrencia.
Más detalle en los límites de tasa explicados y la guía de confiabilidad y límites.
Que los usuarios gestionen sus programaciones
/recordatorioslista las tareas próximas con su hora local y un botón para borrar.- Cada mensaje recurrente incluye una forma de detenerlo con un toque.
- Horas de silencio: no envíes mensajes no urgentes de noche en la zona del usuario.
Pruébalo
- Inyecta el reloj: las funciones reciben
nowcomo parámetro para que las pruebas viajen en el tiempo. - Prueba los cambios de horario de verano de las zonas donde viven tus usuarios.
- Mata el worker a mitad de un lote en staging y confirma que no se pierde ninguna tarea. Mira pruebas de bots.
Fuentes
- Documentación de Python, zoneinfo: docs.python.org/3/library/zoneinfo.html
- Documentación de PostgreSQL, SELECT FOR UPDATE SKIP LOCKED: postgresql.org/docs/current/sql-select.html
- Cloudflare Workers, cron triggers: developers.cloudflare.com/workers/configuration/cron-triggers
- FAQ de bots de Telegram, límites de envío: core.telegram.org/bots/faq
- Meta for Developers, plantillas de WhatsApp: developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates
Comparativas y herramientas relacionadas
Preguntas frecuentes
¿Puedo usar setTimeout para recordatorios?
Solo para prototipos. Los temporizadores en memoria desaparecen en cada reinicio o despliegue. Guarda las tareas en una base de datos y revisa las vencidas.
¿Guardo la hora del recordatorio en hora local o en UTC?
Guarda el instante en UTC y la zona IANA del usuario por separado. Calcula las recurrencias en la zona del usuario para manejar los cambios de horario de verano.