Skip to content

Рецепты ​

Готовые куски кода под частые задачи. Во всех примерах используется такая обёртка:

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

async function api(path, options = {}) {
  const res = await fetch(API + path, {
    ...options,
    headers: {
      Authorization: `Bot ${TOKEN}`,
      'Content-Type': 'application/json',
      ...options.headers
    }
  })

  const text = await res.text()
  if (!res.ok) throw new Error(`${path} → ${res.status}: ${text}`)
  return text ? JSON.parse(text) : null
}

Найти комнату по имени ​

Идентификаторы комнат не нужно вписывать в код — бот находит их сам:

js
const { channels } = await api('/me')
const channelId = channels[0].id

const { rooms } = await api(`/channels/${channelId}/rooms`)
const general = rooms.find((room) => room.type === 'text' && room.name === 'Общий')

await api(`/rooms/${general.id}/messages`, {
  method: 'POST',
  body: JSON.stringify({ channelId, text: 'Бот на связи' })
})

Посчитать людей, не считая ботов ​

Боты — такие же участники канала и попадают в total. Если нужны именно люди, отфильтруйте по isBot:

js
const { members, total, online } = await api(`/channels/${channelId}/members`)

const people = members.filter((m) => !m.isBot)
const bots = members.filter((m) => m.isBot)

console.log(`Всего участников: ${total}, в сети: ${online}`)
console.log(`Из них ботов: ${bots.length}`)

Когда total точен, а когда нет

total и online — счётчики всего канала и всегда точны. А вот members на больших каналах приходит не целиком (офлайновая часть постранично), поэтому members.length может быть меньше total. Для счётчиков берите total/online, для разбора по признакам — то, что пришло в members.

Отчёт о канале в чат ​

Собрать сведения и написать их в комнату — всё, что для этого нужно, бот узнаёт сам:

js
const { channels, bot } = await api('/me')
const ref = channels[0]

const channel = await api(`/channels/${ref.id}`)
const { rooms, categories } = await api(`/channels/${ref.id}/rooms`)
const members = await api(`/channels/${ref.id}/members`)

const textRooms = rooms.filter((r) => r.type === 'text')
const voiceRooms = rooms.filter((r) => r.type === 'voice')
const people = members.members.filter((m) => !m.isBot).length

const report = [
  `**${channel.name}**`,
  channel.description ? `_${channel.description}_` : '',
  ``,
  `Участников: ${members.total} (в сети ${members.online})`,
  `Комнат: текстовых ${textRooms.length}, голосовых ${voiceRooms.length}`,
  categories.length ? `Категории: ${categories.map((c) => c.name).join(', ')}` : '',
  ``,
  `**Голосовые сейчас:**`,
  ...voiceRooms.map((room) => {
    const who = room.users.length
      ? room.users.map((u) => u.channelNickname ?? u.name).join(', ')
      : 'пусто'
    return `• ${room.name} — ${who}`
  })
]
  .filter(Boolean)
  .join('\n')

await api(`/rooms/${textRooms[0].id}/messages`, {
  method: 'POST',
  body: JSON.stringify({ channelId: channel.id, text: report })
})

Результат в чате:

Тестовый сервак
О канале

Участников: 4 (в сети 3)
Комнат: текстовых 1, голосовых 1

Голосовые сейчас:
• Гостинная — Z3oM

Живое сообщение вместо десяти ​

Бот пишет сообщение один раз и обновляет его — комната не засоряется:

js
const { id } = await api(`/rooms/${roomId}/messages`, {
  method: 'POST',
  body: JSON.stringify({ channelId, text: 'Сборка: запущена…' })
}).then((r) => r)

for (const step of ['тесты', 'линтер', 'публикация']) {
  await doStep(step)

  await api(`/rooms/${roomId}/messages/${id}`, {
    method: 'PATCH',
    body: JSON.stringify({ channelId, text: `Сборка: ${step} ✅` })
  })
}

⚠️ Править своё сообщение можно 30 минут после отправки — ограничение общее для людей и ботов. Для долгих процессов отправляйте новое сообщение, когда окно закрывается.

Убрать за собой ​

Бот удаляет свои служебные сообщения, чтобы не копить мусор:

js
const { id } = await postTemporaryNotice()

setTimeout(() => {
  api(`/rooms/${roomId}/messages/${id}`, {
    method: 'DELETE',
    body: JSON.stringify({ channelId })
  }).catch(console.error)
}, 60_000)

Удалять бот может только свои сообщения: чужие требуют права delete_text_msg_users, которого у роли «Бот» нет.

Приветствовать входящих в голосовую ​

js
client.newSubscription(channel).on('publication', async ({ data: event }) => {
  if (event.event !== 'voice.member_joined') return

  const { rooms } = await api(`/channels/${event.channelId}/rooms`)
  const room = rooms.find((r) => r.id === event.data.roomId)

  await api(`/rooms/${textRoomId}/messages`, {
    method: 'POST',
    body: JSON.stringify({
      channelId: event.channelId,
      text: `${event.data.user.name} зашёл в «${room.name}»`
    })
  })
})

Голосование реакциями ​

totalCount приходит уже посчитанным — считать самому не нужно:

js
const POLL_MESSAGE = '3f8a1c62-…'

if (event.event === 'reaction.added' && event.data.messageId === POLL_MESSAGE) {
  const { emoji, totalCount, user } = event.data
  console.log(`${user.name} выбрал ${emoji}, всего таких голосов: ${totalCount}`)

  if (emoji === '✅' && totalCount >= 5) {
    await api(`/rooms/${event.data.roomId}/messages`, {
      method: 'POST',
      body: JSON.stringify({ channelId: event.channelId, text: 'Набрано 5 голосов — решено!' })
    })
  }
}

