Todos estos apuntan a ctx.from y devuelven Promise<SentMessage>.
ctx.reply es la respuesta de texto (say, answer y text son alias obsoletos):
await ctx.reply('👋 ¡Hola!')await ctx.quote('respondiendo a tu mensaje') // cita el mensaje entranteawait ctx.reply('escribiendo esto...', { typing: true }) // muestra "escribiendo…" antes de enviar
Cada envío (estos helpers, bot.send, las difusiones, ctx.edit) se revisa
después de los hooks before('send') y antes del transporte: un contenido que
ningún transporte puede entregar (un text que no es string, como
ctx.reply(undefined); media sin origen; coordenadas fuera de ±90/±180; una
encuesta o un mensaje de botones sin opciones) rechaza con un TypeError que
nombra el campo, y no se envía nada. Los límites de cada transporte (número de
botones, tamaño de la media) los sigue comprobando cada provider; un texto más
largo de lo que admite el canal se divide solo (ver Texto largo). Se aceptan
todas las formas de MediaSource, incluida { native } (una referencia propia
del provider, como un file_id de Telegram).Una respuesta de ctx que recibe un RateLimitedError con una espera de hasta
5 s se reintenta una vez en el sitio, para que un paso de flujo no termine por un
límite de envío pasajero (se ajusta con BotConfig.rateLimitRetry: { retries, maxWaitMs } o se desactiva con false). Las esperas más largas y el resto de
errores rechazan como antes; bot.send() no se reintenta (las difusiones tienen
su propio back-off).
Cada canal limita la longitud de un mensaje de texto (capabilities.maxTextLength en
unidades UTF-16, y capabilities.maxTextBytes en bytes UTF-8 en Instagram). Un envío
text más largo —una respuesta de ctx, un .say de flujo, bot.send o una difusión—
sale como varios mensajes, en orden, cortados en el límite más natural que cabe: párrafo,
línea, frase y, por último, palabra. Un corte nunca parte un emoji (pares sustitutos,
secuencias ZWJ, banderas, tonos de piel) ni el formato del chat: los enlaces y las URL
quedan enteros, y un bloque de código, un código en línea, un *negrita* o un elemento
HTML más largo que el límite se cierra al final de una parte y se reabre al inicio de la
siguiente.
const sent = await ctx.reply(respuestaLarga) // 9000 caracteres en Telegram → 3 mensajessent.id // la última partesent.parts // el ref de cada parte, en orden (solo si el texto se dividió)
Los hooks before('send') corren una vez, sobre el texto completo, antes de
dividirlo: un hook que añade un pie o traduce el texto cambia el mensaje entero, y lo que
devuelve es lo que se divide, así que cada parte sigue cabiendo (si un hook alarga un
texto corto por encima del límite, también se divide). Un abort() omite el texto
entero. Después cada parte es un envío propio: los hooks after('send'), el log de
mensajes y el outbox durable ven cada parte. Solo la primera parte conserva quoted; un
idempotencyKey explícito recibe un sufijo #2, #3… desde la segunda parte (desde
#1 cuando fue un hook el que alargó el texto). Si una parte falla, el envío rechaza y
las partes restantes no se envían; con durabilidad, si el outbox difiere una parte
(SendDeferredError), las restantes quedan anotadas detrás de ella y el barrido las
entrega todas, en orden. bot.enqueue() también divide: anota un registro por parte
(key, key#2, …). El texto que cabe se envía intacto. ctx.edit nunca se divide y ctx.stream() divide por su cuenta. Se
desactiva con new Bot({ splitLongText: false }) (el provider rechaza entonces un texto
largo con PayloadTooLargeError). Para dividir texto tú mismo, usa
splitMessage(texto, { maxLength, maxBytes, format }) de @botmesh/core.
await ctx.photo('https://picsum.photos/400/300', '📸 pie de foto')await ctx.photo('./foto.jpg', 'pie de foto') // archivo localawait ctx.photo(buffer, 'pie de foto') // Buffer
ctx.buttons(text, buttons) envía contenido de botones normalizado. Cómo
aterriza depende del provider:
@botmesh/baileys — los botones/listas interactivos nativos están
muertos en el protocolo actual de WhatsApp Web, así que se degradan a un
menú de texto numerado. Un menú numerado (.choose() en un flow, o
c.choose() en una conversación — ver Flujos) o una encuesta
real suele leerse mejor.
await ctx.buttons('Elige una', [ { id: 'a', title: 'Opción A' }, { id: 'b', title: 'Opción B' },])// Las encuestas son nativas en Baileys y Telegram. La Cloud API no tiene// encuestas: ahí ctx.poll() rechaza con UnsupportedContentError — usa botones.await ctx.poll('¿Qué prefieres?', ['Opción A', 'Opción B'])// Selección múltipleawait ctx.poll('¿Qué te gusta?', ['🍕 Pizza', '🍔 Hamburguesa'], true)
Si un provider no puede honrar un tipo de contenido, send rechaza con un
UnsupportedContentError tipado de @botmesh/core.
BotConfig.typing muestra “escribiendo…” antes de cada respuesta de texto
enviada por ctx (reply y sus alias obsoletos, quote y .say de los flujos).
Dura longitud del texto / charsPerSecond, acotado a [minMs, maxMs]. Viene
desactivado por defecto.
typing: true usa la temporización del bot, typing: false lo suprime y un
objeto lo activa con esos cambios. Un maxMs por respuesta menor que el minMs
del bot baja el mínimo en lugar de lanzar un error.
delay (ms o '500ms', '1.5s') corre antes del indicador. cancelIf: () => boolean
se comprueba después de la espera: si devuelve true, la respuesta se descarta.
Si el indicador falla, el envío no falla: es cosmético y best effort.
En providers sin capabilities.typing no hay indicador ni espera. delay se respeta en todos.
Una respuesta en espera se descarta (resuelve { skipped: true }) si el bot se detiene, el chat pasa a handoff o el flujo se aborta durante la espera. stop({ drain: true }) espera a esas respuestas.
ctx.replyWithTyping(texto, msPorCaracter?) está obsoleto: marca su propio ritmo (40 ms
por carácter por defecto) e ignora BotConfig.typing. Usa ctx.reply(texto, { typing: true }).
Las respuestas largas o generadas por IA se pueden enviar por partes en lugar de
de una vez: ve Streaming.
const msg = await ctx.reply('Procesando…')await ctx.edit('Listo ✅') // edita la última respuesta de este mismo contextoawait ctx.edit(msg.ref, 'Listo ✅') // edita un mensaje concretoawait ctx.edit('Listo ✅', { fallback: 'skip' }) // nunca envía un mensaje nuevo
ctx.edit(contenido) solo apunta a la última respuesta enviada por este mismo
contexto (esta ejecución del handler, o este flujo) y solo si es del mismo tipo
de contenido. Nunca edita respuestas a un mensaje anterior, respuestas del flujo
de otro participante en un grupo ni una respuesta seguida de un
bot.send al chat. El resultado trae edited: true cuando el
provider editó el mensaje en el sitio.Si el provider no puede editar (sin capabilities.edit, un tipo de contenido que
no admite edición o nada que editar), ctx.edit envía el contenido como mensaje
nuevo en el chat del mensaje editado (ref.chatId), o no envía nada con
{ fallback: 'skip' }. Las ediciones pasan por los hooks before/after('send')
(el payload lleva edit: ref, ver Hooks) y actualizan el registro de salida.
await ctx.react('👍')await ctx.react('❤️')await ctx.react('🔥', msg.ref) // reacciona a otro mensajeawait ctx.react('') // quita la reacción, donde el transporte lo permite
En providers que no pueden enviar reacciones, ctx.react resuelve
{ skipped: true } en lugar de rechazar.
await ctx.markRead() // marca el mensaje entrante como leídoawait ctx.typing() // muestra "escribiendo…"await ctx.recording() // muestra "grabando audio…"await ctx.delete() // elimina el mensaje entrante (si está permitido)
Las acciones se degradan a no-op en providers que no tienen la capacidad.
ctx.typing() y ctx.recording() son best-effort: nunca rechazan. Si el
provider no puede mostrar el indicador, el error se registra en debug y el
handler sigue, así que un indicador fallido nunca cuesta la respuesta siguiente.
Los handlers de bot.onType(...) son matchers de baja especificidad (los
antiguos onImage/onAudio/… son alias obsoletos): un
bot.when(...) más específico que también coincida gana primero, a menos
que llame a ctx.pass(). Los mismos matchers están disponibles como constantes de
trigger (IMAGE, VOICE_NOTE, LOCATION, CALL…; los de varios tipos, como
MEDIA, quedan justo por debajo): ver Triggers especiales.
Una ubicación compartida llega con ctx.type === 'location' y sus
coordenadas normalizadas en ctx.sharedLocation (no en ctx.location, que
envía una):
bot.onType('location', async (ctx) => { const loc = ctx.sharedLocation if (!loc) return ctx.reply('No pude leer esa ubicación') await ctx.reply(`📍 ${loc.latitude}, ${loc.longitude}${loc.name ? ` (${loc.name})` : ''}`)})
sharedLocation es { latitude, longitude, name?, address?, live? } (grados
decimales WGS84; live: true en el primer mensaje de una ubicación en tiempo
real). ctx.body conserva el nombre del lugar o la dirección (o ''), así que
los triggers de texto siguen funcionando. La rellenan: WhatsApp vía Baileys
(pines y ubicación en tiempo real), WhatsApp Cloud API, Telegram (pines,
ubicación en tiempo real y lugares/venues), Messenger e Instagram (cuando Meta
envía coordenadas), Evolution API y WPPConnect (pines y ubicación en tiempo
real), Gupshup y Twilio (Latitude/Longitude). Discord y HighLevel no reciben
ubicaciones. En otros canales, o si el transporte no trae coordenadas,
ctx.type sigue siendo 'location' pero sharedLocation es undefined. Si
escribes un provider, mapea el payload con
toInboundLocation({ latitude, longitude, name, address, live }) de
@botmesh/core/spi, que descarta coordenadas ausentes o fuera de rango.Dentro de una conversación, c.waitLocation(opts?) espera una y resuelve con
el InboundLocation; cualquier otra cosa vuelve a pedirla con otherwise (por
defecto, el texto localizado core.wait.location). El mensaje entrante
guardado también la conserva (StoredMessage.location).
bot.conversation('envio', async (c) => { await c.say('Comparte la ubicación de entrega 📍') const loc = await c.waitLocation({ timeout: '5m' }) await c.say(`Entregamos en ${loc.latitude}, ${loc.longitude}`)})
ctx.poll(pregunta, opciones, multiSelect?) envía una encuesta nativa. Los
votos llegan por el evento poll con el recuento completo y actual:
await ctx.poll('¿Pizza o sushi?', ['Pizza', 'Sushi'])bot.on('poll', (vote) => { // vote.ref.id → id del mensaje de la encuesta // vote.ref.chatId → chat donde se votó (el mismo chatId que usan sus mensajes) // vote.senderId → quien acaba de votar, con el id canónico de sus mensajes // vote.options → recuento actual: [{ name, votes, voters }] const elegidas = vote.options.filter((o) => o.voters?.includes(vote.senderId)).map((o) => o.name) console.log(elegidas.length ? `Eligió: ${elegidas.join(', ')}` : 'Retiró su voto')})
En Baileys, WhatsApp cifra cada voto de extremo a extremo; el provider
lo descifra por su cuenta. Recuerda las 500 encuestas más recientes que vio
(las del bot y las que crean otros en los chats que escucha), solo acepta
un voto que llega por el chat de la encuesta y nombra a su creador, guarda
el último voto de cada persona y cuenta cada mensaje de voto una sola vez
(la re-entrega offline de Baileys no se vuelve a emitir). Límites: solo se
descifran las encuestas que vio este proceso (tras un reinicio, los
votos de encuestas anteriores se descartan con un log debug) y el bot no
puede votar. Una encuesta que crea otra persona sigue llegando como mensaje
poll (bot.onType('poll'), el trigger POLL);
el voto cifrado ya no. Detalles en el README de @botmesh/baileys.En Telegram, las encuestas se envían no anónimas, así que cada voto llega
como un update poll_answer y el provider lo suma en el mismo evento poll
(una selección vacía = voto retirado). Telegram solo informa de votos en
encuestas que envió el bot, y el provider recuerda las 500 más recientes que
envió este proceso. Los miembros del chat ven quién votó; anonymousPolls: true
envía encuestas anónimas, cuyos votos nunca llegan al bot. Un canal rechaza los
votantes públicos: la encuesta se reintenta una vez como anónima y el canal se recuerda.
El media entrante se descarga de forma perezosa: el contexto solo lleva una
referencia hasta que pides los bytes con loadMediaOf(ctx). Descarga una sola vez
a través del provider y guarda el resultado en ctx.media:
import { loadMediaOf, MediaTooLargeError } from '@botmesh/core'bot.onType('image', async (ctx) => { try { const media = await loadMediaOf(ctx, { maxBytes: 5 * 1024 * 1024 }) if (!media) return // sin media, o la descarga falló console.log(media.mimetype) // 'image/jpeg' console.log(media.buffer) // Buffer console.log(media.size) // bytes, si el provider los declaró // ctx.media ya es el mismo payload } catch (err) { if (err instanceof MediaTooLargeError) await ctx.reply('Ese archivo es demasiado grande') else throw err }})
Resuelve undefined si el mensaje no trae media, el provider no puede
descargar o (por defecto) la descarga falla. { throwOnError: true } rechaza
con el error de la descarga.
maxBytes se compara con el tamaño que declaró el provider, antes de descargar
nada, y otra vez mientras se lee; la descarga se corta en cuanto lo supera.
Rechaza con MediaTooLargeError (un PayloadTooLargeError, con bytes y
maxBytes).
Llámalo en un hook before('message') para descargar antes del enrutado; así
transcribe las notas de voz el plugin de voz.
Además de los helpers de respuesta, cada handler recibe campos de datos en
ctx: ctx.body, ctx.from (id del chat), ctx.sender (id del usuario;
= from en DMs, participant en grupos), ctx.senderInfo, ctx.pushName,
ctx.chat ({ id, kind, name?, isGroup, isBroadcast, participants? }),
ctx.type, ctx.isGroup / ctx.isPrivate / ctx.isFromMe,
ctx.mentions, ctx.quoted?, ctx.media? (tras loadMediaOf), ctx.timestamp (cuándo se
envió el mensaje, en milisegundos epoch en todos los canales: new Date(ctx.timestamp)), ctx.tools
(registro de DI), ctx.raw (payload nativo del provider), ctx.ref (la
referencia al mensaje — { id, chatId, native? }, reemplaza el ctx.key
de v1), ctx.user (el perfil persistente del remitente),
ctx.handoff() (pasar el chat a un humano), ctx.call (el
CallEvent, solo en contextos CALL) y los miembros de
i18nctx.lang, ctx.langSource, ctx.t() y ctx.setLang().
Cada provider resuelve los alias antes de emitir, así que ctx.sender es
el mismo para la misma persona (el contrato de identidad):
Baileys: las formas LID y PN (teléfono) de un usuario se unen en un id
canónico (el PN cuando se conoce). Lo usan los mensajes, las reacciones,
los votos, las llamadas, los eventos de grupo y presencia, ctx.mentions
y el propio selfId del bot, así que groupMode: 'mentions' funciona
tanto si la mención usó el LID como el PN.
Cloud API (@botmesh/meta): los usuarios que activaron un username
de WhatsApp pueden llegar sin teléfono, solo con un id propio de tu
negocio (BSUID, p. ej. US.13491208655302741918). El provider
mantiene un id estable por persona (el teléfono si se conoce y, si no, el
BSUID), aprende los pares teléfono ↔ BSUID (persístelos con un
identityMap) y envía a un BSUID con el campo recipient de forma
automática. senderInfo.username y senderInfo.platformData.bsuid dan
los detalles. Las plantillas de autenticación exigen teléfono: enviarlas a
un BSUID falla con el error de Graph 131062, mapeado a
UnsupportedContentError. Ver el README de @botmesh/meta.
Para escribirle a alguien sin esperar a que escriba primero — p. ej. desde
una tarea programada — usa la instancia del bot directamente.
El contenido es la unión etiquetada neutral de transporte ({ kind, ... }):
await bot.send('573001234567@s.whatsapp.net', '🌅 ¡Buenos días!')await bot.send('573001234567@s.whatsapp.net', { kind: 'image', media: { url }, caption: 'hola',})await bot.send(chatId, 'Hola, soy Ana de soporte') // mismo camino; un string se envía como texto
bot.broadcast(chatIds, content, options?) envía un mismo contenido (un string se
envía como texto) a muchos chats, por lotes, sin bloquear las colas de entrada de cada chat:
const ac = new AbortController()const res = await bot.broadcast(chatIds, '🎉 ¡Nueva promo!', { batchSize: 10, // chats por lote (default 10) delayBetween: '2s', // pausa entre lotes (ms o '2s', '1m'; default 0) concurrency: 1, // envíos en paralelo por lote (default 1) retries: 3, // reintentos por chat tras un RateLimitedError (default 3) onProgress: (done, total) => bot.logger.info(`${done}/${total}`), onFail: (chatId, error) => bot.logger.warn({ chatId, err: error }, 'broadcast falló'), signal: ac.signal, // aborta para parar antes})// → { total, sent, failed, skipped, errors: [{ chatId, error }], messages, cancelled, pending }
Un RateLimitedError pausa a todos los workers durante su retryAfterMs y reintenta ese chat.
Los demás errores se anotan en errors y la difusión sigue; nunca rechaza por el fallo de un chat.
Abortar signal o llamar a bot.stop() impide nuevos envíos; los chats sin intentar vuelven en pending (cancelled: true).
Los ids duplicados se envían una vez; los envíos que omite un hook before('send') cuentan como skipped.
Cambio incompatiblesent y failed ahora son contadores. Los envíos aceptados pasaron a messages y los fallos a errors.
Desde un handler, lánzala con void bot.broadcast(...) para no retener la cola de ese chat.
bot.trigger(name, { chatId, userId?, isGroup?, data?, ifActive?, body? }) inicia un flujo
registrado o una bot.conversation para alguien que no ha escrito: recordatorios,
webhooks, paneles de administración. data inicializa c.state:
bot.schedule('0 9 * * *', async () => { for (const cita of await db.citasDeHoy()) { await bot.trigger('recordatorio', { chatId: cita.chatId, data: { hora: cita.hora } }) }})
Pasa por las listas de acceso, el gate de handoff humano y la cola FIFO del chat.
Resuelve { started, name, chatId, userId, reason? } en cuanto la conversación
empieza, no cuando termina (para eso, escucha 'conversation:ended'). reason es
'stopped' | 'blocked' | 'handoff' | 'busy'; un name desconocido rechaza.
Si el sujeto ya tiene una conversación activa o dormida, no se toca salvo con ifActive: 'replace'.
Puedes hacer await bot.trigger(...) desde un handler o comando del mismo chat sin problema: corre en el turno de ese mensaje.
userId (por defecto, chatId) e isGroup apuntan a un participante de un grupo;
body fija el ctx.body del mensaje sintético (por defecto '').
No corren hooks before('message'), matchers ni el evento 'message', y no se
persiste nada del mensaje sintético (ctx.raw es { synthetic: 'trigger', name }).
bot.triggerFlow(id, { chatId, userId?, isGroup? }, { data?, body? }) está obsoleto:
equivale a bot.trigger(id, { chatId, userId, isGroup, data, body, ifActive: 'replace' })
con result.started como resultado.
bot.metrics(), bot.resetMetrics(), el exportador de Prometheus toPrometheus() y
los eventos 'conversation:started' / 'conversation:ended' se documentan en
Operación.