Zum Inhalt springen

Chat-Brücken

Eine Chat-Brücke macht ein externes Chat-Produkt — heute Slack — zu einem vollwertigen Weg in einen Arbeitsbereich von Control Center. Erwähnst du den Bot, wacht ein Agent auf; antwortest du im Slack-Thread, läuft die Unterhaltung weiter; tippst du einen Slash-Befehl, wird ein Ticket angelegt. Darunter liegen dieselben Bereiche, Transkripte, Tickets und dieselbe Zuordnung, die die Desktop-App nutzt. Chat ist eine Oberfläche auf dem Arbeitsbereich, keine Kopie davon.

Slack ist der einzige Anbieter, der ausgeliefert wird. Nirgends im Produkt gibt es eine Umsetzung für Discord, Telegram, Teams oder Matrix. Die Funktion ist absichtlich anbieterneutral geschrieben — ein zweites Produkt ist ein Enum-Wert, ein Adapter und ein Plugin —, aber heute meint „Chat“ Slack.

Zwei Oberflächen decken das ab, und sie gehören verschiedenen Personen. Einen Arbeitsbereich mit Slack zu verbinden und den Bot anzupassen sind Admin-Aktionen unter Einstellungen → Arbeitsbereich → Allgemein. Dein eigenes Chat-Konto mit deiner Identität in Control Center zu verknüpfen ist eine Aktion als Mitglied unter Einstellungen → Arbeitsbereich → Profil und Identität.

Drei Entscheidungen prägen die ganze Funktion: in welche Richtung die Verbindung läuft, was eine von außen eintreffende Nachricht autorisiert und wo das produktspezifische Wissen leben darf.

Die Brücke lauscht nie auf den Anbieter. Slack wird über Socket Mode erreicht: Der Server fragt bei Slack eine Socket-URL an und wählt nach außen, dann empfängt er über diese Verbindung Erwähnungen, Direktnachrichten und Slash-Befehle.

Genau diese Folge ist der Sinn. Ein Laptop hinter NAT, ein Heimserver ohne Domain, eine Maschine, die das Netz wechselt — sie alle können die Brücke betreiben, ohne eingehenden Port, ohne Tunnel, ohne TLS-Zertifikat und ohne öffentliche URL, die zu rotieren wäre, sobald sie sich ändert. Es gibt keinen Webhook-Endpunkt abzusichern, weil es keinen Webhook-Endpunkt gibt. Wenn Control Center die App-Definition zusammensetzt, setzt es Socket Mode durch und streicht jede Request-URL, denn eine App, die für beides eingerichtet ist, funktioniert nur zur Hälfte.

Der Preis ist eine Verbindung, die am Leben bleiben muss, und das ist die Aufgabe des Servers: Die Wiederverbindung ist exponentieller Backoff mit Jitter, gedeckelt bei 30 Sekunden, eine vom Anbieter angestoßene Socket-Rotation verbindet neu, ohne diesen Backoff hochzusetzen, und abgelehnte Zugangsdaten sind endgültig — ein widerrufenes Token endlos erneut zu versuchen, brächte nur ein Rate-Limit ein, also hält die Brücke an und wartet auf neue Tokens, während sie den Fehler des Anbieters in den Einstellungen wortgetreu meldet.

Ein Arbeitsbereich ohne Chat-App kostet überhaupt nichts: kein Socket, keine Timer, kein Verkehr. Und das Ganze startet nach dem Ready-Banner des Servers, denn eine Chat-App, die langsam antwortet, darf den Start nie aufhalten.

Die Mitgliedschaft autorisiert, nicht der Besitz des Tokens

Abschnitt betitelt „Die Mitgliedschaft autorisiert, nicht der Besitz des Tokens“

Wer die App installiert hat, ist nicht die Person, die die Nachricht sendet. Jedes eingehende Ereignis wird deshalb zu einem Prinzipal aufgelöst, bevor irgendetwas geschieht, und es fällt geschlossen aus, mit einer Erklärung:

  • Ein nicht verknüpftes Chat-Konto erfährt im Slack-Thread, in dem es geschrieben hat, wie die Verknüpfung geht.
  • Ein verknüpftes Konto, dessen Nutzer kein Mitglied dieses Arbeitsbereichs ist, soll einen Admin um eine Einladung bitten.
  • Ein Mitglied mit nur lesender Rolle (viewer, guest) erfährt, dass seine Rolle keine Arbeit starten kann.

