Tema
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.tsWhatsappService
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 → ChatwootMetaGraphService
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 NXSe o SET retornar null, a mensagem já foi processada → skip.