1171 lines
37 KiB
TypeScript
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();
|