Перейти к содержимому

Чат-мост

Провайдер Значение в протоколе Транспорт Вид пространства
Slack slack Socket Mode (исходящий WSS) slack

Slack — единственный провайдер. ChatProvider — это enum с единственным значением, и cc_server регистрирует один плагин. Схема, каждое обращение и каждый уникальный индекс привязаны к provider, поэтому идентификаторы никогда не переходят между провайдерами, а добавление нового — изменение только на сервере.

Одно приложение на стороне провайдера на каждую пару рабочее пространство × провайдер.

Имя команды задаётся для каждого приложения (cc по умолчанию, то есть /cc). Администратор может его переименовать; тогда мост использует то имя, с которым команду вызвали в последний раз.

Команда Действие
/cc help (или /cc без глагола) Перечисляет, что умеет бот
/cc link <code> Погашает одноразовый код и привязывает идентичность отправителя в чате
/cc ticket <title> Создаёт локальный тикет, о котором сообщил привязанный участник
/cc ticket <title> | <description> То же, но всё после первого | становится описанием
Неизвестный глагол Сообщает неизвестный глагол вместе с текстом справки

Способы обратиться к агенту:

Жест Поведение
@bot … в канале Открывает тред Slack и соответствующее пространство Control Center; агент отвечает в треде Slack
Ответ в привязанном треде Slack Продолжает то же пространство Control Center — упоминание не нужно
Обычное сообщение в канале Игнорируется (не адресовано боту и не находится в привязанном треде Slack)
Личное сообщение боту Одно пространство Control Center на каждый разговор в личных сообщениях; задаёт заголовок треда Slack и статус «is thinking…», пока агент работает

Объявляются дескриптором провайдера — именно он генерирует диалог подключения и проверяет отправленные данные.

Идентификатор поля Метка Обязательно Префикс Где находится
botToken Bot user OAuth token Да xoxb- OAuth & Permissions
appToken App-level token Да xapp- Basic Information → App-Level Tokens, область connections:write
configRefreshToken App configuration refresh token Нет xoxe App configuration tokens on api.slack.com/apps

configRefreshToken — это учётные данные для управления приложением: без них соединение работает, но бота нельзя создать или изменить из Control Center. Они меняются при каждом использовании, и новое значение сохраняется сразу же.

Значения обрезаются по краям, необъявленные ключи отбрасываются, а несовпадение префикса отклоняется с одинаковым сообщением и на клиенте, и на сервере. Обязательные поля должны присутствовать при каждом подключении; необязательное поле, оставленное пустым, сохраняет ранее сохранённое значение.

Области доступа токена бота:

app_mentions:read
assistant:write
channels:history
chat:write
commands
groups:history
im:history
users:read
users:read.email

Подписки бота на события:

app_home_opened
app_mention
message.channels
message.groups
message.im

Структурные настройки, которые Control Center задаёт принудительно, когда создаёт или изменяет приложение: включённый Socket Mode, отсутствие request URL, включённая и доступная для записи вкладка Messages, always_online у бота, требуемые области доступа и события, объединённые с тем, что уже есть у приложения (никогда с вычитанием), и одна slash-команда с описанием File a ticket or link your Control Center account — маркер, по которому в экспортированном манифесте опознаётся собственная команда Control Center.

Состояние Значение в протоколе Что означает
Не подключено disconnected Для этой пары рабочее пространство × провайдер учётные данные не сохранены
Подключение… connecting Учётные данные сохранены, транспорт устанавливает или переустанавливает соединение
Онлайн connected Транспорт поднят и принимает события
Ошибка подключения error Данные сохранены, но соединение не работает; рядом показана собственная ошибка провайдера

Переподключение — это экспоненциальная задержка с джиттером и потолком в 30 секунд. Ротация сокета по инициативе провайдера переподключается сразу, не наращивая задержку. Отклонённые учётные данные (invalid_auth, not_authed, token_revoked, account_inactive, token_expired, invalid_app_id) или отсутствующая область доступа — это окончательный отказ: мост перестаёт повторять попытки, пока не появятся новые учётные данные.

Мост написан под самую богатую поверхность и отказывается от возможностей по одной.

Возможность Slack Что происходит при её отсутствии
streaming да Весь ответ отправляется один раз, в конце
streamingRequiresThread да Цель ответа без якоря треда отправляет ответы целиком
ephemeralMessages да Ответ на slash-команду без пригодного ответного дескриптора отбрасывается (отказы всегда публикуются в треде)
threadStatus да Нет временной строки «is thinking…»
threadTitle да Тред чата сохраняет заголовок по умолчанию
slashCommands да Нет поверхности /cc
taskCards да Только текст — ни карточки над ответом, ни ссылки «View in Control Center»
maxMessageLength 39 000 Более длинные ответы усекаются с указанием на Control Center
maxStreamChunkLength 3 800 Добавления разбиваются по границе абзаца, строки или пробела

