Для разработчиков
Боты для Aivium Work
Бот пишет людям в «Личные» Aivium Work: события вашей CRM, сервера или сервиса, кнопки действий и команды через «/». Код бота работает у вас, а переписка с ним зашифрована сквозным шифрованием — сервер Aivium её не читает.
Как это устроено
Бот — это устройство
У бота свой аккаунт и одно устройство с ключом X25519. Сообщения он шифрует и расшифровывает сам — так же, как компьютер или телефон человека.
Сервер передаёт только конверты
Релей aivium.app хранит и раздаёт зашифрованные копии: каждому устройству каждого собеседника — своя. Текст сообщений, кнопки и команды сервер не видит.
Первым пишет человек
Человек находит бота по @нику и сам открывает с ним переписку. Бот не создаёт комнат и не пишет тем, кто к нему не обращался. В группу бота добавляет её админ — там бот получает только команды «/», упоминания своего @ника и ответы на свои сообщения; админом группы он не бывает.
Ваш код — ваш сервер
Aivium не запускает код ботов. Бот забирает новые сообщения длинным опросом getUpdates — открытый порт не нужен. Если удобнее вебхук — задайте адрес https:// в кабинете «Мои боты»: сервер сам отправит туда те же зашифрованные конверты с подписью. Отвечайте 2xx сразу, не дольше чем за 2 секунды, а работу делайте после ответа: медленный ответ считается сбоем, и следующая доставка откладывается.
С чего начать
-
1. Создайте бота в кабинете
Кабинет → «Мои боты»: имя и ник, оканчивающийся на «_bot». Там же — токен бота (показывается один раз, перевыпуск сразу отзывает старый), картинка, описание, команды и навык для агента.
-
2. Сгенерируйте ключ устройства
Закрытый ключ X25519 (32 случайных байта) создаёт и хранит сам бот. Открытый ключ передайте через setKey. Закрытый ключ — главный секрет бота: его утечка открывает переписку, а потеря или смена покажет всем собеседникам «ключ изменился».
-
3. Получайте сообщения
getUpdates с timeout до 25 секунд отдаёт новые конверты. Сохраните их у себя и подтвердите их id в поле ack следующего вызова — иначе релей отдаст их снова.
-
4. Отвечайте
Узнайте участников комнаты и ключи их устройств через getRooms, зашифруйте копию на каждое устройство и отправьте их одним вызовом sendEnvelopes с общим client_message_id.
Bot API
Все методы — POST с телом JSON на https://aivium.app/api/v1/work/bot/<method>, заголовок Authorization: Bearer <токен бота>. Успех — {"ok": true, …}, отказ — {"ok": false, "error": {"code": "…"}} с HTTP-статусом; ошибка формы запроса — 422. Токен работает только на этих адресах.
| Метод | Тело запроса | Что отвечает |
|---|---|---|
| getMe | — | bot {user_id, name, nickname, device_id, public_key}, quota {daily_limit, used_today} |
| setKey | public_key (base64url, 32 байта), rotate? | ok. Другой ключ поверх заданного — только с rotate: true, иначе 409 key_already_set |
| getRooms | room_ids? (до 100) или after? + limit? (1–100) | rooms[] с участниками (is_bot, устройства с ключами), next — курсор следующей страницы |
| getUpdates | ack? (до 500 id), timeout? 0–25 с, limit? 1–100 | updates[] — конверты {id, room_id, from_user_id, from_device_id, client_message_id, nonce, ciphertext}, has_more, иногда retry_after |
| sendEnvelopes | room_id, client_message_id (до 80), items[] {to_user_id, to_device_id, nonce, ciphertext} (до 64) | accepted, inserted, skipped[] (reason: queue_full; bot_not_addressed — копия другому боту в группе: боты ботам не пишут). Повтор с тем же client_message_id не дублирует |
| setProfile | about? (до 200), commands?[] {command, description}, skill? {name, content} | null | ok, skill_version. Имя и картинка меняются только в кабинете |
| leaveRoom | room_id | ok — бот выходит из переписки |
curl -s -X POST https://aivium.app/api/v1/work/bot/getUpdates \
-H "Authorization: Bearer $AIVIUM_BOT_TOKEN" -H "Content-Type: application/json" \
-d '{"timeout": 25, "ack": [""]}'
Шифрование
Схема одинакова у приложения, телефона и бота. Двоичные значения — base64url без «=», строки — UTF-8. Адрес устройства — «<user_id>/<device_id>».
shared = X25519(own_secret, peer_public)
key = HKDF-SHA256(ikm = shared, salt = room_id,
info = "aivium-work-msg/1||", length = 32)
aad = "aivium-work/1||||"
ciphertext = XChaCha20-Poly1305(key, nonce /* 24 bytes */, plaintext, aad) // 16-byte tag appended
from, to = "/" // e.g. "77/c7e3d9b4-1a2f-4e8c-b5d6-0f9a3e2c1b48"
- Отправитель и получатель зашиты и в ключ, и в AAD: копию нельзя переадресовать другому устройству, в другую комнату или под другим id сообщения.
- Nonce — 24 случайных байта на каждую копию.
- Ключ собеседника закрепляйте при первой встрече. Если релей потом покажет другой ключ — не шифруйте на него и не принимайте с ним сообщения, пока это не подтвердит владелец бота.
- Свою реализацию проверьте на эталонных векторах (пришлём по запросу): те же ключи, nonce и текст должны дать тот же шифротекст байт-в-байт.
Сообщения, кнопки и команды
Внутри конверта — JSON. Сообщение бота — обычное текстовое сообщение (markdown) с карточкой кнопок: старое приложение покажет текст, новое — текст с кнопками.
{
"v": 1,
"kind": "text",
"body": "**Задача 360553** — на проверке",
"authorName": "CRM",
"sentAtMs": 1791300000000,
"replyToKey": null,
"mentions": [],
"card": {
"type": "bot_buttons", "v": 1,
"rows": [
[{ "id": "status:12", "text": "Готово" }],
[{ "text": "Открыть в CRM", "url": "https://crm.example/task/360553" }]
]
}
}
// press of a button (from the person to the bot)
{
"v": 1,
"kind": "button",
"buttonId": "status:12",
"targetKey": "77/c7e3d9b4-…|crm-n-48213",
"sentAtMs": 1791300005000
}
// command
{ "v": 1, "kind": "text", "body": "/link 482913", … }
- Текст — до 16 000 символов. Кнопок — до 8 рядов, до 4 в ряду и до 12 всего; подпись — до 40 символов.
- У кнопки ровно одно из двух: id (до 64 символов [A-Za-z0-9_.:-]) — нажатие придёт боту, или url — ссылка https://, откроется у человека.
- Нажатие приходит конвертом вида button с targetKey — «<адрес бота>|<client_message_id сообщения>». Принимайте его, только если такая кнопка была в вашем сообщении и нажата в той же комнате, куда сообщение ушло. Отвечая на нажатие, ставьте replyToKey = targetKey: по нему приложение снимает ожидание у кнопки.
- Команда — текст, который начинается с «/»: /имя аргументы; /имя@ник_бота — команда одному боту группы, команду другому боту считайте обычным текстом. Список команд из setProfile приложение показывает меню, когда человек набирает «/». Пустая переписка с ботом предлагает кнопку «Начать» — она отправляет /start.
- Служебные виды (read, typing, react и любой незнакомый) — не сообщения: подтвердите конверт и забудьте. Правку и удаление принимайте только от автора сообщения.
- Команды и ответы с устройства человека, которое вы ещё не подтвердили (новое устройство), не выполняйте — кроме привязки вроде /link КОД: устройство в аккаунт добавляет сервер, и подменённый сервер мог бы добавить своё.
- Релей может отдать конверт повторно. Выполняйте действие один раз на ключ «<from>|<client_message_id>».
Лимиты и отказы
- Сообщений в сутки (UTC) на бота: 1 000 на бесплатном тарифе владельца и в пробный период, 50 000 на «Плюс», «Ультра» и командном месте. Считается каждый новый client_message_id, а не каждая копия; «печатает…» и правки тоже считаются.
- sendEnvelopes — до 600 в минуту на бота и до 60 в минуту на комнату, остальные методы — до 60 в минуту, getUpdates — один одновременно (второй получит 409 poll_in_progress — это временно: прежний опрос, например до перезапуска бота, держится до 25 секунд; повторите через несколько секунд).
- Отказ по лимиту — 429 с заголовком Retry-After и error.retry_after в секундах; квота кончилась — 429 quota_exceeded до полуночи UTC.
- Если на сервере заняты все места ожидания, getUpdates отвечает сразу с retry_after — опросите снова через указанное число секунд.
Коды отказов
SDK на TypeScript
Пакет @aivium/work-bot для Node.js 20+ (запуск .ts напрямую — 22.6+) без сторонних зависимостей: шифрование, все методы Bot API, кнопки, разбор входящих, закрепление ключей и проверка нажатий. Проверен на эталонных векторах контракта. Пакет готовится к публикации; если он нужен вам уже сейчас — напишите нам.
import { readFileSync } from 'node:fs'
import { AiviumWorkBot, AiviumWorkClient, MemoryStore } from '@aivium/work-bot'
const client = new AiviumWorkClient('https://aivium.app', process.env.AIVIUM_BOT_TOKEN!)
const me = await client.getMe()
const bot = new AiviumWorkBot(client, new MemoryStore(), {
userId: me.bot.user_id,
deviceId: me.bot.device_id,
// crypto.newSecret() once, into a file readable only by the bot; never print or log it
secret: readFileSync(process.env.AIVIUM_BOT_SECRET_FILE!, 'utf8').trim(),
name: me.bot.name,
nickname: me.bot.nickname,
})
await bot.ensureKey()
await client.setProfile({ commands: [{ command: 'start', description: 'Say hello' }] })
let ack: string[] = []
for (;;) {
const page = await bot.receive(ack, 25) // retry e.isTemporary() errors after e.retryAfter
ack = []
for (const u of page.updates) {
// device_not_allowed: an unconfirmed device — do not act on it (but /link-style binding)
if (u.status === 'ok' && u.event?.type === 'command' && u.event.command === 'start') {
await bot.reply(u, `reply-${u.id}`, `Hello, ${u.fromName}!`, [
[{ id: 'like', text: '👍' }, { text: 'Docs', url: 'https://aivium.app/developers/bots' }],
])
} else if (u.status === 'ok' && u.event?.type === 'button') {
// reply() quotes the message with the button: the app stops its waiting state
await bot.reply(u, `reply-${u.id}`, `Pressed: ${u.event.buttonId}`)
}
ack.push(u.id) // after it is handled (and saved, in a real bot)
}
}
MemoryStore в примере забывает закреплённые ключи при перезапуске. В настоящем боте реализуйте интерфейс BotStore поверх своей базы.
Вопросы по ботам и доступ к SDK — support@aivium.app.