Skip to content

Бот-модератор целиком ​

Бот слушает комнаты канала и по команде /кик <ник> выбрасывает участника из голосовой комнаты.

Кик из голосовой ≠ исключение с канала

DELETE /channels/{id}/members/{id}/voice — выбросить из комнаты: человек остаётся на канале и может зайти снова, это мера «остынь».

DELETE /channels/{id}/members/{id} — исключить с канала целиком.

Это разные маршруты и разные права. Бот ниже делает только первое.

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

Что понадобится ​

Ключ со scope (как выбрать):

ScopeЗачем
eventsслышать команду
messages:writeотвечать
channel:readнайти участника по нику
moderationсобственно отключение
commandsобъявить /кик в подсказке у поля ввода (необязательно)

Роль посильнее «Бота». Роль по умолчанию прав модерации не даёт вовсе — попросите администратора канала выдать боту роль с правом kick_users_voice_room (настройки канала → Участники → бот → роль). Подробности: права на канале.

Ещё нужен Node 18+ и два пакета:

bash
npm i centrifuge ws

Код ​

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

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

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

const headers = { Authorization: `Bot ${TOKEN}`, 'Content-Type': 'application/json' }

async function reply(channelId, roomId, text) {
  const res = await fetch(`${API}/rooms/${roomId}/messages`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ channelId, text })
  })

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

// ⚠️ `text` приходит РАЗМЕТКОЙ. Человек, выделивший ник жирным (или кликнувший
// по нему в чате), пришлёт «/кик **Ник**» — и поиск по строке `**Ник**`
// не найдёт никого. Снимаем только парные обёртки вокруг всего значения:
// подчёркивания внутри ников законны, и `my_nick` портить нельзя.
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
}

// Разбор команды: «/кик Ник» → «Ник».
// null — это не наша команда, пустая строка — команда без ника.
function parseCommand(text) {
  if (typeof text !== 'string') return null

  const trimmed = text.trim()
  if (!trimmed.toLowerCase().startsWith(COMMAND)) return null

  return stripFormatting(trimmed.slice(COMMAND.length))
}

// Поиск участника по нику.
// Сужает СЕРВЕР (`search`), точное совпадение требуем мы: серверный поиск
// подстрочный, и по «ан» вернётся и «Анна», и «Иван».
// ⚠️ Выгружать канал целиком и фильтровать у себя нельзя: `limit` ограничен
// сотней, и на канале в три сотни человек нужный в выдачу не попадёт.
async function findMembers(channelId, query) {
  const url = new URL(`${API}/channels/${channelId}/members`)
  url.searchParams.set('search', query)
  url.searchParams.set('limit', '100')

  const res = await fetch(url, { headers })
  if (!res.ok) throw new Error(`участники: ${res.status} ${await res.text()}`)

  const { members } = await res.json()
  const needle = query.toLowerCase()

  // Сверяем и общее имя, и ник на канале: в чате видно именно канальный,
  // а искать надо по тому, что человек видит глазами
  return members.filter((member) => {
    const names = [member.name, member.channelNickname].filter(Boolean)
    return names.some((name) => name.toLowerCase() === needle)
  })
}

async function kickFromVoice(channelId, userId) {
  const res = await fetch(`${API}/channels/${channelId}/members/${userId}/voice`, {
    method: 'DELETE',
    headers
  })

  if (res.status === 204) return { ok: true }

  const body = await res.json().catch(() => ({}))
  return { ok: false, status: res.status, message: body.message, requiredScope: body.requiredScope }
}