Keiner dieser Fälle wird ausgeführt. Eine Chat-Nachricht wird nie jemandem zugerechnet, der sie nicht gesendet hat — nur so bleiben der Prüfpfad, die Rate-Limits pro Prinzipal und die Melder der Tickets zuverlässig. Das Mitgliedschaftsmodell, auf dem das aufsetzt, steht unter Multiplayer — Identität, Mitgliedschaft und Präsenz.

Verknüpfen löst die Identität auf, es ist keine Zugriffserteilung. Die Mitgliedschaft wird bei jedem Ereignis neu geprüft, sodass eine Verknüpfung von jemandem, der inzwischen entfernt wurde, in einer Ablehnung endet und nicht in den alten Rechten. Zwei Wege legen eine Verknüpfung an: ein kurzlebiger Einmalcode, den das Mitglied im Chat eintippt, oder ein automatischer Abgleich mit der verifizierten E-Mail des Anbieters — und der E-Mail-Weg setzt weiterhin voraus, dass der zugeordnete Nutzer schon Mitglied ist, sodass ein Fremder, dessen Adresse zufällig auf dem Server liegt, mit nichts verknüpft wird.

Die Codes bleiben absichtlich im Speicher, 15 Minuten lang gültig. Einen davon zu speichern ergäbe eine dauerhafte Zeile, die wie ein Zugangsdatum aussieht, für etwas, das verfliegen soll, und dass ein Neustart des Servers jeden noch offenen Code ungültig macht, ist das richtige Verhalten und kein Rückschritt — das Mitglied tippt erneut auf „Mein Slack-Konto verknüpfen“.

Eine Ablehnung wird in dem Slack-Thread beantwortet, den die Nachricht geöffnet hat, als echte Antwort. Eine Antwort im Thread kennzeichnet die Nachricht, auf die sie antwortet, erreicht genau die Person, die sie braucht, und hält „you are not linked“ trotzdem aus dem Channel selbst heraus. Eine Nachricht zu senden kann jeder Anbieter, deshalb hängt die Erklärung nie von einer Funktion ab, die die App vielleicht nicht mitbringt.

Eine Brücke, die in beide Richtungen spiegelt, gerät in eine Schleife, wenn nichts die beiden Richtungen auseinanderhält. Jede Nachricht, die die Brücke hereinholt, trägt den Stempel metadata['chat'] — Anbieter, Team, Unterhaltung, Thread, Nachricht und Absender. Der ausgehende Spiegel lässt jede Nachricht aus, die diesen Stempel trägt.

Diese eine Regel macht das Spiegeln in beide Richtungen sicher: Eine Nachricht, die ein Mensch in Control Center tippt, geht in den verknüpften Thread hinaus (der Name des Autors steht davor), während eine Nachricht, die aus dem Chat kam, dort stehen bleibt, wo sie hereingekommen ist.

Nach außen geht bewusst wenig. Die Antwort des Agenten geht als gewöhnlicher Chat-Text hinaus. Ein kurzer Gedanke kann in der Zeile Thinking… der Karte stehen. Die Ausgabe der Tools ist eine Wand aus Diffs und bleibt in Control Center, und dorthin zeigt auch eine gekürzte Antwort.

Eine Karte meldet die Arbeit; der Text bleibt die Nachricht

Abschnitt betitelt „Eine Karte meldet die Arbeit; der Text bleibt die Nachricht“

Wo der Anbieter sie zeichnet, trägt ein Zug eine Aufgabenkarte, die so lange an Ort und Stelle fortgeschrieben wird, wie der Zug läuft, und die über der Antwort sitzt, die sie erklärt.

Die Karte ist ein gruppierter Plan, kein ineinandergeworfener Absatz. Slack hängt details und output an dieselbe Aufgabe, deshalb gehen die Details einer Zeile einmal hinaus (der Gedanke auf Thinking…), und output wird nie gestreamt. Die Einrichtung ist eine Zeile, deren Titel ausgetauscht wird (Working on it…, Cloning …). Das Nachdenken fügt eine Zeile Thinking… an. Jedes Tool bekommt eine eigene Zeile (Read lib/main.dart). Die Antwort ist gewöhnlicher Text unter der Karte — die Planansicht von Slack liest sie aus einem späteren Markdown-Anhang, nicht aus einem Stück, das unter die Aufgaben gemischt ist. Mehr nicht: keine Tool-Ausgabe, und von den Argumenten nur eines aus einer kurzen Freigabeliste, denn die Argumente eines unbekannten Tools können der ganze Inhalt einer Datei sein, und eine Zeile ist eine Zeile.

