Salta ai contenuti

Ponti di chat

Un ponte di chat rende un prodotto di chat esterno — oggi Slack — un modo di prima classe per entrare in uno spazio di lavoro di Control Center. Menziona il bot e un agente si sveglia; rispondi nel thread di Slack e la conversazione prosegue; digita un comando slash e viene aperto un ticket. Sotto, gli stessi spazi, trascrizioni, ticket e la stessa attribuzione che usa l’app desktop. La chat è una superficie dello spazio di lavoro, non una sua copia.

Slack è l’unico provider distribuito. Non esiste alcuna implementazione per Discord, Telegram, Teams o Matrix, in nessuna parte del prodotto. La funzione è scritta di proposito senza conoscere il provider — aggiungere un secondo prodotto è un valore di enum, un adapter e un plugin — ma oggi “chat” significa Slack.

Due superfici lo coprono, e appartengono a persone diverse. Connettere uno spazio di lavoro a Slack e personalizzare il bot sono azioni da amministratore, in Impostazioni → Spazio di lavoro → Generale. Collegare il proprio account di chat alla propria identità in Control Center è un’azione da membro, in Impostazioni → Spazio di lavoro → Profilo e identità.

Tre decisioni danno forma all’intera funzione: in quale direzione scorre la connessione, cosa autorizza un messaggio che arriva dall’esterno e dove può risiedere la conoscenza specifica del prodotto.

Il ponte non resta mai in ascolto per conto del provider. Slack viene raggiunto tramite Socket Mode: il server chiede a Slack un URL di socket e si connette in uscita, poi riceve menzioni, messaggi diretti e comandi slash su quella connessione.

La conseguenza è il punto. Un portatile dietro NAT, un server di casa senza dominio, una macchina che passa da una rete all’altra: tutti possono eseguire il ponte senza porte in entrata, senza tunnel, senza certificato TLS e senza un URL pubblico da far ruotare quando cambia. Non c’è alcun endpoint webhook da proteggere, perché non c’è alcun endpoint webhook. Quando Control Center compone la definizione dell’app impone Socket Mode e rimuove qualsiasi request URL, perché un’app configurata per entrambi sarebbe un’app che funziona a metà.

Il costo è una connessione da mantenere viva, ed è compito del server: la riconnessione è un backoff esponenziale con jitter, limitato a 30 secondi, una rotazione del socket avviata dal provider si riconnette senza aumentare quel backoff e una credenziale rifiutata è definitiva — riprovare all’infinito un token revocato non farebbe che guadagnarsi un limite di frequenza —, quindi il ponte si ferma e aspetta nuovi token, riportando alla lettera l’errore del provider nelle impostazioni.

Uno spazio di lavoro senza app di chat non costa assolutamente nulla: nessun socket, nessun timer, nessun traffico. E il tutto parte dopo il banner con cui il server annuncia di essere pronto, perché un’app di chat lenta a rispondere non deve mai ritardare l’avvio.

Autorizza l’appartenenza, non il possesso del token

Sezione intitolata “Autorizza l’appartenenza, non il possesso del token”

Chi ha installato l’app non è la persona che invia il messaggio. Perciò ogni evento in entrata viene risolto in un Principal prima che accada qualsiasi cosa, e qualsiasi fallimento si traduce in un rifiuto, con una spiegazione:

  • A un account di chat non collegato viene spiegato come collegarlo, nel thread di Slack in cui ha scritto.
  • A un account collegato il cui utente non è membro di questo spazio di lavoro viene detto di chiedere un invito a un amministratore.
  • A un membro con un ruolo di sola lettura (viewer, guest) viene detto che il suo ruolo non può avviare lavoro.

Nessuno di questi casi prosegue. Un messaggio di chat non viene mai attribuito a chi non l’ha inviato: è ciò che tiene onesti il registro di controllo, i limiti di frequenza per principal e gli autori dei ticket. Vedi Multigiocatore — identità, appartenenza e presenza per il modello di appartenenza su cui questo si basa.

Il collegamento è risoluzione di identità, non una concessione di accesso. L’appartenenza viene ricontrollata a ogni evento, quindi un collegamento che appartiene a qualcuno nel frattempo rimosso produce un rifiuto, non i suoi vecchi privilegi. Due percorsi creano un collegamento: un codice monouso di breve durata che il membro digita in chat, oppure un abbinamento automatico sull’e-mail verificata del provider — e il percorso via e-mail richiede comunque che l’utente abbinato sia già membro, quindi un estraneo il cui indirizzo esiste per caso sul server non collega nulla.

I codici sono tenuti di proposito in memoria, con un time to live di 15 minuti. Persisterne uno creerebbe una riga durevole a forma di credenziale per qualcosa destinato a evaporare, e un riavvio del server che invalida tutti i codici in sospeso è il comportamento corretto, non una regressione: il membro preme di nuovo “collega”.