async function handleMessage(event) {
  const nick = parseCommand(event.data.message?.text)
  if (nick === null) return

  const { channelId } = event
  const { roomId } = event.data

  if (!nick) {
    await reply(channelId, roomId, `Кого выбросить? \`${COMMAND} Ник\``)
    return
  }

  try {
    const found = await findMembers(channelId, nick)

    if (found.length === 0) {
      await reply(channelId, roomId, `На канале нет участника с ником «${nick}».`)
      return
    }

    // ⚠️ Ники в Emotify не уникальны. Кикнуть наугад одного из тёзок хуже,
    // чем не кикнуть никого
    if (found.length > 1) {
      const ids = found.map((member) => `\`${member.id.slice(0, 8)}\``).join(', ')
      await reply(
        channelId,
        roomId,
        `Ник «${nick}» носят ${found.length} участника (${ids}). Уточните, кого именно.`
      )
      return
    }

    const target = found[0]
    const result = await kickFromVoice(channelId, target.id)

    if (result.ok) {
      await reply(channelId, roomId, `**${target.name}** отключён от голосовой комнаты.`)
      return
    }

    // Причину сообщает портал — она точнее любой догадки по коду ответа
    const hint = result.requiredScope
      ? `ключу не выдан scope «${result.requiredScope}»`
      : (result.message ?? `сервер ответил ${result.status}`)

    await reply(channelId, roomId, `Не вышло: ${hint}`)
  } catch (error) {
    console.error('Кик не выполнен:', error)
    await reply(channelId, roomId, 'Не смог выполнить команду — портал не ответил.')
  }
}

// Объявляем команду при старте: так её видно в подсказке у поля ввода
// и на экране установки бота на канал
async function announceCommands() {
  const res = await fetch(`${API}/commands`, {
    method: 'PUT',
    headers: { ...headers, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      commands: [
        { name: COMMAND, description: 'Отключить участника от голосовой', usage: '<ник>' }
      ]
    })
  })

  // Ключ без scope «commands» — бот работает, просто не показывается в меню
  if (!res.ok) console.warn('Команды не объявлены:', res.status, await res.text())
}

async function connect() {
  const res = await fetch(`${API}/events/token`, { headers })
  if (!res.ok) {
    // 403 с requiredScope: events — ключ выпущен без доступа к событиям
    console.error('Не получил токен подписки:', res.status, await res.text())
    process.exit(1)
  }

  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
      handleMessage(event).catch(console.error)
    })
    .subscribe()

  client.on('connected', () => console.log('Бот-модератор подключён. Команда:', COMMAND))
  client.on('error', (ctx) => console.error('Centrifugo:', ctx))
  client.connect()
}

announceCommands().catch(console.error)
connect().catch(console.error)

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

Как это выглядит ​

/кик MyTestGuest

MyTestGuest отключён от голосовой комнаты.

Отключённый получает уведомление с названием комнаты и именем того, кто его отключил, и остаётся участником канала.

Отказы, которые вы увидите ​

Бот показывает причину словами портала, а не кодом ответа — она точнее любой догадки:

СитуацияОтвет
Участник не в голосовой«Пользователь не находится в голосовой комнате этого канала»
Цель — владелец канала«Отключить владельца канала может только основатель»
Цель — основатель«Нельзя отключить основателя канала»
У роли бота нет права«Недостаточно прав для отключения от голосовых комнат»
Ключ без нужного scope«ключу не выдан scope „moderation“»

Различать эти два случая важно

Если в ответе есть requiredScope — дело в ключе, и лечится он перевыпуском у автора приложения. Если нет — не хватает права роли, и это к администратору канала. Подробнее: права на канале.

На что обратить внимание ​

Тёзки — отдельный случай, а не мелочь. Ники в Emotify не уникальны: одно имя могут носить полтора десятка человек. Бот, который кикает первого попавшегося из совпадений, рано или поздно выбросит не того — и сделает это молча.

Точное совпадение требует бот, а не сервер. Параметр search ищет подстроку: по «ан» вернётся и «Анна», и «Иван». Сервер здесь сужает выборку, а решение «это тот самый человек» принимает ваш код.

Разметку снимать обязательно. message.text — это Markdown; человек, выделивший ник жирным, пришлёт **Ник**. Подробности — на странице событий.

Права проверяет портал, а не бот. Своей проверки «можно ли мне кикать» в коде нет и не нужно: она была бы второй правдой и разошлась бы с настоящей при первой же смене роли.

Куда развивать ​

  • Мут вместо кика — PUT /channels/{id}/members/{id}/voice-mute с причиной и сроком: мера мягче, а право отдельное (mute_users_voice_room).
  • Перенос в «тихую» комнату — PUT /channels/{id}/members/{id}/room вместо отключения.
  • Автомодерация по событиям — слушать voice.member_joined и применять правила канала автоматически.
  • Журнал — все действия модерации портал уже пишет в журнал канала, дублировать их сообщениями в чат не нужно.

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