Оформление
События
Чтобы бот реагировал на происходящее, 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уникален для каждой доставки — по нему удобно отсекать дубли, если повтор пришёл после того, как ваш ответ потерялся в сети.
Полный работающий приёмник с проверкой подписи — на странице эхо-бот целиком.