Saltar al contenido principal
Paquetes públicosLos paquetes de esta página se publican en npm y quedan cubiertos por semver desde su versión estable (1.0.0). Cada uno guarda sus piezas internas en una entrada /internal sin garantía semver (@botmesh/nlu no tiene).
Todos son plugins normales con un install síncrono (bot.plug(factory({...})); nluPlugin es un spec, bot.plug(nluPlugin, config)). Importar un paquete tipa su entrada de ctx.tools.*; fuera de un handler, usa bot.injectTools.<nombre>.
Instala solo los que uses.

Cómo encajan en el pipeline

Tres de ellos se enganchan en before('message'). La prioridad más alta corre primero, así que el orden no depende del orden en que llames a bot.plug(): De ahí se deduce que:
  • Chatwoot ve los mensajes que la moderación descarta después (el agente tiene el contexto completo).
  • Un mensaje que descarta la moderación nunca llega a Dialogflow, a los comandos ni a los handlers.
  • Los hooks before('message') siguen corriendo mientras un chat está en handoff, así que la moderación y Chatwoot siguen funcionando ahí. El hook de Dialogflow se salta los chats en handoff: no se llama a detectIntent mientras atiende un humano (ver Dialogflow).
  • Las rutas de Dialogflow son predicados de bot.when, así que ganan al catch-all de aiPlugin sea cual sea el orden de plug. La regla “instala aiPlugin después de slashCommandsPlugin” sigue valiendo; estos plugins no la cambian.

Chatwoot

Conecta el bot con un inbox de canal API en Chatwoot, sobre la API de handoff del core.
Con Express o Fastify, pasa el body crudo:

Qué hace

Webhooks que se ignoran: notas privadas, mensajes incoming, mensajes de agent bots, otros inboxes, conversaciones desconocidas y los ecos de los mensajes que creó el propio plugin. Cada chat se asocia a un contacto de Chatwoot (identifier = id del chat) y a una conversación. El mapeo vive en el store del bot a través de la API de cursores (chatwoot:chat:<chatId>, chatwoot:conv:<id>), así que sobrevive a reinicios con un store persistente. Los ids de chat solo se usan como claves opacas. Si el mapeo se pierde (un store nuevo, o un reinicio con MemoryStore), el plugin encuentra el contacto por identifier exacto (API de filtros de contactos) y sigue usando su conversación más reciente en el inbox, así que los agentes que responden ahí siguen llegando al chat. Una instancia de chatwootPlugin() sirve a un bot: enchufar la misma instancia en un segundo bot lanza un error (crea una por bot, cada una con su URL de webhook).

Opciones de Chatwoot

ctx.tools.chatwoot (también el objeto que devuelve chatwootPlugin()): handleWebhook(), webhookHandler({ maxBytes }), conversationOf(chatId), chatOf(conversationId), addLabels(chatId, labels), removeLabels(chatId, labels), flush() (espera a la sincronización pendiente, útil al apagar y en tests) y client (ChatwootClient).

Seguridad

  • La autenticación del webhook es obligatoria: la factory lanza un error sin webhookToken, webhookSecret o insecureNoWebhookAuth: true. Reserva esta última para un endpoint que no sea accesible desde fuera.
  • El token se acepta en ?token=, x-chatwoot-token o Authorization: Bearer. Chatwoot (comprobado en 4.7) no firma los webhooks de cuenta, así que pon el token en la URL del webhook; webhookSecret es para un emisor o proxy que firme como se describe a continuación. La firma HMAC-SHA256 va en x-chatwoot-signature (hex, admite el prefijo sha256=) y se calcula sobre ${timestamp}.${rawBody}, con x-chatwoot-timestamp (segundos) dentro de webhookToleranceSec (300). Una firma sobre el body solo no caduca nunca, así que una petición capturada se podría repetir: se rechaza salvo con webhookAllowUntimedSignature: true. Las comparaciones son de tiempo constante.
  • handleWebhook responde 401 sin autenticación válida, 400 con JSON inválido y 503 antes de que el plugin esté instalado; nunca lanza. webhookHandler solo acepta POST y limita el body a 1 MiB.
  • El HTTP saliente pasa por secureFetch de @botmesh/http: solo HTTPS, protección anti-SSRF, timeouts y sin seguir redirecciones, así que el api_access_token nunca llega a otro origen. Un Chatwoot self-hosted en la LAN necesita http.allowPrivateHosts (y http.allowHttp para HTTP plano): una baseUrl http:// sin http.allowHttp lanza al crear el plugin, y un host privado bloqueado se reporta indicando la opción.
  • Con syncMode: 'all' todas las conversaciones se copian a Chatwoot. Usa syncMode: 'handoff' si solo los chats escalados deben salir del bot.

