Skip to content

Plattform-Adapter

源码版本v2026.7.20

Verantwortung

Jede Plattform (Telegram, Discord, Slack, Signal, WhatsApp, WeChat, QQ …) hat einen Adapter (adapter), der native Plattform-Events in ein einheitliches MessageEvent übersetzt und einheitliche Sende-Aktionen an die jeweilige Plattform-API zurückleitet. Adapter erben von BasePlatformAdapter und implementieren nur wenige abstrakte Methoden. Adapter stammen aus dem eingebauten gateway/platforms/ oder dem Plugin (plugin) plugins/platforms/ und werden stets über die PlatformRegistry (registry) registriert.

Designmotiv

Native Plattform-APIs unterscheiden sich massiv: Telegram Bot API nutzt sendMessage + MarkdownV2-Escaping, Discord läuft über Webhooks + Schnee-IDs, Signal spricht mit einem lokalen signal-cli-Daemon, WeChat nicht einmal Rich-Text. Würden diese Unterschiede in die Hauptschleife des Agenten wandern, wäre der Agent nie fertig. Die Adapter-Schicht schluckt sie — nach oben spuckt sie ein einheitliches MessageEvent, nach unten nimmt sie das abstrakte send(chat_id, content) entgegen. Zusammen mit dem Deferred-Loading der PlatformRegistry wird auch hermes chat (ein Befehl, der Plattformen gar nicht berührt) nicht von zwanzig Plattform-SDKs ausgebremst.

Schlüsseldateien

Datenfluss

  1. Beim Start bringt GatewayRunner (gateway/run.py:3029) die Adapter über _start_one_profile_adapter:9389 hoch.
  2. Der Adapter wird aus der PlatformRegistry (gateway/platform_registry.py:162) nach Namen geladen; Deferred Loading ist erlaubt (zunächst billiger Platzhalter, echter Import erst bei Nutzung).
  3. Der Adapter hört auf Plattform-Events, übersetzt sie in MessageEvent:1759 und übergibt sie an _handle_message:9947.
  4. Der Agent erzeugt Streaming-Output (streaming output); der Adapter übergibt ihn über die einheitliche Schnittstelle (send:3008 / send_draft) an die Plattform; Medien laufen über den CachedMedia:1638-Cache.
  5. Plattform-Unterschiede (Nachrichtenlänge, Rich-Text, Bildobergrenzen) werden vollständig vom Adapter geschluckt – der Gateway (gateway)-Kern bekommt davon nichts mit.

Die PlatformRegistry pflegt zwei Register — _entries für real Geladenes, _deferred für billige Platzhalter. Erst beim Lookup wird letzteres in ersteres überführt; hier die Kernfelder:

python
class PlatformRegistry:
    """Central registry of platform adapters.

    Thread-safe for reads (dict lookups are atomic under GIL).
    Writes happen at startup during sequential discovery.
    """
    def __init__(self) -> None:
        self._entries: dict[str, PlatformEntry] = {}
        # Deferred platform loaders: name -> zero-arg callable that imports the
        # owning plugin module (which calls register() and populates _entries).
        self._deferred: dict[str, Callable[[], None]] = {}

    def get(self, name: str) -> Optional[PlatformEntry]:
        """Look up a platform entry by name."""
        if name not in self._entries:
            self._resolve(name)
        return self._entries.get(name)

Das Motiv für Deferred steht im Kommentar — Adapter-Module importieren oben schwere SDKs (lark_oapi, discord.py, slack_bolt …); Vollast-Laden würde selbst hermes chat um Sekunden bremsen. Der Platzhalter-Loader importiert erst beim ersten get(name) wirklich.

BasePlatformAdapter erzwingt über ABC drei Methoden — connect, disconnect, send; das ist der Mindestvertrag eines Adapters:

python
@abstractmethod
async def connect(self, *, is_reconnect: bool = False) -> bool:
    """Connect to the platform and start receiving messages.

    Args:
        is_reconnect: False on a cold first boot; True when the reconnect
            watcher is re-establishing a platform that was previously running
            and dropped after an outage. Adapters that buffer a server-side
            update queue (e.g. Telegram's Bot API) should preserve that queue
            when ``is_reconnect`` is True so messages sent during the outage
            are delivered rather than silently discarded.
    """
    pass

@abstractmethod
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:

is_reconnect lässt beim Reconnect die serverseitige Update-Queue (wie bei Telegram) erhalten; sonst werden während des Abbruchs gesendete Nachrichten lautlos weggeworfen.

Ein konkreter Adapter (Signal) sieht so aus. send übersetzt den einheitlichen Content in RPC-Parameter von signal-cli:

python
class SignalAdapter(BasePlatformAdapter):
    """Signal messenger adapter using signal-cli HTTP daemon."""

    platform = Platform.SIGNAL
    SUPPORTS_MESSAGE_EDITING = False  # Signal hat keine edit-API für gesendete Nachrichten

    async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
        """Send a text message with native Signal formatting."""
        await self._stop_typing_indicator(chat_id)
        plain_text, text_styles = self._markdown_to_signal(content)
        params: Dict[str, Any] = {"account": self.account, "message": plain_text}
        if text_styles:
            params["textStyles"] = text_styles
        if chat_id.startswith("group:"):
            params["groupId"] = chat_id[6:]
        else:
            params["recipient"] = [await self._resolve_recipient(chat_id)]
        result = await self._rpc("send", params)

SUPPORTED_MESSAGE_EDITING = False als Fähigkeitsbit sagt dem Stream-Konsumenten: Versuch nicht, bereits gesendete Nachrichten zu editieren; sonst bleibt am Signal-Client ein «Editieren fehlgeschlagen»-Platzhalter stehen. Plattform-Eigenschaftsunterschiede werden über Adapter-Felder ausgedrückt, nicht über if-else-Verzweigungen.

Grenzen und Fehler

  • Plattform offline/abgebrochen: connect unterscheidet über is_reconnect zwischen Kaltstart und Wiederherstellung; während des Abbruchs gesendete User-Nachrichten können auf der Serverseite stauen. Hat der Adapter die Update-Queue (Telegram) nicht bewahrt oder keine SSE-Reconnect-Logik (Signal), gehen Nachrichten verloren.
  • Schweres SDK versehentlich geladen: Ist der Name für den Deferred-Eintrag falsch oder wird _resolve_all auf einem Pfad aufgerufen, der es nicht sollte, zieht es discord.py in hermes chat hinein. register vor Deferred ist das Auffangwehr.
  • Fähigkeitsbits inkonsistent: Ist SUPPORTED_MESSAGE_EDITING=False, der Stream-Konsument aber läuft über den Edit-Pfad, hinterlässt der Client einen «Editieren fehlgeschlagen»-Platzhalter. Neue Fähigkeitsbits müssen von allen Adaptern mit einem Default explizit deklariert werden.
  • Multi-Profile, doppelter Listener: Dieselbe Signal-Nummer in mehreren Profilen registriert startet zwei SSE-Listener. Signal nutzt _acquire_platform_lock('signal-phone', self.account, ...) als prozessweite Sperre gegen Doppelstart.

Zusammenfassung

Adapter = Übersetzer zwischen «Plattform-Dialekt ↔ einheitlichem Event». Eine neue Plattform bedeutet nur: von BasePlatformAdapter erben, wenige abstrakte Methoden implementieren und registrieren – siehe gateway/platforms/ADDING_A_PLATFORM.md. Telegram/Discord/Slack laufen über den Plugin-Pfad und werden gleich behandelt wie eingebaute Adapter.

Inoffizielle Community-Lernseite. Basiert auf dem MIT-lizenzierten NousResearch/hermes-agent-Quellcode.