Ir al contenido

Puentes de chat

Un puente de chat convierte un producto de chat externo —hoy Slack— en una vía de primer nivel hacia un espacio de trabajo de Control Center. Menciona al bot y un agente se despierta; responde en el hilo de Slack y la conversación continúa; escribe un comando slash y se registra un ticket. Por debajo, los mismos espacios, transcripciones, tickets y atribución que usa la aplicación de escritorio. El chat es una superficie del espacio de trabajo, no una copia de él.

Slack es el único proveedor incluido. No hay ninguna implementación de Discord, Telegram, Teams o Matrix en todo el producto. La función está escrita deliberadamente sin conocer el proveedor —añadir un segundo producto es un valor de enum, un adaptador y un plugin—, pero hoy «chat» significa Slack.

Dos superficies lo cubren y pertenecen a personas distintas. Conectar un espacio de trabajo a Slack y personalizar el bot son acciones de administrador, en Ajustes → Espacio de trabajo → General. Vincular tu propia cuenta de chat con tu identidad de Control Center es una acción de miembro, en Ajustes → Espacio de trabajo → Perfil e identidad.

Tres decisiones dan forma a toda la función: en qué dirección corre la conexión, qué autoriza un mensaje que llega desde fuera y dónde puede vivir el conocimiento específico de cada producto.

El puente nunca escucha al proveedor. Se llega a Slack mediante Socket Mode: el servidor pide a Slack una URL de socket y establece la conexión saliente; después recibe menciones, mensajes directos y comandos slash por esa conexión.

La consecuencia es precisamente lo importante. Un portátil detrás de NAT, un servidor doméstico sin dominio, una máquina que cambia de red: todos pueden ejecutar el puente sin puerto de entrada, sin túnel, sin certificado TLS y sin ninguna URL pública que rotar cuando cambie. No hay ningún endpoint de webhook que proteger porque no hay ningún endpoint de webhook. Cuando Control Center compone la definición de la aplicación, impone Socket Mode y elimina cualquier URL de solicitud, porque una aplicación configurada para ambas cosas sería una aplicación que funciona a medias.

El coste es una conexión que mantener viva, y eso es tarea del servidor: la reconexión usa retroceso exponencial con fluctuación aleatoria acotado a 30 segundos, una rotación de socket iniciada por el proveedor se reconecta sin aumentar ese retroceso y una credencial rechazada es terminal — reintentar para siempre un token revocado solo conseguiría un límite de velocidad, así que el puente se detiene y espera tokens nuevos mientras muestra en los ajustes el error del propio proveedor, literalmente.

Un espacio de trabajo sin aplicación de chat no cuesta absolutamente nada: ni socket, ni temporizadores, ni tráfico. Y todo arranca después del mensaje de servidor listo, porque una aplicación de chat que tarda en responder no debe retrasar nunca el arranque.

Autoriza la pertenencia, no la posesión del token

Sección titulada «Autoriza la pertenencia, no la posesión del token»

Quien instaló la aplicación no es quien envía el mensaje. Por eso cada evento entrante se resuelve a un Principal antes de que ocurra nada, y falla en cerrado con una explicación:

  • Una cuenta de chat sin vincular recibe instrucciones para vincularse, en el propio hilo de Slack donde escribió.
  • Una cuenta vinculada cuyo usuario no es miembro de este espacio de trabajo recibe el aviso de que pida una invitación a un administrador.
  • Un miembro cuyo rol es de solo lectura (viewer, guest) recibe el aviso de que su rol no puede iniciar trabajo.

Ninguno de esos casos continúa. Un mensaje de chat nunca se atribuye a quien no lo envió, y eso es lo que mantiene honestos el registro de auditoría, los límites de velocidad por Principal y el autor de cada ticket. Consulta Multijugador — identidad, pertenencia y presencia para ver el modelo de pertenencia en el que se apoya.

Vincular es resolver una identidad, no conceder acceso. La pertenencia se vuelve a comprobar en cada evento, así que un vínculo que pertenece a alguien que desde entonces ha sido eliminado se resuelve en una negativa, no en sus antiguos privilegios. Hay dos vías para establecer un vínculo: un código de un solo uso y corta vida que el miembro escribe en el chat, o una coincidencia automática con el correo verificado del proveedor; y la vía del correo sigue exigiendo que el usuario coincidente ya sea miembro, así que un desconocido cuya dirección resulte existir en el servidor no queda vinculado a nada.

Los códigos se mantienen deliberadamente en memoria, con un tiempo de vida de 15 minutos. Persistir uno crearía una fila duradera con forma de credencial para algo destinado a evaporarse, y que reiniciar el servidor invalide todos los códigos pendientes es el comportamiento correcto, no una regresión — el miembro vuelve a pulsar «vincular».

