Plattform-Adapter
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
class BasePlatformAdapter(ABC):2317— abstrakte Adapter-BasisklasseAbstrakte Methoden send / send_draft:2983-3015— Sendeschnittstelle, die Subklassen implementieren müssenMessageType / ProcessingOutcome / MessageEvent:1737-1759— einheitliches Event-ModellCachedMedia:1638-1737— Medien-Cache (vermeidet wiederholtes Hochladen)SendResult / EphemeralReply:1898-2068— Sendeergebnis und flüchtige AntwortAllgemeine Sende-Hilfen:3173-3326—send_slash_confirm/send_clarify/send_typing/send_multiple_imagesgateway/platforms/-Verzeichnis— signal / whatsapp_cloud / weixin / bluebubbles / yuanbao / qqbot…plugins/platforms/-Verzeichnis— Telegram / Discord / Slack (als Plugin)ADDING_A_PLATFORM.md— Leitfaden zum Hinzufügen einer PlattformPlatformRegistry:162— Registry (register / register_deferred / unregister)
Datenfluss
- Beim Start bringt
GatewayRunner(gateway/run.py:3029) die Adapter über_start_one_profile_adapter:9389hoch. - 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). - Der Adapter hört auf Plattform-Events, übersetzt sie in
MessageEvent:1759und übergibt sie an_handle_message:9947. - 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 denCachedMedia:1638-Cache. - 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:
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:
@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:
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:
connectunterscheidet überis_reconnectzwischen 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_allauf einem Pfad aufgerufen, der es nicht sollte, zieht es discord.py inhermes chathinein.registervor 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.