6 Commits
2.3.5 ... 2.4.3

46 changed files with 1631 additions and 788 deletions

1
.gitignore vendored
View File

@@ -8,4 +8,3 @@ cookies.txt
bin/ bin/
mychats.txt mychats.txt
manybot.conf manybot.conf
update.log

292
README.md
View File

@@ -1,17 +1,18 @@
![ManyBot Logo](logo.png) ![ManyBot Logo](logo.png)
Criei esse bot para servir um grupo de amigos. Meu foco não é fazer ele funcionar para todo mundo. ManyBot é um bot para WhatsApp que roda 100% localmente, sem depender da API oficial do WhatsApp. Ele utiliza a biblioteca `whatsapp-web.js`, que automatiza o WhatsApp Web sem depender de gráficos (headless).
Ele é 100% local e gratuito, sem necessidade de APIs burocraticas. Usufrui da biblioteca `whatsapp-web.js`, que permite bastante coisa mesmo sem a API oficial. Algumas funcionalidades desse bot incluem:
- Suporte a múltiplos chats em uma única sessão
- Sistema de plugins — adicione, remova ou crie funcionalidades sem mexer no núcleo do bot
Você consegue totalmente clonar esse repoistório e rodar seu próprio ManyBot. A licenca GPLv3 permite que você modifique o que quiser e faça seu próprio bot, mas se for publicar, deve ser open source assim como o ManyBot original. # Exemplos
Algumas funcionalidades desse bot inclui: <center>
- Funciona em multiplos chats em apenas uma única sessão
- Comandos de jogos e download com yt-dlp ![Exemplo do gerador de figurinhas](examples/figurinha.gif)
- Gerador de figurinhas
- Ferramenta para pegar IDs dos chats </center>
- Entre outros
--- ---
@@ -25,32 +26,283 @@ obs: Sistemas Android e iOS ainda não são 100% compatíveis. O suporte para Te
# Instalação (Linux) # Instalação (Linux)
1. Clone o repositório e entre: 1. Clone o repositório e entre:
``` ```bash
git clone https://github.com/synt-xerror/manybot git clone https://github.com/synt-xerror/manybot
cd manybot cd manybot
``` ```
2. Execute o script de instalação: 2. Crie e abra o arquivo de configuração (use o editor de sua preferência):
``` ```bash
bash setup touch manybot.conf
nano manybot.conf
``` ```
3. Rode o bot pela primeira vez: 3. Nele você pode configurar algumas coisas do ManyBot. Esse é o arquivo base para que possa modificar:
```bash
# Comentários com '#'
CLIENT_ID=bot_permanente
CMD_PREFIX=!
CHATS=[
123456789@c.us,
123456789@g.us
]
PLUGINS=[
video,
audio,
figurinha,
adivinhacao
]
``` ```
node src/main.js - **CLIENT_ID:** ID do cliente, serve para identificar sua sessão.
- Valor padrão: `bot_permanente`
- **CMD_PREFIX:** Prefixo do comando, o caractere que você usa para executar um comando (!many, !figurinha).
- Valor padrão: `!`
- **CHATS:** ID dos chats no qual você quer que o bot assista. Use o utilitário: `src/utils/get_id.js` para descobrir os IDs. Deixe vazio caso queira que funcione com qualquer chat.
- Valor padrão: (nenhum)
- **PLUGINS:** Lista de plugins ativos. Cada nome corresponde a uma pasta dentro de `src/plugins/`. Remova ou comente uma linha para desativar o plugin sem apagá-lo.
- Valor padrão: (nenhum)
obs: o utilitário `src/utils/get_id.js` usa um CLIENT_ID separado para que não entre em conflito com a sessão principal do ManyBot. Você terá que escanear o QR Code novamente para executá-lo.
4. Execute o script de instalação:
```bash
bash ./setup
```
5. Rode o bot pela primeira vez (você deve rodar da raiz, não dentro de `src`):
```bash
node ./src/main.js
``` ```
Ele vai pedir para que escaneie o QR Code com seu celular. Ele vai pedir para que escaneie o QR Code com seu celular.
Vá em: WhatsApp > Trẽs pontos no canto inferior > Dispositivos conectados > Conectar um dispositivo
No WhatsApp:
Menu (três pontos) > Dispositivos conectados > Conectar um dispositivo
# Instalação (Windows) # Instalação (Windows)
O uso desse bot foi pensado para rodar em um terminal Linux com Bash. No entanto, você pode usar o Git Bash, que simula um terminal Linux com Bash real: O uso desse bot foi pensado para rodar em um terminal Linux. No entanto, você pode usar o Git Bash, que simula um terminal Linux com Bash real:
1. Para baixar o Git Bash: https://git-scm.com/install/windows 1. Para baixar o Git Bash: https://git-scm.com/install/windows
Selecione a versão que deseja (portátil ou instalador) Selecione a versão que deseja (portátil ou instalador)
2. Para baixar o Node.js: https://nodejs.org/pt-br/download/current 2. Para baixar o Node.js: https://nodejs.org/pt-br/download
Role a tela e selecione "binário independente (.zip)" Role a tela e selecione "Instalador Windows (.msi)"
Ou se preferir, use um gerenciador de pacotes como mostra no conteúdo inicial Ou se preferir, use um gerenciador de pacotes como mostra no conteúdo inicial
Após baixar e instalar ambos, abra o Git Bash e execute exatamente os mesmos passos do Linux Depois de instalar ambos, abra o Git Bash e execute exatamente os mesmos comandos mostrados na seção Linux.
# Uso
Feito a instalação, você pode executar o bot apenas rodando:
```bash
node ./src/main.js
```
## Atualizações
É recomendável sempre ter a versão mais recente do ManyBot. Para isso, temos um utilitário logo na raíz. Para executar:
```bash
bash ./update
```
## Criando um serviço (opcional)
Se estiver rodando numa VPS ou apenas quer mais controle, é recomendável criar um serviço systemd. Siga os passos abaixo para saber como criar, habilitar e gerenciar um.
1. Configurando o diretório
Primeiro passo é garantir que o diretório do ManyBot esteja no local adequado, é recomendável guardar em `/root/manybot` (os passos a seguir supõem que esteja essa localização)
2. Criando o serviço
Abra o arquivo:
```bash
/etc/systemd/system/manybot.service
```
E cole o seguinte conteúdo:
```conf
[Unit]
Description=ManyBot
After=network.target
[Service]
ExecStart=/usr/bin/env node /root/manybot/src/main.js
WorkingDirectory=/root/manybot
Restart=always
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
```
3. Iniciando e habilitando o serviço:
Primeiro reinicie o daemon do systemd:
```bash
systemctl daemon-reload
```
Inicie o serviço:
```bash
systemctl start manybot
```
Habilite para que ele seja iniciado junto com o seu sistema (opcional):
```bash
systemctl enable manybot
```
4. Gerenciando o serviço:
Ver logs:
```bash
journalctl -u manybot
```
Em tempo real:
```bash
journalctl -u manybot -f
```
Parar o serviço:
```bash
systemctl stop manybot
```
Reiniciar o serviço:
```bash
systemctl restart manybot
```
Saiba mais sobre como gerenciar serviços em: https://www.digitalocean.com/community/tutorials/how-to-use-systemctl-to-manage-systemd-services-and-units-pt
Sobre o journalctl: https://www.digitalocean.com/community/tutorials/how-to-use-journalctl-to-view-and-manipulate-systemd-logs-pt
# Plugins
O ManyBot é construído em torno de um sistema de plugins. O núcleo do bot (kernel) apenas conecta ao WhatsApp e distribui as mensagens — quem decide o que fazer com elas são os plugins.
Isso significa que você pode adicionar, remover ou criar funcionalidades sem tocar no código principal do bot.
## Plugins incluídos
O ManyBot vem com alguns plugins prontos para uso, como:
- **video** — baixa um vídeo da internet e envia no chat (`!video <link>`)
- **audio** — baixa o áudio de um vídeo e envia como mensagem de voz (`!audio <link>`)
- **figurinha** — converte imagens, GIFs e vídeos em figurinhas (`!figurinha`)
- **adivinhacao** — jogo de adivinhação de um número entre 1 e 100 (`!adivinhação começar`)
- **forca** — clássico jogo da forca (`!forca começar`)
- **many** — exibe a lista de comandos disponíveis (`!many`)
- **obrigado** — responde agradecimentos (`!obrigado`, `!valeu`, `!brigado`)
Para ativar ou desativar qualquer um deles, basta editar a lista `PLUGINS` no `manybot.conf`.
## Criando um plugin
Cada plugin é uma pasta dentro de `plugins/` com um arquivo `index.js`. O bot carrega automaticamente todos os plugins listados no `manybot.conf`.
A estrutura mínima de um plugin:
```
plugins/
└── meu-plugin/
└── index.js
```
O `index.js` deve exportar uma função `default` que o kernel chama a cada mensagem recebida. A função recebe `{ msg, api }` e decide por conta própria se age ou ignora:
```js
// plugins/meu-plugin/index.js
import { CMD_PREFIX } from "../../config.js"
export default async function ({ msg, api }) {
if (!msg.is(CMD_PREFIX + "oi")) return;
await msg.reply("Olá! 👋");
}
```
### O objeto `msg`
Contém as informações da mensagem recebida:
| Propriedade | Descrição |
|---|---|
| `msg.body` | Texto da mensagem |
| `msg.args` | Tokens da mensagem — `["!video", "https://..."]` |
| `msg.type` | Tipo — `"chat"`, `"image"`, `"video"`, `"audio"`, `"sticker"` |
| `msg.sender` | ID de quem enviou |
| `msg.senderName` | Nome de quem enviou |
| `msg.fromMe` | `true` se foi o próprio bot que enviou |
| `msg.hasMedia` | `true` se a mensagem tem mídia |
| `msg.hasReply` | `true` se é uma resposta a outra mensagem |
| `msg.isGif` | `true` se a mídia é um GIF |
| `msg.is(cmd)` | Retorna `true` se a mensagem começa com `cmd` |
| `msg.reply(text)` | Responde à mensagem com quote |
| `msg.downloadMedia()` | Baixa a mídia — retorna `{ mimetype, data }` |
| `msg.getReply()` | Retorna a mensagem citada, ou `null` |
### O objeto `api`
Contém tudo que o plugin pode fazer — enviar mensagens, acessar outros plugins, registrar logs:
| Método | Descrição |
|---|---|
| `api.send(text)` | Envia texto no chat |
| `api.sendVideo(filePath)` | Envia um vídeo a partir de um arquivo local |
| `api.sendAudio(filePath)` | Envia um áudio a partir de um arquivo local |
| `api.sendImage(filePath, caption?)` | Envia uma imagem a partir de um arquivo local |
| `api.sendSticker(bufferOuPath)` | Envia uma figurinha — aceita `Buffer` ou caminho |
| `api.getPlugin(name)` | Retorna a API pública de outro plugin |
| `api.chat.id` | ID do chat atual |
| `api.chat.name` | Nome do chat atual |
| `api.chat.isGroup` | `true` se é um grupo |
| `api.log.info(...)` | Loga uma mensagem informativa |
| `api.log.warn(...)` | Loga um aviso |
| `api.log.error(...)` | Loga um erro |
### Lendo o manybot.conf no plugin
Se o seu plugin precisar de configurações próprias, você pode adicioná-las diretamente no `manybot.conf` e importá-las no código:
```js
import { MEU_PREFIXO } from "../../src/config.js";
const prefixo = MEU_PREFIXO ?? "padrão";
```
### Expondo uma API para outros plugins
Um plugin pode expor funções para que outros plugins as utilizem. Para isso, basta exportar um objeto `api`:
```js
// plugins/utilidades/index.js
export const api = {
formatarData: (date) => date.toLocaleDateString("pt-BR"),
};
export default async function ({ msg }) {
// lógica normal do plugin
}
```
Outro plugin pode chamar:
```js
const utils = api.getPlugin("utilidades");
utils.formatarData(new Date());
```
### Erros no plugin
Se um plugin lançar um erro, o kernel o desativa automaticamente e loga o problema — o restante dos plugins continua funcionando normalmente. Isso garante que um plugin com bug não derruba o bot inteiro.
# Considerações
ManyBot é distribuído sob a licença GPLv3. Você pode usar, modificar e redistribuir o software conforme os termos da licença.
Saiba mais sobre as permissões lendo o arquivo [LICENSE](LICENSE) ou em: https://www.gnu.org/licenses/quick-guide-gplv3.pt-br.html

