botruntime
Разработка

Файлы: приём и отправка

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

Файлы бот принимает и отправляет через client. Отдельного примитива вроде Conversation или Table для файлов нет по дизайну. Загрузка идёт в файловый стор платформы. Сам файл уходит в диалог обычным сообщением с типом file. client импортируется из рантайма или приходит в обработчик как props.client.

Файлы живут под общим auth-гейтом платформы. Ссылка на файл ведёт на API платформы и требует токен. Голым URL наружу она не уходит.

Отправьте файл

Чтобы отдать пользователю документ, загрузите байты в стор и создайте файловое сообщение. client.uploadFile делает это одним вызовом: регистрирует метаданные, заливает байты и возвращает файл с постоянным url.

src/domain/documents.ts
import { client, user } from '@holocronlab/botruntime-runtime'

const DOCX_MIME =
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document'

// Загрузить документ в файловый стор бота и отправить его в диалог одним
// файловым сообщением. key — стабильный путь (перезалив перезаписывает
// файл, а не плодит копии).
export async function sendDocument(
  conversationId: string,
  key: string,
  bytes: Uint8Array,
  title: string,
): Promise<void> {
  const { file } = await client.uploadFile({
    key,
    content: bytes,
    contentType: DOCX_MIME,
  })
  if (!file?.url) {
    throw new Error('uploadFile не вернул url файла')
  }

  await client.createMessage({
    conversationId,
    userId: user.id,
    type: 'file',
    tags: {},
    payload: { fileUrl: file.url, title, mimeType: DOCX_MIME },
  })
}

content принимает Uint8Array, Buffer, ArrayBuffer, Blob или строку. Вместо байтов можно передать url — тогда клиент сам скачает содержимое по ссылке и зальёт. Оба сразу указывать нельзя.

payload файлового сообщения — три поля. fileUrl — ссылка из file.url. title — имя файла у получателя. mimeType — MIME-тип. Интеграция канала (например, Telegram) сама скачает эту ссылку с токеном бота и отправит клиенту байтами. Токен наружу не светится.

Предел размера payload — порядка 40 МБ. Большие файлы держите в сторе и передавайте ссылкой, а не встраивайте в сообщение.

Две фазы загрузки

uploadFile — обёртка над двухфазным протоколом стора. Знать фазы полезно, если вы заливаете файл из браузера, стримом или по частям.

Регистрация метаданных. client.upsertFile({ key, size, contentType }) возвращает { file: { uploadUrl, url } }. uploadUrl — одноразовый адрес для заливки байтов. url — постоянная download-ссылка. Строки файла и самих байтов ещё нет.

Заливка байтов. PUT тела файла на uploadUrl с заголовком Content-Type. Файл появляется в сторе и становится доступен по url только после успешного PUT.

Доставка. client.createMessage({ type: 'file', payload: { fileUrl, title, mimeType } }) отправляет файл в диалог.

upload.ts
import { client } from '@holocronlab/botruntime-runtime'

// Ручной двухфазный аплоад, когда байты нельзя отдать одним вызовом
// (заливка из браузера, стрим, чанки): upsertFile → PUT байтов → URL.
// PUT идёт мимо client — Bearer-токен приложите сами, только на адрес платформы.
export async function uploadBytes(
  key: string,
  bytes: Uint8Array,
  contentType: string,
  token: string,
): Promise<string> {
  const { file } = await client.upsertFile({
    key,
    size: bytes.byteLength,
    contentType,
  })
  if (!file?.uploadUrl || !file?.url) {
    throw new Error('upsertFile не вернул uploadUrl/url')
  }

  const res = await fetch(file.uploadUrl, {
    method: 'PUT',
    headers: {
      authorization: `Bearer ${token}`,
      'content-type': contentType,
    },
    body: new Uint8Array(bytes).buffer as ArrayBuffer,
  })
  if (!res.ok) {
    throw new Error(`заливка байтов -> ${res.status}`)
  }

  return file.url
}

Вызовы client.* (в том числе upsertFile и uploadFile) уходят с токеном бота автоматически. Прямой PUT на uploadUrl идёт мимо клиента — токен приложите сами. Вешайте Bearer-токен только на адрес платформы. На сторонние origin токен слать нельзя.

Стабильные ключи

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

// Претензия по делу №42 всегда лежит по одному ключу — повторная генерация
// заменяет старую версию, диалог не засоряется дублями.
const key = `cases/${caseId}/claim.docx`

Ключи с / разрешены и группируют файлы по «папкам». Служебные части URL (?key=…) платформа экранирует сама.

Приём входящих файлов

Пользователь прислал вложение — обработчик диалога получает сообщение с type равным file или image. Ссылка лежит в payload: у файла — fileUrl, у изображения — imageUrl.

src/conversations/index.ts
import { Conversation } from '@holocronlab/botruntime-runtime'

export default new Conversation({
  channel: '*',
  handler: async ({ message, execute }) => {
    if (message?.type === 'file') {
      const { fileUrl, title, mimeType } = message.payload
      // fileUrl — auth-гейтед ссылка API платформы. Передайте её инструменту,
      // который скачает байты с токеном бота или отдаст файл в LLM-шлюз.
      await execute({
        instructions: `Пользователь прислал документ «${title}» (${mimeType}). ` +
          `Разбери его и продолжи диалог.`,
      })
      return
    }

    await execute({ instructions: 'Ты — помощник. Отвечай кратко и по-русски.' })
  },
})

PDF можно отдать в LLM-шлюз напрямую по ссылке — промежуточный OCR не нужен. Значимые величины из документа (суммы, даты, реквизиты) всё равно считает и проверяет код инструмента, а не модель. LLM только извлекает и формулирует.

Скачайте защищённый файл

file.url ведёт на GET /v1/files/download?key=… API платформы и требует Bearer-токен. Ссылка не публичная: голый запрос без токена или с другого origin вернёт 401.

Не отдавайте file.url как публичный href (<a href>, <img src>) во внешний UI — запрос уйдёт без токена и получит 401. Качайте байты с токеном на своей стороне и показывайте их через временный object-URL.

Так делает и веб-консоль: тянет байты запросом с токеном и сохраняет их клиентски, не пуская браузер по ссылке напрямую.

download.ts
// Скачать защищённый файл: байты тянутся с токеном, сохраняются через
// временный object-URL (голый href ушёл бы без токена → 401).
async function download(path: string, key: string): Promise<void> {
  const blob = await apiFetchBlob(path, { key })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = key.split('/').pop() || key
  a.click()
  URL.revokeObjectURL(url)
}

Стабильные HTTP-операции перечислены в Files API.

Fail-loud

Файл, который не загрузился или не отправился, — это ошибка вызывающему коду, а не тихий пропуск. Три правила:

  • upsertFile обязан вернуть uploadUrl и url. Нет — бросайте исключение.
  • Неуспешный PUT байтов (res.ok === false) — тоже ошибка, не «почти получилось».
  • uploadFile бросает при провале заливки. Не глотайте это исключение.

Файл, который «вроде отправился», хуже честной ошибки. Пользователь думает, что получил документ, а его нет. Возвращайте проверяемый результат или ошибку.

Дальше

On this page