Isaac Quarenta
feat(botcore): BotCore WhatsApp PV simples estilo negra_plena -> POST /api/akira (Akiragpu)
6727a3d Download botcore/modules/BotCore.ts from akra35567/Akiragpu: direct link, hf CLI and curl.
- Browser
- Download file 12.7 kB
-
https://huggingface.co/spaces/akra35567/Akiragpu/resolve/main/botcore/modules/BotCore.ts
- Command line
-
hf download hf://spaces/akra35567/Akiragpu/botcore/modules/BotCore.ts
-
curl -L -o BotCore.ts https://huggingface.co/spaces/akra35567/Akiragpu/resolve/main/botcore/modules/BotCore.ts
12.7 kB
| import http from 'http'; | |
| import { | |
| makeWASocket, | |
| useMultiFileAuthState, | |
| DisconnectReason, | |
| Browsers, | |
| } from '@whiskeysockets/baileys'; | |
| import type { WASocket } from '@whiskeysockets/baileys'; | |
| import qrcode from 'qrcode-terminal'; | |
| import QRCode from 'qrcode'; | |
| import pino, { type Logger } from 'pino'; | |
| import fs from 'fs'; | |
| import * as pathModule from 'path'; | |
| import ConfigManager from './ConfigManager.js'; | |
| import { MessageProcessor } from './MessageProcessor.js'; | |
| import { APIClient } from './APIClient.js'; | |
| import { CustomerIdentifier } from './CustomerIdentifier.js'; | |
| /** | |
| * AKIRA BotCore — fluxo PV simples (espelho da negra_plena, sem menus): | |
| * messages.upsert -> extractInfo -> shouldRespondToAI | |
| * -> payload {usuario, numero, mensagem, tipo_conversa...} | |
| * -> presence composing -> POST /api/akira -> reply quoted -> paused | |
| * -> erro = FALLBACK_MESSAGE | |
| */ | |
| export class BotCore { | |
| public config: ConfigManager; | |
| public logger: Logger; | |
| public sock: WASocket | null = null; | |
| public isConnected = false; | |
| private messageProcessor: MessageProcessor; | |
| private apiClient: APIClient; | |
| private customerId: CustomerIdentifier; | |
| private reconnectTimer: NodeJS.Timeout | null = null; | |
| private pairingRequested = false; | |
| private httpServer: http.Server | null = null; | |
| private latestQR: string | null = null; | |
| private latestQRDataUrl: string | null = null; | |
| private lastQRAt: number | null = null; | |
| private pairingCode: string | null = null; | |
| constructor() { | |
| this.config = ConfigManager.getInstance(); | |
| this.logger = pino({ level: this.config.LOG_LEVEL }); | |
| if (!fs.existsSync(this.config.AUTH_FOLDER)) { | |
| fs.mkdirSync(this.config.AUTH_FOLDER, { recursive: true }); | |
| } | |
| if (!fs.existsSync(this.config.TEMP_FOLDER)) { | |
| fs.mkdirSync(this.config.TEMP_FOLDER, { recursive: true }); | |
| } | |
| this.customerId = new CustomerIdentifier(); | |
| this.messageProcessor = new MessageProcessor(this.logger); | |
| this.apiClient = new APIClient(this.logger); | |
| this.startHealthServer(); | |
| } | |
| private startHealthServer() { | |
| this.httpServer = http.createServer((req, res) => { | |
| const url = new URL(req.url || '/', 'http://localhost'); | |
| const path = url.pathname.replace(/\/$/, '') || '/'; | |
| if (path === '/qr' || path === '/qrcode' || path === '/scan') { | |
| res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); | |
| res.end(this.getQrPageHtml()); | |
| return; | |
| } | |
| if (path === '/qr.json') { | |
| const body = JSON.stringify({ | |
| status: 'ok', | |
| whatsapp: this.isConnected ? 'online' : 'connecting', | |
| empresa: this.config.COMPANY_NAME, | |
| qr_available: !!this.latestQRDataUrl, | |
| pairing_code: this.pairingCode, | |
| updated_at: this.lastQRAt ? new Date(this.lastQRAt).toISOString() : null, | |
| }); | |
| res.writeHead(200, { 'Content-Type': 'application/json' }); | |
| res.end(body); | |
| return; | |
| } | |
| if (path === '/qr.png') { | |
| if (!this.latestQRDataUrl) { | |
| res.writeHead(404, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify({ status: 'waiting', message: 'QR ainda não gerado' })); | |
| return; | |
| } | |
| const base64 = this.latestQRDataUrl.split(',')[1] || ''; | |
| const buf = Buffer.from(base64, 'base64'); | |
| res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': buf.length }); | |
| res.end(buf); | |
| return; | |
| } | |
| const body = JSON.stringify({ | |
| status: 'ok', | |
| whatsapp: this.isConnected ? 'online' : 'connecting', | |
| empresa: this.config.COMPANY_NAME, | |
| qr_available: !!this.latestQRDataUrl, | |
| }); | |
| res.writeHead(200, { 'Content-Type': 'application/json' }); | |
| res.end(body); | |
| }); | |
| this.httpServer.listen(this.config.PORT, '0.0.0.0', () => { | |
| this.logger.info(`Healthcheck em 0.0.0.0:${this.config.PORT} (/ /qr /qr.json /qr.png)`); | |
| }); | |
| } | |
| private escapeHtml(s: string): string { | |
| return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| } | |
| private getQrPageHtml(): string { | |
| const empresa = this.escapeHtml(this.config.COMPANY_NAME); | |
| const online = this.isConnected; | |
| const statusText = online ? 'WhatsApp ligado' : 'A ligar WhatsApp…'; | |
| const statusColor = online ? '#16a34a' : '#d97706'; | |
| const pairing = this.pairingCode ? this.escapeHtml(this.pairingCode) : null; | |
| let main: string; | |
| if (online && !this.latestQRDataUrl) { | |
| main = `<div class="ok">✅ Sessão ligada. Não é preciso ler QR.</div>`; | |
| } else if (this.latestQRDataUrl) { | |
| main = ` | |
| <img src="${this.latestQRDataUrl}" alt="QR WhatsApp" width="280" height="280" /> | |
| ${pairing ? `<p>Código: <strong class="code">${pairing}</strong></p>` : ''} | |
| <p><a href="/qr.png" target="_blank">Abrir só a imagem (/qr.png)</a></p>`; | |
| } else if (pairing) { | |
| main = `<p>Código: <strong class="code">${pairing}</strong></p> | |
| <p class="muted">No WhatsApp: Aparelhos ligados > Ligar um aparelho > Ligar com número.</p>`; | |
| } else { | |
| main = `<div class="waiting">A gerar QR… aguarde e recarregue.</div>`; | |
| } | |
| return `<!doctype html><html lang="pt"><head><meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <meta http-equiv="refresh" content="8" /> | |
| <title>${empresa} — Ligar WhatsApp</title> | |
| <style> | |
| body{font-family:system-ui,Arial,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:24px} | |
| .card{background:#1e293b;border:1px solid #334155;border-radius:16px;padding:28px;max-width:420px;width:100%;text-align:center} | |
| h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 16px} | |
| .badge{display:inline-block;padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;color:#fff;background:${statusColor};margin-bottom:16px} | |
| img{background:#fff;border-radius:12px;padding:8px} | |
| .code{font-size:28px;letter-spacing:4px;background:#0f172a;border:1px dashed #475569;border-radius:8px;padding:6px 14px} | |
| .muted{color:#94a3b8;font-size:12px}a{color:#38bdf8;font-size:13px} | |
| .ok{background:#052e16;border:1px solid #16a34a;border-radius:10px;padding:14px;font-weight:700} | |
| </style></head><body><div class="card"> | |
| <h1>${empresa} — WhatsApp</h1><p class="sub">Ligue o número da Akira</p> | |
| <div class="badge">${statusText}</div> | |
| ${main} | |
| <ol style="text-align:left;font-size:13px;color:#cbd5e1;line-height:1.7"><li>No telemóvel abra o <strong>WhatsApp</strong></li><li><strong>Regulações / Aparelhos ligados</strong></li><li><strong>Ligar um aparelho</strong> e aponte ao QR</li></ol> | |
| </div></body></html>`; | |
| } | |
| public async connect() { | |
| const { state, saveCreds } = await useMultiFileAuthState(this.config.AUTH_FOLDER); | |
| this.sock = makeWASocket({ | |
| auth: state, | |
| browser: Browsers.macOS('Desktop'), | |
| logger: this.logger.child({ level: 'silent' }), | |
| syncFullHistory: false, | |
| markOnlineOnConnect: true, | |
| }); | |
| this.sock.ev.on('connection.update', async (update) => { | |
| const { connection, lastDisconnect, qr } = update; | |
| if (qr) { | |
| await this.handleAuth(qr); | |
| } | |
| if (connection === 'close') { | |
| this.isConnected = false; | |
| this.pairingRequested = false; | |
| const statusCode = (lastDisconnect?.error as any)?.output?.statusCode; | |
| const shouldReconnect = statusCode !== DisconnectReason.loggedOut; | |
| this.logger.warn({ statusCode }, 'Ligação WhatsApp fechada'); | |
| if (shouldReconnect) { | |
| if (this.reconnectTimer) clearTimeout(this.reconnectTimer); | |
| this.reconnectTimer = setTimeout(() => this.connect(), 5000); | |
| } else { | |
| this.logger.error('Sessão terminada. É necessário voltar a emparelhar o número.'); | |
| this.latestQR = null; | |
| this.latestQRDataUrl = null; | |
| this.pairingCode = null; | |
| } | |
| } else if (connection === 'open') { | |
| this.isConnected = true; | |
| this.pairingRequested = false; | |
| this.latestQR = null; | |
| this.latestQRDataUrl = null; | |
| this.pairingCode = null; | |
| this.lastQRAt = null; | |
| const botJid = this.sock?.user?.id; | |
| if (botJid) { | |
| this.messageProcessor.setBotId(botJid); | |
| this.logger.info(`${this.config.COMPANY_NAME} Bot online`); | |
| } | |
| } | |
| }); | |
| this.sock.ev.on('creds.update', saveCreds); | |
| this.sock.ev.on('messages.upsert', async (m) => { | |
| if (m.type !== 'notify') return; | |
| for (const msg of m.messages) { | |
| if (!msg.message) continue; | |
| await this.handleIncomingMessage(msg); | |
| } | |
| }); | |
| } | |
| private async handleAuth(qr: string) { | |
| this.latestQR = qr; | |
| this.lastQRAt = Date.now(); | |
| try { | |
| this.latestQRDataUrl = await QRCode.toDataURL(qr, { width: 400, margin: 2, errorCorrectionLevel: 'H' }); | |
| } catch (err) { | |
| this.logger.error({ err }, 'Falha ao gerar imagem do QR'); | |
| this.latestQRDataUrl = null; | |
| } | |
| if (this.config.BOT_NUMERO_REAL && this.sock && !this.sock.authState.creds.registered) { | |
| if (this.pairingRequested) return; | |
| this.pairingRequested = true; | |
| try { | |
| const code = await this.sock.requestPairingCode(this.config.BOT_NUMERO_REAL); | |
| this.pairingCode = code; | |
| this.lastQRAt = Date.now(); | |
| this.logger.info(`Código de emparelhamento: ${code} — ver também em /qr`); | |
| } catch (err) { | |
| this.logger.error({ err }, 'Falha ao pedir pairing code'); | |
| } | |
| return; | |
| } | |
| this.logger.info('Leia o QR: Aparelhos ligados > Ligar um aparelho — ou abra /qr'); | |
| qrcode.generate(qr, { small: true }); | |
| } | |
| private async handleIncomingMessage(msg: any) { | |
| if (!this.sock) return; | |
| try { | |
| const info = this.messageProcessor.extractInfo(msg); | |
| if (!info || !this.messageProcessor.shouldRespondToAI(info, msg)) return; | |
| this.logger.info(`[${info.isGroup ? 'GRUPO' : 'PV'}] ${info.senderCrmFormat}: ${info.text.slice(0, 80)}`); | |
| // Payload no formato que POST /api/akira espera | |
| const payload = { | |
| usuario: info.senderName, | |
| numero: info.senderNum, | |
| mensagem: info.text, | |
| message_id: info.messageId, | |
| tipo_conversa: info.isGroup ? 'grupo' : 'pv', | |
| grupo_id: info.isGroup ? this.customerId.getClientId(info.remoteJid) : null, | |
| nome_usuario: info.senderName, | |
| sender_jid: info.remoteJid, | |
| tipo_mensagem: 'texto', | |
| }; | |
| await this.sock.presenceSubscribe(info.remoteJid); | |
| await this.sock.sendPresenceUpdate('composing', info.remoteJid); | |
| try { | |
| const resposta = await this.apiClient.queueApiCall(payload); | |
| await this.sock.sendPresenceUpdate('paused', info.remoteJid); | |
| const texto = APIClient.extractText(resposta).trim(); | |
| if (texto) { | |
| await this.sock.sendMessage(info.remoteJid, { text: texto }, { quoted: msg }); | |
| } | |
| } catch (err) { | |
| await this.sock.sendPresenceUpdate('paused', info.remoteJid); | |
| this.logger.error({ err }, 'Falha ao contactar o cérebro AKIRA'); | |
| await this.sock.sendMessage(info.remoteJid, { text: this.config.FALLBACK_MESSAGE }); | |
| } | |
| } catch (err) { | |
| this.logger.error({ err }, 'Erro em handleIncomingMessage'); | |
| } | |
| } | |
| } | |