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 = `
${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}
- No telemóvel abra o WhatsApp
- Regulações / Aparelhos ligados
- 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');
}
}
}