Skip to content

Módulo WhatsApp — Classes e Métodos

Documentação detalhada do módulo modules/integrations/whatsapp/ com assinaturas completas de métodos.

Estrutura do módulo

modules/integrations/whatsapp/
├── whatsapp.module.ts
├── whatsapp.controller.ts
├── whatsapp.service.ts
├── whatsapp-send.processor.ts
├── whatsapp-webhook.controller.ts
├── meta-graph.service.ts
├── whatsapp-pricing-sync.service.ts
├── router/
│   ├── whatsapp-router.service.ts
│   ├── business-hours.service.ts
│   └── customer-context.service.ts
├── chatwoot/
│   ├── chatwoot-api.service.ts
│   ├── chatwoot-config.service.ts
│   ├── chatwoot-config.controller.ts
│   └── chatwoot-outbound-webhook.controller.ts
├── evolution/
│   └── evolution-api.service.ts
└── dto/
    ├── create-template.dto.ts
    └── save-chatwoot-config.dto.ts

WhatsappService

Arquivo: whatsapp.service.ts | Decorator: @Injectable()

Dependências injetadas

typescript
constructor(
  private prisma: PrismaService,
  private encryption: EncryptionService,
  private metaGraph: MetaGraphService,
  private minio: MinioService,
  private pricingSync: WhatsappPricingSyncService,
  private chatwootDb: ChatwootDbService,
) {}

Métodos públicos

typescript
// Cria template (local e/ou na Meta API)
async createTemplate(
  storeWhatsappId: string,
  organizationId: string,
  dto: CreateTemplateDto,
): Promise<WhatsappTemplate>

// Lista templates de uma instância
async listTemplates(
  storeWhatsappId: string,
  organizationId: string,
): Promise<any[]>  // inclui variableMapping e buttonConfig reconstruídos

// Sincroniza templates da Meta API para o DB local
async syncTemplatesFromMeta(
  storeWhatsappId: string,
  organizationId: string,
): Promise<{ synced: number }>

// Edita template (3 modos: saveMapping | saveDraft | submit para Meta)
async editTemplate(
  storeWhatsappId: string,
  organizationId: string,
  templateId: string,
  dto: CreateTemplateDto,
): Promise<any>  // pode retornar metaWarning se a Meta falhou mas local salvou

// Remove template (da Meta e do DB)
async deleteTemplate(
  storeWhatsappId: string,
  organizationId: string,
  templateId: string,
): Promise<WhatsappTemplate>

// Upload de mídia para template (MinIO + Meta)
async uploadTemplateMedia(
  storeWhatsappId: string,
  organizationId: string,
  file: Express.Multer.File,
): Promise<{ url: string; handle: string }>
// url = URL pública MinIO, handle = "h:xxx" para uso na Meta API

// Busca informações da conta (tier, quality_score)
async getAccountInfo(
  storeWhatsappId: string,
  organizationId: string,
): Promise<Record<string, unknown>>

// Status detalhado de rate limit
async getRateLimitStatus(
  storeWhatsappId: string,
  organizationId: string,
): Promise<{
  tier: string
  limit: number
  sentToday: number
  sentThisMonth: number
  remaining: number
  percentageUsed: number
  isUnlimited: boolean
  qualityScore: number
  estimatedCostToday: number
  estimatedCostMonth: number
  costBreakdownToday: Record<string, number>
  costBreakdownMonth: Record<string, number>
  resetAt: string  // ISO da próxima meia-noite UTC
}>

// Métricas agregadas de campanhas WhatsApp
async getAnalytics(
  organizationId: string,
  start?: string,
  end?: string,
): Promise<{
  totalSent: number
  totalDelivered: number
  totalReads: number
  totalFailed: number
  totalReplies: number
  totalConversions: number
  totalRevenue: number
  readRate: number
  replyRate: number
  deliveryRate: number
  conversionRate: number
}>

// Monitoramento de conversas (redirects, sem menu, janela expirada)
async getConversationMonitoring(
  organizationId: string,
  start: Date,
  end: Date,
): Promise<{
  redirects: { total: number; byDay: Array<{ date: string; count: number }> }
  noMenu: { total: number; byDay: Array<{ date: string; count: number }> }
  windowExpiry: { total: number; byDay: Array<{ date: string; count: number }> }
}>

