Оформление
Рецепты
Готовые куски кода под частые задачи. Во всех примерах используется такая обёртка:
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, как показано на странице ключей и лимитов.