diff --git a/.github/workflows/publish_docker_image.yml b/.github/workflows/publish_docker_image.yml new file mode 100644 index 00000000..8419d7dc --- /dev/null +++ b/.github/workflows/publish_docker_image.yml @@ -0,0 +1,64 @@ +name: Build Docker image + +on: + push: + tags: ['v*'] + +jobs: + build-amd: + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Extract existing image metadata + id: image-meta + uses: docker/metadata-action@v4 + with: + images: atendai/evolution-api + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push AMD image + uses: docker/build-push-action@v4 + with: + context: . + labels: ${{ steps.image-meta.outputs.labels }} + platforms: linux/amd64 + push: true + + build-arm: + runs-on: buildjet-4vcpu-ubuntu-2204-arm + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Extract existing image metadata + id: image-meta + uses: docker/metadata-action@v4 + with: + images: atendai/evolution-api + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push ARM image + uses: docker/build-push-action@v4 + with: + context: . + labels: ${{ steps.image-meta.outputs.labels }} + platforms: linux/arm64 + push: true diff --git a/.gitignore b/.gitignore index c55eb628..ecba09fb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ /Docker/.env +.vscode + # Logs logs/**.json *.log @@ -44,4 +46,5 @@ docker-compose.yaml /temp/* .DS_Store -*.DS_Store \ No newline at end of file +*.DS_Store +.tool-versions diff --git a/.vscode/settings.json b/.vscode/settings.json index 71db0b08..8f568c69 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,5 +9,8 @@ "source.fixAll": "explicit" }, "prisma-smart-formatter.typescript.defaultFormatter": "esbenp.prettier-vscode", - "prisma-smart-formatter.prisma.defaultFormatter": "Prisma.prisma" + "prisma-smart-formatter.prisma.defaultFormatter": "Prisma.prisma", + "i18n-ally.localesPaths": [ + "store/messages" + ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7f016d..0fb48755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,46 @@ +# 1.7.0 (2024-03-11 18:23) + +### Feature + +* Added update message endpoint +* Add translate capabilities to QRMessages in CW +* Join in Group by Invite Code +* Read messages from whatsapp in chatwoot +* Add support to use use redis in cacheservice +* Add support for labels +* Command to clearcache from chatwoot inbox +* Whatsapp Cloud API Oficial + +### Fixed + +* Proxy configuration improvements +* Correction in sending lists +* Adjust in webhook_base64 +* Correction in typebot text formatting +* Correction in chatwoot text formatting and render list message +* Only use a axios request to get file mimetype if necessary +* When possible use the original file extension +* When receiving a file from whatsapp, use the original filename in chatwoot if possible +* Remove message ids cache in chatwoot to use chatwoot's api itself +* Adjusts the quoted message, now has contextInfo in the message Raw +* Collecting responses with text or numbers in Typebot +* Added sendList endpoint to swagger documentation +* Implemented a function to synchronize message deletions on WhatsApp, automatically reflecting in Chatwoot. +* Improvement on numbers validation +* Fix polls in message sending +* Sending status message +* Message 'connection successfully' spamming +* Invalidate the conversation cache if reopen_conversation is false and the conversation was resolved +* Fix looping when deleting a message in chatwoot +* When receiving a file from whatsapp, use the original filename in chatwoot if possible +* Correction in the sendList Function +* Implement contact upsert in messaging-history.set +* Improve proxy error handling +* Refactor fetching participants for group in WhatsApp service +* Fixed problem where the typebot final keyword did not work +* Typebot's wait now pauses the flow and composing is defined by the delay_message parameter in set typebot +* Composing over 20s now loops until finished + # 1.6.1 (2023-12-22 11:43) ### Fixed diff --git a/Docker/.env.example b/Docker/.env.example index 2813117e..0d056dd5 100644 --- a/Docker/.env.example +++ b/Docker/.env.example @@ -16,6 +16,7 @@ LOG_BAILEYS=error # Default time: 5 minutes # If you don't even want an expiration, enter the value false DEL_INSTANCE=false +DEL_TEMP_INSTANCES=true # Delete instances with status closed on start # Temporary data storage STORE_MESSAGES=true @@ -47,10 +48,17 @@ REDIS_URI=redis://redis:6379 REDIS_PREFIX_KEY=evdocker RABBITMQ_ENABLED=false +RABBITMQ_RABBITMQ_MODE=global +RABBITMQ_EXCHANGE_NAME=evolution_exchange RABBITMQ_URI=amqp://guest:guest@rabbitmq:5672 WEBSOCKET_ENABLED=false +WA_BUSINESS_TOKEN_WEBHOOK=evolution +WA_BUSINESS_URL=https://graph.facebook.com +WA_BUSINESS_VERSION=v18.0 +WA_BUSINESS_LANGUAGE=pt_BR + SQS_ENABLED=false SQS_ACCESS_KEY_ID= SQS_SECRET_ACCESS_KEY= @@ -84,6 +92,8 @@ WEBHOOK_EVENTS_GROUPS_UPSERT=true WEBHOOK_EVENTS_GROUPS_UPDATE=true WEBHOOK_EVENTS_GROUP_PARTICIPANTS_UPDATE=true WEBHOOK_EVENTS_CONNECTION_UPDATE=true +WEBHOOK_EVENTS_LABELS_EDIT=true +WEBHOOK_EVENTS_LABELS_ASSOCIATION=true WEBHOOK_EVENTS_CALL=true # This event fires every time a new token is requested via the refresh route WEBHOOK_EVENTS_NEW_JWT_TOKEN=false @@ -109,6 +119,13 @@ QRCODE_COLOR=#198754 TYPEBOT_API_VERSION=latest TYPEBOT_KEEP_OPEN=false +#Chatwoot +# If you leave this option as false, when deleting the message for everyone on WhatsApp, it will not be deleted on Chatwoot. +CHATWOOT_MESSAGE_DELETE=false # false | true +# This db connection is used to import messages from whatsapp to chatwoot database +CHATWOOT_IMPORT_DATABASE_CONNECTION_URI=postgres://user:password@hostname:port/dbname +CHATWOOT_IMPORT_DATABASE_PLACEHOLDER_MEDIA_MESSAGE=true + # Defines an authentication type for the api # We recommend using the apikey because it will allow you to use a custom token, # if you use jwt, a random token will be generated and may be expired and you will have to generate a new token diff --git a/Docker/docker-compose.yaml b/Docker/docker-compose.yaml index cbc55c12..4a2af41b 100644 --- a/Docker/docker-compose.yaml +++ b/Docker/docker-compose.yaml @@ -4,7 +4,7 @@ services: api: container_name: evolution_api - image: davidsongomes/evolution-api + image: atendai/evolution-api restart: always ports: - 8080:8080 @@ -19,4 +19,4 @@ services: volumes: evolution_instances: - evolution_store: \ No newline at end of file + evolution_store: diff --git a/Docker/evolution-api-all-services/.env.example b/Docker/evolution-api-all-services/.env.example index 555ba7bc..a28ad50f 100644 --- a/Docker/evolution-api-all-services/.env.example +++ b/Docker/evolution-api-all-services/.env.example @@ -16,6 +16,7 @@ LOG_BAILEYS=error # Default time: 5 minutes # If you don't even want an expiration, enter the value false DEL_INSTANCE=false +DEL_TEMP_INSTANCES=true # Delete instances with status closed on start # Temporary data storage STORE_MESSAGES=true @@ -73,6 +74,8 @@ WEBHOOK_EVENTS_GROUPS_UPSERT=true WEBHOOK_EVENTS_GROUPS_UPDATE=true WEBHOOK_EVENTS_GROUP_PARTICIPANTS_UPDATE=true WEBHOOK_EVENTS_CONNECTION_UPDATE=true +WEBHOOK_EVENTS_LABELS_EDIT=true +WEBHOOK_EVENTS_LABELS_ASSOCIATION=true # This event fires every time a new token is requested via the refresh route WEBHOOK_EVENTS_NEW_JWT_TOKEN=false diff --git a/Docker/evolution-api-all-services/docker-compose.yaml b/Docker/evolution-api-all-services/docker-compose.yaml index 1fe01975..5f936cd1 100644 --- a/Docker/evolution-api-all-services/docker-compose.yaml +++ b/Docker/evolution-api-all-services/docker-compose.yaml @@ -62,7 +62,7 @@ services: api: container_name: evolution_api - image: davidsongomes/evolution-api + image: atendai/evolution-api restart: always depends_on: - mongodb @@ -88,4 +88,4 @@ volumes: networks: evolution-net: external: true - \ No newline at end of file + diff --git a/Dockerfile b/Dockerfile index cf9bccf4..0fc4b287 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM node:20.7.0-alpine AS builder -LABEL version="1.6.1" description="Api to control whatsapp features through http requests." +LABEL version="1.7.0" description="Api to control whatsapp features through http requests." LABEL maintainer="Davidson Gomes" git="https://github.com/DavidsonGomes" LABEL contact="contato@agenciadgcode.com" @@ -35,6 +35,7 @@ ENV LOG_COLOR=true ENV LOG_BAILEYS=error ENV DEL_INSTANCE=false +ENV DEL_TEMP_INSTANCES=true ENV STORE_MESSAGES=true ENV STORE_MESSAGE_UP=true @@ -62,10 +63,17 @@ ENV REDIS_URI=redis://redis:6379 ENV REDIS_PREFIX_KEY=evolution ENV RABBITMQ_ENABLED=false +ENV RABBITMQ_MODE=global +ENV RABBITMQ_EXCHANGE_NAME=evolution_exchange ENV RABBITMQ_URI=amqp://guest:guest@rabbitmq:5672 ENV WEBSOCKET_ENABLED=false +ENV WA_BUSINESS_TOKEN_WEBHOOK=evolution +ENV WA_BUSINESS_URL=https://graph.facebook.com +ENV WA_BUSINESS_VERSION=v18.0 +ENV WA_BUSINESS_LANGUAGE=pt_BR + ENV SQS_ENABLED=false ENV SQS_ACCESS_KEY_ID= ENV SQS_SECRET_ACCESS_KEY= @@ -98,6 +106,8 @@ ENV WEBHOOK_EVENTS_GROUPS_UPSERT=true ENV WEBHOOK_EVENTS_GROUPS_UPDATE=true ENV WEBHOOK_EVENTS_GROUP_PARTICIPANTS_UPDATE=true ENV WEBHOOK_EVENTS_CONNECTION_UPDATE=true +ENV WEBHOOK_EVENTS_LABELS_EDIT=true +ENV WEBHOOK_EVENTS_LABELS_ASSOCIATION=true ENV WEBHOOK_EVENTS_CALL=true ENV WEBHOOK_EVENTS_NEW_JWT_TOKEN=false diff --git a/package.json b/package.json index b8c413eb..0907228d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "evolution-api", - "version": "1.6.1", + "version": "1.7.0", "description": "Rest api for communication with WhatsApp", "main": "./dist/src/main.js", "scripts": { @@ -46,11 +46,11 @@ "@figuro/chatwoot-sdk": "^1.1.16", "@hapi/boom": "^10.0.1", "@sentry/node": "^7.59.2", - "@whiskeysockets/baileys": "github:PurpShell/Baileys#combined", + "@whiskeysockets/baileys": "6.6.0", "amqplib": "^0.10.3", "aws-sdk": "^2.1499.0", - "axios": "^1.3.5", - "class-validator": "^0.13.2", + "axios": "^1.6.5", + "class-validator": "^0.14.1", "compression": "^1.7.4", "cors": "^2.8.5", "cross-env": "^7.0.3", @@ -60,28 +60,33 @@ "exiftool-vendored": "^22.0.0", "express": "^4.18.2", "express-async-errors": "^3.1.1", + "form-data": "^4.0.0", "hbs": "^4.2.0", + "https-proxy-agent": "^7.0.2", + "i18next": "^23.7.19", "jimp": "^0.16.13", "join": "^3.0.0", "js-yaml": "^4.1.0", "jsonschema": "^1.4.1", - "jsonwebtoken": "^8.5.1", + "jsonwebtoken": "^9.0.2", "libphonenumber-js": "^1.10.39", "link-preview-js": "^3.0.4", "mongoose": "^6.10.5", "node-cache": "^5.1.2", "node-mime-types": "^1.1.0", "node-windows": "^1.0.0-beta.8", + "parse-bmfont-xml": "^1.1.4", + "pg": "^8.11.3", "pino": "^8.11.0", - "proxy-agent": "^6.3.0", "qrcode": "^1.5.1", "qrcode-terminal": "^0.12.0", "redis": "^4.6.5", - "sharp": "^0.30.7", + "sharp": "^0.32.2", "socket.io": "^4.7.1", "socks-proxy-agent": "^8.0.1", "swagger-ui-express": "^5.0.0", "uuid": "^9.0.0", + "xml2js": "^0.6.2", "yamljs": "^0.3.0" }, "devDependencies": { diff --git a/src/config/env.config.ts b/src/config/env.config.ts index 511f4ebc..1baac567 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -34,6 +34,7 @@ export type SaveData = { MESSAGE_UPDATE: boolean; CONTACTS: boolean; CHATS: boolean; + LABELS: boolean; }; export type StoreConf = { @@ -41,6 +42,7 @@ export type StoreConf = { MESSAGE_UP: boolean; CONTACTS: boolean; CHATS: boolean; + LABELS: boolean; }; export type CleanStoreConf = { @@ -69,6 +71,8 @@ export type Redis = { export type Rabbitmq = { ENABLED: boolean; + MODE: string; // global, single, isolated + EXCHANGE_NAME: string; // available for global and single, isolated mode will use instance name as exchange URI: string; }; @@ -84,6 +88,13 @@ export type Websocket = { ENABLED: boolean; }; +export type WaBusiness = { + TOKEN_WEBHOOK: string; + URL: string; + VERSION: string; + LANGUAGE: string; +}; + export type EventsWebhook = { APPLICATION_STARTUP: boolean; INSTANCE_CREATE: boolean; @@ -103,6 +114,8 @@ export type EventsWebhook = { CHATS_DELETE: boolean; CHATS_UPSERT: boolean; CONNECTION_UPDATE: boolean; + LABELS_EDIT: boolean; + LABELS_ASSOCIATION: boolean; GROUPS_UPSERT: boolean; GROUP_UPDATE: boolean; GROUP_PARTICIPANTS_UPDATE: boolean; @@ -127,16 +140,41 @@ export type Auth = { export type DelInstance = number | boolean; +export type Language = string | 'en'; + export type GlobalWebhook = { URL: string; ENABLED: boolean; WEBHOOK_BY_EVENTS: boolean; }; +export type CacheConfRedis = { + ENABLED: boolean; + URI: string; + PREFIX_KEY: string; + TTL: number; +}; +export type CacheConfLocal = { + ENABLED: boolean; + TTL: number; +}; export type SslConf = { PRIVKEY: string; FULLCHAIN: string }; export type Webhook = { GLOBAL?: GlobalWebhook; EVENTS: EventsWebhook }; export type ConfigSessionPhone = { CLIENT: string; NAME: string }; export type QrCode = { LIMIT: number; COLOR: string }; export type Typebot = { API_VERSION: string; KEEP_OPEN: boolean }; +export type Chatwoot = { + MESSAGE_DELETE: boolean; + IMPORT: { + DATABASE: { + CONNECTION: { + URI: string; + }; + }; + PLACEHOLDER_MEDIA_MESSAGE: boolean; + }; +}; + +export type CacheConf = { REDIS: CacheConfRedis; LOCAL: CacheConfLocal }; export type Production = boolean; export interface Env { @@ -150,12 +188,17 @@ export interface Env { RABBITMQ: Rabbitmq; SQS: Sqs; WEBSOCKET: Websocket; + WA_BUSINESS: WaBusiness; LOG: Log; DEL_INSTANCE: DelInstance; + DEL_TEMP_INSTANCES: boolean; + LANGUAGE: Language; WEBHOOK: Webhook; CONFIG_SESSION_PHONE: ConfigSessionPhone; QRCODE: QrCode; TYPEBOT: Typebot; + CHATWOOT: Chatwoot; + CACHE: CacheConf; AUTHENTICATION: Auth; PRODUCTION?: Production; } @@ -209,6 +252,7 @@ export class ConfigService { MESSAGE_UP: process.env?.STORE_MESSAGE_UP === 'true', CONTACTS: process.env?.STORE_CONTACTS === 'true', CHATS: process.env?.STORE_CHATS === 'true', + LABELS: process.env?.STORE_LABELS === 'true', }, CLEAN_STORE: { CLEANING_INTERVAL: Number.isInteger(process.env?.CLEAN_STORE_CLEANING_TERMINAL) @@ -231,6 +275,7 @@ export class ConfigService { MESSAGE_UPDATE: process.env?.DATABASE_SAVE_MESSAGE_UPDATE === 'true', CONTACTS: process.env?.DATABASE_SAVE_DATA_CONTACTS === 'true', CHATS: process.env?.DATABASE_SAVE_DATA_CHATS === 'true', + LABELS: process.env?.DATABASE_SAVE_DATA_LABELS === 'true', }, }, REDIS: { @@ -240,6 +285,8 @@ export class ConfigService { }, RABBITMQ: { ENABLED: process.env?.RABBITMQ_ENABLED === 'true', + MODE: process.env?.RABBITMQ_MODE || 'isolated', + EXCHANGE_NAME: process.env?.RABBITMQ_EXCHANGE_NAME || 'evolution_exchange', URI: process.env.RABBITMQ_URI || '', }, SQS: { @@ -252,6 +299,12 @@ export class ConfigService { WEBSOCKET: { ENABLED: process.env?.WEBSOCKET_ENABLED === 'true', }, + WA_BUSINESS: { + TOKEN_WEBHOOK: process.env.WA_BUSINESS_TOKEN_WEBHOOK || '', + URL: process.env.WA_BUSINESS_URL || '', + VERSION: process.env.WA_BUSINESS_VERSION || '', + LANGUAGE: process.env.WA_BUSINESS_LANGUAGE || 'en', + }, LOG: { LEVEL: (process.env?.LOG_LEVEL.split(',') as LogLevel[]) || [ 'ERROR', @@ -269,6 +322,10 @@ export class ConfigService { DEL_INSTANCE: isBooleanString(process.env?.DEL_INSTANCE) ? process.env.DEL_INSTANCE === 'true' : Number.parseInt(process.env.DEL_INSTANCE) || false, + DEL_TEMP_INSTANCES: isBooleanString(process.env?.DEL_TEMP_INSTANCES) + ? process.env.DEL_TEMP_INSTANCES === 'true' + : true, + LANGUAGE: process.env?.LANGUAGE || 'en', WEBHOOK: { GLOBAL: { URL: process.env?.WEBHOOK_GLOBAL_URL || '', @@ -294,6 +351,8 @@ export class ConfigService { CHATS_UPSERT: process.env?.WEBHOOK_EVENTS_CHATS_UPSERT === 'true', CHATS_DELETE: process.env?.WEBHOOK_EVENTS_CHATS_DELETE === 'true', CONNECTION_UPDATE: process.env?.WEBHOOK_EVENTS_CONNECTION_UPDATE === 'true', + LABELS_EDIT: process.env?.WEBHOOK_EVENTS_LABELS_EDIT === 'true', + LABELS_ASSOCIATION: process.env?.WEBHOOK_EVENTS_LABELS_ASSOCIATION === 'true', GROUPS_UPSERT: process.env?.WEBHOOK_EVENTS_GROUPS_UPSERT === 'true', GROUP_UPDATE: process.env?.WEBHOOK_EVENTS_GROUPS_UPDATE === 'true', GROUP_PARTICIPANTS_UPDATE: process.env?.WEBHOOK_EVENTS_GROUP_PARTICIPANTS_UPDATE === 'true', @@ -318,6 +377,29 @@ export class ConfigService { API_VERSION: process.env?.TYPEBOT_API_VERSION || 'old', KEEP_OPEN: process.env.TYPEBOT_KEEP_OPEN === 'true', }, + CHATWOOT: { + MESSAGE_DELETE: process.env.CHATWOOT_MESSAGE_DELETE === 'false', + IMPORT: { + DATABASE: { + CONNECTION: { + URI: process.env.CHATWOOT_DATABASE_CONNECTION_URI || '', + }, + }, + PLACEHOLDER_MEDIA_MESSAGE: process.env?.CHATWOOT_IMPORT_PLACEHOLDER_MEDIA_MESSAGE === 'true', + }, + }, + CACHE: { + REDIS: { + ENABLED: process.env?.CACHE_REDIS_ENABLED === 'true', + URI: process.env?.CACHE_REDIS_URI || '', + PREFIX_KEY: process.env?.CACHE_REDIS_PREFIX_KEY || 'evolution-cache', + TTL: Number.parseInt(process.env?.CACHE_REDIS_TTL) || 604800, + }, + LOCAL: { + ENABLED: process.env?.CACHE_LOCAL_ENABLED === 'true', + TTL: Number.parseInt(process.env?.CACHE_REDIS_TTL) || 86400, + }, + }, AUTHENTICATION: { TYPE: process.env.AUTHENTICATION_TYPE as 'apikey', API_KEY: { diff --git a/src/dev-env.yml b/src/dev-env.yml index 80c7e376..9f7a413f 100644 --- a/src/dev-env.yml +++ b/src/dev-env.yml @@ -12,7 +12,6 @@ SERVER: DISABLE_MANAGER: false DISABLE_DOCS: false - CORS: ORIGIN: - "*" @@ -48,6 +47,7 @@ LOG: # Default time: 5 minutes # If you don't even want an expiration, enter the value false DEL_INSTANCE: false # or false +DEL_TEMP_INSTANCES: true # Delete instances with status closed on start # Temporary data storage STORE: @@ -84,6 +84,8 @@ REDIS: RABBITMQ: ENABLED: false + MODE: "global" + EXCHANGE_NAME: "evolution_exchange" URI: "amqp://guest:guest@localhost:5672" SQS: @@ -96,6 +98,12 @@ SQS: WEBSOCKET: ENABLED: false +WA_BUSINESS: + TOKEN_WEBHOOK: evolution + URL: https://graph.facebook.com + VERSION: v18.0 + LANGUAGE: pt_BR + # Global Webhook Settings # Each instance's Webhook URL and events will be requested at the time it is created WEBHOOK: @@ -127,6 +135,8 @@ WEBHOOK: GROUP_UPDATE: true GROUP_PARTICIPANTS_UPDATE: true CONNECTION_UPDATE: true + LABELS_EDIT: true + LABELS_ASSOCIATION: true CALL: true # This event fires every time a new token is requested via the refresh route NEW_JWT_TOKEN: false @@ -150,9 +160,30 @@ QRCODE: COLOR: "#198754" TYPEBOT: - API_VERSION: 'old' # old | latest + API_VERSION: "old" # old | latest KEEP_OPEN: false +CHATWOOT: + # If you leave this option as false, when deleting the message for everyone on WhatsApp, it will not be deleted on Chatwoot. + MESSAGE_DELETE: true # false | true + IMPORT: + # This db connection is used to import messages from whatsapp to chatwoot database + DATABASE: + CONNECTION: + URI: "postgres://user:password@hostname:port/dbname" + PLACEHOLDER_MEDIA_MESSAGE: true + +# Cache to optimize application performance +CACHE: + REDIS: + ENABLED: false + URI: "redis://localhost:6379" + PREFIX_KEY: "evolution-cache" + TTL: 604800 + LOCAL: + ENABLED: false + TTL: 86400 + # Defines an authentication type for the api # We recommend using the apikey because it will allow you to use a custom token, # if you use jwt, a random token will be generated and may be expired and you will have to generate a new token @@ -168,3 +199,5 @@ AUTHENTICATION: JWT: EXPIRIN_IN: 0 # seconds - 3600s === 1h | zero (0) - never expires SECRET: L=0YWt]b2w[WF>#>:&E` + +LANGUAGE: "pt-BR" # pt-BR, en diff --git a/src/docs/swagger.yaml b/src/docs/swagger.yaml index 603aa0e0..a6bf3288 100644 --- a/src/docs/swagger.yaml +++ b/src/docs/swagger.yaml @@ -25,7 +25,7 @@ info: [![Run in Postman](https://run.pstmn.io/button.svg)](https://god.gw.postman.com/run-collection/26869335-5546d063-156b-4529-915f-909dd628c090?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D26869335-5546d063-156b-4529-915f-909dd628c090%26entityType%3Dcollection%26workspaceId%3D339a4ee7-378b-45c9-b5b8-fd2c0a9c2442) - version: 1.6.1 + version: 1.7.0 contact: name: DavidsonGomes email: contato@agenciadgcode.com @@ -51,6 +51,7 @@ tags: - name: Send Message Controller - name: Chat Controller - name: Group Controller + - name: Label Controller - name: Profile Settings - name: JWT - name: Settings @@ -940,7 +941,72 @@ paths: description: Successful response content: application/json: {} - + /message/sendList/{instanceName}: + post: + tags: + - Send Message Controller + summary: Send a list to a specified instance. + description: This endpoint allows users to send a list to a chat. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + number: + type: string + options: + type: object + properties: + delay: + type: integer + presence: + type: string + listMessage: + type: object + properties: + title: + type: string + description: + type: string + footerText: + type: string + nullable: true + buttonText: + type: string + sections: + type: array + items: + type: object + properties: + title: + type: string + rows: + type: array + items: + type: object + properties: + title: + type: string + description: + type: string + rowId: + type: string + parameters: + - name: instanceName + in: path + required: true + schema: + type: string + description: The name of the instance to which the poll should be sent. + example: "evolution" + responses: + "200": + description: Successful response + content: + application/json: {} + /chat/whatsappNumbers/{instanceName}: post: tags: @@ -1791,6 +1857,8 @@ paths: "GROUP_UPDATE", "GROUP_PARTICIPANTS_UPDATE", "CONNECTION_UPDATE", + "LABELS_EDIT", + "LABELS_ASSOCIATION", "CALL", "NEW_JWT_TOKEN", ] @@ -1867,6 +1935,8 @@ paths: "GROUP_UPDATE", "GROUP_PARTICIPANTS_UPDATE", "CONNECTION_UPDATE", + "LABELS_EDIT", + "LABELS_ASSOCIATION", "CALL", "NEW_JWT_TOKEN", ] @@ -1943,6 +2013,8 @@ paths: "GROUP_UPDATE", "GROUP_PARTICIPANTS_UPDATE", "CONNECTION_UPDATE", + "LABELS_EDIT", + "LABELS_ASSOCIATION", "CALL", "NEW_JWT_TOKEN", ] @@ -1981,6 +2053,97 @@ paths: content: application/json: {} + /label/findLabels/{instanceName}: + get: + tags: + - Label Controller + summary: List all labels for an instance. + parameters: + - name: instanceName + in: path + schema: + type: string + required: true + description: "- required" + example: "evolution" + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + type: object + properties: + color: + type: integer + name: + type: string + id: + type: string + predefinedId: + type: string + required: + - color + - name + - id + /label/handleLabel/{instanceName}: + put: + tags: + - Label Controller + summary: Change the label (add or remove) for an specific chat. + parameters: + - name: instanceName + in: path + schema: + type: string + required: true + description: "- required" + example: "evolution" + requestBody: + content: + application/json: + schema: + type: object + properties: + number: + type: string + labelId: + type: string + action: + type: string + enum: + - add + - remove + required: + - number + - labelId + - action + example: + number: '553499999999' + labelId: '1' + action: add + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: object + properties: + numberJid: + type: string + labelId: + type: string + remove: + type: boolean + add: + type: boolean + required: + - numberJid + - labelId + /settings/set/{instanceName}: post: tags: @@ -2011,6 +2174,9 @@ paths: read_status: type: boolean description: "Indicates whether to mark status messages as read." + sync_full_history: + type: boolean + description: "Indicates whether to request a full history messages sync on connect." parameters: - name: instanceName in: path @@ -2076,6 +2242,15 @@ paths: conversation_pending: type: boolean description: "Indicates whether to mark conversations as pending." + import_contacts: + type: boolean + description: "Indicates whether to import contacts from phone to Chatwoot when connecting." + import_messages: + type: boolean + description: "Indicates whether to import messages from phone to Chatwoot when connecting." + days_limit_import_messages: + type: number + description: "Indicates number of days to limit messages imported to Chatwoot." parameters: - name: instanceName in: path diff --git a/src/libs/cacheengine.ts b/src/libs/cacheengine.ts new file mode 100644 index 00000000..a22d7e68 --- /dev/null +++ b/src/libs/cacheengine.ts @@ -0,0 +1,22 @@ +import { CacheConf, ConfigService } from '../config/env.config'; +import { ICache } from '../whatsapp/abstract/abstract.cache'; +import { LocalCache } from './localcache'; +import { RedisCache } from './rediscache'; + +export class CacheEngine { + private engine: ICache; + + constructor(private readonly configService: ConfigService, module: string) { + const cacheConf = configService.get('CACHE'); + + if (cacheConf?.REDIS?.ENABLED && cacheConf?.REDIS?.URI !== '') { + this.engine = new RedisCache(configService, module); + } else if (cacheConf?.LOCAL?.ENABLED) { + this.engine = new LocalCache(configService, module); + } + } + + public getEngine() { + return this.engine; + } +} diff --git a/src/libs/localcache.ts b/src/libs/localcache.ts new file mode 100644 index 00000000..fe1f295f --- /dev/null +++ b/src/libs/localcache.ts @@ -0,0 +1,48 @@ +import NodeCache from 'node-cache'; + +import { CacheConf, CacheConfLocal, ConfigService } from '../config/env.config'; +import { ICache } from '../whatsapp/abstract/abstract.cache'; + +export class LocalCache implements ICache { + private conf: CacheConfLocal; + static localCache = new NodeCache(); + + constructor(private readonly configService: ConfigService, private readonly module: string) { + this.conf = this.configService.get('CACHE')?.LOCAL; + } + + async get(key: string): Promise { + return LocalCache.localCache.get(this.buildKey(key)); + } + + async set(key: string, value: any, ttl?: number) { + return LocalCache.localCache.set(this.buildKey(key), value, ttl || this.conf.TTL); + } + + async has(key: string) { + return LocalCache.localCache.has(this.buildKey(key)); + } + + async delete(key: string) { + return LocalCache.localCache.del(this.buildKey(key)); + } + + async deleteAll(appendCriteria?: string) { + const keys = await this.keys(appendCriteria); + if (!keys?.length) { + return 0; + } + + return LocalCache.localCache.del(keys); + } + + async keys(appendCriteria?: string) { + const filter = `${this.buildKey('')}${appendCriteria ? `${appendCriteria}:` : ''}`; + + return LocalCache.localCache.keys().filter((key) => key.substring(0, filter.length) === filter); + } + + buildKey(key: string) { + return `${this.module}:${key}`; + } +} diff --git a/src/libs/postgres.client.ts b/src/libs/postgres.client.ts new file mode 100644 index 00000000..d1a68cdf --- /dev/null +++ b/src/libs/postgres.client.ts @@ -0,0 +1,49 @@ +import postgresql from 'pg'; + +import { Chatwoot, configService } from '../config/env.config'; +import { Logger } from '../config/logger.config'; + +const { Pool } = postgresql; + +class Postgres { + private logger = new Logger(Postgres.name); + private pool; + private connected = false; + + getConnection(connectionString: string) { + if (this.connected) { + return this.pool; + } else { + this.pool = new Pool({ + connectionString, + ssl: { + rejectUnauthorized: false, + }, + }); + + this.pool.on('error', () => { + this.logger.error('postgres disconnected'); + this.connected = false; + }); + + try { + this.logger.verbose('connecting new postgres'); + this.connected = true; + } catch (e) { + this.connected = false; + this.logger.error('postgres connect exception caught: ' + e); + return null; + } + + return this.pool; + } + } + + getChatwootConnection() { + const uri = configService.get('CHATWOOT').IMPORT.DATABASE.CONNECTION.URI; + + return this.getConnection(uri); + } +} + +export const postgresClient = new Postgres(); diff --git a/src/libs/rediscache.client.ts b/src/libs/rediscache.client.ts new file mode 100644 index 00000000..b3f8dead --- /dev/null +++ b/src/libs/rediscache.client.ts @@ -0,0 +1,59 @@ +import { createClient, RedisClientType } from 'redis'; + +import { CacheConf, CacheConfRedis, configService } from '../config/env.config'; +import { Logger } from '../config/logger.config'; + +class Redis { + private logger = new Logger(Redis.name); + private client: RedisClientType = null; + private conf: CacheConfRedis; + private connected = false; + + constructor() { + this.conf = configService.get('CACHE')?.REDIS; + } + + getConnection(): RedisClientType { + if (this.connected) { + return this.client; + } else { + this.client = createClient({ + url: this.conf.URI, + }); + + this.client.on('connect', () => { + this.logger.verbose('redis connecting'); + }); + + this.client.on('ready', () => { + this.logger.verbose('redis ready'); + this.connected = true; + }); + + this.client.on('error', () => { + this.logger.error('redis disconnected'); + this.connected = false; + }); + + this.client.on('end', () => { + this.logger.verbose('redis connection ended'); + this.connected = false; + }); + + try { + this.logger.verbose('connecting new redis client'); + this.client.connect(); + this.connected = true; + this.logger.verbose('connected to new redis client'); + } catch (e) { + this.connected = false; + this.logger.error('redis connect exception caught: ' + e); + return null; + } + + return this.client; + } + } +} + +export const redisClient = new Redis(); diff --git a/src/libs/rediscache.ts b/src/libs/rediscache.ts new file mode 100644 index 00000000..cd0b1283 --- /dev/null +++ b/src/libs/rediscache.ts @@ -0,0 +1,83 @@ +import { RedisClientType } from 'redis'; + +import { CacheConf, CacheConfRedis, ConfigService } from '../config/env.config'; +import { Logger } from '../config/logger.config'; +import { ICache } from '../whatsapp/abstract/abstract.cache'; +import { redisClient } from './rediscache.client'; + +export class RedisCache implements ICache { + private readonly logger = new Logger(RedisCache.name); + private client: RedisClientType; + private conf: CacheConfRedis; + + constructor(private readonly configService: ConfigService, private readonly module: string) { + this.conf = this.configService.get('CACHE')?.REDIS; + this.client = redisClient.getConnection(); + } + + async get(key: string): Promise { + try { + return JSON.parse(await this.client.get(this.buildKey(key))); + } catch (error) { + this.logger.error(error); + } + } + + async set(key: string, value: any, ttl?: number) { + try { + await this.client.setEx(this.buildKey(key), ttl || this.conf?.TTL, JSON.stringify(value)); + } catch (error) { + this.logger.error(error); + } + } + + async has(key: string) { + try { + return (await this.client.exists(this.buildKey(key))) > 0; + } catch (error) { + this.logger.error(error); + } + } + + async delete(key: string) { + try { + return await this.client.del(this.buildKey(key)); + } catch (error) { + this.logger.error(error); + } + } + + async deleteAll(appendCriteria?: string) { + try { + const keys = await this.keys(appendCriteria); + if (!keys?.length) { + return 0; + } + + return await this.client.del(keys); + } catch (error) { + this.logger.error(error); + } + } + + async keys(appendCriteria?: string) { + try { + const match = `${this.buildKey('')}${appendCriteria ? `${appendCriteria}:` : ''}*`; + const keys = []; + for await (const key of this.client.scanIterator({ + MATCH: match, + COUNT: 100, + })) { + keys.push(key); + } + + return [...new Set(keys)]; + } catch (error) { + this.logger.error(error); + } + } + + buildKey(key: string) { + return `${this.conf?.PREFIX_KEY}:${this.module}:${key}`; + } +} diff --git a/src/utils/chatwoot-import-helper.ts b/src/utils/chatwoot-import-helper.ts new file mode 100644 index 00000000..3283683f --- /dev/null +++ b/src/utils/chatwoot-import-helper.ts @@ -0,0 +1,472 @@ +import { inbox } from '@figuro/chatwoot-sdk'; +import { proto } from '@whiskeysockets/baileys'; + +import { Chatwoot, configService } from '../config/env.config'; +import { Logger } from '../config/logger.config'; +import { postgresClient } from '../libs/postgres.client'; +import { InstanceDto } from '../whatsapp/dto/instance.dto'; +import { ChatwootRaw, ContactRaw, MessageRaw } from '../whatsapp/models'; +import { ChatwootService } from '../whatsapp/services/chatwoot.service'; + +type ChatwootUser = { + user_type: string; + user_id: number; +}; + +type FksChatwoot = { + phone_number: string; + contact_id: string; + conversation_id: string; +}; + +type firstLastTimestamp = { + first: number; + last: number; +}; + +type IWebMessageInfo = Omit & Partial>; + +class ChatwootImport { + private logger = new Logger(ChatwootImport.name); + private repositoryMessagesCache = new Map>(); + private historyMessages = new Map(); + private historyContacts = new Map(); + + public getRepositoryMessagesCache(instance: InstanceDto) { + return this.repositoryMessagesCache.has(instance.instanceName) + ? this.repositoryMessagesCache.get(instance.instanceName) + : null; + } + + public setRepositoryMessagesCache(instance: InstanceDto, repositoryMessagesCache: Set) { + this.repositoryMessagesCache.set(instance.instanceName, repositoryMessagesCache); + } + + public deleteRepositoryMessagesCache(instance: InstanceDto) { + this.repositoryMessagesCache.delete(instance.instanceName); + } + + public addHistoryMessages(instance: InstanceDto, messagesRaw: MessageRaw[]) { + const actualValue = this.historyMessages.has(instance.instanceName) + ? this.historyMessages.get(instance.instanceName) + : []; + this.historyMessages.set(instance.instanceName, actualValue.concat(messagesRaw)); + } + + public addHistoryContacts(instance: InstanceDto, contactsRaw: ContactRaw[]) { + const actualValue = this.historyContacts.has(instance.instanceName) + ? this.historyContacts.get(instance.instanceName) + : []; + this.historyContacts.set(instance.instanceName, actualValue.concat(contactsRaw)); + } + + public deleteHistoryMessages(instance: InstanceDto) { + this.historyMessages.delete(instance.instanceName); + } + + public deleteHistoryContacts(instance: InstanceDto) { + this.historyContacts.delete(instance.instanceName); + } + + public clearAll(instance: InstanceDto) { + this.deleteRepositoryMessagesCache(instance); + this.deleteHistoryMessages(instance); + this.deleteHistoryContacts(instance); + } + + public getHistoryMessagesLenght(instance: InstanceDto) { + return this.historyMessages.get(instance.instanceName)?.length ?? 0; + } + + public async importHistoryContacts(instance: InstanceDto, provider: ChatwootRaw) { + try { + if (this.getHistoryMessagesLenght(instance) > 0) { + return; + } + + const pgClient = postgresClient.getChatwootConnection(); + + let totalContactsImported = 0; + + const contacts = this.historyContacts.get(instance.instanceName) || []; + if (contacts.length === 0) { + return 0; + } + + let contactsChunk: ContactRaw[] = this.sliceIntoChunks(contacts, 3000); + while (contactsChunk.length > 0) { + // inserting contacts in chatwoot db + let sqlInsert = `INSERT INTO contacts + (name, phone_number, account_id, identifier, created_at, updated_at) VALUES `; + const bindInsert = [provider.account_id]; + + for (const contact of contactsChunk) { + bindInsert.push(contact.pushName); + const bindName = `$${bindInsert.length}`; + + bindInsert.push(`+${contact.id.split('@')[0]}`); + const bindPhoneNumber = `$${bindInsert.length}`; + + bindInsert.push(contact.id); + const bindIdentifier = `$${bindInsert.length}`; + + sqlInsert += `(${bindName}, ${bindPhoneNumber}, $1, ${bindIdentifier}, NOW(), NOW()),`; + } + if (sqlInsert.slice(-1) === ',') { + sqlInsert = sqlInsert.slice(0, -1); + } + sqlInsert += ` ON CONFLICT (identifier, account_id) + DO UPDATE SET + name = EXCLUDED.name, + phone_number = EXCLUDED.phone_number, + identifier = EXCLUDED.identifier`; + + totalContactsImported += (await pgClient.query(sqlInsert, bindInsert))?.rowCount ?? 0; + contactsChunk = this.sliceIntoChunks(contacts, 3000); + } + + this.deleteHistoryContacts(instance); + + return totalContactsImported; + } catch (error) { + this.logger.error(`Error on import history contacts: ${error.toString()}`); + } + } + + public async importHistoryMessages( + instance: InstanceDto, + chatwootService: ChatwootService, + inbox: inbox, + provider: ChatwootRaw, + ) { + try { + const pgClient = postgresClient.getChatwootConnection(); + + const chatwootUser = await this.getChatwootUser(provider); + if (!chatwootUser) { + throw new Error('User not found to import messages.'); + } + + let totalMessagesImported = 0; + + const messagesOrdered = this.historyMessages.get(instance.instanceName) || []; + if (messagesOrdered.length === 0) { + return 0; + } + + // ordering messages by number and timestamp asc + messagesOrdered.sort((a, b) => { + return ( + parseInt(a.key.remoteJid) - parseInt(b.key.remoteJid) || + (a.messageTimestamp as number) - (b.messageTimestamp as number) + ); + }); + + const allMessagesMappedByPhoneNumber = this.createMessagesMapByPhoneNumber(messagesOrdered); + // Map structure: +552199999999 => { first message timestamp from number, last message timestamp from number} + const phoneNumbersWithTimestamp = new Map(); + allMessagesMappedByPhoneNumber.forEach((messages: MessageRaw[], phoneNumber: string) => { + phoneNumbersWithTimestamp.set(phoneNumber, { + first: messages[0]?.messageTimestamp as number, + last: messages[messages.length - 1]?.messageTimestamp as number, + }); + }); + + // processing messages in batch + const batchSize = 4000; + let messagesChunk: MessageRaw[] = this.sliceIntoChunks(messagesOrdered, batchSize); + while (messagesChunk.length > 0) { + // Map structure: +552199999999 => MessageRaw[] + const messagesByPhoneNumber = this.createMessagesMapByPhoneNumber(messagesChunk); + + if (messagesByPhoneNumber.size > 0) { + const fksByNumber = await this.selectOrCreateFksFromChatwoot( + provider, + inbox, + phoneNumbersWithTimestamp, + messagesByPhoneNumber, + ); + + // inserting messages in chatwoot db + let sqlInsertMsg = `INSERT INTO messages + (content, account_id, inbox_id, conversation_id, message_type, private, content_type, + sender_type, sender_id, created_at, updated_at) VALUES `; + const bindInsertMsg = [provider.account_id, inbox.id]; + + messagesByPhoneNumber.forEach((messages: MessageRaw[], phoneNumber: string) => { + const fksChatwoot = fksByNumber.get(phoneNumber); + + messages.forEach((message) => { + if (!message.message) { + return; + } + + if (!fksChatwoot?.conversation_id || !fksChatwoot?.contact_id) { + return; + } + + const contentMessage = this.getContentMessage(chatwootService, message); + if (!contentMessage) { + return; + } + + bindInsertMsg.push(contentMessage); + const bindContent = `$${bindInsertMsg.length}`; + + bindInsertMsg.push(fksChatwoot.conversation_id); + const bindConversationId = `$${bindInsertMsg.length}`; + + bindInsertMsg.push(message.key.fromMe ? '1' : '0'); + const bindMessageType = `$${bindInsertMsg.length}`; + + bindInsertMsg.push(message.key.fromMe ? chatwootUser.user_type : 'Contact'); + const bindSenderType = `$${bindInsertMsg.length}`; + + bindInsertMsg.push(message.key.fromMe ? chatwootUser.user_id : fksChatwoot.contact_id); + const bindSenderId = `$${bindInsertMsg.length}`; + + bindInsertMsg.push(message.messageTimestamp as number); + const bindmessageTimestamp = `$${bindInsertMsg.length}`; + + sqlInsertMsg += `(${bindContent}, $1, $2, ${bindConversationId}, ${bindMessageType}, FALSE, 0, + ${bindSenderType},${bindSenderId}, to_timestamp(${bindmessageTimestamp}), to_timestamp(${bindmessageTimestamp})),`; + }); + }); + if (bindInsertMsg.length > 2) { + if (sqlInsertMsg.slice(-1) === ',') { + sqlInsertMsg = sqlInsertMsg.slice(0, -1); + } + totalMessagesImported += (await pgClient.query(sqlInsertMsg, bindInsertMsg))?.rowCount ?? 0; + } + } + messagesChunk = this.sliceIntoChunks(messagesOrdered, batchSize); + } + + this.deleteHistoryMessages(instance); + this.deleteRepositoryMessagesCache(instance); + + this.importHistoryContacts(instance, provider); + + return totalMessagesImported; + } catch (error) { + this.logger.error(`Error on import history messages: ${error.toString()}`); + + this.deleteHistoryMessages(instance); + this.deleteRepositoryMessagesCache(instance); + } + } + + public async selectOrCreateFksFromChatwoot( + provider: ChatwootRaw, + inbox: inbox, + phoneNumbersWithTimestamp: Map, + messagesByPhoneNumber: Map, + ): Promise> { + const pgClient = postgresClient.getChatwootConnection(); + + const bindValues = [provider.account_id, inbox.id]; + const phoneNumberBind = Array.from(messagesByPhoneNumber.keys()) + .map((phoneNumber) => { + const phoneNumberTimestamp = phoneNumbersWithTimestamp.get(phoneNumber); + + if (phoneNumberTimestamp) { + bindValues.push(phoneNumber); + let bindStr = `($${bindValues.length},`; + + bindValues.push(phoneNumberTimestamp.first); + bindStr += `$${bindValues.length},`; + + bindValues.push(phoneNumberTimestamp.last); + return `${bindStr}$${bindValues.length})`; + } + }) + .join(','); + + // select (or insert when necessary) data from tables contacts, contact_inboxes, conversations from chatwoot db + const sqlFromChatwoot = `WITH + phone_number AS ( + SELECT phone_number, created_at::INTEGER, last_activity_at::INTEGER FROM ( + VALUES + ${phoneNumberBind} + ) as t (phone_number, created_at, last_activity_at) + ), + + only_new_phone_number AS ( + SELECT * FROM phone_number + WHERE phone_number NOT IN ( + SELECT phone_number + FROM contacts + JOIN contact_inboxes ci ON ci.contact_id = contacts.id AND ci.inbox_id = $2 + JOIN conversations con ON con.contact_inbox_id = ci.id + AND con.account_id = $1 + AND con.inbox_id = $2 + AND con.contact_id = contacts.id + WHERE contacts.account_id = $1 + ) + ), + + new_contact AS ( + INSERT INTO contacts (name, phone_number, account_id, identifier, created_at, updated_at) + SELECT REPLACE(p.phone_number, '+', ''), p.phone_number, $1, CONCAT(REPLACE(p.phone_number, '+', ''), + '@s.whatsapp.net'), to_timestamp(p.created_at), to_timestamp(p.last_activity_at) + FROM only_new_phone_number AS p + ON CONFLICT(identifier, account_id) DO UPDATE SET updated_at = EXCLUDED.updated_at + RETURNING id, phone_number, created_at, updated_at + ), + + new_contact_inbox AS ( + INSERT INTO contact_inboxes (contact_id, inbox_id, source_id, created_at, updated_at) + SELECT new_contact.id, $2, gen_random_uuid(), new_contact.created_at, new_contact.updated_at + FROM new_contact + RETURNING id, contact_id, created_at, updated_at + ), + + new_conversation AS ( + INSERT INTO conversations (account_id, inbox_id, status, contact_id, + contact_inbox_id, uuid, last_activity_at, created_at, updated_at) + SELECT $1, $2, 0, new_contact_inbox.contact_id, new_contact_inbox.id, gen_random_uuid(), + new_contact_inbox.updated_at, new_contact_inbox.created_at, new_contact_inbox.updated_at + FROM new_contact_inbox + RETURNING id, contact_id + ) + + SELECT new_contact.phone_number, new_conversation.contact_id, new_conversation.id AS conversation_id + FROM new_conversation + JOIN new_contact ON new_conversation.contact_id = new_contact.id + + UNION + + SELECT p.phone_number, c.id contact_id, con.id conversation_id + FROM phone_number p + JOIN contacts c ON c.phone_number = p.phone_number + JOIN contact_inboxes ci ON ci.contact_id = c.id AND ci.inbox_id = $2 + JOIN conversations con ON con.contact_inbox_id = ci.id AND con.account_id = $1 + AND con.inbox_id = $2 AND con.contact_id = c.id`; + + const fksFromChatwoot = await pgClient.query(sqlFromChatwoot, bindValues); + + return new Map(fksFromChatwoot.rows.map((item: FksChatwoot) => [item.phone_number, item])); + } + + public async getChatwootUser(provider: ChatwootRaw): Promise { + try { + const pgClient = postgresClient.getChatwootConnection(); + + const sqlUser = `SELECT owner_type AS user_type, owner_id AS user_id + FROM access_tokens + WHERE token = $1`; + + return (await pgClient.query(sqlUser, [provider.token]))?.rows[0] || false; + } catch (error) { + this.logger.error(`Error on getChatwootUser: ${error.toString()}`); + } + } + + public createMessagesMapByPhoneNumber(messages: MessageRaw[]): Map { + return messages.reduce((acc: Map, message: MessageRaw) => { + if (!this.isIgnorePhoneNumber(message?.key?.remoteJid)) { + const phoneNumber = message?.key?.remoteJid?.split('@')[0]; + if (phoneNumber) { + const phoneNumberPlus = `+${phoneNumber}`; + const messages = acc.has(phoneNumberPlus) ? acc.get(phoneNumberPlus) : []; + messages.push(message); + acc.set(phoneNumberPlus, messages); + } + } + + return acc; + }, new Map()); + } + + public async getContactsOrderByRecentConversations( + inbox: inbox, + provider: ChatwootRaw, + limit = 50, + ): Promise<{ id: number; phone_number: string; identifier: string }[]> { + try { + const pgClient = postgresClient.getChatwootConnection(); + + const sql = `SELECT contacts.id, contacts.identifier, contacts.phone_number + FROM conversations + JOIN contacts ON contacts.id = conversations.contact_id + WHERE conversations.account_id = $1 + AND inbox_id = $2 + ORDER BY conversations.last_activity_at DESC + LIMIT $3`; + + return (await pgClient.query(sql, [provider.account_id, inbox.id, limit]))?.rows; + } catch (error) { + this.logger.error(`Error on get recent conversations: ${error.toString()}`); + } + } + + public getContentMessage(chatwootService: ChatwootService, msg: IWebMessageInfo) { + const contentMessage = chatwootService.getConversationMessage(msg.message); + if (contentMessage) { + return contentMessage; + } + + if (!configService.get('CHATWOOT').IMPORT.PLACEHOLDER_MEDIA_MESSAGE) { + return ''; + } + + const types = { + documentMessage: msg.message.documentMessage, + documentWithCaptionMessage: msg.message.documentWithCaptionMessage?.message?.documentMessage, + imageMessage: msg.message.imageMessage, + videoMessage: msg.message.videoMessage, + audioMessage: msg.message.audioMessage, + stickerMessage: msg.message.stickerMessage, + templateMessage: msg.message.templateMessage?.hydratedTemplate?.hydratedContentText, + }; + const typeKey = Object.keys(types).find((key) => types[key] !== undefined); + + switch (typeKey) { + case 'documentMessage': + return `__`; + + case 'documentWithCaptionMessage': + return `__`; + + case 'templateMessage': + return msg.message.templateMessage.hydratedTemplate.hydratedTitleText + ? `*${msg.message.templateMessage.hydratedTemplate.hydratedTitleText}*\\n` + : '' + msg.message.templateMessage.hydratedTemplate.hydratedContentText; + + case 'imageMessage': + return '__'; + + case 'videoMessage': + return '_