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>.
Cómo encajan en el pipeline
Tres de ellos se enganchan enbefore('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 adetectIntentmientras atiende un humano (ver Dialogflow). - Las rutas de Dialogflow son predicados de
bot.when, así que ganan al catch-all deaiPluginsea cual sea el orden deplug. La regla “instalaaiPlugindespués deslashCommandsPlugin” 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.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,webhookSecretoinsecureNoWebhookAuth: true. Reserva esta última para un endpoint que no sea accesible desde fuera. - El token se acepta en
?token=,x-chatwoot-tokenoAuthorization: Bearer. Chatwoot (comprobado en 4.7) no firma los webhooks de cuenta, así que pon el token en la URL del webhook;webhookSecretes para un emisor o proxy que firme como se describe a continuación. La firma HMAC-SHA256 va enx-chatwoot-signature(hex, admite el prefijosha256=) y se calcula sobre${timestamp}.${rawBody}, conx-chatwoot-timestamp(segundos) dentro dewebhookToleranceSec(300). Una firma sobre el body solo no caduca nunca, así que una petición capturada se podría repetir: se rechaza salvo conwebhookAllowUntimedSignature: true. Las comparaciones son de tiempo constante. handleWebhookresponde401sin autenticación válida,400con JSON inválido y503antes de que el plugin esté instalado; nunca lanza.webhookHandlersolo aceptaPOSTy limita el body a 1 MiB.- El HTTP saliente pasa por
secureFetchde@botmesh/http: solo HTTPS, protección anti-SSRF, timeouts y sin seguir redirecciones, así que elapi_access_tokennunca llega a otro origen. Un Chatwoot self-hosted en la LAN necesitahttp.allowPrivateHosts(yhttp.allowHttppara HTTP plano): unabaseUrlhttp://sinhttp.allowHttplanza 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. UsasyncMode: '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 enctx.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:
assigneees un string libre. Construye el panel sobrebot.injectTools.ticketsy protégelo tú. -
Con
handoffOnAssign, solo la primera asignación pasa el chat a un humano: una reasignación mantiene el handoff y sumeta.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ú abot.resume(chatId)si el bot debe volver a responder. -
metatiene que ser un objeto JSON plano (sinDate,Map, instancias de clases, funciones niBigInt); cualquier otra cosa se rechaza antes de guardar el ticket. Una propiedad con valorundefinedse descarta, como en JSON, así que{ email: user?.email }o{ ...prev, clave: undefined }(para quitar una clave) funcionan; unundefineddentro 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 incluirclosedrecorre 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, comodurableKvTicketStorage(kv)sobre el mismoDurableKv(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ésTicketContentionError). ConreuseOpen(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. Uncreateque falla nunca deja un ticket abierto invisible paraget,listyqueue.
Moderación
Moderación de grupos que corre antes que cualquier handler.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 lan) 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/…).allowInvitescompara el código exacto. actionacepta una acción o varias en orden.autoKickOnLink: truees un atajo delinkFilter: { action: ['delete', 'kick'] }y solo se aplica si no haylinkFilterdefinido (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.
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í quekickse degrada amutey la detección de admins depende deadmins/isAdmin. GroupAdminApiyModerationProvider.groupsson@experimental(fuera de semver) hasta que@botmesh/coredefina una capacidad de grupos para los providers; su forma puede cambiar en una versión minor.
Dialogflow
Pasa cada texto entrante pordetectIntent 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.
routes: { 'book.table': reservaFlow, greeting: (ctx) => ctx.reply('¡Hola!') }.
Para CX, añade cx: { agentId, location, environment? }.
Cómo funciona
- El hook
before('message')llama adetectIntenty guarda el resultado asociado a ese mensaje.ctx.tools.dialogflow.resultFor(ctx)lo lee desde cualquier handler. whenDialogflowIntentregistra un predicado debot.whenque lee ese resultado (sin una segunda llamada). Si la intención coincide con al menosminConfidence, se ejecuta el destino; si no, el mensaje sigue hacia los demás matchers y el catch-all.- No se envían a Dialogflow: mensajes vacíos, interacciones, comandos
(
commandPrefix, por defecto'/'), textos de más demaxTextLength(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 defectotrue) y lo que rechacefilter. - Si
detectIntent(ofilter) falla, se registra en el log, se llama aonError(error, ctx)y el mensaje sigue su camino. Un401descarta el access token en caché y reintenta una vez con uno nuevo.
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':minConfidencefuera de 0–1, unfulfillmentosessionScopedesconocido, uncommandPrefixvacío, unalocationmal formada…) lanzanDialogflowErrorde forma síncrona al hacerbot.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__,constructoryprototype: 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 unTypeErroral registrarlo.
Limitaciones de Dialogflow
- Solo se envía texto (ni audio ni eventos). En CX el rellenado de
formularios vive en las páginas:
allRequiredParamsPresentsiempre estrue.
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.
nluPlugines un plugin spec: se enchufa conbot.plug(nluPlugin, config). Deja unIntentEngineenctx.tools.nlu(y enbot.injectTools.nlu).whenIntent(bot, engine, intent, handler)es un predicado debot.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? }onull;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 ahola) 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 endefine(): nunca podría coincidir. La puntuación separa palabras, así que un id comoA-1no llena un único slot. Una frase exacta, o una con marcadores{slot}que encaja con todo el mensaje, puntúa1; 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: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').