Aparência
Organizando um bot grande
Um bot de 30 linhas cabe num arquivo. Um bot com comandos, páginas, moderação e DMs, não. Esta página mostra a organização do bot completo — use como ponto de partida.
A estrutura
bot-completo/
├─ .env ← o token (fora do Git)
├─ package.json
├─ scripts/
│ └─ link-de-instalacao.js ← mostra o link para adicionar o bot a um grupo
└─ src/
├─ index.js ← liga tudo: cliente, comandos, eventos
├─ golive/ ← falar com o Go Live (reutilizável em qualquer bot)
│ ├─ rest.js ← HTTP: token, JSON, 429, repetição
│ ├─ gateway.js ← WebSocket: register, heartbeat, reconexão
│ ├─ client.js ← junta os dois e cria eventos amigáveis
│ ├─ structures.js ← GroupMessage/DirectMessage com .reply(), .react()...
│ └─ permissions.js ← "fulano pode X?"
├─ lib/ ← recursos do bot
│ ├─ commands.js ← sistema de comandos
│ └─ paginator.js ← páginas por reação
└─ commands/ ← um arquivo por comando
├─ ping.js
├─ ajuda.js
└─ ...A ideia é separar camadas:
| Camada | Sabe de... | Não sabe de... |
|---|---|---|
golive/ | HTTP, WebSocket, formato dos eventos | comandos, regras do seu bot |
lib/ | como um comando ou paginador funciona | quais comandos existem |
commands/ | o que cada comando faz | detalhes de HTTP e WebSocket |
index.js | como ligar as peças | detalhes de cada peça |
Assim, trocar a forma de reconectar não mexe em nenhum comando, e criar um comando não exige entender o WebSocket.
O cliente: eventos amigáveis
O GoLiveClient transforma os eventos crus em eventos prontos para usar:
| Evento do cliente | Vem de | O que entrega |
|---|---|---|
ready / reconnected | registered | a conta do bot |
messageCreate | group-message | um GroupMessage com .reply(), .send(), .react(), .edit(), .delete() |
messageUpdate | group-message-updated | o GroupMessage como ficou (editedAt preenchido) |
messageDelete | group-message-deleted | { groupId, channelId, messageId } |
reactionAdd / reactionRemove | group-message-reactions + diff | { messageId, emoji, userId, ... } |
reactionUpdate | group-message-reactions | o estado completo |
typing | group-typing | quem está digitando |
directMessage | dm (só as recebidas) | um DirectMessage com .reply() |
groupAdd | group-added | { groupId, addedBy } — alguém adicionou o bot a um grupo |
groupUpdate / groupRemove | group-updated / group-removed | o grupo (e o motivo) |
raw | tudo | o evento como veio |
js
// O cliente do bot: junta a API HTTP (Rest) com a conexão em tempo real
// (Gateway) e transforma os eventos crus em eventos fáceis de usar.
//
// Eventos emitidos:
// ready (user) conectado e registrado pela 1ª vez
// reconnected (user) registrado de novo após uma queda
// messageCreate (GroupMessage) mensagem nova numa sala de texto
// messageUpdate (GroupMessage) o autor editou o texto (como ficou)
// messageDelete ({ groupId, channelId, messageId })
// reactionAdd ({ groupId, channelId, messageId, emoji, userId, reactions })
// reactionRemove ({ groupId, channelId, messageId, emoji, userId, reactions })
// reactionUpdate ({ groupId, channelId, messageId, reactions })
// typing ({ groupId, channelId, userId, name, typing })
// directMessage (DirectMessage) DM recebida (as enviadas pelo bot não)
// groupUpdate ({ groupId }) algo mudou no grupo
// groupAdd ({ groupId, addedBy }) alguém que gerencia um grupo adicionou o bot
// groupRemove ({ groupId, reason }) o bot saiu/foi removido do grupo
// raw (payload) todo evento, como veio
// disconnect / reconnecting / error / debug
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import { Rest } from "./rest.js";
import { Gateway } from "./gateway.js";
import { DirectMessage, GroupMessage } from "./structures.js";
import { hasPermission } from "./permissions.js";
// Quantas mensagens têm suas reações lembradas (para descobrir quem reagiu).
const MAX_TRACKED_MESSAGES = 2000;
// Por quanto tempo os dados de um grupo ficam em cache (invalidado antes em "group-updated").
const GROUP_CACHE_MS = 60_000;
export class GoLiveClient extends EventEmitter {
/** A conta do bot (resposta de GET /auth/me), disponível depois do login. */
user = null;
#groups = new Map();
#reactions = new Map();
#readyOnce = false;
constructor({ token, apiUrl, gatewayUrl } = {}) {
super();
if (!token) throw new Error("Defina o token do bot (variável GOLIVE_TOKEN).");
this.rest = new Rest({ token, baseUrl: apiUrl });
this.gateway = new Gateway({ token, url: gatewayUrl });
this.gateway.on("dispatch", (payload) => this.#dispatch(payload));
for (const name of ["error", "debug", "disconnect", "reconnecting"]) {
this.gateway.on(name, (arg) => this.emit(name, arg));
}
}
/** Confere o token, descobre quem é o bot e abre a conexão em tempo real. */
async login() {
const { account } = await this.rest.get("/auth/me");
if (!account.bot) {
this.emit("debug", "atenção: este token não é de uma conta bot");
}
this.user = account;
this.gateway.connect();
return account;
}
destroy() {
this.gateway.close();
}
// ─── Eventos ────────────────────────────────────────────────────────────
#dispatch(payload) {
this.emit("raw", payload);
switch (payload.type) {
case "registered":
this.emit(this.#readyOnce ? "reconnected" : "ready", this.user);
this.#readyOnce = true;
break;
case "group-message":
this.#rememberReactions(payload.message.id, payload.message.reactions ?? []);
this.emit("messageCreate", new GroupMessage(this, payload));
break;
case "group-message-updated":
// O evento não traz o autor; o nome é o que ele usava quando enviou.
this.emit(
"messageUpdate",
new GroupMessage(this, {
message: payload.message,
author: { id: payload.message.from, name: payload.message.fromName },
mentioned: payload.mentioned,
})
);
break;
case "group-message-deleted":
this.#reactions.delete(payload.messageId);
this.emit("messageDelete", payload);
break;
case "group-message-reactions":
this.#diffReactions(payload);
break;
case "group-typing":
this.emit("typing", payload);
break;
case "group-updated":
this.#groups.delete(payload.groupId);
this.emit("groupUpdate", payload);
break;
case "group-added":
this.emit("groupAdd", payload);
break;
case "group-removed":
this.#groups.delete(payload.groupId);
this.emit("groupRemove", payload);
break;
case "dm":
// O evento chega para os dois lados da conversa — inclusive quando é o
// próprio bot quem envia. Só repassamos as que outra pessoa mandou.
if (payload.message.from !== this.user?.id) {
this.emit("directMessage", new DirectMessage(this, payload));
}
break;
}
}
// O evento de reações traz a lista completa de quem está em cada emoji, não
// "fulano reagiu". Guardamos o último estado de cada mensagem e comparamos.
#rememberReactions(messageId, reactions) {
this.#reactions.delete(messageId);
this.#reactions.set(messageId, new Map(reactions.map((r) => [r.emoji, new Set(r.users)])));
if (this.#reactions.size > MAX_TRACKED_MESSAGES) {
this.#reactions.delete(this.#reactions.keys().next().value);
}
}
#diffReactions({ groupId, channelId, messageId, reactions }) {
const before = this.#reactions.get(messageId);
this.#rememberReactions(messageId, reactions);
const base = { groupId, channelId, messageId, reactions };
this.emit("reactionUpdate", base);
// Uma mensagem que o bot não viu chegar: não dá para saber o que mudou.
if (!before) return;
const after = this.#reactions.get(messageId);
for (const [emoji, users] of after) {
const old = before.get(emoji);
for (const userId of users) {
if (!old?.has(userId)) this.emit("reactionAdd", { ...base, emoji, userId });
}
}
for (const [emoji, users] of before) {
const now = after.get(emoji);
for (const userId of users) {
if (!now?.has(userId)) this.emit("reactionRemove", { ...base, emoji, userId });
}
}
}
/** As reações de uma mensagem como o bot as viu por último: Map<emoji, Set<userId>>. */
cachedReactions(messageId) {
return this.#reactions.get(messageId) ?? null;
}
/**
* Passa a acompanhar as reações de uma mensagem que o bot não viu chegar
* (uma mensagem antiga, buscada pela API). Sem isso, a primeira mudança nas
* reações dela não gera reactionAdd/reactionRemove.
*/
trackReactions(message) {
this.#rememberReactions(message.id, message.reactions ?? []);
}
// ─── Mensagens ─────────────────────────────────────────────────────────
/**
* Envia uma mensagem numa sala de texto. `content` é um texto ou um objeto
* { text, images, url, replyTo, mentions }. Um `nonce` é gerado para que uma
* nova tentativa (depois de uma queda de rede) não duplique a mensagem.
*/
async sendMessage(groupId, channelId, content) {
const body = typeof content === "string" ? { text: content } : { ...content };
body.nonce ??= randomUUID();
const { message } = await this.rest.post(`/groups/${groupId}/channels/${channelId}/messages`, body);
return message;
}
/** Troca o texto de uma mensagem do próprio bot. Devolve a mensagem como ficou. */
async editMessage(groupId, channelId, messageId, text) {
const { message } = await this.rest.patch(`/groups/${groupId}/channels/${channelId}/messages/${messageId}`, {
text,
});
return message;
}
deleteMessage(groupId, channelId, messageId) {
return this.rest.delete(`/groups/${groupId}/channels/${channelId}/messages/${messageId}`);
}
/** Adiciona (`on = true`) ou tira (`on = false`) a reação do bot. Devolve as reações atuais. */
async react(groupId, channelId, messageId, emoji, on = true) {
const { reactions } = await this.rest.post(
`/groups/${groupId}/channels/${channelId}/messages/${messageId}/reactions`,
{ emoji, on }
);
return reactions;
}
/** Mostra (ou esconde) "Bot está digitando..." na sala. */
sendTyping(groupId, channelId, typing = true) {
return this.rest.post(`/groups/${groupId}/channels/${channelId}/typing`, { typing });
}
/** Uma página (até 50) de mensagens, da mais antiga para a mais nova, antes de `before` (ms). */
fetchMessages(groupId, channelId, { before } = {}) {
const query = before ? `?before=${before}` : "";
return this.rest.get(`/groups/${groupId}/channels/${channelId}/messages${query}`);
}
/**
* Uma mensagem específica. Não existe rota para buscar uma mensagem só, mas
* a página "antes de ts + 1" termina exatamente nela.
*/
async fetchMessage(groupId, channelId, messageId, ts) {
const { messages } = await this.fetchMessages(groupId, channelId, { before: ts + 1 });
return messages.find((m) => m.id === messageId) ?? null;
}
// ─── Mensagens diretas ─────────────────────────────────────────────────
async sendDirectMessage(userId, content) {
const body = typeof content === "string" ? { text: content } : { ...content };
const { message } = await this.rest.post(`/dm/${userId}`, body);
return message;
}
/** Troca o texto de uma DM do próprio bot. Devolve a mensagem como ficou. */
async editDirectMessage(userId, messageId, text) {
const { message } = await this.rest.patch(`/dm/${userId}/messages/${messageId}`, { text });
return message;
}
/** Apaga uma DM do próprio bot, para os dois lados. */
deleteDirectMessage(userId, messageId) {
return this.rest.delete(`/dm/${userId}/messages/${messageId}`);
}
// ─── Grupos ────────────────────────────────────────────────────────────
/** Os grupos em que o bot está. */
async fetchGroups() {
const { groups } = await this.rest.get("/groups");
return groups;
}
/** GET /groups/:id — salas, cargos, quem tem qual cargo. Com cache curto. */
async fetchGroup(groupId, { force = false } = {}) {
const cached = this.#groups.get(groupId);
if (!force && cached && Date.now() - cached.at < GROUP_CACHE_MS) return cached.data;
const data = await this.rest.get(`/groups/${groupId}`);
this.#groups.set(groupId, { at: Date.now(), data });
return data;
}
/** Se `userId` tem a permissão `key` (ex.: "kickMembers") no grupo. */
async hasPermission(groupId, userId, key) {
return hasPermission(await this.fetchGroup(groupId), userId, key);
}
/**
* O link para adicionar este bot a um grupo. Bot não entra em grupo sozinho
* (nem por convite): quem gerencia o grupo abre este link e escolhe onde.
*/
installUrl(siteUrl = "https://golive.nemtudo.me") {
return `${siteUrl.replace(/\/+$/, "")}/bots/${this.user.id}/add`;
}
/** Sai de um grupo. */
async leaveGroup(groupId) {
await this.rest.post(`/groups/${groupId}/leave`);
this.#groups.delete(groupId);
}
}Os objetos com atalhos
Em vez de montar o replyTo à mão toda vez, a mensagem sabe responder a si mesma:
js
// Objetos com atalhos em volta do que a API entrega.
/** `<@id>` — como uma mensagem menciona alguém. */
export const mention = (userId) => `<@${userId}>`;
/** `<#id>` — como uma mensagem aponta para uma sala de texto. */
export const channelMention = (channelId) => `<#${channelId}>`;
/** Os ids mencionados com `<@id>` num texto, em ordem e sem repetir. */
export function mentionedIds(text) {
return [...new Set([...String(text ?? "").matchAll(/<@([A-Za-z0-9:_-]{1,80})>/g)].map((m) => m[1]))];
}
/** Uma mensagem de sala de texto de grupo (evento "group-message"). */
export class GroupMessage {
constructor(client, { message, author, mentioned }) {
this.client = client;
this.raw = message;
this.id = message.id;
this.groupId = message.groupId;
this.channelId = message.channelId;
this.text = message.text ?? "";
this.kind = message.kind ?? "text";
this.images = message.images ?? [];
this.gifUrl = message.url ?? null;
this.replyTo = message.replyTo ?? null;
this.mentions = message.mentions ?? [];
this.createdAt = new Date(message.ts);
/** Quando o texto foi editado pela última vez; null se nunca foi. */
this.editedAt = message.editedAt ? new Date(message.editedAt) : null;
/** { id, name, username, avatarUrl, nameColor, flags, bot, guest } */
this.author = author;
/** Pessoas mencionadas, por id, com nome e avatar. */
this.mentioned = mentioned ?? {};
}
/** Se a mensagem menciona este usuário (pelo token `<@id>`). */
mentionsUser(userId) {
return mentionedIds(this.text).includes(userId) || this.mentions.includes(userId);
}
/** Envia uma mensagem na mesma sala. */
send(content) {
return this.client.sendMessage(this.groupId, this.channelId, content);
}
/**
* Responde citando esta mensagem. Por padrão o autor é notificado, como uma
* menção; passe `{ notify: false }` para responder em silêncio.
*/
reply(content, { notify = true } = {}) {
const body = typeof content === "string" ? { text: content } : { ...content };
body.replyTo = {
id: this.id,
name: this.author.name,
text: this.text.slice(0, 200),
kind: this.kind,
...(this.images.length > 0 ? { images: this.images } : {}),
// É o userId que faz o autor ser notificado da resposta.
...(notify ? { userId: this.author.id } : {}),
};
return this.send(body);
}
react(emoji) {
return this.client.react(this.groupId, this.channelId, this.id, emoji);
}
/** Troca o texto — só funciona numa mensagem do próprio bot. */
edit(text) {
return this.client.editMessage(this.groupId, this.channelId, this.id, text);
}
delete() {
return this.client.deleteMessage(this.groupId, this.channelId, this.id);
}
}
/** Uma mensagem direta (evento "dm"). */
export class DirectMessage {
constructor(client, { message, fromUser }) {
this.client = client;
this.raw = message;
this.id = message.id;
this.text = message.text ?? "";
this.kind = message.kind ?? "text";
this.images = message.images ?? [];
this.createdAt = new Date(message.ts);
/** { id, username, displayName, avatarUrl, nameColor, flags, bot } */
this.author = fromUser;
}
reply(content) {
const body = typeof content === "string" ? { text: content } : { ...content };
body.replyTo = { id: this.id, name: this.author.displayName, text: this.text.slice(0, 200), kind: this.kind };
return this.client.sendDirectMessage(this.author.id, body);
}
}O index.js
Fica curto — só liga as peças:
js
import { GoLiveClient } from "./golive/client.js";
import { CommandHandler } from "./lib/commands.js";
const client = new GoLiveClient({
token: process.env.GOLIVE_TOKEN,
apiUrl: process.env.GOLIVE_API_URL,
gatewayUrl: process.env.GOLIVE_GATEWAY_URL,
});
const commands = new CommandHandler(client, { prefix: process.env.PREFIX || "!" });
await commands.loadFolder(new URL("./commands/", import.meta.url));
client.on("ready", async (me) => {
console.log(`✅ Conectado como ${me.displayName} (@${me.username})`);
const groups = await client.fetchGroups();
console.log(`📚 Estou em ${groups.length} grupo(s): ${groups.map((g) => g.name).join(", ") || "nenhum"}`);
if (groups.length === 0) {
console.log(`➕ Para me colocar num grupo, quem gerencia o grupo abre: ${client.installUrl(process.env.GOLIVE_SITE_URL)}`);
}
console.log(`⌨️ ${commands.commands.size} comandos carregados. Prefixo: ${commands.prefix}`);
});
client.on("reconnected", () => console.log("🔄 Reconectado."));
client.on("reconnecting", ({ delay }) => console.log(`⚠️ Conexão perdida, tentando de novo em ${delay} ms...`));
client.on("error", (err) => console.error("❌", err.message));
// Comandos com prefixo.
client.on("messageCreate", (message) => {
commands.handle(message).catch((err) => console.error(err));
});
// Quando alguém menciona o bot sem usar um comando, ele se apresenta.
client.on("messageCreate", async (message) => {
if (message.author.bot || commands.parse(message.text)) return;
if (!message.mentionsUser(client.user.id)) return;
await message.reply(`Oi, ${message.author.name}! 👋 Digite ${commands.prefix}ajuda para ver o que eu sei fazer.`);
});
// Mensagens diretas.
client.on("directMessage", async (dm) => {
if (dm.author.bot) return;
const text = dm.text.trim().toLowerCase();
if (text === "ajuda" || text === `${commands.prefix}ajuda`) {
const list = [...commands.commands.values()].map((c) => `• ${commands.prefix}${c.name} — ${c.description}`);
await dm.reply(`Meus comandos funcionam nas salas de texto dos grupos:\n${list.join("\n")}`);
} else {
await dm.reply("Oi! Mande \"ajuda\" para ver meus comandos. 🤖");
}
});
client.on("groupAdd", async ({ groupId }) => {
const { group } = await client.fetchGroup(groupId);
console.log(`🎉 Fui adicionado ao grupo ${group.name}.`);
});
client.on("groupRemove", ({ groupId, reason }) => console.log(`🚪 Saí do grupo ${groupId} (${reason}).`));
// Um erro esquecido num handler não pode derrubar o bot.
process.on("unhandledRejection", (err) => console.error("Promise rejeitada:", err));
function shutdown() {
console.log("\n👋 Desligando...");
client.destroy(); // fecha o WebSocket sem tentar reconectar
setTimeout(() => process.exit(0), 500);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
await client.login();Guardando dados
Pontos, avisos, configurações por grupo — isso precisa sobreviver a um reinício. Por ordem de complexidade:
Um arquivo JSON — suficiente para bots pequenos:
jsimport { readFile, writeFile } from "node:fs/promises"; const FILE = "./dados.json"; let db = {}; try { db = JSON.parse(await readFile(FILE, "utf8")); } catch {} export const get = (key, fallback) => db[key] ?? fallback; export async function set(key, value) { db[key] = value; await writeFile(FILE, JSON.stringify(db, null, 2)); }SQLite (
node:sqlite, embutido no Node 22+) — para consultas e rankings.Um banco de verdade (PostgreSQL, MongoDB) — quando o bot roda em várias máquinas.
Guarde dados por grupo (db[groupId].prefixo), já que o mesmo bot atende vários grupos.