Una negativa se responde en el hilo de Slack que abrió el mensaje, como una respuesta real. Una respuesta en hilo etiqueta el mensaje al que contesta, llega a la única persona que la necesita y aun así mantiene «no estás vinculado» fuera del propio canal. Además, publicar un mensaje es la única capacidad que tienen todos los proveedores, así que la explicación nunca depende de una función que la aplicación pueda no incluir.

Un puente que replica en ambos sentidos entrará en bucle a menos que algo distinga las dos direcciones. Cada mensaje que el puente trae se marca con metadata['chat'] — proveedor, equipo, conversación, hilo, mensaje y remitente. La réplica saliente omite cualquier mensaje que lleve esa marca.

Esa única regla es lo que hace segura la réplica bidireccional: un mensaje humano escrito en Control Center se retransmite al hilo vinculado (con el nombre del autor como prefijo), mientras que un mensaje que llegó desde el chat se detiene donde entró.

Lo que cruza hacia fuera es deliberadamente estrecho. La respuesta del agente se retransmite como texto de chat normal. Un pensamiento corto puede aparecer en la fila Thinking… de la tarjeta. La salida de las herramientas es un muro de diffs y se queda en Control Center, que es también adonde apunta una respuesta truncada.

Una tarjeta informa del trabajo; el texto sigue siendo el mensaje

Sección titulada «Una tarjeta informa del trabajo; el texto sigue siendo el mensaje»

Donde el proveedor las representa, un turno lleva una tarjeta de tarea, editada en su sitio mientras dura el turno y situada encima de la respuesta que explica.

La tarjeta es un plan agrupado, no un párrafo apelmazado. Slack concatena details y output en la misma tarea, así que los detalles de una fila se envían una sola vez (el pensamiento en Thinking…) y output nunca se transmite en streaming. La preparación es una fila cuyo título se reemplaza (Working on it…, Cloning …). El razonamiento añade una fila Thinking…. Cada herramienta es su propia fila (Read lib/main.dart). La respuesta es texto normal bajo la tarjeta — la vista de plan de Slack la lee de un añadido de markdown posterior, no de un fragmento mezclado con las tareas. Nada más: ni salida de herramientas ni más argumento de herramienta que los de una breve lista de permitidos, porque los argumentos de una herramienta desconocida pueden ser el cuerpo entero de un archivo y una fila es una sola línea.

La tarjeta también cubre la espera antes del turno. Una primera mención crea la conversación, y crearla clona todos los repositorios en los que trabajará el agente, lo que en un repositorio real son minutos en los que todavía no se le ha pedido nada al agente y el hilo parece como si el bot hubiera ignorado la petición. Así que la tarjeta se abre en el momento en que se acepta la mención (Working on it…) y luego esa fila de preparación se actualiza con las mismas palabras que muestra la aplicación de escritorio — Cloning acme/widgets…, Setting up Reviewer…, Starting the agent… —, después aparece una fila Thinking… en cuanto el agente empieza a razonar y luego una fila por herramienta. Una tarjeta, un mensaje, desde el «te he oído» hasta la respuesta como texto normal debajo. La preparación solo se informa donde alguien del chat la está esperando: una conversación que se está preparando en la aplicación, o una que quedó abandonada y se reanuda al arrancar el servidor, se aprovisiona sin ninguna tarjeta.

Lleva lo único que el chat no puede dar al lector: un enlace View in Control Center a la conversación que hay detrás de la respuesta. Los productos de chat solo aceptan http(s) en un enlace y nada más, así que el botón apunta a una pequeña página que sirve este servidor, la cual redirige el navegador al enlace control-center:// de la aplicación de escritorio. Esa página no lee nada ni resuelve nada — los identificadores de su URL se reflejan en un enlace profundo y en ningún otro sitio —, y un servidor sin una URL pública accesible simplemente produce una tarjeta sin botón, no una tarjeta rota.

Las tarjetas son una capacidad como cualquier otra. Un proveedor que no las tenga se comporta exactamente como antes, solo texto, y un proveedor que rechace una en el momento de la llamada conserva su transmisión: la respuesta siempre tiene prioridad sobre la tarjeta que la describe. Cuando el streaming está totalmente desactivado, la tarjeta terminada viaja en el único mensaje publicado. Un ticket registrado recibe el mismo trato — la confirmación de /cc ticket es una tarjeta completada cuya razón de ser es el enlace al ticket recién creado.

Las capacidades se negocian y luego se recuerdan

Sección titulada «Las capacidades se negocian y luego se recuerdan»

Los productos de chat difieren en lo que pueden representar, así que el puente está escrito contra la superficie más rica — una respuesta que crece en directo dentro de un hilo, un estado transitorio «is thinking…», un hilo con título, una respuesta efímera a un comando slash — y degrada una capacidad cada vez. Un proveedor que no tenga nada de eso sigue funcionando; las respuestas simplemente llegan como un solo mensaje.

