Чат-мост
Провайдеры
Заголовок раздела «Провайдеры»| Провайдер | Значение в протоколе | Транспорт | Вид пространства |
|---|---|---|---|
| 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…», пока агент работает |
Поля учётных данных (Slack)
Заголовок раздела «Поля учётных данных (Slack)»Объявляются дескриптором провайдера — именно он генерирует диалог подключения и проверяет отправленные данные.
| Идентификатор поля | Метка | Обязательно | Префикс | Где находится |
|---|---|---|---|---|
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. Они
меняются при каждом использовании, и новое значение сохраняется сразу же.
Значения обрезаются по краям, необъявленные ключи отбрасываются, а несовпадение префикса отклоняется с одинаковым сообщением и на клиенте, и на сервере. Обязательные поля должны присутствовать при каждом подключении; необязательное поле, оставленное пустым, сохраняет ранее сохранённое значение.
Требования к приложению Slack
Заголовок раздела «Требования к приложению Slack»Области доступа токена бота:
app_mentions:readassistant:writechannels:historychat:writecommandsgroups:historyim:historyusers:readusers:read.emailПодписки бота на события:
app_home_openedapp_mentionmessage.channelsmessage.groupsmessage.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)
Заголовок раздела «Возможности провайдера (Slack)»Мост написан под самую богатую поверхность и отказывается от возможностей по одной.
| Возможность | 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, когда карточка ещё не открыта, — это тёплое
пространство: его оставляют в покое, а подготовка, которой никто в чате не ждёт
— разговор, открытый в приложении, или брошенный, возобновлённый при запуске
сервера, — не создаёт карточки вообще.
Страница перенаправления /open
Заголовок раздела «Страница перенаправления /open»Чат-продукты принимают в ссылке 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 |
Подтверждённый адрес электронной почты у провайдера совпал с пользователем, который уже является участником рабочего пространства |
Операции RPC
Заголовок раздела «Операции RPC»Все они привязаны к рабочему пространству. Сервер не хранит состояния, поэтому
каждый вызов несёт собственный 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 символов |
| Изоляция сбоев | Одно плохое событие никогда не роняет мост; соединение продолжает обслуживать все остальные разговоры |
См. также
Заголовок раздела «См. также»- Настроить интеграцию Slack
- Привязать аккаунт Slack
- Настроить чат-бота
- Чат-мосты
- Ограничения — о классах действий, которые объявляют операции