Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,18 @@ WA_BUSINESS_URL=https://graph.facebook.com
WA_BUSINESS_VERSION=v20.0
WA_BUSINESS_LANGUAGE=en_US

# EvoHub channel — proxy transparente da Meta Cloud API (canal adicional)
# URL = host do hub (control-plane em {URL}/api/v1, data-plane em {URL}/meta)
EVOLUTION_HUB_URL=https://api.evohub.ai
# API-key global do deployment (control-plane: provisiona/lista/conecta canais)
EVOLUTION_HUB_API_KEY=
# Secret do webhook (Fase 2: register-with-own-secret → validate HMAC X-Hub-Signature-256)
EVOLUTION_HUB_WEBHOOK_SECRET=
# Token do GET verify challenge (paridade defensiva com o canal Meta)
EVOLUTION_HUB_TOKEN_WEBHOOK=evolution
# Frontend do hub — usado para construir o public_link do fluxo criar-novo
EVOLUTION_HUB_FRONTEND_URL=https://app.evohub.evolutionfoundation.com.br

# Global Webhook Settings
# Each instance's Webhook URL and events will be requested at the time it is created
WEBHOOK_GLOBAL_ENABLED=false
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ lerna-debug.log*
.tool-versions

/prisma/migrations/*

_evo*
1 change: 0 additions & 1 deletion manager/dist/assets/index-C-JyjMiq.css

This file was deleted.

1 change: 1 addition & 0 deletions manager/dist/assets/index-CCzFRRHA.css

Large diffs are not rendered by default.

584 changes: 0 additions & 584 deletions manager/dist/assets/index-pLdnG_0T.js

This file was deleted.

67 changes: 67 additions & 0 deletions manager/dist/assets/index-xAg89uDr.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions manager/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<link rel="icon" type="image/png" href="https://evolution-api.com/files/evo/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Evolution Manager</title>
<script type="module" crossorigin src="/assets/index-pLdnG_0T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-JyjMiq.css">
<script type="module" crossorigin src="/assets/index-xAg89uDr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CCzFRRHA.css">
</head>
<body>
<div id="root"></div>
Expand Down
18 changes: 17 additions & 1 deletion src/api/integrations/channel/channel.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ConfigService } from '@config/env.config';
import { BadRequestException } from '@exceptions';
import EventEmitter2 from 'eventemitter2';

import { EvoHubStartupService } from './evohub/evohub.startup.service';
import { EvolutionStartupService } from './evolution/evolution.channel.service';
import { BusinessStartupService } from './meta/whatsapp.business.service';
import { BaileysStartupService } from './whatsapp/whatsapp.baileys.service';
Expand Down Expand Up @@ -52,7 +53,10 @@ export class ChannelController {
}

public init(instanceData: InstanceDto, data: ChannelDataType) {
if (!instanceData.token && instanceData.integration === Integration.WHATSAPP_BUSINESS) {
if (
!instanceData.token &&
(instanceData.integration === Integration.WHATSAPP_BUSINESS || instanceData.integration === Integration.EVOHUB)
) {
throw new BadRequestException('token is required');
}

Expand All @@ -68,6 +72,18 @@ export class ChannelController {
);
}

if (instanceData.integration === Integration.EVOHUB) {
return new EvoHubStartupService(
data.configService,
data.eventEmitter,
data.prismaRepository,
data.cache,
data.chatwootCache,
data.baileysCache,
data.providerFiles,
);
}

if (instanceData.integration === Integration.EVOLUTION) {
return new EvolutionStartupService(
data.configService,
Expand Down
4 changes: 4 additions & 0 deletions src/api/integrations/channel/channel.router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Router } from 'express';

import { EvoHubControlPlaneRouter } from './evohub/evohub.controlplane.router';
import { EvoHubRouter } from './evohub/evohub.router';
import { EvolutionRouter } from './evolution/evolution.router';
import { MetaRouter } from './meta/meta.router';
import { BaileysRouter } from './whatsapp/baileys.router';
Expand All @@ -12,6 +14,8 @@ export class ChannelRouter {

this.router.use('/', new EvolutionRouter(configService).router);
this.router.use('/', new MetaRouter(configService).router);
this.router.use('/', new EvoHubRouter(configService).router);
this.router.use('/', new EvoHubControlPlaneRouter(configService).router);
this.router.use('/baileys', new BaileysRouter(...guards).router);
}
}
209 changes: 209 additions & 0 deletions src/api/integrations/channel/evohub/evohub.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { ConfigService, EvolutionHub } from '@config/env.config';
import { Logger } from '@config/logger.config';
import axios, { AxiosInstance } from 'axios';

// ---- Tipos do contrato do hub (espelham evolutionHubService.ts do frontend) ----

export interface HubPlan {
id: string;
slug: string;
name: string;
allow_own_meta_app: boolean;
allow_shared_meta_app: boolean;
max_channels_total: number | null;
max_webhooks: number | null;
max_byo_credentials: number | null;
}

export interface MetaAppOptionCred {
id: string;
app_id: string;
name: string;
}

export interface MetaAppOptions {
allowed_modes: ('shared' | 'byo')[];
shared_configured: boolean;
shared_allowed_by_plan: boolean;
byo_allowed_by_plan: boolean;
byo_credentials: MetaAppOptionCred[];
}

// meta_connection embutido no canal completo (GET /api/v1/channels/:id)
// — channel.go:81 + 186-188 (phone_number_id, waba_id em meta_connection)
export interface HubMetaConnection {
phone_number_id?: string | null;
waba_id?: string | null;
business_id?: string | null;
connection_mode?: 'shared' | 'byo';
}

export interface HubChannel {
id: string;
name: string;
type: 'whatsapp' | 'facebook' | 'instagram';
status: string;
channel_credentials_id?: string | null;
created_at?: string;
// Presentes no GET /api/v1/channels/:id (canal COMPLETO via ToResponse()):
// token = channel.go:135; meta_connection = channel.go:81/186-188.
// O GET de lista (/channels) pode NÃO trazer estes campos — só o GET por id traz.
token?: string | null;
meta_connection?: HubMetaConnection | null;
}

// ---- Criar-novo (POST /api/v1/channels) ----
export interface HubProvisionRequest {
name: string;
type: 'whatsapp' | 'facebook' | 'instagram';
channel_credentials_id?: string | null; // set => byo; omitido => shared
webhook_url?: string; // se setado, o hub registra o webhook (single-shot)
}

export interface HubProvisionResponse {
channel_token: string; // channel.token devolvido pelo POST /api/v1/channels
public_link: string; // CONSTRUÍDO: `${FRONTEND_URL}/connect/${channel_token}`
hub_channel_id: string;
}

// POST /api/v1/channels/:id/meta-connect — contrato exato do MetaConnectRequest (Go)
export interface MetaConnectRequest {
phone_number_id: string;
waba_id: string;
business_id: string;
auth_code: string;
connection_mode: 'shared' | 'byo';
}

export interface MetaConnectResponse {
success: boolean;
message: string;
data: {
channel_id: string;
connection_mode: string;
waba_name: string;
business_name: string;
phone_numbers: number;
};
}

/**
* EvoHubClient — cliente HTTP do control-plane do hub. Usa a API-key global
* (EVOLUTION_HUB_API_KEY) como Bearer, base path `/api/v1`. A API-key NUNCA é logada
* nem exposta em respostas; o channel_token resolvido no link-existing nunca trafega
* para o front.
*/
export class EvoHubClient {
private readonly logger = new Logger('EvoHubClient');
private readonly http: AxiosInstance;

constructor(private readonly configService: ConfigService) {
const cfg = this.configService.get<EvolutionHub>('EVOLUTION_HUB');
this.http = axios.create({
baseURL: `${cfg.URL}/api/v1`,
headers: {
Authorization: `Bearer ${cfg.API_KEY}`,
'Content-Type': 'application/json',
},
});
}

async getPlan(): Promise<HubPlan> {
// Endpoint self-service do hub: GET /api/v1/me/plan (GetMyPlan). NÃO usar /plan
// (esse é o admin GET por id e exige UUID param).
const { data } = await this.http.get('/me/plan');
return data;
}

async getMetaAppOptions(): Promise<MetaAppOptions> {
// GET /api/v1/me/meta-app-options (credentials/handler.go:37).
const { data } = await this.http.get('/me/meta-app-options');
return data;
}

async listChannels(type?: 'whatsapp' | 'facebook' | 'instagram'): Promise<HubChannel[]> {
const { data } = await this.http.get('/channels', { params: type ? { type } : {} });
// O hub devolve { channels: [...], count } (channel_handler.go GetChannels).
// Tolera também array nu ou { data: [...] } por robustez.
return this.normalizeChannelList(data);
}

// Normaliza a resposta de lista do hub para HubChannel[] (channels|data|array nu).
private normalizeChannelList(data: any): HubChannel[] {
if (Array.isArray(data)) return data;
if (Array.isArray(data?.channels)) return data.channels;
if (Array.isArray(data?.data)) return data.data;
return [];
}

/**
* Canal COMPLETO por id (contrato §2/§4A): GET /api/v1/channels/:id devolve
* `token` + `meta_connection.phone_number_id` (channel_handler.go:185-202 →
* ToResponse()). Base do link-existing — o evolution-api extrai esses campos
* server-side; o front NUNCA vê o token.
*/
async getChannel(id: string): Promise<HubChannel> {
const { data } = await this.http.get(`/channels/${id}`);

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
return data;
}

/**
* Canais disponíveis para vincular = lista do hub (GET /api/v1/channels). A
* filtragem dos já-vinculados é feita na rota /evohub/available-channels.
*/
async getAvailableChannels(type?: 'whatsapp' | 'facebook' | 'instagram'): Promise<HubChannel[]> {
return this.listChannels(type);
}

// ---- Fase 2 ----

/**
* Cria um canal novo no hub (POST /api/v1/channels) e CONSTRÓI o public_link a
* partir do channel.token devolvido (contrato §3 — NÃO é campo do hub):
* `${FRONTEND_URL}/connect/${channel_token}`.
*
* Request real do hub (CreateChannelRequest): { name, type, webhook_url?,
* webhook_secret? }. Quando webhook_url é enviado, o hub registra o webhook
* E retorna a resposta ENVELOPADA em { channel, webhook_id }; sem webhook a
* resposta é o ChannelResponse plano. Normalizamos os dois.
*
* Webhook = recipe register-with-own-secret (contrato §7): registramos com o
* nosso EVOLUTION_HUB_WEBHOOK_SECRET, então o hub assina os webhooks com ele e
* a validação HMAC no inbound bate.
*/
async provisionChannel(req: HubProvisionRequest): Promise<HubProvisionResponse> {
const cfg = this.configService.get<EvolutionHub>('EVOLUTION_HUB');
const body: Record<string, any> = {
name: req.name,
type: req.type,
};
if (req.channel_credentials_id) body.channel_credentials_id = req.channel_credentials_id;
// Registra o webhook do evolution-api junto da criação (single-shot) para
// receber mensagens inbound. webhook_secret = nosso secret (register-with-own-secret).
if (req.webhook_url) {
body.webhook_url = req.webhook_url;
if (cfg.WEBHOOK_SECRET) body.webhook_secret = cfg.WEBHOOK_SECRET;
}

const { data } = await this.http.post('/channels', body);
// Normaliza: { channel: {...}, webhook_id } (com webhook) OU ChannelResponse plano.
const channel = data?.channel ?? data;
const channelToken: string = channel.token;
const hubChannelId: string = channel.id;

return {
channel_token: channelToken,
public_link: `${cfg.FRONTEND_URL}/connect/${channelToken}`,
hub_channel_id: hubChannelId,
};
}

/**
* (FASE 2) Conecta o canal no hub. connection_mode='shared' usa o Meta App da
* Evolution; 'byo' exige channel_credentials no hub.
*/
async connectToMeta(channelId: string, req: MetaConnectRequest): Promise<MetaConnectResponse> {
const { data } = await this.http.post(`/channels/${channelId}/meta-connect`, req);

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
return data;
}
}
63 changes: 63 additions & 0 deletions src/api/integrations/channel/evohub/evohub.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { MetaController } from '@api/integrations/channel/meta/meta.controller';
import { PrismaRepository } from '@api/repository/repository.service';
import { WAMonitoringService } from '@api/services/monitor.service';
import { ConfigService, EvolutionHub } from '@config/env.config';
import { Logger } from '@config/logger.config';
import * as crypto from 'crypto';

