Passo a Passo
WHATICKET + WAVOIP
# DOWNLOAD LM STUDIO
- https://lmstudio.ai/
###### Construir integração
# Back
- backend\src\database\migrations\20250207140438-add-lm-to-whatsapp.ts
- backend\src\models\Whatsapp.ts
- backend\src\services\TicketServices\ShowTicketService.ts
- backend\src\services\WhatsappService\CreateWhatsAppService.ts
- backend\src\services\WhatsappService\UpdateWhatsAppService.ts
- backend\src\services\WbotServices\HandleLM.ts
- backend\src\services\WbotServices\wbotMessageListener.ts
# Front:
- frontend\src\components\WhatsAppModal\index.js
BACKEND
DATABASE
20250207140438-add-lm-to-whatsapp
import { QueryInterface, DataTypes } from "sequelize";
module.exports = {
up: (queryInterface: QueryInterface) => {
return Promise.all([
queryInterface.addColumn("Whatsapps", "lmModel", {
type: DataTypes.TEXT,
allowNull: true,
}),
queryInterface.addColumn("Whatsapps", "lmUrl", {
type: DataTypes.TEXT,
allowNull: true,
}),
queryInterface.addColumn("Whatsapps", "lmPrompt", {
type: DataTypes.TEXT,
allowNull: true,
})
]);
},
down: (queryInterface: QueryInterface) => {
return Promise.all([
queryInterface.removeColumn("Whatsapps", "lmModel"),
queryInterface.removeColumn("Whatsapps", "lmUrl"),
queryInterface.removeColumn("Whatsapps", "lmPrompt")
]);
}
};
MODELS
backend\src\models\Whatsapp.ts
@Column
lmModel: string;
@Column
lmUrl: string;
@Column
lmPrompt: string;
SERVICES
backend\src\services\TicketServices\ShowTicketService.ts
import Ticket from "../../models/Ticket";
import AppError from "../../errors/AppError";
import Contact from "../../models/Contact";
import User from "../../models/User";
import Queue from "../../models/Queue";
import Whatsapp from "../../models/Whatsapp";
const ShowTicketService = async (id: string | number): Promise => {
const ticket = await Ticket.findByPk(id, {
include: [
{
model: Contact,
as: "contact",
attributes: ["id", "name", "number", "profilePicUrl", "messengerId", "instagramId"],
include: ["extraInfo"]
},
{
model: User,
as: "user",
attributes: ["id", "name"]
},
{
model: Queue,
as: "queue",
attributes: ["id", "name", "color"]
},
{
model: Whatsapp,
as: "whatsapp",
attributes: ["name", "type", "wavoip", "lmModel", "lmUrl", "lmPrompt"]
}
]
});
if (!ticket) {
throw new AppError("ERR_NO_TICKET_FOUND", 404);
}
return ticket;
};
export default ShowTicketService;
backend\src\services\WhatsappService\CreateWhatsAppService.ts
import * as Yup from "yup";
import AppError from "../../errors/AppError";
import Whatsapp from "../../models/Whatsapp";
import AssociateWhatsappQueue from "./AssociateWhatsappQueue";
interface Request {
name: string;
queueIds?: number[];
greetingMessage?: string;
farewellMessage?: string;
status?: string;
isDefault?: boolean;
wavoip?: string;
lmModel?: string;
lmUrl?: string;
lmPrompt?: string;
}
interface Response {
whatsapp: Whatsapp;
oldDefaultWhatsapp: Whatsapp | null;
}
const CreateWhatsAppService = async ({
name,
status = "OPENING",
queueIds = [],
greetingMessage,
farewellMessage,
isDefault = false,
wavoip,
lmModel,
lmPrompt,
lmUrl
}: Request): Promise => {
const schema = Yup.object().shape({
name: Yup.string()
.required()
.min(2)
.test(
"Check-name",
"This whatsapp name is already used.",
async value => {
if (!value) return false;
const nameExists = await Whatsapp.findOne({
where: { name: value }
});
return !nameExists;
}
),
isDefault: Yup.boolean().required()
});
try {
await schema.validate({ name, status, isDefault });
} catch (err) {
throw new AppError(err.message);
}
const whatsappFound = await Whatsapp.findOne();
isDefault = !whatsappFound;
let oldDefaultWhatsapp: Whatsapp | null = null;
if (isDefault) {
oldDefaultWhatsapp = await Whatsapp.findOne({
where: { isDefault: true }
});
if (oldDefaultWhatsapp) {
await oldDefaultWhatsapp.update({ isDefault: false });
}
}
if (queueIds.length > 1 && !greetingMessage) {
throw new AppError("ERR_WAPP_GREETING_REQUIRED");
}
const whatsapp = await Whatsapp.create(
{
name,
status,
greetingMessage,
farewellMessage,
isDefault,
wavoip,
lmModel,
lmPrompt,
lmUrl
},
{ include: ["queues"] }
);
await AssociateWhatsappQueue(whatsapp, queueIds);
return { whatsapp, oldDefaultWhatsapp };
};
export default CreateWhatsAppService;
backend\src\services\WhatsappService\UpdateWhatsAppService.ts
import * as Yup from "yup";
import { Op } from "sequelize";
import AppError from "../../errors/AppError";
import Whatsapp from "../../models/Whatsapp";
import ShowWhatsAppService from "./ShowWhatsAppService";
import AssociateWhatsappQueue from "./AssociateWhatsappQueue";
interface WhatsappData {
name?: string;
status?: string;
session?: string;
isDefault?: boolean;
greetingMessage?: string;
farewellMessage?: string;
queueIds?: number[];
wavoip?: string;
lmModel?: string;
lmUrl?: string;
lmPrompt?: string;
}
interface Request {
whatsappData: WhatsappData;
whatsappId: string;
}
interface Response {
whatsapp: Whatsapp;
oldDefaultWhatsapp: Whatsapp | null;
}
const UpdateWhatsAppService = async ({
whatsappData,
whatsappId
}: Request): Promise => {
const schema = Yup.object().shape({
name: Yup.string().min(2),
status: Yup.string(),
isDefault: Yup.boolean()
});
const {
name,
status,
isDefault,
session,
greetingMessage,
farewellMessage,
queueIds = [],
wavoip,
lmModel,
lmPrompt,
lmUrl
} = whatsappData;
try {
await schema.validate({ name, status, isDefault });
} catch (err) {
throw new AppError(err.message);
}
if (queueIds.length > 1 && !greetingMessage) {
throw new AppError("ERR_WAPP_GREETING_REQUIRED");
}
let oldDefaultWhatsapp: Whatsapp | null = null;
if (isDefault) {
oldDefaultWhatsapp = await Whatsapp.findOne({
where: { isDefault: true, id: { [Op.not]: whatsappId } }
});
if (oldDefaultWhatsapp) {
await oldDefaultWhatsapp.update({ isDefault: false });
}
}
const whatsapp = await ShowWhatsAppService(whatsappId);
await whatsapp.update({
name,
status,
session,
greetingMessage,
farewellMessage,
isDefault,
wavoip,
lmModel,
lmPrompt,
lmUrl
});
await AssociateWhatsappQueue(whatsapp, queueIds);
return { whatsapp, oldDefaultWhatsapp };
};
export default UpdateWhatsAppService;
backend\src\services\WbotServices\HandleLM.ts
import axios from 'axios';
import Whatsapp from '../../models/Whatsapp';
import SendWhatsAppMessage from './SendWhatsAppMessage';
import Ticket from '../../models/Ticket';
import { logger } from '../../utils/logger';
// Definição da interface para mensagens no histórico
interface Message {
role: "user" | "assistant" | "system";
content: string;
}
// Objeto para armazenar o histórico de conversas
const conversationHistory: Record = {};
// Função para processar mensagens usando LM Studio com histórico
async function processLmMessage(whatsapp: Whatsapp, ticket: Ticket, text: string, userId: string): Promise {
try {
// Inicializa o histórico do usuário, se não existir
if (!conversationHistory[userId]) {
conversationHistory[userId] = [];
}
// Adiciona a mensagem do usuário ao histórico
conversationHistory[userId].push({ role: "user", content: text });
// Limita o histórico para as últimas 10 interações
if (conversationHistory[userId].length > 10) {
conversationHistory[userId].shift();
}
const response = await axios.post(`${whatsapp.lmUrl}/api/v0/chat/completions`, {
model: whatsapp.lmModel,
messages: [
{ role: "system", content: whatsapp.lmPrompt },
...conversationHistory[userId] // Envia o histórico completo do usuário
],
temperature: 0.7,
max_tokens: 4000
});
let reply: string = response.data.choices[0].message.content;
// Remove qualquer ... antes de responder
reply = reply.replace(/[\s\S]*?<\/think>/g, '').trim();
// Adiciona a resposta da IA ao histórico
conversationHistory[userId].push({ role: "assistant", content: reply });
logger.info(`Resposta gerada para ${userId}: ${reply}`);
await SendWhatsAppMessage({ body: reply, ticket: ticket, quotedMsg: undefined });
return true
} catch (error) {
console.error('Erro ao processar mensagem:', error);
return false;
}
}
export { processLmMessage };
backend\src\services\WbotServices\wbotMessageListener.ts
import { join } from "path";
import { promisify } from "util";
import { writeFile } from "fs";
import * as Sentry from "@sentry/node";
import {
Contact as WbotContact,
Message as WbotMessage,
MessageAck,
Client
} from "whatsapp-web.js";
import Contact from "../../models/Contact";
import Ticket from "../../models/Ticket";
import Message from "../../models/Message";
import { getIO } from "../../libs/socket";
import CreateMessageService from "../MessageServices/CreateMessageService";
import { logger } from "../../utils/logger";
import CreateOrUpdateContactService from "../ContactServices/CreateOrUpdateContactService";
import FindOrCreateTicketService from "../TicketServices/FindOrCreateTicketService";
import ShowWhatsAppService from "../WhatsappService/ShowWhatsAppService";
import { debounce } from "../../helpers/Debounce";
import UpdateTicketService from "../TicketServices/UpdateTicketService";
import CreateContactService from "../ContactServices/CreateContactService";
import GetContactService from "../ContactServices/GetContactService";
import formatBody from "../../helpers/Mustache";
import Whatsapp from "../../models/Whatsapp";
import { processLmMessage } from "./HandleLM";
interface Session extends Client {
id?: number;
}
const writeFileAsync = promisify(writeFile);
const verifyContact = async (msgContact: WbotContact): Promise => {
const profilePicUrl = await msgContact.getProfilePicUrl();
const contactData = {
name: msgContact.name || msgContact.pushname || msgContact.id.user,
number: msgContact.id.user,
profilePicUrl,
isGroup: msgContact.isGroup
};
const contact = CreateOrUpdateContactService(contactData);
return contact;
};
const verifyQuotedMessage = async (
msg: WbotMessage
): Promise => {
if (!msg.hasQuotedMsg) return null;
const wbotQuotedMsg = await msg.getQuotedMessage();
const quotedMsg = await Message.findOne({
where: { id: wbotQuotedMsg.id.id }
});
if (!quotedMsg) return null;
return quotedMsg;
};
// generate random id string for file names, function got from: https://stackoverflow.com/a/1349426/1851801
function makeRandomId(length: number) {
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let counter = 0;
while (counter < length) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return result;
}
const verifyMediaMessage = async (
msg: WbotMessage,
ticket: Ticket,
contact: Contact
): Promise => {
const quotedMsg = await verifyQuotedMessage(msg);
const media = await msg.downloadMedia();
if (!media) {
throw new Error("ERR_WAPP_DOWNLOAD_MEDIA");
}
let randomId = makeRandomId(5);
if (!media.filename) {
const ext = media.mimetype.split("/")[1].split(";")[0];
media.filename = `${randomId}-${new Date().getTime()}.${ext}`;
} else {
media.filename = media.filename.split('.').slice(0,-1).join('.')+'.'+randomId+'.'+media.filename.split('.').slice(-1);
}
try {
await writeFileAsync(
join(__dirname, "..", "..", "..", "public", media.filename),
media.data,
"base64"
);
} catch (err) {
Sentry.captureException(err);
logger.error(err);
}
const messageData = {
id: msg.id.id,
ticketId: ticket.id,
contactId: msg.fromMe ? undefined : contact.id,
body: msg.body || media.filename,
fromMe: msg.fromMe,
read: msg.fromMe,
mediaUrl: media.filename,
mediaType: media.mimetype.split("/")[0],
quotedMsgId: quotedMsg?.id
};
await ticket.update({ lastMessage: msg.body || media.filename });
const newMessage = await CreateMessageService({ messageData });
return newMessage;
};
const verifyMessage = async (
msg: WbotMessage,
ticket: Ticket,
contact: Contact
) => {
if (msg.type === 'location')
msg = prepareLocation(msg);
const quotedMsg = await verifyQuotedMessage(msg);
const messageData = {
id: msg.id.id,
ticketId: ticket.id,
contactId: msg.fromMe ? undefined : contact.id,
body: msg.body,
fromMe: msg.fromMe,
mediaType: msg.type,
read: msg.fromMe,
quotedMsgId: quotedMsg?.id
};
// temporaryly disable ts checks because of type definition bug for Location object
// @ts-ignore
await ticket.update({ lastMessage: msg.type === "location" ? msg.location.description ? "Localization - " + msg.location.description.split('\\n')[0] : "Localization" : msg.body });
await CreateMessageService({ messageData });
};
const prepareLocation = (msg: WbotMessage): WbotMessage => {
let gmapsUrl = "https://maps.google.com/maps?q=" + msg.location.latitude + "%2C" + msg.location.longitude + "&z=17&hl=pt-BR";
msg.body = "data:image/png;base64," + msg.body + "|" + gmapsUrl;
// temporaryly disable ts checks because of type definition bug for Location object
// @ts-ignore
msg.body += "|" + (msg.location.description ? msg.location.description : (msg.location.latitude + ", " + msg.location.longitude))
return msg;
};
const verifyQueue = async (
wbot: Session,
msg: WbotMessage,
ticket: Ticket,
contact: Contact
) => {
const { queues, greetingMessage } = await ShowWhatsAppService(wbot.id!);
if (queues.length === 1) {
await UpdateTicketService({
ticketData: { queueId: queues[0].id },
ticketId: ticket.id
});
return;
}
const selectedOption = msg.body;
const choosenQueue = queues[+selectedOption - 1];
if (choosenQueue) {
await UpdateTicketService({
ticketData: { queueId: choosenQueue.id },
ticketId: ticket.id
});
const body = formatBody(`\u200e${choosenQueue.greetingMessage}`, contact);
const sentMessage = await wbot.sendMessage(`${contact.number}@c.us`, body);
await verifyMessage(sentMessage, ticket, contact);
} else {
let options = "";
queues.forEach((queue, index) => {
options += `*${index + 1}* - ${queue.name}\n`;
});
const body = formatBody(`\u200e${greetingMessage}\n${options}`, contact);
const debouncedSentMessage = debounce(
async () => {
const sentMessage = await wbot.sendMessage(
`${contact.number}@c.us`,
body
);
verifyMessage(sentMessage, ticket, contact);
},
3000,
ticket.id
);
debouncedSentMessage();
}
};
const isValidMsg = (msg: WbotMessage): boolean => {
if (msg.from === "status@broadcast") return false;
if (
msg.type === "chat" ||
msg.type === "audio" ||
msg.type === "ptt" ||
msg.type === "video" ||
msg.type === "image" ||
msg.type === "document" ||
msg.type === "vcard" ||
//msg.type === "multi_vcard" ||
msg.type === "sticker" ||
msg.type === "location"
)
return true;
return false;
};
const handleMessage = async (
msg: WbotMessage,
wbot: Session
): Promise => {
if (!isValidMsg(msg)) {
return;
}
try {
let msgContact: WbotContact;
let groupContact: Contact | undefined;
if (msg.fromMe) {
// messages sent automatically by wbot have a special character in front of it
// if so, this message was already been stored in database;
if (/\u200e/.test(msg.body[0])) return;
// media messages sent from me from cell phone, first comes with "hasMedia = false" and type = "image/ptt/etc"
// in this case, return and let this message be handled by "media_uploaded" event, when it will have "hasMedia = true"
if (!msg.hasMedia && msg.type !== "location" && msg.type !== "chat" && msg.type !== "vcard"
//&& msg.type !== "multi_vcard"
) return;
msgContact = await wbot.getContactById(msg.to);
} else {
msgContact = await msg.getContact();
}
const chat = await msg.getChat();
if (chat.isGroup) {
let msgGroupContact;
if (msg.fromMe) {
msgGroupContact = await wbot.getContactById(msg.to);
} else {
msgGroupContact = await wbot.getContactById(msg.from);
}
groupContact = await verifyContact(msgGroupContact);
}
const whatsapp = await ShowWhatsAppService(wbot.id!);
const unreadMessages = msg.fromMe ? 0 : chat.unreadCount;
const contact = await verifyContact(msgContact);
if (
unreadMessages === 0 &&
whatsapp.farewellMessage &&
formatBody(whatsapp.farewellMessage, contact) === msg.body
)
return;
const ticket = await FindOrCreateTicketService(
contact,
wbot.id!,
unreadMessages,
groupContact
);
if (msg.hasMedia) {
await verifyMediaMessage(msg, ticket, contact);
} else {
if(whatsapp.lmUrl && whatsapp.lmModel && whatsapp.lmPrompt && !msg.fromMe && !chat.isGroup) {
await processLmMessage(whatsapp, ticket, msg.body, contact.number);
}
await verifyMessage(msg, ticket, contact);
}
if (
!ticket.queue &&
!chat.isGroup &&
!msg.fromMe &&
!ticket.userId &&
whatsapp.queues.length >= 1
) {
await verifyQueue(wbot, msg, ticket, contact);
}
if (msg.type === "vcard") {
try {
const array = msg.body.split("\n");
const obj = [];
let contact = "";
for (let index = 0; index < array.length; index++) {
const v = array[index];
const values = v.split(":");
for (let ind = 0; ind < values.length; ind++) {
if (values[ind].indexOf("+") !== -1) {
obj.push({ number: values[ind] });
}
if (values[ind].indexOf("FN") !== -1) {
contact = values[ind + 1];
}
}
}
for await (const ob of obj) {
const cont = await CreateContactService({
name: contact,
number: ob.number.replace(/\D/g, "")
});
}
} catch (error) {
console.log(error);
}
}
/* if (msg.type === "multi_vcard") {
try {
const array = msg.vCards.toString().split("\n");
let name = "";
let number = "";
const obj = [];
const conts = [];
for (let index = 0; index < array.length; index++) {
const v = array[index];
const values = v.split(":");
for (let ind = 0; ind < values.length; ind++) {
if (values[ind].indexOf("+") !== -1) {
number = values[ind];
}
if (values[ind].indexOf("FN") !== -1) {
name = values[ind + 1];
}
if (name !== "" && number !== "") {
obj.push({
name,
number
});
name = "";
number = "";
}
}
}
// eslint-disable-next-line no-restricted-syntax
for await (const ob of obj) {
try {
const cont = await CreateContactService({
name: ob.name,
number: ob.number.replace(/\D/g, "")
});
conts.push({
id: cont.id,
name: cont.name,
number: cont.number
});
} catch (error) {
if (error.message === "ERR_DUPLICATED_CONTACT") {
const cont = await GetContactService({
name: ob.name,
number: ob.number.replace(/\D/g, ""),
email: ""
});
conts.push({
id: cont.id,
name: cont.name,
number: cont.number
});
}
}
}
msg.body = JSON.stringify(conts);
} catch (error) {
console.log(error);
}
} */
} catch (err) {
Sentry.captureException(err);
logger.error(`Error handling whatsapp message: Err: ${err}`);
}
};
const handleMsgAck = async (msg: WbotMessage, ack: MessageAck) => {
await new Promise(r => setTimeout(r, 500));
const io = getIO();
try {
const messageToUpdate = await Message.findByPk(msg.id.id, {
include: [
"contact",
{
model: Message,
as: "quotedMsg",
include: ["contact"]
}
]
});
if (!messageToUpdate) {
return;
}
await messageToUpdate.update({ ack });
io.to(messageToUpdate.ticketId.toString()).emit("appMessage", {
action: "update",
message: messageToUpdate
});
} catch (err) {
Sentry.captureException(err);
logger.error(`Error handling message ack. Err: ${err}`);
}
};
const wbotMessageListener = (wbot: Session): void => {
wbot.on("message_create", async msg => {
handleMessage(msg, wbot);
});
wbot.on("media_uploaded", async msg => {
handleMessage(msg, wbot);
});
wbot.on("message_ack", async (msg, ack) => {
handleMsgAck(msg, ack);
});
};
export { wbotMessageListener, handleMessage };
FRONTEND
COMPONENTS
frontend\src\components\WhatsAppModal\index.js
import React, { useState, useEffect } from "react";
import * as Yup from "yup";
import { Formik, Form, Field } from "formik";
import { toast } from "react-toastify";
import { makeStyles } from "@material-ui/core/styles";
import { green } from "@material-ui/core/colors";
import {
Dialog,
DialogContent,
DialogTitle,
Button,
DialogActions,
CircularProgress,
TextField,
Switch,
FormControlLabel,
Select,
MenuItem,
} from "@material-ui/core";
import api from "../../services/api";
import { i18n } from "../../translate/i18n";
import toastError from "../../errors/toastError";
import QueueSelect from "../QueueSelect";
const useStyles = makeStyles(theme => ({
root: {
display: "flex",
flexWrap: "wrap",
},
multFieldLine: {
display: "flex",
"& > *:not(:last-child)": {
marginRight: theme.spacing(1),
},
},
btnWrapper: {
position: "relative",
},
buttonProgress: {
color: green[500],
position: "absolute",
top: "50%",
left: "50%",
marginTop: -12,
marginLeft: -12,
},
}));
const SessionSchema = Yup.object().shape({
name: Yup.string()
.min(2, "Too Short!")
.max(50, "Too Long!")
.required("Required"),
});
const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
const classes = useStyles();
const initialState = {
name: "",
greetingMessage: "",
farewellMessage: "",
isDefault: false,
wavoip: "",
lmModel: "",
lmUrl: "",
lmPrompt: "",
};
const [whatsApp, setWhatsApp] = useState(initialState);
const [selectedQueueIds, setSelectedQueueIds] = useState([]);
const [isHubSelected, setIsHubSelected] = useState(false);
const [availableChannels, setAvailableChannels] = useState([]);
const [selectedChannel, setSelectedChannel] = useState("");
// Função para buscar os canais disponíveis no hub
const fetchChannels = async () => {
try {
const { data } = await api.get("/hub-channel/");
console.log("Canais disponíveis:", data); // Adicione isso para verificar os dados recebidos
setAvailableChannels(data);
} catch (err) {
toastError(err);
}
};
useEffect(() => {
console.log("selectedChannel has changed:", selectedChannel);
}, [selectedChannel]);
useEffect(() => {
const fetchSession = async () => {
if (!whatsAppId) return;
try {
const { data } = await api.get(`whatsapp/${whatsAppId}`);
setWhatsApp(data);
const whatsQueueIds = data.queues?.map(queue => queue.id);
setSelectedQueueIds(whatsQueueIds);
} catch (err) {
toastError(err);
}
};
fetchSession();
}, [whatsAppId]);
const handleSaveWhatsApp = async values => {
const whatsappData = { ...values,
queueIds: selectedQueueIds,
type: isHubSelected ? "hub" : "whatsapp",
...(isHubSelected ? {} : {
wavoip: values.wavoip ,
lmUrl: values.lmUrl,
lmPrompt: values.lmPrompt,
lmModel: values.lmModel
}),
};
try {
if (isHubSelected && selectedChannel) {
console.log("2 >>>>>>>>>>>>>>>")
// Encontrar o objeto do canal completo baseado no ID do canal selecionado
const selectedChannelObj = availableChannels.find(
channel => channel.id === selectedChannel
);
if (selectedChannelObj) {
// Enviar o objeto completo do canal
const channels = [selectedChannelObj];
await api.post("/hub-channel/", {
...whatsappData,
channels
});
setTimeout(() => {
window.location.reload();
}, 100);
}
} else {
if (whatsAppId) {
await api.put(`/whatsapp/${whatsAppId}`, whatsappData);
} else {
await api.post("/whatsapp", whatsappData);
}
}
toast.success(i18n.t("whatsappModal.success"));
handleClose();
} catch (err) {
toastError(err);
}
};
const handleClose = () => {
onClose();
setWhatsApp(initialState);
setIsHubSelected(false);
setSelectedChannel("");
};
return (
);
};
export default React.memo(WhatsAppModal);