Limitaciones de Chatwoot

  • Los medios entrantes se reflejan como texto ([image] caption), no se suben como adjuntos.
  • Un chat, una conversación. Si la conversación mapeada ya no existe (Chatwoot responde 404, o 422 mientras aún borra un contacto), se crea otra y el mensaje va ahí.
  • Marcar como fallida una respuesta rechazada usa la API de estado de mensajes, que Chatwoot solo ofrece en inboxes de canal API (el tipo que este plugin necesita de todos modos).
  • Si el store no tiene cursores, el mapeo vive en memoria (se avisa en el log al instalar).

Tickets

Tickets de soporte y una cola en ctx.tools.tickets.

API

Estados: open (en cola), assigned, pending (esperando al cliente, fuera de la cola) y closed. Los ids son strings secuenciales ('1', '2'…).

Opciones de tickets

Las plantillas admiten {{id}}, {{position}}, {{subject}}, {{priority}}, {{assignee}}, {{status}} y {{reason}}.

Persistencia

Por defecto los tickets se guardan con la API de cursores del store (tickets:seq, tickets:index, tickets:t:<id>), que implementan los seis stores; con un MemoryStore simple viven en RAM. En memoria solo quedan los tickets no cerrados; los cerrados se leen bajo demanda. Los datos se cargan de forma perezosa en la primera llamada y las mutaciones se serializan: si falla una escritura en el store, la llamada se rechaza y la memoria no cambia. TicketDesk también se puede usar sin bot: new TicketDesk({ storage: memoryTicketStorage() }).

Limitaciones de tickets

  • No hay interfaz ni identidad de agentes: assignee es un string libre. Construye el panel sobre bot.injectTools.tickets y protégelo tú.
  • Con handoffOnAssign, solo la primera asignación pasa el chat a un humano: una reasignación mantiene el handoff y su meta.assignee (lee el ticket para saber el agente actual), y desasignar (assignee: null) devuelve el ticket a la cola pero deja el chat en handoff. Llama tú a bot.resume(chatId) si el bot debe volver a responder.
  • meta tiene que ser un objeto JSON plano (sin Date, Map, instancias de clases, funciones ni BigInt); cualquier otra cosa se rechaza antes de guardar el ticket. Una propiedad con valor undefined se descarta, como en JSON, así que { email: user?.email } o { ...prev, clave: undefined } (para quitar una clave) funcionan; un undefined dentro de un array se rechaza.
  • En modo compartido cada llamada relee el índice y todos los tickets activos, así que su coste crece con el número de tickets abiertos.
  • list() con un filtro que pueda incluir closed recorre todos los ids desde 1 hasta el último.
  • El almacenamiento por defecto (los cursores del store) no tiene compare-and-set, así que sirve para un proceso por namespace del store: dos réplicas sobre él repartirían el mismo id y se pisarían el índice. Para réplicas, pasa un almacenamiento con setIf, como durableKvTicketStorage(kv) sobre el mismo DurableKv (Postgres, Redis, SQLite o un archivo) en todas las réplicas. El desk relee entonces el índice en cada llamada, reclama los ids con compare-and-set y reintenta una escritura perdida (maxRetries, 20 por defecto; después TicketContentionError). Con reuseOpen (por defecto) también reclama con compare-and-set el ticket abierto del chat, así que dos réplicas que crean a la vez un ticket para el mismo chat (desde un handler, la API o el manager) obtienen el mismo. Un create que falla nunca deja un ticket abierto invisible para get, list y queue.

Moderación

Moderación de grupos que corre antes que cualquier handler.
Un mensaje que infringe una regla ejecuta sus acciones y recibe abort(): no llega a comandos, conversaciones ni handlers. Los mensajes de un usuario silenciado se descartan hasta que vence el silencio. Por defecto solo se moderan grupos (applyTo: 'all' incluye los chats privados). Las interacciones y los mensajes propios del bot nunca se moderan.

