Skip to content

События ​

Чтобы бот реагировал на происходящее, Emotify присылает ему события. Транспорта два — выберите один, оба несут одинаковые данные:

ТранспортКогда выбирать
WebSocketбот работает где угодно, публичный адрес не нужен. Проще для старта
Вебхуку бота есть публичный https-адрес; Emotify сам присылает POST

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

Нужен scope events

Оба транспорта настраиваются маршрутами, которые открывает scope events: без него GET /events/token и PUT /webhook отвечают 403 с полем requiredScope.

Это самый широкий доступ на чтение — поток несёт все сообщения всех каналов, где стоит бот. Боту, который только пишет, отмечать его не нужно.

Список событий ​

СобытиеКогда приходит
message.createdв текстовой комнате появилось сообщение
message.updatedсообщение изменено
message.deletedсообщение удалено
reaction.addedна сообщение поставили реакцию
reaction.removedреакцию сняли
voice.member_joinedкто-то вошёл в голосовую комнату
voice.member_leftкто-то покинул голосовую комнату

Общая для всех событий обёртка:

ПолеОписание
eventимя события
timestampкогда произошло, ISO-8601
channelIdканал, где это случилось
dataсодержимое, своё у каждого события

Оба транспорта несут одинаковое тело

По WebSocket приходит ровно тот же объект, что и в теле вебхука — никаких обёрток разбирать не нужно.

message.created ​

json
{
  "event": "message.created",
  "timestamp": "2026-09-19T12:00:00.000Z",
  "channelId": "4facb875-a03f-406e-ad47-3d8515eb69de",
  "data": {
    "roomId": "a0d4cc29-04e9-4567-9a7a-4b2668d3af30",
    "message": {
      "id": "3f8a1c62-…",
      "text": "Привет!",
      "createdAt": "2026-09-19T12:00:00.000Z",
      "author": { "id": "b49c3095-…", "name": "Z3oM" }
    }
  }
}

data.roomId — комната, куда обычно и отвечают. Формат сообщения тот же, что в истории.

text — это Markdown, а не голый текст

Человек, выделивший слово жирным, пришлёт **Москва**, а не Москва. Бот, который сравнивает text со строкой или вырезает из него аргумент команды, обязан снять разметку — иначе /погода **Москва** не найдёт города, а /кик **Ник** не найдёт участника.

⚠️ Снимайте парные обёртки вокруг всего значения, а не каждый символ: _ и ~ встречаются в никах, и my_nick портить нельзя.

js
function stripFormatting(value) {
  const wrappers = ['***', '**', '__', '~~', '*', '_', '`']
  let result = value.trim()
  let changed = true

  while (changed) {
    changed = false
    for (const wrapper of wrappers) {
      const doubled = wrapper.length * 2
      if (result.length > doubled && result.startsWith(wrapper) && result.endsWith(wrapper)) {
        result = result.slice(wrapper.length, -wrapper.length).trim()
        changed = true
      }
    }
  }

  return result
}

message.updated ​

json
{
  "event": "message.updated",
  "timestamp": "2026-09-19T12:05:00.000Z",
  "channelId": "4facb875-…",
  "data": {
    "roomId": "a0d4cc29-…",
    "message": {
      "id": "3f8a1c62-…",
      "text": "исправленный текст",
      "editedAt": "2026-09-19T12:05:00.000Z",
      "author": { "id": "b49c3095-…", "name": "Z3oM" }
    }
  }
}

text — уже новый текст сообщения, перечитывать историю не нужно.

message.deleted ​

json
{
  "event": "message.deleted",
  "timestamp": "2026-09-19T12:06:00.000Z",
  "channelId": "4facb875-…",
  "data": {
    "roomId": "a0d4cc29-…",
    "message": {
      "id": "3f8a1c62-…",
      "deletedAt": "2026-09-19T12:06:00.000Z",
      "author": { "id": "b49c3095-…", "name": "Z3oM" }
    }
  }
}

Текста здесь нет — сообщение уже удалено. Если он вам нужен, храните его при получении message.created.

reaction.added и reaction.removed ​

json
{
  "event": "reaction.added",
  "timestamp": "2026-09-19T12:07:00.000Z",
  "channelId": "4facb875-…",
  "data": {
    "roomId": "a0d4cc29-…",
    "messageId": "3f8a1c62-…",
    "emoji": "👍",
    "totalCount": 3,
    "user": { "id": "b49c3095-…", "name": "Z3oM" }
  }
}
ПолеОписание
emojiкакая реакция
totalCountсколько таких реакций у сообщения после действия
userкто поставил или снял

Удобно для голосований и выдачи ролей по реакции: считать самому не нужно, totalCount уже посчитан.

voice.member_joined ​

json
{
  "event": "voice.member_joined",
  "timestamp": "2026-09-19T12:00:00.000Z",
  "channelId": "4facb875-…",
  "data": {
    "roomId": "29004975-…",
    "user": { "id": "b49c3095-…", "name": "Z3oM" }
  }
}

