Passo a Passo
WHATICKET + FACEBOOK + INSTAGRAM
# Download GIT, Node, Xammp e Ngrok (dev local)
- criar banco de dados
# Clonar repositório
- git clone https://github.com/canove/whaticket-community.git
- config env
# Instalar back
- cd backend
- npm i -f
- npx sequelize db:migrate
- npx sequelize db:seed:all
- npm run dev:server
# Instalar front
- cd frontend
- npm i -f
- export NODE_OPTIONS=--openssl-legacy-provider && npm run build
- export NODE_OPTIONS=--openssl-legacy-provider && npm start
###### Construir integração
# Ngrok
- ngrok http 8080
https://1bf1-2804-3d34-5009-5f01-00-2.ngrok-free.app
# Back
- backend/package.json
# executar npm i -f (backend)
- backend/src/database/migrations/20240905140438-add-hub-to-contacts.ts
- backend/src/database/migrations/20240905140438-add-type-to-whatsapp.ts
- backend/src/database/migrations/20240905140438-change-column-number-to-allownull.ts
- backend/src/database/seeds/20240905070006-create-hubToken-settings.ts
- backend/src/models/Contact.ts
- backend/src/models/Whatsapp.ts
- backend/src/helpers/ConvertMp3ToMp4.ts
- backend/src/helpers/downloadHubFiles.ts
- backend/src/helpers/setChannelHubWebhook.ts
- backend/src/helpers/showHubToken.ts
- backend/src/services/ContactServices/UpdateContactService.ts
- backend/src/services/HubServices/CreateHubChannelsService.ts
- backend/src/services/HubServices/CreateHubMessageService.ts
- backend/src/services/HubServices/CreateHubTicketService.ts
- backend/src/services/HubServices/CreateOrUpdateHubTicketService.ts
- backend/src/services/HubServices/FindOrCreateHubContactService.ts
- backend/src/services/HubServices/HubMessageListener.ts
- backend/src/services/HubServices/ListHubChannels.ts
- backend/src/services/HubServices/SendMediaMessageHubService.ts
- backend/src/services/HubServices/SendTextMessageHubService.ts
- backend/src/services/HubServices/UpdateMessageHubAck.ts
- backend/src/services/TicketServices/ListTicketsService.ts
- backend/src/services/TicketServices/ShowTicketService.ts
- backend/src/services/WbotServices/StartAllWhatsAppsSessions.ts
- backend/src/controllers/ChannelHubController.ts
- backend/src/controllers/ContactController.ts
- backend/src/controllers/MessageHubController.ts
- backend/src/controllers/WebhookHubController.ts
- backend/src/routes/hubChannelRoutes.ts
- backend/src/routes/hubMessageRoutes.ts
- backend/src/routes/hubWebhookRoutes.ts
- backend/src/routes/index.ts
# Front:
- frontend/src/components/ContactModal/index.js
- frontend/src/components/MessageInput/index.js
- frontend/src/components/TicketListItem/index.js
- frontend/src/components/WhatsAppModal/index.js
- frontend/src/pages/Connections/index.js
- frontend/src/pages/Contacts/index.js
- frontend/src/pages/Settings/index.js
- frontend/src/translate/languages/pt.js
BACKEND
package.json
{"name":"backend","version":"1.0.0","description":"","main":"index.js","scripts":{"build":"tsc","watch":"tsc -w","start":"nodemon dist\/server.js","dev:server":"ts-node-dev --respawn --transpile-only --ignore node_modules src\/server.ts","pretest":"NODE_ENV=test sequelize db:migrate && NODE_ENV=test sequelize db:seed:all","test":"NODE_ENV=test jest","posttest":"NODE_ENV=test sequelize db:migrate:undo:all"},"author":"","license":"MIT","dependencies":{"@ffmpeg-installer\/ffmpeg":"^1.1.0","@sentry\/node":"^5.29.2","@types\/mime-types":"^2.1.4","@types\/pino":"^6.3.4","axios":"^1.7.7","bcryptjs":"^2.4.3","cookie-parser":"^1.4.5","cors":"^2.8.5","date-fns":"^2.16.1","dotenv":"^8.2.0","express":"^4.17.1","express-async-errors":"^3.1.1","file-type":"^19.4.1","fluent-ffmpeg":"^2.1.3","http-graceful-shutdown":"^2.3.2","jsonwebtoken":"^8.5.1","mime":"^4.0.4","mime-types":"^2.1.35","multer":"^1.4.2","mustache":"^4.2.0","mysql2":"^2.2.5","notificamehubsdk":"^0.0.19","pg":"^8.4.1","pino":"^6.9.0","pino-pretty":"~4.7.1","qrcode-terminal":"^0.12.0","reflect-metadata":"^0.1.13","sequelize":"^5.22.3","sequelize-cli":"^5.5.1","sequelize-typescript":"^1.1.0","socket.io":"^3.0.5","uuid":"^8.3.2","whatsapp-web.js":"^1.23.0","yup":"^0.32.8"},"devDependencies":{"@types\/bcryptjs":"^2.4.2","@types\/bluebird":"^3.5.32","@types\/cookie-parser":"^1.4.2","@types\/cors":"^2.8.7","@types\/express":"^4.17.13","@types\/factory-girl":"^5.0.2","@types\/faker":"^5.1.3","@types\/fluent-ffmpeg":"^2.1.26","@types\/jest":"^26.0.15","@types\/jsonwebtoken":"^8.5.0","@types\/multer":"^1.4.4","@types\/mustache":"^4.1.2","@types\/node":"^14.11.8","@types\/pino-pretty":"~4.7.1","@types\/supertest":"^2.0.10","@types\/uuid":"^8.3.3","@types\/validator":"^13.1.0","@types\/yup":"^0.29.8","@typescript-eslint\/eslint-plugin":"^4.4.0","@typescript-eslint\/parser":"^4.4.0","eslint":"^7.10.0","eslint-config-airbnb-base":"^14.2.0","eslint-config-prettier":"^6.12.0","eslint-import-resolver-typescript":"^2.3.0","eslint-plugin-import":"^2.22.1","eslint-plugin-prettier":"^3.1.4","factory-girl":"^5.0.4","faker":"^5.1.0","jest":"^26.6.0","nodemon":"^2.0.4","prettier":"^2.1.2","supertest":"^5.0.0","ts-jest":"^26.4.1","ts-node-dev":"^1.0.0-pre.63","typescript":"4.1.6"}}
DATABASE
backend\src\database\migrations\20240905070006-create-hubToken-settings.ts
import { QueryInterface } from "sequelize";
module.exports = {
up: (queryInterface: QueryInterface) => {
return queryInterface.bulkInsert(
"Settings",
[
{
key: "hubToken",
value: "hubToken",
createdAt: new Date(),
updatedAt: new Date()
}
],
{}
);
},
down: (queryInterface: QueryInterface) => {
return queryInterface.bulkDelete("Settings", {});
}
};
backend\src\database\migrations\20240905140438-add-hub-to-contacts.ts
import { QueryInterface, DataTypes } from "sequelize";
module.exports = {
up: (queryInterface: QueryInterface) => {
return Promise.all([
queryInterface.addColumn("Contacts", "messengerId", {
type: DataTypes.TEXT,
allowNull: true,
}),
queryInterface.addColumn("Contacts", "instagramId", {
type: DataTypes.TEXT,
allowNull: true,
})
]);
},
down: (queryInterface: QueryInterface) => {
return Promise.all([
queryInterface.removeColumn("Contacts", "messengerId"),
queryInterface.removeColumn("Contacts", "instagramId")
]);
}
};
backend\src\database\migrations\20240905140438-add-type-to-whatsapp.ts
import { QueryInterface, DataTypes } from "sequelize";
module.exports = {
up: (queryInterface: QueryInterface) => {
return queryInterface.addColumn("Whatsapps", "type", {
type: DataTypes.TEXT
});
},
down: (queryInterface: QueryInterface) => {
return queryInterface.removeColumn("Whatsapps", "type");
}
};
backend\src\database\migrations\20240905140438-change-column-number-to-allownull.ts
import { QueryInterface, DataTypes } from "sequelize";
module.exports = {
up: (queryInterface: QueryInterface) => {
return queryInterface.changeColumn("Contacts", "number", {
type: DataTypes.STRING,
allowNull: true,
unique: true
});
},
down: (queryInterface: QueryInterface) => {
return queryInterface.changeColumn("Contacts", "number", {
type: DataTypes.STRING,
allowNull: false,
unique: true
});
}
};
MODELS
backend\src\models\Contact.ts
import {
Table,
Column,
CreatedAt,
UpdatedAt,
Model,
PrimaryKey,
AutoIncrement,
AllowNull,
Unique,
Default,
HasMany
} from "sequelize-typescript";
import ContactCustomField from "./ContactCustomField";
import Ticket from "./Ticket";
@Table
class Contact extends Model {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
name: string;
@AllowNull(true)
@Unique
@Column
number: string;
@AllowNull(false)
@Default("")
@Column
email: string;
@Column
profilePicUrl: string;
@Default(false)
@Column
isGroup: boolean;
@CreatedAt
createdAt: Date;
@UpdatedAt
updatedAt: Date;
@HasMany(() => Ticket)
tickets: Ticket[];
@Column
messengerId: string;
@Column
instagramId: string;
@HasMany(() => ContactCustomField)
extraInfo: ContactCustomField[];
}
export default Contact;
backend\src\models\Whatsapp.ts
import {
Table,
Column,
CreatedAt,
UpdatedAt,
Model,
DataType,
PrimaryKey,
AutoIncrement,
Default,
AllowNull,
HasMany,
Unique,
BelongsToMany
} from "sequelize-typescript";
import Queue from "./Queue";
import Ticket from "./Ticket";
import WhatsappQueue from "./WhatsappQueue";
@Table
class Whatsapp extends Model {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@AllowNull
@Unique
@Column(DataType.TEXT)
name: string;
@Column(DataType.TEXT)
session: string;
@Column(DataType.TEXT)
qrcode: string;
@Column
status: string;
@Column
battery: string;
@Column
plugged: boolean;
@Column
retries: number;
@Column(DataType.TEXT)
greetingMessage: string;
@Column(DataType.TEXT)
farewellMessage: string;
@Column
type: string;
@Default(false)
@AllowNull
@Column
isDefault: boolean;
@CreatedAt
createdAt: Date;
@UpdatedAt
updatedAt: Date;
@HasMany(() => Ticket)
tickets: Ticket[];
@BelongsToMany(() => Queue, () => WhatsappQueue)
queues: Array;
@HasMany(() => WhatsappQueue)
whatsappQueues: WhatsappQueue[];
}
export default Whatsapp;
HELPERS
backend\src\helpers\ConvertMp3ToMp4.ts
import ffmpeg from "fluent-ffmpeg";
import { path as ffmpegPath } from "@ffmpeg-installer/ffmpeg";
import fs from "fs";
// CONVERTER MP3 PARA MP4
const convertMp3ToMp4 = (input: string, outputMP4: string): Promise => {
return new Promise((resolve, reject) => {
ffmpeg.setFfmpegPath(ffmpegPath);
if (!fs.existsSync(input)) {
const errorMsg = `Input file does not exist: ${input}`;
console.error(errorMsg);
return reject(new Error(errorMsg));
}
ffmpeg(input)
.inputFormat("mp3")
.output(outputMP4)
.outputFormat("mp4")
.on("start", (commandLine) => {
})
.on("error", (error: Error) => {
reject(error);
})
.on("progress", (progress) => {
console.log(`Processing...`);
})
.on("end", () => {
console.log("Transcoding succeeded !");
resolve();
})
.run();
});
};
export { convertMp3ToMp4 };
backend\src\helpers\setChannelHubWebhook.ts
import Whatsapp from "../models/Whatsapp";
import { IChannel } from "../controllers/ChannelHubController";
import { showHubToken } from "./showHubToken";
const {
Client,
MessageSubscription
} = require("notificamehubsdk");
require("dotenv").config();
export const setChannelWebhook = async (
whatsapp: IChannel | any,
whatsappId: string
) => {
const notificameHubToken = await showHubToken();
const client = new Client(notificameHubToken);
const url = `https://1bf1-2804-3d34-5009-5f01-00-2.ngrok-free.app/hub-webhook/${whatsapp.qrcode}`;
const subscription = new MessageSubscription(
{
url
},
{
channel: whatsapp.qrcode
}
);
// client
// .updateSubscription("subscription-identifier", subscription)
client
.createSubscription(subscription)
.then((response: any) => {
console.log("Webhook subscribed:", response);
})
.catch((error: any) => {
console.log("Error:", error);
});
await Whatsapp.update(
{
status: "CONNECTED"
},
{
where: {
id: whatsappId
}
}
);
};
backend\src\helpers\showHubToken.ts
import Setting from "../models/Setting";
export const showHubToken = async (): Promise => {
const notificameHubToken = await Setting.findOne({
where: {
key: "hubToken"
}
});
if (!notificameHubToken) {
throw new Error("Notificame Hub token not found");
}
if(notificameHubToken) {
return notificameHubToken.value;
}
};
backend\src\helpers\downloadHubFiles.ts
import axios from "axios";
import { extname, join } from "path";
import { writeFile } from "fs/promises";
import mime from "mime-types";
export const downloadFiles = async (url: string) => {
try {
const { data } = await axios.get(url, {
responseType: "arraybuffer"
});
const type = url.split("?")[0].split(".").pop();
const filename = `${new Date().getTime()}.${type}`;
const filePath = `${__dirname}/../../public/${filename}`;
await writeFile(
join(__dirname, "..", "..", "public", filename),
data,
"base64"
);
// const fileTypeResult = await fileType.fromBuffer(data);
const mimeType = mime.lookup(filePath);
const extension = extname(filePath);
const originalname = url.split("/").pop();
const media = {
mimeType,
extension,
filename,
data,
originalname
};
return media;
} catch (error) {
console.error("Erro ao processar a requisição:", error);
throw error; // Lança o erro para quem chama a função
}
};
SERVICES
backend\src\services\ContactServices\UpdateContactService.ts
import AppError from "../../errors/AppError";
import Contact from "../../models/Contact";
import ContactCustomField from "../../models/ContactCustomField";
interface ExtraInfo {
id?: number;
name: string;
value: string;
}
interface ContactData {
email?: string;
number?: string;
name?: string;
extraInfo?: ExtraInfo[];
}
interface Request {
contactData: ContactData;
contactId: string;
}
const UpdateContactService = async ({
contactData,
contactId
}: Request): Promise => {
const { email, name, number, extraInfo } = contactData;
const contact = await Contact.findOne({
where: { id: contactId },
attributes: ["id", "name", "number", "profilePicUrl", "messengerId", "instagramId"],
include: ["extraInfo"]
});
if (!contact) {
throw new AppError("ERR_NO_CONTACT_FOUND", 404);
}
if (extraInfo) {
await Promise.all(
extraInfo.map(async info => {
await ContactCustomField.upsert({ ...info, contactId: contact.id });
})
);
await Promise.all(
contact.extraInfo.map(async oldInfo => {
const stillExists = extraInfo.findIndex(info => info.id === oldInfo.id);
if (stillExists === -1) {
await ContactCustomField.destroy({ where: { id: oldInfo.id } });
}
})
);
}
await contact.update({
name,
number,
email
});
await contact.reload({
attributes: ["id", "name", "number", "profilePicUrl", "messengerId", "instagramId"],
include: ["extraInfo"]
});
return contact;
};
export default UpdateContactService;
backend\src\services\HubServices\CreateHubChannelsService.ts
import Whatsapp from "../../models/Whatsapp";
import { IChannel } from "../../controllers/ChannelHubController";
import { getIO } from "../../libs/socket";
interface Request {
channels: IChannel[];
}
interface Response {
whatsapps: Whatsapp[];
}
const CreateChannelsService = async ({
channels
}: Request): Promise => {
channels = channels.map(channel => {
return {
...channel,
type: channel.channel,
qrcode: channel.id,
status: "CONNECTED"
};
});
const whatsapps = await Whatsapp.bulkCreate(channels);
// for(const whatsapp of whatsapps){
// const connection = await Whatsapp.findOne({
// where: { qrcode: whatsapp.id }
// });
// const io = getIO();
// io.emit("whatsapp", {
// action: "update",
// connection
// });
// }
return { whatsapps };
};
export default CreateChannelsService;
backend\src\services\HubServices\CreateHubMessageService.ts
import { getIO } from "../../libs/socket";
import Message from "../../models/Message";
import Ticket from "../../models/Ticket";
import Whatsapp from "../../models/Whatsapp";
interface MessageData {
id: string;
contactId: number;
body: string;
ticketId: number;
fromMe: boolean;
fileName?: string;
mediaType?: string;
originalName?: string;
}
const CreateMessageService = async (
messageData: MessageData
): Promise => {
// console.log("creating message");
// console.log({
// messageData
// });
const {
id,
contactId,
body,
ticketId,
fromMe,
fileName,
mediaType,
originalName
} = messageData;
if ((!body || body === "") && (!fileName || fileName === "")) {
return;
}
const data: any = {
id,
contactId,
body,
ticketId,
fromMe,
ack: 2
};
if (fileName) {
data.mediaUrl = fileName;
data.mediaType = mediaType === "photo" ? "image" : mediaType;
data.body = data.mediaUrl;
}
// console.log({
// creatingMediaMessageData: data
// });
try {
const newMessage = await Message.create(data);
// await newMessage.reload({
// include: [
// {
// association: "ticket",
// }
// ]
// });
const message = await Message.findByPk(messageData.id, {
include: [
"contact",
{
model: Ticket,
as: "ticket",
include: [
"contact", "queue",
{
model: Whatsapp,
as: "whatsapp",
attributes: ["name"]
}
]
},
{
model: Message,
as: "quotedMsg",
include: ["contact"]
}
]
});
if(message){
const io = getIO();
io.to(message.ticketId.toString())
.to(message.ticket.status)
.to("notification")
.emit("appMessage", {
action: "create",
message,
ticket: message.ticket,
contact: message.ticket.contact
});
}
return newMessage;
} catch (error) {
console.log(error);
}
};
export default CreateMessageService;
backend\src\services\HubServices\CreateHubTicketService.ts
import AppError from "../../errors/AppError";
import CheckContactOpenTickets from "../../helpers/CheckContactOpenTickets";
import Ticket from "../../models/Ticket";
import User from "../../models/User";
import Whatsapp from "../../models/Whatsapp";
import ShowContactService from "../ContactServices/ShowContactService";
interface Request {
contactId: number;
status: string;
userId: number;
queueId ?: number;
channel: string;
}
const CreateTicketService = async ({
contactId,
status,
userId,
queueId,
channel
}: Request): Promise => {
let connectionType
if(channel === 'instagram' || channel === 'facebook') {
connectionType = 'facebook'
}
console.log('channel', channel)
console.log('connectionType', connectionType)
const connection = await Whatsapp.findOne({
where: { type: connectionType! }
});
if (!connection) {
throw new Error("Connection id not found");
}
await CheckContactOpenTickets(contactId, connection.id);
const { isGroup } = await ShowContactService(contactId);
if(queueId === undefined) {
const user = await User.findByPk(userId, { include: ["queues"]});
queueId = user?.queues.length === 1 ? user.queues[0].id : undefined;
}
const newTicket = await Ticket.create({
status,
lastMessage: null,
contactId,
isGroup,
whatsappId: connection.id
});
const ticket = await Ticket.findByPk(newTicket.id, { include: ["contact"] });
if (!ticket) {
throw new AppError("ERR_CREATING_TICKET");
}
return ticket;
};
export default CreateTicketService;
backend\src\services\HubServices\CreateOrUpdateHubTicketService.ts
import { Op } from "sequelize";
import Ticket from "../../models/Ticket";
import Whatsapp from "../../models/Whatsapp";
import { IContent } from "./HubMessageListener";
import { getIO } from "../../libs/socket";
interface TicketData {
contactId: number;
channel: string;
contents: IContent[];
connection: Whatsapp;
}
const CreateOrUpdateTicketService = async (
ticketData: TicketData
): Promise => {
const { contactId, channel, contents, connection } = ticketData;
const io = getIO();
const ticketExists = await Ticket.findOne({
where: {
contactId,
channel,
whatsappId: connection.id,
}
});
if (ticketExists) {
let newStatus = ticketExists.status;
let newQueueId = ticketExists.queueId;
if (ticketExists.status === "closed") {
newStatus = "pending";
}
await ticketExists.update({
lastMessage: contents[0].text,
status: newStatus,
queueId: newQueueId
});
await ticketExists.reload({
include: [
{
association: "contact"
},
{
association: "user"
},
{
association: "queue"
},
{
association: "tags"
},
{
association: "whatsapp"
}
]
});
return ticketExists;
}
const newTicket = await Ticket.create({
status: "pending",
channel,
lastMessage: contents[0].text,
contactId,
whatsappId: connection.id
});
await newTicket.reload({
include: [
{
association: "contact"
},
{
association: "user"
},
{
association: "whatsapp"
}
]
});
return newTicket;
};
export default CreateOrUpdateTicketService;
backend\src\services\HubServices\FindOrCreateHubContactService.ts
import Contact from "../../models/Contact";
import Whatsapp from "../../models/Whatsapp";
interface HubContact {
name: string;
firstName: string;
lastName: string;
picture: string;
from: string;
whatsapp?: Whatsapp;
channel: string;
}
const FindOrCreateContactService = async (
contact: HubContact
): Promise => {
const { name, picture, firstName, lastName, from, channel } = contact;
console.log('contact', contact)
let numberFb
let numberIg
let contactExists
if(channel === 'facebook'){
numberFb = from
contactExists = await Contact.findOne({
where: {
messengerId: from,
}
});
}
if(channel === 'instagram'){
numberIg = from
contactExists = await Contact.findOne({
where: {
instagramId: from
}
});
}
if (contactExists) {
await contactExists.update({ name: name || firstName || 'Name Unavailable' , firstName, lastName, profilePicUrl: picture })
return contactExists;
}
const newContact = await Contact.create({
name: name || firstName || 'Name Unavailable',
number: null,
profilePicUrl: picture,
messengerId: numberFb || null,
instagramId: numberIg || null
});
return newContact;
};
export default FindOrCreateContactService;
backend\src\services\HubServices\HubMessageListener.ts
import Whatsapp from "../../models/Whatsapp";
import { downloadFiles } from "../../helpers/downloadHubFiles";
import CreateMessageService from "./CreateHubMessageService";
import CreateOrUpdateTicketService from "./CreateOrUpdateHubTicketService";
import FindOrCreateContactService from "./FindOrCreateHubContactService";
import { UpdateMessageAck } from "./UpdateMessageHubAck";
import FindOrCreateTicketService from "../TicketServices/FindOrCreateTicketService";
export interface HubInMessage {
type: "MESSAGE";
id: string;
timestamp: string;
subscriptionId: string;
channel: "telegram" | "whatsapp" | "facebook" | "instagram" | "sms" | "email";
direction: "IN";
message: {
id: string;
from: string;
to: string;
direction: "IN";
channel:
| "telegram"
| "whatsapp"
| "facebook"
| "instagram"
| "sms"
| "email";
visitor: {
name: string;
firstName: string;
lastName: string;
picture: string;
};
contents: IContent[];
timestamp: string;
};
}
export interface IContent {
type: "text" | "image" | "audio" | "video" | "file" | "location";
text?: string;
url?: string;
fileUrl?: string;
latitude?: number;
longitude?: number;
filename?: string;
fileSize?: number;
fileMimeType?: string;
}
export interface HubConfirmationSentMessage {
type: "MESSAGE_STATUS";
timestamp: string;
subscriptionId: string;
channel: "telegram" | "whatsapp" | "facebook" | "instagram" | "sms" | "email";
messageId: string;
contentIndex: number;
messageStatus: {
timestamp: string;
code: "SENT" | "REJECTED";
description: string;
};
}
const verifySentMessageStatus = (message: HubConfirmationSentMessage) => {
const {
messageStatus: { code }
} = message;
const isMessageSent = code === "SENT";
if (isMessageSent) {
return true;
}
return false;
};
const HubMessageListener = async (
message: any | HubInMessage | HubConfirmationSentMessage,
whatsapp: Whatsapp,
medias: Express.Multer.File[]
) => {
console.log("HubMessageListener", message);
console.log("contents", message.message.contents);
if(message.direction === 'IN'){
message.fromMe = false
}
const ignoreEvent = message.direction === 'OUT'
if (ignoreEvent) {
return;
}
const isMessageFromMe = message.type === "MESSAGE_STATUS";
if (isMessageFromMe) {
const isMessageSent = verifySentMessageStatus(
message as HubConfirmationSentMessage
);
if (isMessageSent) {
console.log("HubMessageListener: message sent");
UpdateMessageAck(message.messageId);
} else {
console.log(
"HubMessageListener: message not sent",
message.messageStatus.code,
message.messageStatus.description
);
}
return;
}
const {
message: { id, from, channel, contents, visitor }
} = message as HubInMessage;
try {
const contact = await FindOrCreateContactService({
...visitor,
from,
whatsapp,
channel
});
const unreadMessages = 1
const ticket = await FindOrCreateTicketService(
contact,
whatsapp.id!,
unreadMessages,
);
// const ticket = await CreateOrUpdateTicketService({
// contactId: contact.id,
// channel,
// contents,
// whatsapp
// });
if (contents[0]?.type === "text") {
await CreateMessageService({
id,
contactId: contact.id,
body: contents[0].text || "",
ticketId: ticket.id,
fromMe: false,
});
} else if (contents[0]?.fileUrl) {
const media = await downloadFiles(contents[0].fileUrl);
if (typeof media.mimeType === "string") {
await CreateMessageService({
id,
contactId: contact.id,
body: contents[0].text || '',
ticketId: ticket.id,
fromMe: false,
fileName: `${media.filename}`,
mediaType: media.mimeType.split("/")[0],
originalName: media.originalname
});
}
}
} catch (error: any) {
console.log(error);
}
};
export default HubMessageListener;
backend\src\services\HubServices\ListHubChannels.ts
import { showHubToken } from "../../helpers/showHubToken";
const { Client } = require("notificamehubsdk");
require("dotenv").config();
const ListChannels = async () => {
try {
const notificameHubToken = await showHubToken();
if (!notificameHubToken) {
throw new Error("NOTIFICAMEHUB_TOKEN_NOT_FOUND");
}
const client = new Client(notificameHubToken);
const response = await client.listChannels();
console.log("Response:", response);
return response;
} catch (error) {
throw new Error('Error');
}
};
export default ListChannels;
backend\src\services\HubServices\SendMediaMessageHubService.ts
require("dotenv").config();
const { Client, FileContent } = require("notificamehubsdk");
import Contact from "../../models/Contact";
import CreateMessageService from "./CreateHubMessageService";
import { showHubToken } from "../../helpers/showHubToken";
import { convertMp3ToMp4 } from "../../helpers/ConvertMp3ToMp4";
export const SendMediaMessageService = async (
media: Express.Multer.File,
message: string,
ticketId: number,
contact: Contact,
connection: any
) => {
const notificameHubToken = await showHubToken();
const client = new Client(notificameHubToken);
let channelClient
let contactNumber
let type
let mediaUrl
if(contact.messengerId && !contact.instagramId){
contactNumber = contact.messengerId
type = 'facebook'
channelClient = client.setChannel(type);
}
if(!contact.messengerId && contact.instagramId){
contactNumber = contact.instagramId
type = 'instagram'
channelClient = client.setChannel(type);
}
message = message.replace(/\n/g, " ");
const backendUrl = 'https://1bf1-2804-3d34-5009-5f01-00-2.ngrok-free.app';
const filename = encodeURIComponent(media.filename);
mediaUrl = `${backendUrl}/public/${filename}`;
if (media.mimetype.includes("image")) {
if (type === "telegram") {
media.mimetype = "photo";
} else {
media.mimetype = "image";
}
} else if (
(type === "telegram" || type === "facebook") &&
media.mimetype.includes("audio")
) {
media.mimetype = "audio";
} else if (
(type === "telegram" || type === "facebook") &&
media.mimetype.includes("video")
) {
media.mimetype = "video";
} else if (type === "telegram" || type === "facebook") {
media.mimetype = "file";
}
try {
if (media.originalname.includes('.mp3') && type === 'instagram') {
const inputPath = media.path;
const outputMP4Path = `${media.destination}/${media.filename.split('.')[0]}.mp4`;
try {
await convertMp3ToMp4(inputPath, outputMP4Path);
media.filename = outputMP4Path.split('/').pop() ?? 'default.mp4';
mediaUrl = `${backendUrl}/public/${media.filename}`;
media.originalname = media.filename
media.mimetype = 'audio'
} catch(e){
}
}
if (media.originalname.includes('.mp3') && type === 'facebook') {
mediaUrl = `${backendUrl}/public/${media.filename}`;
media.originalname = media.filename
media.mimetype = 'audio'
}
const content = new FileContent(
mediaUrl,
media.mimetype,
media.originalname,
media.originalname
);
console.log({
token: connection.qrcode,
number: contactNumber,
content,
message
});
let response = await channelClient.sendMessage(
connection.qrcode,
contactNumber,
content
);
console.log("response:", response);
let data: any;
try {
const jsonStart = response.indexOf("{");
const jsonResponse = response.substring(jsonStart);
data = JSON.parse(jsonResponse);
} catch (error) {
data = response;
}
const newMessage = await CreateMessageService({
id: data.id,
contactId: contact.id,
body: message,
ticketId,
fromMe: true,
fileName: `${media.filename}`,
mediaType: media.mimetype.split("/")[0],
originalName: media.originalname
});
return newMessage;
} catch (error) {
console.log("Error:", error);
}
};
backend\src\services\HubServices\SendTextMessageHubService.ts
require("dotenv").config();
const { Client, TextContent } = require("notificamehubsdk");
import Contact from "../../models/Contact";
import CreateMessageService from "./CreateHubMessageService";
import { showHubToken } from "../../helpers/showHubToken";
export const SendTextMessageService = async (
message: string,
ticketId: number,
contact: Contact,
connection: any
) => {
const notificameHubToken = await showHubToken();
const client = new Client(notificameHubToken);
let channelClient
message = message.replace(/\n/g, " ");
const content = new TextContent(message);
let contactNumber
if(contact.messengerId && !contact.instagramId){
contactNumber = contact.messengerId
channelClient = client.setChannel('facebook');
}
if(!contact.messengerId && contact.instagramId){
contactNumber = contact.instagramId
channelClient = client.setChannel('instagram');
}
try {
console.log({
token: connection.qrcode,
number: contactNumber,
content,
message
});
let response = await channelClient.sendMessage(
connection.qrcode,
contactNumber,
content
);
console.log("response:", response);
let data: any;
try {
const jsonStart = response.indexOf("{");
const jsonResponse = response.substring(jsonStart);
data = JSON.parse(jsonResponse);
} catch (error) {
data = response;
}
const newMessage = await CreateMessageService({
id: data.id,
contactId: contact.id,
body: message,
ticketId,
fromMe: true
});
return newMessage;
} catch (error) {
console.log("Error:", error);
}
};
backend\src\services\HubServices\UpdateMessageHubAck.ts
import Message from "../../models/Message";
export const UpdateMessageAck = async (messageId: string): Promise => {
const message = await Message.findOne({
where: {
id: messageId
}
});
if (!message) {
return;
}
await message.update({
ack: 3
});
};
backend\src\services\TicketServices\ListTicketsService.ts
import { Op, fn, where, col, Filterable, Includeable } from "sequelize";
import { startOfDay, endOfDay, parseISO } from "date-fns";
import Ticket from "../../models/Ticket";
import Contact from "../../models/Contact";
import Message from "../../models/Message";
import Queue from "../../models/Queue";
import ShowUserService from "../UserServices/ShowUserService";
import Whatsapp from "../../models/Whatsapp";
interface Request {
searchParam?: string;
pageNumber?: string;
status?: string;
date?: string;
showAll?: string;
userId: string;
withUnreadMessages?: string;
queueIds: number[];
}
interface Response {
tickets: Ticket[];
count: number;
hasMore: boolean;
}
const ListTicketsService = async ({
searchParam = "",
pageNumber = "1",
queueIds,
status,
date,
showAll,
userId,
withUnreadMessages
}: Request): Promise => {
let whereCondition: Filterable["where"] = {
[Op.or]: [{ userId }, { status: "pending" }],
queueId: { [Op.or]: [queueIds, null] }
};
let includeCondition: Includeable[];
includeCondition = [
{
model: Contact,
as: "contact",
attributes: ["id", "name", "number", "profilePicUrl", "messengerId", "instagramId"]
},
{
model: Queue,
as: "queue",
attributes: ["id", "name", "color"]
},
{
model: Whatsapp,
as: "whatsapp",
attributes: ["name"]
}
];
if (showAll === "true") {
whereCondition = { queueId: { [Op.or]: [queueIds, null] } };
}
if (status) {
whereCondition = {
...whereCondition,
status
};
}
if (searchParam) {
const sanitizedSearchParam = searchParam.toLocaleLowerCase().trim();
includeCondition = [
...includeCondition,
{
model: Message,
as: "messages",
attributes: ["id", "body"],
where: {
body: where(
fn("LOWER", col("body")),
"LIKE",
`%${sanitizedSearchParam}%`
)
},
required: false,
duplicating: false
}
];
whereCondition = {
...whereCondition,
[Op.or]: [
{
"$contact.name$": where(
fn("LOWER", col("contact.name")),
"LIKE",
`%${sanitizedSearchParam}%`
)
},
{ "$contact.number$": { [Op.like]: `%${sanitizedSearchParam}%` } },
{
"$message.body$": where(
fn("LOWER", col("body")),
"LIKE",
`%${sanitizedSearchParam}%`
)
}
]
};
}
if (date) {
whereCondition = {
createdAt: {
[Op.between]: [+startOfDay(parseISO(date)), +endOfDay(parseISO(date))]
}
};
}
if (withUnreadMessages === "true") {
const user = await ShowUserService(userId);
const userQueueIds = user.queues.map(queue => queue.id);
whereCondition = {
[Op.or]: [{ userId }, { status: "pending" }],
queueId: { [Op.or]: [userQueueIds, null] },
unreadMessages: { [Op.gt]: 0 }
};
}
const limit = 40;
const offset = limit * (+pageNumber - 1);
const { count, rows: tickets } = await Ticket.findAndCountAll({
where: whereCondition,
include: includeCondition,
distinct: true,
limit,
offset,
order: [["updatedAt", "DESC"]]
});
const hasMore = count > offset + tickets.length;
return {
tickets,
count,
hasMore
};
};
export default ListTicketsService;
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"]
}
]
});
if (!ticket) {
throw new AppError("ERR_NO_TICKET_FOUND", 404);
}
return ticket;
};
export default ShowTicketService;
backend\src\services\WbotServices\StartAllWhatsAppsSessions.ts
import { setChannelWebhook } from "../../helpers/setChannelHubWebhook";
import ListWhatsAppsService from "../WhatsappService/ListWhatsAppsService";
import { StartWhatsAppSession } from "./StartWhatsAppSession";
export const StartAllWhatsAppsSessions = async (): Promise => {
const whatsapps = await ListWhatsAppsService();
if (whatsapps.length > 0) {
whatsapps.forEach(whatsapp => {
if(whatsapp.type !== null) {
setChannelWebhook(whatsapp, whatsapp.id.toString());
} else {
StartWhatsAppSession(whatsapp);
}
});
}
};
CONTROLLERS
backend\src\controllers\ChannelHubController.ts
import { Request, Response } from "express";
import CreateChannelsService from "../services/HubServices/CreateHubChannelsService";
import { setChannelWebhook } from "../helpers/setChannelHubWebhook";
import { getIO } from "../libs/socket";
import ListChannels from "../services/HubServices/ListHubChannels";
export interface IChannel {
name: string;
status?: string;
isDefault?: boolean;
qrcode?: string;
type?: string;
channel?: string;
id?:string;
}
export const store = async (req: Request, res: Response): Promise => {
const { whatsapps } = await CreateChannelsService(req.body);
whatsapps.forEach(whatsapp => {
setTimeout(() => {
setChannelWebhook(whatsapp, whatsapp.id.toString());
}, 2000);
});
return res.status(200).json(whatsapps);
};
export const index = async (req: Request, res: Response): Promise => {
try {
const channels = await ListChannels();
return res.status(200).json(channels);
} catch (error) {
return res.status(500).json({ error: error });
}
};
backend\src\controllers\ContactController.ts
import * as Yup from "yup";
import { Request, Response } from "express";
import { getIO } from "../libs/socket";
import ListContactsService from "../services/ContactServices/ListContactsService";
import CreateContactService from "../services/ContactServices/CreateContactService";
import ShowContactService from "../services/ContactServices/ShowContactService";
import UpdateContactService from "../services/ContactServices/UpdateContactService";
import DeleteContactService from "../services/ContactServices/DeleteContactService";
import CheckContactNumber from "../services/WbotServices/CheckNumber"
import CheckIsValidContact from "../services/WbotServices/CheckIsValidContact";
import GetProfilePicUrl from "../services/WbotServices/GetProfilePicUrl";
import AppError from "../errors/AppError";
import GetContactService from "../services/ContactServices/GetContactService";
type IndexQuery = {
searchParam: string;
pageNumber: string;
};
type IndexGetContactQuery = {
name: string;
number: string;
};
interface ExtraInfo {
name: string;
value: string;
}
interface ContactData {
name: string;
number: string;
email?: string;
messengerId?: string;
instagramId?: string;
extraInfo?: ExtraInfo[];
}
export const index = async (req: Request, res: Response): Promise => {
const { searchParam, pageNumber } = req.query as IndexQuery;
const { contacts, count, hasMore } = await ListContactsService({
searchParam,
pageNumber
});
return res.json({ contacts, count, hasMore });
};
export const getContact = async (req: Request, res: Response): Promise => {
const { name, number } = req.body as IndexGetContactQuery;
const contact = await GetContactService({
name,
number
});
return res.status(200).json(contact);
};
export const store = async (req: Request, res: Response): Promise => {
const newContact: ContactData = req.body;
newContact.number = newContact.number.replace("-", "").replace(" ", "");
const schema = Yup.object().shape({
name: Yup.string().required(),
number: Yup.string()
.required()
.matches(/^\d+$/, "Invalid number format. Only numbers is allowed.")
});
try {
await schema.validate(newContact);
} catch (err) {
throw new AppError(err.message);
}
await CheckIsValidContact(newContact.number);
const validNumber : any = await CheckContactNumber(newContact.number)
const profilePicUrl = await GetProfilePicUrl(validNumber);
let name = newContact.name
let number = validNumber
let email = newContact.email
let extraInfo = newContact.extraInfo
const contact = await CreateContactService({
name,
number,
email,
extraInfo,
profilePicUrl
});
const io = getIO();
io.emit("contact", {
action: "create",
contact
});
return res.status(200).json(contact);
};
export const show = async (req: Request, res: Response): Promise => {
const { contactId } = req.params;
const contact = await ShowContactService(contactId);
return res.status(200).json(contact);
};
export const update = async (
req: Request,
res: Response
): Promise => {
const contactData: ContactData = req.body;
const schema = Yup.object().shape({
name: Yup.string(),
// number: Yup.string().matches(
// /^\d+$/,
// "Invalid number format. Only numbers is allowed."
// )
});
try {
await schema.validate(contactData);
} catch (err) {
throw new AppError(err.message);
}
if(!contactData.messengerId && !contactData.instagramId){
await CheckIsValidContact(contactData.number);
}
const { contactId } = req.params;
const contact = await UpdateContactService({ contactData, contactId });
const io = getIO();
io.emit("contact", {
action: "update",
contact
});
return res.status(200).json(contact);
};
export const remove = async (
req: Request,
res: Response
): Promise => {
const { contactId } = req.params;
await DeleteContactService(contactId);
const io = getIO();
io.emit("contact", {
action: "delete",
contactId
});
return res.status(200).json({ message: "Contact deleted" });
};
backend\src\controllers\MessageHubController.ts
import { Request, Response } from "express";
import Contact from "../models/Contact";
import Ticket from "../models/Ticket";
import { SendTextMessageService } from "../services/HubServices/SendTextMessageHubService";
import Whatsapp from "../models/Whatsapp";
import { SendMediaMessageService } from "../services/HubServices/SendMediaMessageHubService";
import CreateHubTicketService from "../services/HubServices/CreateHubTicketService";
import { getIO } from "../libs/socket";
interface TicketData {
contactId: number;
status: string;
queueId: number;
userId: number;
channel: string;
}
export const send = async (req: Request, res: Response): Promise => {
const { body: message } = req.body;
const { ticketId } = req.params;
const medias = req.files as Express.Multer.File[];
console.log("sending hub message controller");
const ticket = await Ticket.findByPk(ticketId, {
include: [
{
model: Contact,
as: "contact",
attributes: ["number", "messengerId", "instagramId"]
},
{
model: Whatsapp,
as: "whatsapp",
attributes: ["qrcode", "type"]
}
]
});
if (!ticket) {
return res.status(404).json({ message: "Ticket not found" });
}
try {
if (medias) {
await Promise.all(
medias.map(async (media: Express.Multer.File) => {
await SendMediaMessageService(
media,
message,
ticket.id,
ticket.contact,
ticket.whatsapp
);
})
);
} else {
await SendTextMessageService(
message,
ticket.id,
ticket.contact,
ticket.whatsapp
);
}
return res.status(200).json({ message: "Message sent" });
} catch (error) {
console.log(error);
return res.status(400).json({ message: error });
}
};
export const store = async (req: Request, res: Response): Promise => {
const { contactId, status, userId, channel }: TicketData = req.body;
const ticket = await CreateHubTicketService({ contactId, status, userId, channel });
const io = getIO();
io.to(ticket.status).emit("ticket", {
action: "update",
ticket
});
return res.status(200).json(ticket);
};
backend\src\controllers\WebhookHubController.ts
import { Request, Response } from "express";
import Whatsapp from "../models/Whatsapp";
import HubMessageListener from "../services/HubServices/HubMessageListener";
export const listen = async (
req: Request,
res: Response
): Promise => {
console.log("Webhook received");
const medias = req.files as Express.Multer.File[];
const { channelId } = req.params;
const connection = await Whatsapp.findOne({
where: { qrcode: channelId }
});
if (!connection) {
return res.status(404).json({ message: "Whatsapp channel not found" });
}
try {
await HubMessageListener(req.body, connection, medias);
return res.status(200).json({ message: "Webhook received" });
} catch (error) {
return res.status(400).json({ message: error });
}
};
ROUTES
backend\src\routes\hubChannelRoutes.ts
import express from "express";
import * as ChannelController from "../controllers/ChannelHubController";
import isAuth from "../middleware/isAuth";
const hubChannelRoutes = express.Router();
hubChannelRoutes.post("/hub-channel/", isAuth, ChannelController.store);
hubChannelRoutes.get("/hub-channel/", isAuth, ChannelController.index);
export default hubChannelRoutes;
backend\src\routes\hubMessageRoutes.ts
import express from "express";
import uploadConfig from "../config/upload";
import * as MessageController from "../controllers/MessageHubController";
import isAuth from "../middleware/isAuth";
import multer from "multer";
const hubMessageRoutes = express.Router();
const upload = multer(uploadConfig);
hubMessageRoutes.post(
"/hub-message/:ticketId",
isAuth,
upload.array("medias"),
MessageController.send
);
hubMessageRoutes.post("/hub-ticket", isAuth, MessageController.store);
export default hubMessageRoutes;
backend\src\routes\hubWebhookRoutes.ts
import express from "express";
import uploadConfig from "../config/upload";
import * as WebhookController from "../controllers/WebhookHubController";
import multer from "multer";
const hubWebhookRoutes = express.Router();
const upload = multer(uploadConfig);
hubWebhookRoutes.post(
"/hub-webhook/:channelId",
upload.array("medias"),
WebhookController.listen
);
export default hubWebhookRoutes;
backend\src\routes\index.ts
import { Router } from "express";
import userRoutes from "./userRoutes";
import authRoutes from "./authRoutes";
import settingRoutes from "./settingRoutes";
import contactRoutes from "./contactRoutes";
import ticketRoutes from "./ticketRoutes";
import whatsappRoutes from "./whatsappRoutes";
import messageRoutes from "./messageRoutes";
import whatsappSessionRoutes from "./whatsappSessionRoutes";
import queueRoutes from "./queueRoutes";
import quickAnswerRoutes from "./quickAnswerRoutes";
import apiRoutes from "./apiRoutes";
import hubChannelRoutes from "./hubChannelRoutes";
import hubMessageRoutes from "./hubMessageRoutes";
import hubWebhookRoutes from "./hubWebhookRoutes";
const routes = Router();
routes.use(userRoutes);
routes.use("/auth", authRoutes);
routes.use(settingRoutes);
routes.use(contactRoutes);
routes.use(ticketRoutes);
routes.use(whatsappRoutes);
routes.use(messageRoutes);
routes.use(whatsappSessionRoutes);
routes.use(queueRoutes);
routes.use(quickAnswerRoutes);
routes.use(hubChannelRoutes);
routes.use(hubMessageRoutes);
routes.use(hubWebhookRoutes);
routes.use("/api/messages", apiRoutes);
export default routes;
FRONTEND
COMPONENTS
frontend\src\components\ContactModal\index.js
import React, { useState, useEffect, useRef } from "react";
import * as Yup from "yup";
import { Formik, FieldArray, Form, Field } from "formik";
import { toast } from "react-toastify";
import { makeStyles } from "@material-ui/core/styles";
import { green } from "@material-ui/core/colors";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from "@material-ui/core/DialogTitle";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
import CircularProgress from "@material-ui/core/CircularProgress";
import { i18n } from "../../translate/i18n";
import api from "../../services/api";
import toastError from "../../errors/toastError";
const useStyles = makeStyles(theme => ({
root: {
display: "flex",
flexWrap: "wrap",
},
textField: {
marginRight: theme.spacing(1),
flex: 1,
},
extraAttr: {
display: "flex",
justifyContent: "center",
alignItems: "center",
},
btnWrapper: {
position: "relative",
},
buttonProgress: {
color: green[500],
position: "absolute",
top: "50%",
left: "50%",
marginTop: -12,
marginLeft: -12,
},
}));
const ContactSchema = Yup.object().shape({
name: Yup.string()
.min(2, "Too Short!")
.max(50, "Too Long!")
.required("Required"),
// number: Yup.string().min(8, "Too Short!").max(50, "Too Long!"),
email: Yup.string().email("Invalid email"),
});
const ContactModal = ({ open, onClose, contactId, initialValues, onSave }) => {
const classes = useStyles();
const isMounted = useRef(true);
const initialState = {
name: "",
number: "",
email: "",
};
const [contact, setContact] = useState(initialState);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
const fetchContact = async () => {
if (initialValues) {
setContact(prevState => {
return { ...prevState, ...initialValues };
});
}
if (!contactId) return;
try {
const { data } = await api.get(`/contacts/${contactId}`);
if (isMounted.current) {
setContact(data);
}
} catch (err) {
toastError(err);
}
};
fetchContact();
}, [contactId, open, initialValues]);
const handleClose = () => {
onClose();
setContact(initialState);
};
const handleSaveContact = async values => {
try {
if (contactId) {
await api.put(`/contacts/${contactId}`, values);
handleClose();
} else {
const { data } = await api.post("/contacts", values);
if (onSave) {
onSave(data);
}
handleClose();
}
toast.success(i18n.t("contactModal.success"));
} catch (err) {
toastError(err);
}
};
return (
);
};
export default ContactModal;
frontend\src\components\MessageInput\index.js
import React, { useState, useEffect, useContext, useRef } from "react";
import "emoji-mart/css/emoji-mart.css";
import { useParams } from "react-router-dom";
import { Picker } from "emoji-mart";
import MicRecorder from "mic-recorder-to-mp3";
import clsx from "clsx";
import { makeStyles } from "@material-ui/core/styles";
import Paper from "@material-ui/core/Paper";
import InputBase from "@material-ui/core/InputBase";
import CircularProgress from "@material-ui/core/CircularProgress";
import { green } from "@material-ui/core/colors";
import AttachFileIcon from "@material-ui/icons/AttachFile";
import IconButton from "@material-ui/core/IconButton";
import MoreVert from "@material-ui/icons/MoreVert";
import MoodIcon from "@material-ui/icons/Mood";
import SendIcon from "@material-ui/icons/Send";
import CancelIcon from "@material-ui/icons/Cancel";
import ClearIcon from "@material-ui/icons/Clear";
import MicIcon from "@material-ui/icons/Mic";
import CheckCircleOutlineIcon from "@material-ui/icons/CheckCircleOutline";
import HighlightOffIcon from "@material-ui/icons/HighlightOff";
import {
FormControlLabel,
Hidden,
Menu,
MenuItem,
Switch,
} from "@material-ui/core";
import ClickAwayListener from "@material-ui/core/ClickAwayListener";
import { i18n } from "../../translate/i18n";
import api from "../../services/api";
import RecordingTimer from "./RecordingTimer";
import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext";
import { AuthContext } from "../../context/Auth/AuthContext";
import { useLocalStorage } from "../../hooks/useLocalStorage";
import toastError from "../../errors/toastError";
const Mp3Recorder = new MicRecorder({ bitRate: 128 });
const useStyles = makeStyles((theme) => ({
mainWrapper: {
background: "#eee",
display: "flex",
flexDirection: "column",
alignItems: "center",
borderTop: "1px solid rgba(0, 0, 0, 0.12)",
[theme.breakpoints.down("sm")]: {
position: "fixed",
bottom: 0,
width: "100%",
},
},
newMessageBox: {
background: "#eee",
width: "100%",
display: "flex",
padding: "7px",
alignItems: "center",
},
messageInputWrapper: {
padding: 6,
marginRight: 7,
background: "#fff",
display: "flex",
borderRadius: 20,
flex: 1,
position: "relative",
},
messageInput: {
paddingLeft: 10,
flex: 1,
border: "none",
},
sendMessageIcons: {
color: "grey",
},
uploadInput: {
display: "none",
},
viewMediaInputWrapper: {
display: "flex",
padding: "10px 13px",
position: "relative",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: "#eee",
borderTop: "1px solid rgba(0, 0, 0, 0.12)",
},
emojiBox: {
position: "absolute",
bottom: 63,
width: 40,
borderTop: "1px solid #e8e8e8",
},
circleLoading: {
color: green[500],
opacity: "70%",
position: "absolute",
top: "20%",
left: "50%",
marginLeft: -12,
},
audioLoading: {
color: green[500],
opacity: "70%",
},
recorderWrapper: {
display: "flex",
alignItems: "center",
alignContent: "middle",
},
cancelAudioIcon: {
color: "red",
},
sendAudioIcon: {
color: "green",
},
replyginMsgWrapper: {
display: "flex",
width: "100%",
alignItems: "center",
justifyContent: "center",
paddingTop: 8,
paddingLeft: 73,
paddingRight: 7,
},
replyginMsgContainer: {
flex: 1,
marginRight: 5,
overflowY: "hidden",
backgroundColor: "rgba(0, 0, 0, 0.05)",
borderRadius: "7.5px",
display: "flex",
position: "relative",
},
replyginMsgBody: {
padding: 10,
height: "auto",
display: "block",
whiteSpace: "pre-wrap",
overflow: "hidden",
},
replyginContactMsgSideColor: {
flex: "none",
width: "4px",
backgroundColor: "#35cd96",
},
replyginSelfMsgSideColor: {
flex: "none",
width: "4px",
backgroundColor: "#6bcbef",
},
messageContactName: {
display: "flex",
color: "#6bcbef",
fontWeight: 500,
},
messageQuickAnswersWrapper: {
margin: 0,
position: "absolute",
bottom: "50px",
background: "#ffffff",
padding: "2px",
border: "1px solid #CCC",
left: 0,
width: "100%",
"& li": {
listStyle: "none",
"& a": {
display: "block",
padding: "8px",
textOverflow: "ellipsis",
overflow: "hidden",
maxHeight: "32px",
"&:hover": {
background: "#F1F1F1",
cursor: "pointer",
},
},
},
},
}));
const MessageInput = ({ ticketStatus }) => {
const classes = useStyles();
const { ticketId } = useParams();
const [medias, setMedias] = useState([]);
const [inputMessage, setInputMessage] = useState("");
const [showEmoji, setShowEmoji] = useState(false);
const [loading, setLoading] = useState(false);
const [recording, setRecording] = useState(false);
const [quickAnswers, setQuickAnswer] = useState([]);
const [typeBar, setTypeBar] = useState(false);
const inputRef = useRef();
const [anchorEl, setAnchorEl] = useState(null);
const { setReplyingMessage, replyingMessage } =
useContext(ReplyMessageContext);
const { user } = useContext(AuthContext);
const [channelType, setChannelType] = useState(null);
const [signMessage, setSignMessage] = useLocalStorage("signOption", true);
useEffect(() => {
inputRef.current.focus();
}, [replyingMessage]);
useEffect(() => {
const fetchChannelType = async () => {
try {
const { data } = await api.get(`/tickets/${ticketId}`);
setChannelType(data.whatsapp?.type);
} catch (err) {
toastError(err);
}
};
fetchChannelType();
}, [ticketId]);
useEffect(() => {
inputRef.current.focus();
return () => {
setInputMessage("");
setShowEmoji(false);
setMedias([]);
setReplyingMessage(null);
};
}, [ticketId, setReplyingMessage]);
const handleChangeInput = (e) => {
setInputMessage(e.target.value);
handleLoadQuickAnswer(e.target.value);
};
const handleQuickAnswersClick = (value) => {
setInputMessage(value);
setTypeBar(false);
};
const handleAddEmoji = (e) => {
let emoji = e.native;
setInputMessage((prevState) => prevState + emoji);
};
const handleChangeMedias = (e) => {
if (!e.target.files) {
return;
}
const selectedMedias = Array.from(e.target.files);
setMedias(selectedMedias);
};
const handleInputPaste = (e) => {
if (e.clipboardData.files[0]) {
setMedias([e.clipboardData.files[0]]);
}
};
const handleUploadMedia = async (e) => {
setLoading(true);
e.preventDefault();
const formData = new FormData();
formData.append("fromMe", true);
medias.forEach((media) => {
formData.append("medias", media);
formData.append("body", media.name);
});
try {
if (channelType !== null) {
await api.post(`/hub-message/${ticketId}`, formData);
} else {
await api.post(`/messages/${ticketId}`, formData);
}
} catch (err) {
toastError(err);
}
setLoading(false);
setMedias([]);
};
const handleSendMessage = async () => {
if (inputMessage.trim() === "") return;
setLoading(true);
const message = {
read: 1,
fromMe: true,
mediaUrl: "",
body: signMessage
? `*${user?.name}:*\n${inputMessage.trim()}`
: inputMessage.trim(),
quotedMsg: replyingMessage,
};
try {
if (channelType !== null) {
await api.post(`/hub-message/${ticketId}`, message);
} else {
await api.post(`/messages/${ticketId}`, message);
}
} catch (err) {
toastError(err);
}
setInputMessage("");
setShowEmoji(false);
setLoading(false);
setReplyingMessage(null);
};
const handleStartRecording = async () => {
setLoading(true);
try {
await navigator.mediaDevices.getUserMedia({ audio: true });
await Mp3Recorder.start();
setRecording(true);
setLoading(false);
} catch (err) {
toastError(err);
setLoading(false);
}
};
const handleLoadQuickAnswer = async (value) => {
if (value && value.indexOf("/") === 0) {
try {
const { data } = await api.get("/quickAnswers/", {
params: { searchParam: inputMessage.substring(1) },
});
setQuickAnswer(data.quickAnswers);
if (data.quickAnswers.length > 0) {
setTypeBar(true);
} else {
setTypeBar(false);
}
} catch (err) {
setTypeBar(false);
}
} else {
setTypeBar(false);
}
};
const handleUploadAudio = async () => {
setLoading(true);
try {
const [, blob] = await Mp3Recorder.stop().getMp3();
if (blob.size < 10000) {
setLoading(false);
setRecording(false);
return;
}
const formData = new FormData();
const filename = `${new Date().getTime()}.mp3`;
formData.append("medias", blob, filename);
formData.append("body", filename);
formData.append("fromMe", true);
if (channelType !== null) {
await api.post(`/hub-message/${ticketId}`, formData);
} else {
await api.post(`/messages/${ticketId}`, formData);
}
} catch (err) {
toastError(err);
}
setRecording(false);
setLoading(false);
};
const handleCancelAudio = async () => {
try {
await Mp3Recorder.stop().getMp3();
setRecording(false);
} catch (err) {
toastError(err);
}
};
const handleOpenMenuClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleMenuItemClick = (event) => {
setAnchorEl(null);
};
const renderReplyingMessage = (message) => {
return (
{!message.fromMe && (
{message.contact?.name}
)}
{message.body}
setReplyingMessage(null)}
>
);
};
if (medias.length > 0)
return (
setMedias([])}
>
{loading ? (
) : (
{medias[0]?.name}
{/* */}
)}
);
else {
return (
{replyingMessage && renderReplyingMessage(replyingMessage)}
setShowEmoji((prevState) => !prevState)}
>
{showEmoji ? (
setShowEmoji(false)}>
) : null}
{
setSignMessage(e.target.checked);
}}
name="showAllTickets"
color="primary"
/>
}
/>
{
input && input.focus();
input && (inputRef.current = input);
}}
className={classes.messageInput}
placeholder={
ticketStatus === "open"
? i18n.t("messagesInput.placeholderOpen")
: i18n.t("messagesInput.placeholderClosed")
}
multiline
maxRows={5}
value={inputMessage}
onChange={handleChangeInput}
disabled={recording || loading || ticketStatus !== "open"}
onPaste={(e) => {
ticketStatus === "open" && handleInputPaste(e);
}}
onKeyPress={(e) => {
if (loading || e.shiftKey) return;
else if (e.key === "Enter") {
handleSendMessage();
}
}}
/>
{typeBar ? (
{quickAnswers.map((value, index) => {
return (
-
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
handleQuickAnswersClick(value.message)}>
{`${value.shortcut} - ${value.message}`}
);
})}
) : (
)}
{inputMessage ? (
) : recording ? (
{loading ? (
) : (
)}
) : (
)}
);
}
};
export default MessageInput;
frontend\src\components\TicketListItem\index.js
import React, { useState, useEffect, useRef, useContext } from "react";
import { useHistory, useParams } from "react-router-dom";
import { parseISO, format, isSameDay } from "date-fns";
import clsx from "clsx";
import { makeStyles } from "@material-ui/core/styles";
import { green } from "@material-ui/core/colors";
import ListItem from "@material-ui/core/ListItem";
import ListItemText from "@material-ui/core/ListItemText";
import ListItemAvatar from "@material-ui/core/ListItemAvatar";
import Typography from "@material-ui/core/Typography";
import Avatar from "@material-ui/core/Avatar";
import Divider from "@material-ui/core/Divider";
import Badge from "@material-ui/core/Badge";
import { i18n } from "../../translate/i18n";
import api from "../../services/api";
import ButtonWithSpinner from "../ButtonWithSpinner";
import MarkdownWrapper from "../MarkdownWrapper";
import { Tooltip } from "@material-ui/core";
import { AuthContext } from "../../context/Auth/AuthContext";
import toastError from "../../errors/toastError";
import FacebookIcon from "@material-ui/icons/Facebook";
import InstagramIcon from "@material-ui/icons/Instagram";
import WhatsAppIcon from "@material-ui/icons/WhatsApp";
const useStyles = makeStyles(theme => ({
ticket: {
position: "relative",
},
pendingTicket: {
cursor: "unset",
},
noTicketsDiv: {
display: "flex",
height: "100px",
margin: 40,
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
},
noTicketsText: {
textAlign: "center",
color: "rgb(104, 121, 146)",
fontSize: "14px",
lineHeight: "1.4",
},
noTicketsTitle: {
textAlign: "center",
fontSize: "16px",
fontWeight: "600",
margin: "0px",
},
contactNameWrapper: {
display: "flex",
justifyContent: "space-between",
},
lastMessageTime: {
justifySelf: "flex-end",
},
closedBadge: {
alignSelf: "center",
justifySelf: "flex-end",
marginRight: 32,
marginLeft: "auto",
},
contactLastMessage: {
paddingRight: 20,
},
newMessagesCount: {
alignSelf: "center",
marginRight: 8,
marginLeft: "auto",
},
badgeStyle: {
color: "white",
backgroundColor: green[500],
},
acceptButton: {
position: "absolute",
left: "50%",
},
ticketQueueColor: {
flex: "none",
width: "8px",
height: "100%",
position: "absolute",
top: "0%",
left: "0%",
},
userTag: {
position: "absolute",
marginRight: 5,
right: 5,
bottom: 5,
background: "#2576D2",
color: "#ffffff",
border: "1px solid #CCC",
padding: 1,
paddingLeft: 5,
paddingRight: 5,
borderRadius: 10,
fontSize: "0.9em"
},
}));
const TicketListItem = ({ ticket }) => {
const classes = useStyles();
const history = useHistory();
const [loading, setLoading] = useState(false);
const { ticketId } = useParams();
const isMounted = useRef(true);
const { user } = useContext(AuthContext);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
const handleAcepptTicket = async id => {
setLoading(true);
try {
await api.put(`/tickets/${id}`, {
status: "open",
userId: user?.id,
});
} catch (err) {
setLoading(false);
toastError(err);
}
if (isMounted.current) {
setLoading(false);
}
history.push(`/tickets/${id}`);
};
const handleSelectTicket = id => {
history.push(`/tickets/${id}`);
};
return (
{
if (ticket.status === "pending") return;
handleSelectTicket(ticket.id);
}}
selected={ticketId && +ticketId === ticket.id}
className={clsx(classes.ticket, {
[classes.pendingTicket]: ticket.status === "pending",
})}
>
{ticket.contact.name}
{ticket.status === "closed" && (
)}
{ticket.lastMessage && (
{isSameDay(parseISO(ticket.updatedAt), new Date()) ? (
<>{format(parseISO(ticket.updatedAt), "HH:mm")}>
) : (
<>{format(parseISO(ticket.updatedAt), "dd/MM/yyyy")}>
)}
)}
{ticket.whatsappId && (
{ticket.whatsapp?.name}
)}
{ticket.contact.messengerId && (
)}
{ticket.contact.instagramId && (
)}
{ticket.contact.number && (
)}
}
secondary={
{ticket.lastMessage ? (
{ticket.lastMessage}
) : (
)}
}
/>
{ticket.status === "pending" && (
handleAcepptTicket(ticket.id)}
>
{i18n.t("ticketsList.buttons.accept")}
)}
);
};
export default TicketListItem;
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,
};
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 };
try {
if (isHubSelected && selectedChannel) {
// 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);
PAGES
frontend\src\pages\Connections\index.js
import React, { useState, useCallback, useContext } from "react";
import { toast } from "react-toastify";
import { format, parseISO } from "date-fns";
import { makeStyles } from "@material-ui/core/styles";
import { green } from "@material-ui/core/colors";
import {
Button,
TableBody,
TableRow,
TableCell,
IconButton,
Table,
TableHead,
Paper,
Tooltip,
Typography,
CircularProgress,
} from "@material-ui/core";
import {
Edit,
CheckCircle,
SignalCellularConnectedNoInternet2Bar,
SignalCellularConnectedNoInternet0Bar,
SignalCellular4Bar,
CropFree,
DeleteOutline,
} from "@material-ui/icons";
import MainContainer from "../../components/MainContainer";
import MainHeader from "../../components/MainHeader";
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
import Title from "../../components/Title";
import TableRowSkeleton from "../../components/TableRowSkeleton";
import api from "../../services/api";
import WhatsAppModal from "../../components/WhatsAppModal";
import ConfirmationModal from "../../components/ConfirmationModal";
import QrcodeModal from "../../components/QrcodeModal";
import { i18n } from "../../translate/i18n";
import { WhatsAppsContext } from "../../context/WhatsApp/WhatsAppsContext";
import toastError from "../../errors/toastError";
import FacebookIcon from "@material-ui/icons/Facebook";
import InstagramIcon from "@material-ui/icons/Instagram";
import WhatsAppIcon from "@material-ui/icons/WhatsApp";
const useStyles = makeStyles(theme => ({
mainPaper: {
flex: 1,
padding: theme.spacing(1),
overflowY: "scroll",
...theme.scrollbarStyles,
},
customTableCell: {
display: "flex",
alignItems: "center",
justifyContent: "center",
},
tooltip: {
backgroundColor: "#f5f5f9",
color: "rgba(0, 0, 0, 0.87)",
fontSize: theme.typography.pxToRem(14),
border: "1px solid #dadde9",
maxWidth: 450,
},
tooltipPopper: {
textAlign: "center",
},
buttonProgress: {
color: green[500],
},
}));
const CustomToolTip = ({ title, content, children }) => {
const classes = useStyles();
return (
{title}
{content && {content} }
}
>
{children}
);
};
const Connections = () => {
const classes = useStyles();
const { whatsApps, loading } = useContext(WhatsAppsContext);
const [whatsAppModalOpen, setWhatsAppModalOpen] = useState(false);
const [qrModalOpen, setQrModalOpen] = useState(false);
const [selectedWhatsApp, setSelectedWhatsApp] = useState(null);
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
const confirmationModalInitialState = {
action: "",
title: "",
message: "",
whatsAppId: "",
open: false,
};
const [confirmModalInfo, setConfirmModalInfo] = useState(
confirmationModalInitialState
);
const getChannelIcon = (channel) => {
switch (channel.toLowerCase()) {
case "facebook":
return ;
case "instagram":
return ;
case "whatsapp":
return ;
default:
return null;
}
};
const handleStartWhatsAppSession = async whatsAppId => {
try {
await api.post(`/whatsappsession/${whatsAppId}`);
} catch (err) {
toastError(err);
}
};
const handleRequestNewQrCode = async whatsAppId => {
try {
await api.put(`/whatsappsession/${whatsAppId}`);
} catch (err) {
toastError(err);
}
};
const handleOpenWhatsAppModal = () => {
setSelectedWhatsApp(null);
setWhatsAppModalOpen(true);
};
const handleCloseWhatsAppModal = useCallback(() => {
setWhatsAppModalOpen(false);
setSelectedWhatsApp(null);
}, [setSelectedWhatsApp, setWhatsAppModalOpen]);
const handleOpenQrModal = whatsApp => {
setSelectedWhatsApp(whatsApp);
setQrModalOpen(true);
};
const handleCloseQrModal = useCallback(() => {
setSelectedWhatsApp(null);
setQrModalOpen(false);
}, [setQrModalOpen, setSelectedWhatsApp]);
const handleEditWhatsApp = whatsApp => {
setSelectedWhatsApp(whatsApp);
setWhatsAppModalOpen(true);
};
const handleOpenConfirmationModal = (action, whatsAppId) => {
if (action === "disconnect") {
setConfirmModalInfo({
action: action,
title: i18n.t("connections.confirmationModal.disconnectTitle"),
message: i18n.t("connections.confirmationModal.disconnectMessage"),
whatsAppId: whatsAppId,
});
}
if (action === "delete") {
setConfirmModalInfo({
action: action,
title: i18n.t("connections.confirmationModal.deleteTitle"),
message: i18n.t("connections.confirmationModal.deleteMessage"),
whatsAppId: whatsAppId,
});
}
setConfirmModalOpen(true);
};
const handleSubmitConfirmationModal = async () => {
if (confirmModalInfo.action === "disconnect") {
try {
await api.delete(`/whatsappsession/${confirmModalInfo.whatsAppId}`);
} catch (err) {
toastError(err);
}
}
if (confirmModalInfo.action === "delete") {
try {
await api.delete(`/whatsapp/${confirmModalInfo.whatsAppId}`);
toast.success(i18n.t("connections.toasts.deleted"));
} catch (err) {
toastError(err);
}
}
setConfirmModalInfo(confirmationModalInitialState);
};
const renderActionButtons = whatsApp => {
return (
<>
{whatsApp.status === "qrcode" && (
)}
{whatsApp.status === "DISCONNECTED" && (
<>
{" "}
>
)}
{(whatsApp.status === "CONNECTED" ||
whatsApp.status === "PAIRING" ||
whatsApp.status === "TIMEOUT") &&
whatsApp.type.toLowerCase() === "whatsapp" && (
)}
{whatsApp.status === "OPENING" && (
)}
>
);
};
const renderStatusToolTips = whatsApp => {
return (
{whatsApp.status === "DISCONNECTED" && (
)}
{whatsApp.status === "OPENING" && (
)}
{whatsApp.status === "qrcode" && (
)}
{whatsApp.status === "CONNECTED" && (
)}
{(whatsApp.status === "TIMEOUT" || whatsApp.status === "PAIRING") && (
)}
);
};
return (
{confirmModalInfo.message}
{i18n.t("connections.title")}
{i18n.t("connections.table.name")}
Canal
{i18n.t("connections.table.status")}
{i18n.t("connections.table.session")}
{i18n.t("connections.table.lastUpdate")}
{i18n.t("connections.table.default")}
{i18n.t("connections.table.actions")}
{loading ? (
) : (
<>
{whatsApps?.length > 0 &&
whatsApps.map(whatsApp => (
{whatsApp.name}
{getChannelIcon(whatsApp.type)}
{renderStatusToolTips(whatsApp)}
{renderActionButtons(whatsApp)}
{format(parseISO(whatsApp.updatedAt), "dd/MM/yy HH:mm")}
{whatsApp.isDefault && (
)}
{whatsApp.type.toLowerCase() === "whatsapp" && (
handleEditWhatsApp(whatsApp)}
>
)}
{
handleOpenConfirmationModal("delete", whatsApp.id);
}}
>
))}
>
)}
);
};
export default Connections;
frontend\src\pages\Contacts\index.js
import React, { useState, useEffect, useReducer, useContext } from "react";
import openSocket from "../../services/socket-io";
import { toast } from "react-toastify";
import { useHistory } from "react-router-dom";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Paper from "@material-ui/core/Paper";
import Button from "@material-ui/core/Button";
import Avatar from "@material-ui/core/Avatar";
import WhatsAppIcon from "@material-ui/icons/WhatsApp";
import FacebookIcon from "@material-ui/icons/Facebook";
import InstagramIcon from "@material-ui/icons/Instagram";
import SearchIcon from "@material-ui/icons/Search";
import TextField from "@material-ui/core/TextField";
import InputAdornment from "@material-ui/core/InputAdornment";
import IconButton from "@material-ui/core/IconButton";
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
import EditIcon from "@material-ui/icons/Edit";
import api from "../../services/api";
import TableRowSkeleton from "../../components/TableRowSkeleton";
import ContactModal from "../../components/ContactModal";
import ConfirmationModal from "../../components/ConfirmationModal/";
import { i18n } from "../../translate/i18n";
import MainHeader from "../../components/MainHeader";
import Title from "../../components/Title";
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
import MainContainer from "../../components/MainContainer";
import toastError from "../../errors/toastError";
import { AuthContext } from "../../context/Auth/AuthContext";
import { Can } from "../../components/Can";
const reducer = (state, action) => {
if (action.type === "LOAD_CONTACTS") {
const contacts = action.payload;
const newContacts = [];
contacts.forEach((contact) => {
const contactIndex = state.findIndex((c) => c.id === contact.id);
if (contactIndex !== -1) {
state[contactIndex] = contact;
} else {
newContacts.push(contact);
}
});
return [...state, ...newContacts];
}
if (action.type === "UPDATE_CONTACTS") {
const contact = action.payload;
const contactIndex = state.findIndex((c) => c.id === contact.id);
if (contactIndex !== -1) {
state[contactIndex] = contact;
return [...state];
} else {
return [contact, ...state];
}
}
if (action.type === "DELETE_CONTACT") {
const contactId = action.payload;
const contactIndex = state.findIndex((c) => c.id === contactId);
if (contactIndex !== -1) {
state.splice(contactIndex, 1);
}
return [...state];
}
if (action.type === "RESET") {
return [];
}
};
const useStyles = makeStyles((theme) => ({
mainPaper: {
flex: 1,
padding: theme.spacing(1),
overflowY: "scroll",
...theme.scrollbarStyles,
},
}));
const Contacts = () => {
const classes = useStyles();
const history = useHistory();
const { user } = useContext(AuthContext);
const [loading, setLoading] = useState(false);
const [pageNumber, setPageNumber] = useState(1);
const [searchParam, setSearchParam] = useState("");
const [contacts, dispatch] = useReducer(reducer, []);
const [selectedContactId, setSelectedContactId] = useState(null);
const [contactModalOpen, setContactModalOpen] = useState(false);
const [deletingContact, setDeletingContact] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [hasMore, setHasMore] = useState(false);
useEffect(() => {
dispatch({ type: "RESET" });
setPageNumber(1);
}, [searchParam]);
useEffect(() => {
setLoading(true);
const delayDebounceFn = setTimeout(() => {
const fetchContacts = async () => {
try {
const { data } = await api.get("/contacts/", {
params: { searchParam, pageNumber },
});
dispatch({ type: "LOAD_CONTACTS", payload: data.contacts });
setHasMore(data.hasMore);
setLoading(false);
} catch (err) {
toastError(err);
}
};
fetchContacts();
}, 500);
return () => clearTimeout(delayDebounceFn);
}, [searchParam, pageNumber]);
useEffect(() => {
const socket = openSocket();
socket.on("contact", (data) => {
if (data.action === "update" || data.action === "create") {
dispatch({ type: "UPDATE_CONTACTS", payload: data.contact });
}
if (data.action === "delete") {
dispatch({ type: "DELETE_CONTACT", payload: +data.contactId });
}
});
return () => {
socket.disconnect();
};
}, []);
const handleSearch = (event) => {
setSearchParam(event.target.value.toLowerCase());
};
const handleOpenContactModal = () => {
setSelectedContactId(null);
setContactModalOpen(true);
};
const handleCloseContactModal = () => {
setSelectedContactId(null);
setContactModalOpen(false);
};
const handleSaveTicket = async (contactId) => {
if (!contactId) return;
const { data } = await api.get(`/contacts/${contactId}`);
setLoading(true);
if(data.number){
try {
const { data: ticket } = await api.post("/tickets", {
contactId: contactId,
userId: user?.id,
status: "open",
});
history.push(`/tickets/${ticket.id}`);
} catch (err) {
toastError(err);
}
} else if(!data.number && data.instagramId && !data.messengerId){
try {
const { data: ticket } = await api.post("/hub-ticket", {
contactId: contactId,
userId: user?.id,
status: "open",
channel: "instagram"
});
history.push(`/tickets/${ticket.id}`);
} catch (err) {
toastError(err);
}
} else if(!data.number && data.messengerId && !data.instagramId){
try {
const { data: ticket } = await api.post("/hub-ticket", {
contactId: contactId,
userId: user?.id,
status: "open",
channel: "facebook"
});
history.push(`/tickets/${ticket.id}`);
} catch (err) {
toastError(err);
}
}
setLoading(false);
};
const hadleEditContact = (contactId) => {
setSelectedContactId(contactId);
setContactModalOpen(true);
};
const handleDeleteContact = async (contactId) => {
try {
await api.delete(`/contacts/${contactId}`);
toast.success(i18n.t("contacts.toasts.deleted"));
} catch (err) {
toastError(err);
}
setDeletingContact(null);
setSearchParam("");
setPageNumber(1);
};
const handleimportContact = async () => {
try {
await api.post("/contacts/import");
history.go(0);
} catch (err) {
toastError(err);
}
};
const loadMore = () => {
setPageNumber((prevState) => prevState + 1);
};
const handleScroll = (e) => {
if (!hasMore || loading) return;
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollHeight - (scrollTop + 100) < clientHeight) {
loadMore();
}
};
return (
deletingContact
? handleDeleteContact(deletingContact.id)
: handleimportContact()
}
>
{deletingContact
? `${i18n.t("contacts.confirmationModal.deleteMessage")}`
: `${i18n.t("contacts.confirmationModal.importMessage")}`}
{i18n.t("contacts.title")}
),
}}
/>
{i18n.t("contacts.table.name")}
{i18n.t("contacts.table.whatsapp")}
{i18n.t("contacts.table.email")}
Messenger ID
Instagram ID
{i18n.t("contacts.table.actions")}
<>
{contacts.map((contact) => (
{ }
{contact.name}
{contact.number}
{contact.email}
{contact.messengerId}
{contact.instagramId}
{contact.number && (
handleSaveTicket(contact.id)}
>
)}
{!contact.number && !contact.instagramId && (
handleSaveTicket(contact.id)}
>
)}
{!contact.number && !contact.messengerId && (
handleSaveTicket(contact.id)}
>
)}
hadleEditContact(contact.id)}
>
(
{
setConfirmOpen(true);
setDeletingContact(contact);
}}
>
)}
/>
))}
{loading && }
>
);
};
export default Contacts;
frontend\src\pages\Settings\index.js
import React, { useState, useEffect } from "react";
import openSocket from "../../services/socket-io";
import { makeStyles } from "@material-ui/core/styles";
import Paper from "@material-ui/core/Paper";
import Typography from "@material-ui/core/Typography";
import Container from "@material-ui/core/Container";
import Select from "@material-ui/core/Select";
import TextField from "@material-ui/core/TextField";
import { toast } from "react-toastify";
import api from "../../services/api";
import { i18n } from "../../translate/i18n.js";
import toastError from "../../errors/toastError";
const useStyles = makeStyles(theme => ({
root: {
display: "flex",
alignItems: "center",
padding: theme.spacing(8, 8, 3),
},
paper: {
padding: theme.spacing(2),
display: "flex",
alignItems: "center",
marginBottom: 12,
},
settingOption: {
marginLeft: "auto",
},
margin: {
margin: theme.spacing(1),
},
}));
const Settings = () => {
const classes = useStyles();
const [settings, setSettings] = useState([]);
useEffect(() => {
const fetchSession = async () => {
try {
const { data } = await api.get("/settings");
setSettings(data);
} catch (err) {
toastError(err);
}
};
fetchSession();
}, []);
useEffect(() => {
const socket = openSocket();
socket.on("settings", data => {
if (data.action === "update") {
setSettings(prevState => {
const aux = [...prevState];
const settingIndex = aux.findIndex(s => s.key === data.setting.key);
aux[settingIndex].value = data.setting.value;
return aux;
});
}
});
return () => {
socket.disconnect();
};
}, []);
const handleChangeSetting = async e => {
const selectedValue = e.target.value;
const settingKey = e.target.name;
try {
await api.put(`/settings/${settingKey}`, {
value: selectedValue,
});
toast.success(i18n.t("settings.success"));
} catch (err) {
toastError(err);
}
};
const getSettingValue = key => {
const setting = settings.find(s => s.key === key);
return setting ? setting.value : "";
};
return (
{i18n.t("settings.title")}
{i18n.t("settings.settings.userCreation.name")}
0 && getSettingValue("userApiToken")}
/>
{/* Novo campo para hubToken */}
0 && getSettingValue("hubToken")}
onChange={handleChangeSetting}
InputProps={{
style: { filter: "blur(5px)" },
readOnly: true
}}
/>
);
};
export default Settings;