arcadiasuite/server/whatsapp/service.ts

1171 lines
37 KiB
TypeScript

import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
WASocket,
ConnectionState,
} from "@whiskeysockets/baileys";
import { Boom } from "@hapi/boom";
import { EventEmitter } from "events";
import * as fs from "fs";
import * as path from "path";
import { db } from "../../db/index";
import { whatsappContacts, whatsappMessages, whatsappTickets, whatsappSessions, graphNodes, graphEdges, chatThreads, chatParticipants, chatMessages, pcCrmLeads, tenants } from "@shared/schema";
import { eq, and, desc, sql } from "drizzle-orm";
import { learningService } from "../learning/service";
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
});
interface AutoReplyConfig {
enabled: boolean;
welcomeMessage: string;
businessHours: { start: number; end: number };
outsideHoursMessage: string;
aiEnabled: boolean;
maxAutoRepliesPerContact: number;
}
interface WhatsAppSession {
socket: WASocket | null;
qrCode: string | null;
status: "disconnected" | "connecting" | "connected" | "qr_pending";
phoneNumber: string | null;
}
interface IncomingMessage {
userId: string;
from: string;
messageId: string;
text: string;
timestamp: number;
pushName: string | null;
}
class WhatsAppService extends EventEmitter {
private sessions: Map<string, WhatsAppSession> = new Map();
private authBasePath = "./whatsapp-sessions";
private autoReplyConfigs: Map<string, AutoReplyConfig> = new Map();
private autoReplyCount: Map<string, number> = new Map();
constructor() {
super();
if (!fs.existsSync(this.authBasePath)) {
fs.mkdirSync(this.authBasePath, { recursive: true });
}
}
async setAutoReplyConfig(userId: string, config: Partial<AutoReplyConfig>): Promise<void> {
const existing = this.autoReplyConfigs.get(userId) || await this.loadAutoReplyConfig(userId);
const merged = { ...existing, ...config };
this.autoReplyConfigs.set(userId, merged);
await db.update(whatsappSessions)
.set({ autoReplyConfig: merged })
.where(eq(whatsappSessions.userId, userId));
}
async loadAutoReplyConfig(userId: string): Promise<AutoReplyConfig> {
const defaults: AutoReplyConfig = {
enabled: false,
welcomeMessage: "Olá! Obrigado por entrar em contato. Em breve um atendente irá te responder.",
businessHours: { start: 8, end: 18 },
outsideHoursMessage: "Nosso horário de atendimento é de 8h às 18h. Deixe sua mensagem que retornaremos assim que possível.",
aiEnabled: true,
maxAutoRepliesPerContact: 3,
};
try {
const [session] = await db.select({ autoReplyConfig: whatsappSessions.autoReplyConfig })
.from(whatsappSessions)
.where(eq(whatsappSessions.userId, userId))
.limit(1);
if (session?.autoReplyConfig) {
return { ...defaults, ...(session.autoReplyConfig as Partial<AutoReplyConfig>) };
}
} catch {}
return defaults;
}
getAutoReplyConfig(userId: string): AutoReplyConfig {
return this.autoReplyConfigs.get(userId) || {
enabled: false,
welcomeMessage: "Olá! Obrigado por entrar em contato. Em breve um atendente irá te responder.",
businessHours: { start: 8, end: 18 },
outsideHoursMessage: "Nosso horário de atendimento é de 8h às 18h. Deixe sua mensagem que retornaremos assim que possível.",
aiEnabled: true,
maxAutoRepliesPerContact: 3,
};
}
private async generateAIResponse(message: string, contactName: string, conversationHistory: string[]): Promise<string> {
try {
const systemPrompt = `Você é um assistente virtual de atendimento ao cliente da empresa Arcádia Suite.
Seja educado, prestativo e objetivo. Responda em português brasileiro.
Se não souber a resposta para algo específico, diga que vai encaminhar para um atendente humano.
Mantenha respostas curtas e diretas (máximo 2-3 frases).
Nome do cliente: ${contactName}`;
const messages: any[] = [
{ role: "system", content: systemPrompt },
];
conversationHistory.slice(-5).forEach((msg) => {
messages.push({ role: "user", content: msg });
});
messages.push({ role: "user", content: message });
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages,
max_tokens: 200,
temperature: 0.7,
});
return response.choices[0]?.message?.content || "Obrigado pela mensagem. Um atendente irá te responder em breve.";
} catch (error: any) {
console.error("[WhatsApp] AI response error:", error.message);
return "Obrigado pela mensagem. Um atendente irá te responder em breve.";
}
}
private async processAutoReply(msg: IncomingMessage, contact: typeof whatsappContacts.$inferSelect): Promise<void> {
try {
const config = this.getAutoReplyConfig(msg.userId);
if (!config.enabled) return;
const contactKey = `${msg.userId}_${contact.id}`;
const currentCount = this.autoReplyCount.get(contactKey) || 0;
if (currentCount >= config.maxAutoRepliesPerContact) {
return;
}
const currentHour = new Date().getHours();
const isBusinessHours = currentHour >= config.businessHours.start && currentHour < config.businessHours.end;
let replyText: string;
if (!isBusinessHours) {
replyText = config.outsideHoursMessage;
} else if (currentCount === 0) {
replyText = config.welcomeMessage;
} else if (config.aiEnabled) {
const recentMessages = await db.select().from(whatsappMessages)
.where(and(
eq(whatsappMessages.userId, msg.userId),
eq(whatsappMessages.whatsappContactId, contact.id)
))
.orderBy(desc(whatsappMessages.timestamp))
.limit(5);
const history = recentMessages.reverse().map(m => m.body || "");
replyText = await this.generateAIResponse(msg.text, contact.name || contact.pushName || "Cliente", history);
} else {
return;
}
await this.sendMessage(msg.userId, msg.from, replyText);
this.autoReplyCount.set(contactKey, currentCount + 1);
setTimeout(() => {
const count = this.autoReplyCount.get(contactKey) || 0;
if (count > 0) {
this.autoReplyCount.set(contactKey, count - 1);
}
}, 30 * 60 * 1000);
console.log(`[WhatsApp] Auto-reply sent to ${contact.phoneNumber}`);
} catch (error: any) {
console.error("[WhatsApp] Auto-reply error:", error.message);
}
}
async connect(userId: string): Promise<{ qrCode?: string; status: string }> {
const existing = this.sessions.get(userId);
if (existing?.status === "connected") {
return { status: "connected" };
}
if (existing?.status === "connecting" || existing?.status === "qr_pending") {
return { qrCode: existing.qrCode || undefined, status: existing.status };
}
const authPath = path.join(this.authBasePath, userId);
if (!fs.existsSync(authPath)) {
fs.mkdirSync(authPath, { recursive: true });
}
const session: WhatsAppSession = {
socket: null,
qrCode: null,
status: "connecting",
phoneNumber: null,
};
this.sessions.set(userId, session);
try {
const { state, saveCreds } = await useMultiFileAuthState(authPath);
const sock = makeWASocket({
auth: state,
printQRInTerminal: false,
browser: ["Arcádia Suite", "Chrome", "1.0.0"],
});
session.socket = sock;
sock.ev.on("creds.update", saveCreds);
sock.ev.on("connection.update", (update: Partial<ConnectionState>) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
session.qrCode = qr;
session.status = "qr_pending";
this.emit("qr", { userId, qr });
}
if (connection === "close") {
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
session.status = "disconnected";
session.qrCode = null;
this.emit("disconnected", { userId, shouldReconnect });
if (shouldReconnect) {
setTimeout(() => this.connect(userId), 5000);
} else {
this.sessions.delete(userId);
if (fs.existsSync(authPath)) {
fs.rmSync(authPath, { recursive: true, force: true });
}
}
}
if (connection === "open") {
session.status = "connected";
session.qrCode = null;
session.phoneNumber = sock.user?.id?.split(":")[0] || null;
this.emit("connected", { userId, phoneNumber: session.phoneNumber });
}
});
sock.ev.on("messages.upsert", async (m) => {
const messages = m.messages;
for (const msg of messages) {
if (!msg.key.fromMe && msg.message) {
const text = msg.message.conversation ||
msg.message.extendedTextMessage?.text ||
"";
const remoteJid = msg.key.remoteJid || "";
const incomingMsg: IncomingMessage = {
userId,
from: remoteJid,
messageId: msg.key.id || "",
text,
timestamp: (msg.messageTimestamp as number) || Date.now() / 1000,
pushName: msg.pushName || null,
};
await this.handleIncomingMessage(incomingMsg);
this.emit("message", incomingMsg);
}
}
});
return new Promise((resolve) => {
const checkStatus = () => {
const s = this.sessions.get(userId);
if (s?.status === "qr_pending" && s.qrCode) {
resolve({ qrCode: s.qrCode, status: "qr_pending" });
} else if (s?.status === "connected") {
resolve({ status: "connected" });
} else {
setTimeout(checkStatus, 500);
}
};
setTimeout(checkStatus, 1000);
});
} catch (error) {
console.error("WhatsApp connection error:", error);
session.status = "disconnected";
throw error;
}
}
private async handleIncomingMessage(msg: IncomingMessage): Promise<void> {
try {
const phoneNumber = msg.from.replace("@s.whatsapp.net", "").replace("@g.us", "");
let [contact] = await db.select().from(whatsappContacts)
.where(and(
eq(whatsappContacts.userId, msg.userId),
eq(whatsappContacts.whatsappId, msg.from)
)).limit(1);
if (!contact) {
const [newContact] = await db.insert(whatsappContacts).values({
userId: msg.userId,
whatsappId: msg.from,
phoneNumber,
pushName: msg.pushName,
name: msg.pushName,
}).returning();
contact = newContact;
} else if (msg.pushName && msg.pushName !== contact.pushName) {
await db.update(whatsappContacts)
.set({ pushName: msg.pushName, name: msg.pushName })
.where(eq(whatsappContacts.id, contact.id));
}
await db.insert(whatsappMessages).values({
userId: msg.userId,
whatsappContactId: contact.id,
remoteJid: msg.from,
messageId: msg.messageId,
fromMe: "false",
body: msg.text,
messageType: "text",
timestamp: new Date(msg.timestamp * 1000),
status: "received",
});
let [ticket] = await db.select().from(whatsappTickets)
.where(and(
eq(whatsappTickets.ownerId, msg.userId),
eq(whatsappTickets.contactId, contact.id),
eq(whatsappTickets.status, "open")
)).limit(1);
if (!ticket) {
const protocol = `${Date.now()}`.slice(-8);
await db.insert(whatsappTickets).values({
ownerId: msg.userId,
contactId: contact.id,
status: "open",
lastMessage: msg.text,
unreadCount: 1,
protocol,
});
} else {
await db.update(whatsappTickets)
.set({
lastMessage: msg.text,
unreadCount: (ticket.unreadCount || 0) + 1,
updatedAt: new Date(),
})
.where(eq(whatsappTickets.id, ticket.id));
}
console.log(`[WhatsApp] Message saved from ${phoneNumber}: ${msg.text.slice(0, 50)}...`);
await this.saveToKnowledgeGraph(msg, contact, "inbound");
this.processAutoReply(msg, contact).catch((err) => {
console.error("[WhatsApp] Auto-reply failed:", err.message);
});
} catch (error) {
console.error("[WhatsApp] Error handling incoming message:", error);
}
}
private async saveToKnowledgeGraph(msg: IncomingMessage, contact: typeof whatsappContacts.$inferSelect, direction: "inbound" | "outbound"): Promise<void> {
try {
const [existingMessageNode] = await db.select().from(graphNodes)
.where(and(
eq(graphNodes.type, "whatsapp_message"),
eq(graphNodes.externalId, msg.messageId)
)).limit(1);
if (existingMessageNode) {
return;
}
const [contactNode] = await db.select().from(graphNodes)
.where(and(
eq(graphNodes.type, "whatsapp_contact"),
eq(graphNodes.externalId, contact.whatsappId)
)).limit(1);
let contactNodeId: number;
if (!contactNode) {
const [newNode] = await db.insert(graphNodes).values({
type: "whatsapp_contact",
externalId: contact.whatsappId,
data: {
name: contact.name || contact.pushName || contact.phoneNumber,
phoneNumber: contact.phoneNumber,
pushName: contact.pushName,
userId: msg.userId,
},
}).returning();
contactNodeId = newNode.id;
} else {
contactNodeId = contactNode.id;
await db.update(graphNodes)
.set({
data: {
...((contactNode.data as any) || {}),
name: contact.name || contact.pushName || contact.phoneNumber,
pushName: contact.pushName,
lastMessageAt: new Date().toISOString(),
},
updatedAt: new Date(),
})
.where(eq(graphNodes.id, contactNodeId));
}
const [messageNode] = await db.insert(graphNodes).values({
type: "whatsapp_message",
externalId: msg.messageId,
data: {
body: msg.text,
direction,
timestamp: new Date(msg.timestamp * 1000).toISOString(),
contactId: contact.id,
userId: msg.userId,
},
}).returning();
await db.insert(graphEdges).values({
sourceNodeId: direction === "inbound" ? contactNodeId : messageNode.id,
targetNodeId: direction === "inbound" ? messageNode.id : contactNodeId,
relationshipType: direction === "inbound" ? "sent" : "sent_to",
weight: "1",
});
if (msg.text && msg.text.length > 5) {
learningService.saveInteraction({
userId: msg.userId,
source: "whatsapp",
question: direction === "inbound"
? `Mensagem recebida de ${contact.name || contact.phoneNumber}`
: `Mensagem enviada para ${contact.name || contact.phoneNumber}`,
answer: msg.text,
context: {
contactId: contact.id,
contactName: contact.name || contact.pushName,
phoneNumber: contact.phoneNumber,
direction,
messageId: msg.messageId,
},
tags: ["whatsapp", direction, "atendimento"],
category: "comunicacao",
}).catch((err: any) => {
console.log("[WhatsApp] Learning service error (non-fatal):", err.message);
});
}
console.log(`[WhatsApp] Message added to knowledge graph: ${msg.messageId}`);
} catch (error) {
console.error("[WhatsApp] Error saving to knowledge graph:", error);
}
}
async disconnect(userId: string): Promise<void> {
const session = this.sessions.get(userId);
if (session?.socket) {
await session.socket.logout();
session.socket = null;
}
session && (session.status = "disconnected");
this.sessions.delete(userId);
const authPath = path.join(this.authBasePath, userId);
if (fs.existsSync(authPath)) {
fs.rmSync(authPath, { recursive: true, force: true });
}
}
getStatus(userId: string): WhatsAppSession | null {
return this.sessions.get(userId) || null;
}
async sendMessage(userId: string, to: string, text: string): Promise<boolean> {
const session = this.sessions.get(userId);
if (!session?.socket || session.status !== "connected") {
throw new Error("WhatsApp not connected");
}
const jid = to.includes("@") ? to : `${to}@s.whatsapp.net`;
await session.socket.sendMessage(jid, { text });
const phoneNumber = to.replace("@s.whatsapp.net", "").replace("@g.us", "");
let [contact] = await db.select().from(whatsappContacts)
.where(and(
eq(whatsappContacts.userId, userId),
eq(whatsappContacts.whatsappId, jid)
)).limit(1);
if (!contact) {
const [newContact] = await db.insert(whatsappContacts).values({
userId,
whatsappId: jid,
phoneNumber,
}).returning();
contact = newContact;
}
const sentMessageId = `sent_${Date.now()}`;
await db.insert(whatsappMessages).values({
userId,
whatsappContactId: contact.id,
remoteJid: jid,
messageId: sentMessageId,
fromMe: "true",
body: text,
messageType: "text",
timestamp: new Date(),
status: "sent",
});
const [ticket] = await db.select().from(whatsappTickets)
.where(and(
eq(whatsappTickets.ownerId, userId),
eq(whatsappTickets.contactId, contact.id),
eq(whatsappTickets.status, "open")
)).limit(1);
if (ticket) {
await db.update(whatsappTickets)
.set({ lastMessage: text, updatedAt: new Date() })
.where(eq(whatsappTickets.id, ticket.id));
}
const outboundMsg: IncomingMessage = {
userId,
from: jid,
messageId: sentMessageId,
text,
timestamp: Date.now() / 1000,
pushName: null,
};
await this.saveToKnowledgeGraph(outboundMsg, contact, "outbound");
return true;
}
async sendMedia(
userId: string,
ticketId: number,
filePath: string,
fileName: string,
mimeType: string,
caption?: string
): Promise<boolean> {
const session = this.sessions.get(userId);
if (!session?.socket || session.status !== "connected") {
throw new Error("WhatsApp not connected");
}
const [ticket] = await db.select().from(whatsappTickets)
.where(and(
eq(whatsappTickets.id, ticketId),
eq(whatsappTickets.ownerId, userId)
)).limit(1);
if (!ticket) {
throw new Error("Ticket not found");
}
const [contact] = await db.select().from(whatsappContacts)
.where(eq(whatsappContacts.id, ticket.contactId)).limit(1);
if (!contact) {
throw new Error("Contact not found");
}
const jid = contact.whatsappId;
const fileBuffer = fs.readFileSync(filePath);
let messageContent: any;
let messageType: string;
if (mimeType.startsWith("image/")) {
messageType = "image";
messageContent = { image: fileBuffer, caption: caption || undefined };
} else if (mimeType.startsWith("video/")) {
messageType = "video";
messageContent = { video: fileBuffer, caption: caption || undefined };
} else if (mimeType.startsWith("audio/")) {
messageType = "audio";
messageContent = { audio: fileBuffer, mimetype: mimeType, ptt: false };
} else {
messageType = "document";
messageContent = {
document: fileBuffer,
fileName: fileName,
mimetype: mimeType,
caption: caption || undefined
};
}
await session.socket.sendMessage(jid, messageContent);
const sentMessageId = `sent_media_${Date.now()}`;
const displayText = caption || `📎 ${fileName}`;
await db.insert(whatsappMessages).values({
userId,
whatsappContactId: contact.id,
remoteJid: jid,
messageId: sentMessageId,
fromMe: "true",
body: displayText,
messageType,
timestamp: new Date(),
status: "sent",
});
await db.update(whatsappTickets)
.set({ lastMessage: displayText, updatedAt: new Date() })
.where(eq(whatsappTickets.id, ticketId));
return true;
}
async getTickets(userId: string, status?: string): Promise<any[]> {
const conditions = [eq(whatsappTickets.ownerId, userId)];
if (status) {
conditions.push(eq(whatsappTickets.status, status));
}
const tickets = await db.select({
ticket: whatsappTickets,
contact: whatsappContacts,
})
.from(whatsappTickets)
.leftJoin(whatsappContacts, eq(whatsappTickets.contactId, whatsappContacts.id))
.where(and(...conditions))
.orderBy(desc(whatsappTickets.updatedAt));
const result = [];
for (const t of tickets) {
const [lastMessage] = await db.select()
.from(whatsappMessages)
.where(and(
eq(whatsappMessages.userId, userId),
eq(whatsappMessages.whatsappContactId, t.ticket.contactId)
))
.orderBy(desc(whatsappMessages.timestamp))
.limit(1);
result.push({
...t.ticket,
contact: t.contact,
lastMessage: lastMessage?.body || null,
lastMessageFromMe: lastMessage?.fromMe || false,
});
}
return result;
}
async getTicketMessages(userId: string, ticketId: number): Promise<any[]> {
const [ticket] = await db.select().from(whatsappTickets)
.where(and(
eq(whatsappTickets.id, ticketId),
eq(whatsappTickets.ownerId, userId)
)).limit(1);
if (!ticket) return [];
const messages = await db.select().from(whatsappMessages)
.where(and(
eq(whatsappMessages.userId, userId),
eq(whatsappMessages.whatsappContactId, ticket.contactId)
))
.orderBy(whatsappMessages.timestamp);
return messages;
}
async markTicketAsRead(userId: string, ticketId: number): Promise<void> {
await db.update(whatsappTickets)
.set({ unreadCount: 0 })
.where(and(
eq(whatsappTickets.id, ticketId),
eq(whatsappTickets.ownerId, userId)
));
}
async closeTicket(userId: string, ticketId: number): Promise<void> {
await db.update(whatsappTickets)
.set({ status: "closed", closedAt: new Date() })
.where(and(
eq(whatsappTickets.id, ticketId),
eq(whatsappTickets.ownerId, userId)
));
}
async getContacts(userId: string): Promise<any[]> {
return db.select().from(whatsappContacts)
.where(eq(whatsappContacts.userId, userId))
.orderBy(desc(whatsappContacts.createdAt));
}
async createContact(
userId: string,
data: {
name: string;
phone: string;
email?: string;
company?: string;
isLead: boolean;
leadSource: string;
}
): Promise<{ contact: any; leadCreated: boolean; lead?: any }> {
const phoneNumber = data.phone.replace(/\D/g, "");
const whatsappId = `${phoneNumber}@s.whatsapp.net`;
const [existingContact] = await db.select().from(whatsappContacts)
.where(and(
eq(whatsappContacts.userId, userId),
eq(whatsappContacts.whatsappId, whatsappId)
)).limit(1);
let contact;
if (existingContact) {
[contact] = await db.update(whatsappContacts)
.set({ name: data.name, phoneNumber })
.where(eq(whatsappContacts.id, existingContact.id))
.returning();
} else {
[contact] = await db.insert(whatsappContacts).values({
userId,
whatsappId,
name: data.name,
phoneNumber,
}).returning();
}
let leadCreated = false;
let lead = null;
if (data.isLead) {
const [tenant] = await db.select().from(tenants).limit(1);
if (tenant) {
[lead] = await db.insert(pcCrmLeads).values({
tenantId: tenant.id,
userId,
name: data.name,
email: data.email || null,
phone: phoneNumber,
company: data.company || null,
source: data.leadSource,
status: "new",
notes: `Lead criado via WhatsApp`,
}).returning();
leadCreated = true;
}
}
return { contact, leadCreated, lead };
}
async getAnalytics(userId: string): Promise<any> {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startOfWeek = new Date(today);
startOfWeek.setDate(today.getDate() - today.getDay());
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const allTickets = await db.select().from(whatsappTickets)
.where(eq(whatsappTickets.ownerId, userId));
const allMessages = await db.select().from(whatsappMessages)
.where(eq(whatsappMessages.userId, userId));
const todayTickets = allTickets.filter(t => new Date(t.createdAt) >= today);
const weekTickets = allTickets.filter(t => new Date(t.createdAt) >= startOfWeek);
const monthTickets = allTickets.filter(t => new Date(t.createdAt) >= startOfMonth);
const openTickets = allTickets.filter(t => t.status === "open");
const closedTickets = allTickets.filter(t => t.status === "closed");
const avgResponseTimes: number[] = [];
for (const ticket of closedTickets) {
if (ticket.closedAt && ticket.createdAt) {
const responseTime = new Date(ticket.closedAt).getTime() - new Date(ticket.createdAt).getTime();
avgResponseTimes.push(responseTime);
}
}
const avgResponseTime = avgResponseTimes.length > 0
? avgResponseTimes.reduce((a, b) => a + b, 0) / avgResponseTimes.length
: 0;
const inboundMessages = allMessages.filter(m => m.fromMe === "false" || m.fromMe === false);
const outboundMessages = allMessages.filter(m => m.fromMe === "true" || m.fromMe === true);
const messagesPerDay: Record<string, number> = {};
const last7Days = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
const key = d.toISOString().split('T')[0];
last7Days.push(key);
messagesPerDay[key] = 0;
}
allMessages.forEach(m => {
const key = new Date(m.timestamp || m.createdAt).toISOString().split('T')[0];
if (messagesPerDay[key] !== undefined) {
messagesPerDay[key]++;
}
});
return {
summary: {
totalTickets: allTickets.length,
openTickets: openTickets.length,
closedTickets: closedTickets.length,
totalMessages: allMessages.length,
inboundMessages: inboundMessages.length,
outboundMessages: outboundMessages.length,
},
periods: {
today: todayTickets.length,
week: weekTickets.length,
month: monthTickets.length,
},
performance: {
avgResponseTimeMs: Math.round(avgResponseTime),
avgResponseTimeFormatted: this.formatDuration(avgResponseTime),
closureRate: allTickets.length > 0 ? Math.round((closedTickets.length / allTickets.length) * 100) : 0,
},
chart: {
labels: last7Days,
data: last7Days.map(d => messagesPerDay[d]),
},
};
}
private formatDuration(ms: number): string {
if (ms === 0) return "N/A";
const hours = Math.floor(ms / (1000 * 60 * 60));
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
async deleteMessage(userId: string, messageId: string): Promise<boolean> {
const session = this.sessions.get(userId);
if (!session?.socket || session.status !== "connected") {
throw new Error("WhatsApp not connected");
}
const [msg] = await db.select().from(whatsappMessages)
.where(and(
eq(whatsappMessages.userId, userId),
eq(whatsappMessages.messageId, messageId)
)).limit(1);
if (!msg) {
throw new Error("Message not found");
}
const isOwnMessage = msg.fromMe === "true" || msg.fromMe === "1" || msg.fromMe === true || msg.fromMe === 1;
if (!isOwnMessage) {
throw new Error("You can only delete your own messages");
}
try {
await session.socket.sendMessage(msg.remoteJid, {
delete: {
remoteJid: msg.remoteJid,
fromMe: true,
id: messageId,
participant: undefined
}
});
await db.update(whatsappMessages)
.set({ isDeleted: 1, body: "[Mensagem apagada]" })
.where(eq(whatsappMessages.messageId, messageId));
return true;
} catch (error) {
console.error("[WhatsApp] Delete message error:", error);
throw error;
}
}
async replyToMessage(userId: string, originalMessageId: string, text: string): Promise<boolean> {
const session = this.sessions.get(userId);
if (!session?.socket || session.status !== "connected") {
throw new Error("WhatsApp not connected");
}
const [originalMsg] = await db.select().from(whatsappMessages)
.where(and(
eq(whatsappMessages.userId, userId),
eq(whatsappMessages.messageId, originalMessageId)
)).limit(1);
if (!originalMsg) {
throw new Error("Original message not found");
}
const [contact] = await db.select().from(whatsappContacts)
.where(eq(whatsappContacts.id, originalMsg.whatsappContactId!)).limit(1);
await session.socket.sendMessage(originalMsg.remoteJid, {
text,
contextInfo: {
stanzaId: originalMessageId,
participant: originalMsg.remoteJid,
quotedMessage: { conversation: originalMsg.body || "" }
}
});
const sentMessageId = `sent_${Date.now()}`;
await db.insert(whatsappMessages).values({
userId,
whatsappContactId: originalMsg.whatsappContactId,
remoteJid: originalMsg.remoteJid,
messageId: sentMessageId,
fromMe: "true",
body: text,
messageType: "text",
timestamp: new Date(),
status: "sent",
quotedMessageId: originalMessageId,
quotedBody: originalMsg.body,
});
return true;
}
async forwardMessage(userId: string, messageId: string, targetJids: string[]): Promise<boolean> {
const session = this.sessions.get(userId);
if (!session?.socket || session.status !== "connected") {
throw new Error("WhatsApp not connected");
}
const [msg] = await db.select().from(whatsappMessages)
.where(and(
eq(whatsappMessages.userId, userId),
eq(whatsappMessages.messageId, messageId)
)).limit(1);
if (!msg) {
throw new Error("Message not found");
}
for (const targetJid of targetJids) {
const jid = targetJid.includes("@") ? targetJid : `${targetJid}@s.whatsapp.net`;
await session.socket.sendMessage(jid, { text: msg.body || "" });
let [contact] = await db.select().from(whatsappContacts)
.where(and(
eq(whatsappContacts.userId, userId),
eq(whatsappContacts.whatsappId, jid)
)).limit(1);
if (!contact) {
const phoneNumber = jid.replace("@s.whatsapp.net", "").replace("@g.us", "");
const [newContact] = await db.insert(whatsappContacts).values({
userId,
whatsappId: jid,
phoneNumber,
}).returning();
contact = newContact;
}
const sentMessageId = `fwd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
await db.insert(whatsappMessages).values({
userId,
whatsappContactId: contact.id,
remoteJid: jid,
messageId: sentMessageId,
fromMe: "true",
body: msg.body,
messageType: "text",
timestamp: new Date(),
status: "sent",
});
}
return true;
}
async syncContacts(userId: string): Promise<number> {
const session = this.sessions.get(userId);
if (!session?.socket || session.status !== "connected") {
throw new Error("WhatsApp not connected");
}
try {
const store = (session.socket as any).store;
if (!store?.contacts) {
return 0;
}
let syncedCount = 0;
const contacts = Object.values(store.contacts) as any[];
for (const contact of contacts) {
if (!contact.id || contact.id.includes("@g.us")) continue;
const [existing] = await db.select().from(whatsappContacts)
.where(and(
eq(whatsappContacts.userId, userId),
eq(whatsappContacts.whatsappId, contact.id)
)).limit(1);
if (!existing) {
await db.insert(whatsappContacts).values({
userId,
whatsappId: contact.id,
name: contact.name || contact.notify || null,
pushName: contact.notify || null,
phoneNumber: contact.id.replace("@s.whatsapp.net", ""),
});
syncedCount++;
} else if (contact.name || contact.notify) {
await db.update(whatsappContacts)
.set({
name: contact.name || existing.name,
pushName: contact.notify || existing.pushName
})
.where(eq(whatsappContacts.id, existing.id));
}
}
return syncedCount;
} catch (error) {
console.error("[WhatsApp] Sync contacts error:", error);
return 0;
}
}
async searchContacts(userId: string, query: string): Promise<any[]> {
const contacts = await db.select().from(whatsappContacts)
.where(eq(whatsappContacts.userId, userId));
const searchLower = query.toLowerCase();
return contacts.filter(c =>
(c.name && c.name.toLowerCase().includes(searchLower)) ||
(c.pushName && c.pushName.toLowerCase().includes(searchLower)) ||
(c.phoneNumber && c.phoneNumber.includes(query))
);
}
async transferTicket(userId: string, ticketId: number, targetUserId: string, note?: string): Promise<boolean> {
const [ticket] = await db.select().from(whatsappTickets)
.where(and(
eq(whatsappTickets.id, ticketId),
eq(whatsappTickets.ownerId, userId)
)).limit(1);
if (!ticket) {
throw new Error("Ticket not found");
}
if (ticket.status !== "open") {
throw new Error("Cannot transfer closed tickets");
}
await db.update(whatsappTickets)
.set({
assignedToId: targetUserId,
updatedAt: new Date()
})
.where(eq(whatsappTickets.id, ticketId));
if (note) {
const [contact] = await db.select().from(whatsappContacts)
.where(eq(whatsappContacts.id, ticket.contactId)).limit(1);
if (contact) {
await db.insert(whatsappMessages).values({
userId,
whatsappContactId: ticket.contactId,
remoteJid: contact.whatsappId,
messageId: `system_${Date.now()}`,
fromMe: "true",
body: `[Sistema] Conversa transferida. Nota: ${note}`,
messageType: "system",
timestamp: new Date(),
status: "delivered",
});
}
}
console.log(`[WhatsApp] Ticket ${ticketId} transferred from ${userId} to ${targetUserId}`);
return true;
}
async forwardTicketToChat(userId: string, ticketId: number, targetUserIds: string[], contextMessage?: string): Promise<{ threadId: number }> {
const [ticket] = await db.select().from(whatsappTickets)
.where(and(
eq(whatsappTickets.id, ticketId),
eq(whatsappTickets.ownerId, userId)
)).limit(1);
if (!ticket) {
throw new Error("Ticket not found");
}
const [contact] = await db.select().from(whatsappContacts)
.where(eq(whatsappContacts.id, ticket.contactId)).limit(1);
const recentMessages = await db.select().from(whatsappMessages)
.where(and(
eq(whatsappMessages.userId, userId),
eq(whatsappMessages.whatsappContactId, ticket.contactId)
))
.orderBy(desc(whatsappMessages.timestamp))
.limit(5);
const participantIds = [userId, ...targetUserIds];
const threadName = `WhatsApp: ${contact?.name || contact?.pushName || contact?.phoneNumber || "Contato"}`;
const [newThread] = await db.insert(chatThreads).values({
type: "group",
name: threadName,
}).returning();
for (const participantId of participantIds) {
await db.insert(chatParticipants).values({
threadId: newThread.id,
participantId,
});
}
let summaryText = `Conversa encaminhada do WhatsApp\n`;
summaryText += `Contato: ${contact?.name || contact?.pushName || contact?.phoneNumber || "Desconhecido"}\n`;
summaryText += `Telefone: ${contact?.phoneNumber || "N/A"}\n\n`;
if (contextMessage) {
summaryText += `Mensagem: ${contextMessage}\n\n`;
}
if (recentMessages.length > 0) {
summaryText += `Últimas mensagens:\n`;
for (const msg of recentMessages.reverse()) {
const sender = msg.fromMe === "true" ? "Você" : (contact?.name || contact?.pushName || "Cliente");
summaryText += `[${sender}]: ${msg.body || "[Mídia]"}\n`;
}
}
await db.insert(chatMessages).values({
threadId: newThread.id,
senderId: userId,
body: summaryText,
messageType: "system",
});
console.log(`[WhatsApp] Ticket ${ticketId} forwarded to internal chat thread ${newThread.id}`);
return { threadId: newThread.id };
}
}
export const whatsappService = new WhatsAppService();