Anunciar una capacidad no es prometerla. Las respuestas en streaming de Slack necesitan una función de plan de pago, y Slack solo la rechaza en el momento de la llamada. Un rechazo marcado como permanente se recuerda durante toda la vida de la conexión: el puente deja de intentarlo y publica respuestas completas a partir de entonces, y los ajustes lo dicen con claridad, porque «mis respuestas dejaron de llegar en streaming» debería poder responderse sin leer un registro. Las tarjetas de tarea se descartan igual si Slack rechaza una.

Todos los límites pertenecen al proveedor, no al relé: el máximo de mensajes, el máximo de fragmentos de la transmisión y si una transmisión necesita un anclaje de hilo los declara el adaptador, y una respuesta larga se divide por párrafo, línea o límite de palabra en lugar de a mitad de palabra.

Todo lo que tiene forma de producto vive detrás de un puerto. El núcleo genérico conoce el markdown, las conversaciones de chat, los hilos de chat y los miembros; no sabe nada de los sobres de Slack, mrkdwn ni las intenciones de la pasarela. Tres piezas lo hacen posible:

  • El adaptador normaliza un producto: su transporte, sus llamadas a la API, su códec de texto y sus peculiaridades de sobre. Las de Slack son aleccionadoras — una mención de canal llega dos veces (como app_mention y como message) y la mayoría de los subtipos de mensaje son elementos de interfaz y no peticiones nuevas. Ambas cosas se resuelven en el adaptador, así que el núcleo nunca se entera de que Slack entrega por duplicado.
  • El descriptor es la forma pública del proveedor: qué credenciales necesita, con qué formato, qué URL ayudan al usuario a encontrarlas, qué puede hacer y si su aplicación se puede crear o editar desde aquí. La tarjeta de ajustes, el diálogo de conexión y la validación de argumentos se generan todos a partir de él — el cliente devuelve un proveedor más un mapa con clave por identificador de campo.
  • El plugin es la composición en el lado del servidor: cómo se convierten las credenciales en una conexión verificada, cómo se construye un adaptador y cómo se gestiona la aplicación del lado del proveedor.

Así que añadir un producto es un valor de enum, un adaptador y un plugin. Ni una operación RPC nueva, ni un cambio de esquema, ni un cambio en el cliente — y el cliente se mantiene honesto, porque un campo del que nunca ha oído hablar se sigue representando con la etiqueta que proporcionó el servidor.

Dos detalles evitan que la abstracción se filtre hacia arriba. Los pasos de gestión de la aplicación para los que un proveedor no tiene API (generar un token, instalar la aplicación, reinstalarla tras un cambio de permisos) se devuelven como pasos con nombre y sus propios enlaces, de modo que la pantalla de finalización de la configuración los muestra sin saber de qué proveedor se trata. Y todas las instrucciones que escribe el puente nombran al proveedor y el comando que la aplicación usa realmente, así que un comando renombrado o un segundo proveedor se lee correctamente sin ninguna cadena nueva.

Los tokens de chat de un espacio de trabajo están en el directorio propio de ese espacio de trabajo, junto a su base de datos — como JSON plano en <dataDir>/<workspaceId>/chat_credentials/<provider>.json, no en la base de datos y deliberadamente tampoco en el llavero del sistema operativo. Dos consecuencias se pierden fácilmente de vista más adelante: eliminar un espacio de trabajo desvincula el directorio y sus tokens se van con él, y exportar o hacer copia de seguridad de un espacio de trabajo copia workspace.db, de modo que un espacio de trabajo entregado a otra persona no lleva dentro tokens de bot activos. El archivo hereda la frontera de confianza del sistema de archivos del host del servidor — cualquier cosa con acceso de lectura al directorio de datos puede leer un token de bot, que es la misma frontera tras la que están las bases de datos.

Un archivo por proveedor, escrito de forma atómica y solo para el propietario: una aplicación revocada se elimina desvinculando un único archivo, y un archivo corrupto cuesta un proveedor en lugar de todos. Las credenciales entran hacia dentro con la operación de conexión y nunca vuelven a salir — las lecturas de estado responden solo con metadatos de la conexión.

  • Ajustes estructurales de la aplicación. El transporte, los ámbitos y las suscripciones a eventos se imponen, no se exponen. Una aplicación que se puede configurar hasta dejarla en silencio desde una pantalla de ajustes es un ticket de soporte, no una función.
  • Conversación y comandos slash. La superficie de Slack son mensajes, respuestas en hilo y comandos slash.
  • Una segunda fuente de verdad. El chat no es un lugar donde se almacene trabajo. El hilo de Slack es un espacio, la respuesta es una transcripción, el ticket es un ticket — todo ello en el espacio de trabajo y accesible desde cualquier cliente.