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, '"'); } 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 = `
✅ Sessão ligada. Não é preciso ler QR.
`; } else if (this.latestQRDataUrl) { main = ` QR WhatsApp ${pairing ? `

Código: ${pairing}

` : ''}

Abrir só a imagem (/qr.png)

`; } else if (pairing) { main = `

Código: ${pairing}

No WhatsApp: Aparelhos ligados > Ligar um aparelho > Ligar com número.

`; } else { main = `
A gerar QR… aguarde e recarregue.
`; } return ` ${empresa} — Ligar WhatsApp

${empresa} — WhatsApp

Ligue o número da Akira

${statusText}
${main}
  1. No telemóvel abra o WhatsApp
  2. Regulações / Aparelhos ligados
  3. Ligar um aparelho e aponte ao QR
`; } 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'); } } }