Handoff humano y listas de acceso
Dos gates que deciden si el bot responde o no:
- El handoff humano pausa el bot en un chat mientras una persona (un agente, el inbox de un CRM) toma el control.
- La blacklist / whitelist descarta los mensajes de ciertos remitentes o chats antes incluso de guardarlos.
Ambos están en el Bot (y en pluginCtx.bot dentro de los plugins).
Handoff humano
const bot = new Bot({ provider, store, handoff: { allowCommands: ['/bot'] } })
bot.when('hablar con humano', async (ctx) => {
await ctx.handoff({ reason: 'user request', meta: { queue: 'ventas' } })
await ctx.reply('Te conecto con un agente…') // el handler que hace el handoff aún puede responder
})
bot.command('bot', async (ctx) => { await bot.resume(ctx.from); await ctx.reply('🤖 De vuelta') })
bot.on('handoff', ({ chatId, reason, since, meta }) => crm.assign(chatId, reason))
bot.on('resume', ({ chatId, at, reason, handoff }) => crm.close(chatId))
bot.on('message', (ctx) => { if (bot.isHandedOff(ctx.from)) crm.relay(ctx.from, ctx.body) })
// El agente responde fuera del pipeline (un string se envía como texto)
await bot.send(chatId, 'Hola, soy Ana 👋')
await bot.resume(chatId, { reason: 'ticket cerrado' })
bot.isHandedOff(chatId) // boolean
bot.getHandoff(chatId) // { chatId, since, reason?, meta? } | undefined (una copia)
bot.listHandoffs() // HandoffState[]El estado es por chat (ctx.from): un handoff en un grupo pausa el bot para todos los miembros del grupo, pero no en sus DMs.
Qué pasa mientras el chat está en handoff
- Los mensajes se persisten, los hooks
before/after('message')corren (un hookbeforetodavía puede hacerabort()) y el evento'message'se emite, así que una integración de inbox lo ve todo. - Comandos, conversaciones, checkpoints dormidos, matchers y catch-all no corren, y no se emite
'command:unknown'. También se bloquean las interacciones (botones y listas). - Las llamadas al chat se bloquean por completo: sin hooks
before/after('call'), sin evento'call'y sin triggerCALL(cuentan enmetrics().dropped.handoff). - Comandos de escape (
handoff.allowCommands): con o sin prefijo, se comparan como los comandos (sin distinguir mayúsculas ni acentos). Solo pasan si hay unbot.command()registrado con ese nombre; si no, siguen bloqueados y nunca llegan al catch-all. Conviene que ese comando hagabot.resume(...)en vez de iniciar un flujo. - Los temporizadores de
bot.onIdledel chat se cancelan y quedan suspendidos hasta el resume.
Al empezar el handoff
- Se cancelan en silencio (sin
cancelledMessage) todas las conversaciones activas del chat: todos los participantes, con cualquierconversationScope. Sus checkpoints dormidos se borran de memoria y del store. - Si
ctx.handoff()se llama dentro de un.do()de un flujo, el flujo termina: los pasos siguientes no corren. En los pasos de un flujo elctxestá tipado comoRuntimeMessage, así que ahí usabot.handoff(ctx.from).
Valores de retorno
handoff()/resume()devuelvenPromise<boolean>:truesi cambió el estado. Son idempotentes: un segundo handoff no cambia la razón ni emite evento.- El estado cambia de forma síncrona, así que no hace falta
awaitantes de la siguiente línea. - Nunca rechazan por errores del store; esos errores van al evento
'error'. Lanzan unTypeErrorsíncrono si el chatId está vacío. ctx.handoff()resuelvefalsesi el contexto no lo construyó un bot.- El
bot.send()de un agente pasa por los hooksbefore/after('send')y por el ledger de salida como cualquier otro envío. No reanuda el bot.
Persistencia
- El estado se guarda en
StoredChat.handoff = { since, reason?, meta? }(queda ennullal hacer resume), más un pequeño índice de chats en handoff bajo la clave de cursorHANDOFF_INDEX_KEY. bot.start()lo restaura con una lectura por chat en handoff (los stores sin cursores usanlistChats()). Al restaurar no se emite'handoff'. También borra los checkpoints dormidos de cada chat en handoff, así que un flujo viejo nunca se reanuda trasbot.resume().- Si la restauración falla, se informa en
'error'ystart()no falla. Unresume()llamado mientras la restauración sigue en curso la espera. - Sobrevive a reinicios con un store persistente (SQLite, Redis, Postgres, MySQL, Mongo o
MemoryStoreconfile).handoff: { persist: false }lo deja solo en memoria.metadebe ser serializable a JSON. bot.deleteSubject(userId)olvida el handoff del chat de ese usuario, sin eventos.
Blacklist y whitelist
bot.blacklist.add('573001234567@s.whatsapp.net') // ids EXACTOS, como llegan en ctx.sender / ctx.from
bot.blacklist.add(['a@s.whatsapp.net', '1203630@g.us']) // el id de un grupo bloquea a todo el grupo
bot.blacklist.remove('a@s.whatsapp.net')
bot.blacklist.has(id); bot.blacklist.list(); bot.blacklist.size; bot.blacklist.clear()
bot.whitelist.add(['vip@s.whatsapp.net', '1203630@g.us']).enable() // modo whitelist
bot.whitelist.disable(); bot.whitelist.enabledReglas de acceso
- Las dos listas se comparan contra el id del remitente y el id del chat, con igualdad exacta: el core no normaliza nada. En WhatsApp (Baileys) usa el JID completo, no solo el número.
- Gana la blacklist. Una whitelist habilitada pero vacía bloquea a todos. La whitelist está deshabilitada por defecto.
- Un usuario en la whitelist pasa en cualquier grupo; un grupo en la whitelist deja pasar a todos sus miembros.
checkAccess(blacklist, whitelist, { chatId, senderId })devuelve la misma decisión ('allowed' | 'blacklisted' | 'not-whitelisted') para tu propio código.
Qué pasa con un mensaje bloqueado
Se descarta antes de persistir: no se guardan el mensaje, el chat ni el contacto; no corren hooks, comandos, conversaciones ni la reanudación dormida (el checkpoint queda intacto), y no se emite 'message'. Se registra en debug con { gate, chatId, senderId }, nunca el cuerpo. Aplica a mensajes, interacciones y llamadas (una llamada bloqueada no corre hooks 'call', no emite el evento 'call' ni llega a triggers CALL); no filtra reacciones, votos de encuestas, presencia ni eventos de grupo. Los mensajes y llamadas bloqueados cuentan en metrics().dropped.blocked.
Bloquear a alguien no cancela una conversación que ya esté en curso (simplemente dejan de llegar sus respuestas). Usa bot.abortConversation(...) si lo necesitas.
Guardar las listas
Las listas viven en memoria; persistirlas es cosa tuya:
import fs from 'node:fs'
function save() { fs.writeFileSync('acl.json', JSON.stringify(bot.exportAccessLists())) }
bot.blacklist.onChange(save)
bot.whitelist.onChange(save)
bot.loadAccessLists(JSON.parse(fs.readFileSync('acl.json', 'utf8')))
// formato: { blacklist: { ids: [...] }, whitelist: { ids: [...], enabled: true } }add/removeaceptan un id o un array y lanzanTypeErrorsi un id está vacío o no es string (se valida antes de cambiar nada).onChangesolo notifica cambios efectivos;loadnotifica siempre.loadAccessListstambién acepta arrays simples de ids para cada lista.
Siguientes pasos
- Conceptos básicos: dónde corre cada gate
- Hooks
- Control plane