BIN
examples/figurinha.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

8
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "whatsapp-bot", "name": "manybot",
"version": "2.3.4", "version": "2.4.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "whatsapp-bot", "name": "manybot",
"version": "2.3.4", "version": "2.4.2",
"dependencies": { "dependencies": {
"node-addon-api": "^7", "node-addon-api": "^7",
"node-gyp": "^12.2.0", "node-gyp": "^12.2.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "manybot", "name": "manybot",
"version": "2.3.4", "version": "2.4.2",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"node-addon-api": "^7", "node-addon-api": "^7",

29
setup
View File

@@ -162,6 +162,13 @@ log_info "Instalando dependências npm"
export PUPPETEER_SKIP_DOWNLOAD=1 export PUPPETEER_SKIP_DOWNLOAD=1
run_cmd npm install run_cmd npm install
# ------------------------
# Chrome Puppeeter
# ------------------------
log_info "Instalando Chrome"
npx puppeteer browsers install chrome
# ------------------------ # ------------------------
# Diretórios # Diretórios
# ------------------------ # ------------------------
@@ -189,29 +196,9 @@ else
) )
fi fi
chmod +x bin/*
log_debug "Total de arquivos para baixar: ${#files[@]}" log_debug "Total de arquivos para baixar: ${#files[@]}"
# ------------------------
# Config
# ------------------------
log_info "Criando arquivo de configuração"
cat > "src/config.js" << 'EOF'
export const CLIENT_ID = "bot_permanente";
export const BOT_PREFIX = "🤖 *ManyBot:* ";
export const CMD_PREFIX = "!";
export const CHATS = [
// coloque os chats que quer aqui
];
EOF
if [[ -f src/config.js ]]; then
log_ok "Arquivo de configuração criado"
else
log_error "Erro durante criação do arquivo de configuração"
fi
# ------------------------ # ------------------------
# Download # Download
# ------------------------ # ------------------------

View File

@@ -31,7 +31,6 @@ export const client = new Client({
client.on("qr", handleQR); client.on("qr", handleQR);
client.on("ready", () => { client.on("ready", () => {
console.log("READY DISPAROU"); // temporário
printBanner(); printBanner();
logger.success("WhatsApp conectado e pronto!"); logger.success("WhatsApp conectado e pronto!");
logger.info(`Client ID: ${CLIENT_ID}`); logger.info(`Client ID: ${CLIENT_ID}`);

View File

@@ -1,5 +0,0 @@
import { botMsg } from "../../utils/botMsg.js";
export async function cmdA(msg, _chat, _chatId, args) {
if (!args[0]) await msg.reply(botMsg("B!"));
}

View File

@@ -1,44 +0,0 @@
import { iniciarJogo, pararJogo } from "../logic/games/adivinhacao.js";
import { botMsg } from "../../utils/botMsg.js";
import { logger } from "../../logger/logger.js";
const SUBCOMANDOS = new Map([
["começar", async (chat) => {
iniciarJogo(chat.id._serialized);
await chat.sendMessage(botMsg(
"🎮 *Jogo iniciado!*\n\n" +
"Estou pensando em um número de 1 a 100.\n" +
"Tente adivinhar! 🤔"
));
logger.done("!adivinhação", "jogo iniciado");
}],
["parar", async (chat) => {
pararJogo(chat.id._serialized);
await chat.sendMessage(botMsg("🛑 Jogo encerrado."));
logger.done("!adivinhação", "jogo parado");
}],
]);
export async function cmdAdivinhacao(msg, chat, _chatId, args) {
if (!args[0]) {
await chat.sendMessage(botMsg(
"🎮 *Jogo de adivinhação:*\n\n" +
"`!adivinhação começar` — inicia o jogo\n" +
"`!adivinhação parar` — encerra o jogo"
));
return;
}
const subcomando = SUBCOMANDOS.get(args[0]);
if (!subcomando) {
await chat.sendMessage(botMsg(
`❌ Subcomando *${args[0]}* não existe.\n\n` +
"Use `!adivinhação começar` ou `!adivinhação parar`."
));
logger.warn(`!adivinhação — subcomando desconhecido: ${args[0]}`);
return;
}
await subcomando(chat);
}

View File

@@ -1,15 +0,0 @@
import { enqueueDownload } from "../../download/queue.js";
import { botMsg } from "../../utils/botMsg.js";
import { logger } from "../../logger/logger.js";
export async function cmdAudio(msg, chat, chatId, args) {
if (!args[0]) {
await msg.reply(botMsg("❌ Você precisa informar um link.\n\nExemplo: `!audio https://youtube.com/...`"));
logger.warn("!audio sem link");
return;
}
await msg.reply(botMsg("⏳ Baixando o áudio, aguarde..."));
enqueueDownload("audio", args[0], msg, chatId);
logger.done("!audio", `enfileirado → ${args[0]}`);
}

View File

@@ -1,26 +0,0 @@
import { iniciarSessao, gerarSticker, help } from "../logic/figurinha.js";
import { stickerSessions } from "../logic/stickerSessions.js";
import { botMsg } from "../../utils/botMsg.js";
export async function cmdFigurinha(msg, chat, _chatId, args) {
const author = msg.author || msg.from;
const name = msg._data?.notifyName || author.replace(/(:\d+)?@.*$/, "");
const groupId = chat.id._serialized;
if (args[0] === "criar") {
await gerarSticker(msg, groupId);
return;
}
if (stickerSessions.has(groupId)) {
await msg.reply(botMsg(
"⚠️ Já existe uma sessão aberta.\n\n" +
"Envie as mídias e depois use `!figurinha criar`.\n" +
"Ou aguarde 2 minutos para a sessão expirar."
));
return;
}
iniciarSessao(groupId, author, msg);
await msg.reply(botMsg(`✅ Sessão iniciada por *${name}*!\n\n${help}`));
}

View File

@@ -1,24 +0,0 @@
import { botMsg } from "../../utils/botMsg.js";
const HELP = new Map([
["ping", "> `!ping`\nResponde pong."],
["video", "> `!video <link>`\nBaixa vídeo da internet."],
["audio", "> `!audio <link>`\nBaixa áudio da internet."],
["figurinha", "> `!figurinha`\nTransforma imagem/GIF em sticker."],
]);
/**
* Envia a descrição de um comando específico.
* @param {string} cmd — nome do comando (sem prefixo)
* @param {object} chat
*/
export async function processarInfo(cmd, chat) {
const texto = HELP.get(cmd);
if (!texto) {
await chat.sendMessage(botMsg(`❌ Comando '${cmd}' não encontrado.`));
return;
}
await chat.sendMessage(botMsg(texto));
}

View File

@@ -1,11 +0,0 @@
import { botMsg } from "../../utils/botMsg.js";
export async function cmdMany(msg, chat) {
await chat.sendMessage(botMsg(
"*Comandos disponíveis:*\n\n" +
"🎬 `!video <link>` — baixa um vídeo\n" +
"🎵 `!audio <link>` — baixa um áudio\n" +
"🖼️ `!figurinha` — cria figurinhas\n" +
"🎮 `!adivinhação começar|parar` — jogo de adivinhar número\n"
));
}

View File

@@ -1,5 +0,0 @@
import { botMsg } from "../../utils/botMsg.js";
export async function cmdObrigado(msg) {
await msg.reply(botMsg("😊 Por nada!"));
}

View File

@@ -1,15 +0,0 @@
import { enqueueDownload } from "../../download/queue.js";
import { botMsg } from "../../utils/botMsg.js";
import { logger } from "../../logger/logger.js";
export async function cmdVideo(msg, chat, chatId, args) {
if (!args[0]) {
await msg.reply(botMsg("❌ Você precisa informar um link.\n\nExemplo: `!video https://youtube.com/...`"));
logger.warn("!video sem link");
return;
}
await msg.reply(botMsg("⏳ Baixando o vídeo, aguarde..."));
enqueueDownload("video", args[0], msg, chatId);
logger.done("!video", `enfileirado → ${args[0]}`);
}

View File

@@ -1,33 +0,0 @@
import { parseCommand } from "./parser.js";
import { commandRegistry } from "./registry.js";
import { logger } from "../logger/logger.js";
import { botMsg } from "../utils/botMsg.js";
/**
* Roteia a mensagem para o handler correto.
* Não conhece nenhum comando — apenas delega.
*/
export async function processarComando(msg, chat, chatId) {
const { cmd, args, valid } = parseCommand(msg.body);
if (!valid) return;
const handler = commandRegistry.get(cmd);
if (!handler) {
logger.warn(`Comando desconhecido: ${cmd}`);
return;
}
logger.cmd(cmd);
try {
await handler(msg, chat, chatId, args);
} catch (err) {
logger.error(`Falha em ${cmd}${err.message}`);
await chat.sendMessage(botMsg(
"❌ Algo deu errado ao executar esse comando.\n" +
"Tente novamente em instantes."
));
}
}

View File