Die Karte überbrückt auch die Wartezeit vor dem Zug. Die erste Erwähnung legt die Unterhaltung an, und dabei wird jedes Repo geklont, in dem der Agent arbeiten soll — bei einem echten Repository sind das Minuten, in denen den Agenten noch niemand etwas gefragt hat und der Thread so wirkt, als hätte der Bot die Anfrage übersehen. Die Karte geht deshalb in dem Moment auf, in dem die Erwähnung angenommen wird (Working on it…), und der Titel dieser Zeile wechselt danach in dieselben Worte, die der Desktop zeigt — Cloning acme/widgets…, Setting up Reviewer…, Starting the agent… —, dann eine Zeile Thinking…, sobald der Agent zu denken beginnt, dann eine Zeile je Tool. Eine Karte, eine Nachricht, von „ich habe dich gehört“ bis zur Antwort als gewöhnlichem Text darunter. Die Einrichtung wird nur gemeldet, wo im Chat jemand darauf wartet: Eine Unterhaltung, die in der App vorbereitet wird, oder eine hängen gebliebene, die beim Start des Servers weiterläuft, wird ohne jede Karte bereitgestellt.

Sie trägt das Einzige, was der Chat der lesenden Person nicht geben kann: einen Link View in Control Center auf die Unterhaltung hinter der Antwort. Chat-Produkte nehmen in einem Link http(s) an und sonst nichts, also zeigt die Schaltfläche auf eine kleine Seite, die dieser Server ausliefert und die den Browser an den Link control-center:// der Desktop-App weiterreicht. Diese Seite liest nichts und löst nichts auf — die IDs in ihrer URL wandern in einen Deep-Link und sonst nirgends —, und ein Server ohne erreichbare öffentliche URL liefert eine Karte ohne Schaltfläche, keine kaputte.

Karten sind eine Fähigkeit wie jede andere. Ein Anbieter ohne sie verhält sich genau wie zuvor, nur mit Text, und ein Anbieter, der eine zum Zeitpunkt des Aufrufs ablehnt, behält seinen Stream: Die Antwort geht immer vor der Karte, die sie beschreibt. Ist Streaming ganz aus, steht die fertige Karte in der einen Nachricht, die gepostet wird. Ein angelegtes Ticket läuft genauso — die Bestätigung von /cc ticket ist eine abgeschlossene Karte, und sie existiert wegen des Links auf das gerade angelegte Ticket.

Fähigkeiten werden ausgehandelt und dann behalten

Abschnitt betitelt „Fähigkeiten werden ausgehandelt und dann behalten“

Chat-Produkte können Unterschiedliches darstellen, deshalb ist die Brücke gegen die reichste Oberfläche geschrieben — eine Antwort, die live im Thread mitwächst, ein flüchtiger Status „is thinking…“, ein Thread mit Titel, eine ephemere Antwort auf einen Slash-Befehl — und fällt Fähigkeit um Fähigkeit zurück. Ein Anbieter ohne jede davon funktioniert trotzdem; die Antworten kommen einfach als eine Nachricht an.

Eine Fähigkeit zu melden heißt nicht, sie zuzusagen. Die Streaming-Antworten von Slack brauchen eine Funktion eines bezahlten Tarifs, und Slack lehnt erst beim Aufruf ab. Eine Ablehnung, die als dauerhaft markiert ist, gilt für die ganze Lebensdauer der Verbindung: Die Brücke fragt nicht mehr an und postet von da an ganze Antworten, und die Einstellungen sagen das klar, denn „meine Antworten streamen nicht mehr“ soll sich beantworten lassen, ohne ein Log zu lesen. Aufgabenkarten fallen auf demselben Weg weg, wenn Slack eine ablehnt.

Jede Grenze setzt der Anbieter, nicht die Weiterleitung: die Obergrenze für Nachrichten, die Obergrenze für Stream-Blöcke und ob ein Stream einen Thread-Anker braucht, erklärt alles der Adapter, und eine lange Antwort wird an einer Grenze zwischen Absätzen, Zeilen oder Wörtern getrennt, nie mitten im Wort.

