feat(n8n): reconstroi o fluxo em cima do Webhook do Canal (resolve imagem)

O 'Webhook do Canal' do Z-PRO (fora do ChatFlow, opcao 'Mensagens
recebidas') manda o payload completo da mensagem, incluindo msg.image.url
para fotos -- resolve a limitacao que bloqueava imagem. Novo endpoint
orcamento-exames-canal-zpro substitui o antigo orcamento-exames-zpro
(removido). Download da imagem autenticado com Bearer usando o bmToken do
proprio payload. Volta ao envio assincrono via sub-workflow enviar_texto,
ja que esse webhook e uma notificacao (nao e capturado/reenviado pelo
Z-PRO como no ChatFlow). Prompt volta a aceitar foto normalmente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eric
2026-08-21 10:16:09 -03:00
co-authored by Claude Sonnet 5
parent fd77afad61
commit deeefe2b40
2 changed files with 182 additions and 86 deletions
@@ -281,15 +281,63 @@ ferramenta nesse ponto. Corrigido o prompt do `AI Agent` pra não prometer
mais "mande uma foto" ao cliente (antes disso ele pedia foto mesmo sem mais "mande uma foto" ao cliente (antes disso ele pedia foto mesmo sem
nunca conseguir processar nenhuma). nunca conseguir processar nenhuma).
### Descoberta 5: "Webhook do Canal" resolve a imagem (solução final)
O Z-PRO tem, nas configurações do canal, uma opção **"Webhook do Canal"**
independente do ChatFlow: `Habilitar Webhook do Canal` + `URL de destino` +
toggle `Mensagens recebidas` (dispara a cada mensagem recebida pelo canal,
"em paralelo ao webhook geral do tenant"). Payload real capturado:
```json
{
"method": "message",
"msg": {
"from": "5575...",
"id": "wamid....",
"timestamp": "...",
"type": "image",
"image": { "mime_type": "image/jpeg", "id": "...", "url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?..." }
},
"ticket": {
"protocol": "...", "id": 469774, "status": "pending",
"contact": { "name": "...", "number": "..." },
"whatsapp": { "bmToken": "EAA..." }
}
}
```
Pra mensagem de texto, `msg` vem como `{ "type": "text", "text": { "body": "..." } }`
em vez de `image`. O workflow foi reconstruído em cima desse webhook:
- Novo endpoint: `orcamento-exames-canal-zpro` (path completamente separado
do antigo `orcamento-exames-zpro`, que foi removido).
- Campos extraídos: `sessionId` (`ticket.protocol`), `telefone` (`msg.from`),
`nomeContato` (`ticket.contact.name`), `entrada` (`msg.text.body`),
`imagemUrl` (`msg.image.url`), `tipoMidia` (`msg.type`), `mediaToken`
(`ticket.whatsapp.bmToken`).
- A URL da imagem é do WhatsApp/Meta (`lookaside.fbsbx.com`) e exige
autenticação — o node "Baixar imagem" manda `Authorization: Bearer
{{ mediaToken }}` usando o `bmToken` do próprio payload.
- Como esse webhook é uma notificação assíncrona (não é capturado e
reenviado pelo Z-PRO como no mecanismo do ChatFlow), o envio da resposta
voltou a ser feito via API, reaproveitando o sub-workflow
`enviar_texto` (mesma lógica da primeira versão do desenho).
- **Importante:** esse webhook dispara em paralelo a qualquer coisa que o
ChatFlow também dispare para a mesma mensagem. Por isso as interações
"Webhook Avançado" e "Mensagem" dos passos `Menu Orçamento` / `Menu
Orçamento 2` no ChatFlow precisam ser removidas (ação do usuário, fora do
n8n) — senão o cliente recebe duas respostas pra cada mensagem.
- O prompt do `AI Agent` voltou a aceitar/pedir foto normalmente, já que a
imagem agora chega de verdade.
### Ainda em aberto ### Ainda em aberto
Falta o usuário confirmar se existe, fora do construtor de ChatFlow (em - Confirmar, depois de remover os passos antigos do ChatFlow, que não há
Configurações/Integrações do canal ou da empresa), um webhook de propósito mais resposta duplicada.
geral que dispare para toda mensagem recebida no canal — mecanismo - Validar ponta a ponta com uma foto real de pedido de exames (a extração
diferente do "Webhook Avançado" dentro de um ChatFlow. Se não existir, do `imagemUrl`/`mediaToken` foi validada com uma imagem de teste, mas o
verificar com o suporte do Z-PRO se há algum outro caminho suportado pela fluxo completo — download autenticado + IA de visão — ainda não foi
plataforma. Até lá, o workflow funciona **somente com texto** — o cliente testado de ponta a ponta após esta reconstrução).
digita os nomes dos exames, e a IA monta o orçamento normalmente.
## Fora de escopo (por enquanto) ## Fora de escopo (por enquanto)
+125 -77
View File
@@ -4,21 +4,21 @@
"active": true, "active": true,
"nodes": [ "nodes": [
{ {
"parameters": { "id": "webhook-canal-zpro",
"httpMethod": "POST", "name": "Webhook - Canal Z-PRO",
"path": "orcamento-exames-zpro",
"responseMode": "responseNode",
"options": {}
},
"id": "webhook-orcamento",
"name": "Webhook - Foto Exame",
"type": "n8n-nodes-base.webhook", "type": "n8n-nodes-base.webhook",
"typeVersion": 2.1, "typeVersion": 2.1,
"position": [ "position": [
-608, -608,
0 320
], ],
"webhookId": "5ea5f89e-7351-4d45-a881-0055009423a0", "parameters": {
"httpMethod": "POST",
"path": "orcamento-exames-canal-zpro",
"responseMode": "onReceived",
"options": {}
},
"webhookId": "a00a2a36-f7ec-4e8e-a98f-93d3f93dfe45",
"onError": "continueRegularOutput" "onError": "continueRegularOutput"
}, },
{ {
@@ -29,37 +29,43 @@
"id": "campo-sessionid", "id": "campo-sessionid",
"name": "sessionId", "name": "sessionId",
"type": "string", "type": "string",
"value": "={{ $json.body.protocol }}" "value": "={{ $json.body.ticket.protocol }}"
}, },
{ {
"id": "campo-telefone", "id": "campo-telefone",
"name": "telefone", "name": "telefone",
"type": "string", "type": "string",
"value": "={{ $json.body.number }}" "value": "={{ $json.body.msg.from }}"
}, },
{ {
"id": "campo-nomecontato", "id": "campo-nomecontato",
"name": "nomeContato", "name": "nomeContato",
"type": "string", "type": "string",
"value": "={{ $json.body.contactName }}" "value": "={{ $json.body.ticket.contact.name }}"
}, },
{ {
"id": "campo-entrada", "id": "campo-entrada",
"name": "entrada", "name": "entrada",
"type": "string", "type": "string",
"value": "={{ String($json.body.entrada || $json.body.text || '').trim() }}" "value": "={{ ($json.body.msg.text && $json.body.msg.text.body) || '' }}"
}, },
{ {
"id": "campo-imagemurl", "id": "campo-imagemurl",
"name": "imagemUrl", "name": "imagemUrl",
"type": "string", "type": "string",
"value": "={{ $json.body.mediaUrl || $json.body.fileUrl || $json.body.file || '' }}" "value": "={{ ($json.body.msg.image && $json.body.msg.image.url) || '' }}"
}, },
{ {
"id": "campo-tipomidia", "id": "campo-tipomidia",
"name": "tipoMidia", "name": "tipoMidia",
"type": "string", "type": "string",
"value": "={{ $json.body.mediaType || $json.body.type || '' }}" "value": "={{ $json.body.msg.type || '' }}"
},
{
"id": "campo-mediatoken",
"name": "mediaToken",
"type": "string",
"value": "={{ ($json.body.ticket.whatsapp && $json.body.ticket.whatsapp.bmToken) || '' }}"
} }
] ]
}, },
@@ -118,6 +124,15 @@
} }
}, },
"timeout": 20000 "timeout": 20000
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.mediaToken }}"
}
]
} }
}, },
"id": "baixar-imagem", "id": "baixar-imagem",
@@ -158,9 +173,9 @@
{ {
"parameters": { "parameters": {
"promptType": "define", "promptType": "define",
"text": "={{ $('Extrair campos do webhook').item.json.entrada || 'O cliente mandou algo que não chegou como texto (provavelmente uma foto ou áudio, que ainda não conseguimos processar). Peça pra ele digitar os nomes dos exames.' }}", "text": "={{ $('Extrair campos do webhook').item.json.entrada || 'O cliente enviou uma imagem. Veja a imagem anexada, que é o pedido médico dele.' }}",
"options": { "options": {
"systemMessage": "Você é o assistente de orçamentos de exames de uma clínica, atendendo automaticamente pelo WhatsApp, numa conversa com várias mensagens (não só uma).\n\nIMPORTANTE (limitação técnica atual): você NUNCA recebe fotos/imagens nesta conversa, mesmo que o cliente mande uma — só texto chega até você. Por isso, NUNCA peça pro cliente mandar foto ou imagem do pedido. Se o cliente mandar uma foto (você vai perceber isso porque a mensagem dele chega vazia, sem texto), peça educadamente pra ele DIGITAR os nomes dos exames que estão no pedido médico, sem mencionar foto.\n\nTABELA DE EXAMES E VALORES (lista de exemplo — será substituída pela lista real depois):\n- Hemograma completo — R$ 35,00\n- Glicemia de jejum — R$ 20,00\n- Colesterol total e frações — R$ 45,00\n- Triglicérideos — R$ 25,00\n- TSH (hormônio tireoidiano) — R$ 40,00\n- Exame de urina (EAS) — R$ 30,00\n- Ultrassonografia abdominal — R$ 150,00\n- Raio-X de tórax — R$ 90,00\n- Eletrocardiograma (ECG) — R$ 80,00\n- Papanicolau — R$ 60,00\n\nCOMO CONVERSAR:\n1. O cliente pode digitar os nomes dos exames de uma vez ou aos poucos, em mensagens separadas — use também o histórico da conversa (mensagens anteriores) pra juntar as informações, não só a mensagem atual.\n2. Se o cliente ainda não digitou, nem agora nem antes nesta conversa, nenhum nome de exame reconhecível, NÃO invente orçamento — cumprimente rapidamente e peça pra ele digitar os nomes dos exames conforme o pedido médico.\n3. Assim que tiver pelo menos um exame identificável (considerando o texto atual E o histórico da conversa), identifique cada exame pedido.\n4. Para cada exame identificado que corresponde (mesmo com sinônimo, abreviação comum ou pequena diferença de grafia) a um item da tabela acima, inclua na resposta o nome do exame e o valor.\n5. Para exames identificados que NÃO correspondem a nenhum item da tabela, ou que você não conseguiu entender com confiança, liste-os separadamente avisando que um atendente vai confirmar o valor.\n6. Nunca deixe de orçar por causa de um item não reconhecido — sempre orçe o que for possível reconhecer.\n7. Calcule e mostre o valor total somando apenas os itens com preço confirmado na tabela.\n8. Responda sempre em português, tom cordial e objetivo, formatado para WhatsApp: texto simples, sem markdown (nada de ** ou #), pode usar emojis com moderação e quebras de linha para organizar.\n9. Sua resposta deve conter SOMENTE o texto a ser enviado ao cliente nesta mensagem — nada de comentários extras, explicações do seu raciocínio, ou texto fora da conversa com o cliente.", "systemMessage": "Você é o assistente de orçamentos de exames de uma clínica, atendendo automaticamente pelo WhatsApp, numa conversa com várias mensagens (não só uma).\n\nTABELA DE EXAMES E VALORES (lista de exemplo — será substituída pela lista real depois):\n- Hemograma completo — R$ 35,00\n- Glicemia de jejum — R$ 20,00\n- Colesterol total e frações — R$ 45,00\n- Triglicérideos — R$ 25,00\n- TSH (hormônio tireoidiano) — R$ 40,00\n- Exame de urina (EAS) — R$ 30,00\n- Ultrassonografia abdominal — R$ 150,00\n- Raio-X de tórax — R$ 90,00\n- Eletrocardiograma (ECG) — R$ 80,00\n- Papanicolau — R$ 60,00\n\nCOMO CONVERSAR:\n1. O cliente pode mandar uma foto do pedido médico (com ou sem legenda), digitar os nomes dos exames, ou fazer as duas coisas em mensagens separadas, em qualquer ordem — use também o histórico da conversa (mensagens anteriores) pra juntar as informações, não só a mensagem atual. Uma imagem anexada a esta mensagem (quando houver) é o pedido médico do cliente.\n2. Se o cliente ainda não mandou, nem agora nem antes nesta conversa, nenhuma foto de pedido nem nomes de exames, NÃO invente orçamento — cumprimente rapidamente e peça pra ele mandar a foto do pedido médico ou digitar os nomes dos exames.\n3. Assim que tiver pelo menos um exame identificável (considerando a imagem atual, o texto atual E o histórico da conversa), identifique cada exame pedido.\n4. Para cada exame identificado que corresponde (mesmo com sinônimo, abreviação comum ou pequena diferença de grafia) a um item da tabela acima, inclua na resposta o nome do exame e o valor.\n5. Para exames identificados que NÃO correspondem a nenhum item da tabela, ou que você não conseguiu ler/entender com confiança, liste-os separadamente avisando que um atendente vai confirmar o valor.\n6. Nunca deixe de orçar por causa de um item não reconhecido — sempre orçe o que for possível reconhecer.\n7. Calcule e mostre o valor total somando apenas os itens com preço confirmado na tabela.\n8. Responda sempre em português, tom cordial e objetivo, formatado para WhatsApp: texto simples, sem markdown (nada de ** ou #), pode usar emojis com moderação e quebras de linha para organizar.\n9. Sua resposta deve conter SOMENTE o texto a ser enviado ao cliente nesta mensagem — nada de comentários extras, explicações do seu raciocínio, ou texto fora da conversa com o cliente.",
"passthroughBinaryImages": true "passthroughBinaryImages": true
} }
}, },
@@ -179,10 +194,28 @@
"assignments": { "assignments": {
"assignments": [ "assignments": [
{ {
"id": "resp-output", "id": "resp-number",
"name": "output", "name": "number",
"type": "string",
"value": "={{ $('Extrair campos do webhook').item.json.telefone }}"
},
{
"id": "resp-body",
"name": "body",
"type": "string", "type": "string",
"value": "={{ $json.output ? $json.output : 'Recebemos sua solicitação de exames! Nossa equipe vai confirmar os valores e te retornar em breve.' }}" "value": "={{ $json.output ? $json.output : 'Recebemos sua solicitação de exames! Nossa equipe vai confirmar os valores e te retornar em breve.' }}"
},
{
"id": "resp-externalkey",
"name": "externalKey",
"type": "string",
"value": "={{ 'orcamento-' + $execution.id }}"
},
{
"id": "resp-isclosed",
"name": "isClosed",
"type": "boolean",
"value": false
} }
] ]
}, },
@@ -213,60 +246,75 @@
"name": "Simple Memory" "name": "Simple Memory"
}, },
{ {
"parameters": { "id": "enviar-orcamento-zpro",
"respondWith": "text", "name": "Enviar orçamento (Z-PRO)",
"responseBody": "={{ $json.output }}", "type": "n8n-nodes-base.executeWorkflow",
"options": {} "typeVersion": 1.3,
},
"id": "responder-webhook",
"name": "Responder Webhook (texto)",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [ "position": [
912, 900,
-128 -128
]
},
{
"id": "webhook-canal-zpro",
"name": "Webhook - Canal Z-PRO (diagnóstico)",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-608,
320
], ],
"parameters": { "parameters": {
"httpMethod": "POST", "workflowId": {
"path": "orcamento-exames-canal-zpro", "__rl": true,
"responseMode": "onReceived", "mode": "id",
"options": {} "value": "Y4ndG7GuIUrmVRxV",
} "cachedResultName": "MCP Server - Mensageria ZPRO - enviar_texto"
},
"workflowInputs": {
"mappingMode": "defineBelow",
"value": {
"number": "={{ $json.number }}",
"body": "={{ $json.body }}",
"externalKey": "={{ $json.externalKey }}",
"isClosed": "={{ $json.isClosed }}"
},
"matchingColumns": [],
"schema": [
{
"id": "number",
"displayName": "number",
"required": false,
"defaultMatch": false,
"display": true,
"canBeUsedToMatch": true,
"type": "string"
}, },
{ {
"id": "noop-canal-zpro", "id": "body",
"name": "Capturar payload (diagnóstico)", "displayName": "body",
"type": "n8n-nodes-base.noOp", "required": false,
"typeVersion": 1, "defaultMatch": false,
"position": [ "display": true,
-368, "canBeUsedToMatch": true,
320 "type": "string"
},
{
"id": "externalKey",
"displayName": "externalKey",
"required": false,
"defaultMatch": false,
"display": true,
"canBeUsedToMatch": true,
"type": "string"
},
{
"id": "isClosed",
"displayName": "isClosed",
"required": false,
"defaultMatch": false,
"display": true,
"canBeUsedToMatch": true,
"type": "boolean"
}
], ],
"parameters": {} "attemptToConvertTypes": false,
"convertFieldsToString": false
}
}
} }
], ],
"connections": { "connections": {
"Webhook - Foto Exame": {
"main": [
[
{
"node": "Extrair campos do webhook",
"type": "main",
"index": 0
}
]
]
},
"É mensagem de imagem?": { "É mensagem de imagem?": {
"main": [ "main": [
[ [
@@ -343,17 +391,6 @@
] ]
] ]
}, },
"Montar corpo da resposta": {
"main": [
[
{
"node": "Responder Webhook (texto)",
"type": "main",
"index": 0
}
]
]
},
"Extrair campos do webhook": { "Extrair campos do webhook": {
"main": [ "main": [
[ [
@@ -365,11 +402,22 @@
] ]
] ]
}, },
"Webhook - Canal Z-PRO (diagnóstico)": { "Webhook - Canal Z-PRO": {
"main": [ "main": [
[ [
{ {
"node": "Capturar payload (diagnóstico)", "node": "Extrair campos do webhook",
"type": "main",
"index": 0
}
]
]
},
"Montar corpo da resposta": {
"main": [
[
{
"node": "Enviar orçamento (Z-PRO)",
"type": "main", "type": "main",
"index": 0 "index": 0
} }