Aivium Aivium

Для разработчиков

Боты для Aivium Work

Бот пишет людям в «Личные» Aivium Work: события вашей CRM, сервера или сервиса, кнопки действий и команды через «/». Код бота работает у вас, а переписка с ним зашифрована сквозным шифрованием — сервер Aivium её не читает.

Как это устроено

Бот — это устройство

У бота свой аккаунт и одно устройство с ключом X25519. Сообщения он шифрует и расшифровывает сам — так же, как компьютер или телефон человека.

Сервер передаёт только конверты

Релей aivium.app хранит и раздаёт зашифрованные копии: каждому устройству каждого собеседника — своя. Текст сообщений, кнопки и команды сервер не видит.

Первым пишет человек

Человек находит бота по @нику и сам открывает с ним переписку. Бот не создаёт комнат и не пишет тем, кто к нему не обращался. В группу бота добавляет её админ — там бот получает только команды «/», упоминания своего @ника и ответы на свои сообщения; админом группы он не бывает.

Ваш код — ваш сервер

Aivium не запускает код ботов. Бот забирает новые сообщения длинным опросом getUpdates — открытый порт не нужен. Если удобнее вебхук — задайте адрес https:// в кабинете «Мои боты»: сервер сам отправит туда те же зашифрованные конверты с подписью. Отвечайте 2xx сразу, не дольше чем за 2 секунды, а работу делайте после ответа: медленный ответ считается сбоем, и следующая доставка откладывается.

С чего начать

  1. 1. Создайте бота в кабинете

    Кабинет → «Мои боты»: имя и ник, оканчивающийся на «_bot». Там же — токен бота (показывается один раз, перевыпуск сразу отзывает старый), картинка, описание, команды и навык для агента.

  2. 2. Сгенерируйте ключ устройства

    Закрытый ключ X25519 (32 случайных байта) создаёт и хранит сам бот. Открытый ключ передайте через setKey. Закрытый ключ — главный секрет бота: его утечка открывает переписку, а потеря или смена покажет всем собеседникам «ключ изменился».

  3. 3. Получайте сообщения

    getUpdates с timeout до 25 секунд отдаёт новые конверты. Сохраните их у себя и подтвердите их id в поле ack следующего вызова — иначе релей отдаст их снова.

  4. 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 — опросите снова через указанное число секунд.

Коды отказов

401 bot_token_invalid 403 bot_disabled 409 key_not_set 409 key_already_set 409 poll_in_progress 404 room_not_found 422 recipient_not_in_room 422 ciphertext_invalid 413 message_too_large 429 queue_full 429 rate_limited 429 quota_exceeded

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.