balkhaev.com Блог · AI-агенты и автоматизации

Корпоративный AI-агентчасть 8 из 9

Интерфейс: веб-чат и бот в Slack

Выносим агента из терминала. Вход через корпоративный SSO, веб-чат со стримингом и историей разговоров, бот в Slack, который знает, кто ему пишет, и отвечает только в личке.

обновлено 12 мин чтенияagentsoauthslackweb

Корпоративный AI-агентчасть 8 из 9
  1. 01Концепция: агент, который знает компанию
  2. 02Первый разговор с моделью
  3. 03Агентный цикл и инструменты
  4. 04MCP: подключаем внутренние системы
  5. 05Знания компании: поиск по документам
  6. 06Права доступа: SSO, OAuth и агент от имени сотрудника
  7. 07Контекст и деньги: кэширование, сжатие истории и лимиты
  8. 08Интерфейс: веб-чат и бот в Slack
  9. 09В прод: периметр, аудит и проверки

В прошлых частях агент жил в терминале и брал токен сотрудника из переменной окружения. Сотрудники так работать не будут. В этой части сделаем два входа: веб-чат и бота в рабочем мессенджере. Тонкий слой, как обещали в концепции, но с несколькими местами, где легко ошибиться с безопасностью.

Что нужно от интерфейса

  • Знать, кто спрашивает. Вход через корпоративный SSO, дальше агент работает с токеном этого человека, как в части 6.
  • Не терять доступ посреди дня. Access token живёт час, сотрудник работает восемь. Нужен refresh token, и хранить его надо аккуратно.
  • Показывать ответ по мере написания. Ответ с тремя вызовами инструментов может идти полминуты. Пустой экран всё это время выглядит как зависание.
  • Помнить разговоры. Закрыл вкладку, открыл завтра, продолжил.
  • Спрашивать подтверждение опасных действий из части 3, теперь через кнопку, а не через терминал.

Схема

/login, /callback cookie sid события ответы ask(user, conversation, text, events) Браузер SSO Slack web.tsвход, сессии, /api/chat (SSE)slack.ts: /slack/events agent-core.tsцикл, кэш, лимиты (части 3–7) MCP: вики, база store.dbучётки, разговоры

Ядро отдельно от интерфейса

Цикл из части 7 печатал в консоль и спрашивал подтверждение через readline. Теперь у него два потребителя, поэтому всё, что касается вывода, уходит в три колбэка:

agent-core.tsts
import Anthropic from "@anthropic-ai/sdk";
import { connectMcp } from "./mcp";
import { userToken } from "./oidc";
import { type Conversation, type User, saveConversation } from "./store";
import { DAILY_LIMIT_USD, record, spentToday, tally } from "./usage";

type Message = Anthropic.Beta.BetaMessageParam;

// Куда агент сообщает о ходе работы. Веб-чат и бот реализуют это по-своему
export type Events = {
  text: (delta: string) => void;
  tool: (name: string, input: unknown) => void;
  confirm: (name: string, input: unknown) => Promise<boolean>;
};

createAgent(user, token) собирает то же, что agent.ts в прошлой части: подключает MCP-серверы с токеном сотрудника, фиксирует порядок инструментов, ставит метки кэша, считает деньги. Функция turn(messages, events) это цикл из части 7, где process.stdout.write заменён на events.text, вывод вызова инструмента на events.tool, а вопрос в терминале на await events.confirm(...). Вокруг него ask(user, conversation, text, events): берёт агента сотрудника из кеша (или создаёт), блокирует разговор на время хода, дописывает вопрос и ответ в историю, сохраняет её, а при сбое отрезает всё, что дописал этот ход.

Три решения здесь стоит объяснить.

Агент на сотрудника, а не на запрос. Подключение к MCP-серверам стоит нескольких запросов, и открывать его на каждый вопрос незачем. Токен при этом передаётся функцией, а не строкой: access token обновляется раз в час, и подключения должны брать свежий.

Один ход за раз. Если сотрудник отправит второй вопрос, пока агент отвечает на первый, оба хода начнут дописывать одну историю, и она перемешается. Простая блокировка по id разговора это исключает.

