Integración externa - Free Mobile

@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 a linkContact(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 contrario NotFoundError('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:

  1. 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.
  2. 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:

  1. 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.
  2. 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 copia ABCD2345 de la tarjeta n°1 y lo pega en la acción unos centímetros más abajo, lo que
    llama a linkContact(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:
    1. oculta la LinkAccountCard nativa — el flujo de código no tiene sentido aquí;
    2. 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
      a contact_id. Consentimiento = el usuario ha completado y guardado su propia
      destino en su propia página de configuración;
    3. todo lo demás a continuación es inalterado — onSendMessage(contactId, message) se dispara normalmente;
      publishMessage / el enrutamiento entrante simplemente nunca ocurre.

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-link nunca 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-link está limitado al propietario de la
    configuración req, 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

  1. Forma del endpoint — POST /contact/self-link { contact_id } guardado por messaging_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 el contact_id; la implícita requiere aún menos código a
    la integración. ¿Preferencia?
  2. Nombrado — messaging_mode: "outbound" vs. un booleano inbound: false vs. una lista de capacidades.
    messaging_mode se lee mejor si aparece un tercer modo en el futuro (solo entrada?).

¡Excelente respuesta @Will_71, lo voy a pensar!

¡Hola @Will_71, he gestionado el caso de las integraciones unidireccionales en el SDK v0.9.0!

Ok :+1: me pongo con ello esta noche

¡Me encanta!

image

¡¡Sííí!! Tengo ganas de ver la integración :grin:

Va avanzando, pero aún no funciona

Edición: encontré por qué no funciona.
Creé una escena y ejecuté la acción « Enviar un SMS », al mirar los registros se ve que usa la aplicación nativa de Free Mobile y como no la tengo configurada, tengo un error 404.

Si uso la acción « Enviar un mensaje », ahí funciona muy bien. Por otro lado, si pongo Telegram por ejemplo, ¿cómo seleccionar en qué servicio se envía el mensaje?

O bien habría que hacer evolucionar sms.send para apuntar a una integración externa.

¡Genial! :tada:

Por mi parte, creo que podríamos depreciar la acción de escena “Enviar un SMS” y reemplazarla por “Enviar un mensaje”.

La acción “Enviar un mensaje” envía automáticamente el mensaje a través del canal configurado por el usuario.

Y si en el futuro se necesita, siempre podremos agregar un selector de canal en la acción. Pero me parece que el enfoque automático es un muy buen comportamiento por defecto. :slightly_smiling_face:

Sí, creo que hay que añadir el selector porque uso los dos personajes

¿Puedes crear una solicitud de funcionalidad?

Lo lanzaría con Claude

Aquí tienes
Ajout selecteur dans l'action Envoyer un message