Контракт Relay ↔ коннектор (v1, ЭКСПЕРИМЕНТАЛЬНЫЙ)
Статус: ЭКСПЕРИМЕНТАЛЬНЫЙ. Контракт МОЖЕТ МЕНЯТЬСЯ без цикла устаревания, пока его не обкатают минимум две реальные платформы класса 1 (Discord + Telegram). На экспериментальной стадии контракт развивается только аддитивно, под контролем
contract_version. Ломающее изменение обновляет оба репозитория синхронно.
Этот документ — формальный интерфейс между шлюзом Hermes (Python,
gateway/relay/) и коннектором (Node/TypeScript,
NousResearch/gateway-gateway). Первое, что делает разработчик коннектора, —
читает этот файл.
Шлюз запускает универсальный RelayAdapter, который сам устанавливает
исходящее соединение с коннектором, при рукопожатии получает
CapabilityDescriptor, а затем обменивается нормализованными событиями
MessageEvent (входящие) и действиями (исходящие) по двунаправленному
WebSocket в пределах хода. Шлюз никогда не узнаёт, какая именно платформа стоит за
коннектором; вся платформенная логика сокетов и идентичности лежит на
коннекторе.
1. Рукопожатие
- Шлюз открывает транспорт (
connect). - Шлюз вызывает
handshake(); коннектор возвращаетCapabilityDescriptor(раздел 2), описывающий платформу, которую обслуживает этот экземпляр адаптера. - Шлюз настраивает адаптер по дескриптору (лимит символов, единица длины, возможности черновиков/правок/тредов/markdown) и регистрирует обработчик входящих.
- Дальше коннектор стримит входящие события и принимает исходящие действия.
contract_version (сейчас 1) передаётся в дескрипторе. Неизвестные поля
дескриптора шлюз игнорирует (прямая совместимость), а недостающие
необязательные поля заполняет значениями по умолчанию.
2. CapabilityDescriptor (полезная нагрузка рукопожатия)
JSON-объект. Источник истины: gateway/relay/descriptor.py.
| Поле | Тип | Обязательно | Значение |
|---|---|---|---|
contract_version | int | да | Версия контракта (внутри версии — только аддитивные изменения). |
platform | string | да | Имя платформы (например, "discord", "telegram"). |
label | string | да | Человекочитаемая метка. |
max_message_length | int | да | Лимит символов; шлюз отдаёт его как MAX_MESSAGE_LENGTH. 0 → трактовать как 4096. |
supports_draft_streaming | bool | да | Родная поддержка предпросмотра со стримингом черновика. |
supports_edit | bool | да | Возможен стриминг через правки; если false, потребитель деградирует до одного сообщения на сегмент. |
supports_threads | bool | да | Возможность create_handoff_thread. |
markdown_dialect | string | да | "plain", "markdown_v2", "discord", … (определяет supports_code_blocks). |
len_unit | string | да | "chars" (встроенный len) или "utf16" (единицы кода UTF-16, как в Telegram). |
emoji | string | нет | Эмодзи для отображения (по умолчанию 🔌). |
platform_hint | string | нет | Подсказка о платформе для системного промпта. |
pii_safe | bool | нет | Скрывать PII в описаниях сессий. |
supports_context | bool | нет | Может ли коннектор передавать окружающий контекст канала/группы для адресованного хода на этой платформе (модель A — выборка истории по запросу: Discord/Slack/Matrix; модель B — пассивный буфер: Telegram/Signal/WhatsApp). По умолчанию false ⇒ к входящим событиям context не прикрепляется. См. §3. |
supports_inchannel_continuable | bool | нет | Может ли платформа держать плоскую продолжаемую поверхность cron (у родного Slack — cron_continuable_surface: in_channel): сводка публикуется верхнеуровневым сообщением в канале/DM, а обычный ответ продолжает задачу через плоскую сессию (platform, chat_id, None). По умолчанию false ⇒ планировщик шлюза безопасно откатывается к режиму тредов (гейт D6), так что старый коннектор сохраняет сегодняшнее поведение с тредами. |
supports_block_formatting | bool | нет | Умеет ли отправитель этой платформы рендерить блочное форматирование из сырого markdown, когда шлюз проставляет metadata.format_hints во фреймах send/edit (Slack: родной блок markdown для таблиц/списков/кода, текст mrkdwn остаётся запасным вариантом). По умолчанию false ⇒ шлюз никогда не проставляет подсказки, и старый коннектор этих метаданных не получает. |
supported_ops | string[] | нет | Обнаружение возможностей на уровне операций: имена исходящих операций, которые отправитель коннектора для этой платформы действительно реализует (например, ["send", "edit", "typing", "follow_up", "get_chat_info"]). Отсутствует/пуст ⇒ коннектор появился раньше этого поля, и шлюз предполагает устаревший набор операций (send/edit/typing/follow_up); НОВАЯ операция используется только тогда, когда она явно объявлена. |
Большинство полей — проекция уже существующего в шлюзе PlatformEntry;
поля, известные только во время выполнения (len_unit, supports_*,
markdown_dialect), приходят из методов возможностей живого адаптера
платформы.
3. Входящие: конверт MessageEvent
Коннектор нормализует каждое событие протокола платформы в MessageEvent
(gateway/platforms/base.py) и доставляет его шлюзу. Входящие идут по
ИСХОДЯЩЕМУ WebSocket /relay шлюза (см. заметку о транспорте ниже) —
коннектор проталкивает фрейм inbound в сокет, который шлюз уже открыл. Шлюз
вычисляет ключ сессии через build_session_key() из вложенного
SessionSource, поэтому корректное заполнение дискриминаторов — самая
ответственная с точки зрения правильности обязанность коннектора.
Транспорт входящих (обратный канал WS, не HTTP)
Шлюз сам устанавливает исходящее соединение с WebSocket /relay
коннектора для рукопожатия, исходящих действий (§4) и собственной отправки
/stop (§5). Входящие едут по тому же сокету в обратную сторону: коннектор
проталкивает фрейм inbound (и interrupt_inbound для §5) в исходящий WS
шлюза. Входящего HTTP-эндпоинта на стороне шлюза нет — шлюзу не нужно (а
в размещённом варианте и невозможно) открывать какой-либо входящий порт; всё
идёт по соединению, которое он инициировал сам.
Маршрутизация между экземплярами. Экземпляр коннектора, которому
принадлежит сокет платформы (и который, соответственно, порождает входящие
события), как правило, не тот экземпляр, к которому шлюз подключил свой
исходящий WS. Поэтому порождающий экземпляр публикует событие во внутренней
шине relay коннектора (Redis pub/sub; RelayBus в
src/core/relayBus.ts) с ключом по тенанту. Каждый экземпляр коннектора
подписан на шину и направляет каждое сообщение своим локальным сессиям
этого тенанта (RelayServer.routeBusMessage); единственный экземпляр, который
реально держит сокет шлюза, доставляет сообщение, а экземпляры без локальной
сессии для этого тенанта ничего не делают. Межэкземплярная доставка — это
прыжок через Redis внутри кластера, а не публичный HTTP-вызов.
Фреймы (коннектор → шлюз, по WS):
{"type":"inbound", "event": <MessageEvent>, "bufferId"?}{"type":"interrupt_inbound", "session_key", "chat_id"}(§5){"type":"passthrough_forward", "forward": <PassthroughForward>, "bufferId"?}(§5.1)
Контекст канала во входящих (дизайн relay-channel-context). Если
дескриптор исходной платформы объявил supports_context (§2), а чат
многосторонний (chat_type ∈ group/channel/thread/forum, но никогда dm),
коннектор МОЖЕТ прикрепить к входящему MessageEvent два необязательных
аддитивных поля:
context: массив окружающих сообщений только для чтения (тот же канал, от старых к новым) — соседняя неадресованная переписка, которую коннектор выбрал по запросу (модель A) или накопил в буфере (модель B). ТОЛЬКО ДЛЯ СПРАВКИ: она никогда не запускает агента (решение о срабатывании уже принято на стороне коннектора по одному лишь адресованному событию). Шлюз рендерит её вMessageEvent.channel_context(тот же путь инъекции только для чтения, которым пользуется дозаполнение истории).context_error: bool, true, когда платформа умеет контекст, но выборка/буфер не сработали и коннектор безопасно отдал пустойcontext(маркер наблюдаемости; на стороне коннектора виден через span доставки).
Оба отсутствуют ⇒ побайтно то же, что сегодня. Коннектор, который их никогда
не шлёт, чат dm или платформа без контекста дают отсутствие
channel_context.
PassthroughForward — это форма передачи по проводу перенаправленного запроса
passthrough-плоскости (вебхуки классов 2/3 — интеракции Discord, Twilio):
{platform, botId, method, path, headers: [[k,v],…], bodyB64, profile?}.
profile необязателен — коннектор проставляет его, когда NAS определяет целевой
профиль для интеракции Team-Gateway; его отсутствие (однопрофильные шлюзы)
сохраняет устаревшую маршрутизацию в пространство имён сессий agent:main по
умолчанию, зеркально полю profile, которое уже несёт SessionSource во
фрейме inbound (#60586). Тело кодируется в base64, чтобы произвольные байты
пережили транспорт с JSON, разделённым переводами строк; шлюз декодирует base64
обратно ровно в те байты, которые перенаправил коннектор (коннектор уже
проверил подпись провайдера и на краю вырезал любые учётные данные общей
личности — §6, — так что шлюз повторно обрабатывает очищенное тело без токенов
и действует по нему через путь follow_up без токена). См. §3.1.
Доверие. Апгрейд WS аутентифицируется секретом конкретного шлюза (§6.1), так что канал доверенный из конца в конец — входящие фреймы отдельно не подписываются HMAC (аутентифицированный сокет заменяет доказательство происхождения каждой доставки, которое требовалось старому HTTP-пути). Прыжок через шину relay находится внутри домена доверия коннектора (как и хранилища аренд/буферов/capability).
Ранние черновики этого контракта доставляли входящие подписанным HTTP POST на
gatewayEndpoint(HttpGatewayDeliveryплюсinbound_receiverна стороне шлюза) с HMAC-подписью ключом доставки тенанта. Это требовало от каждого шлюза доступного входящего URL — невозможно для размещённых шлюзов, у которых нет публичного IP. Описанный выше обратный канал WS его заменяет; ключ доставки тенанта по-прежнему выдаётся при провижининге ради прямой совместимости, но для входящих больше не используется. Passthrough-плоскость (вебхуки классов 2/3 вроде интеракций Discord / Twilio) исторически всё ещё использовалаgatewayEndpointдля перенаправления после ACK; фаза 5 §5.1 переносит это перенаправление тоже на WS (фреймpassthrough_forwardвыше), так что размещённому шлюзу не нужна никакая публичная входящая поверхность, аgatewayEndpointвыводится из эксплуатации, как только переход завершится.
3.1 Перенаправление passthrough-плоскости (§5.1)
Passthrough-плоскость отвечает на критичный к задержке ACK провайдера на КРАЮ
коннектора (например, отложенный ответ на интеракцию Discord в пределах ~3 с),
а затем перенаправляет настоящий запрос шлюзу в режиме «выстрелил и
забыл». Ответ на это перенаправление не нужен (провайдер уже удовлетворён),
поэтому оно едет по тому же исходящему WS, что и inbound, фреймом
passthrough_forward, а не HTTP POST. Шлюз обрабатывает декодированный запрос
через обычный путь агента (интеракция Discord декодируется в MessageEvent и
обрабатывается как сообщение; ответ уходит по исходящему пути / follow_up).
bufferId присутствует, если перенаправление было буферизовано (фаза 5 §5.3,
переключение в режим «только буферизация»), и шлюз подтверждает его после
надёжной передачи.
Поля SessionSource (поверхность протокола)
Источник истины: SessionSource.to_dict() в gateway/session.py. Это все
ключи, которые шлюз принимает по проводу. platform, chat_id, chat_type,
user_id, user_name, thread_id, chat_name и chat_topic присутствуют
всегда (могут быть null); остальные включаются, только если заданы.
| Поле | Тип | Всегда передаётся | Значение |
|---|---|---|---|
platform | string | да | Имя платформы (совпадает с platform в дескрипторе). |
chat_id | string | да | Основной идентификатор беседы (канал/чат). Дискриминатор ключа сессии. |
chat_type | string | да | dm / group / channel / thread / forum. |
chat_name | string|null | да | Человекочитаемое имя чата. |
user_id | string|null | да | Идентификатор автора сообщения. Дискриминатор ключа сессии. |
user_name | string|null | да | Отображаемое имя автора. |
thread_id | string|null | да | Идентификатор треда/темы форума, если сообщение в треде. Дискриминатор ключа сессии. |
chat_topic | string|null | да | Тема/описание канала (Discord, Slack). |
user_id_alt | string | нет | Платформенный стабильный альтернативный идентификатор (UUID в Signal, union_id в Feishu). |
chat_id_alt | string | нет | Альтернативный идентификатор чата (например, внутренний идентификатор группы в Signal). |
scope_id | string | нет | Платформонезависимый дискриминатор области: гильдия Discord / workspace Slack / сервер Matrix. ОБЯЗАТЕЛЕН для изоляции областей в Discord/Slack. Дискриминатор ключа сессии. (Каноническое имя начиная с миграции протокола D-Q2.5.) |
guild_id | string | нет | Устаревший псевдоним, коннектор его больше не читает. Начиная с D-Q2.5c коннектор читает и пишет только scope_id; общий для всего агента SessionSource.to_dict() шлюза всё ещё выдаёт guild_id (зеркало scope_id) для персистентности сессий вне relay, поэтому поле может по-прежнему появляться на проводе, но коннектор его игнорирует. Не полагайтесь на него. |
parent_chat_id | string | нет | Родительский канал, когда chat_id указывает на тред. |
message_id | string | нет | Идентификатор сообщения-триггера (для закрепления/ответа/реакции). |
is_bot(классификация «автор — бот/вебхук») есть в датаклассе на стороне шлюза, но в v1 намеренно НЕ передаётся по проводу — оно не входит вto_dict(). Не добавляйте его вSessionSourceконнектора, пока оно не будет сначала добавлено сюда и вto_dict()(аддитивное повышение версии).
Дискриминаторы SessionSource по платформам
| Платформа | chat_id | chat_type | user_id | thread_id | scope_id |
|---|---|---|---|---|---|
| Discord | id канала | dm/group/thread | id автора | id канала-треда (в тредах) | id гильдии (ОБЯЗАТЕЛЕН для изоляции серверов) |
| Telegram | id чата | dm/group/forum | id отправителя (from) | id темы форума (в форумах) | — |
Ошибитесь с guild_id в Discord — и два сервера схлопнутся в одну
сессию. Это риск номер один по степени тяжести. Эталон соответствия —
build_session_key() шлюза: для заданного SessionSource нормализация
коннектора обязана дать тот же ключ, что и Python-адаптер. (Тесты заглушки
фазы 1 проверяют «известный вход → известный ключ».)
Личность бота и тенант (консолидация в один бот, приложение A)
Конверт несёт личность бота-источника в поле, отдельном от тенанта.
Тенант определяется по собственному дискриминатору события (guild_id в
Discord, chat_id в Telegram, путь/поддомен вебхука) — никогда по тому,
какой токен/сокет/процесс его доставил. Благодаря этому один общий бот может
обслуживать много тенантов (фаза 6), не перегружая существующее поле.
Определение по автору и путь привязки аккаунта (DM) (фаза 7)
Фаза 7 добавляет самостоятельное подключение каждого пользователя к общему боту, а это меняет то, какой дискриминатор определяет экземпляр для маршрутизируемого входящего сообщения, — и добавляет управляющий путь, по которому пользователи привязывают собственный аккаунт.
Определение по автору (правило мультитенантной гильдии, D-7.2). Одна
гильдия Discord может вмещать много тенантов — разные участники, каждый
привязан к своему агенту. Поэтому при доставке коннектор определяет целевой
экземпляр по аутентифицированной привязке автора (user_instance_binding
с ключом (tenant, platform, platform_user_id) через resolveByUser), а
НЕ по маршруту «гильдия → экземпляр». Конкретно:
- Маршрутизируемое сообщение от привязанного пользователя доходит только до экземпляра этого пользователя — даже если второго привязанного пользователя в той же гильдии обслуживает другой экземпляр (каждый достигает только своего).
- Сообщение от непривязанного пользователя не определяется ни в один экземпляр и отбрасывается (fail-closed — никогда не рассылается другим тенантам гильдии).
- Используется подлинный
user_idиз наблюдаемого события — тот самыйSessionSource.user_id, описанный выше, — а не значение, заявленное шлюзом или переданное в управляющем фрейме.
Это маршрутизация «только владельцу» по user_id, которую коннектор
обеспечивает в WsGatewayDelivery (межрепозиторный эталон — E2E-драйвер
мультитенантной гильдии на стороне шлюза,
gateway_multitenant_guild_driver.py).
Путь привязки аккаунта (DM). Пользователь привязывает аккаунт к экземпляру одноразовым кодом, который погашает через DM общему боту:
- Владелец запускает привязку из Портала (или из self-hosted CLI). Коннектор
выпускает короткоживущий код привязки для аутентифицированного
экземпляра (
POST /manage/link; instanceId берётся из принципала вызывающего — токена с подписью NASaud=agent:{instanceId}либо собственного секрета шлюза для этого экземпляра — и никогда из тела запроса). - Пользователь отправляет
/link <code>личным сообщением общему боту с аккаунта, который хочет привязать. - Наблюдатель входящих на коннекторе поглощает этот DM (ни одному агенту
он не маршрутизируется) и записывает
user_instance_bindingс подлиннымuser_idиз наблюдаемого DM-события. С этого момента определение по автору направляет сообщения этого пользователя в привязанный экземпляр.
Отказ от привязки — на усмотрение коннектора. Снятие экземпляра с
обслуживания (POST /manage/deprovision) удаляет его привязки авторов (его
пользователи перестают в него определяться) и отзывает его секрет шлюза
(его сокет больше не может аутентифицироваться — следующий апгрейд WS
закрывается с кодом 4401). Шлюз, увидевший закрытие 4401 после ранее
успешного рукопожатия, считает это окончательным отзывом: перестаёт
переподключаться и сообщает, что платформа relay отключена (а не
повторяемая ошибка). 4401 до первого успешного рукопожатия остаётся
повторяемым (гонка холодного старта / «ещё не выделено», а не отзыв).
3.2 Примитив перехода в простой / переключения на буферизацию (§5.3)
ПРИМИТИВ масштабирования до нуля (не поведение — здесь ничто не решает заснуть и не приостанавливает машину; эти фреймы будет потреблять следующий поток работ). Он даёт шлюзу войти в переход слив/простой, не теряя входящие, которые придут в его отсутствие: коннектор буферизует их для этого экземпляра и проигрывает при переподключении.
Три фрейма (все привязаны к аутентифицированному идентификатору экземпляра соединения — он читается из сохранённой записи секрета при апгрейде WS и никогда не заявляется во фрейме):
{"type":"going_idle"}(шлюз → коннектор) — отправляется внутри УЖЕ СУЩЕСТВУЮЩЕГО перехода шлюза в слив (адаптер шлёт его перед тем, как разобрать сокет). Просит коннектор переключить этот экземпляр в режим только буферизация.{"type":"going_idle_ack"}(коннектор → шлюз) — коннектор переключился: живая доставка остановлена, последующие входящие для этого экземпляра надёжно буферизуются. Шлюз продолжает обслуживание до этого подтверждения (так событие, попавшее в окно переключения, доставляется вживую, а не теряется, — та же дисциплина порядка «SUBSCRIBE до обслуживания», что и у шины). Только после подтверждения можно безопасно закрываться.{"type":"inbound_ack", "bufferId"}(шлюз → коннектор) — надёжная квитанция о буферизованной доставкеinbound(которая несёт свойbufferId), проигранной при переподключении. Коннектор подтверждает запись буфера только после этого, что даёт слив без дублей на плече доставки: экземпляр, умерший посреди слива, получает повторно ровно неподтверждённый хвост; подтверждённая запись не доставляется повторно никогда.
Буфер и слив. Пока экземпляр переключён, коннектор дописывает входящие в
надёжный буфер плеча доставки для этого экземпляра (delivery:<instanceId>)
вместо живой отправки. При переподключении шлюза (СОВЕРШЕННО НОВЫЙ цикл
переподключения заново дозванивается и повторяет рукопожатие после
неожиданного закрытия) новое рукопожатие заставляет коннектор слить накопившееся
по новому сокету по порядку, с подтверждением каждой записи, а затем снять
переключение, чтобы живая доставка возобновилась. Здесь переиспользуется тот же
механизм drainWithoutDup, что и на плече приёма Discord → коннектор, только
применённый к плечу доставки коннектор → шлюз. На всём протяжении решает
коннектор: шлюз может переключать/сливать только СВОЙ СОБСТВЕННЫЙ экземпляр.
НЕ входит в объём (отложенное поведение): автономный таймер простоя, который РЕШАЕТ начать слив, собственно приостановка машины и модель здоровья «приостановлен» в NAS. Примитив звучит так: «когда шлюз сливается, relay переключается на буферизацию и проигрывает при переподключении, без потерь и дублей»; ЧТО запускает слив — вне объёма.
3.3 Сигнал побудки (§5.2)
Вторая половина цикла сна/побудки: как ПРИОСТАНОВЛЕННЫЙ шлюз узнаёт, что его ждёт буферизованная работа. Это ПРИМИТИВ — здесь ничто не приостанавливает машину; он лишь прокладывает СИГНАЛ побудки, чтобы будущий слой поведения масштабирования до нуля мог опираться на «есть буфер ⇒ побудка отправлена».
- Регистрация. Шлюз регистрирует URL побудки при регистрации /
провижининге — любой доступный URL, к которому коннектор может сделать GET,
чтобы разбудить его (хост автозапуска Fly, хост дашборда). Self-hosted:
hermes gateway enroll --wake-url <url>(илиGATEWAY_RELAY_WAKE_URL/gateway.relay_wake_url). Управляемый / NAS: проставляется в окружение контейнера рядом сGATEWAY_RELAY_URL. Передаётся в теле/relay/provisionкакwakeUrlи хранится по экземпляру в записи секрета на коннекторе (заявляется шлюзом, но безопасно ограничено — та же позиция, что и уinstanceId; организация/тенант по-прежнему проверяются токеном, так что шлюз может зарегистрировать цель побудки только для СВОЕГО СОБСТВЕННОГО экземпляра). ОТЛИЧАЕТСЯ от выведенного из эксплуатацииgatewayEndpoint: это цель для сигнала, а не цель доставки. - Сигнал. Когда буферизуемый (ушедший в простой) адресат получает ПЕРВОЕ
буферизованное событие, коннектор выполняет GET без полезной нагрузки и без
подписи на зарегистрированный
wakeUrlэтого экземпляра, напрямую (НЕ через NAS — relay остаётся независимым от NAS). Он не несёт ни данных тенанта, ни входящих: только говорит «у тебя есть буферизованная работа, переподключись». Полномочия тенанта восстанавливаются обычным образом, когда шлюз заново дозванивается (аутентифицированный апгрейд WS), поэтому утёкший или угаданный URL побудки в худшем случае вызовет ложное переподключение СВОЕГО СОБСТВЕННОГО экземпляра. Частота ограничена по экземпляру (один сигнал на окно охлаждения, а не на событие), и всё это по возможности — неудачный сигнал проглатывается; шлюз всё равно сольёт буфер, когда в следующий раз переподключится сам. Нового фрейма нет: побудка — это внеполосный HTTP GET, а не сообщение relay-WS (сокет лежит — в этом весь смысл).
НЕ входит в объём (отложенное поведение): собственно приостановка машины (Fly
autostop:"suspend") и автономный таймер простоя, решающий заснуть. Примитив звучит так: «буферизованное событие для спящего экземпляра ⇒ его wakeUrl получает сигнал»; ЧТО усыпляет экземпляр (и будит его для обслуживания) — слой поведения.
3.4 Обязательства будущего слоя поведения масштабирования до нуля
§3.2 и §3.3 поставляют примитивы; этот раздел — контракт, который отдельный поток работ по поведению масштабирования до нуля обязан соблюдать, чтобы потреблять их безопасно. Ему принадлежат решение о приостановке, собственно приостановка машины и модель платформы/здоровья — ничего из этого здесь не живёт, — но он ДОЛЖЕН обеспечивать следующие гарантии, на которые примитивы рассчитывают:
- Зарегистрировать
wakeUrlдо того, как экземпляр вообще сможет быть приостановлен. Приостановленный экземпляр без зарегистрированногоwakeUrl— чёрная дыра: буферизованные входящие никогда не вызовут сигнал, и он проспит собственный трафик, пока что-то другое его не переподключит. Слой поведения ДОЛЖЕН гарантировать, что доступная цель побудки зарегистрирована (self-hosted:--wake-url; управляемый: проставлена), как предусловие разрешения на приостановку. URL побудки, недоступный пока машина приостановлена (например, указывает на саму приостановленную машину без платформенного автозапуска перед ней), равносилен отсутствию URL. - Сливаться через
going_idle→ дождатьсяgoing_idle_ackДО разбора сокета или приостановки. Никогда не приостанавливаться с неподтверждённым переключением в полёте. Подтверждение — это заверение коннектора, что доставка для этого экземпляра теперь идёт только в буфер; машина, которая приостанавливается после отправкиgoing_idle, но до подтверждения, может потерять входящие, попавшие в гонку с переключением. Шлюз уже гейтит разбор сокета на подтверждении (Q-5.3c); шаг приостановки ДОЛЖЕН стоять после завершения чистого слива, а не соревноваться с ним. - Держать СОВЕРШЕННО НОВЫЙ цикл переподключения живым как предусловие приостановки. Контракт «побудка → слив» звучит так: «сигнал ⇒ шлюз заново дозванивается ⇒ коннектор сливает на рукопожатии переподключения». Если цикл переподключения отключён, сигнал прилетает на машину, которая никогда не дозвонится заново, и буфер зависает. Слой поведения не должен приостанавливать экземпляр, чей транспорт relay не переподключится при побудке.
- Различать «приостановлен» ≠ «лежит» в модели здоровья (Q-5.3b). Приостановленный экземпляр здоров и спит, а не сломан. Слой здоровья / мониторинга ДОЛЖЕН различать эти два состояния (например, по состоянию машины на платформе), чтобы приостановленный экземпляр не перезапускали, не поднимали по нему тревогу и не выкашивали как нездоровый — это сорвало бы приостановку и может устроить гонку с побудкой/сливом.
- Сигнал побудки — по возможности и с ограничением частоты; не рассчитывайте на ровно-один-раз или мгновенную побудку. Не больше одного сигнала на окно охлаждения на экземпляр, неудачный сигнал проглатывается. Слой поведения не должен полагаться на сигнал как на гарантированный или быстрый; правильность по-прежнему держится на «шлюз сливает, когда в следующий раз переподключится». Побудка «с двойной страховкой» (например, запланированная задача, которая тоже переподключает) — решение слоя поведения, а не примитива.
- Приостанавливаться только при настоящем простое — и простой должен
наблюдаться коннектором, а не угадываться шлюзом. ЧТО считать простоем
(нет хода в работе + нет входящих N минут) — политика слоя поведения, но она
обязана сочетаться с существующим механизмом слива (
gateway_staterunning → draining), а не вводить параллельный путь простоя только для relay — то же интеграционное ограничение, которое §3.2 накладывает наgoing_idle. - Приостанавливаться только тогда, когда каждое мессенджер-соединение
идёт через relay, — и перепроверять это в момент приостановки, а не только
при загрузке. Напрямую подключённая платформа (gRPC-стрим Photon iMessage,
BlueBubbles, бот-токен, с которым шлюз соединяется сам) держит сокет, который
коннектор не может ни буферизовать, ни «пнуть», поэтому приостановка при живом
таком соединении теряет его входящие и уже не просыпается. Гейт считает
включённые платформы профиля запуска И живые адаптеры каждого обслуживаемого
профиля (включая вторичные из
gateway.multiplex_profiles), а наблюдатель простоя спрашивает его заново перед каждой последовательностью засыпания. Так прямой адаптер, поднявшийся уже после старта (сверка профилей), не даёт экземпляру заснуть.
Это гарантии, которые слой поведения ДОЛЖЕН примитивам; примитивы же должны
слою поведения только то, что уже описано в §3.2/§3.3 (переключение по
going_idle, надёжный буфер по экземпляру + слив при переподключении с
подтверждениями, и сигнал при первом буферизованном событии для переключённого
экземпляра).
4. Исходящие: набор действий
Шлюз вызывает транспорт со словарями действий. Источник истины:
gateway/relay/transport.py + gateway/relay/adapter.py.
op | Поля | Результат |
|---|---|---|
send | chat_id, content, reply_to?, metadata? | {success: bool, message_id?, error?} |
edit | chat_id, message_id, content, metadata? | {success: bool, error?} |
typing | chat_id, content?, metadata? | {success: bool} |
follow_up | session_key, kind, content, metadata? | {success: bool, message_id?, error?} |
send_media | chat_id, media_kind, source_url, content? (подпись), filename?, reply_to?, metadata? | {success: bool, message_id?, error?} |
prompt | chat_id, prompt_kind, prompt_id, content (вопрос), options[]{id,label,style?}, timeout_s?, reply_to?, metadata? | {success: bool, message_id?, error?} |
react | chat_id, message_id, emoji, remove?, metadata? | {success: bool, error?} |
thread_create | chat_id (родитель), thread_name, message_id? (якорь), metadata? | {success: bool, thread_id?, error?} |
thread_rename | chat_id (родитель), message_id (идентификатор ТРЕДА), thread_name, only_if_current_name?, metadata? | {success: bool, error?} |
get_chat_info(chat_id) — отдельный проксируемый вызов, возвращающий как
минимум {name, type}.
metadata.profile (круговой путь при мультиплексировании). В metadata
каждого исходящего кадра, адресованного чату, лежат дискриминаторы тенанта,
которые шлюз снял с входящего (scope_id, user_id), а на мультиплексированном
шлюзе — ещё и profile Hermes, в который коннектор направил входящие этого чата;
кадры follow_up несут профиль, закодированный в пространстве имён их session_key.
Коннектор ОБЯЗАН проставить тот же profile в следующем passthrough_forward /
inbound для этого чата или взаимодействия, чтобы нажатие кнопки Discord после
слеш-команды попало в сессию того же профиля (gateway/relay/adapter.py::_with_scope,
send_follow_up). Однопрофильные шлюзы этот ключ никогда не отправляют — кадры
остаются побайтово идентичными.
send_media (фаза 2, исходящие медиа). Медиа пересекают провод ПО
ССЫЛКЕ: source_url — это либо (a) перевыкладка на коннекторе, которую
шлюз ранее загрузил через POST {connector}/relay/media (тело — сырые байты,
заголовки Content-Type + необязательный X-Media-Filename, HMAC-bearer
конкретного шлюза — та же схема токена, что и при апгрейде WS; ответ
{id, size} → ссылка {connector}/relay/media/{id}), либо (b) публичный
http(s) URL (например, генерация на fal.media), который коннектор скачивает
напрямую. media_kind — одно из image / voice / audio / video /
document; оно выбирает родной для платформы канал загрузки (Telegram
sendPhoto/sendVoice/…, multipart-вложение Discord, внешняя загрузка Slack,
загрузка медиа + медиасообщение WhatsApp). Подпись едет в content и
рендерится через обычный markdown-канал платформы; платформы без родных
подписей получают дополнительную текстовую отправку (на стороне коннектора).
Оба маршрута и сама операция гейтятся объявлением send_media в
supported_ops — устаревший коннектор операцию никогда не увидит (отправки
медиа шлюзом деградируют до своих текстовых запасных вариантов, какими они были
до появления медиа). Ограничение размера — 25 МБ (MEDIA_MAX_BYTES в
mediaStore.ts коннектора; загрузки больше отклоняются с кодом 413).
Входящие медиа (фаза 2, приём медиа). media_urls входящего события несут
скачиваемые ссылки: публичные URL платформы проходят как есть (CDN Discord);
закрытые авторизацией или истекающие URL платформы (file API Telegram,
url_private Slack, медиа Graph в WhatsApp) скачиваются на стороне коннектора
с учётными данными ПЛАТФОРМЫ и перевыкладываются как
{connector}/relay/media/{id} — учётные данные платформы провод не пересекают
никогда. Ссылки на перевыкладку читаемы любым аутентифицированным шлюзом
(семантика capability-URL: идентификатор — 128 случайных бит, и он уже был
доставлен каждому допущенному получателю); шлюз скачивает каждую ссылку своим
bearer-токеном и отдаёт агенту ЛОКАЛЬНЫЕ пути к файлам, как и родные адаптеры.
Перевыкладки истекают (TTL ~1 ч) — скачивайте при получении, а не лениво.
Параллельный массив media (в том же порядке) добавляет метаданные kind,
mime, size, filename, caption; message_type отражает вид первого
вложения (image/audio/document).
prompt (фаза 3, интерактив). Одна платформонезависимая операция рисует
самые ответственные взаимодействия шлюза (согласования запуска, подтверждения
slash-команд, выбор уточнений) РОДНЫМИ элементами: компоненты-кнопки Discord,
инлайн-клавиатуры Telegram, действия Slack Block Kit, кнопочные сообщения
WhatsApp (≤3 вариантов) / списки (4–10; >10 деградирует до нумерованного
текстового запасного варианта). prompt_kind (approval/clarify/choice) —
лишь подсказка для оформления. prompt_id выпускает шлюз, для коннектора он
непрозрачен; полезная нагрузка колбэка каждого варианта несёт токен
hp1:<prompt_id>:<option_id> (≤64 байт — лимит callback_data в Telegram
ограничивает все каналы; идентификаторы вариантов — [A-Za-z0-9_.-], ≤32
символов). Шлюз выпускает идентификатор промпта как
<nonce процесса>.<8 hex> в том же алфавите и бюджете: перенаправление
passthrough (нажатие в Discord) коннектор рассылает КАЖДОЙ живой сессии шлюза
у тенанта — в отличие от сообщения, которое сужается до допущенного набора
экземпляров, — так что по nonce шлюз отличает СВОЙ промпт от промпта соседа.
style отображается на платформу (primary/success/danger/secondary).
timeout_s на проводе носит рекомендательный характер — истечение
контролируется на стороне ШЛЮЗА (реестр ожидающих промптов сбрасывает
истёкшие записи; владеющий шлюз затем отвечает коротким уведомлением «больше
не жду»).
prompt_response (фаза 3, входящие). Нажатие пользователя возвращается
обычным входящим MessageEvent с полем
prompt_response: {prompt_id, option_id, label?, prompt_message_id?} — и
никогда голым платформенным custom_id. text события дублирует
/{option_id} с message_type: "command", чтобы шлюз, появившийся раньше
этого поля, направил нажатие как набранный ответ, а не отбросил. Шлюз, который
поле ПОНИМАЕТ, всегда поглощает нажатие сам: идентификатор промпта, который он
не выпускал, принадлежит соседнему шлюзу, до которого дошла та же рассылка, и
если бы текст /{option_id} доходил до канала чата, каждый сосед отвечал бы
«Unknown command» под единственным подтверждением владельца. ИСТОЧНИК —
подлинный НАЖАВШИЙ пользователь (наблюдается коннектором: Telegram
callback_query.from, Slack block_actions.user, WhatsApp messages[].from,
member/user интеракции Discord), поэтому гейты авторизации на стороне шлюза
применяются к нажатию кнопки точно так же, как к набранному /approve. Каналы
приёма: Telegram callback_query (поллинг, расширенный allowed_updates; по
возможности answerCallbackQuery, чтобы остановить спиннер), Slack
POST /slack/interactions (HMAC по сырым байтам + окно защиты от повторов, та
же позиция, что у /slack/commands), интерактивные button_reply/list_reply
WhatsApp (ветка нормализации вебхука), компонентные интеракции Discord типа 3
(очищенное перенаправление passthrough §5.1; крайний ACK для типа 3 —
DEFERRED_UPDATE, так что видимого ответа «thinking…» нет). Чужие полезные
нагрузки колбэков (кнопки другой интеграции) событиями промпта не становятся
никогда: Telegram/Slack/WhatsApp отбрасывают их на коннекторе; перенаправления
Discord типа 3 сохраняют устаревшую форму «custom_id как текст».
react (фаза 3, жизненный цикл подтверждений). Ставит/снимает собственную
реакцию бота emoji на message_id — возвращает через relay статусные
подтверждения жизненного цикла обработки 👀→✅/❌, как у родных адаптеров. По
проводу идёт Unicode-эмодзи; отправитель Slack отображает его в словарь имён
Slack (eyes, white_check_mark, …) и считает already_reacted/no_reaction
успехом (идемпотентность). Telegram использует setMessageReaction (пустой
набор = снять; ограничение Telegram на курируемый набор эмодзи может отклонять
глифы — сбой структурированный, и шлюз считает реакции косметикой). WhatsApp
шлёт сообщение-реакцию (пустой эмодзи = снять). По контракту реакции ставятся
по возможности: сбой react никогда не должен ронять ход.
thread_create / thread_rename (фаза 4, жизненный цикл тредов). Одна
платформонезависимая пара покрывает треды передачи задачи, темы DM/форумов
Telegram и смысловые переименования по названию от LLM. thread_create:
Discord создаёт тред канала (тип 11) или тред, привязанный к сообщению, если
задан message_id; Telegram — createForumTopic (возвращается id темы); Slack
публикует ИМЕНОВАННОЕ корневое сообщение-затравку и возвращает его ts (треды
там привязаны к сообщению — явный якорь message_id возвращается дословно).
Созданный идентификатор едет в SendResult.thread_id. thread_rename: Discord
делает PATCH канала-треда; Telegram — editForumTopic. Защита от затирания
only_if_current_name — это семантика родных адаптеров «ручное
переименование побеждает», обеспечиваемая на стороне КОННЕКТОРА: Discord
сначала читает текущее имя и при несовпадении ничего не делает
(структурированный success:false); в Telegram чтения имени темы нет, поэтому
ЗАЩИЩЁННОЕ переименование невыполнимо и безопасно завершается отказом
(незащищённые переименования проходят). Slack не объявляет thread_rename
(текст корневого сообщения — содержимое, а не имя). WhatsApp не объявляет ни
того, ни другого (тредов нет).
Маркеры автотредов + манифест команд, объявляемый шлюзом (фаза 4,
входящие/рукопожатие). Когда политика исходящих автотредов коннектора
создаёт тред в Discord, последующие входящие события из этого треда несут
source.auto_thread_created: true + source.auto_thread_initial_name —
наблюдаемое коннектором свидетельство, которое включает у шлюза канал
смыслового переименования (название сессии от LLM переименовывает тред через
ЗАЩИЩЁННЫЙ thread_rename; память по экземпляру, так что в парке N>1 промах
просто никогда не включает этот канал). Шлюз может также объявить свой набор
slash-команд во фрейме hello для Discord
(command_manifest: [{name, description, options?}]); коннектор сверяет с ним
ГЛОБАЛЬНУЮ регистрацию application-команд Discord (GET → diff → массовая
перезапись PUT; идемпотентно, с дебаунсом, по возможности — сбой регистрации
никогда не влияет на рукопожатие). Команды по-прежнему диспетчеризуются через
passthrough-плоскость, как и раньше; манифест лишь держит реестр Discord в
синхронизации с тем, что обрабатывает диспетчер шлюза.
Обогащение входящего reply_to (фаза 4). Ответ на платформе может нести
reply_to: {text?, author?, is_own?} рядом с reply_to_message_id — то, что
пользователь ПРОЦИТИРОВАЛ, заполняемое только из данных, которые у коннектора
уже были под рукой (инлайновый referenced_message в Discord, инлайновый
reply_to_message в Telegram, context.from в WhatsApp плюс ограниченный кэш
текстов входящих по экземпляру для текстовой части). Отсутствующие поля
означают, что платформа данных не передала, — дополнительный вызов API
платформы это никогда не вызывает. is_own = процитированное сообщение
написал обслуживаемый бот (то же свидетельство, что и у маркера релевантности
is_reply_to_bot). Шлюз отображает всё это на те же поля контекста ответа
MessageEvent, которые заполняют родные адаптеры.
typing с content? (сброс статуса в Slack). Фрейм typing обычно не
содержит content — коннектор рисует активный индикатор своей платформы
(статус Assistant «is typing…» в Slack, одноразовый индикатор набора в
остальных). Пустая строка в content — явный запрос сброса: в Slack
коннектор ставит статус треда Assistant в "", убирая его. Шлюз отправляет
сброс только для Slack (постоянный статус); одноразовые платформы его никогда
не получают. Аддитивно в пределах contract_version 1, но учтите порядок
развёртывания: коннектор, появившийся раньше gateway-gateway #154, игнорирует
content и на фрейм сброса установит «is typing…» — разворачивайте коннектор
первым.
follow_up (действие с capability, A2). Некоторые входящие полезные
нагрузки несут учётные данные, действующие от имени общей личности бота
(например, токен follow-up интеракции Discord). Согласно §6 коннектор вырезает
их на краю и привязывает в своём хранилище capability с ключом по сессии; до
шлюза они не доходят никогда. Чтобы ими воспользоваться, шлюз выпускает
follow_up, называя сессию, в которой он уже находится (session_key), и
вид capability kind (например, discord.interaction_token) — никогда не
токен. Коннектор находит настоящее значение в своём хранилище, проверяет
совпадение тенанта (тенант B никогда не сможет воспользоваться capability
тенанта A) и отправляет наружу. success: false, если capability
отсутствует/истекла или тенант не совпадает — шлюзу нечего повторять, и это
сделано намеренно (утёкший шлюз не содержит никакого материала capability).
Источник истины: gateway/relay/transport.py (send_follow_up) +
gateway/relay/adapter.py.
5. Маршрутизация прерываний (/stop)
- Шлюз → коннектор:
send_interrupt(session_key, reason?)отправляет/stopпосреди хода по исходящему WS. Коннектор ДОЛЖЕН передать его экземпляру шлюза, который выполняет этотsession_key(инвариант маршрутизации). - Коннектор → шлюз: входящее прерывание для
session_keyдоставляется фреймомinterrupt_inboundв исходящий WS шлюза (заметка о транспорте в §3) — между экземплярами оно маршрутизируется через шину relay к тому экземпляру, который держит сокет, — и мостомon_interrupt(session_key, chat_id)адаптера передаётся в существующий механизм прерывания сессии, отменяя ровно этот ход (соседей не трогая).
Оба направления едут по исходящему WS шлюза: /stop от шлюза к коннектору
уходит по нему, а прерывание от коннектора к шлюзу идёт тем же обратным
каналом inbound как нормализованное событие.
6. Граница доверия и работа с подписанными телами (A2)
Коннектор — единственная граница криптографии и идентичности. Шлюз ничего повторно не проверяет.
Подписи вебхуков (ed25519 в Discord, HMAC в Twilio, BizMsgCrypt в WeCom) вычисляются по точным сырым байтам, а некоторые полезные нагрузки ещё и зашифрованы общим секретом. Коннектор обслуживает общего бота для многих тенантов и держит платформенные секреты каждого из них, поэтому он:
- проверяет / расшифровывает на краю (единственное место, где живут секреты),
- нормализует полезную нагрузку в
MessageEvent, ограниченный тенантом (§3), - вырезает любую capability общей личности из полезной нагрузки и
привязывает её в своём хранилище capability с ключом по сессии (см. §4
follow_up), - передаёт дальше только очищенный
MessageEvent— и никогда сырое подписанное тело.
Шлюз, соответственно, не выполняет никакой проверки платформенных
подписей/криптографии на пути relay; он доверяет нормализованному событию. На
стороне шлюза это принудительно проверяемый инвариант
(tests/gateway/relay/test_relay_sheds_crypto.py: пакет relay не импортирует и
не вызывает платформенную криптографию).
Почему не «передавать подписанное тело байт в байт, чтобы шлюз проверил заново»? Та ранняя модель несостоятельна при недоверенном, одноразовом шлюзе тенанта:
- Повторная проверка HMAC Twilio / криптографии WeCom потребовала бы отдать шлюзу общий секрет подписи — а это и есть утечка, и на общем боте она межтенантная.
- Полезные нагрузки WeCom зашифрованы общим секретом; коннектор обязан расшифровать их на краю хотя бы для маршрутизации, так что передача шифротекста снова потребовала бы отдать шлюзу секрет.
- Токен интеракции Discord живёт внутри подписанного JSON-тела — нельзя одновременно сохранить байты и вырезать учётные данные; это одни и те же байты.
Поэтому от сохранения байтов отказались осознанно: коннектор заново
сериализует очищенное событие, а шлюз ему доверяет. Заодно это унифицирует
passthrough-плоскость и плоскость relay — обе работают по схеме «проверить на
краю → выдать нормализованное событие» и различаются только транспортом.
Полное обоснование A2 и хранилище на стороне коннектора см. в
docs/capability-trust-boundary.md (репозиторий коннектора:
gateway-gateway).
6.1 Аутентификация канала (самой связи коннектор ⇄ шлюз)
A2 делает коннектор единственным держателем платформенных секретов, тогда как
шлюз может быть под управлением клиента и открыт в интернет, поэтому канал
коннектор ⇄ шлюз сам по себе аутентифицируется. Шлюз держит выданный при
регистрации или провижининге секрет шлюза (hermes gateway enroll →
/relay/enroll на коннекторе, либо управляемый самопровижининг →
/relay/provision), которым аутентифицирует апгрейд своего исходящего WS. Это
схема HMAC-SHA256 со списком проверки из нескольких секретов для ротации
(сторона шлюза: gateway/relay/auth.py; сторона коннектора:
src/core/relayAuthToken.ts).
| Плечо | Учётные данные | Механизм |
|---|---|---|
| Апгрейд WS шлюз → коннектор | секрет шлюза | Bearer-заголовок Authorization при апгрейде /relay. Токен — base64url(payload:exp:sig), где payload = gatewayId, а sig = HMAC(payload:exp, secret). Коннектор проверяет его и отклоняет апгрейд (закрытие 4401) при несовпадении/отсутствии/отзыве. Аутентифицированный тенант берётся из хранилища коннектора, а не из фрейма hello. |
Входящие коннектор → шлюз (фреймы inbound / interrupt_inbound) | — (едут по аутентифицированному WS) | Входящие проталкиваются в уже аутентифицированный исходящий сокет шлюза (§3), поэтому подпись каждого сообщения не нужна. Ключ доставки тенанта по-прежнему выдаётся при регистрации/провижининге и сохраняется ради прямой совместимости, но для подписи входящих больше не используется. |
Это аутентификатор канала — отличный от платформенной криптографии,
которую путь relay по-прежнему полностью сбрасывает (§6). Шлюз не хранит ни
одного платформенного секрета; секрет шлюза аутентифицирует только связь с
коннектором. Полная модель угроз и дизайн регистрации/ротации/аварийного
отключения: docs/connector-gateway-auth-design.md (репозиторий коннектора).
7. Доставка по экземплярам и плоскость управления (фаза 6)
Фазы 1–5 рассматривают коннектор как однотенантный фронт: входящие события тенанта рассылаются сокету (сокетам) шлюза этого тенанта. Фаза 6 делает доставку по-ЭКЗЕМПЛЯРНОЙ — общий бот может обслуживать много пользователей/агентов внутри одного тенанта (одна гильдия Discord, один бот Telegram) без перекрёстной доставки — и добавляет небольшую плоскость управления, через которую агент (или управляемый Портал) объявляет, кто что видит и что релевантно. Всё это живёт на стороне коннектора; единственная новая обязанность шлюза — объявить свою политику релевантности при загрузке (§7.3).
7.1 Гейт доставки (на стороне коннектора, для справки)
Для каждого входящего события коннектор решает, какие экземпляры его получат, составляя три фильтра через И. Шлюз их не реализует — они выполняются в коннекторе, — но именно они определяют семантику доставки, на которую шлюз опирается:
| Слой | Вопрос | Источник истины |
|---|---|---|
| владелец / область ∧ принципал | Может ли этот экземпляр видеть этого автора здесь? | привязки user_id → instance по пользователям (порог владельца) + гранты областей (guild, channel) по экземплярам + политика принципалов owner-only / allow-list / any. |
| порог видимости | Может ли привязанный владелец экземпляра реально VIEW_CHANNEL это в Discord? | живой ACL Discord (эффективные права), fail-closed. Сужает слишком широкий грант области вниз. |
| релевантность | При условии, что видеть можно, должен ли агент вступать в разговор? | политика релевантности, объявленная в §7.3 (гейт по адресации / free-response / allow-bots). |
Композиция только сужает доставку (deliver ⇔ authorized ∧ visible ∧ relevant); порог владельца обходит слой релевантности (собственное
сообщение автора всегда доходит до его собственного экземпляра — своего агента
не упоминают через @). Сообщение непривязанного пользователя не доходит ни до
одного экземпляра (fail-closed). Полный дизайн и инварианты живут в
репозитории коннектора (NousResearch/gateway-gateway); этот раздел — сводка
для стороны шлюза.
7.2 Маршруты управления (на стороне коннектора, аутентифицированные)
Коннектор поднимает аутентифицированные маршруты управления. У них та же
двойная аутентификация, что и у апгрейда WS: либо управляемый JWT RS256 с
подписью NAS и aud=agent:{instanceId}, либо bearer собственного секрета
шлюза (§6.1 make_upgrade_token). В обоих случаях коннектор определяет
авторитетные {tenant, instanceId} по своей сохранённой записи — и
никогда по телу запроса (instanceId, заявленный в теле, игнорируется).
| Маршрут | Назначение |
|---|---|
POST /manage/link | Выпустить короткоживущий код для привязки аккаунта на платформе к аутентифицированному экземпляру (поток /link <code>; коннектор читает подлинный user_id из входящего события). |
POST /manage/scope, /manage/scope/release | Занять / освободить область (guild, channel) для аутентифицированного экземпляра. Канал принадлежит не более чем одному экземпляру (непересечение — ограничение первичного ключа). |
POST /manage/principal | Задать политику принципалов экземпляра (owner-only | allow-list | any). |
POST /manage/dm-default | Задать экземпляр по умолчанию для DM пользователя (разрешение неоднозначности в DM, если пользователь привязал больше одного). |
POST /relay/policy | Объявить политику релевантности экземпляра (§7.3). |
Эти маршруты принадлежат коннектору (плоскость управления не входит в путь
агента на шлюзе); шлюз вызывает только POST /relay/policy (§7.3). Остальными
управляют управляемый Портал / CLI hermes.
7.3 Объявление политики релевантности (обязанность шлюза)
Слой релевантности (§7.1) — это потенантный аналог собственных поведенческих
настроек шлюза (require_mention, free_response_channels,
{PLATFORM}_ALLOW_BOTS). Чтобы доставкой через relay управляло то же
поведение, шлюз проецирует эти настройки в платформонезависимую политику и
отправляет её POST-запросом на POST /relay/policy при загрузке (после того
как определён его секрет шлюза).
Тело (gateway/relay/__init__.py: relay_relevance_policy() →
send_relay_policy()):
| Поле | Тип | Проецируется из | Значение |
|---|---|---|---|
platform | string | обслуживаемая платформа (relay_platform_identity) | к какой платформе относится эта политика. |
requireAddress | bool | require_mention | сообщение не от владельца должно упоминать бота через @ или отвечать ему, чтобы считаться релевантным. |
freeResponseScopes | string[] | free_response_channels | идентификаторы областей (каналов), где requireAddress не требуется. Тот же словарь областей, что и у грантов областей в §7.1. |
allowOtherBots | bool | {PLATFORM}_ALLOW_BOTS ∈ {mentions, all} | допускать сообщения от ботов (по умолчанию выключено). |
Аутентификация — токен апгрейда конкретного шлюза (§6.1), поэтому коннектор
привязывает политику к аутентифицированному экземпляру. Шлюз — источник
истины и переобъявляет политику при каждой загрузке (полная замена,
зеркально upsert-у routeKeys при провижининге — самовосстановление). Когда
спроецированная политика целиком совпадает с умолчаниями, шлюз ничего не шлёт
(умолчание коннектора при отсутствующей строке уже совпадает). POST —
мягкий при отказе: сбой логируется, загрузка продолжается — релевантность
лишь оптимизация поверх гейта авторизации (§7.1) и никогда не зависимость
загрузки. Новой входящей поверхности у шлюза нет и новых учётных данных
нет — используется секрет шлюза и тот же хост, что у /relay/provision.
Отсев по релевантности происходит до того, как коннектор разбудит смасштабированного до нуля агента (фаза 5), поэтому исключённая переписка никогда не поднимает агента — релевантность не только фильтр правильности, но и главный рычаг масштабирования до нуля.
8. Управление поведением платформы на стороне шлюза (корпоративные установки)
Корпоративные развёртывания настраивают поведение обслуживаемой платформы на
стороне ШЛЮЗА, в platforms.relay.extra.<platform> — поддерживаемое
подмножество родных опций этой платформы. Родной блок платформы (например,
platforms.slack) на пути relay не читается; коннектор получает результат
этих настроек в метаданных фреймов (§4) и выполняет его механически — своей
политики поведения платформы у него нет.
platforms:
relay:
extra:
slack:
reply_in_thread: true # по умолчанию
Порядок разрешения: вложенный объект extra.<platform> побеждает → устаревший
плоский ключ в extra учитывается как запасной вариант → умолчание. Источник
истины: RelayAdapter._effective_reply_in_thread (gateway/relay/adapter.py).
Значения приводятся ровно так же, как в родном адаптере Slack: 1/true/yes/on
(без учёта регистра, с обрезкой пробелов) — ВКЛ, всё остальное — ВЫКЛ, так что
взятое в кавычки в YAML "false" выключает настройку, а не читается как
истинная строка.
Текущие настройки (Slack):
| Ключ | По умолчанию | Эффект |
|---|---|---|
reply_in_thread | true | true: тред на сообщение — каждое верхнеуровневое сообщение в DM привязывает собственный тред (статус, прогресс, промпты и финальный ответ несут этот metadata.thread_id). false: плоский непрерывный DM — фреймы канала отправки НЕ несут якоря треда (он вырезается, а не просто опускается), одна общая сессия на DM. |
dm_top_level_threads_as_sessions | true | Аварийный выход для паритета с родным адаптером (зеркало platforms.slack.extra.dm_top_level_threads_as_sessions). true: в режиме «тред на сообщение» каждое верхнеуровневое сообщение в DM задаёт собственную сессию, так что параллельные сообщения обрабатываются параллельно. false: размещение ответов в тредах сохраняется, но штамп сессии пропускается — одна непрерывная сессия DM (устаревшая позиция steer/queue). В плоском режиме не действует: там всегда одна непрерывная сессия. |
Фреймы набора/статуса всегда несут якорь ts сообщения-триггера, если он известен (живость безусловна в обоих режимах): строка статуса в Slack привязана к треду, а в плоском режиме вырезание якоря на стороне отправки гарантирует, что якорь статуса никогда не просочится в размещение ответов. Семантика родного ключа: см. Slack.
Разрешение якоря треда применяется к КАЖДОМУ каналу отправки — и к тексту
(send), и к медиа (send_media) — через одну точку
(RelayAdapter._apply_slack_thread_anchor). Медиафреймы уходят через тот же
отправитель Slack на стороне коннектора, который тредит только по
metadata.thread_id, поэтому вложение находит свой якорь ровно так же, как
текстовый ответ: продвигается в метаданные в режиме «тред на сообщение»,
вырезается в плоском режиме.
Изменения вступают в силу при перезапуске шлюза; участие коннектора не требуется.
9. Политика версионирования
contract_version— целое число; повышать его только для аддитивных изменений на экспериментальной стадии (новые необязательные поля, новые операцииop).- Ломающее изменение (переименованное/удалённое поле, изменённая семантика) требует согласованного обновления обоих репозиториев и повышения версии.
- Первый PR коннектора ссылается на SHA коммита этого файла, под который он реализован.