// Lista conversas de uma instância (últimas 50)
async listConversations(
  storeWhatsappId: string,
  organizationId: string,
): Promise<any[]>  // com customerName adicionado

// Mensagens de uma conversa
async getConversationMessages(
  storeWhatsappId: string,
  organizationId: string,
  conversationId: string,
): Promise<Message[]>

// Envia resposta de texto para um contato
async sendReply(
  storeWhatsappId: string,
  organizationId: string,
  conversationId: string,
  text: string,
): Promise<{ wamId: string }>

// Handlers de webhook (chamados pelo WhatsappWebhookController)
async handleTemplateStatusUpdate(value: Record<string, unknown>): Promise<void>
async handleAccountUpdate(wabaId: string, value: Record<string, unknown>): Promise<void>
async handleMessagesEvent(value: Record<string, unknown>): Promise<void>
async updateMessageStatus(status: Record<string, unknown>): Promise<void>
async processInboundMessage(
  metadata: Record<string, unknown>,
  message: Record<string, unknown>,
): Promise<void>
async verifyWebhookToken(token: string): Promise<boolean>

Constantes internas

typescript
const TIER_LIMITS: Record<string, number> = {
  TIER_50:   250,       // 250 conversas/24h
  TIER_250:  2_000,
  TIER_1K:   10_000,
  TIER_10K:  100_000,
  TIER_100K: -1,        // ilimitado
  UNLIMITED: -1,
}

const THROUGHPUT_TO_TIER: Record<string, string> = {
  STANDARD: 'TIER_250',
  HIGH:     'TIER_1K',
  NOT_SET:  'TIER_50',
}

WhatsappSendProcessor

Arquivo: whatsapp-send.processor.ts | Decorator: @Processor('whatsapp-send')

typescript
class WhatsappSendProcessor extends WorkerHost {
  constructor(
    private prisma: PrismaService,
    private metaGraph: MetaGraphService,
    private encryption: EncryptionService,
    private chatwootApi: ChatwootApiService,
    private pricingSync: WhatsappPricingSyncService,
    @InjectQueue('whatsapp-send') private queue: Queue,
  ) {}

  async process(job: Job<WhatsappSendJobData>): Promise<void>

  private async checkCampaignCompletion(campaignId: string): Promise<void>
  // Conta jobs pendentes; se zero, atualiza campaign.status=sent|failed

  private async syncToChatwoot(...): Promise<void>
  // fire-and-forget: encontra/cria contato e conversa no Chatwoot, envia nota de atividade

  private async accumulateCost(...): Promise<void>
  // fire-and-forget: busca rate da categoria do template, incrementa CampaignMetric.cost

  private toDisplayName(raw: string): string
  // "MARIA DA SILVA" → "Maria Silva" (primeiro + último, capitalizado)
}

WhatsappRouterService

Arquivo: router/whatsapp-router.service.ts | Decorator: @Injectable()

Responsável por roteamento de mensagens inbound para a loja correta.

typescript
class WhatsappRouterService {
  constructor(
    private prisma: PrismaService,
    private metaGraph: MetaGraphService,
    private chatwootApi: ChatwootApiService,
    private chatwootConfig: ChatwootConfigService,
    private evolutionApi: EvolutionApiService,
    private encryption: EncryptionService,
    private businessHours: BusinessHoursService,
    private customerContext: CustomerContextService,
  ) {}

  // Ponto de entrada para mensagens inbound do webhook
  async handleInboundMessage(
    phoneNumberId: string,
    senderPhone: string,
    message: Record<string, unknown>,
  ): Promise<void>

  // Envia menu de seleção de loja (botões ou lista)
  async sendStoreMenu(
    waNumber: any,
    accessToken: string,
    senderPhone: string,
    isWabaOfficial?: boolean,
    onlyOpen?: boolean,
  ): Promise<void>

