Next.jsTelegramResendRedisTutorial

Cómo añadir a tu web un chat que te llega a Telegram

October 07, 2026 · 11 min read · Angel LM

Cómo añadir a tu web un chat que te llega a Telegram

En mi portfolio, el botón Let's talk abre un chat. Primero pide nombre y email, ofrece unas opciones guiadas y, si la persona quiere contratarme o tiene una propuesta, le envía un enlace para confirmar su email. Al confirmarlo, la conversación me llega a Telegram, contesto desde el móvil y mi respuesta aparece en el chat de la web, solo para esa persona.

En este post te cuento cómo está construido, con el código esencial, los servicios que usa y los errores que me encontré por el camino.

Qué vamos a construir

El flujo completo es este:

  1. El visitante abre el chat y escribe su nombre y su email.
  2. Elige una opción. Las informativas (tarifas, proyectos) se responden ahí mismo, sin molestarte.
  3. Si elige una que requiere hablar contigo, recibe un correo con un enlace de un solo uso.
  4. Al abrir el enlace vuelve a la web, el chat confirma el email y solo entonces se abre la conversación: te llega a Telegram una ficha con su nombre, email y motivo.
  5. Cada mensaje que escribe te llega a Telegram. Tú respondes (reply) a su mensaje y la respuesta aparece en su chat en segundos.

Piezas:

PiezaPara qué
Widget de chat (client component)La interfaz, el flujo guiado y el polling de respuestas
API routes de Next.jsPedir la verificación, verificar, enviar y leer mensajes
Bot de TelegramTe reenvía los mensajes y recibe tus respuestas por webhook
Upstash RedisGuarda sesiones, mensajes y qué mensaje de Telegram es de quién
ResendEnvía el correo de verificación

La idea clave: cada mensaje de Telegram sabe de quién es

En Vercel las funciones no comparten memoria, así que todo el estado vive en Redis. El truco que hace que las respuestas lleguen a la persona correcta es simple:

  • Cada conversación tiene un sid aleatorio (crypto.randomUUID()) que solo conoce el navegador del visitante.
  • Cada vez que el servidor te envía algo a Telegram, guarda message_id → sid.
  • Cuando respondes a un mensaje, Telegram te dice a qué message_id respondiste (reply_to_message). Con eso se recupera el sid y la respuesta va solo a esa conversación.

No hace falta un bot por visitante ni chats de grupo: un solo chat privado contigo y los "reply" hacen de enrutador.

Prepara los servicios

1. El bot. Habla con @BotFather, usa /newbot y guarda el token.

2. Tu chat id. Abre tu bot y pulsa Start (importante, lo explico al final). Luego visita https://api.telegram.org/bot<TOKEN>/getUpdates y copia message.chat.id.

3. Redis. En Vercel: Storage → Upstash Redis → conectar al proyecto. Crea las variables KV_REST_API_URL y KV_REST_API_TOKEN automáticamente.

4. Resend. Añade tu dominio, crea los registros DNS que te indique (si usas Cloudflare, en modo "DNS only") y genera una API key.

.envbash
TELEGRAM_BOT_TOKEN=123456:ABC...
TELEGRAM_CHAT_ID=123456789
TELEGRAM_WEBHOOK_SECRET=una-cadena-larga-y-aleatoria
KV_REST_API_URL=https://xxx.upstash.io
KV_REST_API_TOKEN=...
RESEND_API_KEY=re_...

Para el secreto del webhook basta con openssl rand -hex 32. Solo admite letras, números, _ y -.

Enviar a Telegram y recordar de quién es

bashbash
pnpm add @upstash/redis server-only

Toda la lógica de servidor vive en un único módulo. Redis.fromEnv() lee tanto UPSTASH_REDIS_REST_URL como KV_REST_API_URL:

lib/chat.tsts
import 'server-only'
import { Redis } from '@upstash/redis'
 
const redis = Redis.fromEnv()
const TTL = 60 * 60 * 24 * 30 // 30 días
 
/** Texto plano (sin parse_mode): no hay nada que escapar. Devuelve el message_id. */
export async function sendTelegram(text: string): Promise<number> {
  const res = await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ chat_id: process.env.TELEGRAM_CHAT_ID, text }),
  })
  const data = await res.json()
  if (!data.ok) throw new Error(`Telegram: ${data.description}`)
  return data.result.message_id
}
 
const linkTelegram = (messageId: number, sid: string) => redis.set(`chat:tg:${messageId}`, sid, { ex: TTL })
 
export async function createSession(data: { name: string; email: string; topic: string }) {
  const sid = crypto.randomUUID()
  await redis.set(`chat:${sid}`, { ...data, createdAt: Date.now() }, { ex: TTL })
 
  const messageId = await sendTelegram(
    `🆕 New chat #${sid.slice(0, 6)}\n${data.topic}\n\n👤 ${data.name}\n✉️ ${data.email}\n\n↩️ Reply to this message to answer.`
  )
  await linkTelegram(messageId, sid)
  return sid
}
 
