Primeiro bot
Do zero ao bot respondendo num canal. São cinco passos, e o terceiro é o que costuma surpreender quem vem do Discord.
1. Criando a aplicação
No Concord, um bot nasce de uma aplicação — e a aplicação já cria a conta dele. O id da
aplicação é o id desse usuário, como no Discord (application_id == bot user id).
- Abra o portal de desenvolvedores.
- Dê um nome e clique em Criar aplicação. A conta do bot é criada junto, com um
@próprio. - Escreva uma descrição: é ela que aparece na tela de autorização, abaixo do nome.
A conta do bot é um usuário de verdade: entra na lista de membros com a etiqueta BOT, recebe cargo, fala no chat e entra na sala de voz pelos mesmos caminhos de qualquer pessoa.
2. Pegando o token
Ainda no portal, abra a sua aplicação e clique em Gerar token novo.
O token aparece uma vez só. Copie na hora: ele não volta a ser mostrado, e gerar outro derruba o bot que estiver usando o anterior.
export CONCORD_TOKEN='MjY.8e5cfaf3b7434e2ca647…'
Guarde no cofre de variáveis do seu deploy, nunca no repositório. Se vazar, volte ao portal e gere outro: o antigo morre na hora e o servidor derruba o WebSocket em no máximo 5 segundos, que é o intervalo em que ele revalida a credencial.
Como o token viaja
No handshake do WebSocket, o concord.js manda o token de duas formas ao mesmo tempo:
- no cabeçalho
Authorization: Bot <token>— é o caminho que o servidor resolve primeiro; - repetido no primeiro frame,
{"token":"Bot <token>"}, como reserva para ambientes em que não dá para definir cabeçalho no handshake.
O primeiro frame é obrigatório e tem 10 segundos de prazo. O SDK cuida disso; você só precisa
saber que é assim para entender o log de debug.
Bot e não BearerBearer é para token de sessão, de quem fez login com senha. Conta de aplicação não tem senha,
então não existe sessão para ela — a credencial dela é o token do portal. Se você estiver
automatizando a conta de uma pessoa, passe tokenType: 'session' ao Client e use Bearer.
3. Pondo o bot num servidor
Pelo link de autorização, montado no portal em Link de convite. Marque ali as permissões que o bot precisa e copie o endereço:
https://app.concord.win/oauth2/authorize?client_id=<id da aplicação>&scope=bot&permissions=<bits>
Quem abrir esse link vê quem está pedindo, o que está pedindo e escolhe em que servidor instalar. Ao autorizar, o bot entra como membro e ganha um cargo gerenciado com exatamente as permissões concedidas.
Duas regras que valem a pena conhecer:
- Ninguém dá o que não tem. A tela mostra em separado o que vai faltar quando quem autoriza não
tem alguma das permissões pedidas.
ADMINsó o dono do servidor entrega. - O cargo é da aplicação. Não dá para editar nem apagar à mão, e ele sai junto quando alguém remove a aplicação em Configurações do servidor → Integrações.
Para um bot de música, peça VIEW_CHANNEL, SEND_MESSAGES, CONNECT e SPEAK.
Depois de instalado, server.me?.permissions diz o que o bot realmente recebeu:
const server = client.servers.cache.first();
console.log(server?.me?.permissions.has(PermissionFlags.SPEAK));
Também dá para entrar por convite comum, como uma pessoa faria:
const server = await client.servers.join('CODIGO-DO-CONVITE');
console.log(`entrei em ${server.name}`);
Em servidor marcado como público, dá para entrar sem convite:
const publicos = await client.fetchDiscover();
await client.servers.joinPublic(publicos[0].id);
Depois disso, dê ao bot um cargo com as permissões de que ele precisa. Um bot de música, por
exemplo, precisa de VIEW_CHANNEL, SEND_MESSAGES, CONNECT e SPEAK. A lista completa de bits
está em Permissões.
4. Escrevendo o bot
Três arquivos.
import { Injectable } from '@nestjs/common';
import {
On, Once, SlashCommand, Ctx, Opt,
InjectConcordClient,
type EventContext, type SlashCommandContext,
} from 'nescord';
import { PermissionFlags, type Client } from 'concord.js';
@Injectable()
export class PingHandler {
constructor(@InjectConcordClient() private readonly client: Client) {}
@Once('ready')
onReady(@Ctx() [client]: EventContext<'ready'>) {
// `client.user` já existe aqui: o `ready` só dispara depois do primeiro `state`,
// ou seja, com os caches de servidores, canais e membros preenchidos.
console.log(`Conectado como @${client.user!.username} em ${client.servers.size} servidor(es)`);
client.setStatus('ouvindo /ping');
}
@On('messageCreate')
async onMessage(@Ctx() [message]: EventContext<'messageCreate'>) {
// Sem isto o bot responde a si mesmo e entra em laço: o servidor reemite o
// `chat` para todos, inclusive o autor.
if (message.fromMe) return;
if (message.content.toLowerCase() === 'oi bot') {
await message.reply('oi!');
}
}
@SlashCommand({ name: 'ping', description: 'Mede a ida e volta até o gateway' })
async ping(@Ctx() [ctx]: SlashCommandContext) {
await ctx.reply(`pong em ${this.client.ping} ms`);
}
@SlashCommand({
name: 'eco',
description: 'Repete o que você escreveu',
// 'rest' engole o resto da linha — serve para frase, link e caminho com espaço.
options: [{ name: 'texto', description: 'O que repetir', type: 'rest', required: true }],
permissions: [PermissionFlags.SEND_MESSAGES],
})
async eco(@Ctx() [ctx]: SlashCommandContext, @Opt('texto') texto: string) {
await ctx.send(texto);
}
}
import { Module } from '@nestjs/common';
import { NescordModule } from 'nescord';
import { PingHandler } from './ping.handler.js';
@Module({
imports: [
NescordModule.forRoot({
// Sem token no código: só variável de ambiente.
token: process.env.CONCORD_TOKEN,
baseUrl: process.env.CONCORD_URL ?? 'https://app.concord.win',
// Vazio é "online". O servidor só aceita '', 'idle', 'dnd' e 'invisible'.
presence: '',
commands: { prefix: '/', ignoreBots: true },
}),
],
providers: [PingHandler],
})
export class AppModule {}
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
// Sem servidor HTTP: o bot é só processo.
const app = await NestFactory.createApplicationContext(AppModule, { bufferLogs: true });
// SIGINT/SIGTERM fecham o gateway e saem da sala de voz antes de encerrar.
app.enableShutdownHooks();
}
void bootstrap();
O NescordModule só chama login() no onApplicationBootstrap, depois que o explorer ligou todos
os handlers. É por isso que nenhum evento se perde entre o ready e o registro dos métodos.
5. Rodando
export CONCORD_TOKEN="…"
export CONCORD_URL="http://localhost:3000"
pnpm tsx src/main.ts
No canal, escreva oi bot e depois /ping. Se as duas respostas vierem, está funcionando.
Quando não vem
| Sintoma | Causa provável |
|---|---|
Sessão inválida, entre de novo e a conexão fecha | O token não é um token de sessão de 64 hex — releia o passo 2 |
| O bot conecta e cai sozinho em poucos segundos | Outra sessão do mesmo usuário abriu (replaced). Feche o outro terminal, ou a janela do navegador logada como o bot |
ready nunca dispara | O primeiro frame não chegou em 10 s, ou o state não veio. Ligue o evento debug e veja o handshake |
O bot vê a mensagem mas reply estoura TimeoutError | O servidor não devolve id de mensagem: o SDK espera o chat reemitido. Se o bot não tem VIEW_CHANNEL no canal, ele nunca vê o eco |
GatewayError com código de permissão | Falta um bit no cargo do bot. Veja Permissões |
A outra credencial: instalação por servidor
Existe um segundo tipo de credencial no Concord, e confundir as duas custa uma hora de depuração.
| Token da aplicação | Credencial de instalação | |
|---|---|---|
| Cabeçalho | Authorization: Bot <token> | Authorization: Bot <token> |
| De onde vem | portal de desenvolvedores | POST /api/servers/{id}/bots |
| Alcance | A conta inteira, em todos os servidores | Um servidor, numa lista fixa de canais |
| Limitada por escopos | Não | Sim: read_channels, send_messages, commands, propose_changes |
Serve no gateway /ws | Sim | Não |
Serve no SFU /api/sfu/* | Sim | Não |
| Rotas | /api/* em geral | só /api/bots/* |
A instalação é criada pelo dono do servidor e some se a propriedade do servidor mudar de mão. Ela é útil para integração de fora — um script de CI que posta no canal de deploy, por exemplo — sem entregar uma conta inteira:
curl -X POST "$CONCORD_URL/api/servers/42/bots" \
-H "Authorization: Bearer $TOKEN_DO_DONO" \
-H 'Content-Type: application/json' \
-d '{"name":"CI","scopes":["send_messages"],"channels":[101]}'
# -> {"id":"…","token":"…","authorization":"Bot","name":"CI"}
No SDK, ela entra separada e só alimenta rest.bots:
const client = new Client({
token: process.env.CONCORD_TOKEN,
installToken: process.env.CONCORD_INSTALL_TOKEN,
});
// `requestId` é idempotente e obrigatório: o mesmo id com o mesmo conteúdo
// devolve `cached: true` em vez de postar duas vezes.
await client.rest.bots.sendMessage({
channel: 101,
text: 'deploy da v2.3.1 no ar',
requestId: `deploy-2-3-1`,
});
Mensagem postada assim aparece com message.bot preenchido, e o message.authorId é o dono da
instalação, não o bot. É um detalhe que importa quando você filtra autoria.
Para um canal que só recebe avisos, há um caminho ainda mais simples — os webhooks:
await client.rest.webhooks.execute(id, token, { content: 'build verde', username: 'CI' });