Sari la conținut

Punți de chat

O punte de chat transformă un produs de chat extern — astăzi Slack — într-o cale de primă clasă către un spațiu de lucru Control Center. Menționează botul și un agent se trezește; răspunde în firul din Slack și conversația continuă; tastează o comandă slash și se înregistrează un tichet. Dedesubt se află aceleași spații, transcripturi, tichete și aceeași atribuire pe care le folosește aplicația de desktop. Chatul este o suprafață a spațiului de lucru, nu o copie a acestuia.

Slack este singurul provider livrat. Nu există nicio implementare pentru Discord, Telegram, Teams sau Matrix nicăieri în produs. Funcționalitatea este scrisă intenționat fără să țină cont de provider — adăugarea unui al doilea produs înseamnă o valoare de enum, un adaptor și un plugin —, dar astăzi „chat” înseamnă Slack.

Două suprafețe acoperă subiectul și ele aparțin unor persoane diferite. Conectarea unui spațiu de lucru la Slack și personalizarea botului sunt acțiuni de admin, în Setări → Spațiu de lucru → General. Legarea propriului cont de chat de identitatea ta Control Center este o acțiune de membru, în Setări → Spațiu de lucru → Profil și identitate.

Trei decizii modelează întreaga funcționalitate: în ce direcție rulează conexiunea, ce autorizează un mesaj care sosește din exterior și unde are voie să trăiască cunoașterea specifică produsului.

Puntea nu ascultă niciodată după provider. Slack este accesat prin Socket Mode: serverul cere de la Slack un URL de socket și inițiază conexiunea spre exterior, apoi primește mențiuni, DM-uri și comenzi slash pe acea conexiune.

Consecința este chiar ideea. Un laptop în spatele unui NAT, un server de acasă fără domeniu, o mașină care se mută între rețele — toate pot rula puntea fără port de intrare, fără tunel, fără certificat TLS și fără URL public de rotit când se schimbă. Nu există niciun endpoint de webhook de securizat, pentru că nu există niciun endpoint de webhook. Când Control Center compune definiția aplicației, impune Socket Mode și elimină orice URL de cerere, pentru că o aplicație configurată pentru amândouă ar fi o aplicație care funcționează pe jumătate.

Costul este o conexiune de menținut în viață, ceea ce ține de server: reconectarea folosește backoff exponențial cu jitter, plafonat la 30 de secunde, o rotire de socket inițiată de provider se reconectează fără să escaladeze acel backoff, iar o credențială respinsă este definitivă — reîncercarea la nesfârșit a unui token revocat ar aduce doar o limită de rată, așa că puntea se oprește și așteaptă tokenuri noi, raportând în setări eroarea proprie a providerului, cuvânt cu cuvânt.

Un spațiu de lucru fără aplicație de chat nu costă absolut nimic: niciun socket, niciun timer, niciun trafic. Și întregul mecanism pornește după bannerul de disponibilitate al serverului, pentru că o aplicație de chat care răspunde greu nu trebuie să întârzie niciodată pornirea.

Persoana care a instalat aplicația nu este persoana care trimite mesajul. Așa că fiecare eveniment de intrare se rezolvă într-un Principal înainte să se întâmple ceva și eșuează în siguranță, cu o explicație:

  • Un cont de chat nelegat primește instrucțiuni de legare, în firul Slack în care a scris.
  • Un cont legat al cărui utilizator nu este membru al acestui spațiu de lucru este îndrumat să ceară o invitație de la un admin.
  • Un membru al cărui rol este doar-citire (viewer, guest) este informat că rolul său nu poate începe munca.

Niciunul dintre aceste cazuri nu merge mai departe. Un mesaj de chat nu este niciodată atribuit cuiva care nu l-a trimis — exact asta ține corecte jurnalul de audit, limitele de rată pe principal și raportorii tichetelor. Vezi Multiplayer — identitate, apartenență și prezență pentru modelul de apartenență pe care se sprijină.

Legarea este o rezolvare de identitate, nu o acordare de acces. Apartenența este reverificată la fiecare eveniment, așa că o legătură aparținând cuiva care a fost între timp eliminat se rezolvă într-un refuz, nu în privilegiile sale vechi. Două căi stabilesc o legătură: un cod de unică folosință, cu durată scurtă de viață, pe care membrul îl tastează în chat, sau o potrivire automată pe emailul verificat al providerului — iar calea prin email cere totuși ca utilizatorul potrivit să fie deja membru, așa că un străin a cărui adresă există din întâmplare pe server nu se leagă de nimic.

Codurile sunt ținute intenționat în memorie, cu o durată de viață de 15 minute. Persistarea unuia ar crea un rând durabil, cu forma unei credențiale, pentru ceva menit să se evapore, iar repornirea serverului care invalidează toate codurile în curs este comportamentul corect, nu o regresie — membrul apasă din nou pe „leagă”.

