Bot de música
Este é o exemplo que valida a plataforma: um bot que toca som de verdade numa sala de voz do
Concord. É o mesmo desenho do apps/music do repositório, reduzido ao que cabe numa página.
O que ele faz: /toca, /proxima com voto, /lista, /pausa, /retoma, /vol, /encerra. Fila
por servidor, busca e download pelo yt-dlp, transcodificação pelo ffmpeg, e saída automática quando
a sala esvazia.
Com o prefixo /, estes nomes nunca chegam ao seu bot:
play tocar pause pausar resume continuar skip pular stop parar fila queue volume sair entrar ajuda
O servidor os consome antes de reemitir o chat (src/community/music.rs, função websocket,
chamada de src/ws.rs), em qualquer canal e sem depender de configuração — o bot de música nativo
do Concord fica com eles. É exatamente por isso que esta receita usa /toca, /proxima, /lista,
/pausa, /retoma, /vol e /encerra, e não os nomes óbvios. A outra saída é trocar o prefixo:
prefix: '!' deixa !tocar passar, porque a interceptação casa o token inteiro com a barra.
Tocar áudio exige SFU ligado no servidor. Se client.sfuEnabled for false, o Concord está em
malha P2P, as rotas /api/sfu/* devolvem 404 e o bot entra na sala mas não publica som. Leia o
aviso completo em Voz.
Requisitos
ffmpeg -version
yt-dlp --version
Permissões do cargo do bot: VIEW_CHANNEL, SEND_MESSAGES, ADD_REACTIONS, CONNECT, SPEAK —
553472 em decimal.
A fonte: yt-dlp
Duas funções. Uma resolve termo de busca ou URL em metadados; a outra abre o áudio como stream.
import { spawn } from 'node:child_process';
import type { Readable } from 'node:stream';
export interface Faixa {
url: string;
titulo: string;
duracao: number;
pediu: number;
pediuNome: string;
}
function rodar(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const p = spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let saida = '';
let erro = '';
p.stdout.on('data', (c) => (saida += c));
p.stderr.on('data', (c) => (erro += c));
// ENOENT aqui quer dizer yt-dlp fora do PATH; a mensagem precisa dizer isso.
p.on('error', (e) => reject(new Error(`yt-dlp não executou: ${e.message}`)));
p.on('close', (code) => (code === 0 ? resolve(saida) : reject(new Error(erro.trim() || 'yt-dlp falhou'))));
});
}
/** Aceita URL ou termo de busca. `ytsearch1:` evita baixar a lista inteira. */
export async function resolver(entrada: string): Promise<Omit<Faixa, 'pediu' | 'pediuNome'>> {
const alvo = /^https?:\/\//.test(entrada) ? entrada : `ytsearch1:${entrada}`;
const json = await rodar(['--dump-single-json', '--no-playlist', '--quiet', alvo]);
const dados = JSON.parse(json);
const item = dados.entries?.[0] ?? dados;
return {
url: item.webpage_url ?? entrada,
titulo: item.title ?? entrada,
duracao: Math.round(item.duration ?? 0),
};
}
/**
* Abre o áudio no stdout. Nada toca o disco: o ffmpeg do createAudioResource
* consome este stream direto.
*/
export function abrir(url: string): Readable {
const p = spawn('yt-dlp', ['-f', 'bestaudio/best', '-o', '-', '--quiet', '--no-playlist', url], {
stdio: ['ignore', 'pipe', 'ignore'],
});
return p.stdout;
}
A fila
Uma fila por servidor, guardada num Map. É aqui que vive o estado do bot.
import { Injectable } from '@nestjs/common';
import { InjectConcordClient } from 'nescord';
import type { Client, ServerId, UserId } from 'concord.js';
import { createAudioResource, type VoiceConnection } from 'concord.js/voice';
import { abrir, type Faixa } from './ytdlp';
interface Sessao {
conexao: VoiceConnection;
faixas: Faixa[];
atual: Faixa | null;
/** Quem já votou para pular a faixa atual. */
votos: Set<UserId>;
/** Timer de saída por inatividade; limpo a cada faixa nova. */
ocioso: NodeJS.Timeout | null;
}
const OCIOSO_MS = 2 * 60_000;
@Injectable()
export class FilaService {
private readonly sessoes = new Map<ServerId, Sessao>();
constructor(@InjectConcordClient() private readonly client: Client) {}
sessao(servidor: ServerId): Sessao | undefined {
return this.sessoes.get(servidor);
}
async enfileirar(conexao: VoiceConnection, faixa: Faixa): Promise<number> {
const sessao = this.sessoes.get(conexao.serverId) ?? this.abrirSessao(conexao);
sessao.faixas.push(faixa);
// Nada tocando: começa agora. Senão, a faixa espera a vez.
if (!sessao.atual) await this.proxima(conexao.serverId);
return sessao.faixas.length;
}
private abrirSessao(conexao: VoiceConnection): Sessao {
const sessao: Sessao = { conexao, faixas: [], atual: null, votos: new Set(), ocioso: null };
this.sessoes.set(conexao.serverId, sessao);
// `finish` é "terminou sozinho": não dispara em stop(), e é por isso que
// pular não encadeia duas faixas de uma vez.
conexao.player.on('finish', () => void this.proxima(conexao.serverId));
conexao.player.on('error', (err) => {
this.client.options.logger.warn(`falha ao tocar: ${err.message}`);
void this.proxima(conexao.serverId);
});
conexao.on('destroyed', () => this.sessoes.delete(conexao.serverId));
return sessao;
}
async proxima(servidor: ServerId): Promise<Faixa | null> {
const sessao = this.sessoes.get(servidor);
if (!sessao) return null;
sessao.votos.clear();
const faixa = sessao.faixas.shift() ?? null;
sessao.atual = faixa;
if (!faixa) {
// Fila vazia: marca para sair se ninguém pedir nada em dois minutos.
sessao.ocioso = setTimeout(() => void this.encerrar(servidor), OCIOSO_MS);
return null;
}
if (sessao.ocioso) {
clearTimeout(sessao.ocioso);
sessao.ocioso = null;
}
await sessao.conexao.play(
createAudioResource(abrir(faixa.url), {
inputType: 'arbitrary',
metadata: { titulo: faixa.titulo, pediu: faixa.pediu },
}),
{ mode: 'now' },
);
return faixa;
}
/** Voto por maioria simples de quem está na sala, sem contar o próprio bot. */
votar(servidor: ServerId, quem: UserId): { votos: number; preciso: number } {
const sessao = this.sessoes.get(servidor)!;
sessao.votos.add(quem);
const naSala = sessao.conexao.channel?.voiceStates.size ?? 1;
const ouvintes = Math.max(1, naSala - 1);
return { votos: sessao.votos.size, preciso: Math.ceil(ouvintes / 2) };
}
async encerrar(servidor: ServerId): Promise<void> {
const sessao = this.sessoes.get(servidor);
if (!sessao) return;
if (sessao.ocioso) clearTimeout(sessao.ocioso);
this.sessoes.delete(servidor);
// `disconnect` manda `leave` e destrói a mídia. `destroy` deixaria a vaga
// ocupada pelos 20 s da carência do servidor.
await sessao.conexao.disconnect();
}
}
Os comandos
import { Injectable } from '@nestjs/common';
import {
On, SlashCommand, Ctx, Opt, UseNescordGuards,
type EventContext, type SlashCommandContext,
} from 'nescord';
import { PermissionFlags } from 'concord.js';
import { FilaService } from './fila.service';
import { NaVozGuard } from './na-voz.guard';
import { resolver } from './ytdlp';
function mmss(segundos: number): string {
const m = Math.floor(segundos / 60);
const s = segundos % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
@Injectable()
export class MusicaHandler {
constructor(private readonly fila: FilaService) {}
@UseNescordGuards(NaVozGuard)
@SlashCommand({
// `play`/`tocar` são nomes reservados pelo servidor (veja o aviso no topo): `toca` passa.
name: 'toca',
description: 'Toca ou enfileira uma faixa',
aliases: ['p', 'musica'],
// 'rest' porque link do YouTube tem parâmetros e termo de busca tem espaço.
options: [{ name: 'fonte', description: 'URL ou termo de busca', type: 'rest', required: true }],
permissions: [PermissionFlags.CONNECT, PermissionFlags.SPEAK],
})
async play(@Ctx() [ctx]: SlashCommandContext, @Opt('fonte') fonte: string) {
// Reagir antes de resolver: o yt-dlp demora, e isto é o "recebi" mais barato
// que existe — não gasta uma das 60 mensagens por segundo do gateway.
await ctx.react('🔎');
const meta = await resolver(fonte);
// joinVoice manda `join` e espera o frame `joined`. Sem estar numa sala as
// rotas /api/sfu devolvem 403.
const conexao = await ctx.joinVoice();
const posicao = await this.fila.enfileirar(conexao, {
...meta,
pediu: ctx.authorId,
pediuNome: ctx.author?.displayName ?? 'alguém',
});
const sessao = this.fila.sessao(conexao.serverId)!;
await ctx.reply(
sessao.atual?.url === meta.url
? `▶️ **${meta.titulo}** (${mmss(meta.duracao)})`
: `➕ **${meta.titulo}** na fila, posição ${posicao}`,
);
}
@UseNescordGuards(NaVozGuard)
@SlashCommand({ name: 'proxima', description: 'Vota para pular a faixa atual', aliases: ['n'], cooldown: 2 })
async skip(@Ctx() [ctx]: SlashCommandContext) {
const sessao = this.fila.sessao(ctx.serverId!);
if (!sessao?.atual) return void (await ctx.reply('Não tem nada tocando.'));
// Quem pediu a faixa pula sozinho; os outros votam.
const sozinho = sessao.atual.pediu === ctx.authorId;
const { votos, preciso } = this.fila.votar(ctx.serverId!, ctx.authorId);
if (!sozinho && votos < preciso) {
return void (await ctx.reply(`🗳️ ${votos}/${preciso} para pular **${sessao.atual.titulo}**`));
}
const proxima = await this.fila.proxima(ctx.serverId!);
await ctx.reply(proxima ? `⏭️ Agora: **${proxima.titulo}**` : '⏭️ Fila vazia.');
}
@SlashCommand({ name: 'lista', description: 'Mostra a fila', aliases: ['q'] })
async queue(@Ctx() [ctx]: SlashCommandContext) {
const sessao = this.fila.sessao(ctx.serverId!);
if (!sessao?.atual) return void (await ctx.reply('Fila vazia.'));
const linhas = [
`▶️ **${sessao.atual.titulo}** — pedida por ${sessao.atual.pediuNome}`,
...sessao.faixas.slice(0, 10).map((f, i) => `${i + 1}. ${f.titulo} (${mmss(f.duracao)})`),
];
if (sessao.faixas.length > 10) linhas.push(`…e mais ${sessao.faixas.length - 10}`);
// Uma mensagem com várias linhas, nunca várias mensagens.
await ctx.reply(linhas.join('\n'));
}
@SlashCommand({ name: 'pausa', description: 'Pausa' })
async pause(@Ctx() [ctx]: SlashCommandContext) {
const ok = this.fila.sessao(ctx.serverId!)?.conexao.pause();
await ctx.react(ok ? '⏸️' : '🚫');
}
@SlashCommand({ name: 'retoma', description: 'Retoma', aliases: ['continua'] })
async resume(@Ctx() [ctx]: SlashCommandContext) {
const ok = this.fila.sessao(ctx.serverId!)?.conexao.resume();
await ctx.react(ok ? '▶️' : '🚫');
}
@SlashCommand({
name: 'vol',
description: 'Ajusta o volume',
options: [{ name: 'nivel', description: 'De 0 a 200 por cento', type: 'integer', required: true }],
})
async volume(@Ctx() [ctx]: SlashCommandContext, @Opt('nivel') nivel: number) {
const sessao = this.fila.sessao(ctx.serverId!);
if (!sessao) return void (await ctx.reply('Não estou tocando.'));
// O player aceita 0..2; o comando fala em porcentagem porque é o que a
// pessoa tem na cabeça.
sessao.conexao.player.setVolume(Math.min(200, Math.max(0, nivel)) / 100);
await ctx.react('🔊');
}
@SlashCommand({
name: 'encerra',
description: 'Para tudo e sai da sala',
permissions: [PermissionFlags.CONNECT],
})
async stop(@Ctx() [ctx]: SlashCommandContext) {
await this.fila.encerrar(ctx.serverId!);
await ctx.reply('Parei e saí.');
}
@On('voiceStateUpdate')
async onVoiceStateUpdate(@Ctx() [estado, anterior]: EventContext<'voiceStateUpdate'>) {
// Alguém saiu. Se sobrou só o bot, não há para quem tocar.
const canal = (estado ?? anterior)?.channel;
if (!canal) return;
const sessao = this.fila.sessao(canal.serverId);
if (!sessao || sessao.conexao.channelId !== canal.id) return;
if (canal.voiceStates.size <= 1) await this.fila.encerrar(canal.serverId);
}
}
O guard
import { Injectable } from '@nestjs/common';
import type { CommandContext, NescordGuard } from 'nescord';
@Injectable()
export class NaVozGuard implements NescordGuard {
canActivate(ctx: CommandContext): boolean {
// Pedir música sem estar na sala não faz sentido: o bot não saberia onde tocar.
return ctx.member?.voiceChannel != null;
}
}
Ligando tudo
import { Module } from '@nestjs/common';
import { NescordModule } from 'nescord';
import { FilaService } from './fila.service';
import { MusicaHandler } from './musica.handler';
import { NaVozGuard } from './na-voz.guard';
@Module({
imports: [
NescordModule.forRoot({
token: process.env.CONCORD_TOKEN,
baseUrl: process.env.CONCORD_URL ?? 'https://app.concord.win',
presence: '',
status: 'ouvindo /toca',
commands: { prefix: '/', ignoreBots: true },
}),
],
providers: [FilaService, MusicaHandler, NaVozGuard],
})
export class AppModule {}
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule, { bufferLogs: true });
// Sem isto, SIGTERM mata o processo com o bot ainda ocupando a vaga na sala —
// e ela só expira depois dos 20 s de carência do servidor.
app.enableShutdownHooks();
}
void bootstrap();
export CONCORD_TOKEN="…"
export CONCORD_URL="http://localhost:3000"
pnpm tsx src/main.ts
Decisões que valem copiar
Reagir antes de responder. /toca reage com 🔎 antes de chamar o yt-dlp. Resolver uma faixa
leva segundos; sem o sinal, parece que o bot ignorou. E reação não consome mensagem.
Uma mensagem, várias linhas. O gateway limita 60 mensagens por segundo por usuário. Um /lista
que manda uma mensagem por faixa chega perto do teto com uma fila de trinta.
Voto para pular, com exceção para quem pediu. Pular a música de outra pessoa é decisão coletiva; pular a sua é só desistir.
disconnect(), não destroy(), ao encerrar. disconnect() manda leave e libera a vaga na
hora. destroy() não manda, e a vaga fica presa pelos 20 segundos de carência — o que, para quem
está na sala, parece que o bot travou.
Sair quando a sala esvazia. Dois minutos sem fila, ou ninguém além do bot na sala, e ele sai. Bot que fica sozinho tocando é bot gastando banda do SFU para ninguém.
finish, não stateChange, para encadear. finish só dispara quando a faixa termina sozinha.
Se o encadeamento ouvisse stateChange para idle, um /proxima adiantaria duas faixas de uma vez.
O que falta para virar produção
Honestamente, isto é um exemplo. Para um bot que fica no ar:
- Fila persistente. O
Mapmorre com o processo. Um deploy no meio da música perde a fila. - Teto de duração e de tamanho de fila. Sem isso, alguém enfileira uma live de 8 horas.
- Cache de resolução. Chamar o yt-dlp duas vezes para a mesma URL é desperdício.
- Repetir e embaralhar. Faltam
/loope/shuffle. - Rate limit por pessoa. O
cooldowndo decorator cobre o comando, não o abuso de fila. - Métrica de falha. Quando o yt-dlp quebra por mudança de site, você quer saber antes de alguém reclamar.