Заявленная потоковая передача — это ещё не обещанная: у Slack она требует функции Agents & AI Apps на платном тарифе. Отказ во время вызова (streaming_not_supported, feature_not_enabled, paid_only, …) запоминается на весь срок жизни соединения и отражается как streamingAvailable: false. Карточки задач Slack используют тот же вызов, поэтому Slack, который отказывает в режиме отображения карточки, сохраняет свой поток и теряет только карточку — один раз, на весь срок жизни соединения.

Поток также должен указать, для какого читателя он предназначен. Чтобы открыть поток в канале, Slack требует и пользователя-получателя, и его команду (в личных сообщениях — нет) и иначе отказывает с missing_recipient_user_id / missing_recipient_team_id — это отказ на каждый вызов, поэтому мост продолжает пытаться, а ход тем временем приходит одним отправленным ответом. Получатель — это тот, кто спросил, взятый из входящего сообщения (его команда, а не команда приложения, поэтому канал Slack Connect разрешается правильно). Для хода, который никто не начинал из чата — работу запустили в Control Center в уже привязанном разговоре, — это участник, чья привязка создала мост; если он с тех пор отвязал свой аккаунт в чате, такой ход отправляется целиком, а не потоком.

Одна сгруппированная карточка на каждый ход агента (task_display_mode: plan) со строкой на каждый шаг. Каждая строка — это task_update из id + title + status, плюс одноразовые details, когда мысль готова. details и output с одним и тем же id объединяются без разделителя, поэтому details отправляются один раз, а output никогда не передаётся потоком. Заголовок плана — это запрос; ответ — отдельное, более позднее добавление с фрагментом markdown_text.

Часть Содержимое
Заголовок плана То, что было запрошено
Строка подготовки Working on it…, затем Cloning …, затем Starting the agent… — одна строка, заголовок заменяется
Строка размышления Thinking…, добавляется, когда агент начинает рассуждать; мысль — это details этой строки, отправленные один раз по завершении рассуждения
Строки инструментов По одной на инструмент (Bash which swift), уникальный id, поэтому они стоят рядом друг с другом
Статус В работе, пока эта строка выполняется; завершён, когда начинается следующая строка или ход заканчивается; ошибка для неудачных, прерванных и упёршихся в лимит ходов
Призыв к действию View in Control Center как замыкающая строка по завершении хода — отсутствует, пока работа ещё идёт, и отсутствует, когда у сервера нет доступного публичного URL

Чего карточка не несёт никогда: вывода инструментов, аргумента вне этого списка разрешённых или чего-либо прочитанного из базы данных. Текст рассуждений — это только подробности строки Thinking… (не длиннее 256 символов), а не тело сообщения. Карточка, которая не сказала бы ничего нового, не отправляется повторно. Упоминание открывает поток сразу, чтобы тред Slack не молчал; ход, который затем не произвёл вообще никакого текста, завершает ту же карточку короткой заметкой, а не вторым сообщением. Ход без слов в пространстве, которое так и не открыло поток (карточек нет или в потоковой передаче уже отказано), всё равно отправляет одно сообщение с завершённой карточкой и заметкой.

Карточки в потоке и в отправленном сообщении — это разные полезные нагрузки:

Форма Структура Текст
В потоке plan_update плюс фрагменты task_update с ключом id Обычная строка, не длиннее 256 символов на фрагмент
В сообщении Блок plan, оборачивающий task_card с ключом task_id Форматированный текст

Обработка сбоев:

Ситуация Поведение
Провайдер отказывает в карточке при добавлении Добавление немедленно повторяется без неё; текст по-прежнему приходит вживую, а карточки отбрасываются на весь срок жизни соединения
Провайдер отказывает и в тексте Поток закрывается, и ответ отправляется целиком
Поток обрывается после того, как часть ответа уже дошла Поток закрывается, и ничего не отправляется повторно

Почему полезная нагрузка разделена именно так, объясняет раздел Чат-мосты.

Карточка открывается, когда упоминание принято, — до клонирования и до первого токена; следующий за этим ход перенимает ту же карточку и тот же поток. Дальнейшее повествование приходит из SpaceProvisioningChanged в шине доменных событий — тот же прогресс, который баннер подготовки в приложении для компьютера читает из строки пространства.

