Добавление адаптера платформы

Руководство о том, как подключить новый мессенджер к шлюзу Hermes. Адаптер платформы связывает Hermes с внешним сервисом сообщений (Telegram, Discord, WeCom и др.), чтобы пользователи общались с агентом прямо оттуда.

совет

Платформу можно добавить двумя способами:

  • Плагин (рекомендуется для сообщества и сторонних разработчиков): положите директорию плагина в ~/.hermes/plugins/ — никаких изменений в основном коде не нужно. См. Путь через плагин ниже.
  • Встроенный: затрагивает более 20 файлов в коде, конфигурации и документации. Используйте чеклист встроенного пути ниже.

Архитектурная схема

User ↔ Messaging Platform ↔ Platform Adapter ↔ Gateway Runner ↔ AIAgent

Каждый адаптер наследует BasePlatformAdapter из gateway/platforms/base.py и реализует:

  • connect() — установить соединение (WebSocket, long-poll, HTTP-сервер и т. п.) (абстрактный)
  • disconnect() — корректное завершение работы (абстрактный)
  • send() — отправить текстовое сообщение в чат (абстрактный)
  • send_typing() — показать индикатор набора текста (опциональное переопределение)
  • get_chat_info() — вернуть метаданные чата (опциональное переопределение)

Входящие сообщения адаптер принимает и передаёт через self.handle_message(event) — базовый класс маршрутизирует их в gateway runner.

Путь через плагин (рекомендуется)

Через систему плагинов адаптер платформы добавляется без правок основного кода Hermes. Плагин — это директория с двумя файлами:

~/.hermes/plugins/my-platform/
  plugin.yaml      # Метаданные плагина
  adapter.py       # Класс адаптера + точка входа register()

plugin.yaml

Метаданные плагина. Блоки requires_env и optional_env автоматически наполняют элементы интерфейса hermes config (см. Переменные окружения в hermes config ниже).

name: my-platform
label: My Platform
kind: platform
version: 1.0.0
description: My custom messaging platform adapter
author: Your Name
requires_env:
  - MY_PLATFORM_TOKEN          # bare string works
  - name: MY_PLATFORM_CHANNEL  # or rich dict for better UX
    description: "Channel to join"
    prompt: "Channel"
    password: false
optional_env:
  - name: MY_PLATFORM_HOME_CHANNEL
    description: "Default channel for cron delivery"
    password: false

adapter.py

import os
from gateway.platforms.base import (
    BasePlatformAdapter, SendResult, MessageEvent, MessageType,
)
from gateway.config import Platform, PlatformConfig


class MyPlatformAdapter(BasePlatformAdapter):
    def __init__(self, config: PlatformConfig):
        super().__init__(config, Platform("my_platform"))
        extra = config.extra or {}
        self.token = os.getenv("MY_PLATFORM_TOKEN") or extra.get("token", "")

    async def connect(self) -> bool:
        # Подключиться к API платформы, запустить слушателей
        self._mark_connected()
        return True

    async def disconnect(self) -> None:
        self._mark_disconnected()

    async def send(self, chat_id, content, reply_to=None, metadata=None):
        # Отправить сообщение через API платформы
        return SendResult(success=True, message_id="...")

    async def get_chat_info(self, chat_id):
        return {"name": chat_id, "type": "dm"}


def check_requirements() -> bool:
    return bool(os.getenv("MY_PLATFORM_TOKEN"))


def validate_config(config) -> bool:
    extra = getattr(config, "extra", {}) or {}
    return bool(os.getenv("MY_PLATFORM_TOKEN") or extra.get("token"))


def _env_enablement() -> dict | None:
    token = os.getenv("MY_PLATFORM_TOKEN", "").strip()
    channel = os.getenv("MY_PLATFORM_CHANNEL", "").strip()
    if not (token and channel):
        return None
    seed = {"token": token, "channel": channel}
    home = os.getenv("MY_PLATFORM_HOME_CHANNEL")
    if home:
        seed["home_channel"] = {"chat_id": home, "name": "Home"}
    return seed