A un rifiuto si risponde nel thread di Slack che il messaggio ha aperto, con una vera risposta. Una risposta in thread contrassegna il messaggio a cui risponde, raggiunge l’unica persona che ne ha bisogno e tiene comunque “non sei collegato” fuori dal canale stesso. Pubblicare un messaggio è anche l’unica capacità che ogni provider possiede, quindi la spiegazione non dipende mai da una funzione che l’app potrebbe non avere.

Un ponte che rispecchia in entrambe le direzioni entra in un ciclo, a meno che qualcosa non distingua le due direzioni. Ogni messaggio che il ponte porta dentro viene contrassegnato con metadata['chat'] — provider, team, conversazione, thread, messaggio e mittente. Lo specchio in uscita salta qualsiasi messaggio che rechi quel contrassegno.

È quella singola regola a rendere sicuro lo specchiamento bidirezionale: un messaggio umano digitato in Control Center viene inoltrato al thread collegato (con il nome dell’autore come prefisso), mentre un messaggio arrivato dalla chat si ferma dove è entrato.

Ciò che attraversa il confine verso l’esterno è di proposito ristretto. La risposta dell’agente viene inoltrata come normale testo di chat. Un pensiero breve può comparire sulla riga Thinking… della scheda. L’output degli strumenti è un muro di diff e resta in Control Center, che è anche il punto a cui rimanda una risposta troncata.

Una scheda racconta il lavoro; il testo resta il messaggio

Sezione intitolata “Una scheda racconta il lavoro; il testo resta il messaggio”

Dove il provider le disegna, un turno porta una sola scheda delle attività, modificata sul posto per tutta la durata del turno e posizionata sopra la risposta che illustra.

La scheda è un piano raggruppato, non un paragrafo ammassato. Slack concatena details e output sulla stessa attività, quindi i dettagli di una riga vengono inviati una volta sola (il pensiero su Thinking…) e output non viene mai trasmesso in streaming. La preparazione è una riga il cui titolo viene sostituito (Working on it…, Cloning …). Il ragionamento aggiunge una riga Thinking…. Ogni strumento ha la propria riga (Read lib/main.dart). La risposta è testo normale sotto la scheda: la vista piano di Slack la legge da un’aggiunta markdown successiva, non da un blocco mescolato alle attività. Nient’altro: nessun output degli strumenti e, degli argomenti, solo quello di uno strumento presente in una breve lista consentita, perché gli argomenti di uno strumento sconosciuto possono essere l’intero contenuto di un file, e una riga è una riga.

La scheda copre anche l’attesa prima del turno. Una prima menzione crea la conversazione, e crearla clona ogni repo in cui l’agente lavorerà: su un repository reale sono minuti in cui all’agente non è ancora stato chiesto nulla e il thread sembra che il bot abbia ignorato la richiesta. La scheda si apre quindi nel momento in cui la menzione viene accettata (Working on it…) e poi quella riga di preparazione si aggiorna con le stesse parole che mostra il desktop — Cloning acme/widgets…, Setting up Reviewer…, Starting the agent… — poi una riga Thinking… non appena l’agente inizia a ragionare, poi una riga per strumento. Una scheda, un messaggio, da “ti ho sentito” fino alla risposta come testo normale sotto. La preparazione viene segnalata solo dove qualcuno in chat la sta aspettando: una conversazione in preparazione nell’app, o una rimasta in sospeso e ripresa all’avvio del server, viene preparata senza alcuna scheda.

Porta l’unica cosa che la chat non può dare a chi legge: un link View in Control Center alla conversazione dietro la risposta. I prodotti di chat accettano http(s) in un link e nient’altro, quindi il pulsante rimanda a una piccola pagina servita da questo server, che rimbalza il browser al link control-center:// dell’app desktop. Quella pagina non legge nulla e non risolve nulla: gli id nel suo URL vengono riportati in un deep link e da nessun’altra parte. E un server senza un URL pubblico raggiungibile produce semplicemente una scheda senza pulsante, non una scheda rotta.

Le schede sono una capacità come qualsiasi altra. Un provider che non le ha si comporta esattamente come prima, solo testo, e un provider che ne rifiuta una al momento della chiamata conserva il proprio stream: la risposta ha sempre la precedenza sulla scheda che la descrive. Quando lo streaming è del tutto disattivato, la scheda finita viaggia invece nell’unico messaggio pubblicato. Un ticket aperto riceve lo stesso trattamento: la conferma di /cc ticket è una scheda completata la cui ragione di esistere è il link al ticket appena creato.

I prodotti di chat differiscono in ciò che sanno disegnare, quindi il ponte è scritto pensando alla superficie più ricca — una risposta che cresce dal vivo dentro un thread, uno stato temporaneo “sta pensando…”, un thread con un titolo, una risposta effimera a un comando slash — e degrada una capacità alla volta. Un provider che non ne ha nessuna funziona comunque; le risposte arrivano semplicemente come un unico messaggio.

