Файлы: приём и отправка
Как бот принимает входящие файлы и отправляет свои: загрузка в файловый стор, стабильные ключи и файловые сообщения через клиент рантайма.
Файлы бот принимает и отправляет через client. Отдельного примитива вроде Conversation или Table для файлов нет по дизайну. Загрузка идёт в файловый стор платформы. Сам файл уходит в диалог обычным сообщением с типом file. client импортируется из рантайма или приходит в обработчик как props.client.
Файлы живут под общим auth-гейтом платформы. Ссылка на файл ведёт на API платформы и требует токен. Голым URL наружу она не уходит.
Отправьте файл
Чтобы отдать пользователю документ, загрузите байты в стор и создайте файловое сообщение. client.uploadFile делает это одним вызовом: регистрирует метаданные, заливает байты и возвращает файл с постоянным url.
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 } }) отправляет файл в диалог.
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.
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.
Так делает и веб-консоль: тянет байты запросом с токеном и сохраняет их клиентски, не пуская браузер по ссылке напрямую.
// Скачать защищённый файл: байты тянутся с токеном, сохраняются через
// временный 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бросает при провале заливки. Не глотайте это исключение.
Файл, который «вроде отправился», хуже честной ошибки. Пользователь думает, что получил документ, а его нет. Возвращайте проверяемый результат или ошибку.