user.name — ник участника на канале, если он установлен, иначе общее имя.

voice.member_left ​

json
{
  "event": "voice.member_left",
  "timestamp": "2026-09-19T12:05:00.000Z",
  "channelId": "4facb875-…",
  "data": {
    "roomId": null,
    "user": { "id": "b49c3095-…" }
  }
}

roomId у выхода всегда null

Emotify сообщает о выходе как «такой-то больше не в голосовой» и не называет комнату. Если комната важна — запомните её из voice.member_joined:

js
const whereIs = new Map() // userId → roomId

if (event.event === 'voice.member_joined') {
  whereIs.set(event.data.user.id, event.data.roomId)
}

if (event.event === 'voice.member_left') {
  const roomId = whereIs.get(event.data.user.id)
  whereIs.delete(event.data.user.id)
  // теперь известно, откуда именно он вышел
}

Текущий состав комнат в любой момент можно перечитать запросом GET /channels/{id}/rooms — он отдаёт голосовые вместе с теми, кто в них сидит.

Пример: бот выходит из голосовой, когда остался один.

js
if (event.event === 'voice.member_left') {
  const { rooms } = await api(`/channels/${event.channelId}/rooms`).then((r) => r.json())
  const mine = rooms.find((room) => room.id === myVoiceRoomId)

  const others = mine?.users.filter((u) => u.id !== myBotId) ?? []
  if (others.length === 0) {
    await api(`/rooms/${myVoiceRoomId}/voice`, { method: 'DELETE' })
  }
}

WebSocket ​

Понадобятся два npm-пакета: centrifuge (клиент протокола) и ws.

bash
npm i centrifuge ws

Шаг 1. Получите токен подписки:

bash
curl https://emotify.ru/api/v1/events/token -H "Authorization: Bot $BOT_TOKEN"
json
{
  "token": "eyJhbGciOi…",
  "channel": "bot:16c6e6b1-…",
  "url": "wss://emotify.ru/cf/connection/websocket",
  "expiresIn": 21600
}

Шаг 2. Подключитесь и подпишитесь на свой канал:

js
import { Centrifuge } from 'centrifuge'
import WebSocket from 'ws'

const res = await fetch('https://emotify.ru/api/v1/events/token', {
  headers: { Authorization: `Bot ${process.env.BOT_TOKEN}` }
})
const { token, channel, url } = await res.json()

const client = new Centrifuge(url, { token, websocket: WebSocket })

client
  .newSubscription(channel)
  .on('publication', ({ data: event }) => {
    if (event.event === 'message.created') {
      console.log(`${event.data.message.author.name}: ${event.data.message.text}`)
    }
  })
  .subscribe()

client.connect()

Переподключение при обрывах библиотека делает сама.

Токен живёт 6 часов — обновляйте его заранее тем же запросом и переподключайтесь с новым.

Один экземпляр бота

Одновременно живёт одно WebSocket-подключение на приложение: второй запущенный процесс выбьет первый.

Вебхук ​

Шаг 1. Задайте адрес и сохраните ключ подписи из ответа:

bash
curl -X PUT https://emotify.ru/api/v1/webhook \
  -H "Authorization: Bot $BOT_TOKEN" -H "Content-Type: application/json" \
  -d '{"url":"https://bot.example.com/emotify"}'

Шаг 2. Принимайте POST-запросы. Каждая доставка несёт заголовки:

http
POST /emotify HTTP/1.1
Content-Type: application/json
X-Emotify-Event: message.created
X-Emotify-Delivery: 7c1e5a90-…
X-Emotify-Signature: sha256=4f0b12…

В теле — событие в формате выше.

Проверка подписи ​

Подпись — HMAC-SHA256 от тела запроса вашим ключом. Проверяйте её всегда: адрес вебхука публичный, и без проверки он примет что угодно от кого угодно.

js
import { createHmac, timingSafeEqual } from 'node:crypto'

function isValid(rawBody, signatureHeader, secret) {
  const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`
  const a = Buffer.from(expected)
  const b = Buffer.from(signatureHeader ?? '')
  return a.length === b.length && timingSafeEqual(a, b)
}

Считайте HMAC от сырого тела

Не от результата JSON.parse → JSON.stringify: порядок ключей и пробелы изменятся, и подпись никогда не сойдётся. Читайте тело запроса как байты.

То же на Python:

python
import hashlib, hmac

def is_valid(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header or "")

Ответ и повторы ​

  • Отвечайте 2xx быстро — лучше сразу, а обработку события делайте после ответа.
  • На ошибку или таймаут Emotify повторит доставку с нарастающими паузами.
  • Серия неудач отключает вебхук. Проверить: GET /webhook → "active": false. После починки задайте адрес заново через PUT — доставки возобновятся.
  • X-Emotify-Delivery уникален для каждой доставки — по нему удобно отсекать дубли, если повтор пришёл после того, как ваш ответ потерялся в сети.

Полный работающий приёмник с проверкой подписи — на странице эхо-бот целиком.

Документация внешнего API Emotify