Откат при сбое. Если что-то упало посреди хода, в истории может остаться вызов инструмента без результата, и следующий запрос к API вернёт ошибку. Поэтому при сбое отрезаем всё, что дописал этот ход. Это не правка истории в смысле части 7: отрезается только хвост, начало разговора не меняется.

Вход через SSO

Для веб-приложения с сервером стандартный способ входа это OAuth authorization code flow с PKCE:

  1. Сотрудник открывает /login. Сервер придумывает случайные state и verifier, запоминает их и отправляет браузер на страницу входа провайдера. В адрес кладёт state и хеш от verifier.
  2. Сотрудник входит, как обычно входит в корпоративные системы: пароль, второй фактор. Провайдер возвращает браузер на наш /callback с одноразовым кодом и тем же state.
  3. Сервер проверяет, что state совпадает с выданным этому браузеру, и меняет код на токены, прикладывая исходный verifier. Провайдер сверяет его с хешем из первого шага.

state защищает от подделки входа: без него злоумышленник мог бы подсунуть сотруднику ссылку со своим кодом, и тот работал бы в чужой учётке. PKCE защищает код: даже перехваченный код бесполезен без verifier, который не покидал наш сервер.

Учебный провайдер из части 6 научился этому. Добавились зарегистрированные клиенты, страница входа и два новых вида обмена в /token: код на токены с проверкой PKCE и refresh token на новую пару токенов.

Refresh token здесь одноразовый: при каждом обновлении провайдер выдаёт новый и гасит старый. В Keycloak и Okta это включается настройкой ротации, и её стоит включить. Украденный refresh token тогда работает только до первого обновления. Если вор обновится первым, у настоящего владельца следующее обновление не пройдёт и его выкинет на страницу входа, а это заметно.

Клиентская сторона входа живёт в oidc.ts. Начало входа это два случайных значения и редирект:

const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));

const url = new URL(`${ISSUER}/authorize`);
url.search = new URLSearchParams({
  response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI,
  scope: "openid profile email offline_access",
  state, code_challenge: challenge, code_challenge_method: "S256",
}).toString();

state и verifier сервер запоминает за этим браузером. На /callback он меняет код на токены запросом в /token с grant_type: "authorization_code" и тем же verifier, сохраняет refresh token в базу, а access token держит в памяти. Когда access token подходит к концу, функция userToken получает новый по refresh token.

Самое тонкое место здесь userToken. Если два запроса одновременно увидят, что токен истекает, и оба пойдут его обновлять, первый получит новый refresh token, а второй придёт со старым, уже погашенным, и сотрудника выкинет. Поэтому обновление идёт одно на пользователя, остальные ждут его результата.

В настоящем провайдере веб-приложение регистрируется как confidential client с секретом, и секрет добавляется к запросам в /token. Scope offline_access нужен, чтобы провайдер вообще выдал refresh token.

Где хранить токены и разговоры

Хватает двух таблиц:

CREATE TABLE users (
  login TEXT PRIMARY KEY, name TEXT, email TEXT,
  refresh_token TEXT,  -- зашифрован AES-GCM ключом из окружения
  slack_id TEXT UNIQUE
);
CREATE TABLE conversations (
  id TEXT PRIMARY KEY, login TEXT NOT NULL, title TEXT,
  messages TEXT NOT NULL,  -- история для API как есть, только дописывается
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);

Refresh token зашифрован. Он даёт доступ к данным сотрудника на недели вперёд, а файл базы рано или поздно окажется в бэкапе, на ноутбуке разработчика или в тикете поддержки. Ключ лежит в переменной окружения, отдельно от базы. Сгенерировать его: openssl rand -base64 32.

Разговор хранится как есть. В колонке messages лежит ровно тот массив, который уходит в API, со всеми вызовами инструментов, блоками размышлений и блоками compaction. Если хранить только текст вопросов и ответов, продолжить разговор завтра не получится: модель не увидит, что она уже нашла. Человеку показываем выжимку из этого массива через transcript.

Логин в каждом запросе к разговорам. loadConversation(id, login) ищет по обоим полям. Без этого любой сотрудник, узнавший id чужого разговора, прочитал бы его, а в нём ответы, построенные на чужих правах.

Веб-сервер