  // Roteia para loja específica (modo Evolution)
  async routeToStore(
    waNumber: any,
    accessToken: string,
    senderPhone: string,
    storeId: string,
  ): Promise<void>

  // Roteia para loja (modo WABA oficial)
  async routeToStoreDirectly(
    waNumber: any,
    accessToken: string,
    senderPhone: string,
    storeId: string,
  ): Promise<void>

  // Resolve instância Evolution prioritária
  async resolveEvolutionInstance(
    orgId: string,
    preferredStoreId: string | null,
  ): Promise<{ number: string; evolutionInstanceName: string; evolutionApiKey: string; chatwootInboxId: string; storeId: string } | null>

  // Resolve loja WABA prioritária
  async resolveWabaStore(
    orgId: string,
    preferredStoreId: string | null,
  ): Promise<{ chatwootInboxId: string; storeId: string; isFallback: boolean } | null>

  // IDs das lojas abertas agora
  async getOpenStoreIds(
    orgId: string,
    isWabaOfficial: boolean,
    excludeStoreId?: string,
  ): Promise<string[]>
}

Fluxo de mensagem inbound

1. Busca storeWhatsappNumber pelo phoneNumberId
2. Detecta modo: evolution vs waba_official
3. Registra WhatsappMessage inbound + verifica opt-out/opt-in
4. Verifica textos especiais:
   - "pare/stop/sair..." → handleOptOut()
   - "voltar/receber..." → handleOptIn()
   - "trocar de loja"   → sendStoreMenu()
5. Detecta interactive reply (botão/lista) → handleInteractiveReply()
6. Detecta campaign quick reply → handleCampaignQuickReply()
7. Roteia por modo:
   - evolution: busca loja preferida do cliente → Evolution
   - waba_official: busca conversa aberta → Chatwoot

MetaGraphService

Arquivo: meta-graph.service.ts | Decorator: @Injectable()

Cliente HTTP para a Meta Graph API v20.0.

typescript
class MetaGraphService {
  constructor(private http: HttpService) {}

  // Templates
  async createTemplate(wabaId, accessToken, payload): Promise<{ id: string; status: string }>
  async listTemplates(wabaId, accessToken): Promise<{ data: Record<string, unknown>[] }>
  async editTemplate(metaTemplateId, accessToken, payload): Promise<{ success: boolean }>
  async deleteTemplate(wabaId, accessToken, templateName, metaTemplateId?): Promise<{ success: boolean }>

  // Informações da conta
  async getPhoneNumberInfo(phoneNumberId, accessToken): Promise<Record<string, unknown>>
  // Retorna: messaging_limit_tier, quality_score, account_mode, throughput
  
  async getWabaInfo(wabaId, accessToken): Promise<Record<string, unknown>>
  // Retorna: account_review_status, ban_state

  // Mensagens
  async sendMessage(
    phoneNumberId: string,
    accessToken: string,
    payload: Record<string, unknown>,
  ): Promise<{ messages: Array<{ id: string }> }>
  // id retornado = WAM ID (rastreamento de status)

  // Mídia
  async uploadMediaForTemplate(
    accessToken, fileBuffer, fileSize, mimeType, originalName,
  ): Promise<string>  // retorna handle "h:xxx"
  
  async downloadMedia(
    mediaId: string,
    accessToken: string,
  ): Promise<{ buffer: Buffer; mimeType: string; fileName: string }>

  // Analytics de conversas (billing)
  async getConversationAnalytics(
    phoneNumberId, accessToken, start, end,
  ): Promise<{ data: Array<{ data_points: Array<{ country; conversation_category; conversation_count; cost }> }> }>
}

BusinessHoursService

Arquivo: router/business-hours.service.ts | Decorator: @Injectable()

typescript
class BusinessHoursService {
  // Verifica se loja está aberta agora (UTC-3, sem DST)
  isOpen(businessHours: unknown): boolean

  // Minutos até fechar (null se fechado)
  minutesUntilClose(businessHours: unknown): number | null

  // Minutos desde o fechamento (null se aberto)
  minutesSinceClose(businessHours: unknown): number | null