@@ -1,192 +0,0 @@
import fs from "fs";
import path from "path";
import os from "os";
import { execFile } from "child_process";
import { promisify } from "util";
import pkg from "whatsapp-web.js";
import { createSticker } from "wa-sticker-formatter";
import { client } from "../../client/whatsappClient.js";
import { botMsg } from "../../utils/botMsg.js";
import { emptyFolder } from "../../utils/file.js";
import { stickerSessions } from "./stickerSessions.js"; // ← sem circular
import { logger } from "../../logger/logger.js";
const { MessageMedia } = pkg;
const execFileAsync = promisify(execFile);
// ── Constantes ────────────────────────────────────────────────
const DOWNLOADS_DIR = path.resolve("downloads");
const FFMPEG = os.platform() === "win32" ? ".\\bin\\ffmpeg.exe" : "./bin/ffmpeg";
const MAX_STICKER_SIZE = 900 * 1024;
const SESSION_TIMEOUT = 2 * 60 * 1000;
const MAX_MEDIA = 30;
// ── Helpers ───────────────────────────────────────────────────
function ensureDownloadsDir() {
fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
}
function cleanupFiles(...files) {
for (const f of files) {
try { if (f && fs.existsSync(f)) fs.unlinkSync(f); } catch { /* ignora */ }
}
}
async function convertVideoToGif(inputPath, outputPath, fps = 12) {
const clampedFps = Math.min(fps, 12);
const filter = [
`fps=${clampedFps},scale=512:512:flags=lanczos,split[s0][s1]`,
`[s0]palettegen=max_colors=256:reserve_transparent=1[p]`,
`[s1][p]paletteuse=dither=bayer`,
].join(";");
await execFileAsync(FFMPEG, ["-i", inputPath, "-filter_complex", filter, "-loop", "0", "-y", outputPath]);
}
async function resizeToSticker(inputPath, outputPath) {
await execFileAsync(FFMPEG, ["-i", inputPath, "-vf", "scale=512:512:flags=lanczos", "-y", outputPath]);
}
async function createStickerWithFallback(stickerInputPath, isAnimated) {
for (const quality of [80, 60, 40, 20]) {
const buffer = await createSticker(fs.readFileSync(stickerInputPath), {
pack: "Criada por ManyBot\n",
author: "\ngithub.com/synt-xerror/manybot",
type: isAnimated ? "FULL" : "STATIC",
categories: ["🤖"],
quality,
});
if (buffer.length <= MAX_STICKER_SIZE) return buffer;
}
throw new Error("Não foi possível reduzir o sticker para menos de 900 KB.");
}
// ── Textos ────────────────────────────────────────────────────
export const help =
"📌 *Como criar figurinhas:*\n\n" +
"1⃣ Digite `!figurinha` para iniciar\n" +
"2⃣ Envie as imagens, GIFs ou vídeos que quer transformar\n" +
"3⃣ Digite `!figurinha criar` para gerar as figurinhas\n\n" +
"⏳ A sessão expira em 2 minutos se nenhuma mídia for enviada.";
// ── Sessão ────────────────────────────────────────────────────
export function iniciarSessao(chatId, author, msg) {
if (stickerSessions.has(chatId)) return false;
const timeout = setTimeout(async () => {
stickerSessions.delete(chatId);
try {
await msg.reply(botMsg(
"⏰ *Sessão expirada!*\n\n" +
"Você demorou mais de 2 minutos para enviar as mídias.\n" +
"Digite `!figurinha` para começar de novo."
));
} catch (err) {
logger.warn(`Erro ao notificar expiração da sessão: ${err.message}`);
}
}, SESSION_TIMEOUT);
stickerSessions.set(chatId, { author, medias: [], timeout });
return true;
}
// ── Coleta de mídia ───────────────────────────────────────────
export async function coletarMidia(msg) {
const chat = await msg.getChat();
const chatId = chat.id._serialized;
const session = stickerSessions.get(chatId);
if (!session) return;
const sender = msg.author || msg.from;
if (sender !== session.author) return;
if (!msg.hasMedia) return;
const media = await msg.downloadMedia();
if (!media) return;
const isGif =
media.mimetype === "image/gif" ||
(media.mimetype === "video/mp4" && msg._data?.isGif);
const isSupported =
media.mimetype?.startsWith("image/") ||
media.mimetype?.startsWith("video/") ||
isGif;
if (!isSupported) return;
if (session.medias.length < MAX_MEDIA) {
session.medias.push(media);
}
}
// ── Geração de stickers ───────────────────────────────────────
export async function gerarSticker(msg, chatId) {
const sender = msg.author || msg.from;
const session = stickerSessions.get(chatId);
if (!session) {
return msg.reply(botMsg("❌ *Nenhuma sessão ativa.*\n\n" + help));
}
if (session.author !== sender) {
return msg.reply(botMsg("🚫 Só quem digitou `!figurinha` pode usar `!figurinha criar`."));
}
if (!session.medias.length) {
return msg.reply(botMsg("📭 *Você ainda não enviou nenhuma mídia!*\n\n" + help));
}
clearTimeout(session.timeout);
await msg.reply(botMsg("⏳ Gerando suas figurinhas, aguarde um momento..."));
ensureDownloadsDir();
for (const media of session.medias) {
try {
const ext = media.mimetype.split("/")[1];
const isVideo = media.mimetype.startsWith("video/");
const isGif = media.mimetype === "image/gif";
const isAnimated = isVideo || isGif;
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const inputPath = path.join(DOWNLOADS_DIR, `${id}.${ext}`);
const gifPath = path.join(DOWNLOADS_DIR, `${id}.gif`);
const resizedPath = path.join(DOWNLOADS_DIR, `${id}-scaled.${ext}`);
fs.writeFileSync(inputPath, Buffer.from(media.data, "base64"));
let stickerInputPath;
if (isAnimated) {
await convertVideoToGif(inputPath, gifPath, isVideo ? 12 : 24);
stickerInputPath = gifPath;
} else {
await resizeToSticker(inputPath, resizedPath);
stickerInputPath = resizedPath;
}
const stickerBuffer = await createStickerWithFallback(stickerInputPath, isAnimated);
const stickerMedia = new MessageMedia("image/webp", stickerBuffer.toString("base64"));
await client.sendMessage(chatId, stickerMedia, { sendMediaAsSticker: true });
cleanupFiles(inputPath, gifPath, resizedPath);
} catch (err) {
logger.error(`Erro ao gerar sticker: ${err.message}`);
await msg.reply(botMsg(
"⚠️ Não consegui criar uma das figurinhas.\n" +
"Tente reenviar essa mídia ou use outro formato (JPG, PNG, GIF, MP4)."
));
}
}
await msg.reply(botMsg("✅ *Figurinhas criadas com sucesso!*\nSalve as que quiser no seu WhatsApp. 😄"));
stickerSessions.delete(chatId);
emptyFolder("downloads");
}

View File

@@ -1,61 +0,0 @@
import { botMsg } from "../../../utils/botMsg.js";
/**
* Estado dos jogos ativos, keyed por chatId.
* Permite múltiplos grupos jogando simultaneamente sem conflito.
* @type {Map<string, number>}
*/
const jogosAtivos = new Map();
const RANGE = { min: 1, max: 100 };
const sorteio = () =>
Math.floor(Math.random() * (RANGE.max - RANGE.min + 1)) + RANGE.min;
/**
* @param {string} chatId
*/
export function iniciarJogo(chatId) {
const numero = sorteio();
jogosAtivos.set(chatId, numero);
return numero;
}
/**
* @param {string} chatId
*/
export function pararJogo(chatId) {
jogosAtivos.delete(chatId);
}
/**
* Processa uma tentativa de adivinhação.
* @param {import("whatsapp-web.js").Message} msg
* @param {import("whatsapp-web.js").Chat} chat
*/
export async function processarJogo(msg, chat) {
const chatId = chat.id._serialized;
const numero = jogosAtivos.get(chatId);
if (numero === undefined) return;
const tentativa = msg.body.trim();
if (!/^\d+$/.test(tentativa)) return;
const num = parseInt(tentativa, 10);
if (num < RANGE.min || num > RANGE.max) {
await msg.reply(botMsg(`⚠️ Digite um número entre ${RANGE.min} e ${RANGE.max}.`));
return;
}
if (num === numero) {
await msg.reply(botMsg(
`🎉 *Acertou!* O número era ${numero}!\n\n` +
"Use \`!adivinhação começar\` para jogar de novo."
));
pararJogo(chatId);
} else {
await chat.sendMessage(botMsg(num > numero ? "📉 Tente um número *menor*!" : "📈 Tente um número *maior*!"));
}
}

View File

@@ -1,7 +0,0 @@
/**
* Armazena sessões ativas de criação de figurinha.
* Módulo neutro — não importa nada do projeto, pode ser importado por qualquer um.
*
* @type {Map<string, { author: string, medias: object[], timeout: NodeJS.Timeout }>}
*/
export const stickerSessions = new Map();

View File

@@ -1,24 +0,0 @@
import { CMD_PREFIX } from "../config.js";
/**
* @typedef {Object} ParsedCommand
* @property {string} cmd — ex: "!video", "a"
* @property {string[]} args — tokens restantes
* @property {boolean} valid — false se não for um comando reconhecível
*/
/**
* Extrai comando e argumentos de uma mensagem.
* Retorna `valid: false` para mensagens que não são comandos.
*
* @param {string} body
* @returns {ParsedCommand}
*/
export function parseCommand(body) {
const tokens = body?.trim().split(/\s+/) ?? [];
const cmd = tokens[0]?.toLowerCase() ?? "";
const args = tokens.slice(1);
const valid = cmd.startsWith(CMD_PREFIX) || cmd === "a";
return { cmd, args, valid };
}

View File

@@ -1,25 +0,0 @@
import { cmdMany } from "./handlers/many.js";
import { cmdVideo } from "./handlers/video.js";
import { cmdAudio } from "./handlers/audio.js";
import { cmdFigurinha } from "./handlers/figurinha.js";
import { cmdAdivinhacao } from "./handlers/adivinhacao.js";
import { cmdObrigado } from "./handlers/obrigado.js";
import { cmdA } from "./handlers/a.js";
/**
* Mapa de comando → handler.
* Cada handler tem a assinatura: (msg, chat, chatId, args) => Promise<void>
*
* @type {Map<string, Function>}
*/
export const commandRegistry = new Map([
["!many", cmdMany],
["!video", cmdVideo],
["!audio", cmdAudio],
["!figurinha", cmdFigurinha],
["!adivinhação", cmdAdivinhacao],
["!obrigado", cmdObrigado],
["!valeu", cmdObrigado],
["!brigado", cmdObrigado],
["a", cmdA],
]);

View File