def register(ctx):
    """Точка входа плагина — вызывается системой плагинов Hermes."""
    ctx.register_platform(
        name="my_platform",
        label="My Platform",
        adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
        check_fn=check_requirements,
        validate_config=validate_config,
        required_env=["MY_PLATFORM_TOKEN"],
        install_hint="pip install my-platform-sdk",
        # Автоконфигурация через переменные окружения — заполняет PlatformConfig.extra
        # из env до создания адаптера. См. раздел «Автоконфигурация через env».
        env_enablement_fn=_env_enablement,
        # Поддержка доставки cron в домашний канал. Позволяет заданиям
        # deliver=my_platform маршрутизироваться без правок cron/scheduler.py.
        # См. раздел «Доставка cron».
        cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
        # Переменные авторизации пользователей для платформы
        allowed_users_env="MY_PLATFORM_ALLOWED_USERS",
        allow_all_env="MY_PLATFORM_ALLOW_ALL_USERS",
        # Ограничение длины сообщения для умного разбиения (0 = без ограничений)
        max_message_length=4000,
        # Подсказка для LLM, добавляемая в системный промпт
        platform_hint=(
            "You are chatting via My Platform. "
            "It supports markdown formatting."
        ),
        # Отображение
        emoji="💬",
    )

    # Опционально: зарегистрировать инструменты, специфичные для платформы
    ctx.register_tool(
        name="my_platform_search",
        toolset="my_platform",
        schema={...},
        handler=my_search_handler,
    )

Конфигурация

Пользователи настраивают платформу в config.yaml:

gateway:
  platforms:
    my_platform:
      enabled: true
      extra:
        token: "..."
        channel: "#general"

Либо через переменные окружения, которые адаптер читает в __init__.

Что система плагинов делает автоматически

При вызове ctx.register_platform() следующие точки интеграции отрабатывают сами — никаких изменений в основном коде:

Точка интеграцииКак работает
Создание адаптера шлюзаРеестр проверяется раньше встроенной цепочки if/elif
Парсинг конфигурацииPlatform._missing_() принимает любое имя платформы
Валидация подключённой платформыВызывается validate_config() из реестра
Авторизация пользователейПроверяются allowed_users_env / allow_all_env
Автовключение только по envenv_enablement_fn заполняет PlatformConfig.extra + home_channel
Мост конфигурации YAMLapply_yaml_config_fn транслирует ключи config.yaml в env / extras
Доставка croncron_deliver_env_var включает поддержку deliver=<name>
Элементы интерфейса hermes configrequires_env / optional_env в plugin.yaml заполняются автоматически
Инструмент send_messageМаршрутизируется через живой адаптер шлюза
Кросс-платформенная доставка вебхуковРеестр проверяется для известных платформ
Доступ к команде /updateФлаг allow_update_command
Каталог каналовПлатформы плагинов включены в перечисление
Подсказки в системном промптеplatform_hint инжектируется в контекст LLM
Разбиение сообщенийmax_message_length для умного дробления
Редактирование PIIФлаг pii_safe
hermes statusПоказывает платформы плагинов с тегом (plugin)
hermes gateway setupПлатформы плагинов отображаются в меню настройки
hermes tools / hermes skillsПлатформы плагинов в конфигурации на платформу
Блокировка токена (мультипрофиль)Используйте acquire_scoped_lock() в connect()
Предупреждение об осиротевшей конфигурацииОписательный лог при отсутствии плагина

Автоконфигурация через env

Чаще платформу настраивают переменными окружения в ~/.hermes/.env, а не правкой config.yaml. Хук env_enablement_fn даёт плагину подхватить эти переменные до создания адаптера, чтобы hermes gateway status, get_connected_platforms() и доставка cron видели актуальное состояние без инициализации SDK платформы.

def _env_enablement() -> dict | None:
    """Заполнить PlatformConfig.extra из переменных окружения.

    Вызывается реестром платформ во время load_gateway_config().
    Вернуть None, если платформа не настроена минимально — тогда
    вызывающий код пропускает автовключение. Вернуть dict для заполнения extras.

    Специальный ключ 'home_channel' извлекается и становится полноценным
    датаклассом HomeChannel в PlatformConfig; остальные ключи
    сливаются в PlatformConfig.extra.
    """
    token = os.getenv("MY_PLATFORM_TOKEN", "").strip()
    channel = os.getenv("MY_PLATFORM_CHANNEL", "").strip()
    if not (token and channel):
        return None
    seed = {"token": token, "channel": channel}
    home = os.getenv("MY_PLATFORM_HOME_CHANNEL")
    if home:
        seed["home_channel"] = {
            "chat_id": home,
            "name": os.getenv("MY_PLATFORM_HOME_CHANNEL_NAME", "Home"),
        }
    return seed