  // Horário de fechamento hoje (HH:MM) ou null
  todayCloseTime(businessHours: unknown): string | null

  // Texto legível: "Seg 09:00–18:00, Ter 09:00–18:00, ..."
  formatHours(businessHours: unknown): string
}

Formato esperado de businessHours:

json
{
  "1": { "open": "09:00", "close": "18:00", "enabled": true },
  "2": { "open": "09:00", "close": "18:00", "enabled": true },
  "6": { "open": "09:00", "close": "13:00", "enabled": true }
}

Chaves: 1 = Segunda ... 7 = Domingo.


ChatwootApiService

Arquivo: chatwoot/chatwoot-api.service.ts | Decorator: @Injectable()

typescript
class ChatwootApiService {
  constructor(
    private http: HttpService,
    private config: ChatwootConfigService,
  ) {}

  // Contatos
  async findOrCreateContact(orgId, phone, name?): Promise<ChatwootContact | null>
  async updateContact(orgId, contactId, name): Promise<void>
  async syncContactName(orgId, phone, name): Promise<boolean>

  // Conversas
  async findOrCreateConversation(
    orgId, contactId, inboxId, assignedAgent?, attributionWindowDays?,
  ): Promise<{ conversation: ChatwootConversation; isNew: boolean } | null>
  async findOpenConversation(orgId, contactId, inboxId): Promise<ChatwootConversation | null>
  async findAnyOpenConversation(orgId, contactId, inboxId?): Promise<ChatwootConversation | null>
  async findConversationWithLabel(orgId, contactId, inboxId, label): Promise<ChatwootConversation | null>
  async getConversationMessages(orgId, conversationId, limit?): Promise<Array<{ senderName, content, type }>>
  async getConversationContact(orgId, conversationId): Promise<{ id, phone, name } | null>
  async getConversationsByLabel(orgId, label): Promise<Array<{ id, inboxId, contactId, customerName, customerPhone }>>

  // Mensagens
  async sendOutboundMessage(orgId, conversationId, content, messageType?, agentId?, contentAttributes?): Promise<number | null>
  async sendIncomingMessage(orgId, conversationId, content): Promise<number | null>
  async sendIncomingMessageWithAttachment(orgId, conversationId, content, buffer, mimeType, fileName): Promise<number | null>
  async sendPrivateNote(orgId, conversationId, content, important?): Promise<number | null>

  // Labels
  async addConversationLabel(orgId, conversationId, label): Promise<boolean>
  async setConversationLabels(orgId, conversationId, labels): Promise<boolean>
  async swapConversationLabel(orgId, conversationId, remove, add): Promise<boolean>

  // Status e atribuição
  async updateConversationStatus(orgId, conversationId, status: 'open'|'pending'|'resolved'): Promise<boolean>
  async markConversationUnread(orgId, conversationId): Promise<boolean>
  async assignConversation(orgId, conversationId, agentId): Promise<boolean>
  async setAgentAvailability(orgId, agentId, availability: 'online'|'offline'|'busy'): Promise<boolean>

  // Agentes
  async findOrCreateAgent(orgId, email, name): Promise<number | null>
  async findAgentByEmail(orgId, email): Promise<number | null>
  async listInboxAgents(orgId, inboxId): Promise<Array<{ id, name, email }>>
  async addAgentToInbox(orgId, inboxId, agentIds): Promise<boolean>
  async removeAgentFromInbox(orgId, inboxId, agentIds): Promise<boolean>

  // Inboxes
  async createApiChannelInbox(orgId, name): Promise<{ id, webhookUrl } | null>
  async deleteInbox(orgId, inboxId): Promise<boolean>
  async findInboxByName(orgId, name): Promise<{ id, name } | null>
  async listInboxes(orgId): Promise<Array<{ id, name, channel_type }>>
  async updateInboxWebhookUrl(orgId, inboxId, webhookUrl): Promise<boolean>
}

EvolutionApiService

Arquivo: evolution/evolution-api.service.ts | Decorator: @Injectable()

typescript
class EvolutionApiService {
  // EVOLUTION_API_URL = env (default: http://evolution-api:8080)
  // EVOLUTION_API_KEY = env (global key)

