Перейти до вмісту

Чат-міст

Провайдер Значення в протоколі Транспорт Тип простору
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 символів
Ізоляція збоїв Одна погана подія ніколи не валить міст; з’єднання продовжує обслуговувати всі інші розмови