def register(ctx):
    ctx.register_platform(
        name="my_platform",
        label="My Platform",
        adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
        check_fn=check_requirements,
        validate_config=validate_config,
        env_enablement_fn=_env_enablement,
        # ... другие поля
    )

Мост конфигурации YAML→env

Кто-то предпочитает задавать ключи config.yaml (my_platform.require_mention, my_platform.allowed_channels и т. д.) вместо переменных окружения. Хук apply_yaml_config_fn отдаёт эту трансляцию самому плагину, чтобы основной gateway/config.py не разбирался в YAML-схеме вашей платформы.

import os

def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> dict | None:
    """Транслировать ключи `my_platform:` из config.yaml в env / extras.

    yaml_cfg     — полный разобранный dict верхнего уровня config.yaml
    platform_cfg — собственный sub-dict платформы (yaml_cfg.get("my_platform", {}))

    Можно напрямую мутировать os.environ (с проверкой `not os.getenv(...)`
    для приоритета env > YAML) и/или вернуть dict для слияния в
    PlatformConfig.extra. Вернуть None или {} при отсутствии extras.
    """
    if "require_mention" in platform_cfg and not os.getenv("MY_PLATFORM_REQUIRE_MENTION"):
        os.environ["MY_PLATFORM_REQUIRE_MENTION"] = str(platform_cfg["require_mention"]).lower()
    allowed = platform_cfg.get("allowed_channels")
    if allowed is not None and not os.getenv("MY_PLATFORM_ALLOWED_CHANNELS"):
        if isinstance(allowed, list):
            allowed = ",".join(str(v) for v in allowed)
        os.environ["MY_PLATFORM_ALLOWED_CHANNELS"] = str(allowed)
    return None  # ничего дополнительного для слияния в PlatformConfig.extra

def register(ctx):
    ctx.register_platform(
        name="my_platform",
        ...,
        apply_yaml_config_fn=_apply_yaml_config,
    )

Хук вызывается во время load_gateway_config() после общего цикла по общим ключам (он обрабатывает unauthorized_dm_behavior, notice_delivery, reply_prefix, require_mention и т. д.) и до _apply_env_overrides() — так что плагину остаётся транслировать только платформоспецифичные ключи.

Исключения из хука перехватываются и пишутся в лог на уровне debug — некорректный плагин никогда не прервёт загрузку конфигурации шлюза.

Доставка cron

Чтобы задания cron с deliver=my_platform уходили в настроенный домашний канал, укажите в cron_deliver_env_var имя переменной окружения с ID чата/комнаты/канала по умолчанию:

ctx.register_platform(
    name="my_platform",
    ...
    cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
)

Планировщик читает эту переменную, когда определяет домашнюю цель для заданий deliver=my_platform, и считает платформу допустимой целью cron наравне с _KNOWN_DELIVERY_PLATFORMS. Если env_enablement_fn заполняет dict home_channel (см. выше), приоритет у него — cron_deliver_env_var остаётся запасным вариантом для cron-заданий, которые запускаются до заполнения env.

Доставка cron из отдельного процесса

cron_deliver_env_var делает платформу распознаваемой целью deliver=. Чтобы сама отправка прошла, когда cron-задание выполняется в отдельном процессе от шлюза (то есть hermes cron run отдельно от hermes gateway), зарегистрируйте standalone_sender_fn:

async def _standalone_send(
    pconfig,
    chat_id,
    message,
    *,
    thread_id=None,
    media_files=None,
    force_document=False,
):
    """Открыть эфемерное соединение / получить свежий токен, отправить и закрыть."""
    # ... открыть соединение, отправить сообщение, вернуть результат ...
    return {"success": True, "message_id": "..."}
    # или {"error": "..."}

ctx.register_platform(
    name="my_platform",
    ...
    cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
    standalone_sender_fn=_standalone_send,
)

Зачем нужен этот хук: встроенные платформы (Telegram, Discord, Slack и др.) поставляют прямые REST-хелперы в tools/send_message_tool.py, и cron доставляет сообщения, не удерживая шлюз в том же процессе. Платформы-плагины исторически зависели от _gateway_runner_ref(), который вне процесса шлюза возвращает None, — без standalone_sender_fn отправка на стороне cron падает с ошибкой No live adapter for platform '<name>'.

