diff --git a/README.md b/README.md index 08f9519..c1d2668 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ ![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: -- Funciona em multiplos chats em apenas uma única sessão -- Comandos de jogos e download com yt-dlp -- Gerador de figurinhas -- Ferramenta para pegar IDs dos chats -- Entre outros +
+ +![Exemplo do gerador de figurinhas](examples/figurinha.gif) + +
--- @@ -25,32 +26,283 @@ obs: Sistemas Android e iOS ainda não são 100% compatíveis. O suporte para Te # Instalação (Linux) 1. Clone o repositório e entre: -``` +```bash git clone https://github.com/synt-xerror/manybot cd manybot ``` -2. Execute o script de instalação: -``` -bash setup +2. Crie e abra o arquivo de configuração (use o editor de sua preferência): +```bash +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. -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) -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 Selecione a versão que deseja (portátil ou instalador) -2. Para baixar o Node.js: https://nodejs.org/pt-br/download/current -Role a tela e selecione "binário independente (.zip)" +2. Para baixar o Node.js: https://nodejs.org/pt-br/download +Role a tela e selecione "Instalador Windows (.msi)" 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 `) +- **audio** — baixa o áudio de um vídeo e envia como mensagem de voz (`!audio `) +- **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 \ No newline at end of file diff --git a/examples/figurinha.gif b/examples/figurinha.gif new file mode 100644 index 0000000..3dde5e4 Binary files /dev/null and b/examples/figurinha.gif differ diff --git a/package-lock.json b/package-lock.json index 74b0cf1..36b0a69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "whatsapp-bot", - "version": "2.3.4", + "name": "manybot", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "whatsapp-bot", - "version": "2.3.4", + "name": "manybot", + "version": "2.4.0", "dependencies": { "node-addon-api": "^7", "node-gyp": "^12.2.0", diff --git a/setup b/setup index e046515..d2031ee 100755 --- a/setup +++ b/setup @@ -162,6 +162,13 @@ log_info "Instalando dependências npm" export PUPPETEER_SKIP_DOWNLOAD=1 run_cmd npm install +# ------------------------ +# Chrome Puppeeter +# ------------------------ +log_info "Instalando Chrome" + +npx puppeteer browsers install chrome + # ------------------------ # Diretórios # ------------------------ @@ -189,29 +196,9 @@ else ) fi +chmod +x bin/* 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 # ------------------------ diff --git a/src/client/whatsappClient.js b/src/client/whatsappClient.js index e5496c3..fadbeca 100644 --- a/src/client/whatsappClient.js +++ b/src/client/whatsappClient.js @@ -16,14 +16,21 @@ logger.info(isTermux // ── Instância ───────────────────────────────────────────────── export const client = new Client({ authStrategy: new LocalAuth({ clientId: CLIENT_ID }), - puppeteer: { headless: true, ...resolvePuppeteerConfig() }, + puppeteer: { + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + ...(resolvePuppeteerConfig().args || []) + ], + ...resolvePuppeteerConfig() + }, }); // ── Eventos ─────────────────────────────────────────────────── client.on("qr", handleQR); client.on("ready", () => { - console.log("READY DISPAROU"); // temporário printBanner(); logger.success("WhatsApp conectado e pronto!"); logger.info(`Client ID: ${CLIENT_ID}`); diff --git a/src/commands/handlers/a.js b/src/commands/handlers/a.js deleted file mode 100644 index f352d66..0000000 --- a/src/commands/handlers/a.js +++ /dev/null @@ -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!")); -} \ No newline at end of file diff --git a/src/commands/handlers/adivinhacao.js b/src/commands/handlers/adivinhacao.js deleted file mode 100644 index cb23069..0000000 --- a/src/commands/handlers/adivinhacao.js +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/src/commands/handlers/audio.js b/src/commands/handlers/audio.js deleted file mode 100644 index 0840db0..0000000 --- a/src/commands/handlers/audio.js +++ /dev/null @@ -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]}`); -} \ No newline at end of file diff --git a/src/commands/handlers/figurinha.js b/src/commands/handlers/figurinha.js deleted file mode 100644 index eb06442..0000000 --- a/src/commands/handlers/figurinha.js +++ /dev/null @@ -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}`)); -} \ No newline at end of file diff --git a/src/commands/handlers/info.js b/src/commands/handlers/info.js deleted file mode 100644 index e4bda03..0000000 --- a/src/commands/handlers/info.js +++ /dev/null @@ -1,24 +0,0 @@ -import { botMsg } from "../../utils/botMsg.js"; - -const HELP = new Map([ - ["ping", "> `!ping`\nResponde pong."], - ["video", "> `!video `\nBaixa vídeo da internet."], - ["audio", "> `!audio `\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)); -} \ No newline at end of file diff --git a/src/commands/handlers/many.js b/src/commands/handlers/many.js deleted file mode 100644 index c813314..0000000 --- a/src/commands/handlers/many.js +++ /dev/null @@ -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 ` — baixa um vídeo\n" + - "🎵 `!audio ` — baixa um áudio\n" + - "🖼️ `!figurinha` — cria figurinhas\n" + - "🎮 `!adivinhação começar|parar` — jogo de adivinhar número\n" - )); -} \ No newline at end of file diff --git a/src/commands/handlers/obrigado.js b/src/commands/handlers/obrigado.js deleted file mode 100644 index fe0d8c7..0000000 --- a/src/commands/handlers/obrigado.js +++ /dev/null @@ -1,5 +0,0 @@ -import { botMsg } from "../../utils/botMsg.js"; - -export async function cmdObrigado(msg) { - await msg.reply(botMsg("😊 Por nada!")); -} \ No newline at end of file diff --git a/src/commands/handlers/video.js b/src/commands/handlers/video.js deleted file mode 100644 index 9a0af97..0000000 --- a/src/commands/handlers/video.js +++ /dev/null @@ -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]}`); -} \ No newline at end of file diff --git a/src/commands/index.js b/src/commands/index.js deleted file mode 100644 index e022f10..0000000 --- a/src/commands/index.js +++ /dev/null @@ -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." - )); - } -} \ No newline at end of file diff --git a/src/commands/logic/figurinha.js b/src/commands/logic/figurinha.js deleted file mode 100644 index 94ddaaf..0000000 --- a/src/commands/logic/figurinha.js +++ /dev/null @@ -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"); -} \ No newline at end of file diff --git a/src/commands/logic/games/adivinhacao.js b/src/commands/logic/games/adivinhacao.js deleted file mode 100644 index 4a5675c..0000000 --- a/src/commands/logic/games/adivinhacao.js +++ /dev/null @@ -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} - */ -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*!")); - } -} \ No newline at end of file diff --git a/src/commands/logic/stickerSessions.js b/src/commands/logic/stickerSessions.js deleted file mode 100644 index db31222..0000000 --- a/src/commands/logic/stickerSessions.js +++ /dev/null @@ -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} - */ -export const stickerSessions = new Map(); \ No newline at end of file diff --git a/src/commands/parser.js b/src/commands/parser.js deleted file mode 100644 index 219cc47..0000000 --- a/src/commands/parser.js +++ /dev/null @@ -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 }; -} \ No newline at end of file diff --git a/src/commands/registry.js b/src/commands/registry.js deleted file mode 100644 index dcae333..0000000 --- a/src/commands/registry.js +++ /dev/null @@ -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 - * - * @type {Map} - */ -export const commandRegistry = new Map([ - ["!many", cmdMany], - ["!video", cmdVideo], - ["!audio", cmdAudio], - ["!figurinha", cmdFigurinha], - ["!adivinhação", cmdAdivinhacao], - ["!obrigado", cmdObrigado], - ["!valeu", cmdObrigado], - ["!brigado", cmdObrigado], - ["a", cmdA], -]); \ No newline at end of file diff --git a/src/config.js b/src/config.js index f0b2407..313b794 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,13 @@ +/** + * config.js + * + * Lê e parseia o manybot.conf. + * Suporta listas multilinhas e comentários inline. + */ + import fs from "fs"; function parseConf(raw) { - // Remove comentários inline e de linha inteira, preservando estrutura const lines = raw.split("\n"); const cleaned = []; @@ -9,13 +15,11 @@ function parseConf(raw) { let buffer = ""; for (let line of lines) { - // Remove comentário inline (# ...) — mas só fora de strings line = line.replace(/#.*$/, "").trim(); if (!line) continue; if (!insideList) { if (line.includes("=[") && !line.includes("]")) { - // Início de lista multilinha insideList = true; buffer = line; } else { @@ -24,7 +28,6 @@ function parseConf(raw) { } else { buffer += line; if (line.includes("]")) { - // Fim da lista insideList = false; cleaned.push(buffer); buffer = ""; @@ -32,7 +35,6 @@ function parseConf(raw) { } } - // Parseia cada linha chave=valor const result = {}; for (const line of cleaned) { const eqIdx = line.indexOf("="); @@ -55,10 +57,15 @@ function parseConf(raw) { return result; } -const raw = fs.readFileSync("manybot.conf", "utf8"); +const raw = fs.readFileSync("manybot.conf", "utf8"); const config = parseConf(raw); -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 CHATS = config.CHATS ?? []; \ No newline at end of file +export const CLIENT_ID = config.CLIENT_ID ?? "bot_permanente"; +export const CMD_PREFIX = config.CMD_PREFIX ?? "!"; +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; \ No newline at end of file diff --git a/src/download/downloader.js b/src/download/downloader.js deleted file mode 100644 index 9123197..0000000 --- a/src/download/downloader.js +++ /dev/null @@ -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} 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} 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); - }); - }); -} \ No newline at end of file diff --git a/src/download/mediaType.js b/src/download/mediaType.js deleted file mode 100644 index 51340cb..0000000 --- a/src/download/mediaType.js +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/download/queue.js b/src/download/queue.js index ca45b4f..23ac1fa 100644 --- a/src/download/queue.js +++ b/src/download/queue.js @@ -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, + * errorFn: (err: Error) => Promise, + * }} Job + */ + +/** @type {Job[]} */ let queue = []; let processing = false; /** * Adiciona um job à fila e inicia o processamento se estiver idle. - * @param {"video"|"audio"} type - * @param {string} url - * @param {object} msg - * @param {string} chatId + * + * @param {Function} workFn — async () => void — toda a lógica do plugin + * @param {Function} errorFn — async (err) => void — chamado se workFn lançar */ -export function enqueueDownload(type, url, msg, chatId) { - queue.push({ type, url, msg, chatId }); +export function enqueue(workFn, errorFn) { + queue.push({ workFn, errorFn }); if (!processing) processQueue(); } async function processQueue() { processing = true; - while (queue.length) { - const job = queue.shift(); - await processJob(job); + await processJob(queue.shift()); } - processing = false; } -/** - * Executa um único job: baixa, envia e limpa. - * @param {DownloadJob} job - */ -async function processJob(job) { - const { mime, label } = resolveMediaType(job.type); - +async function processJob({ workFn, errorFn }) { try { - const filePath = await download(job.type, job.url, job.msg.id._serialized); - - 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); + await workFn(); } catch (err) { - logger.error(`Falha ao baixar ${label} — ${err.message}`); - await job.msg.reply(botMsg( - `❌ 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." - )); + logger.error(`Falha no job — ${err.message}`); + try { await errorFn(err); } catch { } } } \ No newline at end of file diff --git a/src/handlers/messageHandler.js b/src/handlers/messageHandler.js deleted file mode 100644 index 95c780f..0000000 --- a/src/handlers/messageHandler.js +++ /dev/null @@ -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}`); -} \ No newline at end of file diff --git a/src/kernel/messageHandler.js b/src/kernel/messageHandler.js new file mode 100644 index 0000000..73cc094 --- /dev/null +++ b/src/kernel/messageHandler.js @@ -0,0 +1,48 @@ +/** + * messageHandler.js + * + * Pipeline central de uma mensagem recebida. + * + * Ordem: + * 1. Filtra chats não permitidos (CHATS do .conf) + * 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 } 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"; + +/** + * @param {import("whatsapp-web.js").Message} msg + */ +export async function handleMessage(msg) { + const chat = await msg.getChat(); + const chatId = getChatId(chat); + + // Filtra chats não autorizados + if (!CHATS.includes(chatId)) return; + + // Loga a mensagem recebida + const ctx = await buildMessageContext(msg, chat); + logger.msg(ctx); + + // Monta a api que será passada para os plugins + const api = buildApi({ msg, chat, pluginRegistry }); + + // Distribui para todos os plugins ativos + 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}`); +} \ No newline at end of file diff --git a/src/kernel/pluginApi.js b/src/kernel/pluginApi.js new file mode 100644 index 0000000..ee06ef4 --- /dev/null +++ b/src/kernel/pluginApi.js @@ -0,0 +1,187 @@ +/** + * 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} params.pluginRegistry + * @returns {object} api + */ +export function buildApi({ msg, chat, pluginRegistry }) { + + // ── Helpers internos ────────────────────────────────────── + 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} + */ + 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 }); + }, + + // ── 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), + }, + }; +} \ No newline at end of file diff --git a/src/kernel/pluginGuard.js b/src/kernel/pluginGuard.js new file mode 100644 index 0000000..a8910c9 --- /dev/null +++ b/src/kernel/pluginGuard.js @@ -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() ?? ""}` + ); + } +} \ No newline at end of file diff --git a/src/kernel/pluginLoader.js b/src/kernel/pluginLoader.js new file mode 100644 index 0000000..84e5a30 --- /dev/null +++ b/src/kernel/pluginLoader.js @@ -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} + */ +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 }); + } +} \ No newline at end of file diff --git a/src/kernel/scheduler.js b/src/kernel/scheduler.js new file mode 100644 index 0000000..7b067db --- /dev/null +++ b/src/kernel/scheduler.js @@ -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; +} \ No newline at end of file diff --git a/src/logger/formatter.js b/src/logger/formatter.js index 8561205..275441a 100644 --- a/src/logger/formatter.js +++ b/src/logger/formatter.js @@ -6,8 +6,6 @@ export const c = { blue: "\x1b[34m", magenta: "\x1b[35m", }; -export const SEP = `${c.gray}${"─".repeat(52)}${c.reset}`; - export const now = () => 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}(privado)${c.reset}`; -export const formatBody = (body, isCommand) => +export const formatBody = (body) => 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}${c.reset}`; export const formatReply = (quotedName, quotedNumber, quotedPreview) => diff --git a/src/logger/logger.js b/src/logger/logger.js index 8a48a51..6827f39 100644 --- a/src/logger/logger.js +++ b/src/logger/logger.js @@ -1,5 +1,5 @@ import { - c, SEP, now, + c, now, formatType, formatContext, formatBody, formatReply, } 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. */ 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), 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), @@ -18,23 +18,10 @@ export const logger = { * @param {import("./messageContext.js").MessageContext} ctx */ msg(ctx) { - const { chatName, isGroup, senderName, senderNumber, type, body, isCommand, quoted } = ctx; - - const typeLabel = formatType(type); - const context = formatContext(chatName, isGroup); - 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 - ); + const { chatName, isGroup, senderName, senderNumber, type, body, quoted } = ctx; + const context = isGroup ? `${chatName} (grupo)` : chatName; + const reply = quoted ? ` → Responde ${quoted.name} +${quoted.number}: "${quoted.preview}"` : ""; + 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}`); }, cmd: (cmd, extra = "") => diff --git a/src/logger/messageContext.js b/src/logger/messageContext.js index 433e15e..23d1853 100644 --- a/src/logger/messageContext.js +++ b/src/logger/messageContext.js @@ -28,7 +28,6 @@ export async function getNumber(msg) { * @property {string} senderNumber * @property {string} type * @property {string} body - * @property {boolean} isCommand * @property {{ name: string, number: string, preview: string } | null} quoted */ export async function buildMessageContext(msg, chat, botPrefix) { @@ -47,7 +46,6 @@ export async function buildMessageContext(msg, chat, botPrefix) { senderNumber: number, type: msg?.type || "text", body: msg.body, - isCommand: !!msg.body?.trimStart().startsWith(botPrefix), quoted, }; } diff --git a/src/main.js b/src/main.js index 6d99c7a..2c18b76 100644 --- a/src/main.js +++ b/src/main.js @@ -1,8 +1,30 @@ -import client from "./client/whatsappClient.js"; -import { handleMessage } from "./handlers/messageHandler.js"; -import { logger } from "./logger/logger.js"; +/** + * main.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) => { try { @@ -16,4 +38,5 @@ client.on("message_create", async (msg) => { }); client.initialize(); +console.log("\n"); logger.info("Cliente inicializado. Aguardando conexão com WhatsApp..."); \ No newline at end of file diff --git a/src/plugins/a/index.js b/src/plugins/a/index.js new file mode 100644 index 0000000..7850963 --- /dev/null +++ b/src/plugins/a/index.js @@ -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!"); +} \ No newline at end of file diff --git a/src/plugins/adivinhação/index.js b/src/plugins/adivinhação/index.js new file mode 100644 index 0000000..e50f639 --- /dev/null +++ b/src/plugins/adivinhação/index.js @@ -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*!"); + } +} \ No newline at end of file diff --git a/src/plugins/audio/index.js b/src/plugins/audio/index.js new file mode 100644 index 0000000..9a8dcdb --- /dev/null +++ b/src/plugins/audio/index.js @@ -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." + ); + } + ); +} \ No newline at end of file diff --git a/src/plugins/figurinha/index.js b/src/plugins/figurinha/index.js new file mode 100644 index 0000000..7553b6d --- /dev/null +++ b/src/plugins/figurinha/index.js @@ -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}`); +} \ No newline at end of file diff --git a/src/plugins/forca/index.js b/src/plugins/forca/index.js new file mode 100644 index 0000000..aad8518 --- /dev/null +++ b/src/plugins/forca/index.js @@ -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!") + ); +} \ No newline at end of file diff --git a/src/plugins/many/index.js b/src/plugins/many/index.js new file mode 100644 index 0000000..d70f9e7 --- /dev/null +++ b/src/plugins/many/index.js @@ -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 \` — baixa um vídeo\n` + + `🎵 \`${CMD_PREFIX}audio \` — 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` + ); +} \ No newline at end of file diff --git a/src/plugins/obrigado/index.js b/src/plugins/obrigado/index.js new file mode 100644 index 0000000..eb4dfaf --- /dev/null +++ b/src/plugins/obrigado/index.js @@ -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!"); +} \ No newline at end of file diff --git a/src/plugins/video/index.js b/src/plugins/video/index.js new file mode 100644 index 0000000..1a302eb --- /dev/null +++ b/src/plugins/video/index.js @@ -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." + ); + } + ); +} \ No newline at end of file diff --git a/src/utils/get_id.js b/src/utils/get_id.js index 3da624f..7e5c8e7 100644 --- a/src/utils/get_id.js +++ b/src/utils/get_id.js @@ -4,8 +4,9 @@ */ import pkg from "whatsapp-web.js"; 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 arg = process.argv[2]; @@ -17,7 +18,15 @@ if (!arg) { const client = new Client({ 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) => { diff --git a/todo.txt b/todo.txt deleted file mode 100644 index ad73ac4..0000000 --- a/todo.txt +++ /dev/null @@ -1,3 +0,0 @@ -Possibilidade de tirar fundo de figurinhas - -Salvar mensagens num banco de dados \ No newline at end of file diff --git a/update b/update index 0d962bc..60bbcc8 100644 --- a/update +++ b/update @@ -114,6 +114,64 @@ fi log "INFO" "Instalando dependências..." 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 # ------------------------------------------------------------------------------ @@ -125,7 +183,7 @@ if [ ${#backed_up[@]} -gt 0 ]; then # Remove o que npm possa ter criado (ex: node_modules) rm -rf "$dst" mv "$src" "$dst" - log "INFO" "Restaurado: $item" + l"INFO" "Restaurado: $item" else log "WARN" "Item esperado no backup não encontrado: $item" fi