Снятие реакции приходит как reaction.removed с тем же набором полей — голос можно и отозвать.

Следить за правками ​

Бот-зеркало или бот-аудит должен знать, что сообщение изменили:

js
if (event.event === 'message.updated') {
  const { message } = event.data
  console.log(`${message.author.name} исправил сообщение на: ${message.text}`)
}

if (event.event === 'message.deleted') {
  // Текста здесь уже нет — сохраните его при message.created, если он нужен
  console.log(`${event.data.message.author.name} удалил сообщение`)
}

⚠️ Собственных правок бот не получает, так что реагировать правкой на правку безопасно — петли не будет.

Выйти из голосовой, когда остался один ​

Полезно музыкальному боту: играть пустой комнате незачем.

js
if (event.event === 'voice.member_left') {
  const { rooms } = await api(`/channels/${event.channelId}/rooms`)
  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' })
  }
}

Команды с префиксом ​

text — это Markdown

Человек, выделивший аргумент жирным, пришлёт **Москва**. Снимайте разметку перед использованием — подробности и готовая функция на странице событий.

Один список — и для объявления, и для разбора. Так объявленное и обрабатываемое не разъедутся: добавили команду в объект — она и в меню у людей, и в switch.

js
// Единственный источник правды о том, что умеет бот
const COMMANDS = {
  ping: {
    description: 'Проверка связи',
    run: (event) => reply(event, 'понг')
  },
  кто: {
    description: 'Кто сейчас в сети',
    run: async (event) => {
      const { members } = await api(`/channels/${event.channelId}/members`)
      const online = members.filter((m) => m.online && !m.isBot).map((m) => m.name)
      await reply(event, online.length ? `В сети: ${online.join(', ')}` : 'Никого нет')
    }
  }
}

// Объявляем при старте: без этого портал о командах не знает и людям
// их не покажет — ни кнопкой у поля ввода, ни подсказкой по «/»
async function announceCommands() {
  await api('/commands', {
    method: 'PUT',
    body: JSON.stringify({
      commands: Object.entries(COMMANDS).map(([name, { description }]) => ({
        name: `/${name}`,
        description
      }))
    })
  })
}

// Разбор входящего сообщения
async function handleMessage(event) {
  if (event.event !== 'message.created') return

  const text = event.data.message.text.trim()
  if (!text.startsWith('/')) return

  const [name, ...rest] = text.slice(1).split(/\s+/)
  const argument = stripFormatting(rest.join(' '))

  const command = COMMANDS[name.toLowerCase()]
  if (command) await command.run(event, argument)
}

function reply(event, text) {
  return api(`/rooms/${event.data.roomId}/messages`, {
    method: 'POST',
    body: JSON.stringify({ channelId: event.channelId, text })
  })
}

⚠️ Объявлять обязательно, если бот реагирует на команды. Незарегистрированной команды для портала не существует: люди не читают документацию к боту, они жмут кнопку у поля ввода. Подробности и случай «команд нет вовсе» — раздел «Команды».

Отключить участника от голосовой по нику ​

Команда /кик <ник> выбрасывает человека из голосовой комнаты. С канала он при этом не выходит — это разные действия и разные права.

Нужен scope moderation у ключа, channel:read для поиска участника и право kick_users_voice_roomу роли бота.

js
async function kickByNick(event, nick) {
  const { channelId } = event
  const { roomId } = event.data

  // Сужает СЕРВЕР, точное совпадение требуем мы: поиск подстрочный,
  // и по «ан» вернётся и «Анна», и «Иван»
  const url = `/channels/${channelId}/members?search=${encodeURIComponent(nick)}&limit=100`
  const { members } = await api(url)

  const needle = nick.toLowerCase()
  const found = members.filter((member) =>
    [member.name, member.channelNickname]
      .filter(Boolean)
      .some((name) => name.toLowerCase() === needle)
  )

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

  // ⚠️ Ники не уникальны. Кикнуть наугад одного из тёзок хуже, чем не кикнуть никого
  if (found.length > 1) {
    return reply(event, `Ник «${nick}» носят ${found.length} участника. Уточните, кого именно.`)
  }

  const target = found[0]
  const res = await fetch(`${API}/channels/${channelId}/members/${target.id}/voice`, {
    method: 'DELETE',
    headers: { Authorization: `Bot ${TOKEN}` }
  })

  if (res.status === 204) {
    return reply(event, `**${target.name}** отключён от голосовой комнаты.`)
  }

  // Причину сообщает портал — она точнее любой догадки по коду ответа
  const body = await res.json().catch(() => ({}))
  return reply(event, `Не вышло: ${body.message ?? res.status}`)
}

Типичные ответы портала, которые стоит показать человеку как есть:

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

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

DELETE /channels/{id}/members/{id}/voice — выбросить из комнаты, человек остаётся на канале и может зайти снова. DELETE /channels/{id}/members/{id} — исключить с канала целиком. Права у них тоже разные.

Листать историю до конца ​

js
async function* allMessages(channelId, roomId) {
  let beforeId

  while (true) {
    const query = new URLSearchParams({ channelId, limit: '100' })
    if (beforeId) query.set('beforeId', beforeId)

    const { messages, hasMore } = await api(`/rooms/${roomId}/messages?${query}`)
    yield* messages

    if (!hasMore || messages.length === 0) return
    beforeId = messages[messages.length - 1].id
  }
}

for await (const message of allMessages(channelId, roomId)) {
  console.log(message.author.name, message.text)
}

⚠️ Помните про квоту: 60 запросов в минуту. Полное листание большой комнаты упрётся в неё — добавьте паузу или обрабатывайте 429, как показано на странице ключей и лимитов.

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