export async function addUserMessage(sid: string, name: string, text: string) {
  await redis.rpush(`chat:${sid}:msgs`, { from: 'user', text, at: Date.now() })
  const messageId = await sendTelegram(`💬 #${sid.slice(0, 6)} · ${name}\n\n${text}`)
  await linkTelegram(messageId, sid)
}

Los mensajes de cada conversación son una lista en Redis (chat:<sid>:msgs). El widget pide los nuevos con LRANGE a partir del último índice que ya vio.

Verificar el email antes de abrir el chat

Sin verificación, cualquiera podría escribirte haciéndose pasar por otra persona. La solución es un enlace mágico:

lib/chat.tsts
const sha256 = async (v: string) =>
  Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(v))).toString('hex')
 
// Dominio fijo: nunca el Host de la petición.
const SITE = process.env.SITE_URL ?? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
 
export async function requestVerification(data: { name: string; email: string; topic: string }) {
  const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
  await redis.set(`chat:verify:${await sha256(token)}`, data, { ex: 60 * 30 }) // 30 min
  await sendEmail(data.email, `${SITE}/?chat_token=${token}`)
}
 
export async function verifyAndCreateSession(token: string) {
  // GETDEL: el token solo sirve una vez
  const data = await redis.getdel<{ name: string; email: string; topic: string }>(`chat:verify:${await sha256(token)}`)
  if (!data) return null
  return { sid: await createSession(data), ...data }
}

Tres detalles que importan:

  • Solo se guarda el hash del token. Si alguien lee tu Redis, no puede usar los enlaces pendientes.
  • GETDEL lo consume de forma atómica, así que el enlace no se puede reutilizar.
  • El dominio del enlace es fijo. Si lo construyeras con el Host de la petición, un atacante podría pedir la verificación del email de otra persona con un Host falso, y la víctima recibiría un enlace a otro dominio con un token válido.

Enviar el correo con Resend es una sola llamada fetch, sin SDK:

lib/chat.tsts
async function sendEmail(to: string, link: string) {
  const res = await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      from: 'Tu Nombre <chat@tudominio.com>',
      to,
      subject: 'Confirm your email to chat with me',
      text: `Confirm your email to start the chat:\n${link}\n\nThe link expires in 30 minutes.`,
    }),
  })
  if (!res.ok) throw new Error(`Resend: ${res.status} ${await res.text()}`)
}

El sid solo se crea al verificar. Como el cliente no tiene forma de obtener un sid sin pasar por el enlace, la verificación no se puede saltar desde el navegador.

El webhook: tus respuestas vuelven solo a ese visitante

app/api/telegram/webhook/route.tsts
import { NextResponse } from 'next/server'
import { addOwnerReply, sendTelegram } from '@/lib/chat'
 
export async function POST(req: Request) {
  // Telegram envía el secreto que registraste en setWebhook
  if (req.headers.get('x-telegram-bot-api-secret-token') !== process.env.TELEGRAM_WEBHOOK_SECRET) {
    return NextResponse.json({ ok: false }, { status: 401 })
  }
 
  const { message } = await req.json()
  // Solo mensajes de tu chat privado con el bot
  if (!message?.text || String(message.chat.id) !== process.env.TELEGRAM_CHAT_ID) {
    return NextResponse.json({ ok: true })
  }
 
  const target = message.reply_to_message?.message_id
  const delivered = target ? await addOwnerReply(target, message.message_id, message.text) : false
  if (!delivered) await sendTelegram('⚠️ Not delivered. Reply to a visitor message so I know who it is for.')
 
  return NextResponse.json({ ok: true }) // 200 siempre: si no, Telegram reintenta
}

Y addOwnerReply busca el sid y deja la respuesta en esa conversación:

lib/chat.tsts
export async function addOwnerReply(replyTo: number, ownMessageId: number, text: string) {
  const sid = await redis.get<string>(`chat:tg:${replyTo}`)
  if (!sid) return false
  await redis.rpush(`chat:${sid}:msgs`, { from: 'angel', text, at: Date.now() })
  await linkTelegram(ownMessageId, sid) // también puedes responder a tus propios mensajes del hilo
  return true
}

Tras desplegar, registra el webhook una sola vez:

bashbash
curl "https://api.telegram.org/bot<TOKEN>/setWebhook" -d "url=https://tudominio.com/api/telegram/webhook" -d "secret_token=<TELEGRAM_WEBHOOK_SECRET>" -d 'allowed_updates=["message"]'

Puedes comprobarlo con getWebhookInfo: debe mostrar tu URL, pending_update_count: 0 y ningún error.

El widget