Annunciare una capacità non significa prometterla. Le risposte in streaming di Slack richiedono una funzione di un piano a pagamento, e Slack rifiuta solo al momento della chiamata. Un rifiuto contrassegnato come permanente viene ricordato per tutta la vita della connessione: il ponte smette di chiedere e da quel momento pubblica risposte intere, e le impostazioni lo dicono chiaramente, perché a “le mie risposte non sono più in streaming” si deve poter rispondere senza leggere un log. Le schede delle attività vengono abbandonate allo stesso modo se Slack ne rifiuta una.

Ogni limite appartiene al provider, non al relay: il tetto dei messaggi, il tetto dei blocchi di stream e la necessità o meno di un’ancora di thread per uno stream sono tutti dichiarati dall’adapter, e una risposta lunga viene spezzata su un confine di paragrafo, riga o parola, mai a metà di una parola.

Tutto ciò che ha la forma di un prodotto vive dietro una porta. Il nucleo generico conosce markdown, conversazioni di chat, thread di chat e membri; non sa nulla di envelope di Slack, mrkdwn o gateway intent. Tre pezzi lo rendono possibile:

  • L’adapter normalizza un prodotto: il suo trasporto, le sue chiamate API, il suo codec di testo e le stranezze dei suoi envelope. Quelli di Slack sono istruttivi — una menzione in un canale arriva due volte (come app_mention e come message) e la maggior parte dei sottotipi di messaggio è contorno, non nuove richieste. Entrambe le cose vengono risolte nell’adapter, così il nucleo non scopre mai che Slack consegna due volte.
  • Il descrittore è la forma pubblica del provider: quali credenziali richiede, quali sono i loro formati, quali URL aiutano un utente a trovarle, cosa sa fare e se la sua app può essere creata o modificata da qui. La scheda delle impostazioni, la finestra di connessione e la convalida degli argomenti sono tutte generate da esso — il client rimanda un provider più una mappa con gli id dei campi come chiavi.
  • Il plugin è la composizione lato server: come le credenziali diventano una connessione verificata, come si costruisce un adapter e come si gestisce l’app dal lato del provider.

Aggiungere un prodotto è quindi un valore di enum, un adapter e un plugin. Nessuna nuova operazione RPC, nessuna modifica allo schema, nessuna modifica al client — e il client resta corretto, perché un campo di cui non ha mai sentito parlare viene comunque mostrato con l’etichetta fornita dal server.

Due dettagli impediscono all’astrazione di filtrare verso l’alto. I passaggi di gestione dell’app per cui un provider non ha un’API (genera un token, installa l’app, reinstalla dopo una modifica dei permessi) vengono restituiti come passaggi con un nome e i propri link, così la schermata di completamento della configurazione li mostra senza sapere di quale provider si tratti. E ogni istruzione scritta dal ponte nomina il provider e il comando che l’app usa davvero, così un comando rinominato o un secondo provider risulta corretto senza nuove stringhe.

I token di chat di uno spazio di lavoro risiedono nella directory propria di quello spazio, accanto al suo database — come semplice JSON in <dataDir>/<workspaceId>/chat_credentials/<provider>.json, non nel database e di proposito non nel portachiavi del sistema operativo. Due conseguenze si perdono facilmente di vista in seguito: eliminare uno spazio di lavoro ne rimuove la directory, e i suoi token se ne vanno con essa; ed esportare o eseguire il backup di uno spazio di lavoro copia workspace.db, quindi uno spazio di lavoro passato a qualcun altro non contiene token del bot attivi. Il file eredita il confine di fiducia del filesystem dell’host del server: chiunque possa leggere la directory dei dati può leggere un token del bot, lo stesso confine dietro cui stanno i database.

Un file per provider, scritto in modo atomico e accessibile solo al proprietario: un’app revocata si elimina rimuovendo un singolo file, e un file corrotto costa un provider anziché tutti. Le credenziali entrano dentro con l’operazione di connessione e non escono mai più — le letture di stato rispondono solo con i metadati della connessione.

  • Impostazioni strutturali dell’app. Il trasporto, gli scope e le sottoscrizioni agli eventi sono imposti, non esposti. Un’app che si può configurare fino a farla tacere da una schermata di impostazioni è un ticket di assistenza, non una funzione.
  • Conversazione più comandi slash. La superficie di Slack è fatta di messaggi, risposte in thread e comandi slash.
  • Una seconda fonte di verità. La chat non è un luogo in cui il lavoro viene conservato. Il thread di Slack è uno spazio, la risposta è una trascrizione, il ticket è un ticket — tutti nello spazio di lavoro, raggiungibili da ogni client.