Чат-мосты
Чат-мост превращает внешний чат-продукт — сегодня это Slack — в полноценный путь в рабочее пространство Control Center. Упомяните бота — и проснётся агент; ответьте в треде Slack — и разговор продолжится; введите slash-команду — и будет создан тикет. А под этим — те же пространства, транскрипты, тикеты и атрибуция, которыми пользуется приложение для компьютера. Чат — это поверхность рабочего пространства, а не его копия.
Slack — единственный провайдер, который поставляется. Реализаций Discord, Telegram, Teams или Matrix в продукте нет нигде. Эта функция намеренно написана без привязки к провайдеру — добавление второго продукта это одно значение enum, один адаптер и один плагин, — но сегодня «чат» означает Slack.
Эту возможность охватывают две поверхности, и они принадлежат разным людям. Подключение рабочего пространства к Slack и настройка бота — действия администратора в разделе Настройки → Рабочее пространство → Основные. Привязка собственного аккаунта в чате к вашей личности в Control Center — действие участника в разделе Настройки → Рабочее пространство → Профиль и идентичность.
Всю эту функцию определяют три решения: в какую сторону устанавливается соединение, что даёт право сообщению, пришедшему извне, и где допустимо хранить знания, специфичные для продукта.
Исходящее соединение по замыслу
Заголовок раздела «Исходящее соединение по замыслу»Мост никогда не слушает провайдера. Slack доступен через Socket Mode: сервер запрашивает у Slack URL сокета и сам устанавливает исходящее соединение, а затем получает по нему упоминания, личные сообщения и slash-команды.
Следствие здесь и есть суть. Ноутбук за NAT, домашний сервер без домена, машина, переезжающая между сетями, — все они могут работать с мостом без входящего порта, без туннеля, без сертификата TLS и без публичного URL, который пришлось бы менять при его изменении. Здесь нет эндпоинта webhook, который надо защищать, потому что эндпоинта webhook нет вообще. Когда Control Center собирает определение приложения, он жёстко задаёт Socket Mode и вырезает любой request URL, потому что приложение, настроенное на оба варианта, работало бы наполовину.
Плата за это — соединение, которое надо поддерживать живым, и это задача сервера: переподключение идёт экспоненциальной задержкой с джиттером и потолком в 30 секунд, ротация сокета по инициативе провайдера переподключается, не наращивая эту задержку, а отклонённые учётные данные — это окончательный отказ: бесконечные попытки с отозванным токеном принесли бы только ограничение частоты, поэтому мост останавливается и ждёт новых токенов, показывая в настройках собственную ошибку провайдера дословно.
Рабочее пространство без чат-приложения не стоит вообще ничего: ни сокета, ни таймеров, ни трафика. И всё это запускается после баннера готовности сервера, потому что чат-приложение с медленным ответом ни в коем случае не должно задерживать загрузку.
Право даёт членство, а не владение токеном
Заголовок раздела «Право даёт членство, а не владение токеном»Тот, кто установил приложение, — не тот, кто отправляет сообщение. Поэтому каждое входящее событие до начала любых действий сводится к Principal, и при любых сомнениях запрос отклоняется с объяснением:
- Непривязанному аккаунту в чате сообщают, как привязаться, — в том треде Slack, где он написал.
- Привязанному аккаунту, пользователь которого не является участником этого рабочего пространства, предлагают попросить у администратора приглашение.
- Участнику, чья роль доступна только для чтения (
viewer,guest), сообщают, что его роль не позволяет запускать работу.
Ни один из этих случаев не продолжается. Сообщение в чате никогда не приписывается тому, кто его не отправлял, — именно это делает честными журнал аудита, ограничения частоты для каждого Principal и авторов тикетов. Модель членства, на которую всё это опирается, описана в разделе Совместная работа — идентичность, членство и присутствие.
Привязка — это сопоставление личности, а не предоставление доступа. Членство проверяется заново на каждом событии, поэтому привязка того, кого с тех пор удалили, приводит к отказу, а не к его прежним правам. Привязку можно установить двумя путями: короткоживущий одноразовый код, который участник вводит в чате, или автоматическое сопоставление по подтверждённому адресу электронной почты у провайдера — и путь через почту по-прежнему требует, чтобы сопоставленный пользователь уже был участником, поэтому посторонний, чей адрес случайно есть на сервере, не привязывается ни к чему.
Коды намеренно хранятся в памяти со сроком жизни 15 минут. Сохранение такого кода создало бы долговечную запись, похожую на учётные данные, для того, что должно испариться, а перезапуск сервера, обесценивающий все неиспользованные коды, — это правильное поведение, а не регрессия: участник просто нажимает «привязать» ещё раз.
На отказ отвечают в том треде Slack, который открыло сообщение, — настоящим ответом. Ответ в треде помечает сообщение, на которое отвечает, доходит до единственного человека, которому это нужно, и при этом не выносит «вы не привязаны» в сам канал. К тому же отправка сообщения — единственная возможность, которая есть у любого провайдера, поэтому объяснение никогда не зависит от функции, которой у приложения может не быть.
Происхождение и проблема эха
Заголовок раздела «Происхождение и проблема эха»Мост, зеркалящий в обе стороны, зациклится, если ничто не отличает эти два
направления друг от друга. Каждое сообщение, которое мост принимает,
помечается metadata['chat'] — провайдер, команда, разговор, тред, сообщение
и отправитель. Исходящее зеркало пропускает любое сообщение с этой пометкой.
Именно это единственное правило делает двустороннее зеркалирование безопасным: сообщение человека, набранное в Control Center, передаётся наружу в привязанный тред (с именем автора в начале), а сообщение, пришедшее из чата, останавливается там, где вошло.
Наружу проходит намеренно узкий набор. Ответ агента передаётся как обычный
текст чата. Короткая мысль может появиться в строке Thinking… карточки. Вывод
инструментов — это стена диффов, и он остаётся в Control Center, куда же
указывает и усечённый ответ.
Карточка сообщает о работе; текст остаётся сообщением
Заголовок раздела «Карточка сообщает о работе; текст остаётся сообщением»Там, где провайдер их отображает, ход несёт одну карточку задачи, которая редактируется на месте, пока ход выполняется, и стоит над ответом, который поясняет.
Карточка — это один сгруппированный план, а не слипшийся абзац. Slack
объединяет details и output в одной задаче, поэтому details строки
отправляются один раз (мысль в строке Thinking…), а output никогда не
передаётся потоком. Подготовка — это одна строка, заголовок которой заменяется
(Working on it…, Cloning …). Рассуждение добавляет строку Thinking….
Каждый инструмент — своя строка (Read lib/main.dart). Ответ — обычный текст
под карточкой: представление плана в Slack читает его из более позднего
добавления markdown, а не из фрагмента, подмешанного в задачи. И ничего
больше: никакого вывода инструментов и только аргумент инструмента из
короткого списка разрешённых, потому что аргументы неизвестного инструмента
могут быть целым телом файла, а строка — это одна линия.
Карточка покрывает и ожидание перед ходом. Первое упоминание создаёт
разговор, а его создание клонирует все репозитории, в которых будет работать
агент, — и на реальном репозитории это минуты, когда у агента ещё ничего не
спросили, а тред выглядит так, будто бот проигнорировал запрос. Поэтому
карточка открывается в момент принятия упоминания (Working on it…), затем
строка подготовки обновляется теми же словами, что показывает приложение для
компьютера, — Cloning acme/widgets…, Setting up Reviewer…,
Starting the agent… — потом появляется строка Thinking…, как только агент
начинает рассуждать, а затем по строке на каждый инструмент. Одна карточка, одно
сообщение — от «я вас услышал» до ответа обычным текстом под ним. О подготовке
сообщают только там, где её кто-то ждёт в чате: разговор, который готовят в
приложении, или брошенный разговор, возобновлённый при запуске сервера,
разворачивается вообще без карточки.
Она несёт единственное, чего чат не может дать читателю: ссылку View in
Control Center на разговор за ответом. Чат-продукты принимают в ссылке
http(s) и ничего больше, поэтому кнопка указывает на небольшую страницу,
которую отдаёт этот сервер и которая перенаправляет браузер по ссылке
control-center:// приложения для компьютера. Эта страница ничего не читает и
ничего не разрешает — идентификаторы из её URL передаются в глубокую ссылку и
больше никуда, — а сервер без доступного публичного URL просто выдаёт карточку
без кнопки, а не сломанную.
Карточки — такая же возможность, как и любая другая. Провайдер без них ведёт
себя точно как раньше, только текстом, а провайдер, который отказывает от
карточки во время вызова, сохраняет свой поток: ответ всегда важнее описывающей
его карточки. Когда потоковая передача выключена полностью, готовая карточка
едет вместе с единственным отправленным сообщением. Созданный тикет
обрабатывается так же: подтверждение /cc ticket — это завершённая карточка,
единственный смысл которой — ссылка на только что созданный тикет.
Возможности согласуются, а затем запоминаются
Заголовок раздела «Возможности согласуются, а затем запоминаются»Чат-продукты различаются тем, что умеют отображать, поэтому мост написан под самую богатую поверхность — ответ, который растёт в реальном времени внутри треда, временный статус «is thinking…», тред с заголовком, эфемерный ответ на slash-команду — и отказывается от возможностей по одной. Провайдер, у которого нет ничего этого, всё равно работает: ответы просто приходят одним сообщением.
Заявленная возможность — это ещё не обещание. Потоковые ответы Slack требуют функции платного тарифа, и Slack отказывает только во время вызова. Отказ, помеченный как постоянный, запоминается на весь срок жизни соединения: мост перестаёт спрашивать и с этого момента отправляет ответы целиком, а настройки прямо об этом сообщают, потому что на «мои ответы перестали передаваться потоком» можно ответить и не читая журнал. Карточки задач отбрасываются так же, если Slack отказывает от одной из них.
Каждый предел принадлежит провайдеру, а не ретранслятору: потолок длины сообщения, потолок фрагмента потока и то, нужен ли потоку якорь треда, объявляет адаптер, а длинный ответ разбивается по границе абзаца, строки или слова, а не посреди слова.
Один мост, один адаптер на продукт
Заголовок раздела «Один мост, один адаптер на продукт»Всё, что связано с конкретным продуктом, живёт за портом. Общее ядро знает про
markdown, разговоры в чате, треды чата и участников; оно ничего не знает про
конверты Slack, mrkdwn или gateway intents. Работу обеспечивают три части:
- Адаптер нормализует один продукт: его транспорт, его вызовы API, его
текстовый кодек и его особенности конвертов. Особенности Slack поучительны:
упоминание в канале приходит дважды (как
app_mentionи какmessage), а большинство подтипов сообщений — это оформление, а не новые запросы. И то и другое разрешается в адаптере, поэтому ядро никогда не узнаёт, что Slack доставляет дважды. - Дескриптор — это публичная форма провайдера: какие учётные данные ему нужны, каковы их форматы, какие URL помогают пользователю их найти, что он умеет и можно ли создать или изменить его приложение отсюда. Карточка настроек, диалог подключения и проверка аргументов — всё это генерируется из него: клиент отправляет обратно провайдера и карту с ключами по идентификаторам полей.
- Плагин — это серверная композиция: как учётные данные становятся проверенным соединением, как собирается адаптер и как управляется приложение на стороне провайдера.
Итак, добавление продукта — это одно значение enum, один адаптер и один плагин. Ни новой операции RPC, ни изменения схемы, ни изменения клиента — и клиент остаётся честным, потому что поле, о котором он никогда не слышал, всё равно отображается с меткой, которую предоставил сервер.
Две детали не дают абстракции протечь наверх. Шаги управления приложением, для которых у провайдера нет API (создать токен, установить приложение, переустановить после изменения разрешений), возвращаются как именованные шаги с собственными ссылками, поэтому экран завершения настройки показывает их, не зная, о каком провайдере речь. А каждая инструкция, которую пишет мост, называет провайдера и ту команду, которую приложение действительно использует, поэтому переименованная команда или второй провайдер читаются правильно без новой строки.
Где хранятся учётные данные
Заголовок раздела «Где хранятся учётные данные»Токены чата рабочего пространства лежат в его собственном каталоге, рядом с
его базой данных — как обычный JSON по пути
<dataDir>/<workspaceId>/chat_credentials/<provider>.json, не в базе данных и
намеренно не в связке ключей ОС. Два следствия легко упустить позже: удаление
рабочего пространства отвязывает каталог, и токены уходят вместе с ним, а
экспорт или резервная копия рабочего пространства копирует workspace.db,
поэтому рабочее пространство, переданное кому-то другому, не несёт внутри себя
живых токенов бота. Файл наследует границу доверия файловой системы
хост-сервера: всё, что имеет доступ на чтение к каталогу данных, может
прочитать токен бота, а это та же граница, за которой находятся базы данных.
Один файл на провайдера, записываемый атомарно и доступный только владельцу: отозванное приложение удаляется отвязыванием одного файла, а испорченный файл стоит одного провайдера, а не всех. Учётные данные приходят внутрь через операцию подключения и никогда не возвращаются обратно: чтение статуса отвечает только метаданными соединения.
Что намеренно не предлагается
Заголовок раздела «Что намеренно не предлагается»- Структурные настройки приложения. Транспорт, области доступа и подписки на события задаются принудительно, а не выставляются наружу. Приложение, которое можно настроить до полного молчания с экрана настроек, — это обращение в поддержку, а не функция.
- Разговор и slash-команды. Поверхность Slack — это сообщения, ответы в тредах и slash-команды.
- Второй источник истины. Чат — не место, где хранится работа. Тред Slack — это пространство, ответ — транскрипт, тикет — тикет, и всё это в рабочем пространстве, доступное из любого клиента.
См. также
Заголовок раздела «См. также»- Совместная работа — идентичность, членство и присутствие: Principal и роли, через которые проходит разрешение в мосте
- Рабочие пространства и изоляция: почему привязки и учётные данные относятся к рабочему пространству
- Модель агента: что просыпается, когда приходит упоминание
- Тикеты и делегирование: что создаёт
/cc ticket - Архитектура: где мост находится между сервером и клиентами
- Подключите Slack к рабочему пространству
- Настроить интеграцию Slack
- Привязать аккаунт Slack
- Настроить чат-бота
- Разговоры в PR: те же три решения, принятые для хоста кода