From 6b5d22895917a0901a5a8e62f5b40a7acfeeece8 Mon Sep 17 00:00:00 2001 From: Robson Tenorio Date: Thu, 4 May 2023 01:42:43 -0300 Subject: [PATCH] =?UTF-8?q?Integra=C3=A7=C3=A3o=20com=20ChatGPT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comandos: /gpt - para receber a resposta em texto /gptM - pra receber a resposta em imagem --- backend/package.json | 1 + .../src/controllers/IntegrationController.ts | 41 ++++++ backend/src/database/index.ts | 4 +- .../20230503231400-create-integrations.ts | 29 ++++ .../20230501010700-create-openai-settings.ts | 28 ++++ backend/src/libs/wbot.ts | 108 ++++++++++++++- backend/src/models/Integration.ts | 26 ++++ backend/src/routes/index.ts | 2 + backend/src/routes/integrationRoutes.ts | 16 +++ .../ListIntegrationsService.ts | 11 ++ .../UpdateIntegrationService.ts | 26 ++++ frontend/src/layout/MainListItems.js | 6 + frontend/src/pages/Integrations/index.js | 131 ++++++++++++++++++ frontend/src/routes/index.js | 2 + frontend/src/translate/languages/en.js | 14 +- frontend/src/translate/languages/es.js | 14 +- frontend/src/translate/languages/pt.js | 15 +- 17 files changed, 468 insertions(+), 6 deletions(-) create mode 100644 backend/src/controllers/IntegrationController.ts create mode 100644 backend/src/database/migrations/20230503231400-create-integrations.ts create mode 100644 backend/src/database/seeds/20230501010700-create-openai-settings.ts create mode 100644 backend/src/models/Integration.ts create mode 100644 backend/src/routes/integrationRoutes.ts create mode 100644 backend/src/services/IntegrationServices/ListIntegrationsService.ts create mode 100644 backend/src/services/IntegrationServices/UpdateIntegrationService.ts create mode 100644 frontend/src/pages/Integrations/index.js diff --git a/backend/package.json b/backend/package.json index e3c762e1..278049cc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,6 +30,7 @@ "multer": "^1.4.2", "mustache": "^4.2.0", "mysql2": "^2.2.5", + "openai": "^3.2.1", "pg": "^8.4.1", "pino": "^8.11.0", "pino-pretty": "^10.0.0", diff --git a/backend/src/controllers/IntegrationController.ts b/backend/src/controllers/IntegrationController.ts new file mode 100644 index 00000000..f03e04f7 --- /dev/null +++ b/backend/src/controllers/IntegrationController.ts @@ -0,0 +1,41 @@ +import { Request, Response } from "express"; + +import { getIO } from "../libs/socket"; +import AppError from "../errors/AppError"; + +import UpdateIntegrationService from "../services/IntegrationServices/UpdateIntegrationService"; +import ListIntegrationsService from "../services/IntegrationServices/ListIntegrationsService"; + +export const index = async (req: Request, res: Response): Promise => { + if (req.user.profile === "") { + throw new AppError("ERR_NO_PERMISSION", 403); + } + + const integrations = await ListIntegrationsService(); + + return res.status(200).json(integrations); +}; + +export const update = async ( + req: Request, + res: Response +): Promise => { + if (req.user.profile !== "admin") { + throw new AppError("ERR_NO_PERMISSION", 403); + } + const { integrationKey: key } = req.params; + const { value } = req.body; + + const integration = await UpdateIntegrationService({ + key, + value + }); + + const io = getIO(); + io.emit("integrations", { + action: "update", + integration + }); + + return res.status(200).json(integration); +}; diff --git a/backend/src/database/index.ts b/backend/src/database/index.ts index e36ba9e0..277eef15 100644 --- a/backend/src/database/index.ts +++ b/backend/src/database/index.ts @@ -12,6 +12,7 @@ import UserQueue from "../models/UserQueue"; import QuickAnswer from "../models/QuickAnswer"; import Tag from "../models/Tag"; import ContactTag from "../models/ContactTag"; +import Integration from "../models/Integration"; // eslint-disable-next-line const dbConfig = require("../config/database"); @@ -32,7 +33,8 @@ const models = [ UserQueue, QuickAnswer, Tag, - ContactTag + ContactTag, + Integration ]; sequelize.addModels(models); diff --git a/backend/src/database/migrations/20230503231400-create-integrations.ts b/backend/src/database/migrations/20230503231400-create-integrations.ts new file mode 100644 index 00000000..43b4c265 --- /dev/null +++ b/backend/src/database/migrations/20230503231400-create-integrations.ts @@ -0,0 +1,29 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("Integrations", { + key: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + value: { + type: DataTypes.TEXT, + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("Integrations"); + } +}; diff --git a/backend/src/database/seeds/20230501010700-create-openai-settings.ts b/backend/src/database/seeds/20230501010700-create-openai-settings.ts new file mode 100644 index 00000000..67f1da95 --- /dev/null +++ b/backend/src/database/seeds/20230501010700-create-openai-settings.ts @@ -0,0 +1,28 @@ +import { QueryInterface } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.bulkInsert( + "Integrations", + [ + { + key: "organization", + value: "", + createdAt: new Date(), + updatedAt: new Date() + }, + { + key: "apikey", + value: "", + createdAt: new Date(), + updatedAt: new Date() + } + ], + {} + ); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.bulkDelete("Integrations", {}); + } +}; diff --git a/backend/src/libs/wbot.ts b/backend/src/libs/wbot.ts index bedc2509..b040c781 100644 --- a/backend/src/libs/wbot.ts +++ b/backend/src/libs/wbot.ts @@ -1,15 +1,95 @@ import qrCode from "qrcode-terminal"; -import { Client, LocalAuth } from "whatsapp-web.js"; +import { Client, LocalAuth, MessageMedia } from "whatsapp-web.js"; +import { Configuration, CreateImageRequestSizeEnum, OpenAIApi } from "openai"; import { getIO } from "./socket"; import Whatsapp from "../models/Whatsapp"; import AppError from "../errors/AppError"; import { logger } from "../utils/logger"; import { handleMessage } from "../services/WbotServices/wbotMessageListener"; +import Integration from "../models/Integration"; interface Session extends Client { id?: number; } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +interface CreateImageRequest { + prompt: string; + n?: number; + size?: CreateImageRequestSizeEnum; +} + +async function findIntegrationValue(key: string): Promise { + // Encontre a instância de integração com base na chave fornecida + const integration = await Integration.findOne({ + where: { key } + }); + + // Se a instância for encontrada, retorne o valor + if (integration) { + return integration.value; + } + + // Caso contrário, retorne null + return null as string | null; +} + +let openai: OpenAIApi; + +(async () => { + const organizationDB: string | null = await findIntegrationValue( + "organization" + ); + const apiKeyDB: string | null = await findIntegrationValue("apikey"); + + const configuration = new Configuration({ + organization: organizationDB ?? "", + apiKey: apiKeyDB ?? "" + }); + + openai = new OpenAIApi(configuration); +})(); + +// gera resposta em texto +const getDavinciResponse = async (clientText: string): Promise => { + const options = { + model: "text-davinci-003", // Modelo GPT a ser usado + prompt: clientText, // Texto enviado pelo usuário + temperature: 1, // Nível de variação das respostas geradas, 1 é o máximo + max_tokens: 4000 // Quantidade de tokens (palavras) a serem retornadas pelo bot, 4000 é o máximo + }; + + try { + const response = await openai.createCompletion(options); + let botResponse = ""; + response.data.choices.forEach(({ text }) => { + botResponse += text; + }); + return `Chat GPT 🤖\n\n ${botResponse.trim()}`; + } catch (e) { + return `❌ OpenAI Response Error: ${e.response.data.error.message}`; + } +}; + +// gera a url da imagem +const getDalleResponse = async ( + clientText: string +): Promise => { + const options: CreateImageRequest = { + prompt: clientText, // Descrição da imagem + n: 1, // Número de imagens a serem geradas + // eslint-disable-next-line no-underscore-dangle + size: CreateImageRequestSizeEnum._1024x1024 // Tamanho da imagem + }; + + try { + const response = await openai.createImage(options); + return response.data.data[0].url; + } catch (e) { + return `❌ OpenAI Response Error: ${e.response.data.error.message}`; + } +}; + const sessions: Session[] = []; const syncUnreadMessages = async (wbot: Session) => { @@ -33,7 +113,7 @@ const syncUnreadMessages = async (wbot: Session) => { }; export const initWbot = async (whatsapp: Whatsapp): Promise => { - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { try { logger.level = "trace"; const io = getIO(); @@ -158,6 +238,30 @@ export const initWbot = async (whatsapp: Whatsapp): Promise => { resolve(wbot); }); + + wbot.on("message", async msg => { + const msgChatGPT: string = msg.body; + // mensagem de texto + if (msgChatGPT.includes("/gpt ")) { + const index = msgChatGPT.indexOf(" "); + const question = msgChatGPT.substring(index + 1); + getDavinciResponse(question).then((response: string) => { + wbot.sendMessage(msg.from, response); + }); + } + // imagem + if (msgChatGPT.includes("/gptM ")) { + const index = msgChatGPT.indexOf(" "); + const imgDescription = msgChatGPT.substring(index + 1); + const imgUrl = await getDalleResponse(imgDescription); + if (imgUrl) { + const media = await MessageMedia.fromUrl(imgUrl); + wbot.sendMessage(msg.from, media, { caption: imgDescription }); + } else { + wbot.sendMessage(msg.from, "❌ Não foi possível gerar a imagem."); + } + } + }); } catch (err: any) { logger.error(err); } diff --git a/backend/src/models/Integration.ts b/backend/src/models/Integration.ts new file mode 100644 index 00000000..e00dfa97 --- /dev/null +++ b/backend/src/models/Integration.ts @@ -0,0 +1,26 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + PrimaryKey +} from "sequelize-typescript"; + +@Table +class Integration extends Model { + @PrimaryKey + @Column + key: string; + + @Column + value: string; + + @CreatedAt + createdAt: Date; + + @UpdatedAt + updatedAt: Date; +} + +export default Integration; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 9f8fcdc9..3d80aff2 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -12,6 +12,7 @@ import queueRoutes from "./queueRoutes"; import quickAnswerRoutes from "./quickAnswerRoutes"; import apiRoutes from "./apiRoutes"; import tagRoutes from "./tagRoutes"; +import integrationRoutes from "./integrationRoutes"; const routes = Router(); @@ -27,5 +28,6 @@ routes.use(queueRoutes); routes.use(quickAnswerRoutes); routes.use("/api/messages", apiRoutes); routes.use(tagRoutes); +routes.use(integrationRoutes); export default routes; diff --git a/backend/src/routes/integrationRoutes.ts b/backend/src/routes/integrationRoutes.ts new file mode 100644 index 00000000..c2a6f5cb --- /dev/null +++ b/backend/src/routes/integrationRoutes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import isAuth from "../middleware/isAuth"; + +import * as IntegrationController from "../controllers/IntegrationController"; + +const integrationRoutes = Router(); + +integrationRoutes.get("/integrations", isAuth, IntegrationController.index); + +integrationRoutes.put( + "/integrations/:integrationKey", + isAuth, + IntegrationController.update +); + +export default integrationRoutes; diff --git a/backend/src/services/IntegrationServices/ListIntegrationsService.ts b/backend/src/services/IntegrationServices/ListIntegrationsService.ts new file mode 100644 index 00000000..eea193f7 --- /dev/null +++ b/backend/src/services/IntegrationServices/ListIntegrationsService.ts @@ -0,0 +1,11 @@ +import Integration from "../../models/Integration"; + +const ListIntegrationsService = async (): Promise< + Integration[] | undefined +> => { + const integrations = await Integration.findAll(); + + return integrations; +}; + +export default ListIntegrationsService; diff --git a/backend/src/services/IntegrationServices/UpdateIntegrationService.ts b/backend/src/services/IntegrationServices/UpdateIntegrationService.ts new file mode 100644 index 00000000..c97d4a18 --- /dev/null +++ b/backend/src/services/IntegrationServices/UpdateIntegrationService.ts @@ -0,0 +1,26 @@ +import AppError from "../../errors/AppError"; +import Integration from "../../models/Integration"; + +interface Request { + key: string; + value: string; +} + +const UpdateIntegrationService = async ({ + key, + value +}: Request): Promise => { + const integration = await Integration.findOne({ + where: { key } + }); + + if (!integration) { + throw new AppError("ERR_NO_INTEGRATION_FOUND", 404); + } + + await integration.update({ value }); + + return integration; +}; + +export default UpdateIntegrationService; diff --git a/frontend/src/layout/MainListItems.js b/frontend/src/layout/MainListItems.js index d6a3fd66..ec3badd2 100644 --- a/frontend/src/layout/MainListItems.js +++ b/frontend/src/layout/MainListItems.js @@ -16,6 +16,7 @@ import { Code, ContactPhoneOutlined, DashboardOutlined, + DeveloperModeOutlined, LocalOffer, MenuBook, PeopleAltOutlined, @@ -152,6 +153,11 @@ const MainListItems = (props) => { primary={i18n.t("mainDrawer.listItems.queues")} icon={} /> + } + /> ({ + root: { + display: "flex", + alignItems: "center", + padding: theme.spacing(8, 8, 3), + }, + paper: { + padding: theme.spacing(2), + display: "flex", + alignItems: "center", + marginBottom: 12, + + } +})); + +const Integrations = () => { + const classes = useStyles(); + + const [integrations, setIntegrations] = useState([]); + + useEffect(() => { + const fetchSession = async () => { + try { + const { data } = await api.get("/integrations"); + setIntegrations(data); + } catch (err) { + toastError(err); + } + }; + fetchSession(); + }, []); + + useEffect(() => { + const socket = openSocket(process.env.REACT_APP_BACKEND_URL); + + socket.on("integrations", data => { + if (data.action === "update") { + setIntegrations(prevState => { + const aux = [...prevState]; + const integrationIndex = aux.findIndex(s => s.key === data.integration.key); + aux[integrationIndex].value = data.integration.value; + return aux; + }); + } + }); + + return () => { + socket.disconnect(); + }; + }, []); + + const handleChangeIntegration = async e => { + const selectedValue = e.target.value; + const integrationKey = e.target.name; + + try { + await api.put(`/integrations/${integrationKey}`, { + value: selectedValue, + }); + toast.success(i18n.t("integrations.success")); + } catch (err) { + toastError(err); + } + }; + + const getIntegrationValue = key => { + const { value } = integrations.find(s => s.key === key); + return value; + }; + + return ( +
+ + + {i18n.t("integrations.title")} + + + + + + {i18n.t("integrations.integrations.openai.title")} + + + 0 && getIntegrationValue("organization")} + onChange={handleChangeIntegration} + fullWidth + /> + 0 && getIntegrationValue("apikey")} + /> + + + + +
+ ); +}; + +export default Integrations; diff --git a/frontend/src/routes/index.js b/frontend/src/routes/index.js index 242f8286..485fbf4e 100644 --- a/frontend/src/routes/index.js +++ b/frontend/src/routes/index.js @@ -17,6 +17,7 @@ import Api from "../pages/Api/"; import ApiDocs from "../pages/ApiDocs/"; import ApiKey from "../pages/ApiKey/"; import Tags from "../pages/Tags"; +import Integrations from "../pages/Integrations"; import { AuthProvider } from "../context/Auth/AuthContext"; import { WhatsAppsProvider } from "../context/WhatsApp/WhatsAppsContext"; @@ -43,6 +44,7 @@ const Routes = () => { + diff --git a/frontend/src/translate/languages/en.js b/frontend/src/translate/languages/en.js index 1dd608b1..4a68deb6 100644 --- a/frontend/src/translate/languages/en.js +++ b/frontend/src/translate/languages/en.js @@ -484,6 +484,17 @@ const messages = { deleteMessage: "All agent data will be lost. Open tickets for this agent will be moved to hold.", }, }, + integrations: { + success: "Integration saved successfully.", + title: "Integrations", + integrations: { + openai: { + title: "OpenAI", + organization: "Organization ID", + apikey: "KEY" + } + }, + }, settings: { success: "Settings saved successfully.", title: "Settings", @@ -665,7 +676,8 @@ const messages = { ERR_NO_TAG_FOUND: "Tag not found.", ERR_OUT_OF_HOURS: "Out of Office Hours!", ERR_OPEN_USER_TICKET: "A ticket already exists for this contact with ", - ERR_NONE_USER_TICKET: "A ticket already exists for this contact." + ERR_NONE_USER_TICKET: "A ticket already exists for this contact.", + ERR_NO_INTEGRATION_FOUND: "Integration not found." }, }, }, diff --git a/frontend/src/translate/languages/es.js b/frontend/src/translate/languages/es.js index e819be8f..3df20b9c 100644 --- a/frontend/src/translate/languages/es.js +++ b/frontend/src/translate/languages/es.js @@ -484,6 +484,17 @@ const messages = { deleteMessage: "Se perderán todos los datos del asistente. Los tickets abiertos para este asistente se moverán a espera.", }, }, + integrations: { + success: "Integracion guardada con exito.", + title: "Integraciones", + integrations: { + openai: { + title: "OpenAI", + organization: "Organization ID", + apikey: "KEY" + } + }, + }, settings: { success: "Configuración guardada con éxito.", title: "Configuración", @@ -664,7 +675,8 @@ const messages = { ERR_CONNECTION_CREATION_COUNT: "Límite de conexión alcanzado, comuníquese con soporte para cambiar.", ERR_NO_TAG_FOUND: "Etiqueta no encontrada.", ERR_OPEN_USER_TICKET: "Ya existe un ticket para este contacto con ", - ERR_NONE_USER_TICKET: "Ya existe un ticket para este contacto." + ERR_NONE_USER_TICKET: "Ya existe un ticket para este contacto.", + ERR_NO_INTEGRATION_FOUND: "Integración no encontrada." }, }, }, diff --git a/frontend/src/translate/languages/pt.js b/frontend/src/translate/languages/pt.js index fd1fafea..a47dd7f2 100644 --- a/frontend/src/translate/languages/pt.js +++ b/frontend/src/translate/languages/pt.js @@ -390,6 +390,7 @@ const messages = { queues: "Setores", administration: "Administração", users: "Atendentes", + integrations: "Integrações", settings: "Configurações", sendMsg: "Envio de Mensagens", sendMedia: "Envio de Mídia", @@ -484,6 +485,17 @@ const messages = { deleteMessage: "Todos os dados do atendente serão perdidos. Os tickets abertos deste atendente serão movidos para a espera.", }, }, + integrations: { + success: "Integração salva com sucesso.", + title: "Integrações", + integrations: { + openai: { + title: "OpenAI", + organization: "Organization ID", + apikey: "KEY" + } + }, + }, settings: { success: "Configurações salvas com sucesso.", title: "Configurações", @@ -665,7 +677,8 @@ const messages = { ERR_NO_TAG_FOUND: "Tag não encontrada.", ERR_OUT_OF_HOURS: "Fora do Horário de Expediente!", ERR_OPEN_USER_TICKET: "Já existe um ticket aberto para este contato com ", - ERR_NONE_USER_TICKET: "Já existe um ticket aberto para este contato." + ERR_NONE_USER_TICKET: "Já existe um ticket aberto para este contato.", + ERR_NO_INTEGRATION_FOUND: "Integração não encontrada." }, }, },