Aparência
Bot completo
Um bot pronto para usar e adaptar, com tudo o que esta documentação ensina funcionando junto:
- ⌨️ Sistema de comandos com prefixo, apelidos, cooldown e permissões
- 💬 Respostas com citação e resposta a menções
- 📄 Páginas por reação (
!membros) - 📊 Enquete por reações (
!enquete) - 🛡️ Moderação com checagem de permissão (
!expulsar,!banir) - ✉️ Respostas por DM
- 🔄 Reconexão automática, heartbeat e tratamento de
429
O código fica em examples/bot-completo no repositório desta documentação, e é testado contra uma imitação da API (npm run check:examples na raiz do repositório).
Rodando
bash
cd examples/bot-completo
npm install
cp .env.example .env # e cole o token do bot no .env
npm run link # mostra o link para quem gerencia o grupo adicionar o bot
npm start✅ Conectado como DJ do Grupo (@musica_bot)
📚 Estou em 1 grupo(s): Meu Grupo
⌨️ 9 comandos carregados. Prefixo: !Comandos
| Comando | O que faz | Mostra como... |
|---|---|---|
!ping | Responde e mede a latência da API. | responder com citação |
!ajuda [comando] | Lista os comandos ou explica um. | gerar ajuda a partir dos comandos |
!dado [lados] | Rola um dado. | validar argumentos, cooldown |
!escolher a | b | c | Escolhe uma opção. | argumentos com separador |
!usuario [@pessoa] | Mostra um perfil. | ler menções <@id>, GET /users/:id |
!membros | Lista os membros em páginas. | páginas por reação |
!enquete P | A | B | Votação que encerra em 60 s. | coletar reações |
!expulsar @pessoa [motivo] | Expulsa do grupo. | permissões, tratar 403 |
!banir @pessoa [motivo] | Bane do grupo. | idem |
Além dos comandos: mencione o bot e ele se apresenta; mande uma DM e ele responde com a ajuda.
Configuração
ini
# Copie para .env e preencha. Nunca publique o arquivo .env.
# O token do bot, exatamente como o portal do desenvolvedor mostra
# (https://golive.nemtudo.me/developers — com ou sem o prefixo "Bot ").
GOLIVE_TOKEN=Bot cole-seu-token-aqui
# Prefixo dos comandos.
PREFIX=!
# Só mude se for apontar para outra instalação do GoLive.
# GOLIVE_API_URL=https://apigolive.nemtudo.me
# GOLIVE_GATEWAY_URL=wss://apigolive.nemtudo.me/ws
# GOLIVE_SITE_URL=https://golive.nemtudo.mejson
{
"name": "golive-bot-completo",
"private": true,
"version": "1.0.0",
"description": "Bot de exemplo do Go Live: comandos, respostas, reações, paginação e DMs.",
"type": "module",
"engines": {
"node": ">=20.6"
},
"scripts": {
"start": "node --env-file=.env src/index.js",
"link": "node --env-file=.env scripts/link-de-instalacao.js"
},
"dependencies": {
"ws": "^8.18.0"
}
}Código
O ponto de entrada
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();O cliente HTTP
Token, JSON, repetição automática em 429 e falhas de rede.
js
// Cliente HTTP da API do GoLive.
//
// Toda requisição leva `Authorization: Bot <token>`. Respostas 429 (limite de
// requisições) são repetidas sozinhas depois do tempo que o servidor pede no
// cabeçalho `retry-after`, e erros de rede são repetidos com espera
// exponencial. Qualquer outra resposta de erro vira um GoLiveApiError.
export const DEFAULT_API_URL = "https://apigolive.nemtudo.me";
export class GoLiveApiError extends Error {
constructor(method, path, status, body) {
super(`${method} ${path} falhou (${status}): ${body?.error ?? body?.message ?? "sem detalhes"}`);
this.name = "GoLiveApiError";
this.method = method;
this.path = path;
this.status = status;
this.body = body;
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Aceita o token com ou sem o prefixo "Bot " — o site copia com o prefixo. */
export function toCredential(token) {
const trimmed = String(token ?? "").trim();
return trimmed.startsWith("Bot ") ? trimmed : `Bot ${trimmed}`;
}
export class Rest {
constructor({ token, baseUrl = DEFAULT_API_URL, maxRetries = 3 }) {
this.credential = toCredential(token);
this.baseUrl = (baseUrl ?? DEFAULT_API_URL).replace(/\/+$/, "");
this.maxRetries = maxRetries;
}
async request(method, path, body) {
for (let attempt = 0; ; attempt += 1) {
let response;
try {
response = await fetch(this.baseUrl + path, {
method,
headers: {
Authorization: this.credential,
// Só com corpo: a API recusa um Content-Type JSON sem corpo.
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (err) {
if (attempt >= this.maxRetries) throw err;
await sleep(1000 * 2 ** attempt);
continue;
}
if (response.status === 429 && attempt < this.maxRetries) {
const retryAfter = Number(response.headers.get("retry-after"));
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** attempt);
continue;
}
const text = await response.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { error: text };
}
}
if (!response.ok) throw new GoLiveApiError(method, path, response.status, data);
return data;
}
}
get(path) {
return this.request("GET", path);
}
post(path, body = {}) {
return this.request("POST", path, body);
}
put(path, body = {}) {
return this.request("PUT", path, body);
}
patch(path, body = {}) {
return this.request("PATCH", path, body);
}
delete(path) {
return this.request("DELETE", path);
}
}A conexão em tempo real
register, heartbeat, reconexão com espera crescente e os casos em que não se deve reconectar.
js
// Conexão em tempo real (WebSocket) com o GoLive.
//
// É por aqui que o bot fica sabendo de tudo que acontece: mensagens novas,
// reações, DMs, mudanças nos grupos. O ciclo é:
//
// 1. abrir wss://apigolive.nemtudo.me/ws
// 2. o servidor manda { type: "welcome" }
// 3. o bot manda { type: "register", token: "Bot <token>" }
// 4. o servidor responde { type: "registered", ... } — a partir daqui os
// eventos chegam
//
// O servidor manda um ping de WebSocket a cada 25 segundos; a biblioteca `ws`
// responde sozinha. Se a conexão cair, reconectamos com espera exponencial.
import WebSocket from "ws";
import { EventEmitter } from "node:events";
import { toCredential } from "./rest.js";
export const DEFAULT_GATEWAY_URL = "wss://apigolive.nemtudo.me/ws";
// Códigos de fechamento em que não adianta reconectar: a conexão foi banida,
// ou o token deixou de valer (trocaram o token no portal, ou o bot foi excluído).
const BANNED_CLOSE_CODE = 4003;
const TOKEN_REVOKED_CLOSE_CODE = 4004;
// O servidor pinga a cada 25s. Sem ouvir nada por 60s, a conexão morreu.
const SILENCE_TIMEOUT_MS = 60_000;
const MAX_RECONNECT_DELAY_MS = 30_000;
export class Gateway extends EventEmitter {
#ws = null;
#attempt = 0;
#silenceTimer = null;
#reconnectTimer = null;
#stopped = false;
constructor({ token, url = DEFAULT_GATEWAY_URL }) {
super();
this.credential = toCredential(token);
this.url = url ?? DEFAULT_GATEWAY_URL;
}
connect() {
this.#stopped = false;
clearTimeout(this.#reconnectTimer);
const ws = new WebSocket(this.url);
this.#ws = ws;
ws.on("open", () => {
this.#touch();
this.#register();
});
// A `ws` já responde o ping com um pong; só anotamos que o servidor está vivo.
ws.on("ping", () => this.#touch());
ws.on("message", (data) => {
this.#touch();
let payload;
try {
payload = JSON.parse(data.toString());
} catch {
return;
}
if (payload && typeof payload.type === "string") this.#handle(payload);
});
ws.on("close", (code, reason) => {
if (this.#ws !== ws) return;
clearTimeout(this.#silenceTimer);
this.#ws = null;
this.emit("disconnect", { code, reason: reason.toString() });
if (this.#stopped) return;
if (code === BANNED_CLOSE_CODE) {
this.emit("error", new Error("A conexão foi recusada: esta conta ou IP está banido."));
return;
}
if (code === TOKEN_REVOKED_CLOSE_CODE) {
this.#stopped = true;
this.emit(
"error",
new Error("O token deste bot foi trocado ou o bot foi excluído. Pegue o token novo no portal do desenvolvedor.")
);
return;
}
this.#scheduleReconnect();
});
// Erros de conexão também disparam "close" logo em seguida, que reconecta.
ws.on("error", (err) => this.emit("debug", `erro no WebSocket: ${err.message}`));
}
/** Envia um objeto pelo WebSocket (ignorado se a conexão não estiver aberta). */
send(payload) {
if (this.#ws?.readyState === WebSocket.OPEN) this.#ws.send(JSON.stringify(payload));
}
/** Fecha de vez, sem reconectar. */
close() {
this.#stopped = true;
clearTimeout(this.#reconnectTimer);
clearTimeout(this.#silenceTimer);
this.#ws?.close(1000);
}
#register() {
this.send({ type: "register", token: this.credential });
}
#handle(payload) {
if (payload.type === "registered") {
this.#attempt = 0;
} else if (payload.type === "register-error") {
if (payload.retryable) {
// O servidor está subindo: tenta de novo em instantes.
setTimeout(() => this.#register(), 3000);
} else {
// Token inválido ou revogado — reconectar não resolve.
this.emit(
"error",
new Error(`O Go Live recusou o registro: ${payload.message}. Confira o token do bot.`)
);
this.close();
}
} else if (payload.type === "banned") {
this.#stopped = true;
}
this.emit("dispatch", payload);
}
#touch() {
clearTimeout(this.#silenceTimer);
this.#silenceTimer = setTimeout(() => {
this.emit("debug", "sem sinal do servidor há 60s, reconectando");
this.#ws?.terminate();
}, SILENCE_TIMEOUT_MS);
}
#scheduleReconnect() {
const delay = Math.min(MAX_RECONNECT_DELAY_MS, 1000 * 2 ** this.#attempt) + Math.random() * 1000;
this.#attempt += 1;
this.emit("reconnecting", { attempt: this.#attempt, delay: Math.round(delay) });
this.#reconnectTimer = setTimeout(() => this.connect(), delay);
}
}O cliente
Junta HTTP e WebSocket, descobre quem reagiu (diff de reações) e expõe atalhos.
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);
}
}Mensagens com atalhos
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);
}
}Permissões
js
// Resolve, do lado do bot, se alguém tem uma permissão de gerenciamento no
// grupo — a mesma conta que a API faz:
//
// 1. o dono pode tudo;
// 2. "administrator" (no @everyone ou em qualquer cargo da pessoa) libera tudo;
// 3. senão, a permissão precisa estar ligada no @everyone ou em algum cargo.
//
// Serve para as chaves de `manage` (kickMembers, banMembers, manageMessages...),
// que nunca mudam por sala. Para as de texto/voz a sala pode sobrescrever, e
// quem decide de verdade é sempre a API — isto aqui é só para o bot recusar um
// comando cedo, com uma mensagem amigável.
const SECTIONS = ["manage", "general", "text", "voice"];
function permissionIn(permissions, key) {
for (const section of SECTIONS) {
const values = permissions?.[section];
if (values && key in values) return values[key] === true;
}
return false;
}
/**
* @param {{ group: object, memberRoles: Record<string, string[]> }} groupData
* a resposta de GET /groups/:id
*/
export function hasPermission(groupData, userId, key) {
const { group, memberRoles } = groupData;
if (group.ownerId === userId) return true;
const held = new Set(memberRoles?.[userId] ?? []);
const sets = [group.permissions, ...group.roles.filter((r) => held.has(r.id)).map((r) => r.permissions)];
if (sets.some((p) => p?.manage?.administrator)) return true;
return sets.some((p) => permissionIn(p, key));
}
/**
* A posição mais alta entre os cargos da pessoa (0 sem cargo, Infinity para o
* dono). Só dá para expulsar/banir quem está abaixo de você.
*/
export function rankOf(groupData, userId) {
const { group, memberRoles } = groupData;
if (group.ownerId === userId) return Number.POSITIVE_INFINITY;
const held = new Set(memberRoles?.[userId] ?? []);
return group.roles.filter((r) => held.has(r.id)).reduce((max, r) => Math.max(max, r.position), 0);
}Sistema de comandos
js
// Um sistema de comandos com prefixo ("!ping", "!dado 20"...).
//
// Cada comando é um arquivo em src/commands que exporta um objeto:
//
// export default {
// name: "ping",
// aliases: ["p"],
// description: "Responde pong",
// usage: "!ping",
// cooldown: 3, // segundos entre usos, por pessoa
// permission: "kickMembers", // opcional: permissão exigida de quem usa
// async run({ client, message, args, rest, handler }) { ... },
// };
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
export class CommandHandler {
commands = new Map();
#aliases = new Map();
#cooldowns = new Map();
constructor(client, { prefix = "!" } = {}) {
this.client = client;
this.prefix = prefix;
}
register(command) {
if (!command?.name || typeof command.run !== "function") {
throw new Error("Um comando precisa de `name` e de uma função `run`.");
}
this.commands.set(command.name.toLowerCase(), command);
for (const alias of command.aliases ?? []) this.#aliases.set(alias.toLowerCase(), command.name.toLowerCase());
}
/** Importa todos os arquivos .js de uma pasta como comandos. */
async loadFolder(folderUrl) {
const dir = fileURLToPath(folderUrl);
for (const file of (await readdir(dir)).filter((f) => f.endsWith(".js")).sort()) {
const module = await import(pathToFileURL(join(dir, file)).href);
this.register(module.default);
}
}
find(name) {
const key = name.toLowerCase();
return this.commands.get(key) ?? this.commands.get(this.#aliases.get(key));
}
/** "!dado 20 " → { name: "dado", args: ["20"], rest: "20" }; null se não for comando. */
parse(text) {
if (!text.startsWith(this.prefix)) return null;
const body = text.slice(this.prefix.length).trim();
if (!body) return null;
const [name, ...args] = body.split(/\s+/);
return { name: name.toLowerCase(), args, rest: body.slice(name.length).trim() };
}
async handle(message) {
// Nunca responda a bots — nem a si mesmo. É o que evita dois bots
// conversando entre si para sempre.
if (message.author.bot) return;
const parsed = this.parse(message.text);
if (!parsed) return;
const command = this.find(parsed.name);
if (!command) return;
const key = `${command.name}:${message.author.id}`;
const now = Date.now();
const until = this.#cooldowns.get(key) ?? 0;
if (now < until) {
const seconds = Math.ceil((until - now) / 1000);
await message.reply(`⏳ Calma! Espere ${seconds}s para usar ${this.prefix}${command.name} de novo.`);
return;
}
if (command.permission) {
const allowed = await this.client.hasPermission(message.groupId, message.author.id, command.permission);
if (!allowed) {
await message.reply("🚫 Você não tem permissão para usar este comando.");
return;
}
}
if (command.cooldown) this.#setCooldown(key, now + command.cooldown * 1000);
try {
await command.run({ client: this.client, message, args: parsed.args, rest: parsed.rest, handler: this });
} catch (err) {
console.error(`[comando ${command.name}]`, err);
await message.reply("❌ Algo deu errado ao executar esse comando.").catch(() => {});
}
}
#setCooldown(key, until) {
this.#cooldowns.set(key, until);
// Limpa os vencidos de vez em quando, para o mapa não crescer para sempre.
if (this.#cooldowns.size > 1000) {
const now = Date.now();
for (const [k, t] of this.#cooldowns) if (t <= now) this.#cooldowns.delete(k);
}
}
}Paginador
js
// Páginas por reação: o bot mostra uma página e reage com ⬅️ ➡️ ❌. Quem
// clicar numa seta vê a página seguinte.
//
// A API ainda não tem edição de mensagens, então "virar a página" é enviar a
// página nova e apagar a anterior. O efeito na tela é o mesmo: uma mensagem
// só, sempre no fim da conversa.
export const PREV = "⬅️";
export const NEXT = "➡️";
export const STOP = "❌";
/**
* @param {import("../golive/structures.js").GroupMessage} message
* a mensagem que pediu as páginas (define a sala e quem pode navegar)
* @param {string[]} pages o texto de cada página
* @param {{ time?: number, authorOnly?: boolean }} options
* time: quanto tempo (ms) sem uso até o paginador parar
* authorOnly: só quem pediu pode virar as páginas
*/
export async function paginate(message, pages, { time = 120_000, authorOnly = true } = {}) {
const { client, groupId, channelId } = message;
if (pages.length === 0) throw new Error("Nada para paginar.");
let index = 0;
let current = null;
let busy = false;
let timer = null;
let stopped = false;
const render = (i) => (pages.length > 1 ? `${pages[i]}\n\n📄 Página ${i + 1}/${pages.length}` : pages[i]);
async function show(i) {
const previous = current;
// Sem replyTo com userId: cada página nova notificaria o autor de novo.
current = await client.sendMessage(groupId, channelId, { text: render(i) });
if (previous) await client.deleteMessage(groupId, channelId, previous.id).catch(() => {});
if (pages.length > 1) {
// Uma de cada vez, para as setas aparecerem sempre na mesma ordem.
for (const emoji of [PREV, NEXT, STOP]) await client.react(groupId, channelId, current.id, emoji);
}
}
function armTimer() {
clearTimeout(timer);
timer = setTimeout(() => void stop({ deleteMessage: false }), time);
}
async function stop({ deleteMessage }) {
if (stopped) return;
stopped = true;
clearTimeout(timer);
client.off("reactionAdd", onReaction);
if (!current) return;
if (deleteMessage) {
await client.deleteMessage(groupId, channelId, current.id).catch(() => {});
} else if (pages.length > 1) {
// Tira as setas do bot: sinal de que o paginador expirou.
for (const emoji of [PREV, NEXT, STOP]) {
await client.react(groupId, channelId, current.id, emoji, false).catch(() => {});
}
}
}
async function onReaction(event) {
if (stopped || busy || !current || event.messageId !== current.id) return;
if (event.userId === client.user.id) return;
if (authorOnly && event.userId !== message.author.id) return;
let next;
if (event.emoji === PREV) next = index === 0 ? pages.length - 1 : index - 1;
else if (event.emoji === NEXT) next = (index + 1) % pages.length;
else if (event.emoji === STOP) return stop({ deleteMessage: true });
else return;
busy = true;
try {
index = next;
await show(index);
armTimer();
} catch (err) {
console.error("[paginador]", err);
} finally {
busy = false;
}
}
await show(index);
if (pages.length > 1) {
client.on("reactionAdd", onReaction);
armTimer();
}
return { stop: () => stop({ deleteMessage: false }) };
}
/** Divide uma lista em páginas de `size` linhas. */
export function chunkLines(lines, size = 10) {
const pages = [];
for (let i = 0; i < lines.length; i += size) pages.push(lines.slice(i, i + size).join("\n"));
return pages;
}Os comandos
js
export default {
name: "ping",
description: "Mostra se o bot está vivo e quanto a API demora para responder.",
usage: "!ping",
cooldown: 3,
async run({ message }) {
const start = Date.now();
const sent = await message.reply("🏓 Pong!");
await message.send(`⏱️ A API respondeu em ${Date.now() - start} ms.`);
return sent;
},
};js
export default {
name: "ajuda",
aliases: ["help", "comandos"],
description: "Lista os comandos, ou explica um deles.",
usage: "!ajuda [comando]",
async run({ message, args, handler }) {
const p = handler.prefix;
if (args[0]) {
const command = handler.find(args[0].replace(p, ""));
if (!command) return message.reply(`Não conheço o comando "${args[0]}".`);
const aliases = command.aliases?.length ? `\nTambém: ${command.aliases.map((a) => p + a).join(", ")}` : "";
return message.reply(`${p}${command.name} — ${command.description}\nUso: ${command.usage ?? p + command.name}${aliases}`);
}
const lines = [...handler.commands.values()].map((c) => `• ${p}${c.name} — ${c.description}`);
return message.reply(`📖 Comandos disponíveis:\n${lines.join("\n")}\n\nDigite ${p}ajuda <comando> para detalhes.`);
},
};js
import { randomInt } from "node:crypto";
export default {
name: "dado",
aliases: ["roll"],
description: "Rola um dado (6 lados, ou quantos você disser).",
usage: "!dado [lados]",
cooldown: 2,
async run({ message, args }) {
const sides = args[0] === undefined ? 6 : Number(args[0]);
if (!Number.isInteger(sides) || sides < 2 || sides > 1000) {
return message.reply("Diga um número de lados entre 2 e 1000. Ex.: !dado 20");
}
return message.reply(`🎲 Deu ${randomInt(1, sides + 1)} (d${sides})`);
},
};js
import { randomInt } from "node:crypto";
export default {
name: "escolher",
aliases: ["choose"],
description: "Escolhe uma entre as opções separadas por |.",
usage: "!escolher pizza | hambúrguer | sushi",
async run({ message, rest }) {
const options = rest.split("|").map((o) => o.trim()).filter(Boolean);
if (options.length < 2) return message.reply("Me dê pelo menos duas opções separadas por |.");
return message.reply(`🤔 Eu escolho: ${options[randomInt(options.length)]}`);
},
};js
import { mentionedIds } from "../golive/structures.js";
export default {
name: "usuario",
aliases: ["user", "perfil"],
description: "Mostra o perfil de alguém (mencione a pessoa, ou nada para você).",
usage: "!usuario [@pessoa]",
cooldown: 3,
async run({ client, message }) {
// Uma menção chega no texto como <@id>.
const targetId = mentionedIds(message.text)[0] ?? message.author.id;
if (targetId.startsWith("guest:")) return message.reply("Convidados não têm perfil.");
const { account, live } = await client.rest.get(`/users/${targetId}`);
const created = new Date(account.createdAt).toLocaleDateString("pt-BR");
const lines = [
`👤 ${account.displayName} (@${account.username})${account.bot ? " [BOT]" : ""}`,
account.bio ? `📝 ${account.bio}` : null,
`📅 Conta criada em ${created}`,
live ? `🔴 Ao vivo agora: https://golive.nemtudo.me/watch/${live.room} (${live.peopleCount} pessoas)` : null,
`🔗 https://golive.nemtudo.me/user/${account.username}`,
];
return message.reply(lines.filter(Boolean).join("\n"));
},
};js
import { chunkLines, paginate } from "../lib/paginator.js";
const ROLE_BADGE = { owner: "👑", admin: "🛡️", member: "•" };
export default {
name: "membros",
aliases: ["members"],
description: "Lista os membros do grupo em páginas (navegue com ⬅️ ➡️).",
usage: "!membros",
cooldown: 10,
async run({ client, message }) {
// Sem parâmetros, a rota devolve o grupo inteiro. Em grupos muito grandes,
// prefira as fatias: ?online=1, ?online=0&after=<id>&limit=100, ?q=nome.
const { members } = await client.rest.get(`/groups/${message.groupId}/members`);
const lines = members.map((m) => {
const status = m.online ? "🟢" : "⚫";
const tag = m.bot ? " [BOT]" : m.guest ? " (convidado)" : "";
return `${status} ${ROLE_BADGE[m.role] ?? "•"} ${m.name}${tag}`;
});
const pages = chunkLines(lines, 10).map((page) => `👥 Membros (${members.length})\n\n${page}`);
await paginate(message, pages, { time: 120_000 });
},
};js
const NUMBERS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"];
const DURATION_S = 60;
export default {
name: "enquete",
aliases: ["poll"],
description: `Cria uma votação por reações que termina em ${DURATION_S}s.`,
usage: "!enquete Qual filme? | Matrix | Duna | Interestelar",
cooldown: 30,
async run({ client, message, rest }) {
const [question, ...options] = rest.split("|").map((part) => part.trim()).filter(Boolean);
if (!question || options.length < 2 || options.length > NUMBERS.length) {
return message.reply(`Uso: ${this.usage} (de 2 a ${NUMBERS.length} opções)`);
}
const lines = options.map((option, i) => `${NUMBERS[i]} ${option}`);
const poll = await message.send(
`📊 ${question}\n\n${lines.join("\n")}\n\nVote reagindo! Resultado em ${DURATION_S} segundos.`
);
for (let i = 0; i < options.length; i += 1) {
await client.react(message.groupId, message.channelId, poll.id, NUMBERS[i]);
}
setTimeout(async () => {
try {
// Lê as reações direto da API: mais confiável que o cache se o bot
// tiver reconectado durante a votação.
const final = await client.fetchMessage(message.groupId, message.channelId, poll.id, poll.ts);
if (!final) return; // a enquete foi apagada
const votes = options.map((option, i) => {
const reaction = final.reactions?.find((r) => r.emoji === NUMBERS[i]);
// O próprio bot reagiu em todas as opções: não conta como voto.
const count = (reaction?.users ?? []).filter((id) => id !== client.user.id).length;
return { option, count };
});
const top = Math.max(...votes.map((v) => v.count));
const result = votes.map((v, i) => `${NUMBERS[i]} ${v.option}: ${v.count} voto(s)${v.count === top && top > 0 ? " 🏆" : ""}`);
await client.sendMessage(message.groupId, message.channelId, {
text: `📊 Resultado — ${question}\n\n${result.join("\n")}`,
replyTo: { id: poll.id, name: client.user.displayName, text: poll.text.slice(0, 200) },
});
} catch (err) {
console.error("[enquete]", err);
}
}, DURATION_S * 1000);
},
};js
import { mention, mentionedIds } from "../golive/structures.js";
import { GoLiveApiError } from "../golive/rest.js";
export default {
name: "expulsar",
aliases: ["kick"],
description: "Expulsa alguém do grupo (a pessoa pode voltar com um convite).",
usage: "!expulsar @pessoa [motivo]",
permission: "kickMembers",
async run({ client, message }) {
const [targetId] = mentionedIds(message.text);
if (!targetId) return message.reply(`Mencione quem deve sair. Uso: ${this.usage}`);
if (targetId === client.user.id) return message.reply("Não vou me expulsar 😅");
try {
await client.rest.post(`/groups/${message.groupId}/members/${targetId}/kick`);
} catch (err) {
// 403: o bot não tem a permissão, ou a pessoa tem cargo igual/acima do dele.
if (err instanceof GoLiveApiError && (err.status === 403 || err.status === 404)) {
return message.reply(`Não consegui: ${err.body?.error ?? "sem permissão"}`);
}
throw err;
}
const reason = message.text.replace(/^\S+\s*/, "").replace(/<@[^>]+>/g, "").trim();
return message.send(`👢 ${mention(targetId)} foi expulso(a).${reason ? ` Motivo: ${reason}` : ""}`);
},
};js
import { mention, mentionedIds } from "../golive/structures.js";
import { GoLiveApiError } from "../golive/rest.js";
export default {
name: "banir",
aliases: ["ban"],
description: "Bane alguém do grupo (não pode voltar até ser desbanido).",
usage: "!banir @pessoa [motivo]",
permission: "banMembers",
async run({ client, message }) {
const [targetId] = mentionedIds(message.text);
if (!targetId) return message.reply(`Mencione quem deve ser banido. Uso: ${this.usage}`);
if (targetId === client.user.id) return message.reply("Não vou me banir 😅");
try {
await client.rest.post(`/groups/${message.groupId}/bans/${targetId}`);
} catch (err) {
if (err instanceof GoLiveApiError && (err.status === 403 || err.status === 404)) {
return message.reply(`Não consegui: ${err.body?.error ?? "sem permissão"}`);
}
throw err;
}
const reason = message.text.replace(/^\S+\s*/, "").replace(/<@[^>]+>/g, "").trim();
return message.send(`🔨 ${mention(targetId)} foi banido(a).${reason ? ` Motivo: ${reason}` : ""}`);
},
};Script com o link de instalação
Bot não entra em grupo sozinho: quem gerencia o grupo abre este link e escolhe onde (veja Colocando o bot num grupo).
js
// Mostra o link para adicionar o bot a um grupo, e em quais grupos ele já está.
//
// npm run link
//
// Bot não entra em grupo sozinho — nem com convite. Quem gerencia o grupo
// (tem a permissão "Gerenciar grupo") abre este link, escolhe o grupo e o bot
// entra. O mesmo link fica na aba "Instalação" do portal do desenvolvedor.
import { Rest } from "../src/golive/rest.js";
const SITE_URL = (process.env.GOLIVE_SITE_URL || "https://golive.nemtudo.me").replace(/\/+$/, "");
const rest = new Rest({ token: process.env.GOLIVE_TOKEN, baseUrl: process.env.GOLIVE_API_URL });
const { account } = await rest.get("/auth/me");
const { groups } = await rest.get("/groups");
console.log(`🤖 ${account.displayName} (@${account.username})`);
console.log(`➕ Link para adicionar a um grupo: ${SITE_URL}/bots/${account.id}/add`);
console.log(
groups.length
? `📚 Já estou em: ${groups.map((g) => g.name).join(", ")}`
: "📚 Ainda não estou em nenhum grupo."
);Criando o seu comando
Crie um arquivo em src/commands/ — ele é carregado sozinho na próxima vez que o bot iniciar:
js
// src/commands/abracar.js
import { mention, mentionedIds } from "../golive/structures.js";
export default {
name: "abracar",
aliases: ["hug"],
description: "Dá um abraço em alguém.",
usage: "!abracar @pessoa",
cooldown: 5,
async run({ message }) {
const [target] = mentionedIds(message.text);
if (!target) return message.reply("Mencione quem vai ganhar o abraço!");
return message.send(`🤗 ${mention(message.author.id)} abraçou ${mention(target)}!`);
},
};