Alles, was nach einem bestimmten Produkt aussieht, liegt hinter einem Port. Der generische Kern kennt Markdown, Chat-Unterhaltungen, Chat-Threads und Mitglieder; von Slack-Umschlägen, mrkdwn oder Gateway-Intents weiß er nichts. Drei Teile tragen das:

  • Der Adapter bringt ein Produkt auf eine gemeinsame Form: seinen Transport, seine API-Aufrufe, seinen Text-Codec und die Eigenheiten seiner Umschläge. Bei Slack ist das lehrreich — eine Erwähnung im Channel kommt zweimal an (als app_mention und als message), und die meisten Nachrichten-Subtypen sind Beiwerk, keine neuen Anfragen. Beides löst der Adapter auf, sodass der Kern nie erfährt, dass Slack doppelt ausliefert.
  • Der Deskriptor ist die öffentliche Form des Anbieters: welche Zugangsdaten er braucht, in welchem Format, welche URLs beim Finden helfen, was er kann und ob sich seine App von hier anlegen oder bearbeiten lässt. Einstellungskarte, Verbindungsdialog und die Prüfung der Argumente werden alle daraus erzeugt — der Client schickt einen Anbieter zurück plus eine Map, deren Schlüssel die Feld-IDs sind.
  • Das Plugin ist die Zusammensetzung auf dem Server: wie aus Zugangsdaten eine geprüfte Verbindung wird, wie ein Adapter gebaut wird und wie die App beim Anbieter verwaltet wird.

Ein weiteres Produkt ist damit ein Enum-Wert, ein Adapter und ein Plugin. Keine neue RPC-Operation, keine Änderung am Schema, keine Änderung am Client — und der Client bleibt dabei korrekt, denn ein Feld, das er nicht kennt, erscheint trotzdem mit der Beschriftung, die der Server mitgibt.

Zwei Details halten die Abstraktion davon ab, nach oben durchzusickern. Die Schritte, für die ein Anbieter keine API hat (ein Token erzeugen, die App installieren, nach einer Änderung der Berechtigungen neu installieren), kommen als benannte Schritte mit eigenen Links zurück, sodass der Bildschirm zum Abschließen der Einrichtung sie zeigt, ohne zu wissen, welcher Anbieter das ist. Und jede Anweisung, die die Brücke schreibt, nennt den Anbieter und den Befehl, den die App wirklich benutzt, sodass ein umbenannter Befehl oder ein zweiter Anbieter ohne neuen Text stimmt.

Die Chat-Tokens eines Arbeitsbereichs liegen in seinem eigenen Verzeichnis, neben seiner Datenbank — als einfaches JSON unter <dataDir>/<workspaceId>/chat_credentials/<provider>.json, nicht in der Datenbank und bewusst nicht im Schlüsselbund des Betriebssystems. Zwei Folgen übersieht man später leicht: Wird der Arbeitsbereich gelöscht, wird das Verzeichnis entfernt, und die Tokens gehen mit, und Export oder Sicherung eines Arbeitsbereichs kopiert workspace.db, sodass ein Arbeitsbereich, der an jemand anderen geht, keine aktiven Bot-Tokens in sich trägt. Die Datei erbt die Vertrauensgrenze des Dateisystems auf dem Rechner, der den Server ausführt — wer das Datenverzeichnis lesen kann, kann ein Bot-Token lesen, und hinter derselben Grenze liegen die Datenbanken.

Eine Datei pro Anbieter, atomar geschrieben und nur für den Eigentümer lesbar: Eine widerrufene App verschwindet, indem diese eine Datei entfernt wird, und eine beschädigte Datei kostet einen Anbieter, nicht alle. Zugangsdaten gehen hinein, über das Verbinden, und kommen nie wieder heraus — Statusabfragen antworten nur mit Metadaten der Verbindung.

  • Strukturelle App-Einstellungen. Transport, Scopes und Event-Abonnements werden vorgegeben, nicht zur Einstellung freigegeben. Eine App, die sich von einem Einstellungsbildschirm aus stumm schalten lässt, ist ein Support-Ticket, kein Feature.
  • Unterhaltung plus Slash-Befehle. Die Slack-Oberfläche besteht aus Nachrichten, Antworten im Thread und Slash-Befehlen.
  • Eine zweite maßgebliche Quelle. Im Chat wird keine Arbeit gespeichert. Der Slack-Thread ist ein Bereich, die Antwort ein Transkript, das Ticket ein Ticket — alles im Arbeitsbereich, von jedem Client aus erreichbar.