Un refuz primește răspuns în firul Slack deschis de mesaj, ca răspuns propriu-zis. Un răspuns în fir aplică un ecuson pe mesajul căruia îi răspunde, ajunge la singura persoană care are nevoie de el și ține în continuare „nu ești legat” în afara canalului propriu-zis. Trimiterea unui mesaj este, de asemenea, singura capabilitate pe care o are orice provider, așa că explicația nu depinde niciodată de o funcție pe care aplicația s-ar putea să nu o aibă.

O punte care oglindește în ambele sensuri va intra în buclă dacă ceva nu distinge cele două direcții. Fiecare mesaj pe care puntea îl aduce este marcat cu metadata['chat'] — provider, echipă, conversație, fir, mesaj și expeditor. Oglinda spre exterior sare peste orice mesaj care poartă acel marcaj.

Această singură regulă face oglindirea bidirecțională sigură: un mesaj scris de un om în Control Center este transmis mai departe în firul legat (cu numele autorului ca prefix), în timp ce un mesaj care a venit din chat se oprește acolo unde a intrat.

Ce trece spre exterior este intenționat limitat. Răspunsul agentului este transmis ca text obișnuit de chat. Un gând scurt poate apărea pe rândul Thinking… al cardului. Ieșirea instrumentelor este un zid de diff-uri și rămâne în Control Center, tot acolo unde duce și un răspuns trunchiat.

Acolo unde providerul le afișează, o tură poartă un singur card de sarcină, editat pe loc cât timp rulează tura și așezat deasupra răspunsului pe care îl explică.

Cardul este un plan grupat, nu un paragraf strivit. Slack concatenează details și output pe aceeași sarcină, așa că detaliile unui rând sunt trimise o singură dată (gândul de pe Thinking…), iar output nu este niciodată transmis în flux. Configurarea inițială este un rând al cărui titlu este înlocuit (Working on it…, Cloning …). Raționamentul adaugă un rând Thinking…. Fiecare instrument are propriul rând (Read lib/main.dart). Răspunsul este text obișnuit sub card — vizualizarea de tip plan a Slack îl citește dintr-o adăugare markdown ulterioară, nu dintr-un fragment amestecat în sarcini. Nimic altceva: nicio ieșire de instrument și doar un argument de instrument dintr-o listă scurtă de valori permise, pentru că argumentele unui instrument necunoscut pot fi un întreg corp de fișier, iar un rând este o singură linie.

Cardul acoperă și așteptarea dinaintea turei. O primă mențiune creează conversația, iar crearea ei clonează fiecare repo în care va lucra agentul — ceea ce, pe un depozit real, înseamnă minute în care agentul nu a fost încă întrebat nimic și firul arată ca și cum botul ar fi ignorat cererea. Așa că cardul se deschide în momentul în care mențiunea este acceptată (Working on it…), apoi acel rând de configurare se actualizează cu exact aceleași cuvinte pe care le arată aplicația de desktop — Cloning acme/widgets…, Setting up Reviewer…, Starting the agent… —, după care un rând Thinking… de îndată ce agentul începe să raționeze, apoi un rând pentru fiecare instrument. Un card, un mesaj, de la „te-am auzit” până la răspuns, ca text obișnuit dedesubt. Configurarea inițială este raportată doar acolo unde cineva din chat o așteaptă: o conversație pregătită în aplicație sau una rămasă în pană, reluată la pornirea serverului, se pregătește fără niciun card.

El poartă singurul lucru pe care chatul nu i-l poate da cititorului: un link View in Control Center către conversația din spatele răspunsului. Produsele de chat acceptă http(s) într-un link și nimic altceva, așa că butonul indică o pagină mică servită de acest server, care redirecționează browserul către linkul control-center:// al aplicației de desktop. Pagina aceea nu citește nimic și nu rezolvă nimic — id-urile din URL-ul ei sunt ecouate într-un link profund și nicăieri altundeva —, iar un server fără un URL public accesibil produce pur și simplu un card fără buton, în loc de unul stricat.

Cardurile sunt o capabilitate ca oricare alta. Un provider fără ele se comportă exact ca înainte, doar text, iar un provider care refuză unul în momentul apelului își păstrează fluxul: răspunsul are mereu prioritate față de cardul care îl descrie. Când streamingul este complet oprit, cardul finalizat călătorește în schimb pe singurul mesaj postat. Un tichet înregistrat primește același tratament — confirmarea pentru /cc ticket este un card încheiat al cărui motiv de a exista este linkul către tichetul tocmai creat.

Produsele de chat diferă în ce pot afișa, așa că puntea este scrisă pentru cea mai bogată suprafață — un răspuns care crește în direct în interiorul unui fir, un status temporar „is thinking…”, un fir cu titlu, un răspuns efemer la o comandă slash — și degradează o capabilitate pe rând. Un provider care nu are niciuna funcționează totuși; răspunsurile sosesc pur și simplu ca un singur mesaj.