Состояние подготовки Повествование
Упоминание принято Working on it…
Шаг repo Cloning <repo>…
Шаг prCheckout Checking out the pull request in <repo>…
Шаг script Running the setup script for <repo>…
Шаг agent Setting up <agent>…
provisioning, шага пока нет Preparing the workspace…
ready Starting the agent…
Агент рассуждает Thinking…
failed Workspace setup failed — details are in Control Center.
cancelled Workspace setup was stopped — details are in Control Center.

Повествование о подготовке требует выполнения всех четырёх условий:

Условие
Провайдер отображает карточки
На этом соединении доступна потоковая передача
В пространстве есть чат-запрос в процессе выполнения
Ни одна карточка там уже не сообщает о ходе

Запрос остаётся в процессе выполнения, пока не начнётся ход или не сорвётся подготовка. Событие ready, когда карточка ещё не открыта, — это тёплое пространство: его оставляют в покое, а подготовка, которой никто в чате не ждёт — разговор, открытый в приложении, или брошенный, возобновлённый при запуске сервера, — не создаёт карточки вообще.

Чат-продукты принимают в ссылке http(s) и ничего больше, поэтому призыв к действию не может напрямую назвать схему приложения для компьютера.

Свойство Значение
Маршрут GET /open/workspaces/<workspaceId>/(spaces|tickets)/<id>
Источник Выводится из публичного URL сервера (ws → http, wss → https); loopback сохраняется
Ответ Небольшая HTML-страница с no-store, которая переходит на control-center://workspaces/<workspaceId>/(spaces|tickets)/<id> и показывает ручную кнопку
Проверка Идентификаторы должны соответствовать [A-Za-z0-9_.-]{1,128}; всё остальное — 404
Доступ Публичный и инертный — базу данных он не читает, поэтому оракулом существования не является; авторизацию выполняет приложение, которое он открывает
Поле Предел Примечания
appName 1–35 символов По умолчанию Control Center · <workspace name>
botDisplayName 1–80 символов По умолчанию control-center
description ≤ 140 символов
agentDescription ≤ 300 символов Показывается над личным сообщением боту
command ≤ 32 символов Нормализуется: ведущие слэши убираются, регистр понижается, [^a-z0-9_-] удаляется, пустое значение заменяется на cc
agentEnabled — Открыт ли опыт личных сообщений с агентом

Пустое поле означает «оставить как есть», а не «очистить». Пределы принадлежат Slack, а они самые жёсткие среди смоделированных продуктов.

Свойство Значение
Длина 6 символов
Алфавит 23456789ABCDEFGHJKLMNPQRSTVWXYZ (без 0/O, 1/I, U)
Срок жизни 15 минут
Использований Одно
Хранение В памяти — перезапуск сервера делает выданные коды недействительными
Область Одно рабочее пространство и один провайдер; создание нового кода делает предыдущий код участника недействительным

При погашении регистр не учитывается. Неизвестные, просроченные коды, коды чужого рабочего пространства и коды не того провайдера намеренно завершаются одинаково неразличимо.

Способ Значение в протоколе Как
Код code Участник погасил /cc link <code>
Email email Подтверждённый адрес электронной почты у провайдера совпал с пользователем, который уже является участником рабочего пространства

Все они привязаны к рабочему пространству. Сервер не хранит состояния, поэтому каждый вызов несёт собственный workspace_id в аргументах; диспетчер отклоняет операцию с областью рабочего пространства, в которой он опущен, ещё до открытия любой базы данных. Учётные данные передаются только внутрь — чтение статуса никогда их не возвращает.

Операция Вид Минимальная роль Классы действий
chat.providers чтение guest —
chat.status чтение guest —
chat.connect изменение admin secretAccess, networkEgress
chat.disconnect разрушительная admin secretAccess
chat.listUserLinks чтение guest —
chat.beginUserLink изменение member —
chat.unlinkUser изменение member для себя, admin для любого другого —
chat.createApp изменение admin secretAccess, networkEgress
chat.setupLink чтение admin —
chat.botProfile чтение admin networkEgress
chat.updateBotProfile изменение admin networkEgress

Операции чтения наследуют нижнюю границу guest от своего вида; операции изменения без явной границы требуют member.

chat.setupLink собирает URL создания приложения у провайдера из профиля бота. Он не объявляет ни одного класса действий, потому что не читает секретов и никуда не обращается — единственный вызов делает собственный браузер пользователя.

Поверхность завершает один реактивный запрос:

Запрос Вид Отправляет
chat.watchUserLinks слежение Список, переотправляемый при каждой привязке или отвязке

