@pierre-gilles, he creado un tema aparte para no saturar tu publicación sobre las integraciones externas.
Le he dado todo a Claude y aquí está su análisis sobre Free Mobile:
1. Contexto
Gladys se dota de integraciones externas: programas que se ejecutan en contenedores Docker aislados, supervisados
por Gladys, que se comunican con él a través de una API de host (REST) + un WebSocket saliente, envueltos por
@gladysassistant/integration-sdk. Hoy en día existen dos types de manifiesto (manifest.schema.json, l.16-19):
device— expone dispositivos a través de las pantallas Dispositivos / Descubrimiento / Configuración.communication— canales de mensajería (bots como Telegram): sin pantalla de dispositivos, el usuario vincula su
cuenta desde la UI de Gladys, y la integración intercambia mensajes a través de la API de host.
Llevo el servicio histórico Free Mobile SMS, hasta ahora integrado en el núcleo de Gladys, a una integración
externa. Free Mobile es un canal de comunicación, por lo que type: "communication" es la elección natural — pero esto ha
revelado un hueco estructural en el modelo de comunicación actual. Este documento explica este hueco, lo demuestra con
el código y propone una corrección mínima y retrocompatible.
2. Cómo funciona el modelo de comunicación hoy en día
Una integración de comunicación solo habla con Gladys sobre contactos vinculados. Gladys nunca dice «envía a
William»; dice «envía al contacto X», donde X es un identificador que la propia integración ha creado
durante el vínculo. Tres componentes (README integration-sdk + controlador de la API de host):
- Vinculación (consentimiento) — el usuario hace clic en «Vincular mi cuenta» en la UI de Gladys, lo que genera un código
corto (uso único, TTL 15 min). El usuario envía este código al bot en el canal externo; la integración lo
recibe y llama alinkContact(code, contactId)→POST /api/integration/v1/contact/link. - Entrante —
publishMessage(contactId, text)→POST /api/integration/v1/message. Un contacto desconocido (no
vinculado) desencadena un 404. - Saliente —
onSendMessage(contactId, message): respuestas del cerebro y notificaciones transferidas, entregadas por
la integración en el canal externo.
El código existe para probar un viaje entrante
La razón de ser del código es que transita por el canal externo: al reenviarlo al bot, la persona
en Telegram/Signal prueba que es el usuario de Gladys que lo generó. Esto se confirma por el código
mismo:
externalIntegration.createLinkCode.js— comentario: « El usuario luego lo envía al bot en el canal
externo, y la integración llama a POST /contact/link con él. » Alfabeto elegido para ser « escrito en un chat ».externalIntegration.linkContact.js— el código debe estar presente en la caché (creado por la UI) y no expirado,
de lo contrarioNotFoundError('INVALID_LINK_CODE'). No existe ningún otro medio para crear un contacto. No hay
ningún camino de auto-vinculación.
El modelo supone, por lo tanto, un canal bidireccional.
3. El hueco: los canales solo salientes
Una clase completa de integraciones de comunicación no tiene ningún canal entrante — solo pueden enviar una
notificación:
- Free Mobile SMS (envío de un SMS a su propio número a través de un webhook),
- Pushover, ntfy, Gotify,
- webhooks entrantes Discord / Slack,
- correo electrónico SMTP.
Para todas, dos cosas son ciertas simultáneamente:
- El código no puede transitar. No hay adónde enviarlo — no hay bot, no hay punto de entrada. El paso
central del flujo de vinculación del núcleo es físicamente imposible. - No hay ninguna identidad que probar con un viaje de ida y vuelta. El usuario ingresa su propia dirección — su
clave API, su URL de webhook, su dirección de correo electrónico — en su propia página de configuración de Gladys, mientras
ya está autenticado en Gladys. El consentimiento es el acto de completar y guardar este campo. Un código que
prueba «es usted en el canal externo» responde a una pregunta que nadie hace.
Lo que el usuario experimenta realmente hoy en día (Free Mobile)
La página de configuración communication (PR #2665, config-page/ConfigTab.jsx l.95) muestra LinkAccountCard sin
condición para cualquier integración de comunicación. Por lo tanto, el usuario de Free Mobile ve:
- La tarjeta nativa «Vincular mi cuenta» con un botón «Generar un código» → muestra, por ejemplo,
ABCD2345, y
(su texto i18n) pide «enviar este código al bot en el canal externo» — fácticamente falso: Free
Mobile no tiene tal canal. - La única solución compatible con el núcleo actual es una acción de manifiesto («Vincular mi cuenta») con un
campo de código: el usuario copiaABCD2345de la tarjeta n°1 y lo pega en la acción unos centímetros más abajo, lo que
llama alinkContact(code, username).
Es una ceremonia vacía: el usuario copia un código de una tarjeta y lo pega en otra, para probar un viaje que no existe. Es
confuso y parece roto. Este es el problema a corregir.
4. Propuesta: un campo de manifiesto opcional messaging_mode
Agregar un campo de manifiesto de primer nivel opcional, que solo tiene sentido cuando type: "communication":
{
"type": "communication",
"messaging_mode": "outbound", // "bidirectional" (por defecto) | "outbound"
}
"bidirectional"(predeterminado) — el comportamiento actual, sin cambios. Los bots de Telegram / Signal / Matrix no
declaran nada y continúan funcionando exactamente como antes. Totalmente retrocompatible."outbound"— la integración solo entrega mensajes, nunca los recibe. El núcleo entonces:- oculta la
LinkAccountCardnativa — el flujo de código no tiene sentido aquí; - expone un endpoint de la API del host para vincular al propietario de la configuración sin código
(POST /api/integration/v1/contact/self-link { contact_id }): vincula al usuario autenticado de la
configuración acontact_id. Consentimiento = el usuario ha completado y guardado su propia
destino en su propia página de configuración; - todo lo demás a continuación es inalterado —
onSendMessage(contactId, message)se dispara normalmente;
publishMessage/ el enrutamiento entrante simplemente nunca ocurre.
- oculta la
Por qué es el buen corte
- Mínimo y retrocompatible. Un solo campo enum opcional. El contrato bidireccional (B.15) está intacto; los
archivos del flujo de código (createLinkCode.js,linkContact.js) no se modifican — el saliente es un camino
paralelo, no una reescritura. - Resuelve toda la familia, no solo Free Mobile (Pushover, ntfy, webhooks, SMTP…).
- UX honesta. Sin instrucción engañosa «envía este código al bot», sin ceremonia de copiar y pegar. La UX de
Free Mobile se reduce a rellenar identificador + clave API → guardar → terminado, es decir, el comportamiento
histórico integrado en el núcleo. - La seguridad no se debilita.
self-linknunca vincula más que al usuario ya autenticado de la
página de configuración a un destino que él mismo ha ingresado. Sin vinculación entre usuarios, sin
escalada de privilegios. Es exactamente el consentimiento que proporciona el flujo de código («este usuario
Gladys está de acuerdo»), menos el ida y vuelta que solo tiene sentido para un canal bidireccional. (Una
integración saliente tampoco puede suplantar a otro usuario:self-linkestá limitado al propietario de la
configuraciónreq, y no hay ningún camino entrante para recibir respuestas con la autoridad de otra
persona.)
5. Cambios concretos (puntos de anclaje en la PR #2665)
| # | Capa | Archivo (PR #2665) | Cambio |
|---|---|---|---|
| 1 | Esquema manifest | server/lib/external-integration/manifest.schema.json |
agregar un enum messaging_mode opcional ["bidirectional","outbound"], predeterminado "bidirectional". El mismo archivo sirve al indexador de la tienda, por lo que los manifests salientes validan en todas partes. |
| 2 | Núcleo — lib link | server/lib/external-integration/externalIntegration.selfLinkContact.js (nuevo) |
vincular al usuario propietario de la configuración a contact_id sin código; reutilizar el almacenamiento CONTACT_VARIABLE existente (misma forma que linkContact, sin la búsqueda de código). |
| 3 | Núcleo — API host | server/api/controllers/integrationHost.controller.js + server/api/routes.js |
nueva ruta post /api/integration/v1/contact/self-link + método de controlador, junto al contact/link existente (routes.js agrupa las rutas integration/v1/* juntas). |
| 4 | Núcleo — UI | front/.../config-page/ConfigTab.jsx (l.95) |
mostrar LinkAccountCard solo cuando messaging_mode !== 'outbound' (leído desde integration.manifest). |
| 5 | SDK | @gladysassistant/integration-sdk (lib/gladys-integration.js, index.d.ts, README) |
agregar selfLinkContact(contactId) (POST /contact/self-link) junto a linkContact; documentar messaging_mode. |
Nada más cambia. onSendMessage, getContacts, unlinkContact, la máquina de estados del contenedor, el
protocolo WebSocket — todo intacto.
Borrador de la nueva lib del núcleo (basado en linkContact.js)
// externalIntegration.selfLinkContact.js
const { CONTACT_VARIABLE } = require('./constants');
const { BadParameters } = require('../../utils/coreErrors');
/**
* @description Vincular al usuario propietario de la configuración a un contacto externo SIN un código
* (integraciones de comunicación solo saliente): el consentimiento es que el usuario complete su
* propio destino en su propia página de configuración. Almacenado como linkContact.
* @param {object} service - El servicio de integración externa.
* @param {string} userId - Id del usuario autenticado propietario de la configuración.
* @param {object} body - { contact_id, contact_name? }.
* @returns {Promise<object>} { user: { selector, first_name, language } }.
*/
async function selfLinkContact(
service,
userId,
{ contact_id: contactId, contact_name: contactName } = {},
) {
if (typeof contactId !== 'string' || contactId.length === 0) {
throw new BadParameters('contact_id: debe ser una cadena no vacía');
}
// (protegido por la API del host: service.manifest.messaging_mode debe ser 'outbound')
await this.variable.setValue(
CONTACT_VARIABLE,
JSON.stringify({
contact_id: contactId,
contact_name: contactName || null,
linked_at: new Date().toISOString(),
}),
service.id,
userId,
);
// devolver el usuario, como linkContact
}
6. Preguntas abiertas para el mantenedor
- Forma del endpoint —
POST /contact/self-link { contact_id }guardado pormessaging_mode === 'outbound', vs.
auto-vinculación al primer registro de configuración (sin llamada SDK explícita). La llamada explícita es más
predecible y deja que la integración elija elcontact_id; la implícita requiere aún menos código a
la integración. ¿Preferencia? - Nombrado —
messaging_mode: "outbound"vs. un booleanoinbound: falsevs. una lista de capacidades.
messaging_modese lee mejor si aparece un tercer modo en el futuro (solo entrada?).


