Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -258,11 +258,19 @@ WA_BUSINESS_VERSION=v20.0
WA_BUSINESS_LANGUAGE=en_US

# EvoHub channel — proxy transparente da Meta Cloud API (canal adicional)
# IMPORTANTE: o webhook inbound do hub é entregue em {SERVER_URL}/webhook/evohub —
# o SERVER_URL (topo deste arquivo) precisa ser alcançável PELO hub (local exige
# túnel ou, com hub em Docker, http://host.docker.internal:8080).
# URL = host do hub (control-plane em {URL}/api/v1, data-plane em {URL}/meta)
# SaaS: https://api.evohub.ai | hub local via docker-compose: http://localhost:8086
EVOLUTION_HUB_URL=https://api.evohub.ai
# API-key global do deployment (control-plane: provisiona/lista/conecta canais)
# OBRIGATÓRIA para as rotas /evohub/* — chave criada no hub, formato evh_pk_...
EVOLUTION_HUB_API_KEY=
# Secret do webhook (Fase 2: register-with-own-secret → validate HMAC X-Hub-Signature-256)
# Recomendado: string aleatória forte; é enviada ao registrar o webhook no hub
# (provision/link-existing) e validada no POST /webhook/evohub. Vazio = soft mode
# (aceita webhook sem assinatura).
EVOLUTION_HUB_WEBHOOK_SECRET=
# Token do GET verify challenge (paridade defensiva com o canal Meta)
EVOLUTION_HUB_TOKEN_WEBHOOK=evolution
Expand Down
68 changes: 68 additions & 0 deletions src/api/integrations/channel/evohub/evohub.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@
meta_connection?: HubMetaConnection | null;
}

// Webhook do hub (WebhookResponse — webhook.go:116). Só os campos que usamos.
export interface HubWebhookInfo {
id: string;
name?: string;
url: string;
status?: string;
all_channels?: boolean;
}

// ---- Criar-novo (POST /api/v1/channels) ----
export interface HubProvisionRequest {
name: string;
Expand Down Expand Up @@ -143,7 +152,7 @@
* 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;
}

Expand All @@ -155,6 +164,65 @@
return this.listChannels(type);
}

// ---- Webhooks (inbound do hub -> evolution-api) ----

/** Webhooks já ASSOCIADOS ao canal: GET /api/v1/channels/:id/webhooks → { webhooks, count }. */
async listChannelWebhooks(channelId: string): Promise<HubWebhookInfo[]> {
const { data } = await this.http.get(`/channels/${channelId}/webhooks`);

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return this.normalizeWebhookList(data);
}

/** Todos os webhooks do usuário da API-key: GET /api/v1/webhooks. */
async listWebhooks(): Promise<HubWebhookInfo[]> {
const { data } = await this.http.get('/webhooks');
return this.normalizeWebhookList(data);
}

private normalizeWebhookList(data: any): HubWebhookInfo[] {
if (Array.isArray(data)) return data;
if (Array.isArray(data?.webhooks)) return data.webhooks;
if (Array.isArray(data?.data)) return data.data;
return [];
}

/** POST /api/v1/webhooks/:id/associate — associa um webhook existente ao canal. */
async associateWebhook(webhookId: string, channelId: string): Promise<void> {
await this.http.post(`/webhooks/${webhookId}/associate`, { channel_id: channelId });
}

/**
* Garante (idempotente) que o canal tem um webhook ativo apontando para
* `webhookUrl` — o caminho single-shot do provision não existe no link-existing,
* e sem webhook o canal envia mas nunca RECEBE mensagens. Ordem:
* 1) já associado ao canal com a mesma URL → no-op;
* 2) webhook do usuário com a mesma URL → associa (evita duplicar; um webhook
* all_channels com a URL já cobre o canal, também no-op);
* 3) cria novo com `channels: [channelId]` (single-shot) e `events: []`
* (vazio = TODOS os eventos — webhook_service.go:98). Secret = recipe
* register-with-own-secret, igual ao provision.
*/
async ensureChannelWebhook(channelId: string, webhookUrl: string): Promise<void> {
const associated = await this.listChannelWebhooks(channelId);
if (associated.some((w) => w.url === webhookUrl && w.status !== 'inactive')) return;

const all = await this.listWebhooks();
const existing = all.find((w) => w.url === webhookUrl && w.status !== 'inactive');
if (existing) {
if (!existing.all_channels) await this.associateWebhook(existing.id, channelId);
return;
}

const cfg = this.configService.get<EvolutionHub>('EVOLUTION_HUB');
const body: Record<string, any> = {
name: 'evolution-api inbound',
url: webhookUrl,
events: [],
channels: [channelId],
};
if (cfg.WEBHOOK_SECRET) body.secret = cfg.WEBHOOK_SECRET;
await this.http.post('/webhooks', body);
}

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

/**
Expand Down Expand Up @@ -203,7 +271,7 @@
* 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,22 @@ export class EvoHubControlPlaneRouter extends RouterBroker {
return res.status(422).json({ error: 'hub channel missing token or phone_number_id' });
}

// 2) cria a Instance EVOHUB pelo caminho padrão, com o token JÁ resolvido
// 2) garante o webhook inbound no hub ANTES de criar a Instance (o provision
// registra via single-shot; sem isso o canal vinculado envia mas nunca
// recebe). Ordem proposital: se falhar, nada foi criado e o retry é seguro.
const serverUrl = configService.get<HttpServer>('SERVER').URL;
if (serverUrl) {
try {
await evoHubClient.ensureChannelWebhook(hub_channel_id, `${serverUrl}/webhook/evohub`);
} catch (e) {
return res.status(502).json({
error: 'failed to register inbound webhook on hub',
detail: e?.response?.data ?? e?.message,
});
}
}

// 3) cria a Instance EVOHUB pelo caminho padrão, com o token JÁ resolvido
// (flui pelo channel.controller.init() guard sem relaxá-lo — contrato §5).
const created = await instanceController.createInstance({
instanceName: (req.body.instanceName as string) || `evohub-${phoneNumberId}`,
Expand Down
Loading