Он существует потому, что привязка происходит на другой стороне: участник вводит одноразовый код в чат с ботом, поэтому никакой ответ клиента не может принести результат обратно. И список в настройках, и открытый диалог привязки аккаунта следят за ним, и диалог переключается на подтверждение в тот самый момент, когда приходит привязка самого участника.

chat.beginUserLink всегда создаёт код для собственной идентичности вызывающего. chat.updateBotProfile возвращает remaining_step, только когда остался шаг (обычно переустановка); его отсутствие означает, что изменение уже действует.

chat.providers возвращает по одной записи на каждого предлагаемого провайдера — дескриптор плюс, если он есть, статус соединения. Всё, что отрисовывает интерфейс настроек, берётся из этой полезной нагрузки, и поэтому добавление провайдера — изменение только на сервере.

Что Где
Учётные данные соединения <dataDir>/<workspaceId>/chat_credentials/<provider>.json
Незавершённая пошаговая настройка <dataDir>/<workspaceId>/chat_credentials/<provider>_setup.json
Устаревшие файлы Slack <dataDir>/<workspaceId>/slack_credentials.json и slack_app_setup.json, читаются один раз и переносятся на месте

Учётные данные лежат рядом с базой данных рабочего пространства, а не внутри неё: удаление рабочего пространства уносит с собой его токены чата, а workspace.export или резервная копия копирует workspace.db — поэтому рабочее пространство, переданное кому-то другому, не несёт внутри себя живых токенов бота. Записи атомарны (временный файл плюс переименование) и доступны только владельцу.

Обе живут в базе данных рабочего пространства, где базовый набор миграций сведён к v1, — поэтому чтение из чужого рабочего пространства не компилируется.

chat_space_links — один внешний разговор ↔ одно пространство Control Center:

Столбец Примечания
id UUID v4
workspace_id Область рабочего пространства
provider ChatProvider.wire
external_team_id Команда Slack T…, пусто для провайдеров без такого понятия
external_channel_id Канал Slack C…, группа G…, личное сообщение D…
external_thread_id Якорь треда Slack или null, когда якорем служит весь разговор (личные сообщения боту)
cc_space_id REFERENCES spaces(id) ON DELETE CASCADE
created_by_user_id, created_at, last_activity_at

Уникальность по (workspace_id, provider, external_channel_id, external_thread_id), а частичный индекс обеспечивает то же самое для идентификаторов тредов, равных null (SQLite считает NULL различными).

chat_user_links — один внешний участник ↔ один пользователь Control Center:

Столбец Примечания
id UUID v4
workspace_id Область рабочего пространства
provider ChatProvider.wire
external_team_id, external_user_id Участник Slack U…
user_id Пользователь Control Center (глобальный, поэтому без внешнего ключа)
method code или email
linked_at

Уникальность по (workspace_id, provider, external_team_id, external_user_id) и по (workspace_id, provider, user_id) — однозначная атрибуция в обе стороны, при этом один человек может быть привязан у нескольких провайдеров.

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

Привязанное пространство создаётся с SpaceKind провайдера (slack). Оно ведёт себя как обычное пространство для людей — уведомления и разделы как у topic — а isExternalChat — это проверка, через которую проходит каждое поведение (зеркалирование, значки, хранение).

Каждое сообщение, которое приносит мост, несёт пометку metadata['chat']:

{
"chat": {
"provider": "slack",
"teamId": "T024BE7LD",
"channelId": "C0G9QF9GW",
"threadId": "1700000000.000100",
"messageId": "1700000000.000200",
"userId": "U024BE7LH",
"userLabel": "sam"
}
}

Эта же пометка служит защитой от эха: исходящее зеркало пропускает любое сообщение, которое её несёт, и именно это не даёт входящему сообщению из чата отправиться прямо обратно.

Поведение Подробности
Что ретранслируется наружу Только текст агента плюс строка состояния карточки задачи. Текст рассуждений и вывод инструментов остаются в Control Center
Ритм потоковой передачи Ограничена одним сбросом в секунду, пока идёт ход; карточка и текст, который она поясняет, идут в одном вызове
Сообщения людей, набранные в Control Center Зеркалятся в привязанный тред с именем автора в начале
Пустые ходы Сообщаются короткой заметкой (запуск сорвался, был прерван или остановился на своём лимите хода), а не молчанием
Дедупликация По собственному идентификатору события у провайдера; помнятся последние 512
Агент на входе Новый привязанный разговор будит CEO-агента рабочего пространства, иначе его первого агента, иначе никого
Именование пространств #channel · <request> для треда в канале Slack, Slack DM · <request> для личного сообщения, заголовки не длиннее 48 символов
Изоляция сбоев Одно плохое событие никогда не роняет мост; соединение продолжает обслуживать все остальные разговоры