@@ -1,7 +1,13 @@
/**
* config.js
*
* Lê e parseia o manybot.conf.
* Suporta listas multilinhas e comentários inline.
*/
import fs from "fs"; import fs from "fs";
function parseConf(raw) { function parseConf(raw) {
// Remove comentários inline e de linha inteira, preservando estrutura
const lines = raw.split("\n"); const lines = raw.split("\n");
const cleaned = []; const cleaned = [];
@@ -9,13 +15,11 @@ function parseConf(raw) {
let buffer = ""; let buffer = "";
for (let line of lines) { for (let line of lines) {
// Remove comentário inline (# ...) — mas só fora de strings
line = line.replace(/#.*$/, "").trim(); line = line.replace(/#.*$/, "").trim();
if (!line) continue; if (!line) continue;
if (!insideList) { if (!insideList) {
if (line.includes("=[") && !line.includes("]")) { if (line.includes("=[") && !line.includes("]")) {
// Início de lista multilinha
insideList = true; insideList = true;
buffer = line; buffer = line;
} else { } else {
@@ -24,7 +28,6 @@ function parseConf(raw) {
} else { } else {
buffer += line; buffer += line;
if (line.includes("]")) { if (line.includes("]")) {
// Fim da lista
insideList = false; insideList = false;
cleaned.push(buffer); cleaned.push(buffer);
buffer = ""; buffer = "";
@@ -32,7 +35,6 @@ function parseConf(raw) {
} }
} }
// Parseia cada linha chave=valor
const result = {}; const result = {};
for (const line of cleaned) { for (const line of cleaned) {
const eqIdx = line.indexOf("="); const eqIdx = line.indexOf("=");
@@ -59,6 +61,11 @@ const raw = fs.readFileSync("manybot.conf", "utf8");
const config = parseConf(raw); const config = parseConf(raw);
export const CLIENT_ID = config.CLIENT_ID ?? "bot_permanente"; export const CLIENT_ID = config.CLIENT_ID ?? "bot_permanente";
export const BOT_PREFIX = config.BOT_PREFIX ?? "🤖 *ManyBot:* ";
export const CMD_PREFIX = config.CMD_PREFIX ?? "!"; export const CMD_PREFIX = config.CMD_PREFIX ?? "!";
export const CHATS = config.CHATS ?? []; export const CHATS = config.CHATS ?? [];
/** Lista de plugins ativos — ex: PLUGINS=[video, audio, hello] */
export const PLUGINS = config.PLUGINS ?? [];
/** Exporta o config completo para plugins que precisam de valores customizados */
export const CONFIG = config;

View File

@@ -1,112 +0,0 @@
import { spawn } from "child_process";
import { execFile } from "child_process";
import { promisify } from "util";
import fs from "fs";
import path from "path";
import os from "os";
import { logger } from "../logger/logger.js";
const execFileAsync = promisify(execFile);
const DOWNLOADS_DIR = path.resolve("downloads");
const YT_DLP = os.platform() === "win32" ? ".\\bin\\yt-dlp.exe" : "./bin/yt-dlp";
const FFMPEG = os.platform() === "win32" ? ".\\bin\\ffmpeg.exe" : "./bin/ffmpeg";
const ARGS_BASE = [
"--extractor-args", "youtube:player_client=android",
"--print", "after_move:filepath",
"--cookies", "cookies.txt",
"--add-header", "User-Agent:Mozilla/5.0",
"--add-header", "Referer:https://www.youtube.com/",
"--retries", "4",
"--fragment-retries", "5",
"--socket-timeout", "15",
"--sleep-interval", "1",
"--max-sleep-interval", "4",
"--no-playlist",
];
// Ambos baixam como vídeo — áudio é convertido depois via ffmpeg
const ARGS_BY_TYPE = {
video: ["-f", "bv+ba/best"],
audio: ["-f", "bv+ba/best"], // baixa vídeo, converte depois
};
/**
* Baixa um vídeo ou áudio via yt-dlp.
* Para áudio, baixa o vídeo e converte para mp3 com ffmpeg.
* @param {"video"|"audio"} type
* @param {string} url
* @param {string} id
* @returns {Promise<string>} caminho do arquivo final
*/
export async function download(type, url, id) {
fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
const output = path.join(DOWNLOADS_DIR, `${id}.%(ext)s`);
const args = [...ARGS_BASE, ...ARGS_BY_TYPE[type], "--output", output, url];
const videoPath = await runProcess(YT_DLP, args, type);
if (type === "audio") {
return convertToMp3(videoPath, id);
}
return videoPath;
}
/**
* Converte um arquivo de vídeo para mp3 via ffmpeg.
* Remove o vídeo original após a conversão.
* @param {string} videoPath
* @param {string} id
* @returns {Promise<string>} caminho do mp3
*/
async function convertToMp3(videoPath, id) {
const mp3Path = path.join(DOWNLOADS_DIR, `${id}.mp3`);
await execFileAsync(FFMPEG, [
"-i", videoPath,
"-vn", // sem vídeo
"-ar", "44100", // sample rate
"-ac", "2", // stereo
"-b:a", "192k", // bitrate
"-y", // sobrescreve se existir
mp3Path,
]);
fs.unlinkSync(videoPath); // remove o vídeo intermediário
return mp3Path;
}
// ── Compat ────────────────────────────────────────────────────
export const get_video = (url, id) => download("video", url, id);
export const get_audio = (url, id) => download("audio", url, id);
// ── Interno ───────────────────────────────────────────────────
function runProcess(cmd, args, type) {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args);
let stdout = "";
proc.stdout.on("data", (data) => { stdout += data.toString(); });
proc.stderr.on("data", (data) => { logger.warn(`yt-dlp [${type}]: ${data.toString().trim()}`); });
proc.on("close", (code) => {
if (code !== 0) {
return reject(new Error(
`Não foi possível baixar o ${type}. Verifique se o link é válido e tente novamente.`
));
}
const filepath = stdout.trim().split("\n").filter(Boolean).at(-1);
if (!filepath || !fs.existsSync(filepath)) {
return reject(new Error(
"O download foi concluído, mas o arquivo não foi encontrado. Tente novamente."
));
}
resolve(filepath);
});
});
}

View File

@@ -1,15 +0,0 @@
/**
* Retorna o MIME type e extensão para cada tipo de download suportado.
* @param {"video"|"audio"} type
* @returns {{ mime: string, label: string }}
*/
export function resolveMediaType(type) {
const types = {
video: { mime: "video/mp4", label: "vídeo" },
audio: { mime: "audio/mpeg", label: "áudio" },
};
const resolved = types[type];
if (!resolved) throw new Error(`Tipo de mídia desconhecido: ${type}`);
return resolved;
}

View File

@@ -1,74 +1,54 @@
import fs from "fs";
import path from "path";
import pkg from "whatsapp-web.js";
import { download } from "./downloader.js";
import { resolveMediaType } from "./mediaType.js";
import { botMsg } from "../utils/botMsg.js";
import { emptyFolder } from "../utils/file.js";
import { logger } from "../logger/logger.js";
import client from "../client/whatsappClient.js";
const { MessageMedia } = pkg;
/** /**
* @typedef {{ type: "video"|"audio", url: string, msg: object, chatId: string }} DownloadJob * src/download/queue.js
*
* Fila de execução sequencial para jobs pesados (downloads, conversões).
* Garante que apenas um job roda por vez — sem sobrecarregar yt-dlp ou ffmpeg.
*
* O plugin passa uma `workFn` que faz tudo: baixar, converter, enviar.
* A fila só garante a sequência e trata erros.
*
* Uso:
* import { enqueue } from "../../src/download/queue.js";
* enqueue(async () => { ... toda a lógica do plugin ... }, onError);
*/ */
/** @type {DownloadJob[]} */ import { logger } from "../logger/logger.js";
/**
* @typedef {{
* workFn: () => Promise<void>,
* errorFn: (err: Error) => Promise<void>,
* }} Job
*/
/** @type {Job[]} */
let queue = []; let queue = [];
let processing = false; let processing = false;
/** /**
* Adiciona um job à fila e inicia o processamento se estiver idle. * Adiciona um job à fila e inicia o processamento se estiver idle.
* @param {"video"|"audio"} type *
* @param {string} url * @param {Function} workFn — async () => void — toda a lógica do plugin
* @param {object} msg * @param {Function} errorFn — async (err) => void — chamado se workFn lançar
* @param {string} chatId
*/ */
export function enqueueDownload(type, url, msg, chatId) { export function enqueue(workFn, errorFn) {
queue.push({ type, url, msg, chatId }); queue.push({ workFn, errorFn });
if (!processing) processQueue(); if (!processing) processQueue();
} }
async function processQueue() { async function processQueue() {
processing = true; processing = true;
while (queue.length) { while (queue.length) {
const job = queue.shift(); await processJob(queue.shift());
await processJob(job);
} }
processing = false; processing = false;
} }
/** async function processJob({ workFn, errorFn }) {
* Executa um único job: baixa, envia e limpa.
* @param {DownloadJob} job
*/
async function processJob(job) {
const { mime, label } = resolveMediaType(job.type);
try { try {
const filePath = await download(job.type, job.url, job.msg.id._serialized); await workFn();
const media = new MessageMedia(
mime,
fs.readFileSync(filePath).toString("base64"),
path.basename(filePath)
);
await client.sendMessage(job.chatId, media);
fs.unlinkSync(filePath);
emptyFolder("downloads");
logger.done(`download:${job.type}`, job.url);
} catch (err) { } catch (err) {
logger.error(`Falha ao baixar ${label}${err.message}`); logger.error(`Falha no job${err.message}`);
await job.msg.reply(botMsg( try { await errorFn(err); } catch { }
`❌ Não consegui baixar o ${label}.\n\n` +
"Verifique se o link é válido e tente novamente.\n" +
"Se o problema persistir, o conteúdo pode estar indisponível ou protegido."
));
} }
} }

View File

@@ -1,29 +0,0 @@
import { CHATS, BOT_PREFIX } from "../config.js";
import { getChatId } from "../utils/getChatId.js";
import { processarComando } from "../commands/index.js";
import { coletarMidia } from "../commands/logic/figurinha.js";
import { processarJogo } from "../commands/logic/games/adivinhacao.js";
import { buildMessageContext } from "../logger/messageContext.js";
import { logger } from "../logger/logger.js";
/**
* Pipeline de processamento de uma mensagem recebida.
* Ordem: filtro de chat → log → mídia → comando → jogo.
*
* @param {import("whatsapp-web.js").Message} msg
*/
export async function handleMessage(msg) {
const chat = await msg.getChat();
const chatId = getChatId(chat);
if (!CHATS.includes(chatId)) return;
const ctx = await buildMessageContext(msg, chat, BOT_PREFIX);
logger.msg(ctx);
await coletarMidia(msg);
await processarComando(msg, chat, chatId);
await processarJogo(msg, chat);
logger.done("message_create", `de +${ctx.senderNumber}`);
}

View File

@@ -0,0 +1,43 @@
/**
* messageHandler.js
*
* Pipeline central de uma mensagem recebida.
*
* Ordem:
* 1. Filtra chats não permitidos (CHATS do .conf)
* — se CHATS estiver vazio, aceita todos os chats
* 2. Loga a mensagem
* 3. Passa o contexto para todos os plugins ativos
*
* O kernel não conhece nenhum comando — só distribui.
* Cada plugin decide por conta própria se age ou ignora.
*/
import { CHATS, BOT_PREFIX } from "../config.js";
import { getChatId } from "../utils/getChatId.js";
import { buildApi } from "./pluginApi.js";
import { pluginRegistry } from "./pluginLoader.js";
import { runPlugin } from "./pluginGuard.js";
import { buildMessageContext } from "../logger/messageContext.js";
import { logger } from "../logger/logger.js";
import client from "../client/whatsappClient.js";
export async function handleMessage(msg) {
const chat = await msg.getChat();
const chatId = getChatId(chat);
// CHATS vazio = aceita todos os chats
if (CHATS.length > 0 && !CHATS.includes(chatId)) return;
const ctx = await buildMessageContext(msg, chat, BOT_PREFIX);
logger.msg(ctx);
const api = buildApi({ msg, chat, client, pluginRegistry });
const context = { msg: api.msg, chat: api.chat, api };
for (const plugin of pluginRegistry.values()) {
await runPlugin(plugin, context);
}
logger.done("message_create", `de +${ctx.senderNumber}`);
}

241
src/kernel/pluginApi.js Normal file
View File

@@ -0,0 +1,241 @@
/**
* pluginApi.js
*
* Monta o objeto `api` que cada plugin recebe.
* Plugins só podem fazer o que está aqui — nunca tocam no client diretamente.
*
* O `chat` já vem filtrado pelo kernel (só chats permitidos no .conf),
* então plugins não precisam e não podem escolher destino.
*/
import { logger } from "../logger/logger.js";
import pkg from "whatsapp-web.js";
const { MessageMedia } = pkg;
/**
* @param {object} params
* @param {import("whatsapp-web.js").Message} params.msg
* @param {import("whatsapp-web.js").Chat} params.chat
* @param {Map<string, any>} params.pluginRegistry
* @returns {object} api
*/
export function buildApi({ msg, chat, client, pluginRegistry }) {
const currentChat = chat;
return {
// ── Leitura de mensagem ──────────────────────────────────
msg: {
/** Corpo da mensagem */
body: msg.body ?? "",
/** Tipo: "chat", "image", "video", "audio", "ptt", "sticker", "document" */
type: msg.type,
/** true se a mensagem veio do próprio bot */
fromMe: msg.fromMe,
/** ID de quem enviou (ex: "5511999999999@c.us") */
sender: msg.author || msg.from,
/** Nome de exibição de quem enviou */
senderName: msg._data?.notifyName || String(msg.from).replace(/(:\d+)?@.*$/, ""),
/** Tokens: ["!video", "https://..."] */
args: msg.body?.trim().split(/\s+/) ?? [],
/**
* Verifica se a mensagem é um comando específico.
* @param {string} cmd — ex: "!hello"
*/
is(cmd) {
return msg.body?.trim().toLowerCase().startsWith(cmd.toLowerCase());
},
/** true se a mensagem tem mídia anexada */
hasMedia: msg.hasMedia,
/** true se a mídia é um GIF (vídeo curto em loop) */
isGif: msg._data?.isGif ?? false,
/**
* Baixa a mídia da mensagem.
* Retorna um objeto neutro { mimetype, data } — sem expor MessageMedia.
* @returns {Promise<{ mimetype: string, data: string } | null>}
*/
async downloadMedia() {
const media = await msg.downloadMedia();
if (!media) return null;
return { mimetype: media.mimetype, data: media.data };
},
/** true se a mensagem é uma resposta a outra */
hasReply: msg.hasQuotedMsg,
/**
* Retorna a mensagem citada, se existir.
* @returns {Promise<import("whatsapp-web.js").Message|null>}
*/
async getReply() {
if (!msg.hasQuotedMsg) return null;
return msg.getQuotedMessage();
},
/**
* Responde diretamente à mensagem (com quote).
* @param {string} text
*/
async reply(text) {
return msg.reply(text);
},
},
// ── Envio para o chat atual ──────────────────────────────
/**
* Envia texto simples.
* @param {string} text
*/
async send(text) {
return currentChat.sendMessage(text);
},
/**
* Envia uma mídia (imagem, vídeo, áudio, documento).
* @param {import("whatsapp-web.js").MessageMedia} media
* @param {string} [caption]
*/
async sendMedia(media, caption = "") {
return currentChat.sendMessage(media, { caption });
},
/**
* Envia um arquivo de vídeo a partir de um caminho local.
* @param {string} filePath
* @param {string} [caption]
*/
async sendVideo(filePath, caption = "") {
const media = MessageMedia.fromFilePath(filePath);
return currentChat.sendMessage(media, { caption });
},
/**
* Envia um arquivo de áudio a partir de um caminho local.
* @param {string} filePath
*/
async sendAudio(filePath) {
const media = MessageMedia.fromFilePath(filePath);
return currentChat.sendMessage(media, { sendAudioAsVoice: true });
},
/**
* Envia uma imagem a partir de um caminho local.
* @param {string} filePath
* @param {string} [caption]
*/
async sendImage(filePath, caption = "") {
const media = MessageMedia.fromFilePath(filePath);
return currentChat.sendMessage(media, { caption });
},
/**
* Envia uma figurinha (sticker).
* Aceita filePath (string) ou buffer (Buffer) — o plugin nunca precisa
* saber que MessageMedia existe.
* @param {string | Buffer} source
*/
async sendSticker(source) {
const media = typeof source === "string"
? MessageMedia.fromFilePath(source)
: new MessageMedia("image/webp", source.toString("base64"));
return currentChat.sendMessage(media, { sendMediaAsSticker: true });
},
// ── Envio para chat específico ───────────────────────────
/**
* Envia texto para um chat específico por ID.
* @param {string} chatId
* @param {string} text
*/
async sendTo(chatId, text) {
return client.sendMessage(chatId, text);
},
/**
* Envia vídeo para um chat específico por ID.
* @param {string} chatId
* @param {string} filePath
* @param {string} [caption]
*/
async sendVideoTo(chatId, filePath, caption = "") {
const media = MessageMedia.fromFilePath(filePath);
return client.sendMessage(chatId, media, { caption });
},
/**
* Envia áudio para um chat específico por ID.
* @param {string} chatId
* @param {string} filePath
*/
async sendAudioTo(chatId, filePath) {
const media = MessageMedia.fromFilePath(filePath);
return client.sendMessage(chatId, media, { sendAudioAsVoice: true });
},
/**
* Envia imagem para um chat específico por ID.
* @param {string} chatId
* @param {string} filePath
* @param {string} [caption]
*/
async sendImageTo(chatId, filePath, caption = "") {
const media = MessageMedia.fromFilePath(filePath);
return client.sendMessage(chatId, media, { caption });
},
/**
* Envia figurinha para um chat específico por ID.
* @param {string} chatId
* @param {string | Buffer} source
*/
async sendStickerTo(chatId, source) {
const media = typeof source === "string"
? MessageMedia.fromFilePath(source)
: new MessageMedia("image/webp", source.toString("base64"));
return client.sendMessage(chatId, media, { sendMediaAsSticker: true });
},
// ── Acesso a outros plugins ──────────────────────────────
/**
* Retorna a API pública de outro plugin (o que ele exportou em `exports`).
* Retorna null se o plugin não existir ou estiver desativado.
* @param {string} name — nome do plugin (pasta em /plugins)
* @returns {any|null}
*/
getPlugin(name) {
return pluginRegistry.get(name)?.exports ?? null;
},
// ── Logger ───────────────────────────────────────────────
log: {
info: (...a) => logger.info(...a),
warn: (...a) => logger.warn(...a),
error: (...a) => logger.error(...a),
success: (...a) => logger.success(...a),
},
// ── Info do chat atual ───────────────────────────────────
chat: {
id: currentChat.id._serialized,
name: currentChat.name || currentChat.id.user,
isGroup: /@g\.us$/.test(currentChat.id._serialized),
},
};
}

36
src/kernel/pluginGuard.js Normal file
View File

@@ -0,0 +1,36 @@
/**
* pluginGuard.js
*
* Executa um plugin com segurança.
* Se o plugin lançar um erro:
* - Loga o erro com contexto
* - Marca o plugin como "error" no registry
* - Nunca derruba o bot
*
* Plugins desativados ou com erro são ignorados silenciosamente.
*/
import { logger } from "../logger/logger.js";
import { pluginRegistry } from "./pluginLoader.js";
/**
* @param {object} plugin — entrada do pluginRegistry
* @param {object} context — { msg, chat, api }
*/
export async function runPlugin(plugin, context) {
if (plugin.status !== "active") return;
try {
await plugin.run(context);
} catch (err) {
// Desativa o plugin para não continuar quebrando
plugin.status = "error";
plugin.error = err;
pluginRegistry.set(plugin.name, plugin);
logger.error(
`Plugin "${plugin.name}" desativado após erro: ${err.message}`,
`\n Stack: ${err.stack?.split("\n")[1]?.trim() ?? ""}`
);
}
}

View File

@@ -0,0 +1,88 @@
/**
* pluginLoader.js
*
* Responsável por:
* 1. Ler quais plugins estão ativos no manybot.conf (PLUGINS=[...])
* 2. Carregar cada plugin da pasta /plugins
* 3. Registrar no pluginRegistry com status e exports públicos
* 4. Expor o pluginRegistry para o kernel e para a pluginApi
*/
import fs from "fs";
import path from "path";
import { logger } from "../logger/logger.js";
const PLUGINS_DIR = path.resolve("src/plugins");
/**
* Cada entrada no registry:
* {
* name: string,
* status: "active" | "disabled" | "error",
* run: async function({ msg, chat, api }) — a função default do plugin
* exports: any — o que o plugin expôs via `export const api = { ... }`
* error: Error | null
* }
*
* @type {Map<string, object>}
*/
export const pluginRegistry = new Map();
/**
* Carrega todos os plugins ativos listados em `activePlugins`.
* Chamado uma vez na inicialização do bot.
*
* @param {string[]} activePlugins — nomes dos plugins ativos (do .conf)
*/
export async function loadPlugins(activePlugins) {
if (!fs.existsSync(PLUGINS_DIR)) {
logger.warn("Pasta /plugins não encontrada. Nenhum plugin carregado.");
return;
}
for (const name of activePlugins) {
await loadPlugin(name);
}
const total = pluginRegistry.size;
const ativos = [...pluginRegistry.values()].filter(p => p.status === "active").length;
const erros = total - ativos;
logger.success(`Plugins carregados: ${ativos} ativos${erros ? `, ${erros} com erro` : ""}`);
}
/**
* Carrega um único plugin pelo nome.
* @param {string} name
*/
async function loadPlugin(name) {
const pluginPath = path.join(PLUGINS_DIR, name, "index.js");
if (!fs.existsSync(pluginPath)) {
logger.warn(`Plugin "${name}" não encontrado em ${pluginPath}`);
pluginRegistry.set(name, { name, status: "disabled", run: null, exports: null, error: null });
return;
}
try {
const mod = await import(pluginPath);
// O plugin deve exportar uma função default — essa é a função chamada a cada mensagem
if (typeof mod.default !== "function") {
throw new Error(`Plugin "${name}" não exporta uma função default`);
}
pluginRegistry.set(name, {
name,
status: "active",
run: mod.default,
exports: mod.api ?? null, // exports públicos opcionais (api de outros plugins)
error: null,
});
logger.info(`Plugin carregado: ${name}`);
} catch (err) {
logger.error(`Falha ao carregar plugin "${name}": ${err.message}`);
pluginRegistry.set(name, { name, status: "error", run: null, exports: null, error: err });
}
}

47
src/kernel/scheduler.js Normal file
View File

@@ -0,0 +1,47 @@
/**
* scheduler.js
*
* Permite que plugins registrem tarefas agendadas via cron.
* Usa node-cron por baixo, mas plugins nunca importam node-cron diretamente —
* eles chamam apenas api.schedule(cron, fn).
*
* Uso no plugin:
* import { schedule } from "many";
* schedule("0 9 * * 1", async () => { await api.send("Bom dia!"); });
*/
import cron from "node-cron";
import { logger } from "../logger/logger.js";
/** Lista de tasks ativas (para eventual teardown) */
const tasks = [];
/**
* Registra uma tarefa cron.
* @param {string} expression — expressão cron ex: "0 9 * * 1"
* @param {Function} fn — função async a executar
* @param {string} pluginName — nome do plugin (para log)
*/
export function schedule(expression, fn, pluginName = "unknown") {
if (!cron.validate(expression)) {
logger.warn(`Plugin "${pluginName}" registrou expressão cron inválida: "${expression}"`);
return;
}
const task = cron.schedule(expression, async () => {
try {
await fn();
} catch (err) {
logger.error(`Erro no agendamento do plugin "${pluginName}": ${err.message}`);
}
});
tasks.push({ pluginName, expression, task });
logger.info(`Agendamento registrado — plugin "${pluginName}" → "${expression}"`);
}
/** Para todos os agendamentos (útil no shutdown) */
export function stopAll() {
tasks.forEach(({ task }) => task.stop());
tasks.length = 0;
}

View File

@@ -6,8 +6,6 @@ export const c = {
blue: "\x1b[34m", magenta: "\x1b[35m", blue: "\x1b[34m", magenta: "\x1b[35m",
}; };
export const SEP = `${c.gray}${"─".repeat(52)}${c.reset}`;
export const now = () => export const now = () =>
new Date().toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "medium" }); new Date().toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "medium" });
@@ -26,9 +24,9 @@ export const formatContext = (chatName, isGroup) =>
? `${c.bold}${chatName}${c.reset} ${c.dim}(grupo)${c.reset}` ? `${c.bold}${chatName}${c.reset} ${c.dim}(grupo)${c.reset}`
: `${c.bold}${chatName}${c.reset} ${c.dim}(privado)${c.reset}`; : `${c.bold}${chatName}${c.reset} ${c.dim}(privado)${c.reset}`;
export const formatBody = (body, isCommand) => export const formatBody = (body) =>
body?.trim() body?.trim()
? `${isCommand ? c.yellow : c.green}"${body.length > 200 ? body.slice(0, 200) + "..." : body}"${c.reset}` ? `${c.green}"${body.length > 200 ? body.slice(0, 200) + "..." : body}"${c.reset}`
: `${c.dim}<mídia>${c.reset}`; : `${c.dim}<mídia>${c.reset}`;
export const formatReply = (quotedName, quotedNumber, quotedPreview) => export const formatReply = (quotedName, quotedNumber, quotedPreview) =>

View File

@@ -1,5 +1,5 @@
import { import {
c, SEP, now, c, now,
formatType, formatContext, formatBody, formatReply, formatType, formatContext, formatBody, formatReply,
} from "./formatter.js"; } from "./formatter.js";
@@ -8,7 +8,7 @@ import {
* Cada método lida apenas com saída — sem lógica de negócio ou I/O externo. * Cada método lida apenas com saída — sem lógica de negócio ou I/O externo.
*/ */
export const logger = { export const logger = {
info: (...a) => console.log(`${SEP}\n${c.gray}[${now()}]${c.reset} ${c.cyan}INFO ${c.reset}`, ...a), info: (...a) => console.log(`${c.gray}[${now()}]${c.reset} ${c.cyan}INFO ${c.reset}`, ...a),
success: (...a) => console.log(`${c.gray}[${now()}]${c.reset} ${c.green}OK ${c.reset}`, ...a), success: (...a) => console.log(`${c.gray}[${now()}]${c.reset} ${c.green}OK ${c.reset}`, ...a),
warn: (...a) => console.log(`${c.gray}[${now()}]${c.reset} ${c.yellow}WARN ${c.reset}`, ...a), warn: (...a) => console.log(`${c.gray}[${now()}]${c.reset} ${c.yellow}WARN ${c.reset}`, ...a),
error: (...a) => console.log(`${c.gray}[${now()}]${c.reset} ${c.red}ERROR ${c.reset}`, ...a), error: (...a) => console.log(`${c.gray}[${now()}]${c.reset} ${c.red}ERROR ${c.reset}`, ...a),
@@ -18,23 +18,10 @@ export const logger = {
* @param {import("./messageContext.js").MessageContext} ctx * @param {import("./messageContext.js").MessageContext} ctx
*/ */
msg(ctx) { msg(ctx) {
const { chatName, isGroup, senderName, senderNumber, type, body, isCommand, quoted } = ctx; const { chatName, isGroup, senderName, senderNumber, type, body, quoted } = ctx;
const context = isGroup ? `${chatName} (grupo)` : chatName;
const typeLabel = formatType(type); const reply = quoted ? ` → Responde ${quoted.name} +${quoted.number}: "${quoted.preview}"` : "";
const context = formatContext(chatName, isGroup); console.log(`\n${c.gray}[${now()}]${c.reset} ${c.cyan}MSG${c.reset} ${context} ${c.gray}— De:${c.reset} ${c.white}${senderName}${c.reset} ${c.dim}+${senderNumber}${c.reset} ${c.gray}— Tipo:${c.reset} ${type}${c.green}"${body}"${c.reset}${c.gray}${reply}${c.reset}`);
const bodyText = formatBody(body, isCommand);
const replyLine = quoted
? formatReply(quoted.name, quoted.number, quoted.preview)
: "";
console.log(
`${SEP}\n` +
`${c.gray}[${now()}]${c.reset} ${c.cyan}MSG ${c.reset}${context}\n` +
`${c.gray} De: ${c.reset}${c.white}${senderName}${c.reset} ${c.dim}+${senderNumber}${c.reset}\n` +
`${c.gray} Tipo: ${c.reset}${typeLabel}${isCommand ? ` ${c.yellow}(bot)${c.reset}` : ""}\n` +
`${c.gray} Text: ${c.reset}${bodyText}` +
replyLine
);
}, },
cmd: (cmd, extra = "") => cmd: (cmd, extra = "") =>

View File

@@ -28,7 +28,6 @@ export async function getNumber(msg) {
* @property {string} senderNumber * @property {string} senderNumber
* @property {string} type * @property {string} type
* @property {string} body * @property {string} body
* @property {boolean} isCommand
* @property {{ name: string, number: string, preview: string } | null} quoted * @property {{ name: string, number: string, preview: string } | null} quoted
*/ */
export async function buildMessageContext(msg, chat, botPrefix) { export async function buildMessageContext(msg, chat, botPrefix) {
@@ -47,7 +46,6 @@ export async function buildMessageContext(msg, chat, botPrefix) {
senderNumber: number, senderNumber: number,
type: msg?.type || "text", type: msg?.type || "text",
body: msg.body, body: msg.body,
isCommand: !!msg.body?.trimStart().startsWith(botPrefix),
quoted, quoted,
}; };
} }

View File

@@ -1,8 +1,30 @@
import client from "./client/whatsappClient.js"; /**
import { handleMessage } from "./handlers/messageHandler.js"; * main.js
import { logger } from "./logger/logger.js"; *
* Ponto de entrada do ManyBot.
* Inicializa o cliente WhatsApp e carrega os plugins.
*/
logger.info("Iniciando ManyBot..."); import client from "./client/whatsappClient.js";
import { handleMessage } from "./kernel/messageHandler.js";
import { loadPlugins } from "./kernel/pluginLoader.js";
import { logger } from "./logger/logger.js";
import { PLUGINS } from "./config.js";
logger.info("Iniciando ManyBot...\n");
// Rede de segurança global — nenhum erro deve derrubar o bot
process.on("uncaughtException", (err) => {
logger.error(`uncaughtException — ${err.message}`, `\n Stack: ${err.stack?.split("\n")[1]?.trim() ?? ""}`);
});
process.on("unhandledRejection", (reason) => {
const msg = reason instanceof Error ? reason.message : String(reason);
logger.error(`unhandledRejection — ${msg}`);
});
// Carrega plugins antes de conectar
await loadPlugins(PLUGINS);
client.on("message_create", async (msg) => { client.on("message_create", async (msg) => {
try { try {
@@ -16,4 +38,5 @@ client.on("message_create", async (msg) => {
}); });
client.initialize(); client.initialize();
console.log("\n");
logger.info("Cliente inicializado. Aguardando conexão com WhatsApp..."); logger.info("Cliente inicializado. Aguardando conexão com WhatsApp...");

9
src/plugins/a/index.js Normal file
View File

@@ -0,0 +1,9 @@
import { forcaAtiva } from "../forca/index.js";
export default async function ({ msg }) {
if (msg.body.trim().toLowerCase() !== "a") return;
if (msg.args.length > 1) return;
if (forcaAtiva) return;
await msg.reply("B!");
}

View File

@@ -0,0 +1,79 @@
/**
* plugins/adivinhacao/index.js
*
* Estado dos jogos fica aqui dentro — isolado no plugin.
* Múltiplos grupos jogam simultaneamente sem conflito.
*/
const RANGE = { min: 1, max: 100 };
const jogosAtivos = new Map();
import { CMD_PREFIX } from "../../config.js"
const sorteio = () =>
Math.floor(Math.random() * (RANGE.max - RANGE.min + 1)) + RANGE.min;
export default async function ({ msg, api }) {
const chatId = api.chat.id;
// ── Comando adivinhação ──────────────────────────────────
if (msg.is(CMD_PREFIX + "adivinhação")) {
const sub = msg.args[1];
if (!sub) {
await api.send(
"🎮 *Jogo de adivinhação:*\n\n" +
`\`${CMD_PREFIX}adivinhação começar\` — inicia o jogo\n` +
`\`${CMD_PREFIX}adivinhação parar\` — encerra o jogo`
);
return;
}
if (sub === "começar") {
jogosAtivos.set(chatId, sorteio());
await api.send(
"🎮 *Jogo iniciado!*\n\n" +
"Estou pensando em um número de 1 a 100.\n" +
"Tente adivinhar! 🤔"
);
api.log.info(CMD_PREFIX + "adivinhação — jogo iniciado");
return;
}
if (sub === "parar") {
jogosAtivos.delete(chatId);
await api.send("🛑 Jogo encerrado.");
api.log.info(CMD_PREFIX + "adivinhação — jogo parado");
return;
}
await api.send(
`❌ Subcomando *${sub}* não existe.\n\n` +
`Use ${CMD_PREFIX} + \`adivinhação começar\` ou ${CMD_PREFIX} + \`adivinhação parar\`.`
);
return;
}
// ── Tentativas durante o jogo ─────────────────────────────
const numero = jogosAtivos.get(chatId);
if (numero === undefined) return;
const tentativa = msg.body.trim();
if (!/^\d+$/.test(tentativa)) return;
const num = parseInt(tentativa, 10);
if (num < RANGE.min || num > RANGE.max) {
await msg.reply(`⚠️ Digite um número entre ${RANGE.min} e ${RANGE.max}.`);
return;
}
if (num === numero) {
await msg.reply(
`🎉 *Acertou!* O número era ${numero}!\n\n` +
`Use ${CMD_PREFIX} + \`adivinhação começar\` para jogar de novo.`
);
jogosAtivos.delete(chatId);
} else {
await api.send(num > numero ? "📉 Tente um número *menor*!" : "📈 Tente um número *maior*!");
}
}

123
src/plugins/audio/index.js Normal file
View File

@@ -0,0 +1,123 @@
/**
* plugins/audio/index.js
*
* Baixa vídeo via yt-dlp, converte para mp3 via ffmpeg e envia no chat.
* Todo o processo (download + conversão + envio + limpeza) fica aqui.
*/
import { spawn } from "child_process";
import { execFile } from "child_process";
import { promisify } from "util";
import fs from "fs";
import path from "path";
import os from "os";
import { enqueue } from "../../download/queue.js";
import { emptyFolder } from "../../utils/file.js";
import { CMD_PREFIX } from "../../config.js";
const logStream = fs.createWriteStream("logs/audio-error.log", { flags: "a" });
const execFileAsync = promisify(execFile);
const DOWNLOADS_DIR = path.resolve("downloads");
const YT_DLP = os.platform() === "win32" ? ".\\bin\\yt-dlp.exe" : "./bin/yt-dlp";
const FFMPEG = os.platform() === "win32" ? ".\\bin\\ffmpeg.exe" : "./bin/ffmpeg";
const ARGS_BASE = [
"--extractor-args", "youtube:player_client=android",
"--print", "after_move:filepath",
"--cookies", "cookies.txt",
"--add-header", "User-Agent:Mozilla/5.0",
"--add-header", "Referer:https://www.youtube.com/",
"--retries", "4",
"--fragment-retries", "5",
"--socket-timeout", "15",
"--sleep-interval", "1",
"--max-sleep-interval", "4",
"--no-playlist",
"-f", "bv+ba/best",
];
function downloadRaw(url, id) {
return new Promise((resolve, reject) => {
fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
const output = path.join(DOWNLOADS_DIR, `${id}.%(ext)s`);
const proc = spawn(YT_DLP, [...ARGS_BASE, "--output", output, url]);
let stdout = "";
proc.on("error", err => reject(new Error(
err.code === "EACCES"
? "Sem permissão para executar o yt-dlp. Rode: chmod +x ./bin/yt-dlp"
: err.code === "ENOENT"
? "yt-dlp não encontrado em ./bin/yt-dlp"
: `Erro ao iniciar o yt-dlp: ${err.message}`
)));
proc.stdout.on("data", d => { stdout += d.toString(); });
proc.stderr.on("data", d => logStream.write(d));
proc.on("close", code => {
if (code !== 0) return reject(new Error(
"Não foi possível baixar o áudio. Verifique se o link é válido e tente novamente."
));
const filePath = stdout.trim().split("\n").filter(Boolean).at(-1);
if (!filePath || !fs.existsSync(filePath)) return reject(new Error(
"Download concluído mas arquivo não encontrado. Tente novamente."
));
resolve(filePath);
});
});
}
async function convertToMp3(videoPath, id) {
const mp3Path = path.join(DOWNLOADS_DIR, `${id}.mp3`);
await execFileAsync(FFMPEG, [
"-i", videoPath,
"-vn", // sem vídeo
"-ar", "44100", // sample rate
"-ac", "2", // stereo
"-b:a", "192k", // bitrate
"-y", // sobrescreve se existir
mp3Path,
]);
fs.unlinkSync(videoPath); // remove o vídeo intermediário
return mp3Path;
}
export default async function ({ msg, api }) {
if (!msg.is(CMD_PREFIX + "audio")) return;
const url = msg.args[1];
if (!url) {
await msg.reply(`❌ Você precisa informar um link.\n\nExemplo: \`${CMD_PREFIX}audio https://youtube.com/...\``);
return;
}
await msg.reply("⏳ Baixando o áudio, aguarde...");
const id = `audio-${Date.now()}`;
enqueue(
async () => {
const videoPath = await downloadRaw(url, id);
const mp3Path = await convertToMp3(videoPath, id);
await api.sendAudio(mp3Path);
fs.unlinkSync(mp3Path);
emptyFolder(DOWNLOADS_DIR);
api.log.info(`${CMD_PREFIX}audio concluído → ${url}`);
},
async () => {
await msg.reply(
"❌ Não consegui baixar o áudio.\n\n" +
"Verifique se o link é válido e tente novamente.\n" +
"Se o problema persistir, o conteúdo pode estar indisponível ou protegido."
);
}
);
}

View File

@@ -0,0 +1,247 @@
/**
* plugins/figurinha/index.js
*
* Modos de uso:
* comando + mídia anexa → cria 1 sticker direto
* comando + respondendo mídia → cria 1 sticker direto
* comando + mídia anexa + respondendo mídia → cria 2 stickers direto
* comando (sem mídia nenhuma) → abre sessão
* comando criar (com sessão aberta) → processa as mídias da sessão
*/
import fs from "fs";
import path from "path";
import os from "os";
import { execFile } from "child_process";
import { promisify } from "util";
import { createSticker } from "wa-sticker-formatter";
import { emptyFolder } from "../../utils/file.js";
import { CMD_PREFIX } from "../../config.js";
const execFileAsync = promisify(execFile);
// ── Constantes ────────────────────────────────────────────────
const DOWNLOADS_DIR = path.resolve("downloads");
const FFMPEG = os.platform() === "win32" ? ".\\bin\\ffmpeg.exe" : "./bin/ffmpeg";
const MAX_STICKER_SIZE = 900 * 1024;
const SESSION_TIMEOUT = 2 * 60 * 1000;
const MAX_MEDIA = 30;
const HELP =
"📌 *Como criar figurinhas:*\n\n" +
`1⃣ Envie \`${CMD_PREFIX}figurinha\` junto com uma mídia, ou respondendo uma mídia\n` +
" — o sticker é criado na hora\n\n" +
"2⃣ Ou use o modo sessão para várias mídias de uma vez:\n" +
`\`${CMD_PREFIX}figurinha\` sem mídia para iniciar\n` +
" — envie as imagens, GIFs ou vídeos\n" +
`\`${CMD_PREFIX}figurinha criar\` para gerar todas\n\n` +
"⏳ A sessão expira em 2 minutos se nenhuma mídia for enviada.";
// ── Estado interno ────────────────────────────────────────────
// { chatId → { author, medias[], timeout } }
const sessions = new Map();
// ── Conversão ─────────────────────────────────────────────────
function ensureDir() {
fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
}
function cleanup(...files) {
for (const f of files) {
try { if (f && fs.existsSync(f)) fs.unlinkSync(f); } catch { }
}
}
async function convertToGif(input, output, fps = 12) {
const filter = [
`fps=${Math.min(fps, 12)},scale=512:512:flags=lanczos,split[s0][s1]`,
`[s0]palettegen=max_colors=256:reserve_transparent=1[p]`,
`[s1][p]paletteuse=dither=bayer`,
].join(";");
await execFileAsync(FFMPEG, ["-i", input, "-filter_complex", filter, "-loop", "0", "-y", output]);
}
async function resizeImage(input, output) {
await execFileAsync(FFMPEG, ["-i", input, "-vf", "scale=512:512:flags=lanczos", "-y", output]);
}
async function buildSticker(inputPath, isAnimated) {
for (const quality of [80, 60, 40, 20]) {
const buf = await createSticker(fs.readFileSync(inputPath), {
pack: "Criada por ManyBot\n",
author: "\ngithub.com/synt-xerror/manybot",
type: isAnimated ? "FULL" : "STATIC",
categories: ["🤖"],
quality,
});
if (buf.length <= MAX_STICKER_SIZE) return buf;
}
throw new Error("Não foi possível reduzir o sticker para menos de 900 KB.");
}
/**
* Converte um objeto { mimetype, data } em sticker e envia.
* Retorna true se ok, false se falhou.
*/
async function processarUmaMedia(media, isGif, api, msg) {
ensureDir();
const ext = media.mimetype.split("/")[1];
const isVideo = media.mimetype.startsWith("video/");
const isAnimated = isVideo || isGif;
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const inputPath = path.join(DOWNLOADS_DIR, `${id}.${ext}`);
const gifPath = path.join(DOWNLOADS_DIR, `${id}.gif`);
const resizedPath = path.join(DOWNLOADS_DIR, `${id}-scaled.${ext}`);
try {
fs.writeFileSync(inputPath, Buffer.from(media.data, "base64"));
let stickerInput;
if (isAnimated) {
await convertToGif(inputPath, gifPath, isVideo ? 12 : 24);
stickerInput = gifPath;
} else {
await resizeImage(inputPath, resizedPath);
stickerInput = resizedPath;
}
const buf = await buildSticker(stickerInput, isAnimated);
await api.sendSticker(buf);
return true;
} catch (err) {
api.log.error(`Erro ao gerar sticker: ${err.message}`);
await msg.reply(
"⚠️ Não consegui criar uma das figurinhas.\n" +
"Tente reenviar essa mídia ou use outro formato (JPG, PNG, GIF, MP4)."
);
return false;
} finally {
cleanup(inputPath, gifPath, resizedPath);
}
}
/**
* Verifica se uma mídia é suportada para sticker.
*/
function isSupported(media, isGif) {
return (
media.mimetype?.startsWith("image/") ||
media.mimetype?.startsWith("video/") ||
isGif
);
}
// ── Plugin ────────────────────────────────────────────────────
export default async function ({ msg, api }) {
const chatId = api.chat.id;
if (!msg.is(CMD_PREFIX + "figurinha")) {
// ── Coleta de mídia durante sessão ──────────────────────
const session = sessions.get(chatId);
if (!session) return;
if (!msg.hasMedia) return;
if (msg.sender !== session.author) return;
const media = await msg.downloadMedia();
if (!media) return;
const gif = media.mimetype === "image/gif" ||
(media.mimetype === "video/mp4" && msg.isGif);
if (isSupported(media, gif) && session.medias.length < MAX_MEDIA) {
session.medias.push({ media, isGif: gif });
}
return;
}
// ── figurinha criar ──────────────────────────────────────
const sub = msg.args[1];
if (sub === "criar") {
const session = sessions.get(chatId);
if (!session) {
await msg.reply(`❌ *Nenhuma sessão ativa.*\n\n${HELP}`);
return;
}
if (!session.medias.length) {
await msg.reply(`📭 *Você ainda não enviou nenhuma mídia!*\n\n${HELP}`);
return;
}
clearTimeout(session.timeout);
await msg.reply("⏳ Gerando suas figurinhas, aguarde um momento...");
for (const { media, isGif } of session.medias) {
await processarUmaMedia(media, isGif, api, msg);
}
await msg.reply("✅ *Figurinhas criadas com sucesso!*\nSalve as que quiser no seu WhatsApp. 😄");
sessions.delete(chatId);
emptyFolder(DOWNLOADS_DIR);
return;
}
// ── figurinha com mídia direta ───────────────────────────
const mediasParaCriar = [];
// Mídia anexa à própria mensagem
if (msg.hasMedia) {
const media = await msg.downloadMedia();
if (media) {
const gif = media.mimetype === "image/gif" ||
(media.mimetype === "video/mp4" && msg.isGif);
if (isSupported(media, gif)) mediasParaCriar.push({ media, isGif: gif });
}
}
// Mídia da mensagem citada
if (msg.hasReply) {
const quoted = await msg.getReply();
if (quoted?.hasMedia) {
const media = await quoted.downloadMedia();
if (media) {
const gif = media.mimetype === "image/gif" ||
(media.mimetype === "video/mp4" && quoted.isGif);
if (isSupported(media, gif)) mediasParaCriar.push({ media, isGif: gif });
}
}
}
// Tem mídia para criar direto
if (mediasParaCriar.length > 0) {
await msg.reply("⏳ Gerando figurinha, aguarde...");
for (const { media, isGif } of mediasParaCriar) {
await processarUmaMedia(media, isGif, api, msg);
}
emptyFolder(DOWNLOADS_DIR);
return;
}
// ── figurinha sem mídia → abre sessão ───────────────────
if (sessions.has(chatId)) {
await msg.reply(
"⚠️ Já existe uma sessão aberta.\n\n" +
`Envie as mídias e depois use \`${CMD_PREFIX}figurinha criar\`.\n` +
"Ou aguarde 2 minutos para a sessão expirar."
);
return;
}
const timeout = setTimeout(async () => {
sessions.delete(chatId);
try {
await msg.reply(
"⏰ *Sessão expirada!*\n\n" +
"Você demorou mais de 2 minutos para enviar as mídias.\n" +
`Digite \`${CMD_PREFIX}figurinha\` para começar de novo.`
);
} catch { }
}, SESSION_TIMEOUT);
sessions.set(chatId, { author: msg.sender, medias: [], timeout });
await msg.reply(`✅ Sessão iniciada por *${msg.senderName}*!\n\n${HELP}`);
}

159
src/plugins/forca/index.js Normal file
View File

@@ -0,0 +1,159 @@
/**
* plugins/forca/index.js
*
* Estado dos jogos de forca fica aqui dentro — isolado no plugin.
* Múltiplos grupos jogam simultaneamente sem conflito.
*/
import { CMD_PREFIX } from "../../config.js";
// Estados dos jogos
const jogosAtivos = new Map(); // chatId -> { palavra, tema, vidas, progresso }
const participantesAtivos = new Map(); // chatId -> Set de usuários que reagiram
export let forcaAtiva = false;
// Palavras de exemplo
const PALAVRAS = [
{ palavra: "python", tema: "Linguagem de programação" },
{ palavra: "javascript", tema: "Linguagem de programação" },
{ palavra: "java", tema: "Linguagem de programação" },
{ palavra: "cachorro", tema: "Animal" },
{ palavra: "gato", tema: "Animal" },
{ palavra: "elefante", tema: "Animal" },
{ palavra: "girafa", tema: "Animal" },
{ palavra: "guitarra", tema: "Instrumento musical" },
{ palavra: "piano", tema: "Instrumento musical" },
{ palavra: "bateria", tema: "Instrumento musical" },
{ palavra: "violino", tema: "Instrumento musical" },
{ palavra: "futebol", tema: "Esporte" },
{ palavra: "basquete", tema: "Esporte" },
{ palavra: "natação", tema: "Esporte" },
{ palavra: "tênis", tema: "Esporte" },
{ palavra: "brasil", tema: "País" },
{ palavra: "japão", tema: "País" },
{ palavra: "canadá", tema: "País" },
{ palavra: "frança", tema: "País" },
{ palavra: "marte", tema: "Planeta" },
{ palavra: "vênus", tema: "Planeta" },
{ palavra: "júpiter", tema: "Planeta" },
{ palavra: "saturno", tema: "Planeta" },
{ palavra: "minecraft", tema: "Jogo" },
{ palavra: "fortnite", tema: "Jogo" },
{ palavra: "roblox", tema: "Jogo" },
{ palavra: "amongus", tema: "Jogo" },
{ palavra: "rosa", tema: "Flor" },
{ palavra: "girassol", tema: "Flor" },
{ palavra: "tulipa", tema: "Flor" },
{ palavra: "orquídea", tema: "Flor" },
{ palavra: "tesoura", tema: "Objeto" },
{ palavra: "caderno", tema: "Objeto" },
{ palavra: "computador", tema: "Objeto" },
{ palavra: "telefone", tema: "Objeto" },
{ palavra: "lua", tema: "Corpo celeste" },
{ palavra: "sol", tema: "Corpo celeste" },
{ palavra: "estrela", tema: "Corpo celeste" },
{ palavra: "cometa", tema: "Corpo celeste" },
{ palavra: "oceano", tema: "Natureza" },
{ palavra: "montanha", tema: "Natureza" },
];
// Função para gerar a palavra com underscores
const gerarProgresso = palavra =>
palavra.replace(/[a-zA-Z]/g, "_");
export default async function ({ msg, api }) {
const chatId = api.chat.id;
const sub = msg.args[1];
// ── Comando principal do jogo
if (msg.is(CMD_PREFIX + "forca")) {
if (!sub) {
await api.send(
`🎮 *Jogo da Forca*\n\n` +
`\`${CMD_PREFIX}forca começar\` — inicia o jogo\n` +
`\`${CMD_PREFIX}forca parar\` — encerra o jogo`
);
return;
}
if (sub === "começar") {
forcaAtiva = true;
// Pega uma palavra aleatória
const sorteio = PALAVRAS[Math.floor(Math.random() * PALAVRAS.length)];
// Inicializa o jogo
jogosAtivos.set(chatId, {
palavra: sorteio.palavra.toLowerCase(),
tema: sorteio.tema,
vidas: 6,
progresso: gerarProgresso(sorteio.palavra)
});
participantesAtivos.set(chatId, new Set()); // reset participantes
await api.send(
`🎮 *Jogo da Forca iniciado!*\n\n` +
`Tema: *${sorteio.tema}*\n` +
`Palavra: \`${gerarProgresso(sorteio.palavra)}\`\n` +
`Vidas: 6\n\n` +
`Digite uma letra para adivinhar!`
);
return;
}
if (sub === "parar") {
jogosAtivos.delete(chatId);
participantesAtivos.delete(chatId);
await api.send("🛑 Jogo da Forca encerrado.");
return;
}
await api.send(
`❌ Subcomando *${sub}* não existe.\n` +
`Use ${CMD_PREFIX} + \`forca começar\` ou ${CMD_PREFIX} + \`forca parar\`.`
);
return;
}
// ── Tentativas durante o jogo
const jogo = jogosAtivos.get(chatId);
if (!jogo) return; // Nenhum jogo ativo
const tentativa = msg.body.trim().toLowerCase();
if (!/^[a-z]$/.test(tentativa)) return; // apenas letras simples
// Se a letra está na palavra
let acerto = false;
let novoProgresso = jogo.progresso.split("");
for (let i = 0; i < jogo.palavra.length; i++) {
if (jogo.palavra[i] === tentativa) {
novoProgresso[i] = tentativa;
acerto = true;
}
}
jogo.progresso = novoProgresso.join("");
if (!acerto) jogo.vidas--;
// Feedback para o grupo
if (jogo.progresso === jogo.palavra) {
await msg.reply(`🎉 Parabéns! Palavra completa: \`${jogo.palavra}\``);
jogosAtivos.delete(chatId);
participantesAtivos.delete(chatId);
return;
}
if (jogo.vidas <= 0) {
await msg.reply(`💀 Fim de jogo! Palavra era: \`${jogo.palavra}\``);
jogosAtivos.delete(chatId);
participantesAtivos.delete(chatId);
return;
}
await msg.reply(
`Palavra: \`${jogo.progresso}\`\n` +
`Vidas: ${jogo.vidas}\n` +
(acerto ? "✅ Acertou a letra!" : "❌ Errou a letra!")
);
}

14
src/plugins/many/index.js Normal file
View File

@@ -0,0 +1,14 @@
import { CMD_PREFIX } from "../../config.js"
export default async function ({ msg, api }) {
if (!msg.is(CMD_PREFIX + "many")) return;
await api.send(
`🤖 *ManyBot — Comandos disponíveis:*\n\n` +
`🎬 \`${CMD_PREFIX}video <link>\` — baixa um vídeo\n` +
`🎵 \`${CMD_PREFIX}audio <link>\` — baixa um áudio\n` +
`🖼️ \`${CMD_PREFIX}figurinha\` — cria figurinhas\n` +
`🎮 \`${CMD_PREFIX}adivinhação começar|parar\` — jogo de adivinhar número\n` +
`🎮 \`${CMD_PREFIX}forca começar|parar\` — jogo da forca\n`
);
}

View File

@@ -0,0 +1,8 @@
import { CMD_PREFIX } from "../../config.js";
const gatilhos = ["obrigado", "valeu", "brigado"];
export default async function ({ msg }) {
if (!gatilhos.some(g => msg.is(CMD_PREFIX + g))) return;
await msg.reply("😊 Por nada!");
}

View File

@@ -0,0 +1,98 @@
/**
* plugins/video/index.js
*
* Baixa vídeo via yt-dlp e envia no chat.
* Todo o processo (download + envio + limpeza) fica aqui.
*/
import { spawn } from "child_process";
import fs from "fs";
import path from "path";
import os from "os";
import { enqueue } from "../../download/queue.js";
import { emptyFolder } from "../../utils/file.js";
import { CMD_PREFIX } from "../../config.js";
const logStream = fs.createWriteStream("logs/video-error.log", { flags: "a" });
const DOWNLOADS_DIR = path.resolve("downloads");
const YT_DLP = os.platform() === "win32" ? ".\\bin\\yt-dlp.exe" : "./bin/yt-dlp";
const ARGS_BASE = [
"--extractor-args", "youtube:player_client=android",
"--print", "after_move:filepath",
"--cookies", "cookies.txt",
"--add-header", "User-Agent:Mozilla/5.0",
"--add-header", "Referer:https://www.youtube.com/",
"--retries", "4",
"--fragment-retries", "5",
"--socket-timeout", "15",
"--sleep-interval", "1",
"--max-sleep-interval", "4",
"--no-playlist",
"-f", "bv+ba/best",
];
function downloadVideo(url, id) {
return new Promise((resolve, reject) => {
fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
const output = path.join(DOWNLOADS_DIR, `${id}.%(ext)s`);
const proc = spawn(YT_DLP, [...ARGS_BASE, "--output", output, url]);
let stdout = "";
proc.on("error", err => reject(new Error(
err.code === "EACCES"
? "Sem permissão para executar o yt-dlp. Rode: chmod +x ./bin/yt-dlp"
: err.code === "ENOENT"
? "yt-dlp não encontrado em ./bin/yt-dlp"
: `Erro ao iniciar o yt-dlp: ${err.message}`
)));
proc.stdout.on("data", d => { stdout += d.toString(); });
proc.stderr.on("data", d => logStream.write(d));
proc.on("close", code => {
if (code !== 0) return reject(new Error(
"Não foi possível baixar o vídeo. Verifique se o link é válido e tente novamente."
));
const filePath = stdout.trim().split("\n").filter(Boolean).at(-1);
if (!filePath || !fs.existsSync(filePath)) return reject(new Error(
"Download concluído mas arquivo não encontrado. Tente novamente."
));
resolve(filePath);
});
});
}
export default async function ({ msg, api }) {
if (!msg.is(CMD_PREFIX + "video")) return;
const url = msg.args[1];
if (!url) {
await msg.reply(`❌ Você precisa informar um link.\n\nExemplo: \`${CMD_PREFIX}video https://youtube.com/...\``);
return;
}
await msg.reply("⏳ Baixando o vídeo, aguarde...");
enqueue(
async () => {
const filePath = await downloadVideo(url, `video-${Date.now()}`);
await api.sendVideo(filePath);
fs.unlinkSync(filePath);
emptyFolder(DOWNLOADS_DIR);
api.log.info(`${CMD_PREFIX}video concluído → ${url}`);
},
async () => {
await msg.reply(
"❌ Não consegui baixar o vídeo.\n\n" +
"Verifique se o link é válido e tente novamente.\n" +
"Se o problema persistir, o conteúdo pode estar indisponível ou protegido."
);
}
);
}

View File

@@ -4,8 +4,9 @@
*/ */
import pkg from "whatsapp-web.js"; import pkg from "whatsapp-web.js";
import qrcode from "qrcode-terminal"; import qrcode from "qrcode-terminal";
import { CLIENT_ID } from "../config.js"; import { resolvePuppeteerConfig } from "../client/environment.js";
const CLIENT_ID="getId"
const { Client, LocalAuth } = pkg; const { Client, LocalAuth } = pkg;
const arg = process.argv[2]; const arg = process.argv[2];
@@ -17,7 +18,15 @@ if (!arg) {
const client = new Client({ const client = new Client({
authStrategy: new LocalAuth({ clientId: CLIENT_ID }), authStrategy: new LocalAuth({ clientId: CLIENT_ID }),
puppeteer: { headless: true }, puppeteer: {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
...(resolvePuppeteerConfig().args || [])
],
...resolvePuppeteerConfig()
},
}); });
client.on("qr", (qr) => { client.on("qr", (qr) => {

View File

@@ -1,3 +0,0 @@
Possibilidade de tirar fundo de figurinhas
Salvar mensagens num banco de dados

60
update
View File

@@ -114,6 +114,64 @@ fi
log "INFO" "Instalando dependências..." log "INFO" "Instalando dependências..."
npm ci --omit=dev 2>&1 | tee -a "$log_file" npm ci --omit=dev 2>&1 | tee -a "$log_file"
# ------------------------
# Download
# ------------------------
download_file() {
local url="$1"
local dest="$2"
log "download_file(url=$url, dest=$dest)"
log "Baixando $url"
log "Destino: $dest"
if command -v curl >/dev/null 2>&1; then
log "Downloader: curl"
curl -L "$url" -o "$dest"
elif command -v wget >/dev/null 2>&1; then
log "Downloader: wget"
wget "$url" -O "$dest"
else
log "curl ou wget não encontrados"
exit 1
fi
chmod +x "$dest" 2>/dev/null || true
log "Arquivo pronto: $dest"
}
# ------------------------
# Arquivos por plataforma
# ------------------------
log "Selecionando dependências binárias"
files=()
if [[ "$PLATFORM" == "win" ]]; then
log "Usando binários Windows"
files=(
"https://github.com/synt-xerror/manybot/releases/download/dependencies/yt-dlp.exe bin/yt-dlp.exe"
"https://github.com/synt-xerror/manybot/releases/download/dependencies/ffmpeg.exe bin/ffmpeg.exe"
)
else
log "Usando binários Unix"
files=(
"https://github.com/synt-xerror/manybot/releases/download/dependencies/yt-dlp bin/yt-dlp"
"https://github.com/synt-xerror/manybot/releases/download/dependencies/ffmpeg bin/ffmpeg"
)
log "Total de arquivos para baixar: ${#files[@]}"
# ------------------------
# Download
# ------------------------
for file in "${files[@]}"; do
url="${file%% *}"
dest="${file##* }"
log "Processando dependência"
download_file "$url" "$dest"
done
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Restauração dos arquivos de configuração # Restauração dos arquivos de configuração
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -125,7 +183,7 @@ if [ ${#backed_up[@]} -gt 0 ]; then
# Remove o que npm possa ter criado (ex: node_modules) # Remove o que npm possa ter criado (ex: node_modules)
rm -rf "$dst" rm -rf "$dst"
mv "$src" "$dst" mv "$src" "$dst"
log "INFO" "Restaurado: $item" l"INFO" "Restaurado: $item"
else else
log "WARN" "Item esperado no backup não encontrado: $item" log "WARN" "Item esperado no backup não encontrado: $item"
fi fi