/**
* EvoHubController — reusa o parser de webhook do Meta verbatim (o hub forwarda o
* envelope Meta inalterado) e adiciona a validação de HMAC do header
* X-Hub-Signature-256 sobre o RAW body.
*
* Fase 1: soft-mode — se EVOLUTION_HUB_WEBHOOK_SECRET não estiver setado, aceita o
* webhook sem validar (o hub já valida a assinatura da Meta internamente). Fase 2:
* registrar o webhook no hub com o próprio EVOLUTION_HUB_WEBHOOK_SECRET (o hub assina
* com ele) e validar contra ele — recipe "register-with-own-secret".
*/
export class EvoHubController extends MetaController {
private readonly hubLogger = new Logger('EvoHubController');

constructor(
prismaRepository: PrismaRepository,
waMonitor: WAMonitoringService,
private readonly configService: ConfigService,
) {
super(prismaRepository, waMonitor);
}

/**
* Valida o header X-Hub-Signature-256 (`sha256=<hex>`) contra o
* EVOLUTION_HUB_WEBHOOK_SECRET, computando HMAC-SHA256 sobre o RAW body.
* Comparação constant-time. Secret vazio → soft mode (aceita).
*/
public verifyHmac(rawBody: Buffer | undefined, signatureHeader: string | undefined): boolean {
const secret = this.configService.get<EvolutionHub>('EVOLUTION_HUB').WEBHOOK_SECRET;

if (!secret) {
this.hubLogger.warn('EVOLUTION_HUB_WEBHOOK_SECRET not set — accepting webhook unsigned (soft mode)');
return true;
}

if (!signatureHeader || !signatureHeader.startsWith('sha256=')) {
this.hubLogger.error('EvoHub webhook -> missing or malformed X-Hub-Signature-256');
return false;
}

if (!rawBody) {
this.hubLogger.error('EvoHub webhook -> rawBody unavailable (verify callback not wired in main.ts?)');
return false;
}

const mac = crypto.createHmac('sha256', secret);
mac.update(rawBody);
const expected = `sha256=${mac.digest('hex')}`;

const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}
}
Loading
Loading