From a82e0a0940e9852ceaac1c10dc0130cae7c11ab9 Mon Sep 17 00:00:00 2001 From: mapet-dev Date: Sat, 12 Sep 2026 12:18:20 -0300 Subject: [PATCH 1/4] test prueba --- prueba.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 prueba.txt diff --git a/prueba.txt b/prueba.txt new file mode 100644 index 0000000..11088fa --- /dev/null +++ b/prueba.txt @@ -0,0 +1 @@ +Archivo de prueba. From 35ba6b831ab62c80c22a872325ef40309ccccbed Mon Sep 17 00:00:00 2001 From: Juan Coronel Date: Sat, 12 Sep 2026 12:29:16 -0300 Subject: [PATCH 2/4] docs: add Acordate integration kit --- .env.example | 24 +++ acordate-contracts.md | 280 +++++++++++++++++++++++++++++++++ acordate-integration-status.md | 42 +++++ acordate-qa-checklist.md | 71 +++++++++ 4 files changed, 417 insertions(+) create mode 100644 acordate-contracts.md create mode 100644 acordate-integration-status.md create mode 100644 acordate-qa-checklist.md diff --git a/.env.example b/.env.example index 3de9dd5..b42b8b9 100644 --- a/.env.example +++ b/.env.example @@ -89,3 +89,27 @@ EXA_SEARCH_TYPE=fast # Keep the approval server on loopback unless you add your own auth and # trusted-origin boundary. # WEB_APPROVAL_DIR=/absolute/path/to/web-approvals + + +# ── ACORDATE · Telegram MVP ───────────────────────────────────────────────── +# These server-only values are for the team's Telegram + Next.js workflow. +# MODEL_PROVIDER, OPENAI_API_KEY and MODEL are configured in the provider block +# above. Do not use NEXT_PUBLIC_ for any value in this section. +# +# Create a random value for TELEGRAM_WEBHOOK_SECRET, register it when setting +# the Telegram webhook, and reject requests that do not send the matching +# X-Telegram-Bot-Api-Secret-Token header. +TELEGRAM_BOT_TOKEN= +TELEGRAM_WEBHOOK_SECRET= + +# Supabase project URL and server-side service-role key. Never expose the +# service-role key to Telegram clients or a browser. +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= + +# Shared secret for the protected endpoint that processes due reminders. +CRON_SECRET= + +# Public HTTPS origin used when registering the Telegram webhook, without a +# trailing slash. Example: https://acordate.vercel.app +ACORDATE_PUBLIC_URL= diff --git a/acordate-contracts.md b/acordate-contracts.md new file mode 100644 index 0000000..a934b56 --- /dev/null +++ b/acordate-contracts.md @@ -0,0 +1,280 @@ +# ACORDATE — Contratos de integración (MVP) + +Este documento es la fuente de verdad para las personas 1–5. Si un módulo +necesita cambiarlo, se acuerda aquí antes de modificar la implementación. + +## Decisiones cerradas + +- El canal es **Telegram Bot API**; no habrá frontend web en el MVP. +- Cada usuario de Telegram corresponde a un único `users.id` interno. +- Se almacenan las fechas en UTC y se expresan como ISO 8601, por ejemplo + `2026-09-12T13:00:00.000Z`. +- El perfil se crea con `timezone = "America/Asuncion"`. Si el usuario + proporciona otra zona, se actualiza antes de calcular `scheduledAt`. +- La memoria que se incorpore a un recordatorio debe provenir de + `searchMemory`; el agente no inventa contexto. +- Un aviso enviado queda en estado `sent`. Solo un recordatorio `sent` puede + pasar a `completed` mediante “Hecho”. + +## Identidad y mensaje entrante + +Persona 1 convierte el `Update` de Telegram al siguiente objeto y crea o busca +el usuario antes de invocar al agente. Los IDs externos se tratan como texto. + +```ts +type IncomingMessage = { + telegramUserId: string; + chatId: string; + messageId: string; + text: string; + receivedAt: string; // ISO UTC +}; + +type User = { + id: string; // UUID interno + telegramId: string; // único + timezone: string; // IANA; inicialmente America/Asuncion + createdAt: string; // ISO UTC +}; + +type AgentTurn = { + userId: string; + text: string; + sourceMessageId: string; + receivedAt: string; // ISO UTC + timezone: string; + activeSentReminder: Pick | null; +}; +``` + +Persona 1 construye un `AgentTurn` y el agente recibe ese objeto, nunca un +`telegramUserId`. Para “Hecho”, persona 1 consulta el último reminder `sent` +del usuario y lo entrega como `activeSentReminder`; persona 2 usa su `id` al +llamar `completeReminder`. + +## Contrato común de resultados y errores + +Las tools no devuelven excepciones de infraestructura al modelo. Devuelven una +respuesta estructurada; el adaptador de Telegram convierte el resultado en un +mensaje humano. + +```ts +type ToolErrorCode = + | "VALIDATION_ERROR" + | "NOT_FOUND" + | "NO_ACTIVE_REMINDER" + | "CONFLICT" + | "UNAVAILABLE"; + +type ToolFailure = { + ok: false; + code: ToolErrorCode; + message: string; // seguro para mostrar al usuario +}; +``` + +Los resultados exitosos incluyen `ok: true`. Los secretos, SQL, trazas y +mensajes internos no llegan a Telegram. + +## Entidades persistentes + +```ts +type Memory = { + id: string; // UUID + userId: string; + content: string; + sourceMessageId: string; + createdAt: string; // ISO UTC +}; + +type ReminderStatus = "pending" | "sent" | "completed" | "failed"; + +type Reminder = { + id: string; // UUID + userId: string; + title: string; + scheduledAt: string; // ISO UTC + status: ReminderStatus; + context: string; + sourceMemoryIds: string[]; + sentAt: string | null; + completedAt: string | null; + createdAt: string; // ISO UTC +}; +``` + +Restricciones de base de datos: + +- `users.telegram_id` es `UNIQUE`. +- `memories.user_id` y `reminders.user_id` referencian `users.id`. +- `reminders.status` acepta únicamente los cuatro estados definidos arriba. +- `scheduled_at`, `sent_at`, `completed_at` y `created_at` se guardan como + `timestamptz`. + +## Tools del agente + +Los campos obligatorios se validan con Zod. Los ejemplos muestran los objetos +que cruzan la frontera agente ↔ servicios, no una API pública. + +### `saveMemory` + +Se utiliza solo cuando el usuario pide explícitamente guardar/recordar un dato. + +```ts +type SaveMemoryInput = { + userId: string; + content: string; + sourceMessageId: string; +}; + +type SaveMemoryResult = + | { ok: true; memory: Memory } + | ToolFailure; +``` + +### `searchMemory` + +Devuelve como máximo tres memorias del mismo usuario, ordenadas por relevancia. +Una búsqueda vacía es inválida. + +```ts +type SearchMemoryInput = { + userId: string; + query: string; +}; + +type SearchMemoryResult = + | { + ok: true; + memories: Array & { + score: number; // 0–1; mayor es mejor + }>; + } + | ToolFailure; +``` + +No encontrar una memoria **no es un error**: se devuelve +`{ ok: true, memories: [] }`. En ese caso, el agente pregunta por el contexto +faltante en vez de inventarlo. + +### `createReminder` + +El agente convierte expresiones como “mañana a las 10” a UTC usando la zona del +usuario. Si no puede determinar una fecha/hora futura, debe pedir aclaración y +no llamar la tool. + +```ts +type CreateReminderInput = { + userId: string; + title: string; + scheduledAt: string; // ISO UTC, estrictamente futuro + context: string; + sourceMemoryIds: string[]; // [] si el usuario no dio contexto previo + sourceMessageId: string; +}; + +type CreateReminderResult = + | { ok: true; reminder: Reminder } + | ToolFailure; +``` + +`context` debe ser corto y apto para enviar por Telegram. Para la demo: +`"Necesitás llevar cédula y comprobante."`. + +### `completeReminder` + +Para evitar completar una tarea equivocada, el handler resuelve el último +recordatorio `sent` del usuario y pasa su ID a esta tool. Si no existe uno, +devuelve `NO_ACTIVE_REMINDER` y no modifica datos. + +```ts +type CompleteReminderInput = { + userId: string; + reminderId: string; +}; + +type CompleteReminderResult = + | { ok: true; reminder: Reminder } + | ToolFailure; +``` + +La transición permitida es únicamente `sent → completed`; guarda +`completedAt`. Una segunda confirmación responde con `CONFLICT`. + +## Endpoints de integración + +Estos son los únicos endpoints que necesitan acordar los módulos durante el +MVP. Las tools permanecen internas al backend. + +| Endpoint | Protección | Contrato mínimo | +| --- | --- | --- | +| `GET /api/health` | Ninguna; no expone configuración | Devuelve `200` y `{ "ok": true, "service": "acordate" }` cuando el proceso está vivo. | +| `POST /api/telegram/webhook` | Header `X-Telegram-Bot-Api-Secret-Token` igual a `TELEGRAM_WEBHOOK_SECRET` | Recibe un `Update`, lo adapta a `IncomingMessage` y devuelve `200` para un update aceptado. Un secreto ausente o incorrecto devuelve `401` sin crear datos. | +| `POST /api/internal/run-due-reminders` | `Authorization: Bearer ` | Ejecuta una pasada del scheduler y devuelve `{ "ok": true, "processed": number, "sent": number, "failed": number }`. Sin credencial válida devuelve `401`. | + +`ACORDATE_PUBLIC_URL` determina el webhook que se registra en Telegram: +`{ACORDATE_PUBLIC_URL}/api/telegram/webhook`. Persona 5 verifica la URL pública; +persona 1 registra el webhook una vez que ese endpoint responde correctamente. + +## Límites entre módulos + +| Responsable | Entrega / garantía | +| --- | --- | +| Persona 1 — Telegram | Valida el secreto del webhook, adapta `Update` a `IncomingMessage`, resuelve `userId`, construye el `AgentTurn` y envía texto. Para “Hecho”, incorpora el último reminder `sent` como `activeSentReminder`. | +| Persona 2 — Agente | Clasifica intención, llama las cuatro tools con estos tipos, pide aclaración ante datos faltantes y redacta la respuesta. | +| Persona 3 — Memoria | Implementa `users`, `memories`, `saveMemory` y `searchMemory`; nunca devuelve memoria de otro `userId`. | +| Persona 4 — Recordatorios | Implementa `reminders`, `createReminder`, `completeReminder` y el scheduler. Antes de enviar, cambia a `sent` solo después de que Telegram confirme el envío. | +| Persona 5 — Integración | Mantiene este contrato, configura secretos/deploy, prueba cada frontera y conserva evidencia de las pruebas. | + +## Scheduler y entrega + +El scheduler consulta recordatorios con: + +```text +status = pending AND scheduled_at <= now() +``` + +Por cada uno, construye el aviso: + +```text +🔔 Recordatorio +{title} +{context} +Respondé “Hecho” cuando lo completes. +``` + +Si Telegram confirma el envío: `pending → sent`, con `sentAt`. +Si falla: `pending → failed` y se registra el detalle técnico solo en logs. El +MVP no reintenta automáticamente; nunca lo marca como `sent` sin confirmación. + +## Prueba de aceptación compartida + +Datos de demostración y resultado esperado: + +1. Usuario: “Guardá que para retirar el certificado necesito cédula y + comprobante.” + - Se crea una `Memory` para ese `userId`. +2. Usuario: “Recordame retirar el certificado en 2 minutos.” + - `searchMemory` devuelve la memoria anterior. + - Se crea un `Reminder` `pending`, con el texto anterior en `context`. +3. Scheduler: llega la hora. + - Telegram recibe el aviso; el reminder queda `sent`. +4. Usuario: “Hecho”. + - Se completa ese reminder y queda `completed`. +5. Usuario: “Hecho” otra vez. + - No cambia ningún reminder y recibe una respuesta clara de que no hay un + recordatorio activo. + +## Variables de entorno (nombres acordados) + +```dotenv +OPENAI_API_KEY= +TELEGRAM_BOT_TOKEN= +TELEGRAM_WEBHOOK_SECRET= +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= +CRON_SECRET= +``` + +Se configuran en Vercel/Supabase según corresponda. No se añaden valores a Git, +ni se usan prefijos `NEXT_PUBLIC_` para claves de servidor. diff --git a/acordate-integration-status.md b/acordate-integration-status.md new file mode 100644 index 0000000..0fc66e4 --- /dev/null +++ b/acordate-integration-status.md @@ -0,0 +1,42 @@ +# ACORDATE — Estado de integración + +Actualiza este tablero cada 20–30 minutos y antes de cada integración. La +persona 5 es dueña del documento; cada responsable actualiza su propia fila. + +## Leyenda + +| Estado | Uso | +| --- | --- | +| `⬜ No iniciado` | No hay trabajo integrado todavía. | +| `🟡 En curso` | Se está construyendo; aún no cumple el criterio de salida. | +| `🟢 Integrado` | Está en la rama de integración y pasó su prueba acordada. | +| `🔴 Bloqueado` | Requiere una decisión, credencial o corrección externa. | + +## Snapshot inicial + +| Área | Responsable | Estado | Criterio para declarar “integrado” | Dependencia / bloqueo | Evidencia | +| --- | --- | --- | --- | --- | --- | +| Contratos de integración | P5 | 🟢 Integrado | Documento aceptado por P1–P4. | Falta confirmación del equipo. | `acordate-contracts.md` | +| Configuración segura | P5 | 🟡 En curso | Variables cargadas localmente y en deploy, sin secretos en Git. | Credenciales de Telegram, OpenAI y Supabase. | `.env.example` | +| Telegram y webhook | P1 | ⬜ No iniciado | Mensaje real entra y recibe respuesta; secreto inválido devuelve `401`. | Bot token, URL desplegada. | | +| Agente y tools | P2 | ⬜ No iniciado | Las cuatro tools siguen los contratos y manejan datos faltantes. | OpenAI key, interfaces finales. | | +| Usuarios y memoria | P3 | ⬜ No iniciado | Guarda y busca sin filtrar datos entre usuarios. | Proyecto/credenciales Supabase. | | +| Reminders y scheduler | P4 | ⬜ No iniciado | Crea, envía una vez y completa un reminder. | Tabla reminders, credenciales Telegram. | | +| Deploy / health | P5 | ⬜ No iniciado | URL HTTPS responde `GET /api/health`. | Implementación mínima Next.js. | | +| Demo end-to-end | P5 | ⬜ No iniciado | Checklist 2.1–2.5 aprobado. | Todos los módulos. | | + +## Registro de integración + +Agregar una fila por intento; no borrar fallos. Esto evita que el equipo repita +diagnósticos ya realizados. + +| Hora | Integración probada | Resultado | Próximo paso | Dueño | +| --- | --- | --- | --- | --- | +| — | — | — | — | — | + +## Bloqueos que requieren decisión inmediata + +| Bloqueo | Impacto | Dueño para resolver | Decisión / fecha | +| --- | --- | --- | --- | +| Confirmar dónde vivirá el backend Next.js de Acordate sin alterar las plantillas heredadas. | Sin ello no se pueden integrar webhook, agent y scheduler. | Equipo + P5 | Pendiente, antes de implementar módulos. | +| Crear/controlar las cuentas de Telegram Bot, Supabase, OpenAI y Vercel. | Bloquea pruebas reales y deploy. | Equipo | Pendiente. | diff --git a/acordate-qa-checklist.md b/acordate-qa-checklist.md new file mode 100644 index 0000000..c0ad315 --- /dev/null +++ b/acordate-qa-checklist.md @@ -0,0 +1,71 @@ +# ACORDATE — Checklist de integración y QA + +Este checklist pertenece a la persona 5. No se marca una prueba como pasada +solo porque un componente responda: debe quedar evidencia verificable en la +columna correspondiente. + +## Convenciones + +| Estado | Significado | +| --- | --- | +| `⬜ Pendiente` | Aún no se probó o depende de otro módulo. | +| `🟡 Bloqueado` | No puede continuar; anotar dueño y causa. | +| `🟢 Pasó` | Resultado observado y evidencia guardada. | +| `🔴 Falló` | Se reprodujo un error; enlazar issue o registro. | + +**Evidencia válida:** captura de Telegram sin secretos, ID de una fila de +Supabase, salida sanitizada del endpoint, o video breve. Nunca pegar tokens, +headers de autorización ni URLs con credenciales. + +## 0. Prevuelo de integración + +| # | Prueba | Responsable | Estado | Evidencia / notas | +| --- | --- | --- | --- | --- | +| 0.1 | `acordate-contracts.md` está aceptado por las personas 1–4. | P5 | ⬜ Pendiente | | +| 0.2 | La rama de integración contiene los cambios requeridos, sin conflictos. | P5 | ⬜ Pendiente | | +| 0.3 | `.env` local contiene todas las variables de Acordate; no se versiona. | P5 | ⬜ Pendiente | | +| 0.4 | Las variables equivalentes están cargadas en el entorno de Vercel/Supabase que corresponda. | P5 | ⬜ Pendiente | | +| 0.5 | `GET /api/health` en la URL desplegada devuelve `200` y no expone secretos. | P1 + P5 | ⬜ Pendiente | | +| 0.6 | El chequeo de tipos y las pruebas definidas por el proyecto pasan antes de desplegar. | P5 | ⬜ Pendiente | Comando y salida: | + +## 1. Pruebas por frontera + +| # | Frontera | Caso y resultado esperado | Responsable | Estado | Evidencia / notas | +| --- | --- | --- | --- | --- | --- | +| 1.1 | Telegram → webhook | Un mensaje normal llega al endpoint, se identifica el chat y recibe una respuesta mock o real. | P1 | ⬜ Pendiente | | +| 1.2 | Seguridad del webhook | Un request sin `X-Telegram-Bot-Api-Secret-Token` recibe `401`; no crea usuario, memoria ni reminder. | P1 + P5 | ⬜ Pendiente | | +| 1.3 | Backend → agente | “Guardá que necesito cédula” produce la intención `saveMemory` con `userId` y `sourceMessageId`. | P2 | ⬜ Pendiente | | +| 1.4 | Agente → memoria | La tool guarda el contenido y devuelve un `memory.id` del mismo `userId`. | P2 + P3 | ⬜ Pendiente | | +| 1.5 | Memoria → agente | Una búsqueda relacionada devuelve, como máximo, tres memorias del usuario correcto. | P2 + P3 | ⬜ Pendiente | | +| 1.6 | Agente → reminder | “Recordame … en 2 minutos” crea un reminder futuro `pending`, con `context` y `sourceMemoryIds`. | P2 + P4 | ⬜ Pendiente | | +| 1.7 | Scheduler → Telegram | Una pasada autorizada encuentra el reminder vencido, Telegram confirma el envío y el estado pasa a `sent` una sola vez. | P1 + P4 | ⬜ Pendiente | | +| 1.8 | “Hecho” → completado | El `AgentTurn` incluye el último reminder `sent`; la tool deja ese reminder en `completed`. | P1 + P2 + P4 | ⬜ Pendiente | | + +## 2. Prueba end-to-end de la demo + +Ejecutar en el mismo chat de Telegram y guardar una captura/clip continuo. + +| Paso | Acción | Resultado esperado | Estado | Evidencia / notas | +| --- | --- | --- | --- | --- | +| 2.1 | Enviar: “Guardá que para retirar el certificado necesito cédula y comprobante.” | Acordate confirma que guardó la memoria; existe una fila en `memories`. | ⬜ Pendiente | | +| 2.2 | Enviar: “Recordame retirar el certificado en 2 minutos.” | Recupera la memoria y confirma la fecha/hora del reminder; existe una fila `pending`. | ⬜ Pendiente | | +| 2.3 | Esperar o ejecutar una pasada autorizada del scheduler cuando ya esté vencido. | Llega: “🔔 Recordatorio / Retirar certificado / Necesitás llevar cédula y comprobante.” | ⬜ Pendiente | | +| 2.4 | Enviar: “Hecho”. | Responde con confirmación y la misma fila queda `completed`. | ⬜ Pendiente | | +| 2.5 | Enviar “Hecho” otra vez. | No cambia datos y explica que no hay recordatorio activo. | ⬜ Pendiente | | + +## 3. Casos de seguridad, aislamiento y recuperación + +| # | Caso | Resultado esperado | Responsable | Estado | Evidencia / notas | +| --- | --- | --- | --- | --- | --- | +| 3.1 | Usuario B pregunta por el certificado de Usuario A. | `searchMemory` no devuelve datos de A. | P3 + P5 | ⬜ Pendiente | | +| 3.2 | Usuario pide “recordame mañana” sin hora. | El agente pide una hora; no se crea reminder. | P2 | ⬜ Pendiente | | +| 3.3 | Scheduler intenta enviar y Telegram falla. | El reminder queda `failed`, no `sent`; el detalle técnico queda solo en logs. | P1 + P4 | ⬜ Pendiente | | +| 3.4 | Llamada al scheduler sin `CRON_SECRET`. | Devuelve `401` y no procesa reminders. | P4 + P5 | ⬜ Pendiente | | +| 3.5 | Revisión de repo y demo. | No hay `.env`, tokens, IDs privados ni trazas sensibles en Git, capturas o video. | P5 | ⬜ Pendiente | | + +## Criterio para code freeze + +Solo se entra en code freeze cuando 2.1–2.5 están `🟢 Pasó`, 3.1 y 3.2 pasan, +y hay al menos una evidencia de recuperación o error controlado (3.3 o 3.4). +Después: **bug → fix → repetir la prueba afectada → demo**. No se agregan +features nuevas. From 7565b12032ab20ddd7cd1f6a7204e46d0e2c7460 Mon Sep 17 00:00:00 2001 From: Lujan Date: Sat, 12 Sep 2026 13:17:58 -0300 Subject: [PATCH 3/4] Persona 1 --- .env.example | 8 + acordate-plan-4h-paralelo.md | 854 ++++++++++++++++++ apps/web/README.md | 29 + apps/web/package.json | 2 +- .../web/src/app/api/telegram/webhook/route.ts | 18 + apps/web/src/lib/telegram/agent.ts | 9 + apps/web/src/lib/telegram/client.ts | 28 + apps/web/src/lib/telegram/config.ts | 16 + apps/web/src/lib/telegram/types.ts | 33 + apps/web/src/lib/telegram/webhook.test.ts | 65 ++ apps/web/src/lib/telegram/webhook.ts | 73 ++ 11 files changed, 1134 insertions(+), 1 deletion(-) create mode 100644 acordate-plan-4h-paralelo.md create mode 100644 apps/web/src/app/api/telegram/webhook/route.ts create mode 100644 apps/web/src/lib/telegram/agent.ts create mode 100644 apps/web/src/lib/telegram/client.ts create mode 100644 apps/web/src/lib/telegram/config.ts create mode 100644 apps/web/src/lib/telegram/types.ts create mode 100644 apps/web/src/lib/telegram/webhook.test.ts create mode 100644 apps/web/src/lib/telegram/webhook.ts diff --git a/.env.example b/.env.example index 3de9dd5..e57c1bc 100644 --- a/.env.example +++ b/.env.example @@ -89,3 +89,11 @@ EXA_SEARCH_TYPE=fast # Keep the approval server on loopback unless you add your own auth and # trusted-origin boundary. # WEB_APPROVAL_DIR=/absolute/path/to/web-approvals + +# ── ACORDATE · Telegram webhook ── +# Create the bot with @BotFather. Use a long random secret when calling +# setWebhook; Telegram sends it back in every webhook request for verification. +TELEGRAM_BOT_TOKEN= +TELEGRAM_WEBHOOK_SECRET= +# Use only on your computer to process webhook examples without calling Telegram. +TELEGRAM_DRY_RUN=false diff --git a/acordate-plan-4h-paralelo.md b/acordate-plan-4h-paralelo.md new file mode 100644 index 0000000..b3bd01a --- /dev/null +++ b/acordate-plan-4h-paralelo.md @@ -0,0 +1,854 @@ +# ACORDATE --- PLAN DE TRABAJO EXPRESS + +## Hackathon AI Everywhere --- Demo funcional en 4 horas + +## 1. Objetivo + +Construir una demo de punta a punta que demuestre: + +**MEMORIA → RAZONAMIENTO → ACCIÓN → CONTEXTO** + +Flujo: + +1. El usuario escribe por Telegram una información. +2. Acordate la guarda. +3. El usuario pide un recordatorio relacionado. +4. El agente recupera la memoria relevante. +5. Se crea el recordatorio. +6. Llega el aviso por Telegram con el contexto necesario. +7. El usuario dice "Hecho". +8. Acordate marca la tarea como completada. + +**No buscamos construir el producto completo. Buscamos una demo +sólida.** + +------------------------------------------------------------------------ + +# 2. Canal: Telegram + +## ¿Por qué Telegram? + +Para esta demo usamos **Telegram como canal principal**. + +La propuesta original estaba orientada a WhatsApp, pero el propio +documento indica que la elegibilidad de un número paraguayo para este +caso de uso debe validarse y que, si no se resuelve, el panel web sería +el respaldo. + +Para una hackathon con solo 4 horas: + +- Telegram evita depender de la aprobación/elegibilidad de Meta. +- El bot puede recibir y enviar mensajes directamente. +- Es suficiente para demostrar la experiencia conversacional. +- WhatsApp puede quedar como integración futura. + +**Decisión actual: TELEGRAM.** + +------------------------------------------------------------------------ + +# 3. Stack + +## 3.1 Telegram Bot API --- Canal + +### ¿Qué hace? + +Es la puerta de entrada y salida del producto. + +Recibe: + +- mensajes del usuario; +- comandos; +- confirmaciones como "hecho". + +Y permite enviar: + +- respuestas del agente; +- recordatorios; +- mensajes con contexto. + +### Flujo + +``` text +Usuario + ↓ +Telegram + ↓ +Webhook + ↓ +Next.js +``` + +Y para los avisos: + +``` text +Scheduler + ↓ +Backend + ↓ +Telegram + ↓ +Usuario +``` + +Telegram no toma decisiones de IA. Solo transporta los mensajes. + +------------------------------------------------------------------------ + +## 3.2 Next.js + TypeScript --- Backend + +### ¿Qué hace? + +Es el servidor que conecta todas las piezas. + +Responsabilidades: + +- recibir el webhook de Telegram; +- identificar al usuario; +- enviar el mensaje al agente; +- ejecutar las tools; +- conectarse a Supabase; +- devolver respuestas a Telegram; +- exponer endpoints internos; +- manejar errores. + +### ¿Por qué? + +Necesitamos un punto central que coordine: + +``` text +Telegram ↔ Agente ↔ Tools ↔ Supabase +``` + +TypeScript permite mantener tipos claros entre los módulos. + +------------------------------------------------------------------------ + +## 3.3 AI SDK + OpenAI --- Agente + +### ¿Qué hace? + +Es el "cerebro" de Acordate. + +Interpreta mensajes como: + +> "Guardá que para retirar el certificado necesito cédula y +> comprobante." + +o: + +> "Recordame retirar el certificado mañana a las 10." + +El agente decide qué acción ejecutar. + +Ejemplo: + +``` text +Mensaje + ↓ +Agente + ↓ +¿Es información para guardar? + ↓ +saveMemory() +``` + +Otro ejemplo: + +``` text +“Recordame retirar el certificado” + ↓ + searchMemory() + ↓ + encuentra contexto + ↓ + createReminder() +``` + +### Importante + +El modelo **no escribe directamente en la base de datos**. + +Decide qué tool utilizar y con qué parámetros. + +Las tools hacen las operaciones reales. + +------------------------------------------------------------------------ + +## 3.4 Zod --- Validación + +### ¿Qué hace? + +Valida los datos que propone el agente antes de ejecutar una acción. + +Ejemplo: + +``` ts +createReminder({ + title: string, + scheduledAt: string +}) +``` + +Si el agente genera datos inválidos: + +``` text +Agente + ↓ +Zod + ↓ +❌ inválido → no ejecutar +``` + +Esto evita que una decisión incorrecta del modelo rompa la base de +datos. + +### En la demo + +Cada tool debe tener un esquema Zod. + +------------------------------------------------------------------------ + +## 3.5 Supabase + PostgreSQL --- Base de datos + +### ¿Qué hace? + +Guarda el estado permanente de Acordate. + +Para la demo necesitamos como mínimo: + +``` text +users +memories +reminders +``` + +### `users` + +Identifica al usuario de Telegram. + +``` text +id +telegram_id +created_at +``` + +### `memories` + +Guarda información que el usuario quiere que Acordate recuerde. + +``` text +id +user_id +content +source_message +created_at +embedding +``` + +### `reminders` + +Guarda las tareas programadas. + +``` text +id +user_id +title +scheduled_at +status +context +created_at +``` + +Supabase también nos permite administrar PostgreSQL y centralizar el +backend de datos rápidamente. + +------------------------------------------------------------------------ + +## 3.6 pgvector + búsqueda de texto --- Memoria + +### ¿Qué hace? + +Permite que Acordate encuentre información previamente guardada. + +Ejemplo: + +Primero: + +> "Para retirar el certificado necesito cédula y comprobante." + +Después: + +> "¿Qué necesito llevar para buscar el documento?" + +Aunque las palabras no sean exactamente iguales, la memoria puede +encontrar el contenido relacionado. + +La idea es combinar: + +- búsqueda por significado; +- búsqueda por palabras clave. + +El resultado debe conservar la referencia al mensaje original. + +------------------------------------------------------------------------ + +## 3.7 Supabase Cron + Edge Functions --- Recordatorios + +### ¿Qué hace? + +Se ocupa de revisar qué recordatorios ya llegaron a su momento de +ejecución. + +Flujo: + +``` text +Supabase + ↓ +Cron + ↓ +Busca reminders pendientes + ↓ +¿Ya llegó la hora? + ↓ +Sí + ↓ +Enviar mensaje por Telegram + ↓ +Marcar como enviado +``` + +La IA no necesita estar "pensando" constantemente. + +El scheduler se ocupa de ejecutar los recordatorios guardados. + +### Para la demo + +Construir solamente el camino mínimo necesario para generar un aviso +real. + +No implementar un sistema complejo de recurrencias si pone en riesgo la +demo. + +------------------------------------------------------------------------ + +## 3.8 Vercel --- Deploy + +### ¿Qué hace? + +Publica el backend de Next.js en Internet. + +Necesitamos que Telegram pueda llegar al webhook. + +``` text +Telegram + ↓ Internet +Vercel + ↓ +Next.js +``` + +También permite tener una URL pública para la demo. + +------------------------------------------------------------------------ + +# 4. Tools mínimas + +Para las 4 horas: + +### `saveMemory()` + +Guarda información del usuario. + +``` text +Usuario → “Guardá que necesito cédula” + ↓ +saveMemory() + ↓ +Supabase +``` + +### `searchMemory()` + +Busca información guardada. + +``` text +Usuario → “¿Qué necesito llevar?” + ↓ +searchMemory() + ↓ +Memoria relevante +``` + +### `createReminder()` + +Crea un recordatorio. + +``` text +Usuario → “Recordame mañana a las 10” + ↓ +createReminder() + ↓ +Supabase +``` + +### `completeReminder()` + +Marca la tarea como realizada. + +``` text +Usuario → “Hecho” + ↓ +completeReminder() + ↓ +status = completed +``` + +### Dejar para después + +``` text +listReminders() +updateReminder() +cancelReminder() +``` + +------------------------------------------------------------------------ + +# 5. Roles del equipo + +Somos 5 personas y **todos trabajan simultáneamente**. + +No hay una etapa donde "uno termina y recién después empieza el otro". + +## PERSONA 1 --- Telegram + Backend + +### ¿Para qué existe este rol? + +Construye el canal que conecta al usuario con el sistema. + +### Hace + +- Crear/configurar Telegram Bot. +- Webhook. +- Endpoint de entrada. +- Identificación del usuario. +- Envío de respuestas. +- Conexión Telegram → Next.js. +- Manejo básico de errores. + +### Entregable + +``` text +Telegram + ↓ +Next.js + ↓ +respuesta + ↓ +Telegram +``` + +Puede usar respuestas mock mientras el agente todavía está siendo +construido. + +------------------------------------------------------------------------ + +# PERSONA 2 --- Agente + IA + +### ¿Para qué existe este rol? + +Construye el cerebro que interpreta el lenguaje natural y decide qué +herramienta usar. + +### Hace + +- Prompt del sistema. +- AI SDK. +- Integración con OpenAI. +- Tool calling. +- Las 4 tools mínimas. +- Zod. +- Manejo de información faltante. + +### Entregable + +``` text +Mensaje + ↓ +Agente + ↓ +Tool correcta + ↓ +Resultado +``` + +Puede trabajar completamente aislado de Telegram usando mensajes de +prueba. + +------------------------------------------------------------------------ + +# PERSONA 3 --- Supabase + Memoria + +### ¿Para qué existe este rol? + +Construye la memoria permanente y la base de datos. + +### Hace + +- Crear proyecto Supabase. +- Crear tablas. +- Relaciones. +- `saveMemory`. +- `searchMemory`. +- pgvector. +- búsqueda de texto. +- Datos de prueba. + +### Entregable + +``` text +saveMemory() + ↓ +Supabase + ↓ +searchMemory() + ↓ +memoria encontrada +``` + +Puede trabajar sin esperar al agente. + +------------------------------------------------------------------------ + +# PERSONA 4 --- Recordatorios + Scheduler + +### ¿Para qué existe este rol? + +Hace que Acordate no solamente "responda", sino que **haga algo +después**. + +### Hace + +- `createReminder()`. +- `completeReminder()`. +- Estructura de reminders. +- Scheduler. +- Supabase Cron/Edge Function. +- Trigger de envío. +- Estado del reminder. + +### Entregable + +``` text +createReminder() + ↓ +Supabase + ↓ +Scheduler + ↓ +Telegram + ↓ +🔔 Aviso +``` + +Puede usar un `user_id` y datos mock mientras Telegram todavía no está +conectado. + +------------------------------------------------------------------------ + +# PERSONA 5 --- Integración + QA + Deploy + +### ¿Para qué existe este rol? + +Evita que los cuatro módulos funcionen individualmente pero fallen +cuando se conectan. + +### Hace desde el minuto 0 + +- Crear/revisar repositorio. +- Configurar ramas. +- Variables `.env`. +- Vercel. +- Revisar contratos. +- Probar endpoints. +- Integrar ramas. +- Detectar errores. +- Preparar datos de demo. +- Preparar el guion. +- Hacer pruebas end-to-end. + +### Importante + +Esta persona **no espera hasta el final** para integrar. + +Va probando constantemente. + +------------------------------------------------------------------------ + +# 6. Cómo trabajar EN SIMULTÁNEO + +## Minuto 0--20: contrato común + +Los 5 se reúnen únicamente para definir: + +### Flujo + +``` text +Guardar memoria + ↓ +Crear recordatorio + ↓ +Recuperar memoria + ↓ +Enviar aviso + ↓ +Completar +``` + +### Tools + +``` text +saveMemory +searchMemory +createReminder +completeReminder +``` + +### Datos + +``` text +users +memories +reminders +``` + +### Contratos + +Todos acuerdan exactamente los inputs/outputs. + +Ejemplo: + +``` ts +saveMemory({ + userId: string, + content: string +}) +``` + +``` ts +searchMemory({ + userId: string, + query: string +}) +``` + +``` ts +createReminder({ + userId: string, + title: string, + scheduledAt: string +}) +``` + +``` ts +completeReminder({ + userId: string, + reminderId: string +}) +``` + +Después de esto: + +**TODOS SE SEPARAN Y TRABAJAN EN PARALELO.** + +------------------------------------------------------------------------ + +# 7. Trabajo paralelo por bloques + +## 0:20--1:20 + +``` text +PERSONA 1 → Telegram + webhook +PERSONA 2 → Agente + tools +PERSONA 3 → Supabase + memoria +PERSONA 4 → Reminders + scheduler +PERSONA 5 → Deploy + integración + QA +``` + +Nadie espera a nadie. + +------------------------------------------------------------------------ + +## 1:20--2:00 --- Primera integración + +Se conectan los módulos: + +``` text +Telegram + ↓ +Backend + ↓ +Agente + ↓ +saveMemory + ↓ +Supabase +``` + +Objetivo: + +> "Guardá que para retirar el certificado necesito cédula y +> comprobante." + +Debe quedar guardado. + +------------------------------------------------------------------------ + +## 2:00--2:40 --- Segunda integración + +Ahora: + +``` text +Telegram + ↓ +Agente + ↓ +searchMemory + ↓ +createReminder + ↓ +Supabase +``` + +Objetivo: + +> "Recordame retirar el certificado mañana a las 10." + +El agente recupera el contexto y crea el reminder. + +------------------------------------------------------------------------ + +## 2:40--3:20 --- Aviso + +Conectar: + +``` text +Supabase + ↓ +Scheduler + ↓ +Telegram +``` + +Debe llegar: + +> 🔔 Recordatorio\ +> Retirar certificado.\ +> Necesitás llevar cédula y comprobante. + +------------------------------------------------------------------------ + +## 3:20--3:40 --- Completar + +``` text +“Hecho” + ↓ +completeReminder() + ↓ +status = completed +``` + +------------------------------------------------------------------------ + +## 3:40--4:00 --- CODE FREEZE + +No se agregan funcionalidades. + +Solo: + +``` text +BUG → FIX → TEST → DEMO +``` + +------------------------------------------------------------------------ + +# 8. Arquitectura final + +``` text + ┌──────────────┐ + │ USUARIO │ + └──────┬───────┘ + │ + ▼ + ┌──────────────┐ + │ TELEGRAM │ + └──────┬───────┘ + │ + ▼ + ┌────────────────────┐ + │ Next.js / Backend │ + └─────────┬──────────┘ + │ + ▼ + ┌────────────────────┐ + │ AI SDK + OpenAI │ + │ AGENTE │ + └─────────┬──────────┘ + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + saveMemory() searchMemory() createReminder() + │ │ │ + └─────────────────┼─────────────────┘ + ▼ + ┌────────────┐ + │ SUPABASE │ + │ PostgreSQL │ + └─────┬──────┘ + │ + ▼ + ┌────────────┐ + │ CRON │ + └─────┬──────┘ + │ + ▼ + ┌────────────┐ + │ TELEGRAM │ + └─────┬──────┘ + │ + ▼ + 🔔 AVISO +``` + +------------------------------------------------------------------------ + +# 9. Qué NO hacemos + +Durante estas 4 horas quedan fuera: + +- ❌ Frontend web +- ❌ WhatsApp +- ❌ Audio +- ❌ PDF/documentos +- ❌ Auth +- ❌ RAG complejo +- ❌ Recurrencias complejas +- ❌ Las 7 tools completas +- ❌ Dashboard +- ❌ Features secundarias + +------------------------------------------------------------------------ + +# 10. Regla principal + +> **NO estamos construyendo Acordate completo.** +> +> Estamos construyendo una demo funcional que demuestra: +> +> **MEMORIA → RAZONAMIENTO → ACCIÓN → CONTEXTO** + +Si el usuario puede: + +**guardar algo → pedir un recordatorio relacionado → recibirlo con +contexto → marcarlo como hecho** + +entonces tenemos una demo. diff --git a/apps/web/README.md b/apps/web/README.md index 8b9eb9f..f9950fa 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -19,6 +19,35 @@ MODEL=gpt-5.6-sol AMBIGUOUS_API_KEY=your-workspace-key ``` +## Acordate: Telegram channel + +The hackathon project uses Telegram as its conversation surface. Persona 1 owns +`POST /api/telegram/webhook`: it validates Telegram's optional secret header, +maps `message.from.id` to the stable application `userId`, calls the agent +contract, and replies to `message.chat.id`. Non-text updates are acknowledged +without running the agent. + +Add these root `.env` values before connecting the bot: + +```dotenv +TELEGRAM_BOT_TOKEN=token-from-botfather +TELEGRAM_WEBHOOK_SECRET=a-long-random-value +``` + +For a safe local webhook check, set `TELEGRAM_DRY_RUN=true`; the endpoint will +process the update and log the outgoing reply without contacting Telegram. + +Deploy first, then configure Telegram with the public HTTPS URL: + +```text +https://api.telegram.org/bot/setWebhook?url=https:///api/telegram/webhook&secret_token= +``` + +The initial adapter in `src/lib/telegram/agent.ts` is deliberately a mock. +Persona 2 replaces it with the AI/tool implementation while preserving +`({ userId, message }) => Promise<{ text }>`. `sendTelegramMessage` in +`src/lib/telegram/client.ts` is also the integration point for the scheduler. + Choose an OpenAI model your account can use. Use a demo workspace you control for the first write. This web template needs no managed Channel or Intelligence account. To add managed conversation persistence, use the [official Intelligence onboarding prompt](../../README.md#copilotkit-onboarding) with `apps/web` as the selected app. It connects this existing Next.js/CopilotKit app; keep the Ambiguous record workflow and page approval. Saving a task in Ambiguous and persisting a conversation in Intelligence are separate capabilities. diff --git a/apps/web/package.json b/apps/web/package.json index 101d52e..4143989 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "node --env-file-if-exists=../../.env ../../node_modules/next/dist/bin/next start -p 3100 -H 127.0.0.1", "typecheck": "tsc --noEmit", - "test": "node --import tsx --test src/lib/*.test.ts src/lib/server/*.test.ts src/components/streamed-cards.test.ts", + "test": "node --import tsx --test src/lib/*.test.ts src/lib/server/*.test.ts src/lib/telegram/*.test.ts src/components/streamed-cards.test.ts", "check:workplace": "node --env-file-if-exists=../../.env --import tsx scripts/check-workplace.ts" }, "dependencies": { diff --git a/apps/web/src/app/api/telegram/webhook/route.ts b/apps/web/src/app/api/telegram/webhook/route.ts new file mode 100644 index 0000000..b207dd7 --- /dev/null +++ b/apps/web/src/app/api/telegram/webhook/route.ts @@ -0,0 +1,18 @@ +import { handleAcordateMessage } from "@/lib/telegram/agent"; +import { getTelegramConfig } from "@/lib/telegram/config"; +import { createTelegramWebhookHandler } from "@/lib/telegram/webhook"; + +export const runtime = "nodejs"; + +const handler = createTelegramWebhookHandler({ + config: getTelegramConfig(), + agent: handleAcordateMessage, +}); + +export async function POST(request: Request) { + return handler(request); +} + +export function GET() { + return Response.json({ status: "ok", service: "acordate-telegram-webhook" }); +} diff --git a/apps/web/src/lib/telegram/agent.ts b/apps/web/src/lib/telegram/agent.ts new file mode 100644 index 0000000..ea32faa --- /dev/null +++ b/apps/web/src/lib/telegram/agent.ts @@ -0,0 +1,9 @@ +import type { AcordateAgent } from "./types"; + +/** + * Temporary seam for Persona 2. It keeps Telegram testable before OpenAI and + * the four tools are connected. Replace only this implementation at integration. + */ +export const handleAcordateMessage: AcordateAgent = async ({ message }) => ({ + text: `Recibí: “${message}”. Estoy conectando tu mensaje con Acordate.`, +}); diff --git a/apps/web/src/lib/telegram/client.ts b/apps/web/src/lib/telegram/client.ts new file mode 100644 index 0000000..6582b0b --- /dev/null +++ b/apps/web/src/lib/telegram/client.ts @@ -0,0 +1,28 @@ +import type { TelegramConfig } from "./config"; + +type FetchLike = typeof fetch; + +/** Sends a plain text message. Reused by the webhook and the reminders scheduler. */ +export async function sendTelegramMessage( + config: TelegramConfig, + chatId: string, + text: string, + fetcher: FetchLike = fetch, +): Promise { + if (config.dryRun) { + console.info("Telegram dry run", { chatId, text }); + return; + } + const response = await fetcher( + `https://api.telegram.org/bot${config.botToken}/sendMessage`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ chat_id: chatId, text }), + }, + ); + + if (!response.ok) { + throw new Error(`Telegram sendMessage failed with HTTP ${response.status}`); + } +} diff --git a/apps/web/src/lib/telegram/config.ts b/apps/web/src/lib/telegram/config.ts new file mode 100644 index 0000000..3f9b7c1 --- /dev/null +++ b/apps/web/src/lib/telegram/config.ts @@ -0,0 +1,16 @@ +export type TelegramConfig = { + botToken: string; + webhookSecret: string | undefined; + dryRun: boolean; +}; + +export function getTelegramConfig(env = process.env): TelegramConfig | undefined { + const botToken = env.TELEGRAM_BOT_TOKEN?.trim(); + if (!botToken) return undefined; + + return { + botToken, + webhookSecret: env.TELEGRAM_WEBHOOK_SECRET?.trim() || undefined, + dryRun: env.TELEGRAM_DRY_RUN === "true", + }; +} diff --git a/apps/web/src/lib/telegram/types.ts b/apps/web/src/lib/telegram/types.ts new file mode 100644 index 0000000..39ac759 --- /dev/null +++ b/apps/web/src/lib/telegram/types.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +export const telegramUpdateSchema = z + .object({ + update_id: z.number().int(), + message: z + .object({ + message_id: z.number().int(), + date: z.number().int(), + chat: z.object({ id: z.number().int() }), + from: z.object({ id: z.number().int() }).optional(), + text: z.string().max(4_096).optional(), + }) + .optional(), + }) + .passthrough(); + +export type TelegramUpdate = z.infer; + +export type IncomingTelegramMessage = { + chatId: string; + telegramUserId: string; + text: string; +}; + +/** + * Contract consumed by Persona 1. Persona 2 can replace the mock implementation + * without changing the Telegram webhook. + */ +export type AcordateAgent = (input: { + userId: string; + message: string; +}) => Promise<{ text: string }>; diff --git a/apps/web/src/lib/telegram/webhook.test.ts b/apps/web/src/lib/telegram/webhook.test.ts new file mode 100644 index 0000000..9c1725d --- /dev/null +++ b/apps/web/src/lib/telegram/webhook.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTelegramWebhookHandler } from "./webhook"; + +const config = { + botToken: "test-token", + webhookSecret: "test-secret", + dryRun: true, +}; +const update = { + update_id: 10, + message: { + message_id: 20, + date: 1, + chat: { id: 12345 }, + from: { id: 67890 }, + text: " Guardá mi documento ", + }, +}; +const request = (body: unknown, secret = "test-secret") => + new Request("http://localhost/api/telegram/webhook", { + method: "POST", + headers: { "content-type": "application/json", "x-telegram-bot-api-secret-token": secret }, + body: JSON.stringify(body), + }); + +test("webhook identifies the Telegram user and returns the agent result to the chat", async () => { + const sent: Array<{ chatId: string; text: string }> = []; + const handler = createTelegramWebhookHandler({ + config, + agent: async (input) => { + assert.deepEqual(input, { userId: "67890", message: "Guardá mi documento" }); + return { text: "Listo" }; + }, + send: async (chatId, text) => void sent.push({ chatId, text }), + }); + const response = await handler(request(update)); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true, dryRun: true, reply: "Listo" }); + assert.deepEqual(sent, [{ chatId: "12345", text: "Listo" }]); +}); + +test("webhook rejects an incorrect secret before reading the update", async () => { + let called = false; + const handler = createTelegramWebhookHandler({ + config, + agent: async () => { + called = true; + return { text: "unused" }; + }, + send: async () => {}, + }); + assert.equal((await handler(request(update, "wrong-secret"))).status, 401); + assert.equal(called, false); +}); + +test("webhook safely acknowledges unsupported and malformed updates", async () => { + const handler = createTelegramWebhookHandler({ + config, + agent: async () => ({ text: "unused" }), + send: async () => assert.fail("should not send"), + }); + assert.equal((await handler(request({ update_id: 1 }))).status, 200); + assert.equal((await handler(request({ nope: true }))).status, 400); +}); diff --git a/apps/web/src/lib/telegram/webhook.ts b/apps/web/src/lib/telegram/webhook.ts new file mode 100644 index 0000000..0547ccb --- /dev/null +++ b/apps/web/src/lib/telegram/webhook.ts @@ -0,0 +1,73 @@ +import { ZodError } from "zod"; +import { sendTelegramMessage } from "./client"; +import type { TelegramConfig } from "./config"; +import { telegramUpdateSchema, type AcordateAgent } from "./types"; + +const genericError = "No pude procesar tu mensaje. Intentá de nuevo en un momento."; + +export function createTelegramWebhookHandler(options: { + config: TelegramConfig | undefined; + agent: AcordateAgent; + send?: (chatId: string, text: string) => Promise; +}) { + const send = + options.send ?? + (options.config + ? (chatId: string, text: string) => + sendTelegramMessage(options.config!, chatId, text) + : undefined); + + return async (request: Request): Promise => { + if (!options.config || !send) { + return Response.json( + { error: "Telegram is not configured on this server." }, + { status: 503 }, + ); + } + if ( + options.config.webhookSecret && + request.headers.get("x-telegram-bot-api-secret-token") !== + options.config.webhookSecret + ) { + return Response.json({ error: "Unauthorized webhook." }, { status: 401 }); + } + + try { + const body = await request.json(); + const update = telegramUpdateSchema.parse(body); + const message = update.message; + // Telegram also sends edited messages, callbacks, and non-text content. + // Acknowledge them to prevent retries, but do not treat them as commands. + if (!message?.text?.trim() || !message.from) { + return Response.json({ ok: true, ignored: true }); + } + + const chatId = String(message.chat.id); + const userId = String(message.from.id); + let replyPreview: string | undefined; + try { + const result = await options.agent({ userId, message: message.text.trim() }); + replyPreview = result.text; + await send(chatId, result.text); + } catch (error) { + console.error("Telegram message handling failed", error); + try { + await send(chatId, genericError); + } catch { + console.error("Telegram fallback reply failed"); + } + } + return Response.json( + options.config.dryRun + ? { ok: true, dryRun: true, reply: replyPreview ?? genericError } + : { ok: true }, + ); + } catch (error) { + if (error instanceof ZodError || error instanceof SyntaxError) { + return Response.json({ error: "Invalid Telegram update." }, { status: 400 }); + } + console.error("Telegram webhook failed", error); + return Response.json({ error: "Webhook processing failed." }, { status: 500 }); + } + }; +} From 5c1cbfcae3a5afa40de03ee3caa6b1d430b7d06c Mon Sep 17 00:00:00 2001 From: Maria Lujan Melgarejo Acosta Date: Sat, 12 Sep 2026 14:01:48 -0300 Subject: [PATCH 4/4] feat: add reminders package --- packages/reminders/package.json | 13 +++ packages/reminders/src/index.test.ts | 59 ++++++++++++++ packages/reminders/src/index.ts | 117 +++++++++++++++++++++++++++ packages/reminders/tsconfig.json | 14 ++++ 4 files changed, 203 insertions(+) create mode 100644 packages/reminders/package.json create mode 100644 packages/reminders/src/index.test.ts create mode 100644 packages/reminders/src/index.ts create mode 100644 packages/reminders/tsconfig.json diff --git a/packages/reminders/package.json b/packages/reminders/package.json new file mode 100644 index 0000000..88be82e --- /dev/null +++ b/packages/reminders/package.json @@ -0,0 +1,13 @@ +{ + "name": "reminders", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test 'src/**/*.test.ts'" + } +} diff --git a/packages/reminders/src/index.test.ts b/packages/reminders/src/index.test.ts new file mode 100644 index 0000000..d47792c --- /dev/null +++ b/packages/reminders/src/index.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + completeReminder, + createReminder, + createReminderScheduler, + type Reminder, + type ReminderRepository, +} from "./index"; + +const reminder: Reminder = { + id: "r-1", userId: "u-1", title: "Retirar certificado", scheduledAt: "2026-09-12T16:00:00.000Z", + status: "pending", context: "Llevá cédula.", sourceMemoryIds: [], sentAt: null, completedAt: null, + createdAt: "2026-09-12T15:00:00.000Z", +}; + +test("scheduler sends claimed reminders and marks them sent only after delivery", async () => { + const calls: string[] = []; + const repository: ReminderRepository = { + create: async () => reminder, + completeSent: async () => "not-found", + claimDue: async () => [reminder], + markDelivered: async (id) => { calls.push(`sent:${id}`); }, + markDeliveryFailed: async (id) => { calls.push(`failed:${id}`); }, + }; + const scheduler = createReminderScheduler({ + repository, + notificationSender: { send: async (item) => { calls.push(`send:${item.reminderId}`); assert.match(item.text, /Respondé “Hecho”/); } }, + now: () => new Date("2026-09-12T16:00:01.000Z"), + }); + assert.deepEqual(await scheduler.runDue(), { processed: 1, sent: 1, failed: 0 }); + assert.deepEqual(calls, ["send:r-1", "sent:r-1"]); +}); + +test("scheduler records failed delivery and never marks it sent", async () => { + const calls: string[] = []; + const repository: ReminderRepository = { + create: async () => reminder, + completeSent: async () => "not-found", + claimDue: async () => [reminder], + markDelivered: async () => { calls.push("sent"); }, + markDeliveryFailed: async () => { calls.push("failed"); }, + }; + const scheduler = createReminderScheduler({ repository, notificationSender: { send: async () => { throw new Error("Telegram unavailable"); } } }); + assert.deepEqual(await scheduler.runDue(), { processed: 1, sent: 0, failed: 1 }); + assert.deepEqual(calls, ["failed"]); +}); + +test("tools validate a future reminder and only complete sent reminders", async () => { + const repository: ReminderRepository = { + create: async () => reminder, + completeSent: async () => ({ ...reminder, status: "completed", completedAt: "2026-09-12T17:00:00.000Z" }), + claimDue: async () => [], markDelivered: async () => {}, markDeliveryFailed: async () => {}, + }; + const invalid = await createReminder(repository, { userId: "u", title: "x", context: "y", sourceMemoryIds: [], sourceMessageId: "m", scheduledAt: "2026-09-12T15:00:00.000Z" }, new Date("2026-09-12T15:00:00.000Z")); + assert.equal(invalid.ok, false); + const completed = await completeReminder(repository, { userId: "u-1", reminderId: "r-1" }, new Date("2026-09-12T17:00:00.000Z")); + assert.equal(completed.ok, true); +}); diff --git a/packages/reminders/src/index.ts b/packages/reminders/src/index.ts new file mode 100644 index 0000000..441600b --- /dev/null +++ b/packages/reminders/src/index.ts @@ -0,0 +1,117 @@ +export type ReminderStatus = "pending" | "sent" | "completed" | "failed"; + +export type Reminder = { + id: string; + userId: string; + title: string; + scheduledAt: string; + status: ReminderStatus; + context: string; + sourceMemoryIds: string[]; + sentAt: string | null; + completedAt: string | null; + createdAt: string; +}; + +export type ToolFailure = { + ok: false; + code: "VALIDATION_ERROR" | "NOT_FOUND" | "NO_ACTIVE_REMINDER" | "CONFLICT" | "UNAVAILABLE"; + message: string; +}; + +export type CreateReminderInput = { + userId: string; + title: string; + scheduledAt: string; + context: string; + sourceMemoryIds: string[]; + sourceMessageId: string; +}; + +export type CompleteReminderInput = { userId: string; reminderId: string }; + +export interface ReminderRepository { + create(input: CreateReminderInput): Promise; + completeSent(input: CompleteReminderInput, completedAt: string): Promise; + /** Atomically reserves due reminders. A reserved reminder has a single delivery row. */ + claimDue(now: string, limit: number): Promise; + markDelivered(reminderId: string, deliveredAt: string): Promise; + markDeliveryFailed(reminderId: string, failedAt: string, error: unknown): Promise; +} + +export interface NotificationSender { + send(notification: { userId: string; reminderId: string; text: string }): Promise; +} + +export function createReminder( + repository: ReminderRepository, + input: CreateReminderInput, + now = new Date(), +): Promise<{ ok: true; reminder: Reminder } | ToolFailure> { + if (!input.userId || !input.title.trim() || !input.context.trim() || !input.sourceMessageId) { + return Promise.resolve({ ok: false, code: "VALIDATION_ERROR", message: "Faltan datos para crear el recordatorio." }); + } + const scheduledAt = new Date(input.scheduledAt); + if (Number.isNaN(scheduledAt.getTime()) || scheduledAt <= now) { + return Promise.resolve({ ok: false, code: "VALIDATION_ERROR", message: "La fecha del recordatorio debe ser futura." }); + } + return repository.create({ ...input, title: input.title.trim(), context: input.context.trim() }) + .then((reminder) => ({ ok: true as const, reminder })) + .catch(() => ({ ok: false as const, code: "UNAVAILABLE" as const, message: "No pude guardar el recordatorio. Intentá de nuevo." })); +} + +export async function completeReminder( + repository: ReminderRepository, + input: CompleteReminderInput, + now = new Date(), +): Promise<{ ok: true; reminder: Reminder } | ToolFailure> { + const result = await repository.completeSent(input, now.toISOString()).catch(() => "unavailable" as const); + if (result === "unavailable") return { ok: false, code: "UNAVAILABLE", message: "No pude completar el recordatorio. Intentá de nuevo." }; + if (result === "not-found") return { ok: false, code: "NO_ACTIVE_REMINDER", message: "No tenés un recordatorio activo para completar." }; + if (result === "conflict") return { ok: false, code: "CONFLICT", message: "Ese recordatorio ya fue completado o no está listo para completar." }; + return { ok: true, reminder: result }; +} + +export function reminderNotificationText(reminder: Reminder): string { + return `🔔 Recordatorio\n${reminder.title}\n${reminder.context}\nRespondé “Hecho” cuando lo completes.`; +} + +export type SchedulerResult = { processed: number; sent: number; failed: number }; + +export function createReminderScheduler(options: { + repository: ReminderRepository; + notificationSender: NotificationSender; + now?: () => Date; + batchSize?: number; +}) { + const now = options.now ?? (() => new Date()); + const batchSize = options.batchSize ?? 50; + return { + async runDue(): Promise { + const startedAt = now().toISOString(); + const reminders = await options.repository.claimDue(startedAt, batchSize); + let sent = 0; + let failed = 0; + for (const reminder of reminders) { + try { + await options.notificationSender.send({ + userId: reminder.userId, + reminderId: reminder.id, + text: reminderNotificationText(reminder), + }); + await options.repository.markDelivered(reminder.id, now().toISOString()); + sent += 1; + } catch (error) { + console.error("Reminder delivery failed", { reminderId: reminder.id, error }); + try { + await options.repository.markDeliveryFailed(reminder.id, now().toISOString(), error); + } catch (markError) { + console.error("Reminder failure state could not be recorded", { reminderId: reminder.id, error: markError }); + } + failed += 1; + } + } + return { processed: reminders.length, sent, failed }; + }, + }; +} diff --git a/packages/reminders/tsconfig.json b/packages/reminders/tsconfig.json new file mode 100644 index 0000000..c7dba5f --- /dev/null +++ b/packages/reminders/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src"] +}