  async createInstance(instanceName, chatwootConfig?): Promise<{ instance, qrCode, apiKey } | null>
  async getQrCode(instanceName, apiKey?): Promise<{ pairingCode, code, base64 } | null>
  async getConnectionStatus(instanceName, apiKey?): Promise<'open'|'close'|'connecting'|null>
  async sendText(instanceName, phone, message, apiKey?): Promise<boolean>
  async sendMedia(instanceName, phone, mediaType, mediaUrl, caption?, fileName?, apiKey?): Promise<boolean>
  async deleteInstance(instanceName): Promise<boolean>
  async logout(instanceName): Promise<boolean>
  async listInstances(): Promise<Array<{ instanceName, state }> | null>
  async syncChatwootInboxWebhook(instanceName, cwConfig, inboxId, inboxName, autoCreate?): Promise<boolean>
}

DTOs

CreateTemplateDto

typescript
class CreateTemplateDto {
  name: string                    // max 512 chars
  language?: string               // default "pt_BR"
  category: 'MARKETING' | 'UTILITY' | 'AUTHENTICATION'
  components: TemplateComponentDto[]  // max 10
  saveDraft?: boolean
  saveMapping?: boolean
}

class TemplateComponentDto {
  type: 'HEADER' | 'BODY' | 'FOOTER' | 'BUTTONS'
  format?: 'TEXT' | 'IMAGE' | 'VIDEO' | 'DOCUMENT'
  text?: string
  buttons?: TemplateButtonDto[]
  variableMapping?: Record<string, string>  // campo CRM, não enviado para Meta
  example?: Record<string, unknown>
}

class TemplateButtonDto {
  type: 'QUICK_REPLY' | 'URL' | 'PHONE_NUMBER'
  text: string
  url?: string
  phone_number?: string
  smartRedirect?: boolean    // campo CRM: gera redirect token por cliente
  isUnsubscribe?: boolean    // campo CRM: gera token de opt-out
  fixedStoreId?: string      // campo CRM: força loja específica
  example?: unknown
}

Diagrama de fluxo: envio de campanha WhatsApp

CampaignsService.sendWhatsappCampaign(campaignId)


1. Lock atômico: campaign.status → 'sending'
2. Busca template aprovado + instância WhatsApp
3. Verifica tier da Meta API (rate limit)
4. BehaviorFilterService.getMatchingCustomers()

   ├── Aplica regras de segment + behavior
   └── Filtra: phone presente + phoneType=MOBILE + !whatsappOptOut


5. buildWhatsappJobs() por cliente:
   ├── Resolve vars do template (nome, loja, vendedor)
   ├── Gera redirectToken (se botão URL com smartRedirect)
   └── Gera unsubscribeToken (se botão isUnsubscribe)


6. whatsappQueue.addBulk(jobs)
   [jobs processados em paralelo pelo WhatsappSendProcessor]


Por cada job:
   ├── Verifica rate limit por cliente
   ├── Verifica conflito de campanha
   ├── metaGraph.sendMessage() → WAM ID
   ├── Cria WhatsappMessage(SENT)
   ├── CampaignMetric.sent++
   ├── syncToChatwoot() → nota de atividade [async]
   └── accumulateCost() [async]


7. checkCampaignCompletion():
   ├── Se jobs pendentes: aguarda
   └── Se zero jobs: campaign.status → 'sent' | 'failed'

Webhook: verificação de assinatura Meta

typescript
// WhatsappWebhookController.handleEvent()
private validateSignature(rawBody: Buffer, signature: string, appSecret: string): void {
  const expected = createHmac('sha256', appSecret)
    .update(rawBody)
    .digest('hex')
  
  const received = signature.replace('sha256=', '')
  
  // timingSafeEqual previne timing attacks:
  if (!timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
    throw new ForbiddenException('Assinatura inválida')
  }
}

Deduplicação de mensagens (Redis):

SET webhook:dedup:{wamId} 1 EX 300 NX

Se o SET retornar null, a mensagem já foi processada → skip.

Documentação interna — Galdix CRM