Оформление
Бот-модератор целиком
Бот слушает комнаты канала и по команде /кик <ник> выбрасывает участника из голосовой комнаты.
Кик из голосовой ≠ исключение с канала
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)Как это выглядит
/кик MyTestGuestMyTestGuest отключён от голосовой комнаты.
Отключённый получает уведомление с названием комнаты и именем того, кто его отключил, и остаётся участником канала.
Отказы, которые вы увидите
Бот показывает причину словами портала, а не кодом ответа — она точнее любой догадки:
| Ситуация | Ответ |
|---|---|
| Участник не в голосовой | «Пользователь не находится в голосовой комнате этого канала» |
| Цель — владелец канала | «Отключить владельца канала может только основатель» |
| Цель — основатель | «Нельзя отключить основателя канала» |
| У роли бота нет права | «Недостаточно прав для отключения от голосовых комнат» |
| Ключ без нужного 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и применять правила канала автоматически. - Журнал — все действия модерации портал уже пишет в журнал канала, дублировать их сообщениями в чат не нужно.