diff --git a/.github/workflows/publish_docker_image.yml b/.github/workflows/publish_docker_image.yml index 68a08a313..09d09390e 100644 --- a/.github/workflows/publish_docker_image.yml +++ b/.github/workflows/publish_docker_image.yml @@ -20,7 +20,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: atendai/evolution-api + images: evoapicloud/evolution-api tags: type=semver,pattern=v{{version}} - name: Set up QEMU diff --git a/.github/workflows/publish_docker_image_homolog.yml b/.github/workflows/publish_docker_image_homolog.yml index 77032dc95..b97a5e250 100644 --- a/.github/workflows/publish_docker_image_homolog.yml +++ b/.github/workflows/publish_docker_image_homolog.yml @@ -20,7 +20,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: atendai/evolution-api + images: evoapicloud/evolution-api tags: homolog - name: Set up QEMU diff --git a/.github/workflows/publish_docker_image_latest.yml b/.github/workflows/publish_docker_image_latest.yml index 641dc5e01..cffdab01e 100644 --- a/.github/workflows/publish_docker_image_latest.yml +++ b/.github/workflows/publish_docker_image_latest.yml @@ -20,7 +20,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: atendai/evolution-api + images: evoapicloud/evolution-api tags: latest - name: Set up QEMU diff --git a/.gitignore b/.gitignore index a8226ede1..1cecfa988 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /dist /node_modules +.cursor* + /Docker/.env .vscode diff --git a/Docker/swarm/evolution_api_v2.yaml b/Docker/swarm/evolution_api_v2.yaml index 41c2daa28..867251a53 100644 --- a/Docker/swarm/evolution_api_v2.yaml +++ b/Docker/swarm/evolution_api_v2.yaml @@ -2,7 +2,7 @@ version: "3.7" services: evolution_v2: - image: atendai/evolution-api:v2.1.2 + image: evoapicloud/evolution-api:latest volumes: - evolution_instances:/evolution/instances networks: diff --git a/Dockerfile b/Dockerfile index ca61b39a8..38cb416ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ RUN apk update && \ LABEL version="2.2.3" description="Api to control whatsapp features through http requests." LABEL maintainer="Davidson Gomes" git="https://github.com/DavidsonGomes" -LABEL contact="contato@atendai.com" +LABEL contact="contato@evolution-api.com" WORKDIR /evolution diff --git a/LICENSE b/LICENSE index da01e779d..ad430f14a 100644 --- a/LICENSE +++ b/LICENSE @@ -8,7 +8,7 @@ a. LOGO and copyright information: In the process of using Evolution API's front b. Usage Notification Requirement: If Evolution API is used as part of any project, including closed-source systems (e.g., proprietary software), the user is required to display a clear notification within the system that Evolution API is being utilized. This notification should be visible to system administrators and accessible from the system's documentation or settings page. Failure to comply with this requirement may result in the necessity for a commercial license, as determined by the producer. -Please contact contato@atendai.com to inquire about licensing matters. +Please contact contato@evolution-api.com to inquire about licensing matters. 2. As a contributor, you should agree that: diff --git a/README.md b/README.md index 93197499b..6e40ce741 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@
+[![Docker Image (https://img.shields.io/badge/Docker-Image-blue)](https://hub.docker.com/r/evoapicloud/evolution-api)] [![Whatsapp Group](https://img.shields.io/badge/Group-WhatsApp-%2322BC18)](https://evolution-api.com/whatsapp) [![Discord Community](https://img.shields.io/badge/Discord-Community-blue)](https://evolution-api.com/discord) [![Postman Collection](https://img.shields.io/badge/Postman-Collection-orange)](https://evolution-api.com/postman) @@ -87,6 +88,7 @@ https://github.com/sponsors/EvolutionAPI We are proud to collaborate with the following content creators who have contributed valuable insights and tutorials about Evolution API: - [Promovaweb](https://www.youtube.com/@promovaweb) +- [Sandeco](https://www.youtube.com/@canalsandeco) - [Comunidade ZDG](https://www.youtube.com/@ComunidadeZDG) - [Francis MNO](https://www.youtube.com/@FrancisMNO) - [Pablo Cabral](https://youtube.com/@pablocabral) @@ -111,7 +113,7 @@ Evolution API is licensed under the Apache License 2.0, with the following addit 2. **Usage Notification Requirement**: If Evolution API is used as part of any project, including closed-source systems (e.g., proprietary software), the user is required to display a clear notification within the system that Evolution API is being utilized. This notification should be visible to system administrators and accessible from the system's documentation or settings page. Failure to comply with this requirement may result in the necessity for a commercial license, as determined by the producer. -Please contact contato@atendai.com to inquire about licensing matters. +Please contact contato@evolution-api.com to inquire about licensing matters. Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache License 2.0. Detailed information about the Apache License 2.0 can be found at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0). diff --git a/docker-compose.yaml b/docker-compose.yaml index 9a60a9a90..33918c383 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ services: api: container_name: evolution_api - image: atendai/evolution-api:homolog + image: evoapicloud/evolution-api:latest restart: always depends_on: - redis diff --git a/package.json b/package.json index 7e948028a..974f2a922 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ ], "author": { "name": "Davidson Gomes", - "email": "contato@atendai.com" + "email": "contato@evolution-api.com" }, "license": "Apache-2.0", "bugs": { diff --git a/src/api/controllers/business.controller.ts b/src/api/controllers/business.controller.ts new file mode 100644 index 000000000..3c7f166cc --- /dev/null +++ b/src/api/controllers/business.controller.ts @@ -0,0 +1,15 @@ +import { getCatalogDto, getCollectionsDto } from '@api/dto/business.dto'; +import { InstanceDto } from '@api/dto/instance.dto'; +import { WAMonitoringService } from '@api/services/monitor.service'; + +export class BusinessController { + constructor(private readonly waMonitor: WAMonitoringService) {} + + public async fetchCatalog({ instanceName }: InstanceDto, data: getCatalogDto) { + return await this.waMonitor.waInstances[instanceName].fetchCatalog(instanceName, data); + } + + public async fetchCollections({ instanceName }: InstanceDto, data: getCollectionsDto) { + return await this.waMonitor.waInstances[instanceName].fetchCollections(instanceName, data); + } +} diff --git a/src/api/dto/business.dto.ts b/src/api/dto/business.dto.ts new file mode 100644 index 000000000..d29b3cf97 --- /dev/null +++ b/src/api/dto/business.dto.ts @@ -0,0 +1,14 @@ +export class NumberDto { + number: string; +} + +export class getCatalogDto { + number?: string; + limit?: number; + cursor?: string; +} + +export class getCollectionsDto { + number?: string; + limit?: number; +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 10feb7ce1..7d33e054c 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -1,3 +1,4 @@ +import { getCollectionsDto } from '@api/dto/business.dto'; import { OfferCallDto } from '@api/dto/call.dto'; import { ArchiveChatDto, @@ -91,6 +92,7 @@ import makeWASocket, { BufferedEventData, BufferJSON, CacheStore, + CatalogCollection, Chat, ConnectionState, Contact, @@ -100,6 +102,7 @@ import makeWASocket, { fetchLatestBaileysVersion, generateWAMessageFromContent, getAggregateVotesInPollMessage, + GetCatalogOptions, getContentType, getDevice, GroupMetadata, @@ -113,6 +116,7 @@ import makeWASocket, { MiscMessageGenerationOptions, ParticipantAction, prepareWAMessageMedia, + Product, proto, UserFacingSocketConfig, WABrowserDescription, @@ -1128,9 +1132,10 @@ export class BaileysStartupService extends ChannelStartupService { } } + const editedMessage = + received?.message?.protocolMessage || received?.message?.editedMessage?.message?.protocolMessage; + if (received.message?.protocolMessage?.editedMessage || received.message?.editedMessage?.message) { - const editedMessage = - received.message?.protocolMessage || received.message?.editedMessage?.message?.protocolMessage; if (editedMessage) { if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) this.chatwootService.eventWhatsapp( @@ -1161,6 +1166,17 @@ export class BaileysStartupService extends ChannelStartupService { await this.baileysCache.delete(received.key.id); } + // Cache to avoid duplicate messages + const messageKey = `${this.instance.id}_${received.key.id}`; + const cached = await this.baileysCache.get(messageKey); + + if (cached && !editedMessage) { + this.logger.info(`Message duplicated ignored: ${received.key.id}`); + continue; + } + + await this.baileysCache.set(messageKey, true, 30 * 60); + if ( (type !== 'notify' && type !== 'append') || received.message?.protocolMessage || @@ -1422,6 +1438,17 @@ export class BaileysStartupService extends ChannelStartupService { continue; } + const updateKey = `${this.instance.id}_${key.id}_${update.status}`; + + const cached = await this.baileysCache.get(updateKey); + + if (cached) { + this.logger.info(`Message duplicated ignored: ${key.id}`); + continue; + } + + await this.baileysCache.set(updateKey, true, 30 * 60); + if (status[update.status] === 'READ' && key.fromMe) { if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { this.chatwootService.eventWhatsapp( @@ -3573,6 +3600,18 @@ export class BaileysStartupService extends ChannelStartupService { status: 'DELETED', }, }); + const messageUpdate: any = { + messageId: message.id, + keyId: messageId, + remoteJid: response.key.remoteJid, + fromMe: response.key.fromMe, + participant: response.key?.remoteJid, + status: 'DELETED', + instanceId: this.instanceId, + }; + await this.prismaRepository.messageUpdate.create({ + data: messageUpdate, + }); } else { await this.prismaRepository.message.deleteMany({ where: { @@ -3899,13 +3938,72 @@ export class BaileysStartupService extends ChannelStartupService { } try { - return await this.client.sendMessage(jid, { + const response = await this.client.sendMessage(jid, { ...(options as any), edit: data.key, }); + if (response) { + const messageId = response.message?.protocolMessage?.key?.id; + if (messageId) { + let message = await this.prismaRepository.message.findFirst({ + where: { + key: { + path: ['id'], + equals: messageId, + }, + }, + }); + if (!message) throw new NotFoundException('Message not found'); + + if (!(message.key.valueOf() as any).fromMe) { + new BadRequestException('You cannot edit others messages'); + } + if ((message.key.valueOf() as any)?.deleted) { + new BadRequestException('You cannot edit deleted messages'); + } + + const updateMessage = this.prepareMessage({ ...response }); + message = await this.prismaRepository.message.update({ + where: { id: message.id }, + data: { + message: { + ...updateMessage?.message?.[updateMessage.messageType]?.editedMessage, + }, + status: 'EDITED', + }, + }); + const messageUpdate: any = { + messageId: message.id, + keyId: messageId, + remoteJid: response.key.remoteJid, + fromMe: response.key.fromMe, + participant: response.key?.remoteJid, + status: 'EDITED', + instanceId: this.instanceId, + }; + await this.prismaRepository.messageUpdate.create({ + data: messageUpdate, + }); + + this.sendDataWebhook(Events.MESSAGES_EDITED, { + id: message.id, + instanceId: message.instanceId, + key: message.key, + messageType: message.messageType, + status: 'EDITED', + source: message.source, + messageTimestamp: message.messageTimestamp, + pushName: message.pushName, + participant: message.participant, + message: message.message, + }); + } + } + + return response; } catch (error) { this.logger.error(error); - throw new BadRequestException(error.toString()); + throw error; } } @@ -4534,4 +4632,138 @@ export class BaileysStartupService extends ChannelStartupService { return response; } + + //Business Controller + public async fetchCatalog(instanceName: string, data: getCollectionsDto) { + const jid = data.number ? createJid(data.number) : this.client?.user?.id; + const limit = data.limit || 10; + const cursor = null; + + const onWhatsapp = (await this.whatsappNumber({ numbers: [jid] }))?.shift(); + + if (!onWhatsapp.exists) { + throw new BadRequestException(onWhatsapp); + } + + try { + const info = (await this.whatsappNumber({ numbers: [jid] }))?.shift(); + const business = await this.fetchBusinessProfile(info?.jid); + + let catalog = await this.getCatalog({ jid: info?.jid, limit, cursor }); + console.log(catalog); + let nextPageCursor = catalog.nextPageCursor; + let nextPageCursorJson = nextPageCursor ? JSON.parse(atob(nextPageCursor)) : null; + let pagination = nextPageCursorJson?.pagination_cursor + ? JSON.parse(atob(nextPageCursorJson.pagination_cursor)) + : null; + let fetcherHasMore = pagination?.fetcher_has_more === true ? true : false; + + let productsCatalog = catalog.products || []; + let countLoops = 0; + while (fetcherHasMore && countLoops < 4) { + catalog = await this.getCatalog({ jid: info?.jid, limit, cursor: nextPageCursor }); + nextPageCursor = catalog.nextPageCursor; + nextPageCursorJson = nextPageCursor ? JSON.parse(atob(nextPageCursor)) : null; + pagination = nextPageCursorJson?.pagination_cursor + ? JSON.parse(atob(nextPageCursorJson.pagination_cursor)) + : null; + fetcherHasMore = pagination?.fetcher_has_more === true ? true : false; + productsCatalog = [...productsCatalog, ...catalog.products]; + countLoops++; + } + + return { + wuid: info?.jid || jid, + numberExists: info?.exists, + isBusiness: business.isBusiness, + catalogLength: productsCatalog.length, + catalog: productsCatalog, + }; + } catch (error) { + console.log(error); + return { + wuid: jid, + name: null, + isBusiness: false, + }; + } + } + + public async getCatalog({ + jid, + limit, + cursor, + }: GetCatalogOptions): Promise<{ products: Product[]; nextPageCursor: string | undefined }> { + try { + jid = jid ? createJid(jid) : this.instance.wuid; + + const catalog = await this.client.getCatalog({ jid, limit: limit, cursor: cursor }); + + if (!catalog) { + return { + products: undefined, + nextPageCursor: undefined, + }; + } + + return catalog; + } catch (error) { + throw new InternalServerErrorException('Error getCatalog', error.toString()); + } + } + + public async fetchCollections(instanceName: string, data: getCollectionsDto) { + const jid = data.number ? createJid(data.number) : this.client?.user?.id; + const limit = data.limit <= 20 ? data.limit : 20; //(tem esse limite, não sei porque) + + const onWhatsapp = (await this.whatsappNumber({ numbers: [jid] }))?.shift(); + + if (!onWhatsapp.exists) { + throw new BadRequestException(onWhatsapp); + } + + try { + const info = (await this.whatsappNumber({ numbers: [jid] }))?.shift(); + const business = await this.fetchBusinessProfile(info?.jid); + const collections = await this.getCollections(info?.jid, limit); + + return { + wuid: info?.jid || jid, + name: info?.name, + numberExists: info?.exists, + isBusiness: business.isBusiness, + collectionsLength: collections?.length, + collections: collections, + }; + } catch (error) { + return { + wuid: jid, + name: null, + isBusiness: false, + }; + } + } + + public async getCollections(jid?: string | undefined, limit?: number): Promise { + try { + jid = jid ? createJid(jid) : this.instance.wuid; + + const result = await this.client.getCollections(jid, limit); + + if (!result) { + return [ + { + id: undefined, + name: undefined, + products: [], + status: undefined, + }, + ]; + } + + return result.collections; + } catch (error) { + throw new InternalServerErrorException('Error getCatalog', error.toString()); + } + } } diff --git a/src/api/routes/business.router.ts b/src/api/routes/business.router.ts new file mode 100644 index 000000000..1e510a4ff --- /dev/null +++ b/src/api/routes/business.router.ts @@ -0,0 +1,37 @@ +import { RouterBroker } from '@api/abstract/abstract.router'; +import { NumberDto } from '@api/dto/chat.dto'; +import { businessController } from '@api/server.module'; +import { catalogSchema, collectionsSchema } from '@validate/validate.schema'; +import { RequestHandler, Router } from 'express'; + +import { HttpStatus } from './index.router'; + +export class BusinessRouter extends RouterBroker { + constructor(...guards: RequestHandler[]) { + super(); + this.router + .post(this.routerPath('getCatalog'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: catalogSchema, + ClassRef: NumberDto, + execute: (instance, data) => businessController.fetchCatalog(instance, data), + }); + + return res.status(HttpStatus.OK).json(response); + }) + + .post(this.routerPath('getCollections'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: collectionsSchema, + ClassRef: NumberDto, + execute: (instance, data) => businessController.fetchCollections(instance, data), + }); + + return res.status(HttpStatus.OK).json(response); + }); + } + + public readonly router: Router = Router(); +} diff --git a/src/api/routes/chat.router.ts b/src/api/routes/chat.router.ts index 20126c1a5..5c556705e 100644 --- a/src/api/routes/chat.router.ts +++ b/src/api/routes/chat.router.ts @@ -207,7 +207,6 @@ export class ChatRouter extends RouterBroker { return res.status(HttpStatus.OK).json(response); }) - .post(this.routerPath('updateProfileName'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, diff --git a/src/api/routes/index.router.ts b/src/api/routes/index.router.ts index a4a7c0717..9c0ecd13c 100644 --- a/src/api/routes/index.router.ts +++ b/src/api/routes/index.router.ts @@ -11,6 +11,7 @@ import fs from 'fs'; import mimeTypes from 'mime-types'; import path from 'path'; +import { BusinessRouter } from './business.router'; import { CallRouter } from './call.router'; import { ChatRouter } from './chat.router'; import { GroupRouter } from './group.router'; @@ -82,6 +83,7 @@ router .use('/message', new MessageRouter(...guards).router) .use('/call', new CallRouter(...guards).router) .use('/chat', new ChatRouter(...guards).router) + .use('/business', new BusinessRouter(...guards).router) .use('/group', new GroupRouter(...guards).router) .use('/template', new TemplateRouter(configService, ...guards).router) .use('/settings', new SettingsRouter(...guards).router) diff --git a/src/api/server.module.ts b/src/api/server.module.ts index 49fc56952..9d8df8a67 100644 --- a/src/api/server.module.ts +++ b/src/api/server.module.ts @@ -3,6 +3,7 @@ import { Chatwoot, configService, ProviderSession } from '@config/env.config'; import { eventEmitter } from '@config/event.config'; import { Logger } from '@config/logger.config'; +import { BusinessController } from './controllers/business.controller'; import { CallController } from './controllers/call.controller'; import { ChatController } from './controllers/chat.controller'; import { GroupController } from './controllers/group.controller'; @@ -98,6 +99,7 @@ export const instanceController = new InstanceController( export const sendMessageController = new SendMessageController(waMonitor); export const callController = new CallController(waMonitor); export const chatController = new ChatController(waMonitor); +export const businessController = new BusinessController(waMonitor); export const groupController = new GroupController(waMonitor); export const labelController = new LabelController(waMonitor); diff --git a/src/config/env.config.ts b/src/config/env.config.ts index 78ca891cd..8ba1fa833 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -97,7 +97,7 @@ export type Rabbitmq = { EXCHANGE_NAME: string; GLOBAL_ENABLED: boolean; EVENTS: EventsRabbitmq; - PREFIX_KEY: string; + PREFIX_KEY?: string; }; export type Sqs = { @@ -356,7 +356,7 @@ export class ConfigService { RABBITMQ: { ENABLED: process.env?.RABBITMQ_ENABLED === 'true', GLOBAL_ENABLED: process.env?.RABBITMQ_GLOBAL_ENABLED === 'true', - PREFIX_KEY: process.env?.RABBITMQ_PREFIX_KEY || 'evolution', + PREFIX_KEY: process.env?.RABBITMQ_PREFIX_KEY, EXCHANGE_NAME: process.env?.RABBITMQ_EXCHANGE_NAME || 'evolution_exchange', URI: process.env.RABBITMQ_URI || '', EVENTS: { diff --git a/src/validate/business.schema.ts b/src/validate/business.schema.ts new file mode 100644 index 000000000..91ad17b20 --- /dev/null +++ b/src/validate/business.schema.ts @@ -0,0 +1,17 @@ +import { JSONSchema7 } from 'json-schema'; + +export const catalogSchema: JSONSchema7 = { + type: 'object', + properties: { + number: { type: 'string' }, + limit: { type: 'number' }, + }, +}; + +export const collectionsSchema: JSONSchema7 = { + type: 'object', + properties: { + number: { type: 'string' }, + limit: { type: 'number' }, + }, +}; diff --git a/src/validate/validate.schema.ts b/src/validate/validate.schema.ts index cf3d7828c..4577eae3f 100644 --- a/src/validate/validate.schema.ts +++ b/src/validate/validate.schema.ts @@ -1,4 +1,5 @@ // Integrations Schema +export * from './business.schema'; export * from './chat.schema'; export * from './group.schema'; export * from './instance.schema';