Reglas y acciones

  • Las palabras se comparan sin mayúsculas ni tildes (la ñ sigue siendo distinta de la n) y como palabra completa; las RegExp se prueban sobre el texto crudo.
  • Antes de buscar palabras y enlaces se quitan los caracteres invisibles (espacios de ancho cero, guion suave, controles bidi) y las formas de compatibilidad como spam (ancho completo) se leen como letras normales, así que no sirven para esconder una palabra o una invitación. El formato de WhatsApp alrededor de un enlace (_cursiva_, *negrita*, ~tachado~) tampoco lo esconde.
  • Invitaciones reconocidas: WhatsApp (chat.whatsapp.com/…), Telegram (t.me/+…, t.me/joinchat/…) y Discord (discord.gg/…, discord.com/invite/…). allowInvites compara el código exacto.
  • action acepta una acción o varias en orden.
  • autoKickOnLink: true es un atajo de linkFilter: { action: ['delete', 'kick'] } y solo se aplica si no hay linkFilter definido (global o en el override del grupo).
Las advertencias se cuentan por chat + usuario; al llegar a warnings.max el plugin ejecuta warnings.then y pone el contador a cero (warnings: false lo desactiva). Los textos se configuran con messages: { warn, reasons } ({name}, {reason}, {count}, {max}); los de serie siguen el idioma del chat (inglés por defecto, español con lang: 'es'). Los administradores se saltan todas las reglas (adminBypass, por defecto true): los ids de admins, quien apruebe isAdmin(chatId, userId) y la lista de provider.groups.getAdmins(chatId), en caché durante adminCacheMs (por defecto 5 min) e invalidada con los eventos de grupo promote/demote. Los administradores solo se consultan para un mensaje que rompe una regla o viene de un usuario silenciado. Si isAdmin lanza, el remitente cuenta como no administrador (y se registra un aviso); si getAdmins falla, se conserva la última lista conocida y se vuelve a preguntar al provider tras 30 segundos como mucho. Cuando alguien sale (remove), se olvidan sus advertencias, su silencio y su ventana de spam. El estado no crece sin límite en un bot de larga duración: los silencios vencidos y las listas de administradores caducadas se barren, y se guardan como mucho maxTrackedUsers (10 000 por defecto) contadores de advertencias; se descarta el del usuario advertido hace más tiempo. ctx.tools.moderation: mute, unmute, isMuted, warnings, resetWarnings, kick (resuelve un ActionResult), setGroupRules (false desactiva, undefined quita el override) e invalidateAdmins. La configuración se valida al llamar a moderationPlugin() y en setGroupRules: una acción desconocida, un contador o una ventana no positivos o un campo de tipo incorrecto lanzan un TypeError/RangeError que nombra el campo. {name} en el texto de la advertencia es el nombre visible del remitente sin caracteres de control ni bidi, en una sola línea y con 64 caracteres como mucho.
groupMode corre antesEl filtro groupMode del bot corre antes de los hooks before('message'). Con groupMode: 'mentions' o 'commands', los mensajes de grupo que no mencionan al bot o no son comandos nunca llegan a la moderación. Usa groupMode: 'all' (el valor por defecto) para moderar todo el grupo; el plugin avisa en el log al instalarse si detecta un modo restrictivo.

Limitaciones de la moderación

  • El estado (ventanas de spam, advertencias, silencios) vive en memoria y se pierde al reiniciar.
  • Ningún provider del monorepo expone todavía groups.removeParticipants / getAdmins, así que kick se degrada a mute y la detección de admins depende de admins / isAdmin.
  • GroupAdminApi y ModerationProvider.groups son @experimental (fuera de semver) hasta que @botmesh/core defina una capacidad de grupos para los providers; su forma puede cambiar en una versión minor.

Dialogflow

Pasa cada texto entrante por detectIntent de Dialogflow ES o CX y rutea la intención a un handler o a un flow. Sin SDK de Google: el JWT de la cuenta de servicio se firma con node:crypto (RS256) y todas las peticiones salen por secureFetch.
Las rutas también pueden ir en la config: routes: { 'book.table': reservaFlow, greeting: (ctx) => ctx.reply('¡Hola!') }. Para CX, añade cx: { agentId, location, environment? }.

Cómo funciona

  1. El hook before('message') llama a detectIntent y guarda el resultado asociado a ese mensaje. ctx.tools.dialogflow.resultFor(ctx) lo lee desde cualquier handler.
  2. whenDialogflowIntent registra un predicado de bot.when que lee ese resultado (sin una segunda llamada). Si la intención coincide con al menos minConfidence, se ejecuta el destino; si no, el mensaje sigue hacia los demás matchers y el catch-all.
  3. No se envían a Dialogflow: mensajes vacíos, interacciones, comandos (commandPrefix, por defecto '/'), textos de más de maxTextLength (256 caracteres, el límite de la API), mensajes de un chat en handoff, mensajes de un usuario dentro de una conversación activa (skipActiveConversation, por defecto true) y lo que rechace filter.
  4. Si detectIntent (o filter) falla, se registra en el log, se llama a onError(error, ctx) y el mensaje sigue su camino. Un 401 descarta el access token en caché y reintenta una vez con uno nuevo.