A anunța o capabilitate nu înseamnă a o promite. Răspunsurile în streaming ale Slack necesită o funcție de plan plătit, iar Slack refuză doar în momentul apelului. Un refuz marcat ca permanent este reținut pentru întreaga viață a conexiunii: puntea nu mai cere și postează de atunci răspunsuri întregi, iar setările spun asta fără ocolișuri, pentru că întrebarea „răspunsurile mele nu mai sunt transmise în streaming” ar trebui să aibă răspuns fără să citești un jurnal. Cardurile de sarcină sunt eliminate la fel dacă Slack refuză unul.

Fiecare limită aparține providerului, nu releului: plafonul de mesaje, plafonul de fragmente din flux și dacă un flux are nevoie de un punct de ancorare în fir sunt toate declarate de adaptor, iar un răspuns lung este împărțit la granița dintre paragrafe, linii sau cuvinte, nu la mijlocul unui cuvânt.

Tot ce ține de un anumit produs stă în spatele unui port. Nucleul generic știe despre markdown, conversații de chat, fire de chat și membri; nu știe nimic despre plicurile Slack, mrkdwn sau intențiile de gateway. Trei piese fac asta posibil:

  • Adaptorul normalizează un singur produs: transportul, apelurile API, codecul de text și particularitățile plicurilor sale. Cele ale Slack sunt instructive — o mențiune pe canal sosește de două ori (ca app_mention și ca message), iar majoritatea subtipurilor de mesaj sunt decor, nu cereri noi. Ambele sunt rezolvate în adaptor, așa că nucleul nu află niciodată că Slack livrează de două ori.
  • Descriptorul este forma publică a providerului: ce credențiale are nevoie, care sunt formatele lor, ce URL-uri ajută un utilizator să le găsească, ce poate face și dacă aplicația sa poate fi creată sau editată de aici. Cardul de setări, dialogul de conectare și validarea argumentelor sunt toate generate din el — clientul trimite înapoi un provider plus o hartă indexată după id-ul câmpului.
  • Pluginul este compoziția de pe partea de server: cum devin credențialele o conexiune verificată, cum este construit un adaptor și cum este gestionată aplicația din partea providerului.

Așa că adăugarea unui produs înseamnă o valoare de enum, un adaptor și un plugin. Nicio operație RPC nouă, nicio schimbare de schemă, nicio schimbare în client — iar clientul rămâne corect, pentru că un câmp de care nu a auzit niciodată se afișează totuși cu eticheta furnizată de server.

Două detalii împiedică abstractizarea să se scurgă în sus. Pașii de administrare a aplicației pentru care un provider nu are API (generează un token, instalează aplicația, reinstalează după o schimbare de permisiuni) sunt returnați ca pași cu nume, cu linkurile lor, așa că ecranul de finalizare a configurării îi afișează fără să știe despre ce provider este vorba. Iar fiecare instrucțiune pe care o scrie puntea numește providerul și comanda pe care o folosește efectiv aplicația, așa că o comandă redenumită sau un al doilea provider se citesc corect fără niciun text nou.

Tokenurile de chat ale unui spațiu de lucru stau în directorul propriu al acelui spațiu de lucru, lângă baza de date — ca JSON simplu la <dataDir>/<workspaceId>/chat_credentials/<provider>.json, nu în baza de date și, în mod deliberat, nu în keychainul sistemului de operare. Două consecințe se pierd ușor mai târziu: ștergerea unui spațiu de lucru dezasociază directorul, iar tokenurile pleacă odată cu el, iar exportarea sau salvarea unei copii de siguranță a unui spațiu de lucru copiază workspace.db, așa că un spațiu de lucru dat altcuiva nu duce în el tokenuri de bot active. Fișierul moștenește granița de încredere a sistemului de fișiere al gazdei serverului — orice are acces de citire la directorul de date poate citi un token de bot, care este aceeași graniță în spatele căreia stau bazele de date.

Un fișier per provider, scris atomic și accesibil doar proprietarului: o aplicație revocată se șterge prin dezasocierea unui singur fișier, iar un fișier corupt costă un provider, nu toți. Credențialele intră înăuntru prin operația de conectare și nu ies niciodată înapoi — citirile de stare răspund doar cu metadate de conexiune.

  • Setări structurale ale aplicației. Transportul, scope-urile și abonamentele la evenimente sunt impuse, nu expuse. O aplicație care poate fi configurată până la tăcere dintr-un ecran de setări este un tichet de asistență, nu o funcție.
  • Conversație plus comenzi slash. Suprafața Slack este alcătuită din mesaje, răspunsuri în fir și comenzi slash.
  • O a doua sursă de adevăr. Chatul nu este un loc unde se stochează munca. Firul Slack este un spațiu, răspunsul este un transcript, tichetul este un tichet — toate în spațiul de lucru, accesibile din orice client.