El chat es un client component montado en el layout raíz. Algunas decisiones que simplifican mucho:

  • Se abre con un evento. El botón del navbar llama a window.dispatchEvent(new Event('open-chat')), así que no hace falta un contexto global.
  • La conversación vive en localStorage: sobrevive a recargas y, con el evento storage, se sincroniza entre pestañas. Por ejemplo, cuando el enlace del email se abre en otra pestaña.
  • Polling, no WebSockets. Cada 4 segundos con el chat abierto, cada 20 con el chat cerrado y nada si la pestaña está oculta. Para un chat de contacto es más que suficiente y funciona en serverless sin infraestructura extra.
  • La vuelta del enlace se detecta al montar: si la URL trae ?chat_token=, se quita de la URL con history.replaceState (es de un solo uso), se llama a /api/chat/verify y se abre el chat ya conectado.
components/chat/chat-widget.tsxtsx
useEffect(() => {
  if (!sid) return
  const poll = async () => {
    if (document.hidden) return
    const res = await fetch(`/api/chat/messages?sid=${sid}&after=${serverCount}`, { cache: 'no-store' })
    if (!res.ok) return
    const { messages } = await res.json()
    // añade las respuestas nuevas y avanza el índice
  }
  const id = setInterval(poll, open ? 4000 : 20000)
  poll()
  return () => clearInterval(id)
}, [sid, serverCount, open])

Un detalle que me mordió: si cargas el estado desde localStorage en un useEffect y lo guardas en otro, el primer guardado puede pisar lo guardado con el estado inicial. Solución: no guardar mientras el estado sea la referencia exacta del estado inicial (if (state === INITIAL) return).

Seguridad y límites

  • El sid es un UUID aleatorio: es el "secreto" de cada conversación y nunca se muestra.
  • El webhook rechaza peticiones sin el secret_token y mensajes que no sean de tu chat.
  • Rate limiting con INCR + EXPIRE en Redis: 5 solicitudes por hora por IP, 3 por email, 40 mensajes por conversación y 20 verificaciones por IP.
  • Validación en servidor de longitudes y formato de email.
  • Mensajes a Telegram en texto plano, sin parse_mode: el texto del visitante no puede inyectar formato.
  • Todo caduca a los 30 días en Redis.
  • Si falta configuración, el chat no se rompe: muestra un aviso "offline" con tu email.

Errores que me encontré (para que no te pasen)

Bad Request: chat not found. Un bot no puede escribirte primero. Si nunca le diste a Start en tu bot, cualquier sendMessage a tu chat id falla aunque el id sea correcto.

Las respuestas no vuelven a la web. El webhook solo funciona con una URL pública: Telegram no puede llamar a tu localhost. Si tu entorno local y producción comparten la misma base de Redis, tus respuestas desde Telegram (que entran por producción) aparecen también en el chat local.

getaddrinfo ENOTFOUND xxx.upstash.io". Fíjate en la comilla al final del host: la variable tenía una " de más en el .env. Revisa que los valores no lleven comillas sueltas.

Configuré las variables y sigue diciendo que faltan. En Vercel las variables se aplican al desplegar: hay que hacer redeploy. Y comprueba el entorno: los previews no ven las variables que solo marcaste para Production.

El indicador de diagnóstico. Para depurar esto, la API responde 503 con { "missing": ["TELEGRAM_BOT_TOKEN"] }: solo los nombres de las variables que faltan, nunca los valores. Me ahorró adivinar.

Conclusiones

Con Telegram, Upstash y Resend, cada uno con su plan gratuito, tienes un chat de contacto que te llega al móvil, verifica a quien te escribe y no necesita ni base de datos propia ni servidor de WebSockets. La pieza que lo hace funcionar es pequeña: guardar message_id → sid y usar los reply de Telegram como enrutador.

Pruébalo en el botón Let's talk de esta web.

Sin escribir todo esto: Holagram

Empaqueté todo este flujo en una librería, Holagram, y el chat de esta web ya funciona con ella. Creas el bot, pones las variables de entorno y añades dos piezas:

bashbash
pnpm add @angelitolm/holagram
app/api/holagram/[action]/route.tsts
import { createHolagram } from '@angelitolm/holagram/server'
 
export const { GET, POST } = createHolagram({ ownerName: 'Ana' })
app/layout.tsxtsx
import { Holagram } from '@angelitolm/holagram'
 
// dentro de <body>, después de {children}
<Holagram owner={{ name: 'Ana', status: 'Suelo responder en unas horas' }} />

Trae la verificación por email, el enrutado por reply, los límites de uso, textos en español y una CLI para el bot (npx holagram chat-id, npx holagram webhook https://tu-dominio.com). Puedes probarla sin instalar nada en la demo en vivo, y el código está en GitHub.

Imagen: Shyam Mishra en Unsplash.

A

Written by

Angel LM

Full-stack developer passionate about creating beautiful and functional web applications. Sharing knowledge about React, Next.js, and modern web development.