Функция получает те же pconfig и chat_id, что и живой адаптер, плюс опциональные именованные аргументы thread_id, media_files и force_document. Возврат {"success": True, "message_id": ...} считается успешной доставкой; возврат {"error": "..."} попадает в delivery_errors cron-задания. Исключения внутри функции перехватывает диспетчер и выводит как Plugin standalone send failed: <reason>. Эталонные реализации лежат в plugins/platforms/{irc,teams,google_chat}/adapter.py.

Переменные окружения в hermes config

hermes_cli/config.py сканирует plugins/platforms/*/plugin.yaml при импорте и сам наполняет OPTIONAL_ENV_VARS из блоков requires_env и (опционально) optional_env. Используйте расширенный формат dict, чтобы задать описания, подсказки, флаги пароля и URL — интерфейс CLI подхватит их без лишней возни.

# plugins/platforms/my_platform/plugin.yaml
name: my_platform-platform
label: My Platform
kind: platform
version: 1.0.0
description: >
  My Platform gateway adapter for Hermes Agent.
author: Your Name
requires_env:
  - name: MY_PLATFORM_TOKEN
    description: "Bot API token from the My Platform console"
    prompt: "My Platform bot token"
    url: "https://my-platform.example.com/bots"
    password: true
  - name: MY_PLATFORM_CHANNEL
    description: "Channel to join (e.g. #hermes)"
    prompt: "Channel"
    password: false
optional_env:
  - name: MY_PLATFORM_HOME_CHANNEL
    description: "Default channel for cron delivery (defaults to MY_PLATFORM_CHANNEL)"
    prompt: "Home channel (or empty)"
    password: false
  - name: MY_PLATFORM_ALLOWED_USERS
    description: "Comma-separated user IDs allowed to talk to the bot"
    prompt: "Allowed users (comma-separated)"
    password: false

Поддерживаемые ключи dict: name (обязательный), description, prompt, url, password (bool; если не указан, определяется по суффиксам *_TOKEN / *_SECRET / *_KEY / *_PASSWORD / *_JSON), category (по умолчанию "messaging").

Записи в формате строки (- MY_PLATFORM_TOKEN) тоже работают — для них генерируется общее описание на основе label плагина. Если запись для той же переменной уже жёстко прописана в OPTIONAL_ENV_VARS, приоритет у неё (ради обратной совместимости); форма plugin.yaml служит запасным вариантом.

UX при медленном LLM на разных платформах

У некоторых платформ есть ограничения, которые меняют способ подачи медленного ответа LLM:

  • LINE выдаёт одноразовый reply token, который истекает примерно через 60 секунд после входящего события. Ответ с этим токеном бесплатен, фолбэк на платный Push API — нет. Если LLM не успел к дедлайну, выбор такой: «сжечь платную квоту Push» или «сделать что-то поумнее с reply token, пока он не истёк».
  • WhatsApp закрывает сессию после 24 часов бездействия, дальше принимаются только шаблонные сообщения.
  • SMS не знает ни индикаторов набора текста, ни поэтапных обновлений — длинный ответ выглядит так, будто бот ушёл в офлайн.

Это реальные ограничения, которые базовый BasePlatformAdapter предусмотреть не может. Интерфейс плагинов намеренно оставляет место, чтобы надстроить платформоспецифичный UX поверх базового цикла набора текста, не раздувая список аргументов.

Паттерн: переопределение _keep_typing для промежуточного UX

BasePlatformAdapter._keep_typing — это «сердцебиение» индикатора набора текста: фоновая задача, которая работает, пока LLM генерирует ответ, и отменяется при его доставке. Чтобы добавить платформоспецифичное поведение по порогу времени (например, отправить «думаю…» через 45 секунд), переопределите _keep_typing в адаптере, запустите собственную задачу рядом с super()._keep_typing() и завершите её в блоке finally:

class LineAdapter(BasePlatformAdapter):
    async def _keep_typing(self, chat_id: str, *args, **kwargs) -> None:
        if self.slow_response_threshold <= 0:
            await super()._keep_typing(chat_id, *args, **kwargs)
            return

        async def _fire_at_threshold() -> None:
            try:
                await asyncio.sleep(self.slow_response_threshold)
            except asyncio.CancelledError:
                raise
            # Платформоспецифичная работа — для LINE отправляем Template
            # Buttons «Get answer» с закешированным reply token, чтобы
            # пользователь мог получить кешированный ответ позже через
            # свежий (бесплатный) reply token из postback callback.
            await self._send_slow_response_button(chat_id)

        side_task = asyncio.create_task(_fire_at_threshold())
        try:
            await super()._keep_typing(chat_id, *args, **kwargs)
        finally:
            if not side_task.done():
                side_task.cancel()
                try:
                    await side_task
                except (asyncio.CancelledError, Exception):
                    pass

Ключевые моменты:

  • Всегда вызывайте await super()._keep_typing(...). «Сердцебиение» набора текста полезно само по себе — не заменяйте его, а надстраивайте поверх.
  • Завершайте побочную задачу в finally. Когда LLM заканчивает работу (или /stop отменяет выполнение), шлюз отменяет задачу набора текста. Побочная задача тоже должна заметить отмену — иначе зависнет и может сработать после того, как ответ уже доставлен.
  • Используйте совместно с interrupt_session_activity, чтобы по команде /stop разрешить orphan-состояния UX. Для LINE это перевод записи в postback-кеше из PENDING в ERROR, чтобы постоянная кнопка «Get answer» выдала «Run was interrupted» вместо зацикливания.

Паттерн: переопределение send для маршрутизации через кеш

Если ваш UX при медленном ответе кеширует результат для последующего получения (postback-поток LINE), переопределённый send должен распознавать три режима:

  1. Активный postback pending для этого чата → закешировать ответ по request_id, ничего видимого не отправлять.
  2. Системное подтверждение занятости (⚡ Interrupting, ⏳ Queued, ⏩ Steered) → обойти кеш и отправить видимо, чтобы пользователь увидел реакцию шлюза.
  3. Обычный ответ → отправить через reply-token-или-push как обычно.
async def send(self, chat_id: str, content: str, **kw) -> SendResult:
    if _is_system_bypass(content):
        return await self._send_text_chunks(chat_id, content, force_push=False)
    pending_rid = self._pending_buttons.get(chat_id)
    if pending_rid:
        self._cache.set_ready(pending_rid, content)
        return SendResult(success=True, message_id=pending_rid)
    return await self._send_text_chunks(chat_id, content, force_push=False)

_SYSTEM_BYPASS_PREFIXES — это собственные префиксы подтверждения занятости шлюза (, , , 💾). Их всегда нужно пропускать видимо, в каком бы состоянии ни был кешированный UX.

Когда применять этот паттерн

Используйте переопределение цикла набора текста, если:

  • Исходящий API платформы имеет жёсткое временно́е ограничение (одноразовый reply token, истекающая sticky-сессия и т. д.) И
  • Видимый промежуточный пузырь — приемлемый UX для этой платформы.

Используйте более простой путь slow_response_threshold = 0 (всегда Push), если:

  • Платформа не различает бесплатные и платные отправки, ИЛИ
  • Пользователи предпочитают «загрузка… загрузка… ГОТОВО» без промежуточного интерактивного пузыря.

LINE поддерживает оба варианта: по умолчанию порог 45 с для бесплатного postback-получения, а LINE_SLOW_RESPONSE_THRESHOLD=0 возвращает режим «всегда Push».

Эталонная реализация

Полная реализация postback для LINE — в plugins/platforms/line/adapter.py: конечный автомат RequestCache (PENDING → READY → DELIVERED плюс ERROR для /stop), переопределение _keep_typing с Template Buttons по порогу, переопределение send с маршрутизацией через кеш и переопределение interrupt_session_activity для разрешения orphan-записей PENDING.

Эталонные реализации (путь через плагин)

Полный рабочий пример — plugins/platforms/irc/ в репозитории: полноценный async IRC-адаптер без внешних зависимостей. plugins/platforms/teams/ охватывает Bot Framework / Adaptive Cards, plugins/platforms/google_chat/ — OAuth-based REST API, plugins/platforms/line/ — webhook-driven Messaging API с платформоспецифичным UX для медленного LLM.


Пошаговый чеклист (встроенный путь)

заметка

Этот чеклист — про добавление платформы прямо в основную кодовую базу Hermes; обычно так делают контрибьюторы core для официально поддерживаемых платформ. Сообществу и сторонним разработчикам рекомендуется путь через плагин выше.

1. Enum платформы

Добавьте платформу в enum Platform в gateway/config.py:

class Platform(str, Enum):
    # ... существующие платформы ...
    NEWPLAT = "newplat"

2. Файл адаптера

Создайте gateway/platforms/newplat.py:

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
    BasePlatformAdapter, MessageEvent, MessageType, SendResult,
)

def check_newplat_requirements() -> bool:
    """Вернуть True, если зависимости доступны."""
    return SOME_SDK_AVAILABLE

class NewPlatAdapter(BasePlatformAdapter):
    def __init__(self, config: PlatformConfig):
        super().__init__(config, Platform.NEWPLAT)
        # Читать конфигурацию из dict config.extra
        extra = config.extra or {}
        self._api_key = extra.get("api_key") or os.getenv("NEWPLAT_API_KEY", "")

    async def connect(self) -> bool:
        # Установить соединение, запустить polling/webhook
        self._mark_connected()
        return True

    async def disconnect(self) -> None:
        self._running = False
        self._mark_disconnected()

    async def send(self, chat_id, content, reply_to=None, metadata=None):
        # Отправить сообщение через API платформы
        return SendResult(success=True, message_id="...")

    async def get_chat_info(self, chat_id):
        return {"name": chat_id, "type": "dm"}

Для входящих сообщений создайте MessageEvent и вызовите self.handle_message(event):

source = self.build_source(
    chat_id=chat_id,
    chat_name=name,
    chat_type="dm",  # или "group"
    user_id=user_id,
    user_name=user_name,
)
event = MessageEvent(
    text=content,
    message_type=MessageType.TEXT,
    source=source,
    message_id=msg_id,
)
await self.handle_message(event)

3. Конфигурация шлюза (gateway/config.py)

Три точки изменений:

  1. get_connected_platforms() — Добавить проверку необходимых учётных данных платформы
  2. load_gateway_config() — Добавить запись в map токен/env: Platform.NEWPLAT: "NEWPLAT_TOKEN"
  3. _apply_env_overrides() — Сопоставить все переменные NEWPLAT_* с конфигурацией

4. Gateway runner (gateway/run.py)

Пять точек изменений:

  1. _create_adapter() — Добавить ветку elif platform == Platform.NEWPLAT:
  2. _is_user_authorized() map allowed_usersPlatform.NEWPLAT: "NEWPLAT_ALLOWED_USERS"
  3. _is_user_authorized() map allow_allPlatform.NEWPLAT: "NEWPLAT_ALLOW_ALL_USERS"
  4. Ранняя проверка env, кортеж _any_allowlist — Добавить "NEWPLAT_ALLOWED_USERS"
  5. Ранняя проверка env, кортеж _allow_all — Добавить "NEWPLAT_ALLOW_ALL_USERS"
  6. _UPDATE_ALLOWED_PLATFORMS frozenset — Добавить Platform.NEWPLAT

5. Кросс-платформенная доставка

  1. gateway/platforms/webhook.py — Добавить "newplat" в кортеж типов доставки
  2. cron/scheduler.py — Добавить в frozenset _KNOWN_DELIVERY_PLATFORMS и в map платформ _deliver_result()

6. Интеграция с CLI

  1. hermes_cli/config.py — Добавить все переменные NEWPLAT_* в _EXTRA_ENV_KEYS
  2. hermes_cli/gateway.py — Добавить запись в список _PLATFORMS с key, label, emoji, token_var, setup_instructions и vars
  3. hermes_cli/platforms.py — Добавить запись PlatformInfo с label и default_toolset (используется TUI skills_config и tools_config)
  4. hermes_cli/setup.py — Добавить функцию _setup_newplat() (можно делегировать в gateway.py) и добавить кортеж в список платформ мессенджеров
  5. hermes_cli/status.py — Добавить запись обнаружения платформы: "NewPlat": ("NEWPLAT_TOKEN", "NEWPLAT_HOME_CHANNEL")
  6. hermes_cli/dump.py — Добавить "newplat": "NEWPLAT_TOKEN" в dict обнаружения платформ

7. Инструменты

  1. tools/send_message_tool.py — Добавить "newplat": Platform.NEWPLAT в map платформ
  2. tools/cronjob_tools.py — Добавить newplat в строку описания целей доставки

8. Наборы инструментов

  1. toolsets.py — Добавить определение набора инструментов "hermes-newplat" с _HERMES_CORE_TOOLS
  2. toolsets.py — Добавить "hermes-newplat" в список includes "hermes-gateway"

9. Опционально: подсказки платформы

agent/prompt_builder.py — Если у платформы есть особые ограничения рендеринга (нет markdown, лимиты длины сообщений и т. д.), добавьте запись в dict _PLATFORM_HINTS. Так в системный промпт попадут платформоспецифичные инструкции:

_PLATFORM_HINTS = {
    # ...
    "newplat": (
        "You are chatting via NewPlat. It supports markdown formatting "
        "but has a 4000-character message limit."
    ),
}

Подсказки нужны не всем платформам — добавляйте их, только если поведение агента должно отличаться.

10. Тесты

Создайте tests/gateway/test_newplat.py, охватывающий:

  • Создание адаптера из конфигурации
  • Построение события сообщения
  • Метод send (с моком внешнего API)
  • Платформоспецифичные функции (шифрование, маршрутизация и т. д.)

11. Документация

ФайлЧто добавить
website/docs/user-guide/messaging/newplat.mdПолная страница настройки платформы
website/docs/user-guide/messaging/index.mdТаблица сравнения платформ, диаграмма архитектуры, таблица наборов инструментов, раздел безопасности, ссылка «Далее»
website/docs/reference/environment-variables.mdВсе переменные NEWPLAT_*
website/docs/reference/toolsets-reference.mdНабор инструментов hermes-newplat
website/docs/integrations/index.mdСсылка на платформу
website/sidebars.tsЗапись сайдбара для страницы документации
website/docs/developer-guide/architecture.mdКоличество адаптеров + перечисление
website/docs/developer-guide/gateway-internals.mdПеречисление файлов адаптеров

Аудит паритета

Перед тем как пометить PR с новой платформой завершённым, проведите аудит паритета относительно зрелой платформы:

# Найти все .py-файлы с упоминанием эталонной платформы
search_files "bluebubbles" output_mode="files_only" file_glob="*.py"

# Найти все .py-файлы с упоминанием новой платформы
search_files "newplat" output_mode="files_only" file_glob="*.py"

# Любой файл из первого набора, отсутствующий во втором — потенциальный пробел

Повторите для файлов .md и .ts. Разберите каждый пробел — это перечисление платформы (нужно обновить) или платформоспецифичная ссылка (пропустить)?

Типовые паттерны

Long-poll-адаптеры

Если адаптер работает на long-polling (как Telegram или Weixin), задействуйте задачу polling-цикла:

async def connect(self):
    self._poll_task = asyncio.create_task(self._poll_loop())
    self._mark_connected()

async def _poll_loop(self):
    while self._running:
        messages = await self._fetch_updates()
        for msg in messages:
            await self.handle_message(self._build_event(msg))

Callback/Webhook-адаптеры

Если платформа push-доставляет сообщения на ваш эндпоинт (как WeCom Callback), запустите HTTP-сервер:

async def connect(self):
    self._app = web.Application()
    self._app.router.add_post("/callback", self._handle_callback)
    # ... запустить aiohttp-сервер
    self._mark_connected()

async def _handle_callback(self, request):
    event = self._build_event(await request.text())
    await self._message_queue.put(event)
    return web.Response(text="success")  # Немедленно подтвердить получение

Для платформ с жёсткими дедлайнами ответа (например, 5-секундный лимит WeCom) всегда подтверждайте получение сразу, а ответ агента доставляйте проактивно через API позже. Сессии агента длятся от 3 до 30 минут — вписать ответ в окно callback-запроса нереально.

Блокировки токенов

Если адаптер удерживает постоянное соединение с уникальными учётными данными, добавьте scoped-блокировку, чтобы два профиля не использовали один и тот же токен:

from gateway.status import acquire_scoped_lock, release_scoped_lock

async def connect(self):
    if not acquire_scoped_lock("newplat", self._token):
        logger.error("Token already in use by another profile")
        return False
    # ... подключиться

async def disconnect(self):
    release_scoped_lock("newplat", self._token)

Эталонные реализации

АдаптерПаттернСложностьХорош как пример для
bluebubbles.pyREST + webhookСредняяПростая интеграция REST API
weixin.pyLong-poll + CDNВысокаяОбработка медиа, шифрование
wecom_callback.pyCallback/webhookСредняяHTTP-сервер, AES-крипто, мультиприложение
telegram.pyLong-poll + Bot APIВысокаяПолнофункциональный адаптер с группами, тредами
ESC