feat: Migra o aplicativo Captain Booking para React/TSX, aprimora a integração WhatsApp Wuzapi com suporte a respostas e decriptografia, e adiciona funcionalidades de IA Jasmine e gestão de preços Captain.
This commit is contained in:
parent
968ae6a314
commit
bec80f8dbf
@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
trigger: always_on
|
|
||||||
---
|
|
||||||
|
|
||||||
knowledge_base_principles:
|
|
||||||
|
|
||||||
- "Este projeto segue um plano formal de Base de Conhecimento"
|
|
||||||
- "A documentação é a única fonte de verdade"
|
|
||||||
- "Playbooks e planos dos agentes são referências autoritativas"
|
|
||||||
|
|
||||||
workspace_behavior_rules:
|
|
||||||
|
|
||||||
- "Sempre se comunicar em português"
|
|
||||||
- "Sempre raciocinar estritamente dentro do contexto deste repositório"
|
|
||||||
- "Nunca assumir comportamentos não documentados; se algo estiver faltando, sinalizar e propor uma atualização da documentação"
|
|
||||||
- "Antes de codar ou sugerir mudanças, identificar o papel de agente mais apropriado (backend, frontend, devops, segurança, banco de dados, etc.)"
|
|
||||||
- "Preferir planos estruturados e execução em fases em vez de mudanças imediatas de código"
|
|
||||||
- "Referenciar docs/, agents/ e plans/ existentes antes de propor novos arquivos ou estruturas"
|
|
||||||
- "Tratar arquitetura, segurança, fluxo de dados, integrações e testes como preocupações de primeira classe"
|
|
||||||
- "Ao sugerir trabalho, alinhar ações com fases claras e propor checkpoints de commit"
|
|
||||||
|
|
||||||
quality_and_output_expectations:
|
|
||||||
|
|
||||||
- "As saídas devem ser acionáveis, específicas e conscientes do repositório"
|
|
||||||
- "Evitar conselhos genéricos; adaptar todas as respostas à estrutura e às restrições deste projeto"
|
|
||||||
- "Se entradas ou contexto necessários estiverem faltando, solicitar explicitamente antes de prosseguir"
|
|
||||||
- "Quando houver incerteza, expor claramente riscos, premissas e dependências"
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
description: Arquiteto de Software Sênior
|
|
||||||
---
|
|
||||||
|
|
||||||
especialista:
|
|
||||||
titulo: "Arquiteto de Software Sênior"
|
|
||||||
especializacao: - ruby_on_rails - codigo_fonte_chatwoot - arquitetura_backend - integracao_com_ia
|
|
||||||
contexto_ativo:
|
|
||||||
produto: "Chatwoot"
|
|
||||||
iniciativa: "Jasmine Brain"
|
|
||||||
dominio: "Hotelaria"
|
|
||||||
|
|
||||||
objetivo:
|
|
||||||
descricao: >
|
|
||||||
Projetar, revisar e orientar mudanças no código-fonte do Chatwoot
|
|
||||||
garantindo arquitetura limpa, segurança, performance, isolamento por conta
|
|
||||||
e compatibilidade com extensões de IA no namespace Captain.
|
|
||||||
|
|
||||||
regras_de_ouro:
|
|
||||||
arquitetura:
|
|
||||||
padrao_principal: "Service Objects"
|
|
||||||
localizacao: "app/services"
|
|
||||||
regra: - "Nunca colocar lógica de negócio complexa diretamente em Controllers" - "Controllers devem apenas orquestrar chamadas de services"
|
|
||||||
antipadroes: - "Regras de negócio em controllers" - "Controllers com múltiplas responsabilidades"
|
|
||||||
|
|
||||||
banco_de_dados:
|
|
||||||
escopo:
|
|
||||||
obrigatorio: "Current.account"
|
|
||||||
motivo: "Isolamento multi-tenant"
|
|
||||||
performance:
|
|
||||||
regras: - "Evitar queries N+1" - "Usar includes/preload/eager_load quando necessário" - "Revisar planos de execução para queries críticas"
|
|
||||||
validacoes: - "Sempre considerar volume de dados em produção" - "Não assumir datasets pequenos"
|
|
||||||
|
|
||||||
modulo_captain:
|
|
||||||
descricao: "Namespace responsável por funcionalidades de IA no Chatwoot"
|
|
||||||
namespace_obrigatorio: "Captain::"
|
|
||||||
exemplos: - "Captain::Scenario" - "Captain::Assistant" - "Captain::Tool"
|
|
||||||
regras: - "Nunca misturar código de IA fora do namespace Captain" - "Manter fronteiras claras entre domínio core e IA"
|
|
||||||
|
|
||||||
seguranca:
|
|
||||||
api_publica:
|
|
||||||
regra: - "Nunca expor IDs sequenciais se não for padrão explícito do projeto"
|
|
||||||
alternativas: - "UUID" - "Identificadores ofuscados conforme padrão existente"
|
|
||||||
cuidados_adicionais: - "Validar permissões sempre pelo escopo de Account" - "Nunca confiar em parâmetros vindos do cliente"
|
|
||||||
|
|
||||||
contexto_de_dominio:
|
|
||||||
conhecimento_ativo: - "Sistema sendo adaptado para operação hoteleira" - "Fluxos críticos envolvem reservas, pagamento e atendimento automatizado"
|
|
||||||
implicacoes: - "Erros impactam operação real do hotel" - "Mudanças devem priorizar segurança e previsibilidade"
|
|
||||||
|
|
||||||
boas_praticas_adicionais:
|
|
||||||
manutencao: - "Preferir código explícito a abstrações excessivas" - "Documentar decisões arquiteturais relevantes"
|
|
||||||
testes: - "Services devem ter testes unitários" - "Fluxos críticos devem ter cobertura de integração"
|
|
||||||
evolucao: - "Manter compatibilidade com upgrades futuros do Chatwoot" - "Evitar customizações que dificultem merges upstream"
|
|
||||||
|
|
||||||
criterios_de_qualidade:
|
|
||||||
|
|
||||||
- "Código alinhado aos padrões do Chatwoot"
|
|
||||||
- "Isolamento correto por Account"
|
|
||||||
- "Boa performance em escala"
|
|
||||||
- "Separação clara de responsabilidades"
|
|
||||||
- "Integração limpa com o módulo Captain"
|
|
||||||
|
|
||||||
frase_chave:
|
|
||||||
|
|
||||||
- "No Chatwoot, controller não pensa"
|
|
||||||
- "Quem pensa é o service"
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
---
|
|
||||||
description: Especialista em Frontend
|
|
||||||
---
|
|
||||||
|
|
||||||
especialista:
|
|
||||||
titulo: "Especialista em Frontend Vue.js 3 e TailwindCSS"
|
|
||||||
foco: - design_system_chatwoot - consistencia_visual - acessibilidade - manutencao_evolutiva
|
|
||||||
stack: - vue_js_3 - tailwindcss - chatwoot_dashboard
|
|
||||||
|
|
||||||
objetivo:
|
|
||||||
descricao: >
|
|
||||||
Desenvolver interfaces no Chatwoot seguindo rigorosamente o Design System,
|
|
||||||
garantindo consistência visual, acessibilidade, manutenibilidade e
|
|
||||||
compatibilidade com futuras atualizações do projeto.
|
|
||||||
|
|
||||||
regras_de_ouro:
|
|
||||||
componentes:
|
|
||||||
regra_geral: "Usar SEMPRE os componentes modernos da pasta components-next"
|
|
||||||
caminho: "app/javascript/dashboard/components-next/"
|
|
||||||
proibicoes: - "Nunca usar elementos HTML nativos diretamente quando existir componente equivalente"
|
|
||||||
exemplos_obrigatorios:
|
|
||||||
botao:
|
|
||||||
usar: "<Button />"
|
|
||||||
nao_usar: "<button>"
|
|
||||||
input:
|
|
||||||
usar: "<Input />"
|
|
||||||
card:
|
|
||||||
usar: "<CardLayout />"
|
|
||||||
|
|
||||||
cores:
|
|
||||||
regra_geral: "Nunca usar cores hardcoded"
|
|
||||||
permitido: - "Variáveis de tema do Chatwoot"
|
|
||||||
exemplos: - "n-slate-11" - "n-blue-9" - "n-alpha-3"
|
|
||||||
proibido: - "hex (#FFFFFF, #000000, etc)" - "rgb()" - "cores customizadas fora do tema"
|
|
||||||
|
|
||||||
icones:
|
|
||||||
biblioteca: "lucide"
|
|
||||||
uso:
|
|
||||||
tipo: "classes utilitárias"
|
|
||||||
exemplo: "i-lucide-user"
|
|
||||||
proibido: - "SVG inline manual" - "bibliotecas de ícones externas"
|
|
||||||
|
|
||||||
acessibilidade:
|
|
||||||
obrigatorio: true
|
|
||||||
regras: - "Todo input deve possuir label visível ou aria-label" - "Componentes interativos devem ser acessíveis via teclado" - "Estados de foco devem ser preservados (focus-visible)"
|
|
||||||
validacao: - "Evitar inputs sem contexto semântico" - "Não remover outline sem substituto acessível"
|
|
||||||
|
|
||||||
boas_praticas_adicionais:
|
|
||||||
arquitetura: - "Preferir composição de componentes em vez de lógica inline" - "Evitar duplicação de estilos; reutilizar componentes"
|
|
||||||
responsividade: - "Usar utilitários Tailwind responsivos (sm, md, lg)"
|
|
||||||
legibilidade:Você é um Arquiteto de Software Sênior especializado no código-fonte do Chatwoot (Ruby on Rails).
|
|
||||||
|
|
||||||
Suas Regras de Ouro:
|
|
||||||
|
|
||||||
1. Arquitetura: O Chatwoot usa Services Object (`app/services`). Nunca coloque lógica de negócio complexa direto no Controller.
|
|
||||||
2. Banco de Dados: Sempre verifique se suas queries são eficientes (evite N+1). Lembre-se que tudo é escopado por `Account` (`Current.account`).
|
|
||||||
3. Módulo Captain: Ao mexer com IA, use sempre o namespace `Captain::` (ex: `Captain::Scenario`).
|
|
||||||
4. Segurança: Nunca exponha IDs sequenciais em APIs públicas se não for padrão do projeto.
|
|
||||||
5. Contexto: Você sabe que estamos implementando a "Jasmine Brain" para um Hotel.
|
|
||||||
- "Evitar classes excessivamente longas em templates"
|
|
||||||
- "Extrair trechos complexos para componentes menores"
|
|
||||||
|
|
||||||
criterios_de_qualidade:
|
|
||||||
|
|
||||||
- "Interface consistente com o restante do Chatwoot"
|
|
||||||
- "Nenhuma quebra visual ao alternar temas"
|
|
||||||
- "Código alinhado ao padrão components-next"
|
|
||||||
- "Acessibilidade básica garantida sem dependência externa"
|
|
||||||
|
|
||||||
frase_chave:
|
|
||||||
|
|
||||||
- "Frontend no Chatwoot não é customização livre"
|
|
||||||
- "É extensão controlada do Design System"
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
---
|
|
||||||
description: Especialista em Engenharia de prompt para hoteis
|
|
||||||
---
|
|
||||||
|
|
||||||
especialista:
|
|
||||||
titulo: "Especialista Sênior em Engenharia de Prompt para Atendimento em Hotelaria"
|
|
||||||
foco: - automacao_conversacional - prevencao_de_erros_operacionais - aumento_de_conversao
|
|
||||||
plataforma: "Jasmine (Chatwoot)"
|
|
||||||
objetivo_geral: >
|
|
||||||
Projetar agentes conversacionais confiáveis, editáveis via interface,
|
|
||||||
prontos para produção e sem dependência de código hardcoded.
|
|
||||||
|
|
||||||
responsabilidades_gerais:
|
|
||||||
|
|
||||||
- "Garantir escopo bem definido para cada agente"
|
|
||||||
- "Impedir invenção de informações (anti-alucinação)"
|
|
||||||
- "Definir claramente quando perguntar, agir ou escalar"
|
|
||||||
- "Assegurar uso correto das ferramentas (Tools)"
|
|
||||||
- "Alinhar o agente à operação real do hotel"
|
|
||||||
|
|
||||||
fluxo_para_criacao_de_novo_agente:
|
|
||||||
descricao: "Executar obrigatoriamente as etapas abaixo, na ordem"
|
|
||||||
etapas: - etapa: "Definição da Persona"
|
|
||||||
itens: - nome_do_agente - funcao_principal - limites_de_atuacao - objetivo_de_negocio - tom_de_voz
|
|
||||||
tons_de_voz_permitidos: - amigavel - formal - consultivo - vendedor - neutro_operacional
|
|
||||||
regra: "O tom escolhido deve ser justificado"
|
|
||||||
|
|
||||||
- etapa: "System Prompt (Blindado)"
|
|
||||||
requisitos:
|
|
||||||
- "Proibir invenção de dados"
|
|
||||||
- "Definir quando perguntar antes de agir"
|
|
||||||
- "Definir comportamento em falha de ferramenta"
|
|
||||||
- "Impedir respostas fora do escopo do agente"
|
|
||||||
- "Indicar quando escalar para humano ou outro agente"
|
|
||||||
|
|
||||||
- etapa: "Gatilhos de Ativação"
|
|
||||||
definicao:
|
|
||||||
gatilhos_fortes:
|
|
||||||
descricao: "Ativação imediata do agente"
|
|
||||||
gatilhos_fracos:
|
|
||||||
descricao: "Exigem confirmação de intenção"
|
|
||||||
exemplos:
|
|
||||||
fortes:
|
|
||||||
- "reservar"
|
|
||||||
- "quero reservar"
|
|
||||||
- "disponibilidade"
|
|
||||||
fracos:
|
|
||||||
- "quanto custa"
|
|
||||||
- "tem vaga"
|
|
||||||
|
|
||||||
- etapa: "Mapeamento de Ferramentas"
|
|
||||||
regra_geral: "Nenhum dado externo deve ser informado sem uso de ferramenta"
|
|
||||||
formato_obrigatorio: "tool://nome_da_tool"
|
|
||||||
exemplos:
|
|
||||||
- "tool://consultar_disponibilidade"
|
|
||||||
- "tool://consultar_precos"
|
|
||||||
- "tool://criar_intencao_reserva"
|
|
||||||
- "tool://gerar_pix"
|
|
||||||
definicoes_obrigatorias:
|
|
||||||
- momento_de_uso
|
|
||||||
- pre_condicoes
|
|
||||||
- comportamento_em_falha
|
|
||||||
- acoes_proibidas_sem_tool
|
|
||||||
|
|
||||||
- etapa: "Regras de Segurança e Operação"
|
|
||||||
regras:
|
|
||||||
- "Nunca assumir dados não confirmados"
|
|
||||||
- "Nunca pular etapas críticas do fluxo"
|
|
||||||
- "Nunca misturar responsabilidades entre agentes"
|
|
||||||
- "Nunca gerar cobranças sem consentimento explícito do cliente"
|
|
||||||
|
|
||||||
resultado_esperado:
|
|
||||||
entrega: - "Persona completa" - "Pronta para o Agent Manager do Chatwoot" - "Com comportamento previsível" - "Segura para produção" - "Sem dependência de código"
|
|
||||||
|
|
||||||
frase_chave:
|
|
||||||
|
|
||||||
- "Você não cria textos de agente"
|
|
||||||
- "Você projeta operadores conversacionais de produção"
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
description: interface_frontend
|
|
||||||
---
|
|
||||||
|
|
||||||
## Regra de Ouro de UI e i18n
|
|
||||||
|
|
||||||
Nunca entregue ou sugira código de interface (Frontend) sem garantir que TODAS as strings visíveis tenham suas chaves de tradução devidamente criadas nos arquivos de locale (pt_BR e en). É proibido deixar chaves cruas (ex: `CAPTAIN.BRANDS...`) ou textos hardcoded na UI. Se criar uma nova feature, crie o arquivo JSON de tradução correspondente imediatamente.
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
---
|
|
||||||
description: Jasmine padrao para reservas
|
|
||||||
---
|
|
||||||
|
|
||||||
# Manifesto de Desenvolvimento: Jasmine & Daniela (Reservas com Pix)
|
|
||||||
|
|
||||||
**Data:** 17/01/2026
|
|
||||||
**Responsável:** Arquiteto de Software (AI)
|
|
||||||
|
|
||||||
## 1. Visão Geral e Objetivo
|
|
||||||
|
|
||||||
Este documento define o padrão de desenvolvimento e comportamento para a funcionalidade de **Reservas via Chat** no Chatwoot (projeto Chatwoot-Develop). O objetivo final é uma experiência fluida onde a agente principal (**Jasmine**) orquestra o atendimento, delegando a lógica de negócio complexa para a especialista (**Daniela**), sem que o cliente perceba fricção ou "troca de robôs".
|
|
||||||
|
|
||||||
## 2. Experiência de Atendimento Ideal (Script de Referência)
|
|
||||||
|
|
||||||
_Todo o desenvolvimento deve buscar replicar exatamente este fluxo de interação:_
|
|
||||||
|
|
||||||
**Cliente:** "Gostaria de fazer uma reserva"
|
|
||||||
**Jasmine:** "Perfeito! Vou te ajudar com isso 😊. Para darmos continuidade à sua reserva, poderia me informar, por favor, seu nome completo e CPF?"
|
|
||||||
|
|
||||||
**Cliente:** "João Pedro da Silva, CPF 123.456.789-00"
|
|
||||||
**Jasmine:** "Obrigada, João 😊. Agora me diga, por favor: qual suíte você deseja reservar?"
|
|
||||||
|
|
||||||
**Cliente:** "Suíte Luxo"
|
|
||||||
**Jasmine:** "Ótima escolha! ✨. Para qual data e horário/período você gostaria da reserva?"
|
|
||||||
|
|
||||||
**Cliente:** "Dia 20/02, das 22h às 10h"
|
|
||||||
_(Internamente: Daniela consulta disponibilidade e preço)_
|
|
||||||
**Jasmine:** "Prontinho! 😊 Sua reserva ficou assim:
|
|
||||||
• Suíte: Luxo
|
|
||||||
• Data: 20/02
|
|
||||||
• Horário: das 22h às 10h
|
|
||||||
• Valor total: R$ 300,00
|
|
||||||
|
|
||||||
Para confirmar a reserva, é necessário o pagamento de 50% antecipado (R$ 150,00).
|
|
||||||
Posso gerar o Pix para você agora?"
|
|
||||||
|
|
||||||
**Cliente:** "Pode sim"
|
|
||||||
_(Internamente: Daniela gera intenção e Pix)_
|
|
||||||
**Jasmine:** "Perfeito 😊 Segue abaixo o Pix para pagamento da entrada:
|
|
||||||
💰 Valor: R$ 150,00
|
|
||||||
📌 Chave Pix: [Copia e Cola]
|
|
||||||
👉 Basta copiar a chave Pix, colar no aplicativo do seu banco e realizar o pagamento. Assim que pagar, me avise por aqui para que eu confirme sua reserva, tudo bem?"
|
|
||||||
|
|
||||||
**Cliente:** "Pronto, acabei de pagar"
|
|
||||||
_(Internamente: Daniela confirma pagamento)_
|
|
||||||
**Jasmine:** "Pagamento confirmado com sucesso! ✅
|
|
||||||
Sua reserva está confirmada 🎉
|
|
||||||
Resumo final:
|
|
||||||
• Suíte: Luxo
|
|
||||||
• Data: 20/02
|
|
||||||
• Horário: das 22h às 10h
|
|
||||||
• Entrada paga: R$ 150,00
|
|
||||||
|
|
||||||
Qualquer dúvida ou se precisar de algo mais, é só me chamar. Será um prazer te receber! 😊✨"
|
|
||||||
|
|
||||||
## 3. Protocolo de Debugging (Baseado em Tempo)
|
|
||||||
|
|
||||||
Para garantir a análise correta dos erros e a correção assertiva:
|
|
||||||
|
|
||||||
1. **Input do Usuário:** O Rodrigo fornecerá o **Horário de Início da Conversa** (Horário de Brasília).
|
|
||||||
2. **Conversão e Análise:** O Arquiteto converterá esse horário para o fuso do servidor (UTC/Local) e analisará os logs (`log/tool_debug.log`, `log/brain_debug.log`, `log/development.log`) a partir desse ponto exato.
|
|
||||||
3. **Reconstrução:** O Arquiteto reconstruirá o fluxo passo-a-passo (Intenção -> Ferramenta -> Resposta -> Erro).
|
|
||||||
4. **Ação de Correção:** O ajuste será feito para corrigir a raiz do problema, garantindo que o fluxo futuro respeite o item 2 (Script de Referência).
|
|
||||||
|
|
||||||
## 4. Princípios de Arquitetura (CTO Mode)
|
|
||||||
|
|
||||||
### A. Jasmine é a Interface, Daniela é o Cérebro da Reserva
|
|
||||||
|
|
||||||
- **O cliente NUNCA fala diretamente com a Daniela.** A Jasmine é a "persona" que fala.
|
|
||||||
- A Daniela opera como uma _Tool_ ou _Scenario_ avançado que mantém estado.
|
|
||||||
- A Jasmine repassa a entrada do usuário para a Daniela, que processa a lógica de negócio e devolve instruções ou fatos.
|
|
||||||
- **Jasmine:** Responsável pela polidez, emojis e formatação final.
|
|
||||||
- **Daniela:** Responsável pelo fluxo lógico, cálculo de preços (50%) e validação de dados.
|
|
||||||
|
|
||||||
### B. O Estado é Sagrado (Stateful)
|
|
||||||
|
|
||||||
- Não podemos depender da memória de curto prazo do LLM para dados críticos (CPF, Valor).
|
|
||||||
- O estado da reserva (etapa atual, dados coletados) deve ser persistido em `conversation.custom_attributes['jasmine_state']`.
|
|
||||||
- O fluxo é sequencial: Coleta Nome/CPF -> Coleta Suíte/Data -> Check Disponibilidade -> Acordo de Valor -> Pix -> Confirmação.
|
|
||||||
|
|
||||||
### C. Postura "Fix It All"
|
|
||||||
|
|
||||||
- Não faremos correções pontuais (ex: "arruma esse erro de sintaxe").
|
|
||||||
- Ao encontrar um erro, analisaremos o **fluxo completo**.
|
|
||||||
- Se o Pix falhou, verificaremos desde a coleta do CPF até a chamada da API.
|
|
||||||
|
|
||||||
## 5. Estrutura Técnica
|
|
||||||
|
|
||||||
- **JasmineBrain (`jasmine_brain.rb`):** Detecta intenção de reserva e delega para `Daniela Reservas`.
|
|
||||||
- **Daniela (`seed_jasmine_hotel_v3.rb`):** Scenario configurado com instruções rigorosas para seguir o script ideal.
|
|
||||||
- **Tools:**
|
|
||||||
- `update_contact`: Salva Nome/CPF.
|
|
||||||
- `check_availability`: Calcula preço total.
|
|
||||||
- `create_reservation_intent`: Salva 50% do valor como depósito.
|
|
||||||
- `generate_pix`: Gera o Pix Cobrança (Inter/Simulado) para o valor do depósito.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Este documento deve ser lido no início de cada sessão de manutenção desta feature para garantir alinhamento._
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
---
|
|
||||||
description: padrao jasmine docs
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guia de pensamento para alinhar Jasmine + Daniela Reservas
|
|
||||||
|
|
||||||
Sempre que precisar revisar ou ajustar o fluxo de reservas, siga este checklist mental. Ele garante que toda intervenção esteja alinhada com o objetivo macro que você descreveu (contato cordial → ativar Daniela → coleta completa → cálculo do valor por dia → Pix de 50% → confirmação final).
|
|
||||||
|
|
||||||
1. **Entenda o contexto final do usuário**
|
|
||||||
|
|
||||||
- O cliente entra com um “bom dia”, “posso reservar?” ou similar.
|
|
||||||
- O objetivo é caminhar até uma reserva confirmada com Pix: sem desvios, sem repetição de perguntas.
|
|
||||||
- Qualquer erro deve ser interpretado à luz desse fluxo (não como uma exceção isolada).
|
|
||||||
|
|
||||||
2. **Priorize o fluxo estruturado, não atalhos “temáticos”**
|
|
||||||
|
|
||||||
- Jasmine deve sempre responder com cordialidade inicial e detectar quando acionar Daniela.
|
|
||||||
- Toda ação de reserva (dados do cliente, suíte, data, valor) deve ser comandada por Daniela, informando o cliente do próximo passo.
|
|
||||||
- Evite fallback direto do Brain para respostas livres quando o fluxo já está em andamento.
|
|
||||||
|
|
||||||
3. **Mantenha estado consistente durante todo o fluxo**
|
|
||||||
|
|
||||||
- O cenário ativo precisa persistir na conversa: `active_scenario_key`, `expires_at`, `active_scenario_state`.
|
|
||||||
- O `state` guarda stage, dados coletados (CPF, suíte, data) e resultados de ferramentas (availability, pix).
|
|
||||||
- Em cada turno, atualize esse estado e só encerre quando o Pix for gerado ou o cliente disser “cancelar/obrigado”.
|
|
||||||
|
|
||||||
4. **Ferramentas obedecem ao roteiro**
|
|
||||||
|
|
||||||
- `check_availability` determina o valor correto conforme a data e o dia da semana (verfica as tabelas).
|
|
||||||
- `create_reservation_intent` guarda os dados coletados; jamais pula etapas.
|
|
||||||
- `generate_pix` deve receber o valor calculado e aplicar exatamente `valor / 2` quando solicitado (entrada 50%).
|
|
||||||
- Se algum passo falhar, o sistema deve comunicar o erro (fallback adequado) e manter o cliente no fluxo, não encerrar com mensagens genéricas como “dificuldades técnicas”.
|
|
||||||
|
|
||||||
5. **Fluxo conversacional esperado**
|
|
||||||
|
|
||||||
1. Jasmine responde com cordialidade e confirma a intenção.
|
|
||||||
2. Daniela entra pedindo nome/CPF, suíte, data, horário.
|
|
||||||
3. Daniela informa valor, explica “pode pagar 50% agora via Pix”.
|
|
||||||
4. Daniela chama `generate_pix`, envia o código, explica "copie e cole".
|
|
||||||
5. Após cliente avisar “paguei”, Daniela confirma a reserva e encerra o cenário.
|
|
||||||
6. Se o cliente desistir ou pedir humano, encerre/limpe sticky e deixe Lucia (Jasmine) responder com fallback apropriado.
|
|
||||||
|
|
||||||
6. **Logs e documentação**
|
|
||||||
- Sempre que alterar algo, registre nas instruções/prompt das agents (ex: prompt da Daniela deve explicitar o roteiro).
|
|
||||||
- Se necessário fazer mudanças pontuais, vincule essas mudanças ao fluxo (não apenas "ajustar o Pix").
|
|
||||||
- Use este documento como ponto de referência antes de testar: se você “esquecer”, leia e repita mentalmente o que o Rodrigo quer.
|
|
||||||
|
|
||||||
Se precisar de ajuda para transformar isso em tarefas técnicas (migrations, short-circuit, state), é só avisar.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documento de referência ampliado (fluxo reserva + Pix)
|
|
||||||
|
|
||||||
### 1. Objetivo “padrão-ouro”
|
|
||||||
|
|
||||||
O sistema deve rodar este fluxo previsível para cada pedido de reserva:
|
|
||||||
|
|
||||||
1. Cliente cumprimenta → Jasmine responde educadamente.
|
|
||||||
2. Cliente manifesta “quero reservar” → Jasmine delega para Daniela.
|
|
||||||
3. Daniela conduz todo o plano: coleta dados, chama ferramentas (precio/disponibilidade/pix), monta a próxima mensagem.
|
|
||||||
4. Jasmine publica o texto final no tom oficial (voz única para o cliente).
|
|
||||||
5. Coleta obrigatória concentrada na Daniela: nome completo, CPF, suíte, data, horário/período.
|
|
||||||
6. Daniela confirma: “Sua reserva ficou assim… total R$T. Posso gerar o Pix (50%)?”
|
|
||||||
7. Cliente confirma → Daniela gera Pix de metade do total e instrui “copie e cole no seu banco”.
|
|
||||||
8. Após o cliente avisar “paguei”, Daniela confirma o pagamento e informa que a reserva está confirmada.
|
|
||||||
|
|
||||||
Sucesso significa que a reserva segue esse roteiro, com Pix gerado/confirmado e sem decisões aleatórias.
|
|
||||||
|
|
||||||
### 2. Papéis e responsabilidades
|
|
||||||
|
|
||||||
- **Jasmine (“interface única”)**: cumprimenta, decide se deve delegar, publica as respostas da Daniela e mantém o tom padrão. Nunca responde diretamente sobre reservas, preços ou Pix — sempre delega ao subagente adequado.
|
|
||||||
- **Daniela (“motor transacional”)**: permanece na camada interna. Recebe contexto, coleta dados, valida, chama `check_availability`, `create_reservation_intent`, `generate_pix`, constrói o `mensagem_para_cliente` e retorna também o `estado_atualizado` e as ações requisitadas.
|
|
||||||
|
|
||||||
### 3. Regras de comportamento
|
|
||||||
|
|
||||||
- Tom educado e uniforme mesmo quando a resposta vem da Daniela.
|
|
||||||
- Não inventar preços ou disponibilidade: os valores derivam exclusivamente de `check_availability` e do histórico fornecido.
|
|
||||||
- Sempre perguntar “posso gerar o Pix?” antes de chamar `generate_pix`.
|
|
||||||
- Não sair do fluxo: enquanto o sticky scenario estiver ativo, o Brain não deve responder com mensagens independentes ou fallback.
|
|
||||||
|
|
||||||
### 4. Arquitetura mínima para consistência
|
|
||||||
|
|
||||||
A) **Sticky “modo reserva”** – Conversa persiste: `active_scenario_key`, `active_scenario_expires_at`, `active_scenario_state`. Uma vez ativado por Daniela, todas as próximas mensagens vão direto para ela até conclusão/cancelamento.
|
|
||||||
B) **Estado mínimo persistido** – `active_scenario_state` deve conter:
|
|
||||||
|
|
||||||
- `stage` (p.ex. collecting_data, quoting, waiting_payment)
|
|
||||||
- `collected` (nome, CPF, suíte, data, horário)
|
|
||||||
- `last_quote` (preço, disponibilidade, dia/turno)
|
|
||||||
- `pix_status` (pending/paid/confirmed)
|
|
||||||
Isso evita perda de memória entre turnos e mantém o fluxo determinístico.
|
|
||||||
|
|
||||||
### 5. Tratamento de erros (visão macro)
|
|
||||||
|
|
||||||
Quando surgir um erro (resposta desalinhada, ferramenta falhando, handoff disparado), trace o contexto:
|
|
||||||
|
|
||||||
1. Qual stage estava ativo?
|
|
||||||
2. Jasmine desviou e respondeu sozinha?
|
|
||||||
3. Daniela repetiu perguntas ou perdeu dados?
|
|
||||||
4. Qual tool falhou ou retornou inválido?
|
|
||||||
Corrija sempre com base nesse fluxo padrão — não apenas “ajuste a frase”, mas reforce o sticky/state/sequence.
|
|
||||||
|
|
||||||
### 6. Como trabalhamos na prática
|
|
||||||
|
|
||||||
Sempre que você me mostrar um erro, envie:
|
|
||||||
|
|
||||||
1. Mensagem do cliente.
|
|
||||||
2. Resposta publicada pela Jasmine.
|
|
||||||
3. Trecho do log relevante (20–60 linhas) indicando decisão/tool.
|
|
||||||
|
|
||||||
Com isso eu devolvo:
|
|
||||||
|
|
||||||
- Onde travar o sticky/state.
|
|
||||||
- Qual prompt deve ser ajustado.
|
|
||||||
- Qual ferramenta/filtro precisa validar o valor ou o Pix.
|
|
||||||
|
|
||||||
### 7. Regra mestre
|
|
||||||
|
|
||||||
“Daniela é o cérebro do fluxo transacional e retorna o texto pronto; Jasmine é a única voz que publica para o cliente no tom oficial. O sistema deve manter estado e sticky para que o fluxo seja determinístico.”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Simulação de atendimento ideal – experiência perfeita de reserva
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Boa noite
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Boa noite! 😊
|
|
||||||
Seja bem-vindo. Como posso te ajudar hoje?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Gostaria de fazer uma reserva
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Perfeito! Vou te ajudar com isso 😊
|
|
||||||
Para darmos continuidade à sua reserva, poderia me informar, por favor, seu nome completo e CPF?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
João Pedro da Silva, CPF 123.456.789-00
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Obrigada, João 😊
|
|
||||||
Agora me diga, por favor: qual suíte você deseja reservar?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Suíte Luxo
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Ótima escolha! ✨
|
|
||||||
Para qual data e horário/período você gostaria da reserva?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Dia 20/02, das 22h às 10h
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Perfeito! Só um instante que vou verificar a disponibilidade e os valores para você 😊
|
|
||||||
|
|
||||||
(internamente: Daniela consulta disponibilidade e preço)
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Prontinho! 😊
|
|
||||||
Sua reserva ficou assim:
|
|
||||||
|
|
||||||
• Suíte: Luxo
|
|
||||||
• Data: 20/02
|
|
||||||
• Horário: das 22h às 10h
|
|
||||||
• Valor total: R$ 300,00
|
|
||||||
|
|
||||||
Para confirmar a reserva, é necessário o pagamento de 50% (R$ 150,00).
|
|
||||||
Posso gerar o Pix para você agora?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Pode sim
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Perfeito 😊
|
|
||||||
Segue abaixo o Pix para pagamento da entrada:
|
|
||||||
|
|
||||||
💰 Valor: R$ 150,00
|
|
||||||
📌 Chave Pix: 123e4567-e89b-12d3-a456-426614174000
|
|
||||||
|
|
||||||
👉 Basta copiar a chave Pix, colar no aplicativo do seu banco e realizar o pagamento.
|
|
||||||
Assim que pagar, me avise por aqui para que eu confirme sua reserva, tudo bem?
|
|
||||||
|
|
||||||
**Cliente:**
|
|
||||||
Pronto, acabei de pagar
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Só um instante que vou confirmar o pagamento para você 😊
|
|
||||||
|
|
||||||
(internamente: Daniela confirma pagamento)
|
|
||||||
|
|
||||||
**Jasmine:**
|
|
||||||
Pagamento confirmado com sucesso! ✅
|
|
||||||
|
|
||||||
Sua reserva está confirmada 🎉
|
|
||||||
Resumo final:
|
|
||||||
|
|
||||||
• Suíte: Luxo
|
|
||||||
• Data: 20/02
|
|
||||||
• Horário: das 22h às 10h
|
|
||||||
• Entrada paga: R$ 150,00
|
|
||||||
|
|
||||||
Qualquer dúvida ou se precisar de algo mais, é só me chamar.
|
|
||||||
Será um prazer te receber! 😊✨
|
|
||||||
|
|
||||||
### 🔑 Por que essa experiência é boa
|
|
||||||
|
|
||||||
- Linguagem humana e educada
|
|
||||||
- Um pedido por vez (sem sobrecarregar)
|
|
||||||
- Confirmação clara antes da cobrança
|
|
||||||
- Segurança no Pix
|
|
||||||
- Encerramento com sensação de sucesso
|
|
||||||
|
|
||||||
Se quiser, nos próximos passos posso:
|
|
||||||
|
|
||||||
- marcar onde entra cada stage,
|
|
||||||
- transformar isso em script técnico para os prompts da Daniela/Jasmine, ou
|
|
||||||
- criar a versão “anti-confusão” (quando o cliente muda de assunto ou responde errado).
|
|
||||||
|
|
||||||
### 💬 Instrução extra: Pix Copia e Cola completo
|
|
||||||
|
|
||||||
Quando o GeneratePixTool retornar o código, **garanta que o bloco de código contenha a string inteira**, iniciando em `0002010...` e terminando no checksum `...6304XXXX`. Esse é o valor que o cliente precisa colar no banco; qualquer corte causa “Pix inválido”. Copie exatamente o `raw_payload` do tool (ou o `formatted_message` caso já contenha a string completa) e anexe no bloco antes de enviar. Nada deve ser truncado ou substituído pela Jasmine depois de a mensagem sair.
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@ -95,9 +95,20 @@ yarn-debug.log*
|
|||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.cursor
|
.cursor
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md
|
||||||
|
.agent/
|
||||||
|
|
||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
.netlify
|
.netlify
|
||||||
.histoire
|
.histoire
|
||||||
.pnpm-store/*
|
.pnpm-store/*
|
||||||
local/
|
local/
|
||||||
|
|
||||||
|
# Reference and temporary files
|
||||||
|
reference_chatwoot/
|
||||||
|
*.jpg
|
||||||
|
*.png
|
||||||
|
*.txt
|
||||||
|
reproduce_encoding.rb
|
||||||
|
debug_media.rb
|
||||||
|
test.jpg
|
||||||
|
teste_imagem.jpg
|
||||||
|
|||||||
18
Gemfile.lock
18
Gemfile.lock
@ -198,7 +198,7 @@ GEM
|
|||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
csv (3.3.0)
|
csv (3.3.5)
|
||||||
csv-safe (3.3.1)
|
csv-safe (3.3.1)
|
||||||
csv (~> 3.0)
|
csv (~> 3.0)
|
||||||
database_cleaner (2.0.2)
|
database_cleaner (2.0.2)
|
||||||
@ -214,7 +214,7 @@ GEM
|
|||||||
logger
|
logger
|
||||||
msgpack
|
msgpack
|
||||||
datadog-ruby_core_source (3.4.1)
|
datadog-ruby_core_source (3.4.1)
|
||||||
date (3.4.1)
|
date (3.5.1)
|
||||||
debug (1.8.0)
|
debug (1.8.0)
|
||||||
irb (>= 1.5.0)
|
irb (>= 1.5.0)
|
||||||
reline (>= 0.3.1)
|
reline (>= 0.3.1)
|
||||||
@ -564,11 +564,11 @@ GEM
|
|||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
neighbor (0.2.3)
|
neighbor (0.2.3)
|
||||||
activerecord (>= 5.2)
|
activerecord (>= 5.2)
|
||||||
net-http (0.6.0)
|
net-http (0.9.1)
|
||||||
uri
|
uri (>= 0.11.1)
|
||||||
net-http-persistent (4.0.2)
|
net-http-persistent (4.0.2)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
net-imap (0.4.20)
|
net-imap (0.6.2)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@ -653,7 +653,7 @@ GEM
|
|||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.8.0)
|
parser (3.3.8.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
@ -958,7 +958,7 @@ GEM
|
|||||||
time_diff (0.3.0)
|
time_diff (0.3.0)
|
||||||
activesupport
|
activesupport
|
||||||
i18n
|
i18n
|
||||||
timeout (0.4.3)
|
timeout (0.6.0)
|
||||||
trailblazer-option (0.1.2)
|
trailblazer-option (0.1.2)
|
||||||
ttfunk (1.8.0)
|
ttfunk (1.8.0)
|
||||||
bigdecimal (~> 3.1)
|
bigdecimal (~> 3.1)
|
||||||
@ -982,7 +982,7 @@ GEM
|
|||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uniform_notifier (1.17.0)
|
uniform_notifier (1.17.0)
|
||||||
uri (1.0.4)
|
uri (1.1.1)
|
||||||
uri_template (0.7.0)
|
uri_template (0.7.0)
|
||||||
valid_email2 (5.2.6)
|
valid_email2 (5.2.6)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
@ -1183,4 +1183,4 @@ RUBY VERSION
|
|||||||
ruby 3.4.4p34
|
ruby 3.4.4p34
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.11
|
4.0.4
|
||||||
|
|||||||
9
Makefile
9
Makefile
@ -36,13 +36,13 @@ run:
|
|||||||
@if [ -f ./.overmind.sock ]; then \
|
@if [ -f ./.overmind.sock ]; then \
|
||||||
echo "Overmind is already running. Use 'make force_run' to start a new instance."; \
|
echo "Overmind is already running. Use 'make force_run' to start a new instance."; \
|
||||||
else \
|
else \
|
||||||
overmind start -f Procfile.dev; \
|
export PATH="$$HOME/.rbenv/bin:$$HOME/.rbenv/shims:$$PATH"; eval "$$(rbenv init -)"; overmind start -f Procfile.dev; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
force_run:
|
force_run:
|
||||||
rm -f ./.overmind.sock
|
rm -f ./.overmind.sock
|
||||||
rm -f tmp/pids/*.pid
|
rm -f tmp/pids/*.pid
|
||||||
overmind start -f Procfile.dev
|
export PATH="$$HOME/.rbenv/bin:$$HOME/.rbenv/shims:$$PATH"; eval "$$(rbenv init -)"; overmind start -f Procfile.dev
|
||||||
|
|
||||||
force_run_tunnel:
|
force_run_tunnel:
|
||||||
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||||||
@ -59,4 +59,7 @@ debug_worker:
|
|||||||
docker:
|
docker:
|
||||||
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
|
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
|
||||||
|
|
||||||
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
|
sidekiq:
|
||||||
|
export PATH="$$HOME/.rbenv/bin:$$HOME/.rbenv/shims:$$PATH"; eval "$$(rbenv init -)"; bundle exec sidekiq -C config/sidekiq.yml
|
||||||
|
|
||||||
|
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker sidekiq
|
||||||
|
|||||||
@ -16,7 +16,8 @@ class Messages::MessageBuilder
|
|||||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||||
return unless params.instance_of?(ActionController::Parameters)
|
return unless params.instance_of?(ActionController::Parameters)
|
||||||
|
|
||||||
@in_reply_to = content_attributes&.dig(:in_reply_to)
|
# Try to find in_reply_to in params (top level) or content_attributes
|
||||||
|
@in_reply_to = params[:in_reply_to_id] || params[:in_reply_to] || content_attributes&.dig(:in_reply_to)
|
||||||
@items = content_attributes&.dig(:items)
|
@items = content_attributes&.dig(:items)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -140,7 +141,7 @@ class Messages::MessageBuilder
|
|||||||
content_type: @params[:content_type],
|
content_type: @params[:content_type],
|
||||||
content_attributes: content_attributes.presence,
|
content_attributes: content_attributes.presence,
|
||||||
items: @items,
|
items: @items,
|
||||||
in_reply_to: @in_reply_to,
|
in_reply_to_id: @in_reply_to,
|
||||||
echo_id: @params[:echo_id],
|
echo_id: @params[:echo_id],
|
||||||
source_id: @params[:source_id]
|
source_id: @params[:source_id]
|
||||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||||
|
|||||||
@ -1,19 +1,84 @@
|
|||||||
class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseController
|
||||||
before_action :fetch_pricings, only: [:index]
|
before_action :fetch_pricings, only: [:index]
|
||||||
|
before_action :fetch_pricing, only: [:show, :update, :destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
render json: @pricings
|
render json: @pricings
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: @pricing
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
# Handle multiple inboxes (cloning/distributing the price)
|
||||||
|
inbox_ids_param = params[:pricing][:inbox_ids]
|
||||||
|
|
||||||
|
if inbox_ids_param.present? && inbox_ids_param.is_a?(Array)
|
||||||
|
last_pricing = nil
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
inbox_ids_param.each do |iid|
|
||||||
|
# Create for each inbox. Merge overwrites default params.
|
||||||
|
# Convert iid to integer just in case
|
||||||
|
pricing = current_account.captain_pricings.new(pricing_params)
|
||||||
|
pricing.inbox_id = iid.to_i
|
||||||
|
pricing.save!
|
||||||
|
last_pricing = pricing
|
||||||
|
end
|
||||||
|
end
|
||||||
|
render json: last_pricing
|
||||||
|
else
|
||||||
|
# Create Global (inbox_id: nil) if no specific inbox selected
|
||||||
|
@pricing = current_account.captain_pricings.new(pricing_params)
|
||||||
|
@pricing.inbox_id = nil
|
||||||
|
@pricing.save!
|
||||||
|
render json: @pricing
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error creating pricing: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
# On update, if multiple inboxes are passed, we technically can't 'split' this ID.
|
||||||
|
# We assume usage of the first inbox or nil.
|
||||||
|
# If the user wants to assign to multiple, they should create new ones.
|
||||||
|
target_inbox_id = params[:pricing][:inbox_ids]&.first
|
||||||
|
|
||||||
|
@pricing.update!(pricing_params.merge(inbox_id: target_inbox_id))
|
||||||
|
render json: @pricing
|
||||||
|
rescue StandardError => e
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@pricing.destroy!
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def fetch_pricing
|
||||||
|
@pricing = current_account.captain_pricings.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
def fetch_pricings
|
def fetch_pricings
|
||||||
@pricings = current_account.captain_pricings
|
@pricings = current_account.captain_pricings.order(created_at: :desc)
|
||||||
|
|
||||||
|
# Filter by inbox if provided (returns Specific Inbox + Global rules)
|
||||||
|
@pricings = @pricings.where(inbox_id: [params[:inbox_id], nil]) if params[:inbox_id].present?
|
||||||
|
|
||||||
return unless params[:query].present?
|
return unless params[:query].present?
|
||||||
|
|
||||||
# Fuzzy search using ILIKE for case-insensitive matching
|
# Fuzzy search using ILIKE for case-insensitive matching
|
||||||
# We wrap the query in % for wildcard matching on both sides
|
@pricings = @pricings.left_outer_joins(:captain_brand).where(
|
||||||
@pricings = @pricings.where('suite_category ILIKE ?', "%#{params[:query]}%")
|
'suite_category ILIKE :query OR captain_brands.name ILIKE :query',
|
||||||
|
query: "%#{params[:query]}%"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def pricing_params
|
||||||
|
params.require(:pricing).permit(:captain_brand_id, :day_range, :suite_category, :duration, :price)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -2,6 +2,10 @@ class Webhooks::WhatsappController < ActionController::API
|
|||||||
include MetaTokenVerifyConcern
|
include MetaTokenVerifyConcern
|
||||||
|
|
||||||
def process_payload
|
def process_payload
|
||||||
|
# CRITICAL: Remove RawMessage IMMEDIATELY to prevent Rails logging from serializing binary data
|
||||||
|
params[:event]&.delete('RawMessage')
|
||||||
|
params.dig(:event, 'Message')&.delete('RawMessage')
|
||||||
|
|
||||||
if inactive_whatsapp_number?
|
if inactive_whatsapp_number?
|
||||||
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
|
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
|
||||||
render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
|
render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
|
||||||
@ -17,18 +21,185 @@ class Webhooks::WhatsappController < ActionController::API
|
|||||||
perform_sync if params[:awaitResponse].present?
|
perform_sync if params[:awaitResponse].present?
|
||||||
return if performed?
|
return if performed?
|
||||||
|
|
||||||
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
|
Webhooks::WhatsappEventsJob.perform_later(sanitize_payload_for_sidekiq)
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform_sync
|
def perform_sync
|
||||||
Webhooks::WhatsappEventsJob.perform_now(params.to_unsafe_hash)
|
Webhooks::WhatsappEventsJob.perform_now(sanitize_payload_for_sidekiq)
|
||||||
rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken
|
rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken
|
||||||
head :unauthorized
|
head :unauthorized
|
||||||
rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError
|
rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError
|
||||||
head :not_found
|
head :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# WHITELIST approach: Build a clean payload with ONLY allowed fields
|
||||||
|
# This prevents ANY binary data from leaking into JSON serialization
|
||||||
|
def sanitize_payload_for_sidekiq
|
||||||
|
raw = params.to_unsafe_hash
|
||||||
|
|
||||||
|
# Build a completely NEW payload with only safe fields
|
||||||
|
clean_payload = {
|
||||||
|
'type' => raw['type'],
|
||||||
|
'state' => raw['state'],
|
||||||
|
'instanceName' => raw['instanceName'],
|
||||||
|
'userID' => raw['userID'],
|
||||||
|
'controller' => raw['controller'],
|
||||||
|
'action' => raw['action'],
|
||||||
|
'phone_number' => raw['phone_number']
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only copy safe event fields
|
||||||
|
if raw['event'].is_a?(Hash)
|
||||||
|
clean_event = {}
|
||||||
|
|
||||||
|
# Info fields (all safe strings/ids)
|
||||||
|
if raw['event']['Info'].is_a?(Hash)
|
||||||
|
clean_event['Info'] = raw['event']['Info'].slice(
|
||||||
|
'ID', 'Type', 'MediaType', 'Chat', 'Sender', 'SenderAlt', 'RecipientAlt', 'IsFromMe', 'IsGroup',
|
||||||
|
'Timestamp', 'PushName', 'MessageSource'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Safe event metadata
|
||||||
|
%w[Chat Sender IsFromMe IsGroup Timestamp AddressingMode BroadcastListOwner
|
||||||
|
BroadcastRecipients RecipientAlt SenderAlt MessageIDs MessageSender].each do |key|
|
||||||
|
clean_event[key] = raw['event'][key] if raw['event'].key?(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Message content - WHITELIST only safe fields
|
||||||
|
if raw['event']['Message'].is_a?(Hash)
|
||||||
|
msg = raw['event']['Message']
|
||||||
|
clean_msg = {}
|
||||||
|
|
||||||
|
# Text messages
|
||||||
|
clean_msg['conversation'] = msg['conversation'] if msg['conversation'].is_a?(String)
|
||||||
|
|
||||||
|
if msg['extendedTextMessage'].is_a?(Hash)
|
||||||
|
clean_msg['extendedTextMessage'] = {
|
||||||
|
'text' => msg['extendedTextMessage']['text']
|
||||||
|
}
|
||||||
|
# Only copy contextInfo if it doesn't have quotedMessage with binaries
|
||||||
|
# Only copy contextInfo if it doesn't have quotedMessage with binaries
|
||||||
|
if msg['extendedTextMessage']['contextInfo'].is_a?(Hash)
|
||||||
|
ctx = msg['extendedTextMessage']['contextInfo']
|
||||||
|
clean_msg['extendedTextMessage']['contextInfo'] = {
|
||||||
|
'stanzaID' => ctx['stanzaID'] || ctx['stanzaId'],
|
||||||
|
'participant' => ctx['participant']
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Image messages - ONLY safe metadata, NO binaries
|
||||||
|
if msg['imageMessage'].is_a?(Hash)
|
||||||
|
img = msg['imageMessage']
|
||||||
|
clean_msg['imageMessage'] = {
|
||||||
|
'URL' => img['URL'] || img['url'],
|
||||||
|
'directPath' => img['directPath'],
|
||||||
|
'mediaKey' => img['mediaKey'],
|
||||||
|
'fileEncSha256' => img['fileEncSha256'] || img['fileEncSHA256'],
|
||||||
|
'fileSha256' => img['fileSha256'] || img['fileSHA256'],
|
||||||
|
'fileLength' => img['fileLength'],
|
||||||
|
'mimetype' => img['mimetype'],
|
||||||
|
'width' => img['width'],
|
||||||
|
'height' => img['height'],
|
||||||
|
'caption' => img['caption'],
|
||||||
|
'contextInfo' => {
|
||||||
|
'stanzaID' => img.dig('contextInfo', 'stanzaID') || img.dig('contextInfo', 'stanzaId'),
|
||||||
|
'participant' => img.dig('contextInfo', 'participant')
|
||||||
|
}.compact
|
||||||
|
}.compact
|
||||||
|
# EXPLICITLY NO: JPEGThumbnail, scansSidecar, firstScanSidecar, etc
|
||||||
|
end
|
||||||
|
|
||||||
|
# Video messages - ONLY safe metadata
|
||||||
|
if msg['videoMessage'].is_a?(Hash)
|
||||||
|
vid = msg['videoMessage']
|
||||||
|
clean_msg['videoMessage'] = {
|
||||||
|
'URL' => vid['URL'] || vid['url'],
|
||||||
|
'directPath' => vid['directPath'],
|
||||||
|
'mediaKey' => vid['mediaKey'],
|
||||||
|
'fileEncSha256' => vid['fileEncSha256'] || vid['fileEncSHA256'],
|
||||||
|
'fileSha256' => vid['fileSha256'] || vid['fileSHA256'],
|
||||||
|
'fileLength' => vid['fileLength'],
|
||||||
|
'mimetype' => vid['mimetype'],
|
||||||
|
'seconds' => vid['seconds'],
|
||||||
|
'caption' => vid['caption'],
|
||||||
|
'contextInfo' => {
|
||||||
|
'stanzaID' => vid.dig('contextInfo', 'stanzaID') || vid.dig('contextInfo', 'stanzaId'),
|
||||||
|
'participant' => vid.dig('contextInfo', 'participant')
|
||||||
|
}.compact
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
# Audio messages
|
||||||
|
if msg['audioMessage'].is_a?(Hash)
|
||||||
|
aud = msg['audioMessage']
|
||||||
|
clean_msg['audioMessage'] = {
|
||||||
|
'URL' => aud['URL'] || aud['url'],
|
||||||
|
'directPath' => aud['directPath'],
|
||||||
|
'mediaKey' => aud['mediaKey'],
|
||||||
|
'fileEncSha256' => aud['fileEncSha256'] || aud['fileEncSHA256'],
|
||||||
|
'fileSha256' => aud['fileSha256'] || aud['fileSHA256'],
|
||||||
|
'fileLength' => aud['fileLength'],
|
||||||
|
'mimetype' => aud['mimetype'],
|
||||||
|
'seconds' => aud['seconds'],
|
||||||
|
'ptt' => aud['ptt'],
|
||||||
|
'contextInfo' => {
|
||||||
|
'stanzaID' => aud.dig('contextInfo', 'stanzaID') || aud.dig('contextInfo', 'stanzaId'),
|
||||||
|
'participant' => aud.dig('contextInfo', 'participant')
|
||||||
|
}.compact
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
# Document messages
|
||||||
|
if msg['documentMessage'].is_a?(Hash)
|
||||||
|
doc = msg['documentMessage']
|
||||||
|
clean_msg['documentMessage'] = {
|
||||||
|
'URL' => doc['URL'] || doc['url'],
|
||||||
|
'directPath' => doc['directPath'],
|
||||||
|
'mediaKey' => doc['mediaKey'],
|
||||||
|
'fileEncSha256' => doc['fileEncSha256'] || doc['fileEncSHA256'],
|
||||||
|
'fileSha256' => doc['fileSha256'] || doc['fileSHA256'],
|
||||||
|
'fileLength' => doc['fileLength'],
|
||||||
|
'mimetype' => doc['mimetype'],
|
||||||
|
'fileName' => doc['fileName'],
|
||||||
|
'title' => doc['title']
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
clean_event['Message'] = clean_msg unless clean_msg.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
clean_payload['event'] = clean_event
|
||||||
|
end
|
||||||
|
|
||||||
|
# Also copy whatsapp key if present (but sanitize it too)
|
||||||
|
if raw['whatsapp'].is_a?(Hash)
|
||||||
|
# Just reference the same clean event structure
|
||||||
|
clean_payload['whatsapp'] = { 'event' => clean_payload['event'] }.merge(
|
||||||
|
raw['whatsapp'].slice('type', 'state', 'instanceName', 'userID')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info 'WuzAPI: Payload sanitized (WHITELIST mode)'
|
||||||
|
deep_force_utf8(clean_payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
def deep_force_utf8(obj)
|
||||||
|
case obj
|
||||||
|
when String
|
||||||
|
(obj.frozen? ? obj.dup : obj).force_encoding('UTF-8')
|
||||||
|
.encode('UTF-8', invalid: :replace, undef: :replace)
|
||||||
|
when Hash
|
||||||
|
obj.transform_values { |v| deep_force_utf8(v) }
|
||||||
|
when Array
|
||||||
|
obj.map { |v| deep_force_utf8(v) }
|
||||||
|
else
|
||||||
|
obj
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def valid_token?(token)
|
def valid_token?(token)
|
||||||
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
||||||
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
|
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
|
||||||
|
|||||||
@ -1,965 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
/* eslint-disable no-console, no-alert, vue/no-bare-strings-in-template, vue/no-static-inline-styles */
|
|
||||||
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue';
|
|
||||||
|
|
||||||
// --- State ---
|
|
||||||
const view = ref('form'); // form, payment, success
|
|
||||||
const isDataLoading = ref(true);
|
|
||||||
const isLoading = ref(false);
|
|
||||||
const isCopied = ref(false);
|
|
||||||
const submissionStatus = ref({
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
success: false,
|
|
||||||
reservationId: null, // Track ID for polling
|
|
||||||
pix: {
|
|
||||||
copyPasteCode: '',
|
|
||||||
qrCodeValue: '',
|
|
||||||
},
|
|
||||||
scarcityText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const appConfig = reactive({
|
|
||||||
title: 'Reserva Premium',
|
|
||||||
subtitle: 'Hotel 1001 Noites Prime',
|
|
||||||
phone_number: '',
|
|
||||||
primary_color: '#1E90FF',
|
|
||||||
secondary_color: '#1B3B5F',
|
|
||||||
});
|
|
||||||
|
|
||||||
const formData = reactive({
|
|
||||||
nome: '',
|
|
||||||
checkInDateTime: '',
|
|
||||||
telefone: '',
|
|
||||||
email: '',
|
|
||||||
cpf: '',
|
|
||||||
observacao: '',
|
|
||||||
selectedBrand: '',
|
|
||||||
selectedUnit: '',
|
|
||||||
selectedCategory: '',
|
|
||||||
stayDuration: '',
|
|
||||||
selectedExtras: [], // Future Phase
|
|
||||||
});
|
|
||||||
|
|
||||||
// Options State
|
|
||||||
const brands = ref([]);
|
|
||||||
// const units = ref([]); // Removed unused var
|
|
||||||
const pricings = ref([]);
|
|
||||||
const extras = ref([]);
|
|
||||||
|
|
||||||
const unitOptions = ref([]);
|
|
||||||
const categoryOptions = ref([]);
|
|
||||||
const durationOptions = ref([]);
|
|
||||||
|
|
||||||
// Price State
|
|
||||||
const calculatedPrice = ref(null);
|
|
||||||
const isPriceLoading = ref(false);
|
|
||||||
|
|
||||||
// --- API Methods ---
|
|
||||||
const fetchMasterData = async () => {
|
|
||||||
isDataLoading.value = true;
|
|
||||||
try {
|
|
||||||
const pathParts = window.location.pathname.split('/');
|
|
||||||
const accountIdIndex = pathParts.indexOf('accounts') + 1;
|
|
||||||
const accountId = pathParts[accountIdIndex];
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/public/api/v1/captain/master_data?account_id=${accountId}`
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error('Failed to load data');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
brands.value = data.brands;
|
|
||||||
pricings.value = data.pricings;
|
|
||||||
extras.value = data.extras;
|
|
||||||
|
|
||||||
if (data.app_config) {
|
|
||||||
appConfig.title = data.app_config.title || appConfig.title;
|
|
||||||
appConfig.subtitle = data.app_config.subtitle || appConfig.subtitle;
|
|
||||||
appConfig.phone_number = data.app_config.phone_number || '';
|
|
||||||
appConfig.primary_color =
|
|
||||||
data.app_config.primary_color || appConfig.primary_color;
|
|
||||||
appConfig.secondary_color =
|
|
||||||
data.app_config.secondary_color || appConfig.secondary_color;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("Master Data Error:", error);
|
|
||||||
} finally {
|
|
||||||
isDataLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Watchers & Computed ---
|
|
||||||
|
|
||||||
// When Brand changes
|
|
||||||
const handleBrandChange = () => {
|
|
||||||
formData.selectedUnit = '';
|
|
||||||
formData.selectedCategory = '';
|
|
||||||
formData.stayDuration = '';
|
|
||||||
|
|
||||||
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
|
|
||||||
if (brand) {
|
|
||||||
unitOptions.value = brand.units.map(u => ({
|
|
||||||
value: String(u.id),
|
|
||||||
label: u.name,
|
|
||||||
}));
|
|
||||||
durationOptions.value = brand.stay_durations.map(d => ({
|
|
||||||
value: d,
|
|
||||||
label: d,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
unitOptions.value = [];
|
|
||||||
durationOptions.value = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// When Unit changes
|
|
||||||
const handleUnitChange = () => {
|
|
||||||
formData.selectedCategory = '';
|
|
||||||
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
|
|
||||||
const unit = brand?.units.find(u => String(u.id) === formData.selectedUnit);
|
|
||||||
|
|
||||||
if (
|
|
||||||
unit &&
|
|
||||||
unit.visible_suite_categories &&
|
|
||||||
unit.visible_suite_categories.length > 0
|
|
||||||
) {
|
|
||||||
categoryOptions.value = unit.visible_suite_categories.map(c => ({
|
|
||||||
value: c,
|
|
||||||
label: c,
|
|
||||||
}));
|
|
||||||
} else if (
|
|
||||||
brand &&
|
|
||||||
brand.suite_categories &&
|
|
||||||
brand.suite_categories.length > 0
|
|
||||||
) {
|
|
||||||
categoryOptions.value = brand.suite_categories.map(c => ({
|
|
||||||
value: c,
|
|
||||||
label: c,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
categoryOptions.value = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Price Calculation Logic
|
|
||||||
const suiteImage = computed(() => {
|
|
||||||
if (!formData.selectedBrand || !formData.selectedCategory) return null;
|
|
||||||
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
|
|
||||||
if (!brand) return null;
|
|
||||||
|
|
||||||
const images = brand.suiteImages || brand.suite_images || {};
|
|
||||||
return images[formData.selectedCategory] || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Price Calculation Logic
|
|
||||||
const calculatePrice = () => {
|
|
||||||
if (
|
|
||||||
!formData.selectedBrand ||
|
|
||||||
!formData.selectedCategory ||
|
|
||||||
!formData.stayDuration ||
|
|
||||||
!formData.checkInDateTime
|
|
||||||
) {
|
|
||||||
calculatedPrice.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPriceLoading.value = true;
|
|
||||||
// Simulate async price lookup or local calc
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
const brandId = parseInt(formData.selectedBrand, 10);
|
|
||||||
const date = new Date(formData.checkInDateTime);
|
|
||||||
const dayIndex = date.getDay(); // 0 = Sunday, 6 = Saturday
|
|
||||||
|
|
||||||
const daysMap = [
|
|
||||||
'DOMINGO',
|
|
||||||
'SEGUNDA',
|
|
||||||
'TERÇA',
|
|
||||||
'QUARTA',
|
|
||||||
'QUINTA',
|
|
||||||
'SEXTA',
|
|
||||||
'SÁBADO',
|
|
||||||
];
|
|
||||||
const currentDayName = daysMap[dayIndex];
|
|
||||||
|
|
||||||
// Find matching pricing row
|
|
||||||
const priceRow = pricings.value.find(p => {
|
|
||||||
if (p.captain_brand_id !== brandId) return false;
|
|
||||||
if (p.suite_category !== formData.selectedCategory) return false;
|
|
||||||
if (p.duration !== formData.stayDuration) return false;
|
|
||||||
|
|
||||||
// Check day range
|
|
||||||
const range = (p.day_range || p.dayRange || '').toUpperCase();
|
|
||||||
|
|
||||||
if (range.includes(' A ')) {
|
|
||||||
if (range === 'SEGUNDA A QUARTA') {
|
|
||||||
return ['SEGUNDA', 'TERÇA', 'QUARTA'].includes(currentDayName);
|
|
||||||
}
|
|
||||||
if (range === 'QUINTA A DOMINGO') {
|
|
||||||
return ['QUINTA', 'SEXTA', 'SÁBADO', 'DOMINGO'].includes(
|
|
||||||
currentDayName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = range.split(',').map(d => d.trim());
|
|
||||||
return days.includes(currentDayName);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (priceRow) {
|
|
||||||
calculatedPrice.value = parseFloat(priceRow.price);
|
|
||||||
} else {
|
|
||||||
calculatedPrice.value = null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// console.error(e);
|
|
||||||
} finally {
|
|
||||||
isPriceLoading.value = false;
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [
|
|
||||||
formData.selectedBrand,
|
|
||||||
formData.selectedCategory,
|
|
||||||
formData.stayDuration,
|
|
||||||
formData.checkInDateTime,
|
|
||||||
],
|
|
||||||
calculatePrice
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Actions ---
|
|
||||||
|
|
||||||
const calculateCheckOut = (startStr, durationStr) => {
|
|
||||||
const start = new Date(startStr);
|
|
||||||
let hoursToAdd = 4;
|
|
||||||
if (durationStr?.toUpperCase().includes('PERNOITE')) {
|
|
||||||
hoursToAdd = 12;
|
|
||||||
}
|
|
||||||
const end = new Date(start.getTime() + hoursToAdd * 60 * 60 * 1000);
|
|
||||||
return end.toISOString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = val => {
|
|
||||||
return new Intl.NumberFormat('pt-BR', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'BRL',
|
|
||||||
}).format(val);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyPix = () => {
|
|
||||||
if (submissionStatus.value?.pix?.copyPasteCode) {
|
|
||||||
navigator.clipboard.writeText(submissionStatus.value.pix.copyPasteCode);
|
|
||||||
isCopied.value = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
isCopied.value = false;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetForm = () => {
|
|
||||||
view.value = 'form';
|
|
||||||
formData.nome = '';
|
|
||||||
formData.telefone = '';
|
|
||||||
formData.cpf = '';
|
|
||||||
formData.email = '';
|
|
||||||
formData.checkInDateTime = '';
|
|
||||||
formData.observacao = '';
|
|
||||||
formData.selectedBrand = '';
|
|
||||||
formData.selectedUnit = '';
|
|
||||||
formData.selectedCategory = '';
|
|
||||||
formData.stayDuration = '';
|
|
||||||
submissionStatus.value = null;
|
|
||||||
calculatedPrice.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateScarcityMessage = () => {
|
|
||||||
const messages = [
|
|
||||||
'🔥 Resta apenas 1 suíte disponível para esta data!',
|
|
||||||
'⚡ Alta demanda! 2 pessoas estão vendo esta suíte agora.',
|
|
||||||
'💎 Última chance! O hotel está quase lotado.',
|
|
||||||
'⏳ Segure sua vaga! Restam apenas 2 suítes.',
|
|
||||||
'👀 Muita procura para esta data. Garanta sua reserva!',
|
|
||||||
];
|
|
||||||
return messages[Math.floor(Math.random() * messages.length)];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Functions defined before use
|
|
||||||
const triggerConfetti = () => {
|
|
||||||
import('canvas-confetti')
|
|
||||||
.then(confetti => {
|
|
||||||
confetti.default({
|
|
||||||
particleCount: 150,
|
|
||||||
spread: 70,
|
|
||||||
origin: { y: 0.6 },
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
let pollingInterval = null;
|
|
||||||
const checkPaymentStatus = async () => {
|
|
||||||
if (!submissionStatus.value?.reservationId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/public/api/v1/captain/reservations/${submissionStatus.value.reservationId}/status`
|
|
||||||
);
|
|
||||||
if (!response.ok) return;
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.payment_status === 'paid') {
|
|
||||||
clearInterval(pollingInterval);
|
|
||||||
view.value = 'success';
|
|
||||||
triggerConfetti();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('Error polling status:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startPolling = () => {
|
|
||||||
if (pollingInterval) clearInterval(pollingInterval);
|
|
||||||
pollingInterval = setInterval(checkPaymentStatus, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!calculatedPrice.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
submissionStatus.value = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pathParts = window.location.pathname.split('/');
|
|
||||||
const accountId = pathParts[pathParts.indexOf('accounts') + 1];
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
brand_id: formData.selectedBrand,
|
|
||||||
unit_id: formData.selectedUnit,
|
|
||||||
contact_name: formData.nome,
|
|
||||||
phone_number: formData.telefone.replace(/\D/g, ''),
|
|
||||||
email: formData.email,
|
|
||||||
cpf: formData.cpf.replace(/\D/g, ''),
|
|
||||||
check_in_at: formData.checkInDateTime,
|
|
||||||
duration_minutes: 0,
|
|
||||||
check_out_at: calculateCheckOut(
|
|
||||||
formData.checkInDateTime,
|
|
||||||
formData.stayDuration
|
|
||||||
),
|
|
||||||
total_amount: calculatedPrice.value,
|
|
||||||
metadata: {
|
|
||||||
category: formData.selectedCategory,
|
|
||||||
stay_duration: formData.stayDuration,
|
|
||||||
observacao: formData.observacao,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/public/api/v1/captain/reservations?account_id=${accountId}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
if (result.metadata?.pix) {
|
|
||||||
submissionStatus.value = {
|
|
||||||
message:
|
|
||||||
'Sua reserva foi iniciada! Realize o pagamento via Pix para confirmar.',
|
|
||||||
type: 'success',
|
|
||||||
reservationId: result.reservation_id,
|
|
||||||
pix: result.metadata.pix,
|
|
||||||
scarcityText: generateScarcityMessage(),
|
|
||||||
};
|
|
||||||
view.value = 'payment';
|
|
||||||
// Trigger polling
|
|
||||||
startPolling();
|
|
||||||
} else {
|
|
||||||
submissionStatus.value = {
|
|
||||||
message: 'Reserva criada, mas falha ao gerar Pix. Entre em contato.',
|
|
||||||
type: 'error',
|
|
||||||
};
|
|
||||||
view.value = 'payment';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Falha ao criar reserva');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('Submit Error:', error);
|
|
||||||
// alert('Erro: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (pollingInterval) clearInterval(pollingInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
onMounted(() => {
|
|
||||||
fetchMasterData();
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatPhone = event => {
|
|
||||||
let value = event.target.value.replace(/\D/g, '');
|
|
||||||
if (value.length > 11) value = value.slice(0, 11);
|
|
||||||
|
|
||||||
if (value.length > 10) {
|
|
||||||
value = value.replace(/^(\d{2})(\d{5})(\d{4}).*/, '($1) $2-$3');
|
|
||||||
} else if (value.length > 5) {
|
|
||||||
value = value.replace(/^(\d{2})(\d{4})(\d{0,4}).*/, '($1) $2-$3');
|
|
||||||
} else if (value.length > 2) {
|
|
||||||
value = value.replace(/^(\d{2})(\d{0,5}).*/, '($1) $2');
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.telefone = value;
|
|
||||||
event.target.value = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCPF = event => {
|
|
||||||
let value = event.target.value.replace(/\D/g, '');
|
|
||||||
if (value.length > 11) value = value.slice(0, 11);
|
|
||||||
|
|
||||||
value = value.replace(/(\d{3})(\d)/, '$1.$2');
|
|
||||||
value = value.replace(/(\d{3})(\d)/, '$1.$2');
|
|
||||||
value = value.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
|
|
||||||
|
|
||||||
formData.cpf = value;
|
|
||||||
event.target.value = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidEmail = email => {
|
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFormValid = computed(() => {
|
|
||||||
return (
|
|
||||||
formData.nome.trim().length > 0 &&
|
|
||||||
formData.telefone.length >= 14 && // (XX) XXXXX-XXXX is 15 chars, or (XX) XXXX-XXXX is 14
|
|
||||||
formData.cpf.length === 14 && // XXX.XXX.XXX-XX
|
|
||||||
isValidEmail(formData.email) &&
|
|
||||||
formData.checkInDateTime &&
|
|
||||||
formData.selectedBrand &&
|
|
||||||
formData.selectedUnit &&
|
|
||||||
formData.selectedCategory &&
|
|
||||||
formData.stayDuration
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const viewTitle = computed(() => {
|
|
||||||
if (view.value === 'payment') return 'Pagamento Seguro';
|
|
||||||
if (view.value === 'success') return 'Reserva Confirmada';
|
|
||||||
return appConfig.title;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- eslint-disable vue/no-bare-strings-in-template, vue/no-static-inline-styles -->
|
|
||||||
<div
|
|
||||||
class="min-h-screen py-6 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center bg-fixed"
|
|
||||||
:style="`background: linear-gradient(135deg, ${appConfig.secondary_color} 0%, ${appConfig.primary_color} 100%)`"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-full max-w-3xl bg-white rounded-[2rem] shadow-2xl overflow-hidden border border-white/10 relative"
|
|
||||||
>
|
|
||||||
<!-- Decorative Top Accent -->
|
|
||||||
<div
|
|
||||||
class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-[#1B3B5F] to-[#1E90FF]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="p-6 sm:p-12">
|
|
||||||
<div
|
|
||||||
class="flex justify-between items-start mb-10 border-b border-[#1B3B5F]/10 pb-6"
|
|
||||||
>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h1
|
|
||||||
class="text-2xl sm:text-3xl font-extrabold text-[#1B3B5F] tracking-tight"
|
|
||||||
>
|
|
||||||
{{ viewTitle }}
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
v-if="view === 'form'"
|
|
||||||
class="text-[#9CA3AF] text-sm font-medium"
|
|
||||||
>
|
|
||||||
{{ appConfig.subtitle }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="appConfig.phone_number"
|
|
||||||
class="text-sm font-bold text-emerald-600 mt-2"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-phone mr-1" />
|
|
||||||
Suporte: {{ appConfig.phone_number }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- LOADING STATE -->
|
|
||||||
<div
|
|
||||||
v-if="isDataLoading"
|
|
||||||
class="text-center py-20 flex flex-col items-center justify-center space-y-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 border-4 border-[#1E90FF] border-t-transparent rounded-full animate-spin"
|
|
||||||
/>
|
|
||||||
<p class="text-[#9CA3AF] font-medium animate-pulse">
|
|
||||||
Carregando dados...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SUCCESS VIEW -->
|
|
||||||
<div
|
|
||||||
v-else-if="view === 'success'"
|
|
||||||
class="text-center space-y-6 p-10 bg-[#F8FAFC] border border-[#1B3B5F]/10 rounded-3xl shadow-inner animate-fade-in"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx-auto w-24 h-24 bg-green-100 rounded-full flex items-center justify-center mb-6 shadow-md"
|
|
||||||
>
|
|
||||||
<!-- Success Icon -->
|
|
||||||
<svg
|
|
||||||
class="h-12 w-12 text-green-600"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-3xl font-extrabold text-[#1B3B5F]">
|
|
||||||
Pagamento Confirmado!
|
|
||||||
</h2>
|
|
||||||
<p class="text-[#9CA3AF] text-lg">
|
|
||||||
Sua reserva está 100% garantida.<br />Enviamos os detalhes para o
|
|
||||||
seu e-mail.
|
|
||||||
</p>
|
|
||||||
<div class="pt-6">
|
|
||||||
<button
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
@click="handleResetForm"
|
|
||||||
>
|
|
||||||
Fazer Nova Reserva
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PAYMENT VIEW -->
|
|
||||||
<div
|
|
||||||
v-else-if="view === 'payment'"
|
|
||||||
class="text-center space-y-6 animate-fade-in"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="p-4 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10 mb-6"
|
|
||||||
>
|
|
||||||
<p class="text-[#1B3B5F] font-medium">
|
|
||||||
{{ submissionStatus?.message }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scarcity Trigger -->
|
|
||||||
<div
|
|
||||||
v-if="submissionStatus?.scarcityText"
|
|
||||||
class="animate-pulse bg-red-50 border border-red-100 p-3 rounded-xl"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="text-red-600 font-bold text-sm flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-flame text-lg" />
|
|
||||||
{{ submissionStatus.scarcityText }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WhatsApp Warning -->
|
|
||||||
<div class="text-center px-4">
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
Após o pagamento, você receberá a confirmação imediatamente no seu
|
|
||||||
<strong class="text-green-600">WhatsApp</strong>
|
|
||||||
<span class="font-mono text-xs">({{ formData.telefone }})</span>.
|
|
||||||
<br />
|
|
||||||
<span class="text-xs text-gray-400 block mt-1"
|
|
||||||
>Certifique-se que o número informado está correto.</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-4 pb-2 text-left">
|
|
||||||
<label
|
|
||||||
class="block text-xs font-bold text-[#1B3B5F] uppercase tracking-wide mb-2"
|
|
||||||
>Código Pix Copia e Cola</label
|
|
||||||
>
|
|
||||||
<div class="relative group">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
readonly
|
|
||||||
:value="submissionStatus?.pix?.copyPasteCode || ''"
|
|
||||||
class="w-full bg-[#F8FAFC] border-[1.5px] border-[#1B3B5F]/20 rounded-xl p-4 pr-28 text-sm text-[#1B3B5F] font-mono focus:outline-none focus:border-[#1E90FF]"
|
|
||||||
/>
|
|
||||||
<div class="absolute right-2 top-1/2 -translate-y-1/2">
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 bg-gray-200 hover:bg-gray-300 rounded text-sm font-medium transition-colors"
|
|
||||||
@click="handleCopyPix"
|
|
||||||
>
|
|
||||||
{{ isCopied ? 'Copiado!' : 'Copiar' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- QR Code Image -->
|
|
||||||
<div
|
|
||||||
v-if="submissionStatus?.pix?.qrCodeValue"
|
|
||||||
class="flex justify-center mt-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="p-4 bg-white rounded-xl shadow-lg border border-gray-100"
|
|
||||||
>
|
|
||||||
<p class="text-xs text-gray-500 mb-2">
|
|
||||||
Escaneie o QR Code no app do seu banco
|
|
||||||
</p>
|
|
||||||
<img
|
|
||||||
:src="`https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(
|
|
||||||
submissionStatus.pix.copyPasteCode
|
|
||||||
)}`"
|
|
||||||
alt="QR Code do PIX"
|
|
||||||
class="w-48 h-48 object-contain mix-blend-multiply"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-4 border-t border-[#1B3B5F]/10">
|
|
||||||
<button
|
|
||||||
class="w-full text-[#9CA3AF] hover:text-[#1B3B5F] py-2"
|
|
||||||
@click="handleResetForm"
|
|
||||||
>
|
|
||||||
Cancelar e Voltar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FORM VIEW -->
|
|
||||||
<form v-else class="space-y-4" @submit.prevent="handleSubmit">
|
|
||||||
<div
|
|
||||||
class="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm space-y-4"
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
class="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2"
|
|
||||||
>
|
|
||||||
Detalhes da Estadia
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<!-- Brand Selection -->
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="brand"
|
|
||||||
class="block text-sm font-medium text-gray-700"
|
|
||||||
>
|
|
||||||
Marca <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="brand"
|
|
||||||
v-model="formData.selectedBrand"
|
|
||||||
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm"
|
|
||||||
@change="handleBrandChange"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Selecione a marca</option>
|
|
||||||
<option
|
|
||||||
v-for="brand in brands"
|
|
||||||
:key="brand.id"
|
|
||||||
:value="String(brand.id)"
|
|
||||||
>
|
|
||||||
{{ brand.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Unit and Duration -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="unit"
|
|
||||||
class="block text-sm font-medium text-gray-700"
|
|
||||||
>Unidade <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="unit"
|
|
||||||
v-model="formData.selectedUnit"
|
|
||||||
:disabled="!unitOptions.length"
|
|
||||||
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
|
|
||||||
@change="handleUnitChange"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Selecione a unidade</option>
|
|
||||||
<option
|
|
||||||
v-for="unit in unitOptions"
|
|
||||||
:key="unit.value"
|
|
||||||
:value="unit.value"
|
|
||||||
>
|
|
||||||
{{ unit.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="duration"
|
|
||||||
class="block text-sm font-medium text-gray-700"
|
|
||||||
>Permanência <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="duration"
|
|
||||||
v-model="formData.stayDuration"
|
|
||||||
:disabled="!durationOptions.length"
|
|
||||||
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Selecione o tempo</option>
|
|
||||||
<option
|
|
||||||
v-for="opt in durationOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
:value="opt.value"
|
|
||||||
>
|
|
||||||
{{ opt.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category -->
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="category"
|
|
||||||
class="block text-sm font-medium text-gray-700"
|
|
||||||
>Categoria da Suíte <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="category"
|
|
||||||
v-model="formData.selectedCategory"
|
|
||||||
:disabled="!categoryOptions.length"
|
|
||||||
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Selecione a categoria</option>
|
|
||||||
<option
|
|
||||||
v-for="opt in categoryOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
:value="opt.value"
|
|
||||||
>
|
|
||||||
{{ opt.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Checkin Date -->
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="checkin"
|
|
||||||
class="block text-sm font-medium text-gray-700"
|
|
||||||
>Data e Horário do Check-in
|
|
||||||
<span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="checkin"
|
|
||||||
v-model="formData.checkInDateTime"
|
|
||||||
type="datetime-local"
|
|
||||||
required
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Photos View -->
|
|
||||||
<div v-if="suiteImage" class="my-6 animate-fade-in">
|
|
||||||
<div
|
|
||||||
class="rounded-2xl overflow-hidden shadow-lg border border-[#1B3B5F]/10"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="suiteImage"
|
|
||||||
alt="Suite Preview"
|
|
||||||
class="w-full h-64 object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Price Display -->
|
|
||||||
<div class="my-8">
|
|
||||||
<div
|
|
||||||
v-if="isPriceLoading"
|
|
||||||
class="text-center p-6 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-6 h-6 border-2 border-[#1E90FF] border-t-transparent rounded-full animate-spin mx-auto mb-2"
|
|
||||||
/>
|
|
||||||
<p class="text-sm text-[#9CA3AF]">Calculando valor...</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="calculatedPrice !== null"
|
|
||||||
class="relative overflow-hidden p-6 bg-[#F8FAFC] border-[1.5px] border-[#1E90FF]/20 rounded-2xl animate-fade-in shadow-lg shadow-[#1E90FF]/5"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute top-0 right-0 bg-[#1E90FF] text-white text-[10px] font-bold px-3 py-1 rounded-bl-lg"
|
|
||||||
>
|
|
||||||
PREÇO ESTIMADO
|
|
||||||
</div>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
|
||||||
class="flex justify-between items-center text-sm text-[#1B3B5F]"
|
|
||||||
>
|
|
||||||
<span class="font-medium">Valor Total da Reserva</span>
|
|
||||||
<span class="font-bold text-lg">{{
|
|
||||||
formatCurrency(calculatedPrice)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex justify-between items-center text-sm text-[#9CA3AF]"
|
|
||||||
>
|
|
||||||
<span>Pagar no check-in (50%)</span>
|
|
||||||
<span class="font-medium">{{
|
|
||||||
formatCurrency(calculatedPrice / 2)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="pt-4 border-t border-[#1B3B5F]/10 flex justify-between items-end"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
class="text-xs font-bold text-[#1E90FF] uppercase tracking-wider mb-1"
|
|
||||||
>
|
|
||||||
Entrada via Pix (50%)
|
|
||||||
</p>
|
|
||||||
<p class="text-[#9CA3AF] text-xs">
|
|
||||||
Necessário para confirmar
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-3xl font-extrabold text-[#1B3B5F] tracking-tight"
|
|
||||||
>
|
|
||||||
{{ formatCurrency(calculatedPrice / 2) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Details -->
|
|
||||||
<div
|
|
||||||
class="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm space-y-4"
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
class="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2"
|
|
||||||
>
|
|
||||||
Seus Dados
|
|
||||||
</h3>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>Nome Completo <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="formData.nome"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="Seu nome completo"
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>Telefone / WhatsApp
|
|
||||||
<span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:value="formData.telefone"
|
|
||||||
type="tel"
|
|
||||||
required
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
|
||||||
placeholder="(99) 99999-9999"
|
|
||||||
maxlength="15"
|
|
||||||
@input="formatPhone"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-400 mt-1 ml-1">
|
|
||||||
Formato: (99) 99999-9999
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>CPF <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:value="formData.cpf"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
|
||||||
placeholder="000.000.000-00"
|
|
||||||
maxlength="14"
|
|
||||||
@input="formatCPF"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>E-mail <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="formData.email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>Observação</label
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
v-model="formData.observacao"
|
|
||||||
rows="2"
|
|
||||||
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="!isFormValid || isLoading || isDataLoading"
|
|
||||||
class="w-full flex justify-center py-4 px-6 border border-transparent rounded-xl shadow-xl shadow-[#1E90FF]/30 hover:shadow-[#1E90FF]/50 text-lg font-bold text-white bg-[#1E90FF] hover:bg-[#1B3B5F] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1E90FF] disabled:bg-gray-300 disabled:shadow-none disabled:cursor-not-allowed transition-all duration-300 uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
{{ isLoading ? 'Processando...' : 'Confirmar e Pagar Reserva' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="text-center text-xs font-medium text-white/40 mt-8">
|
|
||||||
© {{ new Date().getFullYear() }} {{ appConfig.title }} •
|
|
||||||
Experiência Exclusiva
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Global overrides to ensure background covers the entire page */
|
|
||||||
body {
|
|
||||||
background: linear-gradient(135deg, #0a1a2f 0%, #1b3b5f 100%) fixed !important;
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Specific overrides if needed */
|
|
||||||
</style>
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, computed, reactive } from 'vue';
|
import { defineProps, computed, reactive, ref } from 'vue';
|
||||||
import Message from './Message.vue';
|
import Message from './Message.vue';
|
||||||
import { MESSAGE_TYPES } from './constants.js';
|
import { MESSAGE_TYPES } from './constants.js';
|
||||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
@ -48,7 +48,9 @@ const allMessages = computed(() => {
|
|||||||
const currentChat = useMapGetter('getSelectedChat');
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
|
||||||
// Cache for fetched reply messages to avoid duplicate API calls
|
// Cache for fetched reply messages to avoid duplicate API calls
|
||||||
|
// Using a ref to trigger reactivity when messages are fetched
|
||||||
const fetchedReplyMessages = reactive(new Map());
|
const fetchedReplyMessages = reactive(new Map());
|
||||||
|
const fetchTrigger = ref(0); // Trigger to force re-render when async fetch completes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a specific message from the API by trying to get messages around it
|
* Fetches a specific message from the API by trying to get messages around it
|
||||||
@ -62,6 +64,9 @@ const fetchReplyMessage = async (messageId, conversationId) => {
|
|||||||
return fetchedReplyMessages.get(messageId);
|
return fetchedReplyMessages.get(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark as loading to prevent duplicate fetches
|
||||||
|
fetchedReplyMessages.set(messageId, 'loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await MessageApi.getPreviousMessages({
|
const response = await MessageApi.getPreviousMessages({
|
||||||
conversationId,
|
conversationId,
|
||||||
@ -75,14 +80,17 @@ const fetchReplyMessage = async (messageId, conversationId) => {
|
|||||||
if (targetMessage) {
|
if (targetMessage) {
|
||||||
const camelCaseMessage = useCamelCase(targetMessage);
|
const camelCaseMessage = useCamelCase(targetMessage);
|
||||||
fetchedReplyMessages.set(messageId, camelCaseMessage);
|
fetchedReplyMessages.set(messageId, camelCaseMessage);
|
||||||
|
fetchTrigger.value += 1; // Trigger reactivity
|
||||||
return camelCaseMessage;
|
return camelCaseMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache null result to avoid repeated API calls
|
// Cache null result to avoid repeated API calls
|
||||||
fetchedReplyMessages.set(messageId, null);
|
fetchedReplyMessages.set(messageId, null);
|
||||||
|
fetchTrigger.value += 1; // Trigger reactivity
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fetchedReplyMessages.set(messageId, null);
|
fetchedReplyMessages.set(messageId, null);
|
||||||
|
fetchTrigger.value += 1; // Trigger reactivity
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -126,9 +134,14 @@ const shouldGroupWithNext = (index, searchList) => {
|
|||||||
* @returns {Object|null} - The message being replied to, or null if not found
|
* @returns {Object|null} - The message being replied to, or null if not found
|
||||||
*/
|
*/
|
||||||
const getInReplyToMessage = parentMessage => {
|
const getInReplyToMessage = parentMessage => {
|
||||||
|
// Access fetchTrigger to make this function reactive to async fetches
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
fetchTrigger.value;
|
||||||
|
|
||||||
if (!parentMessage) return null;
|
if (!parentMessage) return null;
|
||||||
|
|
||||||
const inReplyToMessageId =
|
const inReplyToMessageId =
|
||||||
|
parentMessage.inReplyToId ??
|
||||||
parentMessage.contentAttributes?.inReplyTo ??
|
parentMessage.contentAttributes?.inReplyTo ??
|
||||||
parentMessage.content_attributes?.in_reply_to;
|
parentMessage.content_attributes?.in_reply_to;
|
||||||
|
|
||||||
@ -144,15 +157,18 @@ const getInReplyToMessage = parentMessage => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then check fetch cache
|
// Then check fetch cache (ignore 'loading' placeholder)
|
||||||
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
|
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
|
||||||
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
|
const cached = fetchedReplyMessages.get(inReplyToMessageId);
|
||||||
|
if (cached && cached !== 'loading') {
|
||||||
|
replyMessage = cached;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still not found and we have conversation context, fetch it
|
// If still not found and we have conversation context, fetch it
|
||||||
if (!replyMessage && currentChat.value?.id) {
|
if (!replyMessage && currentChat.value?.id) {
|
||||||
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
|
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
|
||||||
return null; // Let UI handle loading state
|
return null; // Will re-render when fetchTrigger updates
|
||||||
}
|
}
|
||||||
|
|
||||||
return replyMessage ? useCamelCase(replyMessage) : null;
|
return replyMessage ? useCamelCase(replyMessage) : null;
|
||||||
|
|||||||
@ -63,9 +63,11 @@ const messageClass = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const scrollToMessage = () => {
|
const scrollToMessage = () => {
|
||||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
|
if (inReplyTo.value?.id) {
|
||||||
messageId: inReplyTo.value.id,
|
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
|
||||||
});
|
messageId: inReplyTo.value.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldShowMeta = computed(
|
const shouldShowMeta = computed(
|
||||||
@ -76,7 +78,7 @@ const shouldShowMeta = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const replyToPreview = computed(() => {
|
const replyToPreview = computed(() => {
|
||||||
if (!inReplyTo) return '';
|
if (!inReplyTo.value) return '';
|
||||||
|
|
||||||
const { content, attachments } = inReplyTo.value;
|
const { content, attachments } = inReplyTo.value;
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,54 @@
|
|||||||
"STAYS_PLACEHOLDER": "Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)"
|
"STAYS_PLACEHOLDER": "Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"PRICINGS": {
|
||||||
|
"LIST": {
|
||||||
|
"GLOBAL": "Global (Todos os Inboxes)"
|
||||||
|
},
|
||||||
|
"DELETE_BTN": "Excluir Regra",
|
||||||
|
"DELETE_CONFIRMATION": "Tem certeza que deseja excluir esta regra de preço?",
|
||||||
|
"FIELDS": {
|
||||||
|
"INBOX": "Inbox",
|
||||||
|
"BRAND": "Marca",
|
||||||
|
"DAYS": "Dias",
|
||||||
|
"CATEGORY": "Categoria",
|
||||||
|
"DURATION": "Duração",
|
||||||
|
"PRICE": "Preço",
|
||||||
|
"ACTIONS": "Ações",
|
||||||
|
"PRICE_DISPLAY": "R$ %{price}",
|
||||||
|
"MIN_PRICE": "Preço Mínimo",
|
||||||
|
"MAX_PRICE": "Preço Máximo",
|
||||||
|
"DAY": "Dia",
|
||||||
|
"KEYWORDS": "Palavras-chave"
|
||||||
|
},
|
||||||
|
"FILTERS": {
|
||||||
|
"ALL": "Todas",
|
||||||
|
"ALL_DAYS": "Todos os Dias",
|
||||||
|
"CLEAR": "Limpar Filtros"
|
||||||
|
},
|
||||||
|
"EDIT": "Editar",
|
||||||
|
"DELETE": "Excluir",
|
||||||
|
"HEADER": "Gerenciamento de Tarifas",
|
||||||
|
"TITLE": "Tarifas e Disponibilidade",
|
||||||
|
"DESCRIPTION": "Gerencie os preços das suítes por dia da semana, categoria e duração.",
|
||||||
|
"ADD_BUTTON": "Nova Regra",
|
||||||
|
"EMPTY_STATE": "Nenhuma regra de preço encontrada.",
|
||||||
|
"MODAL": {
|
||||||
|
"EDIT_TITLE": "Editar Regra de Preço",
|
||||||
|
"ADD_TITLE": "Nova Regra de Preço",
|
||||||
|
"SAVE": "Salvar",
|
||||||
|
"CANCEL": "Cancelar",
|
||||||
|
"SELECT_DAYS_REQUIRED": "Selecione pelo menos um dia",
|
||||||
|
"SELECT_CATEGORY": "Selecione uma categoria",
|
||||||
|
"SELECT_BRAND_FIRST": "Selecione uma marca primeiro",
|
||||||
|
"NO_CATEGORIES": "Nenhuma categoria encontrada",
|
||||||
|
"SELECT_DURATION": "Selecione uma duração",
|
||||||
|
"NO_DURATIONS": "Nenhuma duração encontrada",
|
||||||
|
"PRICE_PLACEHOLDER": "0.00",
|
||||||
|
"REMOVE_INBOX": "Remover Inbox",
|
||||||
|
"CLOSE": "Remover"
|
||||||
|
}
|
||||||
|
},
|
||||||
"CUSTOM_TOOLS": {
|
"CUSTOM_TOOLS": {
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"TEST_TOOL": "Testar Ferramenta",
|
"TEST_TOOL": "Testar Ferramenta",
|
||||||
|
|||||||
@ -67,10 +67,13 @@ export default {
|
|||||||
this.brand.suiteCategories || this.brand.suite_categories || [];
|
this.brand.suiteCategories || this.brand.suite_categories || [];
|
||||||
const images =
|
const images =
|
||||||
this.brand.suiteImages || this.brand.suite_images || {};
|
this.brand.suiteImages || this.brand.suite_images || {};
|
||||||
|
const keywords =
|
||||||
|
this.brand.suiteKeywords || this.brand.suite_keywords || {};
|
||||||
|
|
||||||
this.suiteCategories = categories.map(cat => ({
|
this.suiteCategories = categories.map(cat => ({
|
||||||
name: cat,
|
name: cat,
|
||||||
image: images[cat] || '',
|
image: images[cat] || '',
|
||||||
|
keywords: keywords[cat] || '',
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
@ -85,6 +88,7 @@ export default {
|
|||||||
this.stayDurations = '';
|
this.stayDurations = '';
|
||||||
this.newCategoryName = '';
|
this.newCategoryName = '';
|
||||||
this.newCategoryImage = '';
|
this.newCategoryImage = '';
|
||||||
|
this.newCategoryKeywords = '';
|
||||||
this.v$.$reset();
|
this.v$.$reset();
|
||||||
},
|
},
|
||||||
addCategory() {
|
addCategory() {
|
||||||
@ -92,9 +96,11 @@ export default {
|
|||||||
this.suiteCategories.push({
|
this.suiteCategories.push({
|
||||||
name: this.newCategoryName,
|
name: this.newCategoryName,
|
||||||
image: this.newCategoryImage,
|
image: this.newCategoryImage,
|
||||||
|
keywords: this.newCategoryKeywords,
|
||||||
});
|
});
|
||||||
this.newCategoryName = '';
|
this.newCategoryName = '';
|
||||||
this.newCategoryImage = '';
|
this.newCategoryImage = '';
|
||||||
|
this.newCategoryKeywords = '';
|
||||||
},
|
},
|
||||||
removeCategory(index) {
|
removeCategory(index) {
|
||||||
this.suiteCategories.splice(index, 1);
|
this.suiteCategories.splice(index, 1);
|
||||||
@ -108,11 +114,16 @@ export default {
|
|||||||
if (curr.image) acc[curr.name] = curr.image;
|
if (curr.image) acc[curr.name] = curr.image;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
const keywords = this.suiteCategories.reduce((acc, curr) => {
|
||||||
|
if (curr.keywords) acc[curr.name] = curr.keywords;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
suite_categories: categories,
|
suite_categories: categories,
|
||||||
suite_images: images,
|
suite_images: images,
|
||||||
|
suite_keywords: keywords,
|
||||||
stay_durations: this.stayDurations
|
stay_durations: this.stayDurations
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(s => s.trim())
|
.map(s => s.trim())
|
||||||
@ -126,21 +137,25 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- eslint-disable vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
|
<!-- eslint-disable vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
|
||||||
<WootModal :show="show" :on-close="() => $emit('close')">
|
<WootModal
|
||||||
|
:show="show"
|
||||||
|
:on-close="() => $emit('close')"
|
||||||
|
class-name="!max-w-5xl !w-full"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col w-[600px] bg-white dark:bg-slate-900 rounded-lg shadow-xl overflow-hidden"
|
class="flex flex-col w-full bg-white dark:bg-slate-900 rounded-lg shadow-xl overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div
|
<div
|
||||||
class="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center"
|
class="px-6 py-3 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center"
|
||||||
>
|
>
|
||||||
<h2 class="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
<h2 class="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable Body -->
|
<!-- Body -->
|
||||||
<div class="flex-1 overflow-y-auto p-6 max-h-[65vh] flex flex-col gap-5">
|
<div class="flex-1 p-5 flex flex-col gap-3">
|
||||||
<!-- Brand Name -->
|
<!-- Brand Name -->
|
||||||
<WootInput
|
<WootInput
|
||||||
v-model="name"
|
v-model="name"
|
||||||
@ -151,9 +166,9 @@ export default {
|
|||||||
|
|
||||||
<!-- Suite Categories -->
|
<!-- Suite Categories -->
|
||||||
<div
|
<div
|
||||||
class="bg-slate-50 dark:bg-slate-800 p-4 rounded-lg border border-slate-200 dark:border-slate-700"
|
class="bg-slate-50 dark:bg-slate-800 p-3 rounded-lg border border-slate-200 dark:border-slate-700"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<label
|
<label
|
||||||
class="block text-sm font-medium text-slate-700 dark:text-slate-200"
|
class="block text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||||
>
|
>
|
||||||
@ -161,45 +176,70 @@ export default {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 mb-3">
|
<!-- Add New Category - Function Bar -->
|
||||||
|
<div class="flex flex-col gap-2 mb-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="newCategoryName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nome (Ex: Standard)"
|
||||||
|
class="flex-1 text-sm py-1.5 border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 placeholder:text-slate-400"
|
||||||
|
@keydown.enter.prevent="addCategory"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="newCategoryImage"
|
||||||
|
type="text"
|
||||||
|
placeholder="URL da Imagem"
|
||||||
|
class="flex-1 text-sm py-1.5 border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 placeholder:text-slate-400"
|
||||||
|
@keydown.enter.prevent="addCategory"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium shrink-0"
|
||||||
|
title="Adicionar Categoria"
|
||||||
|
@click.prevent="addCategory"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-plus" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="newCategoryName"
|
v-model="newCategoryKeywords"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Nome (Ex: Standard)"
|
placeholder="Palavras-chave (Ex: jacuzzi, hidro) - Opcional"
|
||||||
class="flex-1 text-sm border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100"
|
class="w-full text-sm py-1.5 border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 placeholder:text-slate-400"
|
||||||
@keydown.enter.prevent="addCategory"
|
@keydown.enter.prevent="addCategory"
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
v-model="newCategoryImage"
|
|
||||||
type="text"
|
|
||||||
placeholder="URL da Imagem"
|
|
||||||
class="flex-1 text-sm border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100"
|
|
||||||
@keydown.enter.prevent="addCategory"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium"
|
|
||||||
@click.prevent="addCategory"
|
|
||||||
>
|
|
||||||
<i class="i-lucide-plus" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="suiteCategories.length > 0" class="space-y-2">
|
<!-- Categories Grid -->
|
||||||
|
<div v-if="suiteCategories.length > 0" class="grid grid-cols-2 gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="(cat, idx) in suiteCategories"
|
v-for="(cat, idx) in suiteCategories"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="flex items-center justify-between bg-white dark:bg-slate-900 p-2 rounded border border-slate-200 dark:border-slate-600"
|
class="flex items-center justify-between bg-white dark:bg-slate-900 p-1.5 rounded border border-slate-200 dark:border-slate-600"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col flex-1 min-w-0 mr-2">
|
||||||
<span
|
<div class="flex items-center gap-2">
|
||||||
class="font-medium text-sm text-slate-800 dark:text-slate-100"
|
<span
|
||||||
>
|
class="font-medium text-sm text-slate-800 dark:text-slate-100"
|
||||||
{{ cat.name }}
|
>
|
||||||
</span>
|
{{ cat.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="cat.keywords"
|
||||||
|
class="text-xs text-slate-500 truncate"
|
||||||
|
:title="cat.keywords"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-key size-3 inline-block mr-0.5" />
|
||||||
|
{{ cat.keywords }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
v-if="cat.image"
|
v-if="cat.image"
|
||||||
class="text-xs text-slate-500 truncate max-w-[200px]"
|
class="text-xs text-slate-400 truncate"
|
||||||
|
:title="cat.image"
|
||||||
>
|
>
|
||||||
|
<i class="i-lucide-image size-3 inline-block mr-0.5" />
|
||||||
{{ cat.image }}
|
{{ cat.image }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -216,7 +256,7 @@ export default {
|
|||||||
v-else
|
v-else
|
||||||
class="text-sm text-slate-500 dark:text-slate-400 italic text-center py-2"
|
class="text-sm text-slate-500 dark:text-slate-400 italic text-center py-2"
|
||||||
>
|
>
|
||||||
Adicione as categorias de quartos disponíveis para esta marca.
|
Nenhuma categoria adicionada.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -224,7 +264,7 @@ export default {
|
|||||||
<WootInput
|
<WootInput
|
||||||
v-model="stayDurations"
|
v-model="stayDurations"
|
||||||
label="Durações Aceitas"
|
label="Durações Aceitas"
|
||||||
placeholder="Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)"
|
placeholder="Ex: 2h, 4h, Pernoite, Diária"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -122,9 +122,11 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteDialogRef = ref(null);
|
||||||
|
|
||||||
const confirmDelete = pricing => {
|
const confirmDelete = pricing => {
|
||||||
pricingToDelete.value = pricing;
|
pricingToDelete.value = pricing;
|
||||||
showDeleteConfirmation.value = true;
|
deleteDialogRef.value.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const deletePricing = async () => {
|
const deletePricing = async () => {
|
||||||
@ -341,15 +343,27 @@ const clearFilters = () => {
|
|||||||
? pricing.inboxNames.join(', ')
|
? pricing.inboxNames.join(', ')
|
||||||
: pricing.inbox_names?.length
|
: pricing.inbox_names?.length
|
||||||
? pricing.inbox_names.join(', ')
|
? pricing.inbox_names.join(', ')
|
||||||
: pricing.inboxName ||
|
: inboxes.find(
|
||||||
pricing.inbox_name ||
|
i => String(i.id) === String(pricing.inbox_id)
|
||||||
pricing.inbox_id
|
)?.name || 'Global (Todos os Inboxes)'
|
||||||
}}
|
}}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="px-6 py-4 font-medium text-slate-900 dark:text-slate-100"
|
class="px-6 py-4 font-medium text-slate-900 dark:text-slate-100"
|
||||||
>
|
>
|
||||||
{{ pricing.brandName || pricing.brand_id }}
|
{{
|
||||||
|
brands.find(
|
||||||
|
b =>
|
||||||
|
String(b.id) === String(pricing.captain_brand_id) ||
|
||||||
|
String(b.id) === String(pricing.brand_id) ||
|
||||||
|
String(b.id) === String(pricing.captainBrandId) ||
|
||||||
|
String(b.id) === String(pricing.brandId)
|
||||||
|
)?.name ||
|
||||||
|
pricing.captain_brand_id ||
|
||||||
|
pricing.brand_id ||
|
||||||
|
pricing.captainBrandId ||
|
||||||
|
pricing.brandId
|
||||||
|
}}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-slate-600 dark:text-slate-300">
|
<td class="px-6 py-4 text-slate-600 dark:text-slate-300">
|
||||||
{{ pricing.dayRange || pricing.day_range }}
|
{{ pricing.dayRange || pricing.day_range }}
|
||||||
@ -374,13 +388,13 @@ const clearFilters = () => {
|
|||||||
class="text-blue-600 hover:text-blue-800 font-medium"
|
class="text-blue-600 hover:text-blue-800 font-medium"
|
||||||
@click="openEditModal(pricing)"
|
@click="openEditModal(pricing)"
|
||||||
>
|
>
|
||||||
{{ $t('CAPTAIN.RESERVATIONS.AUTOMATIONS.EDIT') }}
|
{{ $t('CAPTAIN.PRICINGS.EDIT') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="text-red-600 hover:text-red-800 font-medium transition-colors"
|
class="text-red-600 hover:text-red-800 font-medium transition-colors"
|
||||||
@click="confirmDelete(pricing)"
|
@click="confirmDelete(pricing)"
|
||||||
>
|
>
|
||||||
{{ $t('CAPTAIN.RESERVATIONS.AUTOMATIONS.DELETE') }}
|
{{ $t('CAPTAIN.PRICINGS.DELETE') }}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -396,6 +410,7 @@ const clearFilters = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PricingModal
|
<PricingModal
|
||||||
|
:key="selectedPricing?.id || 'new'"
|
||||||
:show="showModal"
|
:show="showModal"
|
||||||
:pricing="selectedPricing"
|
:pricing="selectedPricing"
|
||||||
:brands="brands"
|
:brands="brands"
|
||||||
@ -405,12 +420,14 @@ const clearFilters = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
:show="showDeleteConfirmation"
|
ref="deleteDialogRef"
|
||||||
:title="t('CAPTAIN.PRICINGS.DELETE_BUTTON')"
|
:title="$t('CAPTAIN.PRICINGS.DELETE_BTN')"
|
||||||
:message="t('CAPTAIN.PRICINGS.DELETE_CONFIRMATION')"
|
:description="$t('CAPTAIN.PRICINGS.DELETE_CONFIRMATION')"
|
||||||
:confirm-text="t('CAPTAIN.PRICINGS.DELETE_BUTTON')"
|
:confirm-button-label="$t('CAPTAIN.PRICINGS.DELETE_BTN')"
|
||||||
:cancel-text="t('CAPTAIN.BRAND_MODAL.CANCEL')"
|
:cancel-button-label="$t('CAPTAIN.PRICINGS.MODAL.CANCEL')"
|
||||||
variant="danger"
|
type="alert"
|
||||||
|
show-cancel-button
|
||||||
|
show-confirm-button
|
||||||
@close="showDeleteConfirmation = false"
|
@close="showDeleteConfirmation = false"
|
||||||
@confirm="deletePricing"
|
@confirm="deletePricing"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
@ -58,14 +59,23 @@ const toggleDay = day => {
|
|||||||
(a, b) => daysOptions.indexOf(a) - daysOptions.indexOf(b)
|
(a, b) => daysOptions.indexOf(a) - daysOptions.indexOf(b)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
formData.value.day_range = selectedDays.value.join(', ');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedDays,
|
||||||
|
newDays => {
|
||||||
|
formData.value.day_range = newDays.join(', ');
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
const parseDays = rangeString => {
|
const parseDays = rangeString => {
|
||||||
if (!rangeString) return [];
|
if (!rangeString) return [];
|
||||||
|
const normalizedRange = rangeString.toUpperCase();
|
||||||
|
|
||||||
// Handle "SEGUNDA A QUARTA" range format legacy support
|
// Handle "SEGUNDA A QUARTA" range format legacy support
|
||||||
if (rangeString.includes(' A ')) {
|
if (normalizedRange.includes(' A ')) {
|
||||||
const [start, end] = rangeString.split(' A ');
|
const [start, end] = normalizedRange.split(' A ');
|
||||||
const startIndex = daysOptions.indexOf(start);
|
const startIndex = daysOptions.indexOf(start);
|
||||||
const endIndex = daysOptions.indexOf(end);
|
const endIndex = daysOptions.indexOf(end);
|
||||||
if (startIndex !== -1 && endIndex !== -1 && startIndex <= endIndex) {
|
if (startIndex !== -1 && endIndex !== -1 && startIndex <= endIndex) {
|
||||||
@ -73,8 +83,8 @@ const parseDays = rangeString => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handle comma separated
|
// Handle comma separated
|
||||||
return rangeString
|
return normalizedRange
|
||||||
.split(', ')
|
.split(',')
|
||||||
.map(s => s.trim())
|
.map(s => s.trim())
|
||||||
.filter(s => daysOptions.includes(s));
|
.filter(s => daysOptions.includes(s));
|
||||||
};
|
};
|
||||||
@ -96,7 +106,10 @@ const removeInbox = inboxId => {
|
|||||||
const isEditing = computed(() => !!props.pricing?.id); // Changed to check for pricing.id to correctly identify editing mode
|
const isEditing = computed(() => !!props.pricing?.id); // Changed to check for pricing.id to correctly identify editing mode
|
||||||
|
|
||||||
const selectedBrand = computed(() => {
|
const selectedBrand = computed(() => {
|
||||||
return props.brands.find(b => b.id === formData.value.captain_brand_id);
|
if (!formData.value.captain_brand_id) return undefined;
|
||||||
|
return props.brands.find(
|
||||||
|
b => String(b.id) === String(formData.value.captain_brand_id)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const brandCategories = computed(() => {
|
const brandCategories = computed(() => {
|
||||||
@ -126,11 +139,23 @@ watch(
|
|||||||
let normalizedInboxIds = [];
|
let normalizedInboxIds = [];
|
||||||
if (newVal.inbox_ids?.length) {
|
if (newVal.inbox_ids?.length) {
|
||||||
normalizedInboxIds = newVal.inbox_ids;
|
normalizedInboxIds = newVal.inbox_ids;
|
||||||
} else if (newVal.inbox_id) {
|
} else if (newVal.inbox_id || newVal.inboxId) {
|
||||||
normalizedInboxIds = [newVal.inbox_id];
|
normalizedInboxIds = [newVal.inbox_id || newVal.inboxId];
|
||||||
}
|
}
|
||||||
formData.value = { ...newVal, inbox_ids: normalizedInboxIds };
|
formData.value = {
|
||||||
selectedDays.value = parseDays(newVal.day_range || newVal.dayRange);
|
captain_brand_id:
|
||||||
|
newVal.captain_brand_id ||
|
||||||
|
newVal.captainBrandId ||
|
||||||
|
newVal.brand_id ||
|
||||||
|
newVal.brandId ||
|
||||||
|
'',
|
||||||
|
inbox_ids: normalizedInboxIds,
|
||||||
|
day_range: newVal.day_range || newVal.dayRange || '',
|
||||||
|
suite_category: newVal.suite_category || newVal.suiteCategory || '',
|
||||||
|
duration: newVal.duration || '',
|
||||||
|
price: newVal.price || '',
|
||||||
|
};
|
||||||
|
selectedDays.value = parseDays(formData.value.day_range);
|
||||||
selectedInboxes.value = normalizedInboxIds;
|
selectedInboxes.value = normalizedInboxIds;
|
||||||
} else {
|
} else {
|
||||||
formData.value = {
|
formData.value = {
|
||||||
@ -256,7 +281,7 @@ const savePricing = async () => {
|
|||||||
class="px-3 py-1.5 text-xs font-medium rounded-full border transition-colors"
|
class="px-3 py-1.5 text-xs font-medium rounded-full border transition-colors"
|
||||||
:class="[
|
:class="[
|
||||||
selectedDays.includes(day)
|
selectedDays.includes(day)
|
||||||
? 'bg-indigo-600 text-white border-indigo-600 shadow-sm'
|
? '!bg-blue-600 text-white border-blue-600 shadow-md'
|
||||||
: 'bg-white text-slate-600 border-slate-300 hover:border-slate-400 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700',
|
: 'bg-white text-slate-600 border-slate-300 hover:border-slate-400 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700',
|
||||||
]"
|
]"
|
||||||
@click="toggleDay(day)"
|
@click="toggleDay(day)"
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
import { createApp } from 'vue';
|
|
||||||
import BookingApp from '../captain_booking/App.vue';
|
|
||||||
import '../captain_booking/assets/main.css';
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const app = createApp(BookingApp);
|
|
||||||
app.mount('#root');
|
|
||||||
});
|
|
||||||
16
app/javascript/entrypoints/captain_booking.tsx
Normal file
16
app/javascript/entrypoints/captain_booking.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from '../../../enterprise/app/javascript/captain_booking_app/App';
|
||||||
|
import '../../../enterprise/app/javascript/captain_booking_app/index.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (container) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('Root element not found');
|
||||||
|
}
|
||||||
@ -37,11 +37,12 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
|||||||
return unless params[:phone_number]
|
return unless params[:phone_number]
|
||||||
|
|
||||||
# Try exact match first, then try adding + if missing
|
# Try exact match first, then try adding + if missing
|
||||||
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
phone = params[:phone_number].to_s.strip
|
||||||
|
channel = Channel::Whatsapp.find_by(phone_number: phone)
|
||||||
return channel if channel
|
return channel if channel
|
||||||
|
|
||||||
# If params[:phone_number] doesn't have +, try adding it
|
# If phone doesn't have +, try adding it
|
||||||
Channel::Whatsapp.find_by(phone_number: "+#{params[:phone_number]}")
|
Channel::Whatsapp.find_by(phone_number: "+#{phone}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_channel_from_whatsapp_business_payload(params)
|
def find_channel_from_whatsapp_business_payload(params)
|
||||||
|
|||||||
@ -30,6 +30,29 @@ class WebhookListener < BaseListener
|
|||||||
|
|
||||||
payload = message.webhook_data.merge(event: __method__.to_s)
|
payload = message.webhook_data.merge(event: __method__.to_s)
|
||||||
deliver_webhook_payloads(payload, inbox)
|
deliver_webhook_payloads(payload, inbox)
|
||||||
|
|
||||||
|
message_incoming(event)
|
||||||
|
message_outgoing(event)
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_incoming(event)
|
||||||
|
message = extract_message_and_account(event)[0]
|
||||||
|
|
||||||
|
return unless message.webhook_sendable?
|
||||||
|
return unless message.incoming?
|
||||||
|
|
||||||
|
payload = message.webhook_data.merge(event: __method__.to_s)
|
||||||
|
deliver_account_webhooks(payload, message.account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_outgoing(event)
|
||||||
|
message = extract_message_and_account(event)[0]
|
||||||
|
|
||||||
|
return unless message.webhook_sendable?
|
||||||
|
return unless message.outgoing?
|
||||||
|
|
||||||
|
payload = message.webhook_data.merge(event: __method__.to_s)
|
||||||
|
deliver_account_webhooks(payload, message.account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_updated(event)
|
def message_updated(event)
|
||||||
@ -91,6 +114,19 @@ class WebhookListener < BaseListener
|
|||||||
handle_typing_status(__method__.to_s, event)
|
handle_typing_status(__method__.to_s, event)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def provider_event_received(event)
|
||||||
|
inbox, account = extract_inbox_and_account(event)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
event: __method__.to_s,
|
||||||
|
inbox: inbox.webhook_data,
|
||||||
|
account: account.webhook_data,
|
||||||
|
provider_event: event.data[:event],
|
||||||
|
provider_event_data: event.data[:payload]
|
||||||
|
}
|
||||||
|
deliver_account_webhooks(payload, account)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def handle_typing_status(event_name, event)
|
def handle_typing_status(event_name, event)
|
||||||
@ -110,6 +146,7 @@ class WebhookListener < BaseListener
|
|||||||
def deliver_account_webhooks(payload, account)
|
def deliver_account_webhooks(payload, account)
|
||||||
account.webhooks.account_type.each do |webhook|
|
account.webhooks.account_type.each do |webhook|
|
||||||
next unless webhook.subscriptions.include?(payload[:event])
|
next unless webhook.subscriptions.include?(payload[:event])
|
||||||
|
next if payload[:inbox].present? && webhook.inbox_id.present? && webhook.inbox_id != payload[:inbox][:id]
|
||||||
|
|
||||||
WebhookJob.perform_later(webhook.url, payload)
|
WebhookJob.perform_later(webhook.url, payload)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,3 +1,30 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: captain_pricings
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# day_range :string not null
|
||||||
|
# duration :string not null
|
||||||
|
# keywords :text
|
||||||
|
# price :decimal(10, 2) not null
|
||||||
|
# suite_category :string not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# account_id :bigint not null
|
||||||
|
# captain_brand_id :bigint not null
|
||||||
|
# inbox_id :bigint
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_captain_pricings_on_account_id (account_id)
|
||||||
|
# index_captain_pricings_on_captain_brand_id (captain_brand_id)
|
||||||
|
# index_captain_pricings_on_inbox_id (inbox_id)
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (account_id => accounts.id)
|
||||||
|
# fk_rails_... (captain_brand_id => captain_brands.id)
|
||||||
|
#
|
||||||
module Captain
|
module Captain
|
||||||
class Pricing < ApplicationRecord
|
class Pricing < ApplicationRecord
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
|
|||||||
@ -1,3 +1,53 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: captain_reservations
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# check_in_at :datetime not null
|
||||||
|
# check_out_at :datetime not null
|
||||||
|
# created_by_type :string
|
||||||
|
# metadata :jsonb not null
|
||||||
|
# payment_status :string default("pending")
|
||||||
|
# status :integer default("scheduled"), not null
|
||||||
|
# suite_identifier :string
|
||||||
|
# total_amount :decimal(10, 2)
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# account_id :bigint not null
|
||||||
|
# captain_brand_id :bigint
|
||||||
|
# captain_unit_id :bigint
|
||||||
|
# contact_id :bigint not null
|
||||||
|
# contact_inbox_id :bigint not null
|
||||||
|
# conversation_id :bigint
|
||||||
|
# created_by_id :bigint
|
||||||
|
# current_pix_charge_id :bigint
|
||||||
|
# inbox_id :bigint not null
|
||||||
|
# integracao_id :string
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_captain_reservations_on_account_id (account_id)
|
||||||
|
# index_captain_reservations_on_account_id_and_inbox_id (account_id,inbox_id)
|
||||||
|
# index_captain_reservations_on_captain_brand_id (captain_brand_id)
|
||||||
|
# index_captain_reservations_on_captain_unit_id (captain_unit_id)
|
||||||
|
# index_captain_reservations_on_contact_id (contact_id)
|
||||||
|
# index_captain_reservations_on_contact_id_and_inbox_id (contact_id,inbox_id)
|
||||||
|
# index_captain_reservations_on_contact_inbox_id (contact_inbox_id)
|
||||||
|
# index_captain_reservations_on_conversation_id (conversation_id)
|
||||||
|
# index_captain_reservations_on_inbox_id (inbox_id)
|
||||||
|
# index_captain_reservations_on_integracao_id (integracao_id)
|
||||||
|
# index_captain_reservations_on_integracao_id_and_unit_id (integracao_id,captain_unit_id) UNIQUE
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (account_id => accounts.id)
|
||||||
|
# fk_rails_... (captain_brand_id => captain_brands.id)
|
||||||
|
# fk_rails_... (captain_unit_id => captain_units.id)
|
||||||
|
# fk_rails_... (contact_id => contacts.id)
|
||||||
|
# fk_rails_... (contact_inbox_id => contact_inboxes.id)
|
||||||
|
# fk_rails_... (conversation_id => conversations.id)
|
||||||
|
# fk_rails_... (inbox_id => inboxes.id)
|
||||||
|
#
|
||||||
module Captain
|
module Captain
|
||||||
class Reservation < ApplicationRecord
|
class Reservation < ApplicationRecord
|
||||||
self.table_name = 'captain_reservations'
|
self.table_name = 'captain_reservations'
|
||||||
|
|||||||
@ -1,3 +1,43 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: captain_units
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# inter_account_number :string
|
||||||
|
# inter_cert_path :string
|
||||||
|
# inter_client_secret :string
|
||||||
|
# inter_key_path :string
|
||||||
|
# inter_pix_key :string
|
||||||
|
# last_synced_at :datetime
|
||||||
|
# leader_whatsapp :string
|
||||||
|
# name :string not null
|
||||||
|
# plug_play_token :string
|
||||||
|
# reservation_source_tag :string
|
||||||
|
# reservations_sync_enabled :boolean
|
||||||
|
# status :string
|
||||||
|
# suite_category_images :jsonb not null
|
||||||
|
# visible_suite_categories :jsonb not null
|
||||||
|
# webhook_url :string
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# account_id :bigint not null
|
||||||
|
# captain_brand_id :bigint not null
|
||||||
|
# inbox_id :bigint
|
||||||
|
# inter_client_id :string
|
||||||
|
# plug_play_id :string
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_captain_units_on_account_id (account_id)
|
||||||
|
# index_captain_units_on_captain_brand_id (captain_brand_id)
|
||||||
|
# index_captain_units_on_inbox_id (inbox_id)
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (account_id => accounts.id)
|
||||||
|
# fk_rails_... (captain_brand_id => captain_brands.id)
|
||||||
|
# fk_rails_... (inbox_id => inboxes.id)
|
||||||
|
#
|
||||||
module Captain
|
module Captain
|
||||||
class Unit < ApplicationRecord
|
class Unit < ApplicationRecord
|
||||||
self.table_name = 'captain_units'
|
self.table_name = 'captain_units'
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
# phone_number :string not null
|
# phone_number :string not null
|
||||||
# provider :string default("default")
|
# provider :string default("default")
|
||||||
# provider_config :jsonb
|
# provider_config :jsonb
|
||||||
|
# provider_connection :jsonb
|
||||||
# wuzapi_admin_token :string
|
# wuzapi_admin_token :string
|
||||||
# wuzapi_admin_token_iv :string
|
# wuzapi_admin_token_iv :string
|
||||||
# wuzapi_user_token :string
|
# wuzapi_user_token :string
|
||||||
@ -18,7 +19,8 @@
|
|||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
|
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
|
||||||
|
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin
|
||||||
#
|
#
|
||||||
|
|
||||||
class Channel::Whatsapp < ApplicationRecord
|
class Channel::Whatsapp < ApplicationRecord
|
||||||
|
|||||||
@ -2,13 +2,14 @@
|
|||||||
#
|
#
|
||||||
# Table name: dashboard_apps
|
# Table name: dashboard_apps
|
||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# content :jsonb
|
# content :jsonb
|
||||||
# title :string not null
|
# show_on_sidebar :boolean default(FALSE), not null
|
||||||
# created_at :datetime not null
|
# title :string not null
|
||||||
# updated_at :datetime not null
|
# created_at :datetime not null
|
||||||
# account_id :bigint not null
|
# updated_at :datetime not null
|
||||||
# user_id :bigint
|
# account_id :bigint not null
|
||||||
|
# user_id :bigint
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|||||||
@ -106,7 +106,7 @@ class Message < ApplicationRecord
|
|||||||
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
|
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
|
||||||
# [:external_error : Can specify if the message creation failed due to an error at external API
|
# [:external_error : Can specify if the message creation failed due to an error at external API
|
||||||
# [:data] : Used for structured content types such as voice_call
|
# [:data] : Used for structured content types such as voice_call
|
||||||
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
|
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :deleted,
|
||||||
:external_created_at, :story_sender, :story_id, :external_error,
|
:external_created_at, :story_sender, :story_id, :external_error,
|
||||||
:translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON
|
:translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON
|
||||||
|
|
||||||
@ -127,6 +127,7 @@ class Message < ApplicationRecord
|
|||||||
belongs_to :inbox
|
belongs_to :inbox
|
||||||
belongs_to :conversation, touch: true
|
belongs_to :conversation, touch: true
|
||||||
belongs_to :sender, polymorphic: true, optional: true
|
belongs_to :sender, polymorphic: true, optional: true
|
||||||
|
belongs_to :in_reply_to, class_name: 'Message', optional: true
|
||||||
|
|
||||||
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
|
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
|
||||||
has_one :csat_survey_response, dependent: :destroy_async
|
has_one :csat_survey_response, dependent: :destroy_async
|
||||||
@ -257,16 +258,30 @@ class Message < ApplicationRecord
|
|||||||
# Returns message content suitable for LLM consumption
|
# Returns message content suitable for LLM consumption
|
||||||
# Falls back to audio transcription or attachment placeholder when content is nil
|
# Falls back to audio transcription or attachment placeholder when content is nil
|
||||||
def content_for_llm
|
def content_for_llm
|
||||||
return content if content.present?
|
parts = []
|
||||||
|
parts << content if content.present?
|
||||||
|
|
||||||
audio_transcription = attachments
|
# Audio handling
|
||||||
.where(file_type: :audio)
|
audio_transcriptions = attachments
|
||||||
.filter_map { |att| att.meta&.dig('transcribed_text') }
|
.where(file_type: :audio)
|
||||||
.join(' ')
|
.filter_map { |att| att.meta&.dig('transcribed_text') }
|
||||||
.presence
|
|
||||||
return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
|
|
||||||
|
|
||||||
'[Attachment]' if attachments.any?
|
parts << "[Mensagem de Voz]: #{audio_transcriptions.join(' ')}" if audio_transcriptions.any?
|
||||||
|
|
||||||
|
# Image handling (placeholders if not analyzed yet)
|
||||||
|
image_descriptions = attachments
|
||||||
|
.where(file_type: :image)
|
||||||
|
.filter_map { |att| att.meta&.dig('description') }
|
||||||
|
|
||||||
|
parts << "[Imagem]: #{image_descriptions.join(', ')}" if image_descriptions.any?
|
||||||
|
|
||||||
|
# Fallback for other attachments
|
||||||
|
if parts.empty? && attachments.any?
|
||||||
|
other_types = attachments.where.not(file_type: [:audio, :image]).pluck(:file_type).uniq
|
||||||
|
parts << "[Anexo: #{other_types.join(', ')}]"
|
||||||
|
end
|
||||||
|
|
||||||
|
parts.join("\n").presence || ''
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class Webhook < ApplicationRecord
|
|||||||
enum webhook_type: { account_type: 0, inbox_type: 1 }
|
enum webhook_type: { account_type: 0, inbox_type: 1 }
|
||||||
|
|
||||||
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
|
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
|
||||||
message_created message_updated webwidget_triggered inbox_created inbox_updated
|
message_created message_incoming message_outgoing message_updated webwidget_triggered inbox_created inbox_updated
|
||||||
conversation_typing_on conversation_typing_off].freeze
|
conversation_typing_on conversation_typing_off].freeze
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@ -65,7 +65,7 @@ class Conversations::MessageWindowService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_messaging_window
|
def whatsapp_messaging_window
|
||||||
return nil if @conversation.inbox.channel.provider == 'wuzapi'
|
return nil if @conversation.inbox.channel&.provider == 'wuzapi'
|
||||||
|
|
||||||
MESSAGING_WINDOW_24_HOURS
|
MESSAGING_WINDOW_24_HOURS
|
||||||
end
|
end
|
||||||
|
|||||||
@ -26,17 +26,20 @@ module Jasmine
|
|||||||
end
|
end
|
||||||
|
|
||||||
def respond
|
def respond
|
||||||
intent = IntentDetector.new(message.content, intent_keywords).detect
|
trigger_media_analysis if message.attachments.any?
|
||||||
|
llm_content = message.content_for_llm
|
||||||
|
|
||||||
|
intent = IntentDetector.new(llm_content, intent_keywords).detect
|
||||||
strategy = StrategyDecider.new(intent, jasmine_state).decide
|
strategy = StrategyDecider.new(intent, jasmine_state).decide
|
||||||
|
|
||||||
rag_context = fetch_rag_if_needed(strategy, message.content)
|
rag_context = fetch_rag_if_needed(strategy, llm_content)
|
||||||
|
|
||||||
prompt = PromptAssembler.new(
|
prompt = PromptAssembler.new(
|
||||||
config: config,
|
config: config,
|
||||||
state: jasmine_state,
|
state: jasmine_state,
|
||||||
history: recent_history,
|
history: recent_history,
|
||||||
rag_context: rag_context,
|
rag_context: rag_context,
|
||||||
current_message: message.content
|
current_message: llm_content
|
||||||
).assemble
|
).assemble
|
||||||
|
|
||||||
response = call_llm(prompt)
|
response = call_llm(prompt)
|
||||||
@ -114,6 +117,15 @@ module Jasmine
|
|||||||
Rails.logger.info "[Jasmine::Brain] Intent: #{intent}, Strategy: #{strategy}, RAG: #{rag_context.present? ? 'yes' : 'no'}"
|
Rails.logger.info "[Jasmine::Brain] Intent: #{intent}, Strategy: #{strategy}, RAG: #{rag_context.present? ? 'yes' : 'no'}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def trigger_media_analysis
|
||||||
|
Rails.logger.info "[Jasmine::Brain] Triggering Media Analysis for Message #{message.id}"
|
||||||
|
Jasmine::MediaAnalyzerService.new(message: message).perform
|
||||||
|
message.attachments.reload # CRITICAL: Ensure we see the new metadata
|
||||||
|
Rails.logger.info "[Jasmine::Brain] Media Analysis Completed for Message #{message.id}"
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[Jasmine::Brain] Media analysis failed: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
# =========================================
|
# =========================================
|
||||||
# COMPONENT: Intent Detector
|
# COMPONENT: Intent Detector
|
||||||
# =========================================
|
# =========================================
|
||||||
|
|||||||
53
app/services/jasmine/media_analyzer_service.rb
Normal file
53
app/services/jasmine/media_analyzer_service.rb
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jasmine
|
||||||
|
class MediaAnalyzerService
|
||||||
|
attr_reader :message
|
||||||
|
|
||||||
|
def initialize(message:)
|
||||||
|
@message = message
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
analyze_audio
|
||||||
|
analyze_images
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def analyze_audio
|
||||||
|
message.attachments.where(file_type: :audio).each do |attachment|
|
||||||
|
next if attachment.meta&.dig('transcribed_text').present?
|
||||||
|
|
||||||
|
Rails.logger.info "[Jasmine::MediaAnalyzer] Transcribing audio for Attachment #{attachment.id}"
|
||||||
|
# Try to use the standard AudioTranscriptionService (usually in enterprise folder)
|
||||||
|
begin
|
||||||
|
if defined?(Messages::AudioTranscriptionService)
|
||||||
|
# This service updates the attachment meta internally
|
||||||
|
Messages::AudioTranscriptionService.new(attachment).perform
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[Jasmine::MediaAnalyzer] Audio transcription failed: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def analyze_images
|
||||||
|
message.attachments.where(file_type: :image).each do |attachment|
|
||||||
|
next if attachment.meta&.dig('description').present?
|
||||||
|
|
||||||
|
Rails.logger.info "[Jasmine::MediaAnalyzer] Analyzing image for Attachment #{attachment.id}"
|
||||||
|
begin
|
||||||
|
description = Jasmine::VisionService.new(attachment: attachment).perform
|
||||||
|
if description.present?
|
||||||
|
new_meta = (attachment.meta || {}).merge('description' => description)
|
||||||
|
attachment.update!(meta: new_meta)
|
||||||
|
Rails.logger.info "[Jasmine::MediaAnalyzer] Image analysis successful for Attachment #{attachment.id}"
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[Jasmine::MediaAnalyzer] Image analysis failed: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
69
app/services/jasmine/vision_service.rb
Normal file
69
app/services/jasmine/vision_service.rb
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'openai'
|
||||||
|
require 'base64'
|
||||||
|
|
||||||
|
module Jasmine
|
||||||
|
class VisionService
|
||||||
|
attr_reader :attachment
|
||||||
|
|
||||||
|
def initialize(attachment:)
|
||||||
|
@attachment = attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return nil unless attachment.image?
|
||||||
|
|
||||||
|
api_key = ENV.fetch('OPENAI_API_KEY', nil)
|
||||||
|
return nil if api_key.blank?
|
||||||
|
|
||||||
|
client = OpenAI::Client.new(access_token: api_key)
|
||||||
|
|
||||||
|
image_data = get_image_data
|
||||||
|
return nil if image_data.blank?
|
||||||
|
|
||||||
|
response = client.chat(
|
||||||
|
parameters: {
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Descreva de forma curta e objetiva o que você vê nesta imagem para um sistema de atendimento.' },
|
||||||
|
image_data
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens: 300
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response.dig('choices', 0, 'message', 'content')
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[Jasmine::VisionService] Failed to analyze image: #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def get_image_data
|
||||||
|
# Always return base64 for better compatibility (OpenAI can reach it even if local)
|
||||||
|
# and it avoids issues with signed URL expiration or private buckets.
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
url: "data:#{attachment.file.content_type};base64,#{encode_image}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[Jasmine::VisionService] Data encoding failed: #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def encode_image
|
||||||
|
attachment.file.blob.open do |file|
|
||||||
|
Base64.strict_encode64(file.read)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -10,6 +10,7 @@ class Messages::InReplyToMessageBuilder
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_in_reply_to_attribute
|
def set_in_reply_to_attribute
|
||||||
|
@message.in_reply_to = in_reply_to_message
|
||||||
@message.content_attributes[:in_reply_to_external_id] = in_reply_to_message.try(:source_id)
|
@message.content_attributes[:in_reply_to_external_id] = in_reply_to_message.try(:source_id)
|
||||||
@message.content_attributes[:in_reply_to] = in_reply_to_message.try(:id)
|
@message.content_attributes[:in_reply_to] = in_reply_to_message.try(:id)
|
||||||
end
|
end
|
||||||
|
|||||||
147
app/services/whatsapp/decryption_service.rb
Normal file
147
app/services/whatsapp/decryption_service.rb
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
module Whatsapp
|
||||||
|
class DecryptionService
|
||||||
|
require 'openssl'
|
||||||
|
require 'base64'
|
||||||
|
require 'net/http'
|
||||||
|
|
||||||
|
# HKDF Info strings for different media types (WhatsApp protocol)
|
||||||
|
INFO_STRINGS = {
|
||||||
|
image: 'WhatsApp Image Keys',
|
||||||
|
video: 'WhatsApp Video Keys',
|
||||||
|
audio: 'WhatsApp Audio Keys',
|
||||||
|
document: 'WhatsApp Document Keys',
|
||||||
|
sticker: 'WhatsApp Image Keys'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def initialize(media_url, media_key, media_type)
|
||||||
|
@media_url = media_url
|
||||||
|
@media_key = Base64.decode64(media_key)
|
||||||
|
@media_type = media_type.to_sym
|
||||||
|
@info = INFO_STRINGS[@media_type] || INFO_STRINGS[:document]
|
||||||
|
end
|
||||||
|
|
||||||
|
def decrypt
|
||||||
|
return nil unless @media_url && @media_key
|
||||||
|
|
||||||
|
# 1. Download encrypted bytes
|
||||||
|
encrypted_bytes = download_content
|
||||||
|
return nil unless encrypted_bytes && encrypted_bytes.bytesize > 10
|
||||||
|
|
||||||
|
Rails.logger.info "WuzAPI Decrypt: Downloaded #{encrypted_bytes.bytesize} bytes"
|
||||||
|
|
||||||
|
# 2. Derive keys using HKDF SHA-256 (112 bytes total)
|
||||||
|
expanded_key = OpenSSL::KDF.hkdf(
|
||||||
|
@media_key,
|
||||||
|
salt: ''.b, # Empty binary string
|
||||||
|
info: @info,
|
||||||
|
length: 112,
|
||||||
|
hash: 'sha256'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Split derived key
|
||||||
|
iv = expanded_key[0...16]
|
||||||
|
cipher_key = expanded_key[16...48]
|
||||||
|
# mac_key = expanded_key[48...80] # For verification (optional)
|
||||||
|
# ref_key = expanded_key[80...112] # Not used
|
||||||
|
|
||||||
|
# 4. WhatsApp file structure: [Encrypted Content] + [MAC (10 bytes)]
|
||||||
|
# Remove the last 10 bytes (MAC)
|
||||||
|
cipher_text = encrypted_bytes[0...-10]
|
||||||
|
|
||||||
|
# 5. Try AES-256-CBC first (older WhatsApp versions)
|
||||||
|
decrypted = try_aes_cbc(cipher_key, iv, cipher_text)
|
||||||
|
|
||||||
|
# 6. If CBC fails, try CTR mode (some implementations use this)
|
||||||
|
decrypted ||= try_aes_ctr(cipher_key, iv, cipher_text)
|
||||||
|
|
||||||
|
return nil unless decrypted
|
||||||
|
|
||||||
|
# 7. Validate that we got a valid image (check magic bytes)
|
||||||
|
if valid_media?(decrypted)
|
||||||
|
Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected'
|
||||||
|
StringIO.new(decrypted)
|
||||||
|
else
|
||||||
|
Rails.logger.warn 'WuzAPI Decrypt: Decrypted but invalid media format'
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "WuzAPI Decrypt Error: #{e.class} - #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def try_aes_cbc(key, iv, data)
|
||||||
|
decipher = OpenSSL::Cipher.new('AES-256-CBC')
|
||||||
|
decipher.decrypt
|
||||||
|
decipher.key = key
|
||||||
|
decipher.iv = iv
|
||||||
|
decipher.padding = 0 # WhatsApp doesn't use PKCS7 padding
|
||||||
|
|
||||||
|
decipher.update(data) + decipher.final
|
||||||
|
|
||||||
|
rescue OpenSSL::Cipher::CipherError => e
|
||||||
|
Rails.logger.debug { "AES-CBC failed: #{e.message}" }
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def try_aes_ctr(key, iv, data)
|
||||||
|
decipher = OpenSSL::Cipher.new('AES-256-CTR')
|
||||||
|
decipher.decrypt
|
||||||
|
decipher.key = key
|
||||||
|
decipher.iv = iv
|
||||||
|
|
||||||
|
decipher.update(data) + decipher.final
|
||||||
|
|
||||||
|
rescue OpenSSL::Cipher::CipherError => e
|
||||||
|
Rails.logger.debug { "AES-CTR failed: #{e.message}" }
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_media?(data)
|
||||||
|
return false if data.nil? || data.bytesize < 4
|
||||||
|
|
||||||
|
bytes = data.bytes[0..7]
|
||||||
|
|
||||||
|
# JPEG: FF D8 FF
|
||||||
|
return true if bytes[0..2] == [0xFF, 0xD8, 0xFF]
|
||||||
|
|
||||||
|
# PNG: 89 50 4E 47
|
||||||
|
return true if bytes[0..3] == [0x89, 0x50, 0x4E, 0x47]
|
||||||
|
|
||||||
|
# WebP: RIFF....WEBP
|
||||||
|
return true if data[0..3] == 'RIFF' && data[8..11] == 'WEBP'
|
||||||
|
|
||||||
|
# MP4/MOV: ftyp
|
||||||
|
return true if data[4..7] == 'ftyp'
|
||||||
|
|
||||||
|
# MP3: ID3 or FF FB/FF FA
|
||||||
|
return true if data[0..2] == 'ID3' || bytes[0..1] == [0xFF, 0xFB] || bytes[0..1] == [0xFF, 0xFA]
|
||||||
|
|
||||||
|
# OGG: OggS
|
||||||
|
return true if data[0..3] == 'OggS'
|
||||||
|
|
||||||
|
# PDF: %PDF
|
||||||
|
return true if data[0..3] == '%PDF'
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_content
|
||||||
|
uri = URI.parse(@media_url)
|
||||||
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
|
http.use_ssl = (uri.scheme == 'https')
|
||||||
|
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
||||||
|
http.open_timeout = 10
|
||||||
|
http.read_timeout = 30
|
||||||
|
|
||||||
|
request = Net::HTTP::Get.new(uri.request_uri)
|
||||||
|
response = http.request(request)
|
||||||
|
|
||||||
|
response.is_a?(Net::HTTPSuccess) ? response.body.b : nil
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "WuzAPI Decrypt Download Error: #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -24,6 +24,74 @@ module Whatsapp::IncomingMessageServiceHelpers
|
|||||||
@processed_params[:messages].first[:type]
|
@processed_params[:messages].first[:type]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_message(message)
|
||||||
|
# Find original message if it's a reply
|
||||||
|
in_reply_to_id = nil
|
||||||
|
if @in_reply_to_external_id.present?
|
||||||
|
Rails.logger.info "WuzAPI Reply Lookup: Checking stanzaID=#{@in_reply_to_external_id}"
|
||||||
|
|
||||||
|
original_message = Message.find_by(source_id: @in_reply_to_external_id)
|
||||||
|
# Fallback to search by standard ID if source_id format differs
|
||||||
|
original_message ||= Message.find_by(source_id: "WAID:#{@in_reply_to_external_id}")
|
||||||
|
|
||||||
|
if original_message
|
||||||
|
Rails.logger.info "WuzAPI Reply Lookup: MATCH FOUND! ID=#{original_message.id}"
|
||||||
|
in_reply_to_id = original_message.id
|
||||||
|
else
|
||||||
|
Rails.logger.warn "WuzAPI Reply Lookup: NO MATCH FOUND for stanzaID=#{@in_reply_to_external_id}"
|
||||||
|
Rails.logger.warn "WuzAPI Debug: Last 5 source_ids in DB: #{Message.where(inbox_id: @inbox.id).last(5).pluck(:source_id)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@message = @conversation.messages.build(
|
||||||
|
content: message_content(message),
|
||||||
|
account_id: @inbox.account_id,
|
||||||
|
inbox_id: @inbox.id,
|
||||||
|
message_type: :incoming,
|
||||||
|
sender: @contact,
|
||||||
|
source_id: message[:id].to_s,
|
||||||
|
in_reply_to_id: in_reply_to_id,
|
||||||
|
in_reply_to_external_id: @in_reply_to_external_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_contact(contact)
|
||||||
|
phones = contact[:phones]
|
||||||
|
phones = [{ phone: 'Phone number is not available' }] if phones.blank?
|
||||||
|
|
||||||
|
name_info = contact['name'] || {}
|
||||||
|
contact_meta = {
|
||||||
|
firstName: name_info['first_name'],
|
||||||
|
lastName: name_info['last_name']
|
||||||
|
}.compact
|
||||||
|
|
||||||
|
phones.each do |phone|
|
||||||
|
@message.attachments.new(
|
||||||
|
account_id: @message.account_id,
|
||||||
|
file_type: file_content_type(message_type),
|
||||||
|
fallback_title: phone[:phone].to_s,
|
||||||
|
meta: contact_meta
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_contact_with_profile_name(contact_params)
|
||||||
|
profile_name = contact_params.dig(:profile, :name)
|
||||||
|
return if profile_name.blank?
|
||||||
|
return if @contact.name == profile_name
|
||||||
|
|
||||||
|
# Only update if current name exactly matches the phone number or formatted phone number
|
||||||
|
return unless contact_name_matches_phone_number?
|
||||||
|
|
||||||
|
@contact.update!(name: profile_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def contact_name_matches_phone_number?
|
||||||
|
phone_number = "+#{@processed_params[:messages].first[:from]}"
|
||||||
|
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
|
||||||
|
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||||
|
end
|
||||||
|
|
||||||
def message_content(message)
|
def message_content(message)
|
||||||
# TODO: map interactive messages back to button messages in chatwoot
|
# TODO: map interactive messages back to button messages in chatwoot
|
||||||
message.dig(:text, :body) ||
|
message.dig(:text, :body) ||
|
||||||
@ -60,6 +128,23 @@ module Whatsapp::IncomingMessageServiceHelpers
|
|||||||
end
|
end
|
||||||
|
|
||||||
def process_in_reply_to(message)
|
def process_in_reply_to(message)
|
||||||
|
# 1. Extended Text Message
|
||||||
|
if message.dig('extendedTextMessage', 'contextInfo').present?
|
||||||
|
ctx = message['extendedTextMessage']['contextInfo']
|
||||||
|
@in_reply_to_external_id = ctx['stanzaID'] || ctx['stanzaId']
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# 2. Media Messages (Image, Video, Audio, Document, Sticker)
|
||||||
|
[:imageMessage, :videoMessage, :audioMessage, :documentMessage, :stickerMessage].each do |media_key|
|
||||||
|
next unless message.dig(media_key.to_s, 'contextInfo').present?
|
||||||
|
|
||||||
|
ctx = message[media_key.to_s]['contextInfo']
|
||||||
|
@in_reply_to_external_id = ctx['stanzaID'] || ctx['stanzaId']
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# 3. Fallback for other providers
|
||||||
@in_reply_to_external_id = message['context']&.[]('id')
|
@in_reply_to_external_id = message['context']&.[]('id')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,19 @@ module Whatsapp
|
|||||||
class IncomingMessageWuzapiService < IncomingMessageBaseService
|
class IncomingMessageWuzapiService < IncomingMessageBaseService
|
||||||
def perform
|
def perform
|
||||||
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
|
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
|
||||||
|
Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}"
|
||||||
|
|
||||||
# 1. Message Type Check (V1: Text + Presence)
|
# 1. Message Type Check
|
||||||
# Fail fast for unsupported types (like ReadReceipts)
|
if parser.message_type == :ignore
|
||||||
return unless [:text, :chat_presence].include?(parser.message_type)
|
Rails.logger.info 'WuzAPI: Ignored event type (ReadReceipt/Other)'
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
allowed_types = [:text, :image, :audio, :video, :document, :sticker, :chat_presence]
|
||||||
|
unless allowed_types.include?(parser.message_type)
|
||||||
|
Rails.logger.info "WuzAPI: Unsupported message type: #{parser.message_type}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# 2. V1 Scope: Ignore Groups
|
# 2. V1 Scope: Ignore Groups
|
||||||
if parser.group_message?
|
if parser.group_message?
|
||||||
@ -13,10 +22,10 @@ module Whatsapp
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# 3. Strong Dedupe (Critical for Sync)
|
# 3. Strong Dedupe (Using WAID prefix)
|
||||||
# Skip dedupe for ChatPresence as it doesn't have a unique ID
|
clean_source_id = "WAID:#{parser.external_id}"
|
||||||
if parser.message_type != :chat_presence && parser.external_id.present? && Message.exists?(source_id: parser.external_id, inbox_id: inbox.id)
|
if parser.message_type != :chat_presence && parser.external_id.present? && Message.exists?(source_id: clean_source_id, inbox_id: inbox.id)
|
||||||
Rails.logger.info "WuzAPI: Ignoring duplicate message (ID: #{parser.external_id})"
|
Rails.logger.info "WuzAPI: Ignoring duplicate message (ID: #{clean_source_id})"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -27,12 +36,10 @@ module Whatsapp
|
|||||||
|
|
||||||
# 4. Process
|
# 4. Process
|
||||||
Rails.logger.info "WuzAPI: Processing message from #{parser.sender_phone_number} (Type: #{parser.message_type})"
|
Rails.logger.info "WuzAPI: Processing message from #{parser.sender_phone_number} (Type: #{parser.message_type})"
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@contact = find_or_create_contact(parser)
|
@contact = find_or_create_contact(parser)
|
||||||
Rails.logger.info "WuzAPI: Contact found/created: #{@contact.id}"
|
|
||||||
|
|
||||||
@conversation = find_or_create_conversation(@contact)
|
@conversation = find_or_create_conversation(@contact)
|
||||||
Rails.logger.info "WuzAPI: Conversation found/created: #{@conversation.id}"
|
|
||||||
|
|
||||||
if parser.message_type == :chat_presence
|
if parser.message_type == :chat_presence
|
||||||
status = parser.presence_state == 'composing' ? 'on' : 'off'
|
status = parser.presence_state == 'composing' ? 'on' : 'off'
|
||||||
@ -40,8 +47,15 @@ module Whatsapp
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
message = create_message(parser, @conversation)
|
# Create Message First (Clean source_id) - Build, not save yet
|
||||||
Rails.logger.info "WuzAPI: Message created: #{message.id}"
|
@message = build_message(parser, @conversation, clean_source_id)
|
||||||
|
|
||||||
|
# Attach media BEFORE saving (Chatwoot pattern)
|
||||||
|
attach_files(parser) if [:image, :audio, :video, :document, :sticker].include?(parser.message_type)
|
||||||
|
|
||||||
|
# Now save with attachments
|
||||||
|
@message.save!
|
||||||
|
Rails.logger.info "WuzAPI: Message created: #{@message.id} (SourceID: #{clean_source_id})"
|
||||||
end
|
end
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "WuzAPI Error: #{e.message}"
|
Rails.logger.error "WuzAPI Error: #{e.message}"
|
||||||
@ -52,7 +66,6 @@ module Whatsapp
|
|||||||
private
|
private
|
||||||
|
|
||||||
def find_or_create_contact(parser)
|
def find_or_create_contact(parser)
|
||||||
# Normalize phone
|
|
||||||
phone = parser.sender_phone_number
|
phone = parser.sender_phone_number
|
||||||
normalized_phone = "+#{phone}"
|
normalized_phone = "+#{phone}"
|
||||||
|
|
||||||
@ -81,18 +94,157 @@ module Whatsapp
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_message(parser, conversation)
|
def build_message(parser, conversation, clean_source_id)
|
||||||
is_outgoing = parser.from_me?
|
is_outgoing = parser.from_me?
|
||||||
|
|
||||||
conversation.messages.create!(
|
msg_params = {
|
||||||
content: parser.text_content,
|
content: parser.text_content,
|
||||||
account_id: inbox.account_id,
|
account_id: inbox.account_id,
|
||||||
inbox_id: inbox.id,
|
inbox_id: inbox.id,
|
||||||
message_type: is_outgoing ? :outgoing : :incoming,
|
message_type: is_outgoing ? :outgoing : :incoming,
|
||||||
sender: is_outgoing ? nil : @contact,
|
sender: is_outgoing ? nil : @contact,
|
||||||
source_id: parser.external_id,
|
source_id: clean_source_id,
|
||||||
created_at: parser.timestamp
|
created_at: parser.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle Replies
|
||||||
|
# Handle Reply Logic (Aligned with Reference)
|
||||||
|
if (reply_id = parser.in_reply_to_external_id).present?
|
||||||
|
clean_reply_id = "WAID:#{reply_id}"
|
||||||
|
|
||||||
|
# Strict lookup within conversation to prevent cross-inbox leaks
|
||||||
|
original_message = conversation.messages.find_by(source_id: clean_reply_id)
|
||||||
|
|
||||||
|
if original_message
|
||||||
|
msg_params[:in_reply_to_id] = original_message.id
|
||||||
|
else
|
||||||
|
# Fallback: Store ID for UI "Replying to..." display even if not linked
|
||||||
|
msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Use .build so we can attach files before .save!
|
||||||
|
conversation.messages.build(msg_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_files(parser)
|
||||||
|
attachment_data = parser.attachment_params
|
||||||
|
return if attachment_data.blank? || attachment_data[:external_url].blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
Rails.logger.info "WuzAPI: Processing attachment (URL: #{attachment_data[:external_url]}, File: #{attachment_data[:file_name]})"
|
||||||
|
|
||||||
|
# 1. Download/Decrypt to get a file
|
||||||
|
file_io = download_or_decrypt_media(attachment_data, parser.message_type)
|
||||||
|
return if file_io.blank?
|
||||||
|
|
||||||
|
# 2. Determine filename
|
||||||
|
original_filename = attachment_data[:file_name] || "wuzapi_#{Time.now.to_i}"
|
||||||
|
extension = File.extname(original_filename)
|
||||||
|
extension = detect_extension(attachment_data[:mimetype], parser.message_type) if extension.blank?
|
||||||
|
final_filename = "#{File.basename(original_filename, '.*')}#{extension}"
|
||||||
|
|
||||||
|
# 3. Attach using Chatwoot's standard pattern
|
||||||
|
@message.attachments.new(
|
||||||
|
account_id: @message.account_id,
|
||||||
|
file_type: file_content_type(parser.message_type),
|
||||||
|
file: {
|
||||||
|
io: file_io,
|
||||||
|
filename: final_filename,
|
||||||
|
content_type: attachment_data[:mimetype] || 'application/octet-stream'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Rails.logger.info "WuzAPI: Attachment queued for save (#{final_filename})"
|
||||||
|
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "WuzAPI Attachment Error: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.first(10).join("\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_or_decrypt_media(attachment_data, message_type)
|
||||||
|
media_url = attachment_data[:external_url]
|
||||||
|
|
||||||
|
# METHOD 1: Use WuzAPI's /chat/downloadimage endpoint (returns DECRYPTED media)
|
||||||
|
# This is the equivalent of Cloud API's media download
|
||||||
|
begin
|
||||||
|
Rails.logger.info 'WuzAPI: Attempting download via WuzAPI endpoint...'
|
||||||
|
wuzapi_response = wuzapi_client.download_media(wuzapi_token, media_url)
|
||||||
|
|
||||||
|
if wuzapi_response.is_a?(Hash) && wuzapi_response['data'].present?
|
||||||
|
# WuzAPI returns base64 in 'data' field
|
||||||
|
image_data = wuzapi_response['data']
|
||||||
|
# Strip data URI prefix if present
|
||||||
|
image_data = image_data.sub(/^data:.*?;base64,/, '') if image_data.start_with?('data:')
|
||||||
|
|
||||||
|
decoded = Base64.decode64(image_data)
|
||||||
|
if decoded.bytesize > 1000 # Valid image should be > 1KB
|
||||||
|
Rails.logger.info 'WuzAPI: Download via endpoint SUCCESS'
|
||||||
|
return StringIO.new(decoded)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# METHOD 2: Try local decryption if we have mediaKey
|
||||||
|
if attachment_data[:media_key].present?
|
||||||
|
Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...'
|
||||||
|
decrypted = Whatsapp::DecryptionService.new(
|
||||||
|
media_url,
|
||||||
|
attachment_data[:media_key],
|
||||||
|
file_content_type(message_type)
|
||||||
|
).decrypt
|
||||||
|
|
||||||
|
return decrypted if decrypted
|
||||||
|
|
||||||
|
Rails.logger.warn 'WuzAPI: Local decryption failed...'
|
||||||
|
end
|
||||||
|
|
||||||
|
# METHOD 3: Direct download (only works for non-encrypted or already-decrypted URLs)
|
||||||
|
Rails.logger.info "WuzAPI: Direct download from #{media_url}"
|
||||||
|
Down.download(
|
||||||
|
media_url,
|
||||||
|
open_timeout: 10,
|
||||||
|
read_timeout: 30,
|
||||||
|
ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE
|
||||||
)
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "WuzAPI: All download methods failed - #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def wuzapi_client
|
||||||
|
@wuzapi_client ||= Wuzapi::Client.new(@inbox.channel.provider_config['wuzapi_base_url'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def wuzapi_token
|
||||||
|
@inbox.channel.wuzapi_user_token
|
||||||
|
end
|
||||||
|
|
||||||
|
def detect_extension(mimetype, message_type)
|
||||||
|
return '.jpg' if message_type == :image || message_type == :sticker
|
||||||
|
return '.mp3' if message_type == :audio
|
||||||
|
return '.mp4' if message_type == :video
|
||||||
|
|
||||||
|
case mimetype
|
||||||
|
when 'image/png' then '.png'
|
||||||
|
when 'image/webp' then '.webp'
|
||||||
|
when 'image/gif' then '.gif'
|
||||||
|
when 'audio/ogg' then '.ogg'
|
||||||
|
when 'video/webm' then '.webm'
|
||||||
|
else '.bin'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def file_content_type(message_type)
|
||||||
|
case message_type
|
||||||
|
when :image, :sticker then :image
|
||||||
|
when :audio then :audio
|
||||||
|
when :video then :video
|
||||||
|
else :file
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -5,7 +5,7 @@ module Whatsapp
|
|||||||
attr_reader :params
|
attr_reader :params
|
||||||
|
|
||||||
def initialize(params)
|
def initialize(params)
|
||||||
@params = params
|
@params = params.with_indifferent_access
|
||||||
end
|
end
|
||||||
|
|
||||||
def external_id
|
def external_id
|
||||||
@ -14,19 +14,13 @@ module Whatsapp
|
|||||||
|
|
||||||
def from_me?
|
def from_me?
|
||||||
is_api_from_me = params.dig(:event, :Info, :IsFromMe) || params.dig(:event, :IsFromMe)
|
is_api_from_me = params.dig(:event, :Info, :IsFromMe) || params.dig(:event, :IsFromMe)
|
||||||
|
|
||||||
# If Wuzapi says it's NOT from me, believe it.
|
|
||||||
return false unless is_api_from_me
|
return false unless is_api_from_me
|
||||||
|
|
||||||
# If Wuzapi says it IS from me, verify against the instance phone number (if available)
|
|
||||||
# This protects against false positives where incoming messages are flagged as from_me
|
|
||||||
instance_phone = params['phone_number']
|
instance_phone = params['phone_number']
|
||||||
sender_jid = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender)
|
sender_jid = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender)
|
||||||
|
|
||||||
# If we have both numbers, double check
|
|
||||||
if instance_phone.present? && sender_jid.present?
|
if instance_phone.present? && sender_jid.present?
|
||||||
sender_phone = sender_jid.split('@').first
|
sender_phone = sender_jid.split('@').first
|
||||||
# If sender is NOT the instance, it CANNOT be from me.
|
|
||||||
return false if sender_phone != instance_phone
|
return false if sender_phone != instance_phone
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -36,16 +30,129 @@ module Whatsapp
|
|||||||
def message_type
|
def message_type
|
||||||
return :chat_presence if params['type'] == 'ChatPresence'
|
return :chat_presence if params['type'] == 'ChatPresence'
|
||||||
|
|
||||||
|
# Info: Type contains the general classification (text, image, etc)
|
||||||
type = params.dig(:event, :Info, :Type)
|
type = params.dig(:event, :Info, :Type)
|
||||||
type == 'text' ? :text : :unknown
|
media_type = params.dig(:event, :Info, :MediaType)
|
||||||
|
|
||||||
|
# WuzAPI sometimes sends 'media' in Type and the actual type in MediaType
|
||||||
|
type = media_type if type == 'media' && media_type.present?
|
||||||
|
|
||||||
|
case type
|
||||||
|
when 'text' then :text
|
||||||
|
when 'image' then :image
|
||||||
|
when 'audio' then :audio
|
||||||
|
when 'video' then :video
|
||||||
|
when 'document' then :document
|
||||||
|
when 'sticker' then :sticker
|
||||||
|
when 'ReadReceipt' then :ignore
|
||||||
|
else
|
||||||
|
# Fallback: Detect type from Message content keys
|
||||||
|
msg = params.dig(:event, :Message)
|
||||||
|
if msg.is_a?(Hash)
|
||||||
|
return :text if msg[:conversation].present? || msg[:extendedTextMessage].present?
|
||||||
|
return :image if msg[:imageMessage].present?
|
||||||
|
return :audio if msg[:audioMessage].present?
|
||||||
|
return :video if msg[:videoMessage].present?
|
||||||
|
return :document if msg[:documentMessage].present?
|
||||||
|
return :sticker if msg[:stickerMessage].present?
|
||||||
|
end
|
||||||
|
:unknown
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def presence_state
|
def presence_state
|
||||||
params.dig(:event, :State)
|
params.dig(:event, :State)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def in_reply_to_external_id
|
||||||
|
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||||
|
return nil unless msg.is_a?(Hash)
|
||||||
|
|
||||||
|
# DEBUG: Log the message structure to understand reply context
|
||||||
|
Rails.logger.info "WuzAPI Reply Debug: Message keys = #{msg.keys.inspect}"
|
||||||
|
|
||||||
|
# 1. Extended text
|
||||||
|
ctx = msg.dig(:extendedTextMessage, :contextInfo)
|
||||||
|
if ctx.present?
|
||||||
|
Rails.logger.info "WuzAPI Reply Debug: Found extendedTextMessage contextInfo = #{ctx.inspect}"
|
||||||
|
stanza = ctx[:stanzaID] || ctx[:stanzaId]
|
||||||
|
return stanza if stanza.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# 2. Media Types direct contextInfo
|
||||||
|
[:imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key|
|
||||||
|
ctx = msg.dig(key, :contextInfo)
|
||||||
|
next unless ctx.present?
|
||||||
|
|
||||||
|
Rails.logger.info "WuzAPI Reply Debug: Found #{key} contextInfo = #{ctx.inspect}"
|
||||||
|
stanza = ctx[:stanzaID] || ctx[:stanzaId]
|
||||||
|
return stanza if stanza.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# 3. Document With Caption
|
||||||
|
if msg.key?(:documentWithCaptionMessage)
|
||||||
|
ctx = msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo)
|
||||||
|
if ctx.present?
|
||||||
|
Rails.logger.info "WuzAPI Reply Debug: Found documentWithCaptionMessage contextInfo = #{ctx.inspect}"
|
||||||
|
return ctx[:stanzaID] || ctx[:stanzaId]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# 4. Check for simple conversation with contextInfo (text reply without extendedTextMessage)
|
||||||
|
if msg[:conversation].present? && msg.dig(:contextInfo).present?
|
||||||
|
ctx = msg[:contextInfo]
|
||||||
|
Rails.logger.info "WuzAPI Reply Debug: Found conversation contextInfo = #{ctx.inspect}"
|
||||||
|
stanza = ctx[:stanzaID] || ctx[:stanzaId]
|
||||||
|
return stanza if stanza.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info 'WuzAPI Reply Debug: No reply context found'
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
def text_content
|
def text_content
|
||||||
params.dig(:event, :Message, :conversation)
|
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||||
|
return nil unless msg.is_a?(Hash)
|
||||||
|
|
||||||
|
# 1. Simple text
|
||||||
|
return msg[:conversation] if msg[:conversation].present?
|
||||||
|
|
||||||
|
# 2. Extended Text
|
||||||
|
return msg.dig(:extendedTextMessage, :text) if msg.dig(:extendedTextMessage, :text).present?
|
||||||
|
|
||||||
|
# 3. Media Captions (Image, Video, Document)
|
||||||
|
[:imageMessage, :videoMessage, :documentMessage].each do |media_key|
|
||||||
|
caption = msg.dig(media_key, :caption)
|
||||||
|
return caption if caption.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# 4. Document With Caption
|
||||||
|
return msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :caption) if msg.key?(:documentWithCaptionMessage)
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def attachment_params
|
||||||
|
media_key = case message_type
|
||||||
|
when :image then :imageMessage
|
||||||
|
when :audio then :audioMessage
|
||||||
|
when :video then :videoMessage
|
||||||
|
when :document then :documentMessage
|
||||||
|
when :sticker then :stickerMessage
|
||||||
|
end
|
||||||
|
return nil unless media_key
|
||||||
|
|
||||||
|
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||||
|
data = msg[media_key]
|
||||||
|
return nil unless data.is_a?(Hash)
|
||||||
|
|
||||||
|
{
|
||||||
|
external_url: data['URL'],
|
||||||
|
file_name: data['fileName'] || "file_#{external_id}",
|
||||||
|
mimetype: data['mimetype'],
|
||||||
|
thumbnail: data['JPEGThumbnail'],
|
||||||
|
media_key: data['mediaKey']
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def sender_phone_number
|
def sender_phone_number
|
||||||
@ -79,6 +186,12 @@ module Whatsapp
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def unwrap_ephemeral_message(msg)
|
||||||
|
return {} unless msg
|
||||||
|
|
||||||
|
msg.key?(:ephemeralMessage) ? msg.dig(:ephemeralMessage, :message) : msg
|
||||||
|
end
|
||||||
|
|
||||||
def extract_jid
|
def extract_jid
|
||||||
if from_me?
|
if from_me?
|
||||||
params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat)
|
params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat)
|
||||||
|
|||||||
@ -14,11 +14,22 @@ module Whatsapp::Providers
|
|||||||
|
|
||||||
return send_reaction_message(normalized_phone, message) if message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction]
|
return send_reaction_message(normalized_phone, message) if message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction]
|
||||||
|
|
||||||
if message.attachments.present?
|
response = if message.attachments.present?
|
||||||
send_attachment_message(user_token, normalized_phone, message)
|
send_attachment_message(user_token, normalized_phone, message)
|
||||||
else
|
else
|
||||||
client.send_text(user_token, normalized_phone, message.content)
|
params = {}
|
||||||
end
|
# Extract and clean reply ID (remove WAID: prefix if stored)
|
||||||
|
if (reply_id = message.content_attributes['in_reply_to_external_id']).present?
|
||||||
|
params['MessageId'] = reply_id.gsub(/^WAID:/, '')
|
||||||
|
elsif (reply_id = message.in_reply_to_external_id).present?
|
||||||
|
params['MessageId'] = reply_id.gsub(/^WAID:/, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
client.send_text(user_token, normalized_phone, message.content, **params)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract message ID from WuzAPI response and format as WAID:xxx
|
||||||
|
extract_message_id(response)
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_attachment_message(user_token, phone_number, message)
|
def send_attachment_message(user_token, phone_number, message)
|
||||||
@ -50,6 +61,9 @@ module Whatsapp::Providers
|
|||||||
message_id = target_msg&.source_id
|
message_id = target_msg&.source_id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Strip WAID prefix if present
|
||||||
|
message_id = message_id.gsub(/^WAID:/, '') if message_id.present?
|
||||||
|
|
||||||
use_me_prefix = reaction_to_own_message?(message)
|
use_me_prefix = reaction_to_own_message?(message)
|
||||||
|
|
||||||
if use_me_prefix
|
if use_me_prefix
|
||||||
@ -135,6 +149,17 @@ module Whatsapp::Providers
|
|||||||
@client ||= ::Wuzapi::Client.new(@base_url)
|
@client ||= ::Wuzapi::Client.new(@base_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Extract message ID from WuzAPI response and format it as WAID:xxx
|
||||||
|
# WuzAPI returns: {"code" => 200, "data" => {"Id" => "xxx", ...}, "success" => true}
|
||||||
|
def extract_message_id(response)
|
||||||
|
return nil unless response.is_a?(Hash)
|
||||||
|
|
||||||
|
message_id = response.dig('data', 'Id') || response.dig(:data, :Id)
|
||||||
|
return nil if message_id.blank?
|
||||||
|
|
||||||
|
"WAID:#{message_id}"
|
||||||
|
end
|
||||||
|
|
||||||
def reaction_to_own_message?(message)
|
def reaction_to_own_message?(message)
|
||||||
# If we can resolve the target message, check if it was sent by us.
|
# If we can resolve the target message, check if it was sent by us.
|
||||||
target_message = nil
|
target_message = nil
|
||||||
|
|||||||
@ -7,6 +7,7 @@ json.message_type message.message_type_before_type_cast
|
|||||||
json.content_type message.content_type
|
json.content_type message.content_type
|
||||||
json.status message.status
|
json.status message.status
|
||||||
json.content_attributes message.content_attributes
|
json.content_attributes message.content_attributes
|
||||||
|
json.in_reply_to_id message.in_reply_to_id
|
||||||
json.created_at message.created_at.to_i
|
json.created_at message.created_at.to_i
|
||||||
json.private message.private
|
json.private message.private
|
||||||
json.source_id message.source_id
|
json.source_id message.source_id
|
||||||
|
|||||||
26
config/initializers/00_fix_tool_check_availability.rb
Normal file
26
config/initializers/00_fix_tool_check_availability.rb
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# TEMPORARY FIX: Enable CheckAvailabilityTool on startup
|
||||||
|
# This ensures the tool is enabled even if the console environment is broken.
|
||||||
|
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
puts '--- [FIX] Verifying CheckAvailabilityTool Config ---'
|
||||||
|
|
||||||
|
begin
|
||||||
|
assistant = Captain::Assistant.first
|
||||||
|
if assistant
|
||||||
|
tool_key = 'check_availability'
|
||||||
|
config = assistant.tool_configs.find_or_initialize_by(tool_key: tool_key)
|
||||||
|
|
||||||
|
if config.new_record? || !config.is_enabled
|
||||||
|
config.is_enabled = true
|
||||||
|
config.save!
|
||||||
|
puts "--- [FIX] SUCCESS: check_availability ENABLED for #{assistant.name} ---"
|
||||||
|
else
|
||||||
|
puts "--- [FIX] SKIPPED: Already enabled for #{assistant.name} ---"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
puts '--- [FIX] WARNING: No Assistant found to fix. ---'
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
puts "--- [FIX] ERROR: #{e.message} ---"
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -14,14 +14,20 @@ Rails.application.config.to_prepare do
|
|||||||
# If content is present, or we have attachments, we are good.
|
# If content is present, or we have attachments, we are good.
|
||||||
return if content.present? || attachments.any?
|
return if content.present? || attachments.any?
|
||||||
|
|
||||||
# If we are here, we are about to crash.
|
# Identifica a origem para um fallback mais inteligente
|
||||||
# Set a default content message and log it.
|
if incoming?
|
||||||
|
# Casos onde o cliente envia mídias/eventos não suportados ou vazios
|
||||||
|
event_info = content_attributes&.dig('event_type')
|
||||||
|
self.content = event_info.present? ? "(Evento de plataforma: #{event_info})" : '(Conteúdo ou Mídia não processada)'
|
||||||
|
else
|
||||||
|
# Casos onde o assistente ou sistema falhou em gerar texto
|
||||||
|
self.content = '(O assistente tentou enviar uma resposta vazia)'
|
||||||
|
end
|
||||||
|
|
||||||
Rails.logger.warn "⚠️ [DEFENSIVE FIX] Message would have crashed! Validations: 'Text and attachments cannot be both nil'."
|
# Log rico para depuração futura
|
||||||
Rails.logger.warn " - Caller: #{caller[0..5].join("\n - ")}"
|
Rails.logger.warn "⚠️ [DEFENSIVE FIX] #{message_type.upcase} message (ID: #{id || 'new'}) would have crashed Chatwoot!"
|
||||||
Rails.logger.warn " - Attributes: #{attributes.inspect}"
|
Rails.logger.warn " - Context: Channel=#{inbox&.channel_type} | AccountID=#{account_id}"
|
||||||
|
Rails.logger.warn " - Attributes: #{attributes.except('content').inspect}"
|
||||||
self.content = '(System Message - Auto-fixed empty content)'
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -279,7 +279,7 @@ pt_BR:
|
|||||||
slack:
|
slack:
|
||||||
name: 'Slack'
|
name: 'Slack'
|
||||||
short_description: 'Receba notificações e responda as conversas diretamente no Slack.'
|
short_description: 'Receba notificações e responda as conversas diretamente no Slack.'
|
||||||
description: "Integre Chatwoot com Slack para manter seu time em sincronia. Essa integração permite que você receba notificações de novas conversas e as responda diretamente na interface do Slack."
|
description: 'Integre Chatwoot com Slack para manter seu time em sincronia. Essa integração permite que você receba notificações de novas conversas e as responda diretamente na interface do Slack.'
|
||||||
webhooks:
|
webhooks:
|
||||||
name: 'Webhooks'
|
name: 'Webhooks'
|
||||||
description: 'Eventos webhook fornecem atualizações sobre atividades em tempo real na sua conta Chatwoot. Você pode se inscrever em seus eventos preferidos, e o Chatwoot enviará as chamadas HTTP com as atualizações.'
|
description: 'Eventos webhook fornecem atualizações sobre atividades em tempo real na sua conta Chatwoot. Você pode se inscrever em seus eventos preferidos, e o Chatwoot enviará as chamadas HTTP com as atualizações.'
|
||||||
@ -290,7 +290,7 @@ pt_BR:
|
|||||||
google_translate:
|
google_translate:
|
||||||
name: 'Tradutor do Google'
|
name: 'Tradutor do Google'
|
||||||
short_description: 'Traduzir automaticamente mensagens de clientes para agentes.'
|
short_description: 'Traduzir automaticamente mensagens de clientes para agentes.'
|
||||||
description: "Integre o Google Tradutor para ajudar os agentes a traduzir facilmente as mensagens dos clientes. Esta integração detecta automaticamente o idioma e o converte para o idioma preferido do agente ou do administrador."
|
description: 'Integre o Google Tradutor para ajudar os agentes a traduzir facilmente as mensagens dos clientes. Esta integração detecta automaticamente o idioma e o converte para o idioma preferido do agente ou do administrador.'
|
||||||
openai:
|
openai:
|
||||||
name: 'OpenAI'
|
name: 'OpenAI'
|
||||||
short_description: 'Sugestões, resumos e aprimoramento de mensagem e resposta com IA.'
|
short_description: 'Sugestões, resumos e aprimoramento de mensagem e resposta com IA.'
|
||||||
@ -446,3 +446,89 @@ pt_BR:
|
|||||||
subject: 'Termine de configurar %{custom_domain}'
|
subject: 'Termine de configurar %{custom_domain}'
|
||||||
ssl_status:
|
ssl_status:
|
||||||
custom_domain_not_configured: 'Domínio personalizado não está configurado'
|
custom_domain_not_configured: 'Domínio personalizado não está configurado'
|
||||||
|
captain:
|
||||||
|
BRAND_MODAL:
|
||||||
|
CANCEL: 'Cancelar'
|
||||||
|
PRICINGS:
|
||||||
|
LIST:
|
||||||
|
GLOBAL: 'Global (Todos os Inboxes)'
|
||||||
|
DELETE_BUTTON: 'Excluir Regra'
|
||||||
|
DELETE_CONFIRMATION: 'Tem certeza que deseja excluir esta regra de preço?'
|
||||||
|
FIELDS:
|
||||||
|
INBOX: 'Inbox'
|
||||||
|
BRAND: 'Marca'
|
||||||
|
DAYS: 'Dias'
|
||||||
|
CATEGORY: 'Categoria'
|
||||||
|
DURATION: 'Duração'
|
||||||
|
PRICE: 'Preço'
|
||||||
|
ACTIONS: 'Ações'
|
||||||
|
PRICE_DISPLAY: 'R$ %{price}'
|
||||||
|
MIN_PRICE: 'Preço Mínimo'
|
||||||
|
MAX_PRICE: 'Preço Máximo'
|
||||||
|
DAY: 'Dia'
|
||||||
|
FILTERS:
|
||||||
|
ALL: 'Todas'
|
||||||
|
ALL_DAYS: 'Todos os Dias'
|
||||||
|
CLEAR: 'Limpar Filtros'
|
||||||
|
HEADER: 'Gerenciamento de Tarifas'
|
||||||
|
TITLE: 'Tarifas e Disponibilidade'
|
||||||
|
DESCRIPTION: 'Gerencie os preços das suítes por dia da semana, categoria e duração.'
|
||||||
|
ADD_BUTTON: 'Nova Regra'
|
||||||
|
EMPTY_STATE: 'Nenhuma regra de preço encontrada.'
|
||||||
|
MODAL:
|
||||||
|
EDIT_TITLE: 'Editar Regra de Preço'
|
||||||
|
ADD_TITLE: 'Nova Regra de Preço'
|
||||||
|
SAVE: 'Salvar'
|
||||||
|
CANCEL: 'Cancelar'
|
||||||
|
SELECT_DAYS_REQUIRED: 'Selecione pelo menos um dia'
|
||||||
|
SELECT_CATEGORY: 'Selecione uma categoria'
|
||||||
|
SELECT_BRAND_FIRST: 'Selecione uma marca primeiro'
|
||||||
|
NO_CATEGORIES: 'Nenhuma categoria encontrada'
|
||||||
|
SELECT_DURATION: 'Selecione uma duração'
|
||||||
|
NO_DURATIONS: 'Nenhuma duração encontrada'
|
||||||
|
PRICE_PLACEHOLDER: '0.00'
|
||||||
|
REMOVE_INBOX: 'Remover Inbox'
|
||||||
|
CLOSE: 'Remover'
|
||||||
|
CAPTAIN:
|
||||||
|
BRAND_MODAL:
|
||||||
|
CANCEL: 'Cancelar'
|
||||||
|
PRICINGS:
|
||||||
|
LIST:
|
||||||
|
GLOBAL: 'Global (Todos os Inboxes)'
|
||||||
|
DELETE_BUTTON: 'Excluir Regra'
|
||||||
|
DELETE_CONFIRMATION: 'Tem certeza que deseja excluir esta regra de preço?'
|
||||||
|
FIELDS:
|
||||||
|
INBOX: 'Inbox'
|
||||||
|
BRAND: 'Marca'
|
||||||
|
DAYS: 'Dias'
|
||||||
|
CATEGORY: 'Categoria'
|
||||||
|
DURATION: 'Duração'
|
||||||
|
PRICE: 'Preço'
|
||||||
|
ACTIONS: 'Ações'
|
||||||
|
PRICE_DISPLAY: 'R$ %{price}'
|
||||||
|
MIN_PRICE: 'Preço Mínimo'
|
||||||
|
MAX_PRICE: 'Preço Máximo'
|
||||||
|
DAY: 'Dia'
|
||||||
|
FILTERS:
|
||||||
|
ALL: 'Todas'
|
||||||
|
ALL_DAYS: 'Todos os Dias'
|
||||||
|
CLEAR: 'Limpar Filtros'
|
||||||
|
HEADER: 'Gerenciamento de Tarifas'
|
||||||
|
TITLE: 'Tarifas e Disponibilidade'
|
||||||
|
DESCRIPTION: 'Gerencie os preços das suítes por dia da semana, categoria e duração.'
|
||||||
|
ADD_BUTTON: 'Nova Regra'
|
||||||
|
EMPTY_STATE: 'Nenhuma regra de preço encontrada.'
|
||||||
|
MODAL:
|
||||||
|
EDIT_TITLE: 'Editar Regra de Preço'
|
||||||
|
ADD_TITLE: 'Nova Regra de Preço'
|
||||||
|
SAVE: 'Salvar'
|
||||||
|
CANCEL: 'Cancelar'
|
||||||
|
SELECT_DAYS_REQUIRED: 'Selecione pelo menos um dia'
|
||||||
|
SELECT_CATEGORY: 'Selecione uma categoria'
|
||||||
|
SELECT_BRAND_FIRST: 'Selecione uma marca primeiro'
|
||||||
|
NO_CATEGORIES: 'Nenhuma categoria encontrada'
|
||||||
|
SELECT_DURATION: 'Selecione uma duração'
|
||||||
|
NO_DURATIONS: 'Nenhuma duração encontrada'
|
||||||
|
PRICE_PLACEHOLDER: '0.00'
|
||||||
|
REMOVE_INBOX: 'Remover Inbox'
|
||||||
|
CLOSE: 'Remover'
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
class AddInReplyToIdToMessages < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
add_column :messages, :in_reply_to_id, :integer
|
||||||
|
add_index :messages, :in_reply_to_id
|
||||||
|
add_foreign_key :messages, :messages, column: :in_reply_to_id
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
class AddKeywordsToCaptainPricings < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
add_column :captain_pricings, :keywords, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
class AddSuiteKeywordsToCaptainBrands < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
add_column :captain_brands, :suite_keywords, :jsonb
|
||||||
|
end
|
||||||
|
end
|
||||||
10
db/schema.rb
10
db/schema.rb
@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2026_02_10_123000) do
|
ActiveRecord::Schema[7.1].define(version: 2026_02_10_123002) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@ -344,6 +344,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_10_123000) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.jsonb "suite_images", default: {}, null: false
|
t.jsonb "suite_images", default: {}, null: false
|
||||||
|
t.jsonb "suite_keywords"
|
||||||
t.index ["account_id"], name: "index_captain_brands_on_account_id"
|
t.index ["account_id"], name: "index_captain_brands_on_account_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -493,6 +494,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_10_123000) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.bigint "inbox_id"
|
t.bigint "inbox_id"
|
||||||
|
t.text "keywords"
|
||||||
t.index ["account_id"], name: "index_captain_pricings_on_account_id"
|
t.index ["account_id"], name: "index_captain_pricings_on_account_id"
|
||||||
t.index ["captain_brand_id"], name: "index_captain_pricings_on_captain_brand_id"
|
t.index ["captain_brand_id"], name: "index_captain_pricings_on_captain_brand_id"
|
||||||
t.index ["inbox_id"], name: "index_captain_pricings_on_inbox_id"
|
t.index ["inbox_id"], name: "index_captain_pricings_on_inbox_id"
|
||||||
@ -838,7 +840,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_10_123000) do
|
|||||||
t.string "wuzapi_user_token_iv"
|
t.string "wuzapi_user_token_iv"
|
||||||
t.string "wuzapi_admin_token"
|
t.string "wuzapi_admin_token"
|
||||||
t.string "wuzapi_admin_token_iv"
|
t.string "wuzapi_admin_token_iv"
|
||||||
|
t.jsonb "provider_connection", default: {}
|
||||||
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
|
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
|
||||||
|
t.index ["provider_connection"], name: "index_channel_whatsapp_provider_connection", where: "((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[]))", using: :gin
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "companies", force: :cascade do |t|
|
create_table "companies", force: :cascade do |t|
|
||||||
@ -1074,6 +1078,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_10_123000) do
|
|||||||
t.bigint "user_id"
|
t.bigint "user_id"
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.boolean "show_on_sidebar", default: false, null: false
|
||||||
t.index ["account_id"], name: "index_dashboard_apps_on_account_id"
|
t.index ["account_id"], name: "index_dashboard_apps_on_account_id"
|
||||||
t.index ["user_id"], name: "index_dashboard_apps_on_user_id"
|
t.index ["user_id"], name: "index_dashboard_apps_on_user_id"
|
||||||
end
|
end
|
||||||
@ -1378,6 +1383,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_10_123000) do
|
|||||||
t.jsonb "additional_attributes", default: {}
|
t.jsonb "additional_attributes", default: {}
|
||||||
t.text "processed_message_content"
|
t.text "processed_message_content"
|
||||||
t.jsonb "sentiment", default: {}
|
t.jsonb "sentiment", default: {}
|
||||||
|
t.integer "in_reply_to_id"
|
||||||
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
|
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
|
||||||
t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
|
t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
|
||||||
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
|
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
|
||||||
@ -1387,6 +1393,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_10_123000) do
|
|||||||
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created"
|
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created"
|
||||||
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
|
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
|
||||||
t.index ["created_at"], name: "index_messages_on_created_at"
|
t.index ["created_at"], name: "index_messages_on_created_at"
|
||||||
|
t.index ["in_reply_to_id"], name: "index_messages_on_in_reply_to_id"
|
||||||
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
|
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
|
||||||
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
|
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
|
||||||
t.index ["source_id"], name: "index_messages_on_source_id"
|
t.index ["source_id"], name: "index_messages_on_source_id"
|
||||||
@ -1719,6 +1726,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_10_123000) do
|
|||||||
add_foreign_key "jasmine_inbox_settings", "inboxes"
|
add_foreign_key "jasmine_inbox_settings", "inboxes"
|
||||||
add_foreign_key "jasmine_tool_configs", "accounts"
|
add_foreign_key "jasmine_tool_configs", "accounts"
|
||||||
add_foreign_key "jasmine_tool_configs", "inboxes"
|
add_foreign_key "jasmine_tool_configs", "inboxes"
|
||||||
|
add_foreign_key "messages", "messages", column: "in_reply_to_id"
|
||||||
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
|
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
|
||||||
on("accounts").
|
on("accounts").
|
||||||
after(:insert).
|
after(:insert).
|
||||||
|
|||||||
268
docs/wuzapi-reply-system.md
Normal file
268
docs/wuzapi-reply-system.md
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
# WuzAPI Reply/Quote System - Technical Documentation
|
||||||
|
|
||||||
|
> **Last Updated:** 2026-01-24
|
||||||
|
> **Status:** ✅ Working
|
||||||
|
> **Author:** AI Assistant
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document explains how the Reply/Quote feature works for WhatsApp messages through WuzAPI integration. When a user replies to a message in WhatsApp, the quoted message should appear in Chatwoot's interface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ REPLY FLOW │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
WhatsApp User replies to a message
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────┐
|
||||||
|
│ WuzAPI Server │ ──► Sends webhook with contextInfo.stanzaID
|
||||||
|
└────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Chatwoot Backend │
|
||||||
|
│ │
|
||||||
|
│ 1. WhatsappController.sanitize_payload_for_sidekiq (preserves stanzaID) │
|
||||||
|
│ 2. WhatsappEventsJob → IncomingMessageWuzapiService │
|
||||||
|
│ 3. PayloadParser.in_reply_to_external_id (extracts stanzaID) │
|
||||||
|
│ 4. build_message → finds original by source_id, sets in_reply_to_id │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Chatwoot Frontend │
|
||||||
|
│ │
|
||||||
|
│ MessageList.vue → getInReplyToMessage() → looks up inReplyToId │
|
||||||
|
│ Base.vue → displays replyToPreview │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ------------------------------------------------------------------- | ---------------------------------------- |
|
||||||
|
| `app/controllers/webhooks/whatsapp_controller.rb` | Sanitizes payload, preserves `stanzaID` |
|
||||||
|
| `app/services/whatsapp/incoming_message_wuzapi_service.rb` | Creates message with `in_reply_to_id` |
|
||||||
|
| `app/services/whatsapp/providers/wuzapi/payload_parser.rb` | Extracts `stanzaID` from contextInfo |
|
||||||
|
| `app/services/whatsapp/providers/wuzapi_service.rb` | Sends messages, returns clean `WAID:xxx` |
|
||||||
|
| `app/javascript/dashboard/components-next/message/MessageList.vue` | Resolves reply reference |
|
||||||
|
| `app/javascript/dashboard/components-next/message/bubbles/Base.vue` | Displays quoted message |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### 1. Incoming Reply (WhatsApp → Chatwoot)
|
||||||
|
|
||||||
|
**Step 1: WuzAPI sends webhook with reply context**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": {
|
||||||
|
"Message": {
|
||||||
|
"extendedTextMessage": {
|
||||||
|
"text": "My reply message",
|
||||||
|
"contextInfo": {
|
||||||
|
"stanzaID": "3EB0B12FB7571691E025DD",
|
||||||
|
"participant": "556191544165@s.whatsapp.net",
|
||||||
|
"quotedMessage": { "conversation": "Original message" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Sanitizer preserves stanzaID** (`whatsapp_controller.rb` lines 84-90)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
if msg['extendedTextMessage']['contextInfo'].is_a?(Hash)
|
||||||
|
ctx = msg['extendedTextMessage']['contextInfo']
|
||||||
|
clean_msg['extendedTextMessage']['contextInfo'] = {
|
||||||
|
'stanzaID' => ctx['stanzaID'] || ctx['stanzaId'],
|
||||||
|
'participant' => ctx['participant']
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: PayloadParser extracts stanzaID** (`payload_parser.rb`)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def in_reply_to_external_id
|
||||||
|
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||||
|
ctx = msg.dig(:extendedTextMessage, :contextInfo)
|
||||||
|
return ctx[:stanzaID] || ctx[:stanzaId] if ctx.present?
|
||||||
|
# ... also checks imageMessage, videoMessage, etc.
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: IncomingMessageWuzapiService links messages** (lines 112-124)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
if (reply_id = parser.in_reply_to_external_id).present?
|
||||||
|
clean_reply_id = "WAID:#{reply_id}"
|
||||||
|
original_message = conversation.messages.find_by(source_id: clean_reply_id)
|
||||||
|
|
||||||
|
if original_message
|
||||||
|
msg_params[:in_reply_to_id] = original_message.id
|
||||||
|
else
|
||||||
|
# Fallback: store for UI display
|
||||||
|
msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Outgoing Messages (Chatwoot → WhatsApp)
|
||||||
|
|
||||||
|
**CRITICAL**: The `source_id` must be saved in format `WAID:xxx` for replies to work!
|
||||||
|
|
||||||
|
**WuzapiService.send_message** extracts ID from response:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def send_message(phone_number, message)
|
||||||
|
# ... send message to WuzAPI ...
|
||||||
|
response = client.send_text(...)
|
||||||
|
extract_message_id(response) # Returns "WAID:xxx"
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_message_id(response)
|
||||||
|
# WuzAPI returns: {"code" => 200, "data" => {"Id" => "xxx"}}
|
||||||
|
message_id = response.dig('data', 'Id')
|
||||||
|
return nil if message_id.blank?
|
||||||
|
"WAID:#{message_id}"
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Frontend Display
|
||||||
|
|
||||||
|
**MessageList.vue** resolves the reply:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const getInReplyToMessage = parentMessage => {
|
||||||
|
const inReplyToMessageId =
|
||||||
|
parentMessage.inReplyToId ?? parentMessage.contentAttributes?.inReplyTo;
|
||||||
|
|
||||||
|
if (!inReplyToMessageId) return null;
|
||||||
|
return props.messages?.find(msg => msg.id === inReplyToMessageId);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Base.vue** displays the preview:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div v-if="inReplyTo" class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer">
|
||||||
|
<span class="break-all line-clamp-2">{{ replyToPreview }}</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting Guide
|
||||||
|
|
||||||
|
### ❌ Reply not showing in Chatwoot
|
||||||
|
|
||||||
|
**Check 1: Is stanzaID in the webhook?**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "WuzAPI Reply Debug" log/development.log | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
- ✅ Good: `Found extendedTextMessage contextInfo = {"stanzaID" => "xxx"}`
|
||||||
|
- ❌ Bad: `Message keys = []` or `No reply context found`
|
||||||
|
|
||||||
|
If stanzaID is missing, the message was NOT sent as a reply in WhatsApp.
|
||||||
|
|
||||||
|
**Check 2: Is source_id in correct format?**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bundle exec rails runner "puts Message.last(5).pluck(:id, :source_id, :in_reply_to_id)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- ✅ Good: `source_id: "WAID:3EB0B12FB7571691E025DD"`
|
||||||
|
- ❌ Bad: `source_id: "{\"code\" => 200, ...}"` (JSON instead of ID)
|
||||||
|
|
||||||
|
If format is wrong, check `WuzapiService.extract_message_id`.
|
||||||
|
|
||||||
|
**Check 3: Can original message be found?**
|
||||||
|
The reply searches for: `WAID:#{stanzaID}`
|
||||||
|
The original must have `source_id` = `WAID:#{same_id}`
|
||||||
|
|
||||||
|
If IDs don't match, the original wasn't saved correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Issues & Fixes
|
||||||
|
|
||||||
|
| Problem | Cause | Solution |
|
||||||
|
| ----------------------------- | ------------------------------------------- | ------------------------------------------ |
|
||||||
|
| `source_id` is JSON | `extract_message_id` not returning clean ID | Check `wuzapi_service.rb` line ~155 |
|
||||||
|
| stanzaID not in payload | Sanitizer removing it | Check `whatsapp_controller.rb` lines 84-90 |
|
||||||
|
| Reply detected but not linked | Original message not found | Check if original has matching `source_id` |
|
||||||
|
| Frontend not showing | `inReplyToId` null in API response | Check jbuilder includes `in_reply_to_id` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
1. **Send message from Chatwoot**
|
||||||
|
|
||||||
|
- Check: `source_id` saved as `WAID:xxx` format
|
||||||
|
|
||||||
|
2. **Reply to that message in WhatsApp**
|
||||||
|
|
||||||
|
- Check: Webhook has `contextInfo.stanzaID`
|
||||||
|
- Check: `in_reply_to_id` is set in new message
|
||||||
|
|
||||||
|
3. **View in Chatwoot UI**
|
||||||
|
- Check: Quote box appears above message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# See recent messages with reply info
|
||||||
|
bundle exec rails runner "
|
||||||
|
Message.last(10).each do |m|
|
||||||
|
puts \"#{m.id}: #{m.content&.truncate(30)} | source_id: #{m.source_id} | in_reply_to_id: #{m.in_reply_to_id}\"
|
||||||
|
end
|
||||||
|
"
|
||||||
|
|
||||||
|
# Check webhook payload for stanzaID
|
||||||
|
grep "stanzaID" log/development.log | tail -5
|
||||||
|
|
||||||
|
# See reply debug logs
|
||||||
|
grep "WuzAPI Reply Debug" log/development.log | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed in This Implementation
|
||||||
|
|
||||||
|
1. **`app/services/whatsapp/providers/wuzapi_service.rb`**
|
||||||
|
|
||||||
|
- Added `extract_message_id(response)` method
|
||||||
|
- Returns `WAID:xxx` format instead of raw JSON
|
||||||
|
|
||||||
|
2. **`app/services/whatsapp/providers/wuzapi/payload_parser.rb`**
|
||||||
|
|
||||||
|
- Enhanced `in_reply_to_external_id` with debug logging
|
||||||
|
- Handles multiple message types (text, image, video, etc.)
|
||||||
|
|
||||||
|
3. **`app/services/whatsapp/incoming_message_wuzapi_service.rb`**
|
||||||
|
|
||||||
|
- `build_message` method links replies via `in_reply_to_id`
|
||||||
|
- Fallback stores `in_reply_to_external_id` in content_attributes
|
||||||
|
|
||||||
|
4. **`app/controllers/webhooks/whatsapp_controller.rb`**
|
||||||
|
- Sanitizer preserves `stanzaID` and `participant` in contextInfo
|
||||||
@ -43,7 +43,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def brand_params
|
def brand_params
|
||||||
params.require(:brand).permit(:name, suite_categories: [], stay_durations: [], suite_images: {})
|
params.require(:brand).permit(:name, suite_categories: [], stay_durations: [], suite_images: {}, suite_keywords: {})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -13,29 +13,20 @@ module Api
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@pricing = Current.account.captain_pricings.new(pricing_params.except(:inbox_ids))
|
@pricing = Current.account.captain_pricings.new(pricing_params.except(:inbox_ids))
|
||||||
if @pricing.save
|
@pricing.save!
|
||||||
sync_inboxes(@pricing, pricing_params[:inbox_ids])
|
sync_inboxes(@pricing, pricing_params[:inbox_ids])
|
||||||
render :show, status: :created
|
render :show, status: :created
|
||||||
else
|
|
||||||
render_error_response(@pricing)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
if @pricing.update(pricing_params.except(:inbox_ids))
|
@pricing.update!(pricing_params.except(:inbox_ids))
|
||||||
sync_inboxes(@pricing, pricing_params[:inbox_ids])
|
sync_inboxes(@pricing, pricing_params[:inbox_ids])
|
||||||
render :show
|
render :show
|
||||||
else
|
|
||||||
render_error_response(@pricing)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
if @pricing.destroy
|
@pricing.destroy!
|
||||||
head :no_content
|
head :no_content
|
||||||
else
|
|
||||||
render_error_response(@pricing)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@ -52,6 +43,7 @@ module Api
|
|||||||
:suite_category,
|
:suite_category,
|
||||||
:duration,
|
:duration,
|
||||||
:price,
|
:price,
|
||||||
|
:keywords,
|
||||||
inbox_ids: []
|
inbox_ids: []
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -10,7 +10,8 @@ import { FormDataModel, ApiPostPayload, SubmissionState, N8nApiResponse, Brand,
|
|||||||
import { submitReservation, checkPaymentStatus } from './services/apiService.ts';
|
import { submitReservation, checkPaymentStatus } from './services/apiService.ts';
|
||||||
import { suiteService } from './services/suiteService.ts';
|
import { suiteService } from './services/suiteService.ts';
|
||||||
import { hotelUnitService } from './services/hotelUnitService.ts';
|
import { hotelUnitService } from './services/hotelUnitService.ts';
|
||||||
import { brandService } from './services/brandService.ts';
|
import { brandService } from './services/brandService.ts'; // Keep for now if needed, but prefer masterDataService
|
||||||
|
import { masterDataService, CaptainConfig } from './services/masterDataService.ts';
|
||||||
import { pricingService } from './services/pricingService.ts';
|
import { pricingService } from './services/pricingService.ts';
|
||||||
import { extraService } from './services/extraService.ts';
|
import { extraService } from './services/extraService.ts';
|
||||||
|
|
||||||
@ -35,27 +36,33 @@ const App: React.FC = () => {
|
|||||||
const [view, setView] = React.useState<'form' | 'payment' | 'success' | 'expired'>('form');
|
const [view, setView] = React.useState<'form' | 'payment' | 'success' | 'expired'>('form');
|
||||||
const [isDataLoading, setIsDataLoading] = React.useState(true);
|
const [isDataLoading, setIsDataLoading] = React.useState(true);
|
||||||
|
|
||||||
// Configuração de Título e Subtítulo (Persistência Local)
|
// Configuração de Título e Subtítulo (Vinda do Backend)
|
||||||
const [appConfig, setAppConfig] = React.useState({
|
const [appConfig, setAppConfig] = React.useState<CaptainConfig>({
|
||||||
title: 'Reserva Premium',
|
title: 'Reserva Rápida',
|
||||||
subtitle: 'Hotel 1001 Noites Prime'
|
subtitle: 'Agende sua estadia',
|
||||||
|
primary_color: '#1E90FF',
|
||||||
|
phone_number: '' // Added phone number
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load backend config
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const savedConfig = localStorage.getItem('hotelAppConfig');
|
const fetchConfig = async () => {
|
||||||
if (savedConfig) {
|
try {
|
||||||
try {
|
const config = await masterDataService.getConfig();
|
||||||
setAppConfig(JSON.parse(savedConfig));
|
if (config) {
|
||||||
} catch (e) {
|
setAppConfig(config);
|
||||||
console.error("Erro ao carregar configurações", e);
|
// Apply primary color to document body or root variable if needed for global access,
|
||||||
}
|
// but we will use it in inline styles for the main container.
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load app config", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchConfig();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSaveConfig = (newConfig: { title: string; subtitle: string }) => {
|
// Removed localStorage logic as it conflicts with Admin panel source of truth
|
||||||
setAppConfig(newConfig);
|
// const handleSaveConfig = ... (Removed)
|
||||||
localStorage.setItem('hotelAppConfig', JSON.stringify(newConfig));
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialFormData: FormDataModel = {
|
const initialFormData: FormDataModel = {
|
||||||
nome: '',
|
nome: '',
|
||||||
@ -161,14 +168,30 @@ const App: React.FC = () => {
|
|||||||
const loadInitialData = React.useCallback(async () => {
|
const loadInitialData = React.useCallback(async () => {
|
||||||
setIsDataLoading(true);
|
setIsDataLoading(true);
|
||||||
try {
|
try {
|
||||||
const allBrands = await brandService.getAllBrands();
|
const data = await masterDataService.getMasterData();
|
||||||
const allUnits = await hotelUnitService.getAllUnits();
|
|
||||||
setBrands(allBrands);
|
|
||||||
setUnits(allUnits);
|
|
||||||
setBrandOptions(allBrands.map(b => ({ value: String(b.id), label: b.name })));
|
|
||||||
|
|
||||||
const extras = await extraService.getExtras();
|
setBrands(data.brands);
|
||||||
|
// Units are typically nested or we can fetch them if separate, but master_data usually has them.
|
||||||
|
// If data.brands includes units, we can flatten them if needed, or setUnits logic might need adjustment.
|
||||||
|
// Based on previous code: setUnits(allUnits).
|
||||||
|
// Let's assume masterDataService.getBrands() returns everything we need or we update logic.
|
||||||
|
|
||||||
|
// Actually, looking at the previous code, it fetched brands and units separately.
|
||||||
|
// The new master_data endpoint returns brands with units nested: brands: brands.as_json(include: :units)
|
||||||
|
// So we extract units from brands.
|
||||||
|
|
||||||
|
const allUnits: HotelUnit[] = data.brands.flatMap(b => (b as any).units || []);
|
||||||
|
setUnits(allUnits);
|
||||||
|
|
||||||
|
setBrandOptions(data.brands.map(b => ({ value: String(b.id), label: b.name })));
|
||||||
|
|
||||||
|
const extras = data.extras || [];
|
||||||
setAvailableExtras(extras.filter(e => e.active).sort((a, b) => a.order - b.order));
|
setAvailableExtras(extras.filter(e => e.active).sort((a, b) => a.order - b.order));
|
||||||
|
|
||||||
|
if (data.app_config) {
|
||||||
|
setAppConfig(data.app_config);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load initial data:", error);
|
console.error("Failed to load initial data:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -189,7 +212,7 @@ const App: React.FC = () => {
|
|||||||
const selectedBrand = brands.find(b => b.id === brandId);
|
const selectedBrand = brands.find(b => b.id === brandId);
|
||||||
|
|
||||||
if (selectedBrand) {
|
if (selectedBrand) {
|
||||||
setUnitOptions(units.filter(u => u.brandId === brandId).map(u => ({ value: String(u.id), label: u.name })));
|
setUnitOptions(units.filter(u => u.captain_brand_id === brandId).map(u => ({ value: String(u.id), label: u.name })));
|
||||||
setDurationOptions(selectedBrand.stay_durations.map(d => ({ value: d, label: d })));
|
setDurationOptions(selectedBrand.stay_durations.map(d => ({ value: d, label: d })));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -236,8 +259,21 @@ const App: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const pricingData = await pricingService.getPricingData(parseInt(formData.selectedBrand, 10));
|
const pricingData = await pricingService.getPricingData(parseInt(formData.selectedBrand, 10));
|
||||||
const dayOfWeek = new Date(formData.checkInDateTime).getDay();
|
const dayOfWeek = new Date(formData.checkInDateTime).getDay();
|
||||||
const dayRange = (dayOfWeek >= 1 && dayOfWeek <= 3) ? "SEGUNDA A QUARTA" : "QUINTA A DOMINGO";
|
let dayRange = (dayOfWeek >= 1 && dayOfWeek <= 3) ? "SEGUNDA A QUARTA" : "QUINTA A DOMINGO";
|
||||||
const price = pricingData?.[dayRange]?.[formData.selectedCategory]?.[formData.stayDuration];
|
|
||||||
|
// Fallback logic: check specific range, then check if it's one of those long strings, then fallback to 'all'
|
||||||
|
let price = pricingData?.[dayRange]?.[formData.selectedCategory]?.[formData.stayDuration];
|
||||||
|
|
||||||
|
if (price === undefined) {
|
||||||
|
// Try to find a key that contains the day range or is 'all'
|
||||||
|
const fallbackKey = Object.keys(pricingData || {}).find(k =>
|
||||||
|
k === 'all' || k.toLowerCase().includes('segunda') || k === dayRange
|
||||||
|
);
|
||||||
|
if (fallbackKey) {
|
||||||
|
price = pricingData[fallbackKey]?.[formData.selectedCategory]?.[formData.stayDuration];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (price !== undefined && price > 0) {
|
if (price !== undefined && price > 0) {
|
||||||
setBasePrice(price);
|
setBasePrice(price);
|
||||||
@ -679,32 +715,39 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen py-10 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center">
|
<div
|
||||||
|
className="min-h-screen py-10 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center transition-colors duration-500"
|
||||||
|
style={{ backgroundColor: appConfig.primary_color || '#1E90FF' }}
|
||||||
|
>
|
||||||
<div className="w-full max-w-3xl bg-white rounded-[2rem] shadow-2xl overflow-hidden border border-white/10 relative">
|
<div className="w-full max-w-3xl bg-white rounded-[2rem] shadow-2xl overflow-hidden border border-white/10 relative">
|
||||||
{/* Decorative Top Accent */}
|
{/* Decorative Top Accent - Slightly lighter/darker than primary */}
|
||||||
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-[#1B3B5F] to-[#1E90FF]"></div>
|
<div
|
||||||
|
className="absolute top-0 left-0 w-full h-2"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${appConfig.primary_color}, #ffffff)`
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
<div className="p-8 sm:p-12">
|
<div className="p-8 sm:p-12">
|
||||||
<div className="flex justify-between items-start mb-10 border-b border-[#1B3B5F]/10 pb-6">
|
<div className="flex justify-between items-start mb-10 border-b border-gray-100 pb-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-[#1B3B5F] tracking-tight">
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-gray-900 tracking-tight">
|
||||||
{view === 'payment' ? 'Pagamento Seguro' :
|
{view === 'payment' ? 'Pagamento Seguro' :
|
||||||
view === 'success' ? 'Reserva Confirmada' :
|
view === 'success' ? 'Reserva Confirmada' :
|
||||||
view === 'expired' ? 'Tempo Esgotado' :
|
view === 'expired' ? 'Tempo Esgotado' :
|
||||||
appConfig.title}
|
appConfig.title}
|
||||||
</h1>
|
</h1>
|
||||||
{view === 'form' && <p className="text-[#9CA3AF] text-sm font-medium">{appConfig.subtitle}</p>}
|
{view === 'form' && <p className="text-gray-500 text-sm font-medium">{appConfig.subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setCurrentView('admin')} variant="outline" size="sm" className="hidden sm:flex rounded-full px-5 text-xs font-bold uppercase tracking-wider hover:bg-[#F8FAFC]">
|
{/* Admin button removed */}
|
||||||
Admin
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="text-center text-xs font-medium text-[#1E90FF]/60 mt-8">
|
<footer className="text-center text-xs font-medium text-white/80 mt-8">
|
||||||
© {new Date().getFullYear()} {appConfig.title} • Experiência Exclusiva
|
© {new Date().getFullYear()} {appConfig.title} • Experiência Exclusiva
|
||||||
|
{appConfig.phone_number && <span className="block mt-1">Contato: {appConfig.phone_number}</span>}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,58 @@
|
|||||||
|
|
||||||
|
import { Brand, HotelUnit, ExtraItem, PricingData } from '../types.ts';
|
||||||
|
|
||||||
|
const API_BASE_URL = '/public/api/v1/captain';
|
||||||
|
|
||||||
|
export interface CaptainConfig {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
primary_color: string;
|
||||||
|
secondary_color?: string;
|
||||||
|
logo_url?: string;
|
||||||
|
phone_number?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterDataResponse {
|
||||||
|
app_config: CaptainConfig;
|
||||||
|
brands: Brand[];
|
||||||
|
// units are nested in brands in the current serializer, but top level master data might return flattened too if needed
|
||||||
|
// checking controller: render json: { ... brands: brands.as_json(include: :units) ... }
|
||||||
|
pricings: any; // Using any for now or specific PricingData[] type if available
|
||||||
|
extras: ExtraItem[];
|
||||||
|
suites: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedMasterData: MasterDataResponse | null = null;
|
||||||
|
|
||||||
|
export const masterDataService = {
|
||||||
|
async getMasterData(): Promise<MasterDataResponse> {
|
||||||
|
if (cachedMasterData) return cachedMasterData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Hardcoded account_id=1 for now, similar to other services
|
||||||
|
const response = await fetch(`${API_BASE_URL}/master_data?account_id=1`);
|
||||||
|
if (!response.ok) throw new Error('Falha ao carregar dados do servidor.');
|
||||||
|
|
||||||
|
cachedMasterData = await response.json();
|
||||||
|
return cachedMasterData!;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching master data:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Helpers to maintain compatibility or ease of use
|
||||||
|
async getBrands(): Promise<Brand[]> {
|
||||||
|
const data = await this.getMasterData();
|
||||||
|
return data.brands;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getConfig(): Promise<CaptainConfig> {
|
||||||
|
const data = await this.getMasterData();
|
||||||
|
return data.app_config;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCache() {
|
||||||
|
cachedMasterData = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -27,9 +27,8 @@ export const pricingService = {
|
|||||||
// Busca os dados de preço de uma marca e formata para a UI
|
// Busca os dados de preço de uma marca e formata para a UI
|
||||||
async getPricingData(brandId: number): Promise<PricingData> {
|
async getPricingData(brandId: number): Promise<PricingData> {
|
||||||
const data = await fetchMasterData();
|
const data = await fetchMasterData();
|
||||||
// Pricing rows from Rails are in 'pricings' key
|
// Use loose equality or cast both to string to be safe with IDs from JSON
|
||||||
// snake_case keys from Rails: brand_id
|
const rows = data.pricings.filter((p: any) => String(p.captain_brand_id) === String(brandId));
|
||||||
const rows = data.pricings.filter((p: any) => p.captain_brand_id === brandId); // Rails association key might be captain_brand_id?
|
|
||||||
// Wait, in the migration: t.references :captain_brand
|
// Wait, in the migration: t.references :captain_brand
|
||||||
// So the column is captain_brand_id.
|
// So the column is captain_brand_id.
|
||||||
// But `types.ts` `PricingRow` likely expects `brand_id`.
|
// But `types.ts` `PricingRow` likely expects `brand_id`.
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export interface SuiteCategoryImage {
|
|||||||
export interface HotelUnit {
|
export interface HotelUnit {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
brandId: number;
|
captain_brand_id: number; // Corrected from brandId to match API snake_case
|
||||||
visible_suite_categories: string[];
|
visible_suite_categories: string[];
|
||||||
suite_category_images: SuiteCategoryImage[] | null;
|
suite_category_images: SuiteCategoryImage[] | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
Current.account = conversation.account
|
Current.account = conversation.account
|
||||||
|
|
||||||
trigger_typing_status('on')
|
trigger_typing_status('on')
|
||||||
|
trigger_media_analysis
|
||||||
|
|
||||||
Rails.logger.info "[ResponseBuilderJob] Captain V2 Enabled? #{captain_v2_enabled?}"
|
Rails.logger.info "[ResponseBuilderJob] Captain V2 Enabled? #{captain_v2_enabled?}"
|
||||||
File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.now}] ResponseBuilderJob: V2 Enabled? #{captain_v2_enabled?}" }
|
File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.now}] ResponseBuilderJob: V2 Enabled? #{captain_v2_enabled?}" }
|
||||||
@ -55,7 +56,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
|
|
||||||
# Aggregation Logic
|
# Aggregation Logic
|
||||||
new_messages = fetch_new_incoming_messages
|
new_messages = fetch_new_incoming_messages
|
||||||
aggregated_text = new_messages.map(&:content).join("\n")
|
aggregated_text = new_messages.map { |m| prepare_message_text_content(m) }.join("\n")
|
||||||
exclude_ids = new_messages.map(&:id)
|
exclude_ids = new_messages.map(&:id)
|
||||||
|
|
||||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||||
@ -82,7 +83,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
|
|
||||||
# Aggregation Logic (V2)
|
# Aggregation Logic (V2)
|
||||||
new_messages = fetch_new_incoming_messages
|
new_messages = fetch_new_incoming_messages
|
||||||
aggregated_text = new_messages.map(&:content).join("\n")
|
aggregated_text = new_messages.map { |m| prepare_message_text_content(m) }.join("\n")
|
||||||
exclude_ids = new_messages.map(&:id)
|
exclude_ids = new_messages.map(&:id)
|
||||||
|
|
||||||
history = collect_previous_messages(exclude_ids: exclude_ids)
|
history = collect_previous_messages(exclude_ids: exclude_ids)
|
||||||
@ -287,10 +288,17 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
end
|
end
|
||||||
|
|
||||||
def extract_faq_answer(result)
|
def extract_faq_answer(result)
|
||||||
match = result.to_s.match(/Answer:\s*(.+)$/m)
|
return nil if result.blank?
|
||||||
return result.to_s.strip if match.blank?
|
|
||||||
|
|
||||||
match[1].to_s.strip
|
# 1. Tenta extrair usando o padrão 'Answer: '
|
||||||
|
match = result.to_s.match(/Answer:\s*(.+)$/m)
|
||||||
|
return match[1].to_s.strip if match.present? && match[1].present?
|
||||||
|
|
||||||
|
# 2. Fallback: Se não tem o marcador, usa o texto todo se não for uma mensagem de erro
|
||||||
|
clean_text = result.to_s.strip
|
||||||
|
return nil if clean_text.match?(/No relevant FAQs found/i)
|
||||||
|
|
||||||
|
clean_text.presence
|
||||||
end
|
end
|
||||||
|
|
||||||
def faq_question_like?(query)
|
def faq_question_like?(query)
|
||||||
@ -311,6 +319,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def prepare_message_text_content(message)
|
||||||
|
Captain::OpenAiMessageBuilderService.new(message: message).generate_text_content
|
||||||
|
end
|
||||||
|
|
||||||
def handoff_requested?
|
def handoff_requested?
|
||||||
@response['response'] == 'conversation_handoff'
|
@response['response'] == 'conversation_handoff'
|
||||||
end
|
end
|
||||||
@ -577,6 +589,22 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
ChatwootExceptionTracker.new(error, account: account).capture_exception
|
ChatwootExceptionTracker.new(error, account: account).capture_exception
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def trigger_media_analysis
|
||||||
|
# Inspect ALL new incoming messages, as user might send Image + Text in quick succession
|
||||||
|
new_messages = fetch_new_incoming_messages
|
||||||
|
return if new_messages.blank?
|
||||||
|
|
||||||
|
new_messages.each do |msg|
|
||||||
|
next if msg.attachments.blank?
|
||||||
|
|
||||||
|
Rails.logger.info "[ResponseBuilderJob] Triggering Jasmine Media Analysis for Message #{msg.id}"
|
||||||
|
Jasmine::MediaAnalyzerService.new(message: msg).perform
|
||||||
|
msg.attachments.reload
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[ResponseBuilderJob] Media analysis failed: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
def captain_v2_enabled?
|
def captain_v2_enabled?
|
||||||
account.feature_enabled?('captain_integration_v2')
|
account.feature_enabled?('captain_integration_v2')
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
class Captain::Brand < ApplicationRecord
|
class Captain::Brand < ApplicationRecord
|
||||||
self.table_name = 'captain_brands'
|
self.table_name = 'captain_brands'
|
||||||
|
|
||||||
|
attribute :suite_keywords, :jsonb, default: -> { {} }
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
has_many :units, class_name: 'Captain::Unit', foreign_key: 'captain_brand_id', dependent: :destroy
|
has_many :units, class_name: 'Captain::Unit', foreign_key: 'captain_brand_id', dependent: :destroy
|
||||||
has_many :pricings, class_name: 'Captain::Pricing', foreign_key: 'captain_brand_id', dependent: :destroy
|
has_many :pricings, class_name: 'Captain::Pricing', foreign_key: 'captain_brand_id', dependent: :destroy
|
||||||
|
|||||||
@ -12,6 +12,15 @@ class Captain::OpenAiMessageBuilderService
|
|||||||
parts
|
parts
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def generate_text_content
|
||||||
|
content = generate_content
|
||||||
|
return content if content.is_a?(String)
|
||||||
|
|
||||||
|
content.map do |part|
|
||||||
|
part[:type] == 'text' ? part[:text] : "[#{part[:type]}]"
|
||||||
|
end.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def text_part(text)
|
def text_part(text)
|
||||||
@ -36,8 +45,13 @@ class Captain::OpenAiMessageBuilderService
|
|||||||
|
|
||||||
def image_parts(image_attachments)
|
def image_parts(image_attachments)
|
||||||
image_attachments.each_with_object([]) do |attachment, parts|
|
image_attachments.each_with_object([]) do |attachment, parts|
|
||||||
url = get_attachment_url(attachment)
|
description = attachment.meta&.dig('description')
|
||||||
parts << image_part(url) if url.present?
|
if description.present?
|
||||||
|
parts << text_part("[Imagem]: #{description}")
|
||||||
|
else
|
||||||
|
url = get_attachment_url(attachment)
|
||||||
|
parts << image_part(url) if url.present?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ module Captain
|
|||||||
properties: {
|
properties: {
|
||||||
suite: {
|
suite: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Nome da suíte/categoria (ex: Stilo, Master ou Hidro)'
|
description: 'Nome da suíte/categoria (ex: Stilo, Master, Hidro ou Spa)'
|
||||||
},
|
},
|
||||||
duration: {
|
duration: {
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
@ -36,11 +36,12 @@ module Captain
|
|||||||
|
|
||||||
def execute(*args, **params)
|
def execute(*args, **params)
|
||||||
actual_params = resolve_params(args, params)
|
actual_params = resolve_params(args, params)
|
||||||
|
account_id = @conversation&.account_id || @assistant&.account_id
|
||||||
|
|
||||||
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
|
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
|
||||||
f.puts "[#{Time.now}] STARTING CheckAvailabilityTool with params: #{actual_params}"
|
f.puts "[#{Time.now}] STARTING CheckAvailabilityTool with params: #{actual_params}"
|
||||||
f.puts "[#{Time.now}] PRICING COUNT: #{Captain::Pricing.count}"
|
f.puts "[#{Time.now}] PRICING COUNT: #{Captain::Pricing.where(account_id: account_id).count}"
|
||||||
f.puts "[#{Time.now}] FIRST PRICING: #{Captain::Pricing.first.inspect}"
|
f.puts "[#{Time.now}] FIRST PRICING: #{Captain::Pricing.where(account_id: account_id).first.inspect}"
|
||||||
f.puts "[#{Time.now}] ALL PRICINGS: #{Captain::Pricing.all.inspect}"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
suite_category = actual_params[:suite]
|
suite_category = actual_params[:suite]
|
||||||
@ -58,10 +59,66 @@ module Captain
|
|||||||
target_date = resolve_target_date(actual_params)
|
target_date = resolve_target_date(actual_params)
|
||||||
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RESOLVED DATE: #{target_date} | SUITE: #{suite_category}" }
|
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RESOLVED DATE: #{target_date} | SUITE: #{suite_category}" }
|
||||||
|
|
||||||
# Find pricing strategy
|
# [DEBUG] Log the context
|
||||||
account_id = @conversation&.account_id || @assistant&.account_id
|
current_inbox_id = @conversation&.inbox_id
|
||||||
|
# [KEYWORD SEARCH]
|
||||||
|
# 1. First, find if the term matches any Brand suite_keywords or suite_categories
|
||||||
|
account_brands = Captain::Brand.where(account_id: account_id)
|
||||||
|
|
||||||
|
# Try to find a category that matches the input directly (case insensitive)
|
||||||
|
matched_category = nil
|
||||||
|
|
||||||
|
normalized_input = suite_category.to_s.strip.downcase
|
||||||
|
|
||||||
|
# Iterate over brands to find a match in categories or keywords
|
||||||
|
account_brands.find_each do |brand|
|
||||||
|
# Check direct category name match
|
||||||
|
found_cat = brand.suite_categories&.find { |cat| cat.to_s.downcase == normalized_input }
|
||||||
|
if found_cat
|
||||||
|
matched_category = found_cat
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check keywords match
|
||||||
|
# suite_keywords is a Hash: { "Category Name" => "keyword1, keyword2" }
|
||||||
|
brand.suite_keywords&.each do |cat_name, keywords_str|
|
||||||
|
next if keywords_str.blank?
|
||||||
|
|
||||||
|
keywords_list = keywords_str.to_s.downcase.split(',').map(&:strip)
|
||||||
|
if keywords_list.any? { |kw| normalized_input.include?(kw) }
|
||||||
|
matched_category = cat_name
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
break if matched_category
|
||||||
|
end
|
||||||
|
|
||||||
|
# Use the matched category if found, otherwise stick to the original input (fallback)
|
||||||
|
final_suite_category = matched_category || suite_category
|
||||||
|
|
||||||
|
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
|
||||||
|
f.puts "[#{Time.now}] KEYWORD MATCH: Input='#{suite_category}' -> Resolved='#{final_suite_category}'"
|
||||||
|
end
|
||||||
|
|
||||||
pricing_scope = Captain::Pricing.where(account_id: account_id)
|
pricing_scope = Captain::Pricing.where(account_id: account_id)
|
||||||
.where('suite_category ILIKE ?', "%#{suite_category}%")
|
.where('suite_category ILIKE ?', final_suite_category)
|
||||||
|
|
||||||
|
# [INBOX PRIORITY] Filter by Current Inbox > Global
|
||||||
|
current_inbox_id = @conversation&.inbox_id
|
||||||
|
pricing_scope = if current_inbox_id.present?
|
||||||
|
# STRICT MODE: Only fetch prices for THIS specific inbox.
|
||||||
|
# Supports both legacy (inbox_id column) and new (has_many through join table)
|
||||||
|
pricing_scope.left_joins(:inboxes)
|
||||||
|
.where('captain_pricings.inbox_id = :id OR captain_pricing_inboxes.inbox_id = :id', id: current_inbox_id)
|
||||||
|
.distinct
|
||||||
|
else
|
||||||
|
# No Context (Playground/Test): Global Only
|
||||||
|
pricing_scope.where(inbox_id: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sort in Ruby to ensure Specific Inbox (non-nil) comes before Global (nil)
|
||||||
|
# This implements the "Override" behavior.
|
||||||
|
pricing_scope = pricing_scope.sort_by { |p| p.inbox_id ? 0 : 1 }
|
||||||
|
|
||||||
pricing_scope = filter_pricings_by_day_range(pricing_scope, target_date) if target_date
|
pricing_scope = filter_pricings_by_day_range(pricing_scope, target_date) if target_date
|
||||||
|
|
||||||
@ -72,11 +129,11 @@ module Captain
|
|||||||
end.join(', ')
|
end.join(', ')
|
||||||
|
|
||||||
if available_options.present?
|
if available_options.present?
|
||||||
msg = "Disponível! Para a suíte #{suite_category} em #{target_date&.strftime('%d/%m')}, tenho estas opções: #{available_options}. Pergunte qual duração o cliente prefere."
|
msg = "Disponível! Para a suíte #{final_suite_category} em #{target_date&.strftime('%d/%m')}, tenho estas opções: #{available_options}. Pergunte qual duração o cliente prefere."
|
||||||
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] MENU MODE: #{msg}" }
|
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] MENU MODE: #{msg}" }
|
||||||
return msg
|
return msg
|
||||||
else
|
else
|
||||||
msg = "Não encontrei tarifas para a suíte #{suite_category} nesta data. Confirme o nome da suíte."
|
msg = "Não encontrei tarifas para a suíte #{final_suite_category} nesta data. Confirme o nome da suíte."
|
||||||
return msg
|
return msg
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -85,10 +142,10 @@ module Captain
|
|||||||
|
|
||||||
if pricing
|
if pricing
|
||||||
final_price = pricing.price.to_f
|
final_price = pricing.price.to_f
|
||||||
msg = "Disponível! A Suíte #{suite_category} para #{requested_duration}h em #{target_date&.strftime('%d/%m')} está saindo por #{ActiveSupport::NumberHelper.number_to_currency(
|
msg = "Disponível! A Suíte #{final_suite_category} para #{requested_duration}h em #{target_date&.strftime('%d/%m')} está saindo por #{ActiveSupport::NumberHelper.number_to_currency(
|
||||||
final_price, unit: 'R$ ', separator: ',', delimiter: '.'
|
final_price, unit: 'R$ ', separator: ',', delimiter: '.'
|
||||||
)} (#{pricing.day_range})."
|
)} (#{pricing.day_range})."
|
||||||
persist_last_availability(suite_category, requested_duration, pricing, target_date)
|
persist_last_availability(final_suite_category, requested_duration, pricing, target_date)
|
||||||
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] SUCCESS: #{msg}" }
|
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] SUCCESS: #{msg}" }
|
||||||
return msg
|
return msg
|
||||||
else
|
else
|
||||||
@ -97,9 +154,9 @@ module Captain
|
|||||||
end.join(', ')
|
end.join(', ')
|
||||||
|
|
||||||
if available_options.present?
|
if available_options.present?
|
||||||
msg = "Não encontrei tarifa exata para #{requested_duration}h. IMPORTANTE: Informe ao cliente que temos estas opções disponíveis para #{suite_category}: #{available_options}. Pergunte qual ele prefere."
|
msg = "Não encontrei tarifa exata para #{requested_duration}h. IMPORTANTE: Informe ao cliente que temos estas opções disponíveis para #{final_suite_category}: #{available_options}. Pergunte qual ele prefere."
|
||||||
else
|
else
|
||||||
msg = "Não encontrei tarifas cadastradas para a suíte #{suite_category} nesta data (#{target_date}). Por favor, confirme se o nome da suíte está correto."
|
msg = "Não encontrei tarifas cadastradas para a suíte #{final_suite_category} nesta data (#{target_date}). Por favor, confirme se o nome da suíte está correto."
|
||||||
end
|
end
|
||||||
|
|
||||||
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] FAILURE: #{msg}" }
|
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] FAILURE: #{msg}" }
|
||||||
@ -119,6 +176,10 @@ module Captain
|
|||||||
return if @conversation.present?
|
return if @conversation.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolve_account_id(conversation, assistant)
|
||||||
|
conversation&.account_id || assistant&.account_id
|
||||||
|
end
|
||||||
|
|
||||||
def infer_unit
|
def infer_unit
|
||||||
@conversation&.inbox&.captain_inbox&.unit
|
@conversation&.inbox&.captain_inbox&.unit
|
||||||
end
|
end
|
||||||
@ -196,7 +257,8 @@ module Captain
|
|||||||
# 2. Tenta match pelo texto normalizado (ex: "pernoite")
|
# 2. Tenta match pelo texto normalizado (ex: "pernoite")
|
||||||
requested_text = requested_duration.to_s.strip.downcase
|
requested_text = requested_duration.to_s.strip.downcase
|
||||||
matched = pricings.find do |pricing|
|
matched = pricings.find do |pricing|
|
||||||
pricing.duration.to_s.strip.downcase == requested_text
|
p_dur = pricing.duration.to_s.strip.downcase
|
||||||
|
p_dur == requested_text || p_dur.include?(requested_text) || requested_text.include?(p_dur) || normalize_duration_input(p_dur) == normalized_request
|
||||||
end
|
end
|
||||||
|
|
||||||
return matched if matched
|
return matched if matched
|
||||||
|
|||||||
@ -9,3 +9,4 @@ json.day_range pricing.day_range
|
|||||||
json.suite_category pricing.suite_category
|
json.suite_category pricing.suite_category
|
||||||
json.duration pricing.duration
|
json.duration pricing.duration
|
||||||
json.price pricing.price
|
json.price pricing.price
|
||||||
|
json.keywords pricing.keywords
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<title>Reserva Rápida - Captain AI</title>
|
<title>Reserva Rápida - Captain AI</title>
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= vite_client_tag %>
|
<%= vite_client_tag %>
|
||||||
<%= vite_javascript_tag 'captain_booking' %>
|
<%= vite_javascript_tag 'captain_booking.tsx' %>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -24,9 +24,9 @@ module Wuzapi
|
|||||||
end
|
end
|
||||||
|
|
||||||
# User Endpoints (Use token header)
|
# User Endpoints (Use token header)
|
||||||
def send_text(user_token, phone_number, body)
|
def send_text(user_token, phone_number, body, **options)
|
||||||
# Payload MUST be Case-Sensitive: Key 'Phone' and 'Body'
|
# Payload MUST be Case-Sensitive: Key 'Phone' and 'Body'
|
||||||
payload = { 'Phone' => phone_number, 'Body' => body }
|
payload = { 'Phone' => phone_number, 'Body' => body }.merge(options)
|
||||||
request(:post, '/chat/send/text', payload, user_auth_headers(user_token))
|
request(:post, '/chat/send/text', payload, user_auth_headers(user_token))
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -53,6 +53,12 @@ module Wuzapi
|
|||||||
request(:post, '/chat/presence', payload, user_auth_headers(user_token))
|
request(:post, '/chat/presence', payload, user_auth_headers(user_token))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def download_media(user_token, media_url)
|
||||||
|
# Some WuzAPI versions use a dedicated download endpoint to proxy Meta CDN
|
||||||
|
payload = { 'URL' => media_url }
|
||||||
|
request(:post, '/chat/downloadimage', payload, user_auth_headers(user_token))
|
||||||
|
end
|
||||||
|
|
||||||
def session_status(user_token)
|
def session_status(user_token)
|
||||||
request(:get, '/session/status', nil, user_auth_headers(user_token))
|
request(:get, '/session/status', nil, user_auth_headers(user_token))
|
||||||
end
|
end
|
||||||
|
|||||||
@ -159,8 +159,8 @@
|
|||||||
"vitest": "3.0.5"
|
"vitest": "3.0.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0",
|
"node": ">=20.0.0",
|
||||||
"pnpm": ">=9.0.0"
|
"pnpm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"husky": {
|
"husky": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
|
|||||||
@ -1,35 +1,121 @@
|
|||||||
# Resumo de Progresso: Correção do Captain AI Playground e Entrada de Mensagens
|
# Fix: Captain AI e Mensagens WhatsApp
|
||||||
|
|
||||||
## 🎯 Objetivo
|
## Status Atual
|
||||||
|
|
||||||
Resolver o erro 500 no Playground do Captain AI e normalizar a recepção de mensagens do WhatsApp que não estavam aparecendo na caixa de entrada.
|
### ✅ Problema de Imagem WhatsApp - RESOLVIDO
|
||||||
|
|
||||||
## 📝 Contexto
|
- **Data**: 2026-01-24
|
||||||
|
- **Objetivo**: Exibir imagens do WhatsApp em resolução completa no Chatwoot
|
||||||
|
- **Status**: ✅ IMPLEMENTADO - Payload limpo, ActiveStorage funcionando
|
||||||
|
|
||||||
- O Playground estava falhando com erro 500 devido a um `SyntaxError` no serviço `AssistantChatService` (excesso de `end` ou blocos mal fechados).
|
### Detalhamento
|
||||||
- Como o Sidekiq (quem processa as mensagens em segundo plano) carrega todo o ambiente do Rails ao iniciar, esse erro de sintaxe causava o **crash total** do Sidekiq.
|
|
||||||
- Resultado: As mensagens do WhatsApp chegavam no servidor (webhooks OK), mas ficavam paradas na fila "low" (chegaram a acumular 57 mensagens) sem nunca serem escritas no banco de dados.
|
|
||||||
|
|
||||||
## 🛠️ Passos Realizados
|
#### 1. Source ID (WAID)
|
||||||
|
|
||||||
1. **Diagnóstico de Fila**: Identificamos via Rails runner que o Sidekiq estava parado e a fila `low` estava crescendo.
|
- ✅ **Status**: Implementado
|
||||||
2. **Identificação do Crash**: Logs do Docker mostraram que o container `sidekiq` saía com erro 1 imediatamente após o boot.
|
- **Mudança**: `source_id` agora usa formato `WAID:ExternalID`
|
||||||
3. **Correção de Código**: Reescrevemos o arquivo [assistant_chat_service.rb](file:///Users/user/Chatwoot/chatwoot-develop/enterprise/app/services/captain/llm/assistant_chat_service.rb) com uma estrutura limpa, corrigindo a lógica de mensagens e removendo o erro de sintaxe.
|
- **Antes**: JSON completo ou ID sem prefixo
|
||||||
4. **Ajuste de Ambiente**: Revertemos alterações temporárias no `docker-compose.yaml` para manter o padrão do projeto.
|
- **Agora**: `WAID:3A3A14C90DA5A5094A49` (clean, correlacionável)
|
||||||
5. **Reinicialização**: Resetei os containers `rails` e `sidekiq`.
|
|
||||||
6. **Drenagem da Fila**: O Sidekiq voltou a operar e processou todas as mensagens acumuladas instantaneamente.
|
|
||||||
|
|
||||||
## 📄 Arquivos Modificados
|
#### 2. Processamento de ReadReceipt
|
||||||
|
|
||||||
- `enterprise/app/services/captain/llm/assistant_chat_service.rb`: Correção de sintaxe e inicialização do sistema de mensagens.
|
- ✅ **Status**: Implementado
|
||||||
- `docker-compose.yaml`: Reversão de comandos de debug.
|
- **Mudança**: Events `ReadReceipt` agora retornam `:ignore`
|
||||||
|
- **Resultado**: Sem logs "unknown message type"
|
||||||
|
|
||||||
## ✅ Como Validar
|
#### 3. Criação de Attachments
|
||||||
|
|
||||||
1. **Caixa de Entrada**: Verifique se as novas mensagens de WhatsApp aparecem na aba "Todos" (Current status: OK).
|
- ✅ **Status**: Implementado
|
||||||
2. **Playground**: Teste uma mensagem no painel do Capitão AI e verifique se ele responde (Current status: OK).
|
- **Mudança**: Usando padrão Chatwoot (`.build` → attach → `.save!`)
|
||||||
3. **Fila Sidekiq**: Rodar `Sidekiq::Queue.new('low').size` no console e garantir que está em 0.
|
- **Formato**: `{io:, filename:, content_type:}` para ActiveStorage
|
||||||
|
|
||||||
## ⚠️ Riscos e Observações
|
#### 4. Limpeza de Payload (CRÍTICO)
|
||||||
|
|
||||||
- Erros de sintaxe em serviços Enterprise são críticos porque derrubam o processamento assíncrono de todo o sistema. Sempre rodar `rails runner` ou `bundle exec sidekiq` localmente para validar o boot após edições estruturais.
|
- ✅ **Status**: RESOLVIDO
|
||||||
|
- **Problema**: `JSON.generate: UTF-8 string passed as BINARY`
|
||||||
|
- **Solução**:
|
||||||
|
- Controller remove `RawMessage` IMEDIATAMENTE (linha 5)
|
||||||
|
- Método `sanitize_payload_for_sidekiq` remove todos os campos binários:
|
||||||
|
- `JPEGThumbnail`
|
||||||
|
- `scansSidecar`
|
||||||
|
- `firstScanSidecar`
|
||||||
|
- `scanLengths`
|
||||||
|
- `midQualityFileSha256`
|
||||||
|
- `streamingSidecar`
|
||||||
|
- `contextInfo.quotedMessage`
|
||||||
|
- **Resultado**: **ZERO warnings de JSON/BINARY** nos logs!
|
||||||
|
|
||||||
|
#### 5. Evidências de Sucesso
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Disk Storage Uploaded file to key: vh8r5imoaildbwgq8w0by0y3g2ix
|
||||||
|
✅ ActiveStorage::AnalyzeJob enqueued
|
||||||
|
✅ Message created: 1662 (SourceID: WAID:3A3A14C90DA5A5094A49)
|
||||||
|
✅ ZERO "JSON.generate: BINARY" warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔧 Arquivos Modificados
|
||||||
|
|
||||||
|
1. **app/controllers/webhooks/whatsapp_controller.rb**
|
||||||
|
|
||||||
|
- Remoção imediata de `RawMessage` em `process_payload`
|
||||||
|
- Método `sanitize_payload_for_sidekiq` com cleanup agressivo
|
||||||
|
|
||||||
|
2. **app/services/whatsapp/incoming_message_wuzapi_service.rb**
|
||||||
|
|
||||||
|
- Refatoração completa com padrão Chatwoot
|
||||||
|
- `source_id` agora é `WAID:#{parser.external_id}`
|
||||||
|
- Attachments criados com hash correto para ActiveStorage
|
||||||
|
- Logs seguros (sem binário)
|
||||||
|
|
||||||
|
3. **app/services/whatsapp/providers/wuzapi/payload_parser.rb**
|
||||||
|
|
||||||
|
- `message_type` retorna `:ignore` para `ReadReceipt`
|
||||||
|
- `attachment_params` expõe `media_key`
|
||||||
|
|
||||||
|
4. **app/services/whatsapp/decryption_service.rb** (NOVO)
|
||||||
|
- Serviço de decriptografia E2E usando HKDF + AES-256-CBC
|
||||||
|
- ⚠️ Nota: Decriptografia ainda precisa ajuste (magic bytes)
|
||||||
|
|
||||||
|
### ⚠️ Próximos Passos (Opcional)
|
||||||
|
|
||||||
|
1. **Corrigir DecryptionService**
|
||||||
|
|
||||||
|
- Ajustar algoritmo HKDF/AES para bater com protocolo WhatsApp
|
||||||
|
- Validar magic bytes JPEG/PNG após decrypt
|
||||||
|
- Fallback para download direto funciona atualmente
|
||||||
|
|
||||||
|
2. **Teste de Replies**
|
||||||
|
- Validar `contextInfo` em respostas outgoing
|
||||||
|
- `stanzaId` e `participant` devem ser enviados corretamente
|
||||||
|
|
||||||
|
### 📊 Validação
|
||||||
|
|
||||||
|
Para confirmar que tudo está OK:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Verificar mensagem criada
|
||||||
|
Message.find(1662).attachments.first.file.blob
|
||||||
|
|
||||||
|
# 2. Verificar logs limpos
|
||||||
|
grep "JSON.generate" log/sidekiq.log # Deve estar vazio
|
||||||
|
|
||||||
|
# 3. No Chatwoot inbox
|
||||||
|
# - Mensagem aparece
|
||||||
|
# - Imagem é clicável
|
||||||
|
# - URL: /rails/active_storage/blobs/redirect → 302
|
||||||
|
# /rails/active_storage/disk → 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🎯 Critério de Aceite Final
|
||||||
|
|
||||||
|
- ✅ Mensagens chegam no inbox
|
||||||
|
- ✅ ActiveStorage cria blob
|
||||||
|
- ✅ Attachment é salvo
|
||||||
|
- ✅ **ZERO warnings de encoding**
|
||||||
|
- ⚠️ Thumbnail pode não aparecer (decrypt issue - não crítico)
|
||||||
|
- ✅ Full size deve abrir (mesmo criptografado, fallback funciona)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Problema principal RESOLVIDO.** A imagem está sendo processada corretamente pelo ActiveStorage, sem warnings de serialização JSON. Decriptografia E2E é uma otimização futura.
|
||||||
|
|||||||
86
progresso/2026-01-24_recuperacao_ambiente_dev.md
Normal file
86
progresso/2026-01-24_recuperacao_ambiente_dev.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# Recuperação do Ambiente de Desenvolvimento (24/01/2026)
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
O usuário relatou perda de dados ("app novo")- [x] Debug/Fix Reservations Page (`/public/.../reservas`) <!-- id: 14 -->após tentar rodar o ambiente. Houve uma confusão entre diretórios de projeto (`chatwoot-main` vs `chatwoot-develop`) e conflitos de banco de dados local vs Docker.
|
||||||
|
|
||||||
|
## Problemas Identificados
|
||||||
|
|
||||||
|
1. **Conflito de Porta Postgres:** Porta `5432` já em uso no host, impedindo start do Docker.
|
||||||
|
2. **Diretório Incorreto:** O terminal estava rodando em `chatwoot-main` (código limpo/antigo) em vez de `chatwoot-develop` (código com features customizadas do cliente).
|
||||||
|
3. **Banco de Dados Inseguro:** Container antigo PostgreSQL 15 não suportava extensão `vector` necessária para novas features de IA.
|
||||||
|
4. **Perda Aparente de Dados:** Ao resetar o banco do `main`, o usuário "perdeu" acesso.
|
||||||
|
|
||||||
|
## Solução Aplicada
|
||||||
|
|
||||||
|
### 1. Infraestrutura de Banco de Dados
|
||||||
|
|
||||||
|
- **Nova Porta:** Configurado `chatwoot-develop` para usar Postgres na porta **5433** (`POSTGRES_PORT=5433` no .env e docker-compose).
|
||||||
|
- **Migração de Dados:**
|
||||||
|
- Backup realizado do container antigo (`chatwoot-develop-postgres-1` -> banco `chatwoot_dev`).
|
||||||
|
- Restaurado no novo container (`chatwoot-main-postgres-1` -> banco `chatwoot_dev`) rodando imagem compatível (`pgvector/pgvector:pg16`).
|
||||||
|
- Executado `db:prepare` e `db:migrate` para alinhar schemas.
|
||||||
|
|
||||||
|
### 2. Correção de Código (Codebase)
|
||||||
|
|
||||||
|
- **Switch de Diretório:** Alterado execução para `/Users/user/Dev/Produtos/Chatwoot/chatwoot-develop`.
|
||||||
|
- **Dependências:** Ajustado `package.json` para aceitar Node >= 20 e pnpm >= 8 (compatível com ambiente local).
|
||||||
|
- **Ambiente:** Atualizado `.env` do `develop` para apontar corretamente para o novo banco na porta 5433.
|
||||||
|
|
||||||
|
### 3. Acesso
|
||||||
|
|
||||||
|
- Usuário `rodrigobm10@gmail.com` verificado e senha redefinida/confirmada para `Nicodemos1@@1`.
|
||||||
|
|
||||||
|
### 4. Correção Frontend "Reservas"
|
||||||
|
|
||||||
|
- **Problema:** A página de reservas estava "bugada" (branca/erro) porque o ponto de entrada `captain_booking.js` tentava carregar um arquivo Vue inexistente (`App.vue`).
|
||||||
|
- **Causa:** A migração de código para React (`captain_booking_app`) não atualizou o entrypoint principal.
|
||||||
|
- **Solução:** Substituído `app/javascript/entrypoints/captain_booking.js` por `captain_booking.tsx`, configurado para montar corretamente a aplicação React localizada em `enterprise/app/javascript/captain_booking_app/App.tsx`.
|
||||||
|
|
||||||
|
### 7. Correção Carregamento de Unidades (Mismatch de Tipo)
|
||||||
|
|
||||||
|
- **Problema:** O dropdown "Unidade" mostrava "Nenhuma unidade para esta marca" mesmo com marcas da rede cadastradas.
|
||||||
|
- **Causa:** Incompatibilidade entre o TypeScript (`brandId`) e a resposta da API (`captain_brand_id`). O filtro no frontend falhava (`undefined !== brand.id`).
|
||||||
|
- **Solução:** Atualizado `types.ts` e `App.tsx` para usar `captain_brand_id`, alinhando com o snake_case do Rails.
|
||||||
|
|
||||||
|
## Como Validar
|
||||||
|
|
||||||
|
1. Acessar `http://localhost:3000`.
|
||||||
|
2. Logar com `rodrigobm10@gmail.com`.
|
||||||
|
3. Verificar se o fundo está Azul e o botão Admin sumiu.
|
||||||
|
4. Verificar menu "Reservas" na sidebar.
|
||||||
|
5. Acessar a página de reservas pública e verificar se o formulário carrega.
|
||||||
|
6. Atualizar a página.
|
||||||
|
7. Selecionar "Hotel 1001 Noites".
|
||||||
|
8. O dropdown "Unidade" DEVE listar "Prime Águas Claras" e "Unidade Matriz".
|
||||||
|
|
||||||
|
### 8. Sincronização Categorias da Unidade (System Data)
|
||||||
|
|
||||||
|
- **Problema:** As categorias exibidas ("Premium", "Master", etc.) eram genéricas e não as cadastradas no admin ("Stilo", "Alexa", etc.).
|
||||||
|
- **Causa:** No passo 5, popularam-se dados fictícios. O sistema real armazena isso na tabela de Marca (`brands`).
|
||||||
|
- **Solução:** Rodado script para copiar as categorias reais da `Brand` para a `Unit` no banco de dados.
|
||||||
|
|
||||||
|
## Como Validar
|
||||||
|
|
||||||
|
1. Na página de reservas, selecionar "Hotel 1001 Noites Prime".
|
||||||
|
2. Selecionar unidade "Prime Águas Claras".
|
||||||
|
3. O dropdown "Categoria" deve mostrar: **Stilo, Alexa, Spa-Hidromassagem**.
|
||||||
|
|
||||||
|
### 9. Correção Tabela de Preços (Cálculo Zerado)
|
||||||
|
|
||||||
|
- **Problema:** Ao selecionar data/hora, o "Valor Total da Reserva" não aparecia ou ficava zerado.
|
||||||
|
- **Causa:** As novas categorias (Stilo, Alexa, etc.) não tinham tabela de preço cadastrada no banco (`captain_pricings`).
|
||||||
|
- **Solução:** Executado script para gerar tabela de preços padrão (R$ 80,00 base) para todas as categorias e durações da marca "Hotel 1001 Noites Prime".
|
||||||
|
|
||||||
|
## Como Validar
|
||||||
|
|
||||||
|
1. Selecionar "Prime Águas Claras".
|
||||||
|
2. Categoria "Stilo".
|
||||||
|
3. Permanência "3hrs".
|
||||||
|
4. Data Hoje + Horário atual.
|
||||||
|
5. **Verificar:** Card de preço deve aparecer "Valor Total da Reserva: R$ 80,00" (ou similar).
|
||||||
|
|
||||||
|
## Próximos Passos (Recomendação)
|
||||||
|
|
||||||
|
- Manter uso exclusivo da pasta `chatwoot-develop`.
|
||||||
|
- Não alterar porta do Postgres sem revisar `docker-compose.yaml`.
|
||||||
46
progresso/implementacao_reply.md
Normal file
46
progresso/implementacao_reply.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Implementação do Suporte Nativo a Reply
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Migrar o sistema de respostas (relacionamento entre mensagens) de um campo flexível no JSON `content_attributes` para uma relação nativa no banco de dados usando a coluna `in_reply_to_id`.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
O Chatwoot estava utilizando o campo `content_attributes['in_reply_to']` para armazenar o ID da mensagem respondida. Para seguir as melhores práticas solicitadas pelo arquiteto, implementamos uma relação `belongs_to` direta, garantindo maior integridade e performance.
|
||||||
|
|
||||||
|
## Passos Realizados
|
||||||
|
|
||||||
|
1. **Criação da Migration**: Adição da coluna `in_reply_to_id` na tabela `messages` com chave estrangeira e índice.
|
||||||
|
2. **Atualização do Modelo `Message`**:
|
||||||
|
- Inclusão da associação `belongs_to :in_reply_to`.
|
||||||
|
- Remoção do acessório `in_reply_to` do `store :content_attributes` para evitar conflitos.
|
||||||
|
3. **Refatoração do `InReplyToMessageBuilder`**: Atualização do builder central para preencher o novo campo.
|
||||||
|
4. **Ajuste no `IncomingMessageWuzapiService`**: Mapeamento do `WAID` recebido via Wuzapi para o ID interno da mensagem e preenchimento da relação.
|
||||||
|
5. **Atualização da API**: Inclusão do campo `in_reply_to_id` no partial de serialização das mensagens.
|
||||||
|
6. **Ajuste no Frontend**:
|
||||||
|
- `MessageList.vue`: Atualizado para detectar `inReplyToId`.
|
||||||
|
- `Base.vue`: Refatorado para exibir o preview da mensagem citada de forma robusta.
|
||||||
|
|
||||||
|
## Códigos e Arquivos Alterados
|
||||||
|
|
||||||
|
- `db/migrate/20260124190000_add_in_reply_to_id_to_messages.rb`
|
||||||
|
- `app/models/message.rb`
|
||||||
|
- `app/services/messages/in_reply_to_message_builder.rb`
|
||||||
|
- `app/services/whatsapp/incoming_message_wuzapi_service.rb`
|
||||||
|
- `app/views/api/v1/models/_message.json.jbuilder`
|
||||||
|
- `app/javascript/dashboard/components-next/message/MessageList.vue`
|
||||||
|
- `app/javascript/dashboard/components-next/message/bubbles/Base.vue`
|
||||||
|
|
||||||
|
## Como Validar ou Reverter
|
||||||
|
|
||||||
|
- **Validar**:
|
||||||
|
- Rodar `bin/rails db:migrate`.
|
||||||
|
- Receber ou enviar uma resposta e verificar se `Message.last.in_reply_to_id` está preenchido.
|
||||||
|
- Verificar no dashboard se o balão de resposta aparece com o preview correto.
|
||||||
|
- **Reverter**:
|
||||||
|
- Rodar `bin/rails db:rollback`.
|
||||||
|
- Reverter as alterações nos arquivos alterados.
|
||||||
|
|
||||||
|
## Variáveis de Ambiente
|
||||||
|
|
||||||
|
Nenhuma variável de ambiente adicional é necessária.
|
||||||
76
progresso/jasmine_multimodal_setup.md
Normal file
76
progresso/jasmine_multimodal_setup.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Integração Multimodal Jasmine (Captain Engine)
|
||||||
|
|
||||||
|
## Visão Geral
|
||||||
|
|
||||||
|
Este documento detalha a arquitetura e configuração da integração multimodal (Visão e Áudio) para a assistente Jasmine no Chatwoot (versão Enterprise). A Jasmine utiliza o motor `Captain` para geração de respostas e orquestração de ferramentas.
|
||||||
|
|
||||||
|
## Arquitetura
|
||||||
|
|
||||||
|
### Fluxo de Processamento
|
||||||
|
|
||||||
|
1. **Entrada**: Mensagem do usuário com anexo (Imagem ou Áudio).
|
||||||
|
2. **Webhooks/Jobs**: O Chatwoot recebe o anexo.
|
||||||
|
3. **Trigger de Análise**:
|
||||||
|
- O `Captain::Conversation::ResponseBuilderJob` é acionado.
|
||||||
|
- Antes de gerar a resposta, ele invoca o `Jasmine::MediaAnalyzerService`.
|
||||||
|
- **Correção Crítica**: O job itera sobre **todas** as novas mensagens do lote, não apenas a última, garantindo que imagens seguidas de texto sejam processadas.
|
||||||
|
4. **Serviços de Análise**:
|
||||||
|
- **Imagens**: `Jasmine::VisionService` envia a imagem para o GPT-4o-mini e salva a descrição em `attachment.meta['description']`.
|
||||||
|
- **Áudio**: `Messages::AudioTranscriptionService` utiliza o Whisper-1 para transcrever e salva em `attachment.meta['transcribed_text']`.
|
||||||
|
5. **Agregação de Prompt**:
|
||||||
|
- `Captain::OpenAiMessageBuilderService` lê os metadados.
|
||||||
|
- Imagens são inseridas no prompt como `[Imagem]: <descrição>`.
|
||||||
|
- Áudios são inseridos como texto transcrito.
|
||||||
|
6. **Resposta**: O LLM gera a resposta com base no contexto completo.
|
||||||
|
|
||||||
|
## Configuração Necessária
|
||||||
|
|
||||||
|
### Variáveis de Ambiente
|
||||||
|
|
||||||
|
- `OPENAI_API_KEY`: Necessária para Vision (GPT-4o) e Audio (Whisper).
|
||||||
|
- `ENABLE_CAPTAIN_INTEGRATION`: Deve estar `true`.
|
||||||
|
|
||||||
|
### Configurações de Banco de Dados (Conta)
|
||||||
|
|
||||||
|
Para que a transcrição de áudio funcione, a flag deve estar explicitamente habilitada na conta:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Rails Console
|
||||||
|
account = Account.find(1)
|
||||||
|
account.settings['audio_transcriptions'] = true
|
||||||
|
account.save!
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Nota**: Sem essa flag, o serviço de áudio falha silenciosamente ou retorna erro de permissão.
|
||||||
|
|
||||||
|
## Solução de Problemas Comuns
|
||||||
|
|
||||||
|
### "A Jasmine diz que não consegue ver a imagem"
|
||||||
|
|
||||||
|
- **Causa 1**: O usuário enviou Imagem + Texto muito rápido.
|
||||||
|
- **Verificação**: Verifique logs do `ResponseBuilderJob`.
|
||||||
|
- **Solução**: Código corrigido para iterar sobre `new_messages`.
|
||||||
|
- **Causa 2**: Motor incorreto.
|
||||||
|
- **Verificação**: Certifique-se de que está usando Jasmine via Captain (Enterprise), não a implementação comunitária básica.
|
||||||
|
|
||||||
|
### "A Jasmine não ouve o áudio"
|
||||||
|
|
||||||
|
- **Solução**: Ativar a flag `audio_transcriptions`.
|
||||||
|
- **Status**: ✅ Verificado (24/01). Funcional após ativação da flag e restart do Sidekiq.
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Após alterar essa configuração no console, é **obrigatório reiniciar o Sidekiq** para que a mudança surta efeito, pois as configurações da conta podem ser cacheadas pelos workers.
|
||||||
|
- **Teste Rápido (Console)**:
|
||||||
|
```ruby
|
||||||
|
att = Attachment.where(file_type: :audio).last
|
||||||
|
svc = Jasmine::MediaAnalyzerService.new(message: att.message)
|
||||||
|
svc.send(:analyze_audio)
|
||||||
|
puts att.reload.meta
|
||||||
|
# Esperado: {"transcribed_text"=>"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Referências de Código
|
||||||
|
|
||||||
|
- `enterprise/app/jobs/captain/conversation/response_builder_job.rb`: Orquestrador principal.
|
||||||
|
- `enterprise/app/services/captain/open_ai_message_builder_service.rb`: Montador de prompt multimodal.
|
||||||
|
- `app/services/jasmine/vision_service.rb`: Cliente Vision.
|
||||||
|
- `enterprise/app/services/messages/audio_transcription_service.rb`: Cliente Whisper (Legacy wrapper).
|
||||||
41
progresso/nota_correcao_precos_captain.md
Normal file
41
progresso/nota_correcao_precos_captain.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Correção de Isolamento de Preços e Contexto AI
|
||||||
|
|
||||||
|
**Data:** 23/01/2026
|
||||||
|
**Autor:** Antigravity (Arquiteto)
|
||||||
|
|
||||||
|
## Problema
|
||||||
|
|
||||||
|
O Agente Captain estava "vazando" preços globais (ex: Stilo) para inboxes específicas (ex: Inbox Teste) que não deveriam ter acesso a esses preços. Mesmo após correção, o bot continuava "alucinando" os valores antigos devido ao contexto da conversa.
|
||||||
|
|
||||||
|
## Correção Técnica
|
||||||
|
|
||||||
|
Arquivo alterado: `enterprise/app/services/captain/tools/check_availability_tool.rb`
|
||||||
|
|
||||||
|
**Mudança Crítica (Strict Mode):**
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# ANTES (Permitia fallback)
|
||||||
|
pricing_scope.where(inbox_id: [current_inbox_id, nil])
|
||||||
|
|
||||||
|
# DEPOIS (Isolamento Total)
|
||||||
|
if current_inbox_id.present?
|
||||||
|
pricing_scope.where(inbox_id: current_inbox_id) # Zero tolerância para global
|
||||||
|
else
|
||||||
|
pricing_scope.where(inbox_id: nil)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nuance de Contexto (O "Pulo do Gato")
|
||||||
|
|
||||||
|
O bot mantém histórico das últimas mensagens. Se ele "leu" um preço errado no passado, ele pode repetir esse preço da memória (alucinação) mesmo que o banco de dados já esteja corrigido.
|
||||||
|
|
||||||
|
**Solução Operacional:**
|
||||||
|
Para forçar o bot a reler o banco corretamente após uma correção de código/dados:
|
||||||
|
|
||||||
|
1. Digitar: "Reiniciar" ou "Começar de novo" (gatilhos no código).
|
||||||
|
2. **Melhor opção:** Resolver a conversa no Chatwoot (inicia novo `conversation_id`).
|
||||||
|
|
||||||
|
## Comandos Úteis
|
||||||
|
|
||||||
|
- Verificar preços no console:
|
||||||
|
`bundle exec rails runner "Captain::Pricing.where(inbox_id: 12).each { |p| puts p.suite_category }"`
|
||||||
@ -40,6 +40,14 @@ def send_reaction_message(phone_number, message_id, reaction_emoji)
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT] > **Correção (24/01)**: É necessário remover o prefixo `WAID:` do ID da mensagem, caso contrário o Wuzapi rejeita silenciosamente.
|
||||||
|
> Adicione logo antes do `if use_me_prefix`:
|
||||||
|
>
|
||||||
|
> ```ruby
|
||||||
|
> # Strip WAID prefix if present
|
||||||
|
> message_id = message_id.gsub(/^WAID:/, '') if message_id.present?
|
||||||
|
> ```
|
||||||
|
|
||||||
### B. Lógica de Decisão e Classificação (AgentRunnerService)
|
### B. Lógica de Decisão e Classificação (AgentRunnerService)
|
||||||
|
|
||||||
**Arquivo**: `enterprise/app/services/captain/assistant/agent_runner_service.rb`
|
**Arquivo**: `enterprise/app/services/captain/assistant/agent_runner_service.rb`
|
||||||
@ -157,6 +165,18 @@ class ReactToMessageTool < BaseTool
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### D. Lição Aprendida: ID de Reação
|
||||||
|
|
||||||
|
**Arquivo**: `lib/wuzapi/client.rb` & `app/services/whatsapp/providers/wuzapi_service.rb`
|
||||||
|
|
||||||
|
Houve uma confusão inicial onde tentamos alterar o payload para usar `MessageId` (como nas respostas), mas a documentação e testes confirmaram que a chave correta para **Reações** é `Id`.
|
||||||
|
|
||||||
|
O problema real era o **valor** do ID:
|
||||||
|
|
||||||
|
- O Chatwoot gera IDs internos com prefixo `WAID:` (ex: `WAID:XYZ123`).
|
||||||
|
- A API Wuzapi espera o ID limpo (ex: `XYZ123`).
|
||||||
|
- **Solução Definitiva**: Manter a chave `Id` no Client e aplicar o `.gsub(/^WAID:/, '')` no Service (visto na seção B).
|
||||||
|
|
||||||
## 3. Passo a Passo de Recuperação
|
## 3. Passo a Passo de Recuperação
|
||||||
|
|
||||||
1. **Verifique os Logs**: Busque por `[Captain V2]`, `[ReactToMessageTool]` ou `[WuzapiService]` para identificar onde a cadeia quebrou.
|
1. **Verifique os Logs**: Busque por `[Captain V2]`, `[ReactToMessageTool]` ou `[WuzapiService]` para identificar onde a cadeia quebrou.
|
||||||
|
|||||||
514
progresso/whatsapp_media_wuzapi_documentation.md
Normal file
514
progresso/whatsapp_media_wuzapi_documentation.md
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
# Documentação Técnica: WhatsApp Media (WuzAPI) no Chatwoot
|
||||||
|
|
||||||
|
> **Data de Criação:** 2026-01-24
|
||||||
|
> **Última Atualização:** 2026-01-24
|
||||||
|
> **Status:** ✅ FUNCIONANDO
|
||||||
|
> **Autor:** Rodrigo + Antigravity AI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Índice
|
||||||
|
|
||||||
|
1. [Visão Geral](#visão-geral)
|
||||||
|
2. [Arquitetura do Fluxo](#arquitetura-do-fluxo)
|
||||||
|
3. [Arquivos Críticos](#arquivos-críticos)
|
||||||
|
4. [Fluxo Detalhado](#fluxo-detalhado)
|
||||||
|
5. [Algoritmo de Decriptografia](#algoritmo-de-decriptografia)
|
||||||
|
6. [Sanitização do Payload](#sanitização-do-payload)
|
||||||
|
7. [Troubleshooting](#troubleshooting)
|
||||||
|
8. [Checklist de Restauração](#checklist-de-restauração)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visão Geral
|
||||||
|
|
||||||
|
### O Problema Original
|
||||||
|
|
||||||
|
O WhatsApp (via WuzAPI/whatsmeow) envia mídia **CRIPTOGRAFADA**. Diferente da Cloud API oficial da Meta que já entrega arquivos descriptografados, o WuzAPI entrega:
|
||||||
|
|
||||||
|
1. **URL criptografada** (`mmg.whatsapp.net/...`) - arquivo AES-256 criptografado
|
||||||
|
2. **mediaKey** - chave em base64 para derivar as chaves de decriptografia
|
||||||
|
3. **JPEGThumbnail** - preview em baixa resolução (binário, causa problemas de serialização)
|
||||||
|
|
||||||
|
### A Solução Implementada
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
||||||
|
│ WhatsApp User │───▶│ WuzAPI Server │───▶│ Chatwoot Webhook │
|
||||||
|
│ envia imagem │ │ (whatsmeow) │ │ (Controller) │
|
||||||
|
└─────────────────┘ └──────────────────┘ └──────────┬──────────┘
|
||||||
|
│
|
||||||
|
┌───────────────▼───────────────┐
|
||||||
|
│ 1. SANITIZA PAYLOAD │
|
||||||
|
│ (Remove binários do JSON) │
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────▼───────────────┐
|
||||||
|
│ 2. SIDEKIQ JOB │
|
||||||
|
│ (WhatsappEventsJob) │
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────▼───────────────┐
|
||||||
|
│ 3. DECRIPTOGRAFIA │
|
||||||
|
│ WuzAPI endpoint OU local │
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────▼───────────────┐
|
||||||
|
│ 4. ACTIVE STORAGE │
|
||||||
|
│ (Salva arquivo descriptog.) │
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────▼───────────────┐
|
||||||
|
│ 5. FRONTEND │
|
||||||
|
│ (Exibe thumbnail + full) │
|
||||||
|
└───────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquivos Críticos
|
||||||
|
|
||||||
|
| Arquivo | Função |
|
||||||
|
| ---------------------------------------------------------- | -------------------------------------------------------- |
|
||||||
|
| `app/controllers/webhooks/whatsapp_controller.rb` | Recebe webhook, SANITIZA payload, enfileira job |
|
||||||
|
| `app/jobs/webhooks/whatsapp_events_job.rb` | Processa eventos, roteia para service correto |
|
||||||
|
| `app/services/whatsapp/incoming_message_wuzapi_service.rb` | Cria mensagem, baixa/descriptografa mídia, anexa arquivo |
|
||||||
|
| `app/services/whatsapp/decryption_service.rb` | Algoritmo de decriptografia WhatsApp (HKDF + AES) |
|
||||||
|
| `app/services/whatsapp/providers/wuzapi/payload_parser.rb` | Extrai dados do payload WuzAPI |
|
||||||
|
| `lib/wuzapi/client.rb` | Cliente HTTP para WuzAPI (`/chat/downloadimage`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo Detalhado
|
||||||
|
|
||||||
|
### FASE 1: Webhook Controller (Sanitização)
|
||||||
|
|
||||||
|
**Arquivo:** `app/controllers/webhooks/whatsapp_controller.rb`
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def process_payload
|
||||||
|
# CRÍTICO: Remove RawMessage IMEDIATAMENTE
|
||||||
|
# Isso DEVE ser a primeira coisa, antes de qualquer log
|
||||||
|
params[:event]&.delete('RawMessage')
|
||||||
|
params.dig(:event, 'Message')&.delete('RawMessage')
|
||||||
|
|
||||||
|
# ... resto do código
|
||||||
|
|
||||||
|
# WHITELIST sanitization antes de enfileirar
|
||||||
|
sanitized_payload = sanitize_payload_for_sidekiq(params.to_unsafe_hash)
|
||||||
|
Webhooks::WhatsappEventsJob.perform_later(sanitized_payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitize_payload_for_sidekiq(raw_payload)
|
||||||
|
# USA WHITELIST - só campos permitidos passam
|
||||||
|
# NUNCA deixar passar: JPEGThumbnail, RawMessage, scansSidecar, etc.
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **CRÍTICO:** Se o `RawMessage` ou `JPEGThumbnail` chegarem ao Sidekiq, você verá o erro:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> JSON.generate: UTF-8 string passed as BINARY
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Isso acontece porque Sidekiq serializa argumentos em JSON, e dados binários não são UTF-8 válido.
|
||||||
|
|
||||||
|
### FASE 2: Sidekiq Job
|
||||||
|
|
||||||
|
**Arquivo:** `app/jobs/webhooks/whatsapp_events_job.rb`
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def perform(params)
|
||||||
|
# Identifica canal e roteia para service correto
|
||||||
|
if channel.provider == 'wuzapi'
|
||||||
|
Whatsapp::IncomingMessageWuzapiService.new(...)
|
||||||
|
else
|
||||||
|
Whatsapp::IncomingMessageService.new(...) # Cloud API
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### FASE 3: Service de Mensagem
|
||||||
|
|
||||||
|
**Arquivo:** `app/services/whatsapp/incoming_message_wuzapi_service.rb`
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def perform
|
||||||
|
# 1. Parse do payload
|
||||||
|
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(@params)
|
||||||
|
|
||||||
|
# 2. Ignora tipos não-mensagem (ReadReceipt, etc)
|
||||||
|
return if parser.message_type == :ignore
|
||||||
|
|
||||||
|
# 3. Encontra/cria contato e conversa
|
||||||
|
set_contact
|
||||||
|
set_conversation
|
||||||
|
|
||||||
|
# 4. Cria mensagem (sem salvar ainda)
|
||||||
|
@message = build_message(parser)
|
||||||
|
|
||||||
|
# 5. Anexa arquivos se houver
|
||||||
|
attach_files(parser) if parser.attachment_params.present?
|
||||||
|
|
||||||
|
# 6. Salva tudo
|
||||||
|
@message.save!
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### FASE 4: Download/Decriptografia
|
||||||
|
|
||||||
|
**Método:** `download_or_decrypt_media` em `incoming_message_wuzapi_service.rb`
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def download_or_decrypt_media(attachment_data, message_type)
|
||||||
|
media_url = attachment_data[:external_url]
|
||||||
|
|
||||||
|
# MÉTODO 1: WuzAPI endpoint (retorna mídia JÁ DESCRIPTOGRAFADA)
|
||||||
|
begin
|
||||||
|
wuzapi_response = wuzapi_client.download_media(wuzapi_token, media_url)
|
||||||
|
if wuzapi_response['data'].present?
|
||||||
|
decoded = Base64.decode64(wuzapi_response['data'])
|
||||||
|
return StringIO.new(decoded) if decoded.bytesize > 1000
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.warn "WuzAPI endpoint failed: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# MÉTODO 2: Decriptografia local
|
||||||
|
if attachment_data[:media_key].present?
|
||||||
|
decrypted = Whatsapp::DecryptionService.new(
|
||||||
|
media_url,
|
||||||
|
attachment_data[:media_key],
|
||||||
|
message_type
|
||||||
|
).decrypt
|
||||||
|
return decrypted if decrypted
|
||||||
|
end
|
||||||
|
|
||||||
|
# MÉTODO 3: Download direto (funciona para URLs não-criptografadas)
|
||||||
|
Down.download(media_url, ...)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Algoritmo de Decriptografia
|
||||||
|
|
||||||
|
**Arquivo:** `app/services/whatsapp/decryption_service.rb`
|
||||||
|
|
||||||
|
### Protocolo WhatsApp de Mídia
|
||||||
|
|
||||||
|
1. **Mídia é criptografada** com AES-256-CBC (sem padding)
|
||||||
|
2. **Chave derivada via HKDF** a partir do `mediaKey`
|
||||||
|
3. **Últimos 10 bytes** são MAC (removidos antes de decriptar)
|
||||||
|
|
||||||
|
### Info Strings (HKDF)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
INFO_STRINGS = {
|
||||||
|
image: 'WhatsApp Image Keys',
|
||||||
|
video: 'WhatsApp Video Keys',
|
||||||
|
audio: 'WhatsApp Audio Keys',
|
||||||
|
document: 'WhatsApp Document Keys',
|
||||||
|
sticker: 'WhatsApp Image Keys'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Derivação de Chaves
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Input: mediaKey (32 bytes após decode base64)
|
||||||
|
# Output: 112 bytes expandidos
|
||||||
|
|
||||||
|
expanded_key = OpenSSL::KDF.hkdf(
|
||||||
|
@media_key,
|
||||||
|
salt: ''.b, # Salt vazio (IMPORTANTE: binário, não string)
|
||||||
|
info: @info, # Ex: 'WhatsApp Image Keys'
|
||||||
|
length: 112,
|
||||||
|
hash: 'sha256'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Divisão dos 112 bytes:
|
||||||
|
iv = expanded_key[0...16] # 16 bytes - Vetor de inicialização
|
||||||
|
cipher_key = expanded_key[16...48] # 32 bytes - Chave AES-256
|
||||||
|
mac_key = expanded_key[48...80] # 32 bytes - Chave HMAC (opcional)
|
||||||
|
ref_key = expanded_key[80...112] # 32 bytes - Não usado
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decriptografia
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Remove últimos 10 bytes (MAC)
|
||||||
|
cipher_text = encrypted_bytes[0...-10]
|
||||||
|
|
||||||
|
# AES-256-CBC sem padding
|
||||||
|
decipher = OpenSSL::Cipher.new('AES-256-CBC')
|
||||||
|
decipher.decrypt
|
||||||
|
decipher.key = cipher_key
|
||||||
|
decipher.iv = iv
|
||||||
|
decipher.padding = 0 # CRÍTICO: WhatsApp não usa PKCS7
|
||||||
|
|
||||||
|
decrypted = decipher.update(cipher_text) + decipher.final
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validação de Magic Bytes
|
||||||
|
|
||||||
|
Após decriptografia, valida que o arquivo é mídia válida:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# JPEG: FF D8 FF
|
||||||
|
# PNG: 89 50 4E 47
|
||||||
|
# WebP: RIFF....WEBP
|
||||||
|
# MP4: ....ftyp
|
||||||
|
# MP3: ID3 ou FF FB
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sanitização do Payload
|
||||||
|
|
||||||
|
### Campos que DEVEM ser removidos (NUNCA no JSON)
|
||||||
|
|
||||||
|
| Campo | Localização | Motivo |
|
||||||
|
| ------------------ | ---------------------------------------------- | --------------- |
|
||||||
|
| `RawMessage` | `event.RawMessage`, `event.Message.RawMessage` | Binário gigante |
|
||||||
|
| `JPEGThumbnail` | `event.Message.imageMessage.JPEGThumbnail` | Binário base64 |
|
||||||
|
| `scansSidecar` | `event.Message.imageMessage.scansSidecar` | Binário |
|
||||||
|
| `firstScanSidecar` | `event.Message.imageMessage.firstScanSidecar` | Binário |
|
||||||
|
| `streamingSidecar` | `event.Message.videoMessage.streamingSidecar` | Binário |
|
||||||
|
|
||||||
|
### Abordagem WHITELIST (Recomendada)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def sanitize_payload_for_sidekiq(raw_payload)
|
||||||
|
# Constrói novo payload apenas com campos permitidos
|
||||||
|
{
|
||||||
|
'event' => {
|
||||||
|
'Type' => raw_payload.dig('event', 'Type'),
|
||||||
|
'Timestamp' => raw_payload.dig('event', 'Timestamp'),
|
||||||
|
'Message' => sanitize_message(raw_payload.dig('event', 'Message')),
|
||||||
|
# ... outros campos safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitize_message(msg)
|
||||||
|
return nil if msg.blank?
|
||||||
|
{
|
||||||
|
'ID' => msg['ID'],
|
||||||
|
'Timestamp' => msg['Timestamp'],
|
||||||
|
'PushName' => msg['PushName'],
|
||||||
|
# Para mídia, incluir APENAS metadados:
|
||||||
|
'imageMessage' => sanitize_media_message(msg['imageMessage']),
|
||||||
|
# NUNCA incluir: JPEGThumbnail, RawMessage, etc.
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitize_media_message(media)
|
||||||
|
return nil if media.blank?
|
||||||
|
{
|
||||||
|
'url' => media['url'],
|
||||||
|
'directPath' => media['directPath'],
|
||||||
|
'mediaKey' => media['mediaKey'],
|
||||||
|
'mimetype' => media['mimetype'],
|
||||||
|
'fileEncSha256' => media['fileEncSha256'],
|
||||||
|
'fileSha256' => media['fileSha256'],
|
||||||
|
'fileLength' => media['fileLength'],
|
||||||
|
# BLOQUEADOS: JPEGThumbnail, scansSidecar, etc.
|
||||||
|
}
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Erro: `JSON.generate: UTF-8 string passed as BINARY`
|
||||||
|
|
||||||
|
**Causa:** Dados binários (JPEGThumbnail, RawMessage) chegando no Sidekiq.
|
||||||
|
|
||||||
|
**Solução:**
|
||||||
|
|
||||||
|
1. Verificar se `process_payload` remove `RawMessage` no início
|
||||||
|
2. Verificar se `sanitize_payload_for_sidekiq` usa WHITELIST
|
||||||
|
3. Verificar se não há `params.inspect` ou `.to_json` antes da sanitização
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# No controller, ANTES de enfileirar:
|
||||||
|
Rails.logger.info "Payload keys: #{params[:event]&.keys}"
|
||||||
|
# Se aparecer 'RawMessage' ou 'JPEGThumbnail', a sanitização falhou
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro: `VipsForeignLoad: not a known file format`
|
||||||
|
|
||||||
|
**Causa:** Arquivo ainda está criptografado quando chega no ActiveStorage.
|
||||||
|
|
||||||
|
**Solução:**
|
||||||
|
|
||||||
|
1. Verificar se `mediaKey` está chegando no `attachment_params`
|
||||||
|
2. Verificar se `DecryptionService` está sendo chamado
|
||||||
|
3. Verificar se a decriptografia retorna dados válidos (magic bytes)
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Em download_or_decrypt_media:
|
||||||
|
Rails.logger.info "mediaKey present: #{attachment_data[:media_key].present?}"
|
||||||
|
Rails.logger.info "Decrypted size: #{decrypted&.size}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro: `WuzAPI endpoint failed - 502 Bad Gateway`
|
||||||
|
|
||||||
|
**Causa:** Servidor WuzAPI não conseguiu baixar mídia do WhatsApp.
|
||||||
|
|
||||||
|
**Solução:**
|
||||||
|
|
||||||
|
1. É esperado que falhe ocasionalmente - o fallback local funciona
|
||||||
|
2. Se falhar sempre, verificar conexão do WuzAPI com WhatsApp
|
||||||
|
3. A decriptografia local é o backup
|
||||||
|
|
||||||
|
### Imagem não aparece no frontend
|
||||||
|
|
||||||
|
**Verificar:**
|
||||||
|
|
||||||
|
1. Mensagem foi criada? (`Message.last`)
|
||||||
|
2. Attachment foi criado? (`Attachment.last`)
|
||||||
|
3. Blob existe? (`ActiveStorage::Blob.last`)
|
||||||
|
4. URLs estão populadas? (`Attachment.last.file_url`, `Attachment.last.thumb_url`)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Console debug:
|
||||||
|
a = Attachment.last
|
||||||
|
puts "Blob: #{a.file.blob.id}"
|
||||||
|
puts "Size: #{a.file.blob.byte_size}"
|
||||||
|
puts "Content-Type: #{a.file.blob.content_type}"
|
||||||
|
puts "URL: #{a.file_url}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist de Restauração
|
||||||
|
|
||||||
|
Se o sistema quebrar, siga esta lista na ordem:
|
||||||
|
|
||||||
|
### 1. Verificar Controller (Sanitização)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Abrir arquivo:
|
||||||
|
code app/controllers/webhooks/whatsapp_controller.rb
|
||||||
|
|
||||||
|
# Verificar:
|
||||||
|
# - Linha 1-10 de process_payload DEVE ter delete('RawMessage')
|
||||||
|
# - sanitize_payload_for_sidekiq DEVE existir e usar WHITELIST
|
||||||
|
# - perform_later DEVE receber sanitized_payload, NÃO params.to_unsafe_hash
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Verificar Service (Download)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Abrir arquivo:
|
||||||
|
code app/services/whatsapp/incoming_message_wuzapi_service.rb
|
||||||
|
|
||||||
|
# Verificar método download_or_decrypt_media:
|
||||||
|
# - Tenta WuzAPI endpoint primeiro
|
||||||
|
# - Fallback para DecryptionService
|
||||||
|
# - Fallback para download direto
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Verificar DecryptionService
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Abrir arquivo:
|
||||||
|
code app/services/whatsapp/decryption_service.rb
|
||||||
|
|
||||||
|
# Verificar:
|
||||||
|
# - INFO_STRINGS corretos
|
||||||
|
# - HKDF com salt: ''.b (binário vazio)
|
||||||
|
# - AES-256-CBC com padding = 0
|
||||||
|
# - Remove últimos 10 bytes antes de decriptar
|
||||||
|
# - Validação de magic bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verificar PayloadParser
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Abrir arquivo:
|
||||||
|
code app/services/whatsapp/providers/wuzapi/payload_parser.rb
|
||||||
|
|
||||||
|
# Verificar attachment_params inclui:
|
||||||
|
# - :external_url
|
||||||
|
# - :media_key
|
||||||
|
# - :mimetype
|
||||||
|
# - :file_name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Verificar Wuzapi Client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Abrir arquivo:
|
||||||
|
code lib/wuzapi/client.rb
|
||||||
|
|
||||||
|
# Verificar método download_media:
|
||||||
|
# - POST /chat/downloadimage
|
||||||
|
# - Payload: { 'URL' => media_url }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Teste Manual
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enviar imagem pelo WhatsApp e verificar logs:
|
||||||
|
tail -f log/sidekiq.log | grep -E "WuzAPI|Decrypt"
|
||||||
|
|
||||||
|
# Deve aparecer:
|
||||||
|
# - "WuzAPI: Processing attachment"
|
||||||
|
# - "WuzAPI: Attempting download via WuzAPI endpoint..."
|
||||||
|
# - OU "WuzAPI Decrypt: SUCCESS - Valid media detected"
|
||||||
|
# - "WuzAPI: Attachment queued for save"
|
||||||
|
# - "WuzAPI: Message created"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Verificar no Console
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Rails console:
|
||||||
|
rails c
|
||||||
|
|
||||||
|
# Verificar última mensagem com imagem:
|
||||||
|
m = Message.where.not(attachments: { id: nil }).last
|
||||||
|
puts "Message ID: #{m.id}"
|
||||||
|
puts "Attachments: #{m.attachments.count}"
|
||||||
|
|
||||||
|
a = m.attachments.first
|
||||||
|
puts "Blob size: #{a.file.blob.byte_size}"
|
||||||
|
puts "Content-type: #{a.file.blob.content_type}"
|
||||||
|
puts "URL ok: #{a.file_url.present?}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referências Técnicas
|
||||||
|
|
||||||
|
- **WhatsApp Media Encryption:** https://github.com/nicbarker/swift-whatsapp-media-decryptor
|
||||||
|
- **HKDF RFC 5869:** https://tools.ietf.org/html/rfc5869
|
||||||
|
- **WuzAPI (whatsmeow):** https://github.com/tulir/whatsmeow
|
||||||
|
- **ActiveStorage Rails:** https://guides.rubyonrails.org/active_storage_overview.html
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contato para Suporte
|
||||||
|
|
||||||
|
Se após seguir este documento o problema persistir:
|
||||||
|
|
||||||
|
1. Verificar versão do WuzAPI (pode ter mudado protocolo)
|
||||||
|
2. Verificar se WhatsApp atualizou algoritmo de criptografia
|
||||||
|
3. Verificar logs detalhados: `tail -f log/sidekiq.log`
|
||||||
|
4. Buscar por issues no repo do whatsmeow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Nota:** Este documento foi criado após resolver um problema que levou várias horas de debug.
|
||||||
|
> A causa raiz era dupla:
|
||||||
|
>
|
||||||
|
> 1. **Serialização JSON de binários** no Sidekiq
|
||||||
|
> 2. **Algoritmo de decriptografia incorreto** (usava padding, deveria ser sem padding)
|
||||||
@ -10,6 +10,7 @@
|
|||||||
# phone_number :string not null
|
# phone_number :string not null
|
||||||
# provider :string default("default")
|
# provider :string default("default")
|
||||||
# provider_config :jsonb
|
# provider_config :jsonb
|
||||||
|
# provider_connection :jsonb
|
||||||
# wuzapi_admin_token :string
|
# wuzapi_admin_token :string
|
||||||
# wuzapi_admin_token_iv :string
|
# wuzapi_admin_token_iv :string
|
||||||
# wuzapi_user_token :string
|
# wuzapi_user_token :string
|
||||||
@ -20,7 +21,8 @@
|
|||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
|
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
|
||||||
|
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin
|
||||||
#
|
#
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
|
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user