Сервер на Bun.serve отдаёт страницу, обрабатывает /login, /callback и /logout и несколько адресов API: список разговоров, один разговор, вопрос и ответ на подтверждение. Интересен только вопрос: ответ на него это поток событий, и колбэки ядра просто пишут в этот поток.

const send = (event: string, data: unknown) =>
  controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));

const ping = setInterval(() => send("ping", {}), 15_000);
const result = await ask(user, conv, text, {
  text: (delta) => send("text", delta),
  tool: (name, input) => send("tool", { name, input }),
  confirm: (name, input) => waitForButton(user, name, input), // событие confirm и до двух минут ожидания
});
send("done", result);

Что здесь важно для безопасности:

  • Cookie сессии HttpOnly (скрипт на странице её не прочитает), SameSite=Lax (не уходит с запросами, которые начал чужой сайт) и Secure на HTTPS. В cookie только случайный id, всё остальное на сервере.
  • Только JSON в POST. HTML-форма с чужого сайта не умеет отправлять application/json, поэтому подделать вопрос от имени сотрудника через форму нельзя.
  • Чужой разговор отвечает 404, а не 403, по той же причине, что вики в части 6: не подтверждаем, что такой разговор существует.

И несколько деталей про стриминг:

  • SSE поверх POST. Классический EventSource умеет только GET, а вопрос удобнее отправлять телом запроса. Поэтому ответ это обычный поток text/event-stream, а браузер читает его через fetch и ReadableStream.
  • Пинги. Пока модель думает или ждёт инструмент, данных нет. Bun по умолчанию закрывает соединение после 10 секунд тишины, прокси по дороге тоже. Пустое событие раз в 15 секунд держит соединение, а idleTimeout поднят до минуты.
  • Закрытая вкладка не теряет ответ. Если браузер отключился, send просто перестаёт писать, а ход доходит до конца и сохраняется. Сотрудник откроет разговор и увидит ответ.
  • Подтверждение опасного вызова приходит в браузер событием confirm, а ответ сотрудника отдельным запросом в /api/confirm. Ход в это время ждёт, но не дольше двух минут.

Страница

Вся страница это один HTML-файл без сборки. Главное в скрипте это чтение потока событий:

const res = await fetch("/api/ask", { method: "POST", headers: { "content-type": "application/json" }, body });
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;
  const events = buffer.split("\n\n");
  buffer = events.pop();
  for (const raw of events) handle(raw); // text дописывает answer.textContent, tool и confirm рисуют плашки
}

Одно правило важнее остальных: ответ модели вставляется через textContent, никогда через innerHTML. Агент читает документы, которые мог написать кто угодно, и пересказывает их. Если документ содержит <img src=x onerror=...>, а страница вставит ответ как HTML, скрипт выполнится в сессии сотрудника. Если хочется красивого Markdown, рендерите его библиотекой и пропускайте результат через санитайзер вроде DOMPurify.

Бот в Slack

Сначала приложение в Slack: на api.slack.com создаётся приложение, в Event Subscriptions указывается адрес https://agent.company.ru/slack/events и события message.im и app_mention. Права бота: chat:write, im:history, app_mentions:read, users:read, users:read.email. Из настроек понадобятся Bot Token и Signing Secret.

Главное в slack.ts это проверка подписи:

function verify(req: Request, raw: string) {
  const ts = req.headers.get("x-slack-request-timestamp") ?? "";
  const signature = req.headers.get("x-slack-signature") ?? "";
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = `v0=${createHmac("sha256", SIGNING_SECRET).update(`v0:${ts}:${raw}`).digest("hex")}`;
  return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

Остальное это обработчик событий: проверить подпись, сразу ответить, найти сотрудника по Slack id и передать вопрос в то же ядро, что у веб-чата.

Подпись. Адрес /slack/events открыт в интернет, и без проверки любой мог бы прислать событие «пользователь U1 спрашивает про зарплаты». Slack подписывает каждый запрос секретом приложения, а метка времени не даёт повторить старый подписанный запрос.

Три секунды. Slack ждёт ответа на событие три секунды, потом повторяет его. Ответ агента занимает дольше, поэтому сразу отвечаем ok, а работу запускаем без await. Повторы с заголовком x-slack-retry-num пропускаем, иначе агент ответил бы на один вопрос дважды.

Кто пишет. Slack сообщает id пользователя в Slack, а агенту нужна учётка в SSO с токеном. При первом сообщении бот присылает ссылку на вход, и после входа мы запоминаем связку. Ссылка одноразовая и живёт десять минут.

Здесь есть ловушка. Без проверки почты злоумышленник мог бы написать боту сам, получить ссылку для привязки своего аккаунта Slack и переслать её коллеге с текстом «зайди, тут новый портал». Коллега входит через SSO, и аккаунт злоумышленника в Slack оказывается привязан к учётке коллеги, со всеми её правами. Поэтому после входа сверяем почту из SSO с почтой профиля в Slack и привязываем только при совпадении.

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

Стриминг через правку. Бот сразу пишет «Думаю…», а потом правит это сообщение по мере ответа. У Slack есть лимиты на частоту вызовов, и правка на каждый фрагмент текста быстро в них упрётся, поэтому накопленный текст отправляется раз в полторы секунды. У Slack есть и отдельные методы для потоковых ответов AI-приложений: chat.startStream, chat.appendStream и chat.stopStream. Правка сообщения работает в любом рабочем пространстве и показывает идею без лишних зависимостей. Если ваше приложение настроено как AI-ассистент Slack, потоковые методы дадут более плавный вывод.

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

Почти всё это одинаково для Mattermost, Microsoft Teams и других мессенджеров: проверка подписи, быстрый ответ на событие, привязка учётки с проверкой, ответы только в личке.

Запускаем

export TOKEN_KEY=$(openssl rand -base64 32)
export SLACK_BOT_TOKEN=xoxb-... SLACK_SIGNING_SECRET=...
export PUBLIC_URL=http://localhost:8080

bun idp.ts            # учебный SSO, :4000
bun wiki-server.ts    # :3001
bun db-server.ts      # :3002
bun web.ts            # веб-чат и вебхук Slack, :8080

Открываем http://localhost:8080, нас перебрасывает на страницу входа, выбираем сотрудника и попадаем в чат. Чтобы Slack достучался до локальной машины, нужен туннель (ngrok, cloudflared) или тестовый стенд.

Мы проверили всю цепочку на заглушках модели и Slack:

  • Игорь входит, спрашивает про сделки и видит только свои две. RLS из части 6 работает через веб так же, как в терминале.
  • Второй вопрос в том же разговоре продолжает историю.
  • Без cookie API отвечает 401, POST формой 415, а Ольга, запросившая разговор Игоря по id, получает 404.
  • Подпись Slack с неверным секретом даёт 401. Первое сообщение от незнакомого пользователя приводит к ссылке на вход. После входа аккаунт привязан, а повторное использование той же ссылки ничего не привязывает. Пользователь Slack с другой почтой не привязывается.
  • После перезапуска сервера бот отвечает Игорю без нового входа: access token получен заново по refresh token из базы.

Перед продакшеном

  • Сессии и блокировки в памяти. Это работает, пока экземпляр сервера один. Для нескольких экземпляров сессии, ожидающие подтверждения и блокировки разговоров переезжают в Redis или в базу.
  • Лимит частоты на сотрудника. Дневной лимит в деньгах из части 7 есть, но стоит ограничить и число вопросов в минуту, чтобы скрипт с чужим cookie не забил очередь.
  • Форматирование. Модель пишет Markdown, а у Slack свой синтаксис mrkdwn: ссылки и жирный пишутся иначе. Простой конвертер на несколько регулярных выражений решает большую часть проблем. Длинные ответы стоит резать на несколько сообщений.
  • Выход. Когда сотрудник увольняется, его учётку блокируют в SSO, и следующее обновление токена вернёт ошибку. Но access token проживёт до часа. Если нужно быстрее, удаляйте refresh token и сессии по событию из каталога сотрудников.

Что дальше

У агента есть интерфейс, и им можно пользоваться. Осталась последняя часть: вывести его в прод. Развернуть внутри периметра компании, собрать полноценный журнал аудита, проверять качество ответов набором вопросов с эталонами и защититься от промпт-инъекций в документах, которые агент читает.