Skip to content

Эхо-бот целиком ​

Готовый бот, который отвечает на каждое сообщение в комнате. Два варианта — выберите по тому, как хотите получать события:

  • через WebSocket — проще, работает с любой машины, с него и стоит начать;
  • через вебхук — если у бота есть публичный https-адрес.

Перед запуском: создайте приложение и установите его на канал.

Вариант: WebSocket ​

Нужен Node 18+ и два пакета:

bash
npm i centrifuge ws
js
// echo-bot.mjs
// Запуск: BOT_TOKEN=emb_ваш_ключ node echo-bot.mjs
import { Centrifuge } from 'centrifuge'
import WebSocket from 'ws'

const API = 'https://emotify.ru/api/v1'
const TOKEN = process.env.BOT_TOKEN

if (!TOKEN) {
  console.error('Задайте переменную окружения BOT_TOKEN')
  process.exit(1)
}

// Отправка ответа в комнату
async function reply(channelId, roomId, text) {
  const res = await fetch(`${API}/rooms/${roomId}/messages`, {
    method: 'POST',
    headers: { Authorization: `Bot ${TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ channelId, text })
  })

  if (!res.ok) {
    // 403 — у роли бота нет права писать; 429 — превышена квота 20 сообщений/мин
    console.error('Не удалось ответить:', res.status, await res.text())
  }
}

// Подключение к событиям
async function connect() {
  const res = await fetch(`${API}/events/token`, {
    headers: { Authorization: `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') return

      const { roomId, message } = event.data
      // Свои сообщения боту не приходят, поэтому зацикливания не будет
      reply(event.channelId, roomId, `Вы сказали: ${message.text}`).catch(console.error)
    })
    .subscribe()

  client.connect()
  console.log('Эхо-бот подключён и слушает события')
}

connect().catch(console.error)

// Токен подписки живёт 6 часов — обновляемся заранее
setInterval(() => connect().catch(console.error), 5 * 60 * 60 * 1000)

Запустите и напишите что-нибудь в комнате канала, где установлен бот, — он ответит.

Вариант: вебхук ​

Нужен Node 18+, зависимостей нет. И нужен публичный https-адрес — для отладки на своей машине поднимите туннель:

bash
ngrok http 8787

Шаг 1. Скажите Emotify, куда слать события, и сохраните secret из ответа:

bash
curl -X PUT https://emotify.ru/api/v1/webhook \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://ваш-адрес/emotify"}'

Шаг 2. Запустите приёмник:

js
// echo-bot-webhook.mjs
// Запуск: BOT_TOKEN=emb_… WEBHOOK_SECRET=… node echo-bot-webhook.mjs
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'

const API = 'https://emotify.ru/api/v1'
const TOKEN = process.env.BOT_TOKEN
const SECRET = process.env.WEBHOOK_SECRET
const PORT = Number(process.env.PORT ?? 8787)

if (!TOKEN || !SECRET) {
  console.error('Задайте переменные BOT_TOKEN и WEBHOOK_SECRET')
  process.exit(1)
}

// Проверка подписи — обязательна: адрес публичный, и без неё он примет
// что угодно от кого угодно. HMAC считается от СЫРОГО тела запроса.
function isSignatureValid(rawBody, header) {
  const expected = `sha256=${createHmac('sha256', SECRET).update(rawBody).digest('hex')}`
  const a = Buffer.from(expected)
  const b = Buffer.from(header ?? '')
  return a.length === b.length && timingSafeEqual(a, b)
}

async function reply(channelId, roomId, text) {
  const res = await fetch(`${API}/rooms/${roomId}/messages`, {
    method: 'POST',
    headers: { Authorization: `Bot ${TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ channelId, text })
  })

  if (!res.ok) {
    console.error('Не удалось ответить:', res.status, await res.text())
  }
}

function handleEvent(event) {
  if (event.event !== 'message.created') return

  const { roomId, message } = event.data
  reply(event.channelId, roomId, `Вы сказали: ${message.text}`).catch(console.error)
}

createServer((req, res) => {
  if (req.method !== 'POST') {
    res.writeHead(405).end()
    return
  }

  const chunks = []
  req.on('data', (chunk) => chunks.push(chunk))
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks)

    if (!isSignatureValid(rawBody, req.headers['x-emotify-signature'])) {
      res.writeHead(401).end()
      return
    }

    // Отвечаем сразу, обрабатываем после: медленный ответ Emotify считает
    // неудачей и повторяет доставку, а серия неудач отключает вебхук
    res.writeHead(204).end()

    try {
      handleEvent(JSON.parse(rawBody.toString('utf8')))
    } catch (error) {
      console.error('Событие не разобрано:', error)
    }
  })
}).listen(PORT, () => console.log(`Эхо-бот слушает порт ${PORT}`))

Идеи для развития ​

  • Команды. Префикс на платформе один — /. Реагируйте только на сообщения, которые с него начинаются:

    js
    if (!message.text.startsWith('/')) return
    const [command, ...args] = message.text.slice(1).split(' ')

    ⚠️ И объявите список через PUT /commands — иначе портал о ваших командах не знает и людям их не покажет: ни кнопкой у поля ввода, ни подсказкой по /. Сам эхо-бот команд не имеет — он отвечает на обычную речь, и объявлять ему нечего.

  • Реакция вместо ответа — иногда уместнее молча поставить 👍:

    js
    await fetch(`${API}/rooms/${roomId}/messages/${message.id}/reactions`, {
      method: 'POST',
      headers: { Authorization: `Bot ${TOKEN}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ channelId, emoji: '👍' })
    })
  • Голос. POST /rooms/{id}/voice даёт токен LiveKit — дальше публикуйте звук через @livekit/rtc-node.

  • Сайт с входом через Emotify — страница OAuth.

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