Mientras a una intención de ES le faltan parámetros obligatorios, el resultado trae allRequiredParamsPresent: false y la pregunta del agente en messages. La ruta se ejecuta igual, así que comprueba ese campo en el handler si la intención depende del rellenado de parámetros de Dialogflow. fulfillment: 'all' también responde con el texto del agente a los resultados de fallback / no-match, así que esos mensajes nunca llegan a tu onMessage ni al catch-all de aiPlugin. Deja 'off' o 'unrouted' si un LLM debe responder lo que Dialogflow no entiende.

Opciones de Dialogflow

Seguridad y privacidad

  • Unas credenciales inválidas (code: 'INVALID_CREDENTIALS') y unas opciones inválidas (code: 'INVALID_CONFIG': minConfidence fuera de 0–1, un fulfillment o sessionScope desconocido, un commandPrefix vacío, una location mal formada…) lanzan DialogflowError de forma síncrona al hacer bot.plug(). El access token se pide de forma perezosa con el primer mensaje, se guarda en caché y se renueva poco antes de caducar.
  • La clave de sesión se hashea (SHA-256, 32 caracteres) antes de usarse como id de sesión de Dialogflow, así que ni los ids de chat ni los números de teléfono se envían a Google. El texto del mensaje sí.
  • Guarda el JSON de la cuenta de servicio en un secreto, nunca en el repo.
  • Los parámetros (en el resultado y al copiarse a c.state) omiten __proto__, constructor y prototype: vienen de un servicio remoto y no deben llegar al prototipo del objeto de estado.
  • Un destino de ruta que no sea una función handler, un builder flow() o un flow compilado lanza un TypeError al registrarlo.

Limitaciones de Dialogflow

  • Solo se envía texto (ni audio ni eventos). En CX el rellenado de formularios vive en las páginas: allRequiredParamsPresent siempre es true.

Intenciones locales (NLU)

@botmesh/nlu compara los mensajes con intenciones que defines con frases de ejemplo, todo dentro del proceso: sin red, sin modelo y sin dependencias extra. Sirve para intenciones tipo comando (“dónde está mi pedido”, “hablar con una persona”) en un bot que no debe pagar un LLM ni un agente de Dialogflow por cada mensaje. Para lenguaje abierto, usa el plugin de IA, solo o detrás de este.
  • nluPlugin es un plugin spec: se enchufa con bot.plug(nluPlugin, config). Deja un IntentEngine en ctx.tools.nlu (y en bot.injectTools.nlu).
  • whenIntent(bot, engine, intent, handler) es un predicado de bot.when(): el handler solo corre cuando la mejor intención del mensaje es esa, por encima del umbral, y recibe el resultado. Devuelve la función para quitarlo.
  • engine.match(text) devuelve { intent, confidence, entities? } o null; engine.rank(text) puntúa todas las intenciones (útil para depurar una frase); engine.define(intent, utterances) añade frases en caliente.
  • La comparación normaliza mayúsculas, tildes, puntuación, letras de ancho completo y caracteres invisibles (¡HOLA! equivale a hola) y puntúa por palabras en común; los signos vocálicos de escrituras como el devanagari se quedan en su palabra. Una frase con solo emojis o puntuación lanza en define(): nunca podría coincidir. La puntuación separa palabras, así que un id como A-1 no llena un único slot. Una frase exacta, o una con marcadores {slot} que encaja con todo el mensaje, puntúa 1; cada slot captura una palabra.
  • Alcance: sin lematización, sinónimos ni embeddings. Dale a cada intención varias frases cortas con las palabras que de verdad escriben tus usuarios y evita intenciones que compartan casi todo el vocabulario.

Combinarlos

Tickets y Chatwoot controlan ambos el handoff del core, así que se combinan a través de sus eventos:
Asignar un ticket llama a bot.handoff(), y el evento 'handoff' abre la conversación de Chatwoot con sus labels. Cerrar el ticket llama a bot.resume(), y Chatwoot resuelve la conversación. Resolverla en Chatwoot reanuda el bot, pero no cierra el ticket: hazlo desde tu propio listener bot.on('resume', …) si lo necesitas (el reason del evento es 'chatwoot:resolved').

Siguientes pasos