Compare commits

..

1 Commits

23 changed files with 2522 additions and 1579 deletions

View File

@ -12,7 +12,6 @@ DB_NAME=omnichannel
# HTTP/JWT
FRONTEND_URL=http://localhost:3000
BASE_URL=http://localhost:3001
JWT_SECRET=change-this-long-random-secret
JWT_EXPIRES_IN=8h

View File

@ -1,70 +1,24 @@
name: Deploy Dev — Backend
name: Deploy Dev
on:
push:
branches:
- dev
workflow_dispatch:
jobs:
deploy:
name: Deploy para VM Dev
runs-on: omnichannel-uat
env:
DEPLOY_PATH: /opt/omnichannel
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Preflight
shell: bash
- name: Atualizar código na VM Dev
run: |
set -euo pipefail
echo "Runner: $(hostname)"
docker --version
docker compose version
cd /opt/omnichannel/backend
git pull origin dev
- name: Verificar secrets obrigatórios
shell: bash
env:
ENV_DEV: ${{ secrets.ENV_DEV }}
- name: Copiar env
run: |
set -euo pipefail
if [ -z "${ENV_DEV:-}" ]; then
echo "Secret ENV_DEV não configurado. Adicione o conteúdo completo do .env.development em Settings > Actions > Secrets."
exit 1
fi
cp /home/desenvolvimento/.envs/omnichannel/backend.env.development /opt/omnichannel/backend/.env.development
- name: Escrever .env.development
shell: bash
env:
ENV_DEV: ${{ secrets.ENV_DEV }}
- name: Rebuild container
run: |
set -euo pipefail
printf '%s\n' "$ENV_DEV" > "$DEPLOY_PATH/.env.development"
chmod 600 "$DEPLOY_PATH/.env.development"
- name: Sync código
shell: bash
run: |
set -euo pipefail
LOCK="/tmp/omnichannel-backend-dev.lock"
(
flock -n 9 || { echo "Deploy já em andamento. Tente novamente em instantes."; exit 1; }
rsync -az --delete \
--exclude='.git/' \
--exclude='.gitea/' \
--exclude='.env*' \
--exclude='node_modules/' \
./ "$DEPLOY_PATH/backend/"
) 9>"$LOCK"
- name: Build e reiniciar container
shell: bash
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
docker compose build backend
docker compose up -d backend
cd /opt/omnichannel
docker compose up -d --build backend

View File

@ -1,70 +0,0 @@
name: Deploy Prod — Backend
on:
push:
branches:
- master
workflow_dispatch:
jobs:
deploy:
name: Deploy para VM Prod
runs-on: self-hosted-prod
env:
DEPLOY_PATH: /opt/omnichannel
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Preflight
shell: bash
run: |
set -euo pipefail
echo "Runner: $(hostname)"
docker --version
docker compose version
- name: Verificar secrets obrigatórios
shell: bash
env:
ENV_PROD: ${{ secrets.ENV_PROD }}
run: |
set -euo pipefail
if [ -z "${ENV_PROD:-}" ]; then
echo "Secret ENV_PROD não configurado."
exit 1
fi
- name: Escrever .env.production
shell: bash
env:
ENV_PROD: ${{ secrets.ENV_PROD }}
run: |
set -euo pipefail
printf '%s\n' "$ENV_PROD" > "$DEPLOY_PATH/backend/.env.production"
chmod 600 "$DEPLOY_PATH/backend/.env.production"
- name: Sync código
shell: bash
run: |
set -euo pipefail
LOCK="/tmp/omnichannel-backend-prod.lock"
(
flock -n 9 || { echo "Deploy já em andamento. Tente novamente em instantes."; exit 1; }
rsync -az --delete \
--exclude='.git/' \
--exclude='.gitea/' \
--exclude='.env*' \
--exclude='node_modules/' \
./ "$DEPLOY_PATH/backend/"
) 9>"$LOCK"
- name: Build e reiniciar container
shell: bash
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
docker compose build backend
docker compose up -d backend

View File

@ -4,4 +4,4 @@ COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3001
CMD ["npm", "run", "dev:docker"]
CMD ["npm", "run", "dev"]

1802
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,15 +7,13 @@
"start": "cross-env NODE_ENV=production node dist/main.js",
"predev": "node scripts/ensure-port-free.js",
"dev": "cross-env NODE_ENV=development nest start --watch",
"start:dev": "npm run dev",
"dev:docker": "cross-env NODE_ENV=development nest start --watch"
"start:dev": "npm run dev"
},
"dependencies": {
"@nestjs/common": "^11.1.19",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.21",
"@nestjs/serve-static": "^5.0.5",
"@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.21",
@ -30,6 +28,7 @@
"rxjs": "^7.8.2",
"socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.1",
"whatsapp-web.js": "^1.34.7",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
},

View File

@ -1,8 +1,6 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ServeStaticModule } from '@nestjs/serve-static';
import * as path from 'path';
import { AppController } from './app.controller';
import { DatabaseModule } from './infra/database/database.module';
import { AdminModule } from './modules/admin/admin.module';
@ -21,10 +19,6 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
limit: Number(process.env.RATE_LIMIT_MAX || 300),
},
]),
ServeStaticModule.forRoot({
rootPath: path.resolve(process.cwd(), 'uploads'),
serveRoot: '/uploads',
}),
DatabaseModule,
AuthModule,
AdminModule,

View File

@ -48,7 +48,7 @@ async function bootstrap() {
const swaggerConfig = new DocumentBuilder()
.setTitle('Omnichannel Backend API')
.setDescription('Documentacao das rotas da API do Omnichannel Sothis.')
.setDescription('Documentacao das rotas da API do Sothis Connect.')
.setVersion('1.0.0')
.addBearerAuth()
.build();

View File

@ -124,9 +124,11 @@ export class AttendanceAssignmentService {
private readonly agentPresenceService: AgentPresenceService,
) {}
async assignChat(chatId: string, userId: string | number, areaId?: string | number | null) {
async assignChat(chatId: string, userId: string | number, areaId?: string | number | null, skipPresenceCheck = false) {
this.logger.log(`Atribuindo chat ${chatId} ao usuario ${userId}`);
await this.assertUserCanReceiveAssignment(Number(userId));
if (!skipPresenceCheck) {
await this.assertUserCanReceiveAssignment(Number(userId));
}
const result = await this.attendanceAssignmentRepository.assignChat(chatId, Number(userId), areaId ? Number(areaId) : null);
return this.enrichAssignment(result.rows[0]);
}
@ -996,7 +998,7 @@ export class AttendanceAssignmentService {
if (!lastBotSentAt) return false;
const normalized = this.normalize(message).replace(/[!?.,\s]+/g, ' ').trim();
const greetingOnlyMessages = ['oi', 'ola', 'olá', 'bom dia', 'boa tarde', 'boa noite'];
const greetingOnlyMessages = ['oi', 'ola', 'ol<EFBFBD>', 'bom dia', 'boa tarde', 'boa noite'];
if (!greetingOnlyMessages.includes(normalized)) return false;
const lastBotTime = new Date(lastBotSentAt).getTime();

View File

@ -351,8 +351,6 @@ export class AttendanceAssignmentRepository {
routing_attempts = EXCLUDED.routing_attempts,
last_routed_message_id = EXCLUDED.last_routed_message_id,
last_bot_sent_at = CURRENT_TIMESTAMP,
conversation_started_at = CURRENT_TIMESTAMP,
expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours',
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`,
@ -392,8 +390,6 @@ export class AttendanceAssignmentRepository {
triage_audience_id = EXCLUDED.triage_audience_id,
triage_intent_id = EXCLUDED.triage_intent_id,
triage_step = EXCLUDED.triage_step,
conversation_started_at = CURRENT_TIMESTAMP,
expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours',
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`,
@ -427,8 +423,6 @@ export class AttendanceAssignmentRepository {
triage_step = NULL,
triage_builder_version_id = EXCLUDED.triage_builder_version_id,
triage_builder_node_id = EXCLUDED.triage_builder_node_id,
conversation_started_at = CURRENT_TIMESTAMP,
expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours',
updated_at = CURRENT_TIMESTAMP
RETURNING *;
`,

View File

@ -1,6 +1,5 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsInt,
IsNotEmpty,
IsObject,
@ -78,8 +77,9 @@ export class AssignChatDto {
userId: string;
@IsOptional()
@IsString()
areaId?: string;
@Type(() => Number)
@IsInt()
areaId?: number | null;
}
export class TransferChatDto {
@ -125,36 +125,15 @@ export class CreateWhatsappTemplateDto {
@IsInt()
areaId?: number | null;
@IsOptional()
@IsString()
@MaxLength(40)
requestedByRole?: string;
@IsOptional()
@IsString()
@MaxLength(40)
category?: string;
@IsOptional()
@IsString()
@MaxLength(10)
language?: string;
@IsOptional()
@IsString()
@MaxLength(20)
headerType?: string | null;
@IsOptional()
@IsString()
headerText?: string | null;
@IsOptional()
@IsString()
footerText?: string | null;
@IsOptional()
@IsArray()
buttons?: any[] | null;
@IsOptional()
@IsArray()
bodyVariables?: any[] | null;
}
export class UpdateWhatsappTemplateDto {
@ -176,72 +155,4 @@ export class UpdateWhatsappTemplateDto {
@IsString()
@MaxLength(40)
category?: string;
@IsOptional()
@IsString()
@MaxLength(10)
language?: string;
@IsOptional()
@IsString()
@MaxLength(20)
headerType?: string | null;
@IsOptional()
@IsString()
headerText?: string | null;
@IsOptional()
@IsString()
footerText?: string | null;
@IsOptional()
@IsArray()
buttons?: any[] | null;
@IsOptional()
@IsArray()
bodyVariables?: any[] | null;
}
export class SaveWhatsappConfigDto {
@IsOptional()
@IsString()
@IsNotEmpty()
access_token?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
phone_number_id?: string;
@IsOptional()
@IsString()
@MaxLength(20)
api_version?: string;
@IsOptional()
@IsString()
@MaxLength(255)
webhook_token?: string;
@IsOptional()
@IsString()
@MaxLength(100)
waba_id?: string;
}
export class ValidateWhatsappConfigDto {
@IsString()
@IsNotEmpty()
access_token: string;
@IsString()
@IsNotEmpty()
phone_number_id: string;
@IsOptional()
@IsString()
@MaxLength(20)
api_version?: string;
}

View File

@ -1,48 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import { randomUUID } from 'crypto';
const UPLOAD_DIR = path.resolve(process.cwd(), 'uploads', 'media');
const MIME_TO_EXT: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'audio/ogg': '.ogg',
'audio/mpeg': '.mp3',
'audio/mp4': '.m4a',
'audio/amr': '.amr',
'video/mp4': '.mp4',
'video/3gpp': '.3gp',
'application/pdf': '.pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
};
@Injectable()
export class LocalDiskAdapter {
private readonly logger = new Logger(LocalDiskAdapter.name);
constructor() {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
async upload(buffer: Buffer, mimeType: string): Promise<string> {
const ext = MIME_TO_EXT[mimeType] ?? '.bin';
const filename = `${randomUUID()}${ext}`;
const filepath = path.join(UPLOAD_DIR, filename);
fs.writeFileSync(filepath, buffer);
this.logger.log(`Mídia salva: ${filepath}`);
return `/uploads/media/${filename}`;
}
delete(relativePath: string): void {
try {
fs.unlinkSync(path.join(process.cwd(), relativePath));
} catch {
this.logger.warn(`Falha ao deletar mídia: ${relativePath}`);
}
}
}

View File

@ -1,20 +0,0 @@
import { Injectable } from '@nestjs/common';
import { LocalDiskAdapter } from './adapters/local-disk.adapter';
@Injectable()
export class MediaStorageService {
constructor(private readonly disk: LocalDiskAdapter) {}
async upload(buffer: Buffer, mimeType: string): Promise<string> {
return this.disk.upload(buffer, mimeType);
}
getUrl(relativePath: string): string {
const base = (process.env.BASE_URL ?? 'http://localhost:3001').replace(/\/$/, '');
return `${base}${relativePath}`;
}
delete(relativePath: string): void {
this.disk.delete(relativePath);
}
}

View File

@ -1,61 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../../infra/database/database.service';
@Injectable()
export class MensagensRepository {
constructor(private readonly database: DatabaseService) {}
async save(msg: {
wamid: string;
chatId: string;
fromMe: boolean;
type: string;
body?: string;
senderName?: string;
status?: string;
timestamp: number;
mediaPath?: string;
mediaMimeType?: string;
mediaFilename?: string;
}) {
await this.database.query(
`INSERT INTO mensagens (wamid, chat_id, from_me, type, body, sender_name, status, timestamp, media_path, media_mime_type, media_filename)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (wamid) DO NOTHING`,
[msg.wamid, msg.chatId, msg.fromMe, msg.type, msg.body ?? null, msg.senderName ?? null, msg.status ?? null, msg.timestamp, msg.mediaPath ?? null, msg.mediaMimeType ?? null, msg.mediaFilename ?? null],
);
}
async updateStatus(wamid: string, status: string) {
await this.database.query(
`UPDATE mensagens SET status = $1 WHERE wamid = $2`,
[status, wamid],
);
}
async findByChatId(chatId: string) {
const result = await this.database.query(
`SELECT * FROM mensagens WHERE chat_id = $1 ORDER BY timestamp ASC`,
[chatId],
);
return result.rows;
}
async findChats() {
const result = await this.database.query(`
SELECT *
FROM (
SELECT DISTINCT ON (chat_id)
chat_id,
body AS preview,
from_me AS last_message_from_me,
sender_name,
timestamp
FROM mensagens
ORDER BY chat_id, timestamp DESC
) latest
ORDER BY timestamp DESC
`);
return result.rows;
}
}

View File

@ -1,44 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../../infra/database/database.service';
@Injectable()
export class WhatsappConfigRepository {
constructor(private readonly database: DatabaseService) {}
async find() {
const result = await this.database.query(
`SELECT * FROM whatsapp_config LIMIT 1`,
);
return result.rows[0] || null;
}
async update(fields: {
access_token?: string | null;
phone_number_id?: string | null;
api_version?: string | null;
webhook_token?: string | null;
waba_id?: string | null;
updated_by?: number | null;
}) {
const result = await this.database.query(
`UPDATE whatsapp_config SET
access_token = CASE WHEN $1::TEXT IS NOT NULL THEN $1 ELSE access_token END,
phone_number_id = CASE WHEN $2::TEXT IS NOT NULL THEN $2 ELSE phone_number_id END,
api_version = CASE WHEN $3::TEXT IS NOT NULL THEN $3 ELSE api_version END,
webhook_token = CASE WHEN $4::TEXT IS NOT NULL THEN $4 ELSE webhook_token END,
waba_id = CASE WHEN $6::TEXT IS NOT NULL THEN $6 ELSE waba_id END,
updated_at = NOW(),
updated_by = $5
RETURNING *`,
[
fields.access_token ?? null,
fields.phone_number_id ?? null,
fields.api_version ?? null,
fields.webhook_token ?? null,
fields.updated_by ?? null,
fields.waba_id ?? null,
],
);
return result.rows[0] || null;
}
}

View File

@ -25,199 +25,108 @@ export class WhatsappTemplateRepository {
content: string;
category: string;
areaId?: number | null;
language: string;
metaTemplateName: string;
headerType?: string | null;
headerText?: string | null;
footerText?: string | null;
buttonsJson: string | null;
bodyVariablesJson: string | null;
status: string;
requestedByRole: string;
adminApprovedAtSql: 'NULL' | 'CURRENT_TIMESTAMP';
metaSubmittedAtSql: 'NULL' | 'CURRENT_TIMESTAMP';
}) {
return this.database.query(
`
INSERT INTO whatsapp_templates (
name, content, category, area_id, language,
meta_template_name, header_type, header_text, footer_text,
buttons, body_variables, status, updated_at
name,
content,
category,
area_id,
status,
requested_by_role,
admin_approved_at,
meta_submitted_at,
meta_approved_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, 'draft', CURRENT_TIMESTAMP)
VALUES ($1, $2, $3, $4, $5, $6, ${input.adminApprovedAtSql}, ${input.metaSubmittedAtSql}, NULL, CURRENT_TIMESTAMP)
ON CONFLICT (name) DO UPDATE SET
content = EXCLUDED.content,
category = EXCLUDED.category,
area_id = EXCLUDED.area_id,
language = EXCLUDED.language,
meta_template_name = EXCLUDED.meta_template_name,
header_type = EXCLUDED.header_type,
header_text = EXCLUDED.header_text,
footer_text = EXCLUDED.footer_text,
buttons = EXCLUDED.buttons,
body_variables = EXCLUDED.body_variables,
status = 'draft',
meta_status = NULL,
meta_template_id = NULL,
meta_submitted_at = NULL,
meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP
content = EXCLUDED.content,
category = EXCLUDED.category,
area_id = EXCLUDED.area_id,
status = EXCLUDED.status,
requested_by_role = EXCLUDED.requested_by_role,
admin_approved_at = EXCLUDED.admin_approved_at,
meta_submitted_at = EXCLUDED.meta_submitted_at,
meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP
RETURNING *
`,
[
input.name,
input.content,
input.category,
input.areaId || null,
input.language,
input.metaTemplateName,
input.headerType || null,
input.headerText || null,
input.footerText || null,
input.buttonsJson,
input.bodyVariablesJson,
],
[input.name, input.content, input.category, input.areaId || null, input.status, input.requestedByRole],
);
}
updateTemplate(
id: number,
input: {
name: string;
content: string;
category: string;
areaId?: number | null;
language: string;
metaTemplateName: string;
headerType?: string | null;
headerText?: string | null;
footerText?: string | null;
buttonsJson: string | null;
bodyVariablesJson: string | null;
},
) {
updateTemplate(id: number, input: { name: string; content: string; category: string; areaId?: number | null }) {
return this.database.query(
`
UPDATE whatsapp_templates SET
name = $1,
content = $2,
category = $3,
area_id = $4,
language = $5,
meta_template_name = $6,
header_type = $7,
header_text = $8,
footer_text = $9,
buttons = $10::jsonb,
body_variables = $11::jsonb,
status = 'draft',
meta_status = NULL,
meta_template_id = NULL,
meta_submitted_at = NULL,
meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $12
UPDATE whatsapp_templates
SET
name = $1,
content = $2,
category = $3,
area_id = $4,
status = 'meta_review',
admin_approved_at = CURRENT_TIMESTAMP,
meta_submitted_at = CURRENT_TIMESTAMP,
meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $5
RETURNING *
`,
[
input.name,
input.content,
input.category,
input.areaId || null,
input.language,
input.metaTemplateName,
input.headerType || null,
input.headerText || null,
input.footerText || null,
input.buttonsJson,
input.bodyVariablesJson,
id,
],
[input.name, input.content, input.category, input.areaId || null, id],
);
}
updateMetaInfo(
id: number,
info: { metaTemplateId?: string; metaTemplateName?: string; metaStatus: string },
) {
approveTemplateByAdmin(id: number) {
return this.database.query(
`UPDATE whatsapp_templates SET
meta_template_id = COALESCE($2, meta_template_id),
meta_template_name = COALESCE($3, meta_template_name),
meta_status = $4,
meta_submitted_at = CURRENT_TIMESTAMP,
status = 'pending',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *`,
[id, info.metaTemplateId || null, info.metaTemplateName || null, info.metaStatus],
`
UPDATE whatsapp_templates
SET
status = 'meta_review',
admin_approved_at = CURRENT_TIMESTAMP,
meta_submitted_at = CURRENT_TIMESTAMP,
meta_approved_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *
`,
[id],
);
}
async updateMetaStatusByName(metaName: string, metaStatus: string, metaId: string): Promise<number> {
const result = await this.database.query(
`UPDATE whatsapp_templates SET
meta_status = $2,
meta_template_id = COALESCE($3, meta_template_id),
meta_approved_at = CASE WHEN $2::text = 'APPROVED' THEN CURRENT_TIMESTAMP ELSE meta_approved_at END,
updated_at = CURRENT_TIMESTAMP
WHERE meta_template_name = $1`,
[metaName, metaStatus, metaId || null],
rejectTemplateByAdmin(id: number) {
return this.database.query(
`
UPDATE whatsapp_templates
SET
status = 'rejected',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *
`,
[id],
);
return result.rowCount || 0;
}
async importFromMeta(input: {
metaTemplateId: string;
metaTemplateName: string;
metaStatus: string;
language: string;
category: string;
content: string;
headerType?: string | null;
headerText?: string | null;
footerText?: string | null;
buttonsJson: string | null;
}): Promise<boolean> {
try {
const result = await this.database.query(
`INSERT INTO whatsapp_templates (
name, content, category, language,
meta_template_name, meta_template_id, meta_status,
header_type, header_text, footer_text, buttons, body_variables,
status, meta_approved_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, NULL,
CASE WHEN $7::text = 'APPROVED' THEN 'approved' ELSE 'pending' END,
CASE WHEN $7::text = 'APPROVED' THEN CURRENT_TIMESTAMP ELSE NULL END,
CURRENT_TIMESTAMP
)
ON CONFLICT (name) DO UPDATE SET
meta_template_name = EXCLUDED.meta_template_name,
meta_template_id = EXCLUDED.meta_template_id,
meta_status = EXCLUDED.meta_status,
content = CASE WHEN whatsapp_templates.meta_template_name IS NULL THEN EXCLUDED.content ELSE whatsapp_templates.content END,
status = CASE WHEN EXCLUDED.meta_status = 'APPROVED' THEN 'approved' ELSE whatsapp_templates.status END,
meta_approved_at = CASE WHEN EXCLUDED.meta_status = 'APPROVED' THEN CURRENT_TIMESTAMP ELSE whatsapp_templates.meta_approved_at END,
updated_at = CURRENT_TIMESTAMP
WHERE whatsapp_templates.meta_template_name IS NULL`,
[
input.metaTemplateName,
input.content,
input.category,
input.language,
input.metaTemplateName,
input.metaTemplateId,
input.metaStatus,
input.headerType || null,
input.headerText || null,
input.footerText || null,
input.buttonsJson,
],
);
return (result.rowCount || 0) > 0;
} catch (err: any) {
throw new Error(`importFromMeta "${input.metaTemplateName}": ${err.message}`);
}
}
deleteTemplate(id: number) {
return this.database.query('DELETE FROM whatsapp_templates WHERE id = $1 RETURNING meta_template_name', [id]);
return this.database.query('DELETE FROM whatsapp_templates WHERE id = $1', [id]);
}
refreshMetaApprovals() {
return this.database.query(`
UPDATE whatsapp_templates
SET
status = 'approved',
meta_approved_at = COALESCE(meta_approved_at, meta_submitted_at + INTERVAL '15 minutes'),
updated_at = CURRENT_TIMESTAMP
WHERE status = 'meta_review'
AND meta_submitted_at IS NOT NULL
AND meta_submitted_at <= CURRENT_TIMESTAMP - INTERVAL '15 minutes'
`);
}
}

View File

@ -1,37 +0,0 @@
import { Body, Controller, Get, Post, Put, Request } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Roles } from '../auth/decorators/roles.decorator';
import { WhatsappConfigService } from './whatsapp-config.service';
import { SaveWhatsappConfigDto, ValidateWhatsappConfigDto } from './dto/whatsapp.dto';
@ApiTags('WhatsApp Admin')
@Roles('Admin')
@Controller('admin/whatsapp-config')
export class WhatsappAdminController {
constructor(private readonly configService: WhatsappConfigService) {}
@ApiOperation({ summary: 'Retorna configuração atual do WhatsApp (sem expor o token completo)' })
@Get()
async getConfig() {
return this.configService.getPublicConfig();
}
@ApiOperation({ summary: 'Salva configuração do WhatsApp no banco de dados' })
@ApiBody({ type: SaveWhatsappConfigDto })
@Put()
async saveConfig(@Body() body: SaveWhatsappConfigDto, @Request() req: any) {
const userId = req.user?.sub ? Number(req.user.sub) : undefined;
return this.configService.saveConfig(body, userId);
}
@ApiOperation({ summary: 'Valida token e phone number ID junto à Meta API' })
@ApiBody({ type: ValidateWhatsappConfigDto })
@Post('validate')
async validateConfig(@Body() body: ValidateWhatsappConfigDto) {
return this.configService.validateCredentials(
body.access_token,
body.phone_number_id,
body.api_version,
);
}
}

View File

@ -1,91 +0,0 @@
import { Injectable } from '@nestjs/common';
import { WhatsappConfigRepository } from './repositories/whatsapp-config.repository';
interface WhatsappConfig {
access_token: string | null;
phone_number_id: string | null;
api_version: string;
webhook_token: string | null;
waba_id: string | null;
}
@Injectable()
export class WhatsappConfigService {
private _cache: { config: WhatsappConfig; expiresAt: number } | null = null;
private readonly CACHE_TTL_MS = 60_000;
constructor(private readonly repo: WhatsappConfigRepository) {}
async getConfig(): Promise<WhatsappConfig> {
if (this._cache && Date.now() < this._cache.expiresAt) {
return this._cache.config;
}
const row = await this.repo.find();
const config: WhatsappConfig = {
access_token: row?.access_token || process.env.META_ACCESS_TOKEN || null,
phone_number_id: row?.phone_number_id || process.env.META_PHONE_NUMBER_ID || null,
api_version: row?.api_version || process.env.META_API_VERSION || 'v25.0',
webhook_token: row?.webhook_token || process.env.META_WEBHOOK_TOKEN || null,
waba_id: row?.waba_id || process.env.META_WABA_ID || null,
};
this._cache = { config, expiresAt: Date.now() + this.CACHE_TTL_MS };
return config;
}
async getPublicConfig() {
const row = await this.repo.find();
const effectiveToken = row?.access_token || process.env.META_ACCESS_TOKEN;
const effectivePhoneId = row?.phone_number_id || process.env.META_PHONE_NUMBER_ID;
return {
phone_number_id: effectivePhoneId || null,
api_version: row?.api_version || process.env.META_API_VERSION || 'v25.0',
webhook_token: row?.webhook_token || process.env.META_WEBHOOK_TOKEN || null,
waba_id: row?.waba_id || process.env.META_WABA_ID || null,
is_configured: !!(effectiveToken && effectivePhoneId),
has_token: !!effectiveToken,
token_source: row?.access_token ? 'database' : effectiveToken ? 'env' : null,
updated_at: row?.updated_at || null,
};
}
async saveConfig(
dto: { access_token?: string; phone_number_id?: string; api_version?: string; webhook_token?: string; waba_id?: string },
userId?: number,
) {
this._cache = null;
await this.repo.update({
access_token: dto.access_token || null,
phone_number_id: dto.phone_number_id || null,
api_version: dto.api_version || null,
webhook_token: dto.webhook_token || null,
waba_id: dto.waba_id || null,
updated_by: userId,
});
return this.getPublicConfig();
}
async validateCredentials(token: string, phoneNumberId: string, apiVersion = 'v25.0') {
const url = `https://graph.facebook.com/${apiVersion}/${phoneNumberId}?access_token=${token}&fields=display_phone_number,verified_name,quality_rating`;
try {
const response = await fetch(url);
const data = (await response.json()) as any;
if (!response.ok) {
return {
valid: false,
error: data?.error?.message || 'Credenciais inválidas',
code: data?.error?.code,
};
}
return {
valid: true,
phone_number: data.display_phone_number,
verified_name: data.verified_name,
quality_rating: data.quality_rating,
};
} catch (err: any) {
const msg = err?.message || String(err);
console.error('[WhatsappConfig] validateCredentials falhou:', msg);
return { valid: false, error: `Erro de conexão com a Meta API: ${msg}` };
}
}
}

View File

@ -1,303 +1,65 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository';
import { WhatsappConfigService } from './whatsapp-config.service';
import { CreateWhatsappTemplateDto, UpdateWhatsappTemplateDto } from './dto/whatsapp.dto';
@Injectable()
export class WhatsappTemplateService {
private readonly logger = new Logger(WhatsappTemplateService.name);
constructor(
private readonly repo: WhatsappTemplateRepository,
private readonly configService: WhatsappConfigService,
) {}
constructor(private readonly whatsappTemplateRepository: WhatsappTemplateRepository) {}
async getTemplates() {
const result = await this.repo.listTemplates();
await this.whatsappTemplateRepository.refreshMetaApprovals();
const result = await this.whatsappTemplateRepository.listTemplates();
return result.rows;
}
async getTemplateById(id: number) {
const result = await this.repo.getTemplateById(id);
await this.whatsappTemplateRepository.refreshMetaApprovals();
const result = await this.whatsappTemplateRepository.getTemplateById(id);
return result.rows[0] || null;
}
async saveTemplate(dto: CreateWhatsappTemplateDto) {
const metaName = this.toMetaName(dto.name);
const result = await this.repo.saveTemplate({
name: dto.name,
content: dto.content,
category: this.normalizeCategory(dto.category),
areaId: dto.areaId ?? null,
language: dto.language || 'pt_BR',
metaTemplateName: metaName,
headerType: dto.headerType || null,
headerText: dto.headerText || null,
footerText: dto.footerText || null,
buttonsJson: dto.buttons?.length ? JSON.stringify(dto.buttons) : null,
bodyVariablesJson: dto.bodyVariables?.length ? JSON.stringify(dto.bodyVariables) : null,
async saveTemplate(name: string, content: string, areaId?: number | null, requestedByRole = 'admin', category = 'UTILITY') {
const isSupervisor = requestedByRole === 'supervisor';
const result = await this.whatsappTemplateRepository.saveTemplate({
name,
content,
category: this.normalizeTemplateCategory(category),
areaId,
status: isSupervisor ? 'admin_review' : 'meta_review',
requestedByRole,
adminApprovedAtSql: isSupervisor ? 'NULL' : 'CURRENT_TIMESTAMP',
metaSubmittedAtSql: isSupervisor ? 'NULL' : 'CURRENT_TIMESTAMP',
});
return result.rows[0];
}
async updateTemplate(id: number, dto: UpdateWhatsappTemplateDto) {
const metaName = this.toMetaName(dto.name);
const result = await this.repo.updateTemplate(id, {
name: dto.name,
content: dto.content,
category: this.normalizeCategory(dto.category),
areaId: dto.areaId ?? null,
language: dto.language || 'pt_BR',
metaTemplateName: metaName,
headerType: dto.headerType || null,
headerText: dto.headerText || null,
footerText: dto.footerText || null,
buttonsJson: dto.buttons?.length ? JSON.stringify(dto.buttons) : null,
bodyVariablesJson: dto.bodyVariables?.length ? JSON.stringify(dto.bodyVariables) : null,
async updateTemplate(id: number, name: string, content: string, areaId?: number | null, category = 'UTILITY') {
const result = await this.whatsappTemplateRepository.updateTemplate(id, {
name,
content,
areaId,
category: this.normalizeTemplateCategory(category),
});
return result.rows[0];
}
async submitToMeta(templateId: number) {
const template = await this.getTemplateById(templateId);
if (!template) throw new NotFoundException('Template não encontrado');
const config = await this.configService.getConfig();
if (!config.access_token || !config.waba_id) {
throw new BadRequestException(
'Access Token e WABA ID são obrigatórios para enviar templates. Configure em Admin → Canais → WhatsApp → Configurar.',
);
}
const metaName = template.meta_template_name || this.toMetaName(template.name);
const components = this.buildComponents(template);
const payload = {
name: metaName,
language: template.language || 'pt_BR',
category: template.category || 'UTILITY',
components,
};
this.logger.log(`[Meta Templates] Submetendo "${metaName}" para WABA ${config.waba_id}`);
const response = await fetch(
`https://graph.facebook.com/${config.api_version}/${config.waba_id}/message_templates`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${config.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
},
);
const data = (await response.json()) as any;
if (!response.ok) {
this.logger.error('[Meta Templates] Erro ao submeter:', data?.error);
throw new BadRequestException(
data?.error?.message || `Erro ${response.status} ao submeter template à Meta`,
);
}
await this.repo.updateMetaInfo(templateId, {
metaTemplateId: String(data.id),
metaTemplateName: metaName,
metaStatus: data.status || 'PENDING',
});
return { success: true, metaId: data.id, metaName, status: data.status || 'PENDING' };
async approveTemplateByAdmin(id: number) {
const result = await this.whatsappTemplateRepository.approveTemplateByAdmin(id);
return result.rows[0];
}
async syncFromMeta() {
const config = await this.configService.getConfig();
if (!config.access_token || !config.waba_id) {
return { synced: 0, imported: 0, error: 'WhatsApp não configurado com WABA ID' };
}
try {
const response = await fetch(
`https://graph.facebook.com/${config.api_version}/${config.waba_id}/message_templates?fields=id,name,status,language,category,components&limit=250`,
{ headers: { Authorization: `Bearer ${config.access_token}` } },
);
const data = (await response.json()) as any;
if (!response.ok || !Array.isArray(data?.data)) {
this.logger.error('[Meta Templates] Erro ao buscar lista:', data?.error);
return { synced: 0, imported: 0, error: data?.error?.message || 'Erro ao sincronizar' };
}
this.logger.log(`[Meta Templates] Meta retornou ${data.data.length} template(s)`);
let synced = 0;
let imported = 0;
for (const t of data.data) {
const metaId = String(t.id);
this.logger.log(`[Meta Templates] Processando: "${t.name}" status="${t.status}"`);
const updated = await this.repo.updateMetaStatusByName(t.name, t.status, metaId);
if (updated > 0) {
synced++;
this.logger.log(`[Meta Templates] Status atualizado: "${t.name}" → ${t.status}`);
continue;
}
const parsed = this.parseComponents(t.components || []);
try {
const wasInserted = await this.repo.importFromMeta({
metaTemplateId: metaId,
metaTemplateName: t.name,
metaStatus: t.status,
language: t.language || 'pt_BR',
category: this.normalizeCategory(t.category),
content: parsed.content,
headerType: parsed.headerType,
headerText: parsed.headerText,
footerText: parsed.footerText,
buttonsJson: parsed.buttons?.length ? JSON.stringify(parsed.buttons) : null,
});
if (wasInserted) {
imported++;
this.logger.log(`[Meta Templates] Importado: "${t.name}" (${t.status})`);
} else {
this.logger.log(`[Meta Templates] Ignorado (já existe ou sem alteração): "${t.name}"`);
}
} catch (importErr: any) {
this.logger.warn(`[Meta Templates] Falha ao importar "${t.name}": ${importErr.message}`);
}
}
this.logger.log(`[Meta Templates] Sync concluído: ${synced} atualizado(s), ${imported} importado(s)`);
return { synced, imported };
} catch (err: any) {
this.logger.error('[Meta Templates] Falha na sincronização:', err?.message);
return { synced: 0, imported: 0, error: 'Erro de conexão com a Meta' };
}
}
async handleTemplateStatusUpdate(data: {
event?: string;
message_template_name?: string;
message_template_id?: number | string;
reason?: string;
}) {
const status = data.event;
const name = data.message_template_name;
const id = String(data.message_template_id || '');
if (!status || !name) return;
this.logger.log(`[Meta Templates] Status automático: "${name}" → ${status}`);
await this.repo.updateMetaStatusByName(name, status, id);
async rejectTemplateByAdmin(id: number) {
const result = await this.whatsappTemplateRepository.rejectTemplateByAdmin(id);
return result.rows[0];
}
async deleteTemplate(id: number) {
const result = await this.repo.deleteTemplate(id);
const metaName = result.rows[0]?.meta_template_name;
if (metaName) {
await this.deleteFromMeta(metaName).catch((err) =>
this.logger.warn(`[Meta Templates] Falha ao excluir "${metaName}" da Meta:`, err?.message),
);
}
await this.whatsappTemplateRepository.deleteTemplate(id);
return { success: true };
}
private async deleteFromMeta(metaTemplateName: string) {
const config = await this.configService.getConfig();
if (!config.access_token || !config.waba_id) return;
await fetch(
`https://graph.facebook.com/${config.api_version}/${config.waba_id}/message_templates?name=${encodeURIComponent(metaTemplateName)}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${config.access_token}` },
},
);
}
private buildComponents(template: any): any[] {
const components: any[] = [];
if (template.header_type && template.header_text) {
components.push({
type: 'HEADER',
format: template.header_type,
text: template.header_text,
});
}
const bodyComp: any = { type: 'BODY', text: template.content };
const vars = Array.isArray(template.body_variables) ? template.body_variables : [];
if (vars.length > 0) {
bodyComp.example = { body_text: [vars.map((v: any) => v.example || v.label || `valor${v.index}`)] };
}
components.push(bodyComp);
if (template.footer_text) {
components.push({ type: 'FOOTER', text: template.footer_text });
}
const btns = Array.isArray(template.buttons) ? template.buttons : [];
if (btns.length > 0) {
components.push({
type: 'BUTTONS',
buttons: btns.map((b: any) => {
if (b.type === 'QUICK_REPLY') return { type: 'QUICK_REPLY', text: b.text };
if (b.type === 'URL') return { type: 'URL', text: b.text, url: b.url };
if (b.type === 'PHONE_NUMBER') return { type: 'PHONE_NUMBER', text: b.text, phone_number: b.phone };
return b;
}),
});
}
return components;
}
private parseComponents(components: any[]): {
content: string;
headerType?: string | null;
headerText?: string | null;
footerText?: string | null;
buttons?: any[];
} {
const result: any = { content: '', headerType: null, headerText: null, footerText: null, buttons: [] };
for (const c of components || []) {
if (c.type === 'BODY') result.content = c.text || '';
else if (c.type === 'HEADER') {
result.headerType = c.format || null;
result.headerText = c.format === 'TEXT' ? (c.text || null) : null;
}
else if (c.type === 'FOOTER') result.footerText = c.text || null;
else if (c.type === 'BUTTONS') {
result.buttons = (c.buttons || []).map((b: any) => ({
type: b.type,
text: b.text || '',
url: b.url || '',
phone: b.phone_number || '',
}));
}
}
return result;
}
private toMetaName(displayName: string): string {
return String(displayName || '')
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.substring(0, 512) || 'template';
}
private normalizeCategory(category?: string): string {
private normalizeTemplateCategory(category?: string) {
const normalized = String(category || 'UTILITY').trim().toUpperCase();
return ['UTILITY', 'MARKETING', 'AUTHENTICATION'].includes(normalized) ? normalized : 'UTILITY';
}

View File

@ -1,23 +1,4 @@
import { Controller, Get, Post, Body, Delete, Param, ForbiddenException, Query, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
const ALLOWED_MEDIA_TYPES = new Set([
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/wav', 'audio/aac', 'audio/amr',
'video/mp4', 'video/3gpp',
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/msword',
'application/vnd.ms-excel',
]);
const MAX_SIZE_MB: Record<string, number> = {
image: 5,
audio: 16,
video: 16,
document: 100,
};
import { Controller, Get, Post, Body, Delete, Param, ForbiddenException, Query, RawBody } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { WhatsappService } from './whatsapp.service';
import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service';
@ -31,7 +12,6 @@ import {
UpdateWhatsappTemplateDto,
} from './dto/whatsapp.dto';
import { WhatsappTemplateService } from './whatsapp-template.service';
import { WhatsappConfigService } from './whatsapp-config.service';
import { Public } from '../auth/decorators/public.decorator';
@ -43,24 +23,32 @@ export class WhatsappController {
private readonly whatsappService: WhatsappService,
private readonly assignmentService: AttendanceAssignmentService,
private readonly whatsappTemplateService: WhatsappTemplateService,
private readonly configService: WhatsappConfigService,
) {}
@ApiOperation({ summary: 'Consulta status da conexao WhatsApp' })
@ApiOperation({
summary: 'Consulta status da conexao WhatsApp',
description: 'Retorna o estado atual da sessao WhatsApp usada pelo backend, como conectado, aguardando QR Code ou desconectado.',
})
@ApiResponse({ status: 200, description: 'Status atual da conexao retornado.' })
@Get('status')
getStatus() {
return { status: this.whatsappService.getStatus() };
}
@ApiOperation({ summary: 'Lista conversas do WhatsApp' })
@ApiOperation({
summary: 'Lista conversas do WhatsApp',
description: 'Retorna os chats conhecidos pelo backend para alimentar o painel de atendimento em tempo real.',
})
@ApiResponse({ status: 200, description: 'Lista de conversas retornada.' })
@Get('chats')
async getChats() {
return this.whatsappService.getChats();
}
@ApiOperation({ summary: 'Lista mensagens de uma conversa' })
@ApiOperation({
summary: 'Lista mensagens de uma conversa',
description: 'Busca o historico de mensagens de um chat especifico para abrir a conversa no painel do atendente.',
})
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
@ApiResponse({ status: 200, description: 'Mensagens do chat retornadas.' })
@Get('messages/:chatId')
@ -68,158 +56,244 @@ export class WhatsappController {
return this.whatsappService.getChatMessages(chatId);
}
@ApiOperation({ summary: 'Baixa midia de uma mensagem' })
@ApiOperation({
summary: 'Baixa midia de uma mensagem',
description: 'Retorna a midia associada a uma mensagem do WhatsApp quando a mensagem possui anexo.',
})
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
@ApiParam({ name: 'messageId', description: 'Identificador da mensagem com midia.' })
@ApiResponse({ status: 200, description: 'Midia da mensagem retornada.' })
@Get('media/:chatId/:messageId')
async getMessageMedia(@Param('chatId') chatId: string, @Param('messageId') messageId: string) {
return this.whatsappService.getMessageMedia(chatId, messageId);
}
@ApiOperation({ summary: 'Envia mensagem no WhatsApp' })
@ApiOperation({
summary: 'Envia mensagem no WhatsApp',
description: 'Envia uma mensagem ativa para um contato ou responde uma conversa existente, opcionalmente com midia em base64.',
})
@ApiBody({
schema: {
type: 'object',
required: ['to', 'message'],
properties: {
to: { type: 'string', example: '5511999999999@c.us' },
message: { type: 'string', example: 'Ola, como posso ajudar?' },
senderName: { type: 'string', example: 'Rafael Lopes' },
media: {
type: 'object',
nullable: true,
properties: {
data: { type: 'string', description: 'Arquivo em base64.' },
mimetype: { type: 'string', example: 'application/pdf' },
filename: { type: 'string', example: 'documento.pdf' },
},
},
},
},
})
@ApiResponse({ status: 201, description: 'Mensagem enviada.' })
@Post('send')
async sendMessage(@Body() body: SendWhatsappMessageDto) {
return this.whatsappService.sendMessage(body.to, body.message, body.media, body.senderName);
}
@ApiOperation({ summary: 'Envia mídia no WhatsApp' })
@ApiResponse({ status: 201, description: 'Mídia enviada.' })
@Post('send-media')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 100 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (ALLOWED_MEDIA_TYPES.has(file.mimetype)) {
cb(null, true);
} else {
cb(new BadRequestException(`Tipo de arquivo não suportado: ${file.mimetype}`), false);
}
@ApiOperation({
summary: 'Inicia atendimento ativo',
description: 'Abre uma conversa ativa usando um template aprovado e registra o atendimento para acompanhamento no painel.',
})
@ApiBody({
schema: {
type: 'object',
required: ['to', 'templateId', 'userId'],
properties: {
to: { type: 'string', example: '5511999999999' },
templateId: { type: 'number', example: 1 },
userId: { type: 'number', example: 10 },
areaId: { type: 'number', nullable: true, example: 2 },
variables: { type: 'object', additionalProperties: { type: 'string', nullable: true } },
},
},
}))
async sendMediaMessage(
@UploadedFile() file: any,
@Body('to') to: string,
@Body('senderName') senderName?: string,
@Body('caption') caption?: string,
) {
if (!file) throw new BadRequestException('Arquivo não enviado.');
if (!to) throw new BadRequestException('Destinatário (to) obrigatório.');
const mediaType = file.mimetype.startsWith('image/') ? 'image'
: file.mimetype.startsWith('audio/') ? 'audio'
: file.mimetype.startsWith('video/') ? 'video'
: 'document';
const limitMB = MAX_SIZE_MB[mediaType];
if (file.size > limitMB * 1024 * 1024) {
throw new BadRequestException(`Arquivo muito grande. Limite para ${mediaType}: ${limitMB} MB.`);
}
return this.whatsappService.sendMediaMessage(to, file.buffer, file.mimetype, senderName, caption, file.originalname);
}
@ApiOperation({ summary: 'Inicia atendimento ativo' })
})
@ApiResponse({ status: 201, description: 'Atendimento ativo iniciado.' })
@Post('start-attendance')
async startAttendance(@Body() body: StartAttendanceDto) {
return this.whatsappService.startAttendance(body.to, body.templateId, body.userId, body.areaId, body.variables);
}
@ApiOperation({ summary: 'Assume uma conversa' })
@ApiOperation({
summary: 'Assume uma conversa',
description: 'Vincula um chat a um atendente e, opcionalmente, a uma area de atendimento.',
})
@ApiBody({
schema: {
type: 'object',
required: ['chatId', 'userId'],
properties: {
chatId: { type: 'string', example: '5511999999999@c.us' },
userId: { type: 'string', example: '10' },
areaId: { type: 'string', example: '2' },
},
},
})
@Post('assign')
async assignChat(@Body() body: AssignChatDto) {
return this.assignmentService.assignChat(body.chatId, body.userId, body.areaId);
return this.assignmentService.assignChat(body.chatId, body.userId, body.areaId, true);
}
@ApiOperation({ summary: 'Transfere uma conversa' })
@ApiOperation({
summary: 'Transfere uma conversa',
description: 'Encaminha o chat para outra area ou responsavel e registra observacao para contextualizar o proximo atendimento.',
})
@ApiBody({
schema: {
type: 'object',
required: ['chatId', 'areaId'],
properties: {
chatId: { type: 'string', example: '5511999999999@c.us' },
areaId: { type: 'number', example: 2 },
userId: { type: 'number', nullable: true, example: 10 },
note: { type: 'string', nullable: true, example: 'Tony e um colaborador e quer saber sobre ferias.' },
},
},
})
@Post('transfer')
async transferChat(@Body() body: TransferChatDto) {
return this.assignmentService.transferChat(body);
}
@ApiOperation({ summary: 'Libera conversa assumida' })
@ApiOperation({
summary: 'Libera conversa assumida',
description: 'Remove o vinculo de atendimento do chat, permitindo que outro atendente assuma a conversa.',
})
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
@Delete('release/:chatId')
async releaseChat(@Param('chatId') chatId: string) {
return this.assignmentService.releaseChat(chatId);
}
@ApiOperation({ summary: 'Fecha atendimento' })
@ApiOperation({
summary: 'Fecha atendimento',
description: 'Finaliza o atendimento de um chat e remove o vinculo ativo com o atendente.',
})
@ApiBody({
schema: {
type: 'object',
required: ['chatId'],
properties: {
chatId: { type: 'string', example: '5511999999999@c.us' },
userId: { oneOf: [{ type: 'string' }, { type: 'number' }], nullable: true, example: 10 },
},
},
})
@Post('close')
async closeChat(@Body() body: CloseChatDto) {
return this.assignmentService.closeChat(body.chatId, body.userId);
}
@ApiOperation({ summary: 'Consulta atribuicao do chat' })
@ApiOperation({
summary: 'Consulta atribuicao do chat',
description: 'Retorna qual atendente ou area esta responsavel pela conversa no momento.',
})
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
@Get('assignment/:chatId')
async getAssignment(@Param('chatId') chatId: string) {
return this.assignmentService.getAssignment(chatId);
}
// ---- Templates HSM ----
@ApiOperation({ summary: 'Lista templates HSM' })
@ApiOperation({
summary: 'Lista templates de atendimento',
description: 'Retorna os templates cadastrados para abertura ativa e mensagens padronizadas.',
})
@Get('templates')
async getTemplates() {
return this.whatsappTemplateService.getTemplates();
}
@ApiOperation({ summary: 'Cria template HSM' })
@ApiOperation({
summary: 'Cria template de mensagem',
description: 'Cadastra um template para abertura ativa ou resposta padronizada. Pode depender de aprovacao conforme o perfil solicitante.',
})
@ApiBody({
schema: {
type: 'object',
required: ['name', 'content'],
properties: {
name: { type: 'string', example: 'Boas vindas' },
content: { type: 'string', example: 'Ola {{nome}}, tudo bem?' },
areaId: { type: 'number', nullable: true, example: 2 },
requestedByRole: { type: 'string', example: 'admin' },
category: { type: 'string', example: 'atendimento' },
},
},
})
@Post('templates')
async saveTemplate(@Body() body: CreateWhatsappTemplateDto) {
return this.whatsappTemplateService.saveTemplate(body);
return this.whatsappTemplateService.saveTemplate(body.name, body.content, body.areaId, body.requestedByRole, body.category);
}
@ApiOperation({ summary: 'Sincroniza status de templates com a Meta' })
@Post('templates/sync-meta')
async syncTemplatesFromMeta() {
return this.whatsappTemplateService.syncFromMeta();
}
@ApiOperation({ summary: 'Atualiza template HSM existente' })
@ApiOperation({
summary: 'Atualiza template de mensagem',
description: 'Altera nome, conteudo, area ou categoria de um template existente.',
})
@ApiParam({ name: 'id', description: 'ID do template.' })
@Post('templates/update/:id')
async updateTemplate(@Param('id') id: string, @Body() body: UpdateWhatsappTemplateDto) {
return this.whatsappTemplateService.updateTemplate(Number(id), body);
return this.whatsappTemplateService.updateTemplate(Number(id), body.name, body.content, body.areaId, body.category);
}
@ApiOperation({ summary: 'Envia template para aprovação da Meta' })
@ApiOperation({
summary: 'Aprova template pelo admin',
description: 'Marca um template como aprovado para uso nos fluxos de atendimento.',
})
@ApiParam({ name: 'id', description: 'ID do template.' })
@Post('templates/:id/submit-meta')
async submitTemplateToMeta(@Param('id') id: string) {
return this.whatsappTemplateService.submitToMeta(Number(id));
@Post('templates/approve-admin/:id')
async approveTemplateByAdmin(@Param('id') id: string) {
return this.whatsappTemplateService.approveTemplateByAdmin(Number(id));
}
@ApiOperation({ summary: 'Remove template' })
@ApiOperation({
summary: 'Rejeita template pelo admin',
description: 'Marca um template como rejeitado, impedindo seu uso nos fluxos de atendimento.',
})
@ApiParam({ name: 'id', description: 'ID do template.' })
@Post('templates/reject-admin/:id')
async rejectTemplateByAdmin(@Param('id') id: string) {
return this.whatsappTemplateService.rejectTemplateByAdmin(Number(id));
}
@ApiOperation({
summary: 'Remove template',
description: 'Exclui um template cadastrado.',
})
@ApiParam({ name: 'id', description: 'ID do template.' })
@Delete('templates/:id')
async deleteTemplate(@Param('id') id: string) {
return this.whatsappTemplateService.deleteTemplate(Number(id));
}
// ---- Webhook Meta ----
@Public()
@ApiOperation({ summary: 'Verificação do webhook Meta' })
@Get('webhook')
async verifyWebhook(
@Get('webhook')
verifyWebhook(
@Query('hub.mode') mode: string,
@Query('hub.verify_token') token: string,
@Query('hub.challenge') challenge: string,
) {
const config = await this.configService.getConfig();
const expected = config.webhook_token;
if (mode === 'subscribe' && expected && token === expected) {
return challenge;
}
throw new ForbiddenException('Token inválido');
}
@Public()
@ApiOperation({ summary: 'Recebe eventos do webhook Meta' })
@Post('webhook')
async receiveWebhook(@Body() body: any) {
await this.whatsappService.handleMetaWebhookEvent(body);
return { status: 'ok' };
if (mode === 'subscribe' && token === process.env.META_WEBHOOK_TOKEN) {
return challenge;
}
throw new ForbiddenException('Token inválido');
}
@Public()
@ApiOperation({ summary: 'Recebe eventos do webhook Meta' })
@Post('webhook')
async receiveWebhook(@Body() body: any) {
console.log('Webhook Meta:', JSON.stringify(body, null, 2));
return { status: 'ok' };
}
}

View File

@ -44,7 +44,6 @@ export class WhatsappGateway implements OnGatewayInit, OnGatewayConnection, OnGa
handleConnection(client: Socket) {
this.logger.log(`Client connected: ${client.id} (${client.data.user?.username || 'usuario desconhecido'})`);
client.emit('status', 'CONNECTED');
}
handleDisconnect(client: Socket) {

View File

@ -2,32 +2,16 @@ import { Module } from '@nestjs/common';
import { WhatsappService } from './whatsapp.service';
import { WhatsappGateway } from './whatsapp.gateway';
import { WhatsappController } from './whatsapp.controller';
import { WhatsappAdminController } from './whatsapp-admin.controller';
import { AttendanceModule } from '../attendance/attendance.module';
import { AuthModule } from '../auth/auth.module';
import { ContactsModule } from '../contacts/contacts.module';
import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository';
import { WhatsappTemplateService } from './whatsapp-template.service';
import { MensagensRepository } from './repositories/mensagens.repository';
import { WhatsappConfigRepository } from './repositories/whatsapp-config.repository';
import { WhatsappConfigService } from './whatsapp-config.service';
import { MediaStorageService } from './media/media-storage.service';
import { LocalDiskAdapter } from './media/adapters/local-disk.adapter';
@Module({
imports: [AttendanceModule, AuthModule, ContactsModule],
providers: [
WhatsappService,
WhatsappGateway,
WhatsappTemplateService,
WhatsappTemplateRepository,
MensagensRepository,
WhatsappConfigRepository,
WhatsappConfigService,
MediaStorageService,
LocalDiskAdapter,
],
controllers: [WhatsappController, WhatsappAdminController],
providers: [WhatsappService, WhatsappGateway, WhatsappTemplateService, WhatsappTemplateRepository],
controllers: [WhatsappController],
exports: [WhatsappService],
})
export class WhatsappModule {}

View File

@ -1,30 +1,149 @@
import { Injectable, Logger, NotImplementedException } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Client, LocalAuth, MessageMedia } from 'whatsapp-web.js';
import { WhatsappGateway } from './whatsapp.gateway';
import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service';
import { ContactsService } from '../contacts/contacts.service';
import { WhatsappTemplateService } from './whatsapp-template.service';
import { MensagensRepository } from './repositories/mensagens.repository';
import { WhatsappConfigService } from './whatsapp-config.service';
import { MediaStorageService } from './media/media-storage.service';
const MEDIA_TYPES = ['image', 'audio', 'video', 'document', 'sticker'] as const;
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class WhatsappService {
export class WhatsappService implements OnModuleInit {
private client: Client;
private readonly logger = new Logger(WhatsappService.name);
private status: 'DISCONNECTED' | 'AWAITING_QR' | 'CONNECTED' = 'DISCONNECTED';
private currentQr: string | null = null;
private readonly processedIncomingMessages = new Set<string>();
private readonly incomingRoutingQueues = new Map<string, Promise<void>>();
private readonly chatMessagesCache = new Map<string, any[]>();
constructor(
private readonly gateway: WhatsappGateway,
private readonly assignmentService: AttendanceAssignmentService,
private readonly contactsService: ContactsService,
private readonly whatsappTemplateService: WhatsappTemplateService,
private readonly mensagensRepository: MensagensRepository,
private readonly configService: WhatsappConfigService,
private readonly mediaStorageService: MediaStorageService,
) {}
async onModuleInit() {
this.logger.log('Inicializando WhatsApp Client...');
this.client = new Client({
authStrategy: new LocalAuth({ dataPath: './whatsapp-session' }),
puppeteer: {
headless: true,
executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--disable-gpu']
},
webVersionCache: {
type: 'none'
}
});
this.client.on('qr', (qr) => {
this.logger.log('QR Code recebido. Envie para o frontend.');
this.status = 'AWAITING_QR';
this.currentQr = qr;
this.gateway.emitQrCode(qr);
this.gateway.emitStatus(this.status);
});
this.client.on('ready', () => {
this.logger.log('WhatsApp Web Conectado!');
this.status = 'CONNECTED';
this.currentQr = null;
this.gateway.emitStatus(this.status);
});
this.client.on('authenticated', () => {
this.logger.log('WhatsApp Autenticado');
});
this.client.on('auth_failure', (msg) => {
this.logger.error('Falha na Autenticação do WhatsApp', msg);
this.status = 'DISCONNECTED';
this.gateway.emitStatus(this.status);
});
this.client.on('disconnected', (reason) => {
this.logger.warn('WhatsApp Desconectado', reason);
this.status = 'DISCONNECTED';
this.gateway.emitStatus(this.status);
});
// Mudar para message_create captura tanto mensagens recebidas quanto enviadas no WhatsApp
this.client.on('message_create', async (msg) => {
if (msg.from === 'status@broadcast') return;
const remoteJid = msg.id.remote || (msg.fromMe ? msg.to : msg.from);
const messageId = msg.id._serialized;
const messageBody = (msg.body || '').trim();
this.logger.log(`Mensagem registrada (fromMe: ${msg.fromMe}) remote: ${remoteJid} - ${messageBody}`);
let mediaData: any = null;
if (msg.hasMedia) {
try {
this.logger.log(`Baixando mídia em tempo real de ${msg.id._serialized}...`);
const media = await msg.downloadMedia();
if (media) {
mediaData = {
mimetype: media.mimetype,
data: media.data,
filename: media.filename || 'arquivo'
};
}
} catch (err) {
this.logger.error(`Erro ao baixar mídia em tempo real de ${msg.id._serialized}:`, err);
}
}
// Transmite a mensagem em tempo real para o frontend
this.gateway.emitNewMessage({
from: remoteJid,
body: msg.body,
timestamp: msg.timestamp,
isGroupMsg: remoteJid.endsWith('@g.us'),
id: messageId,
fromMe: msg.fromMe,
notifyName: msg['_data']?.notifyName || '',
hasMedia: msg.hasMedia,
media: mediaData
});
// Salva ou atualiza a conversa na persistência híbrida
await this.addOrUpdatePersistentChat(remoteJid, {
name: msg['_data']?.notifyName || remoteJid.split('@')[0],
preview: msg.hasMedia ? `[Mídia: ${mediaData?.filename || 'Arquivo'}]` : (msg.body || '[Mídia]'),
timestamp: msg.timestamp,
unreadCount: msg.fromMe ? 0 : 1,
lastMessageFromMe: msg.fromMe
});
if (!msg.fromMe) {
if (messageBody || msg.hasMedia) {
await this.assignmentService.markCustomerReplied(remoteJid);
}
if (!messageBody) {
this.logger.log(`Triagem ignorada para ${remoteJid}: mensagem sem texto.`);
return;
}
if (this.processedIncomingMessages.has(messageId)) {
return;
}
this.processedIncomingMessages.add(messageId);
if (this.processedIncomingMessages.size > 1000) {
const [oldest] = this.processedIncomingMessages;
this.processedIncomingMessages.delete(oldest);
}
await this.enqueueIncomingRoute(remoteJid, msg, messageBody, messageId);
}
});
this.client.initialize();
}
private async enqueueIncomingRoute(remoteJid: string, msg: any, messageBody: string, messageId: string) {
const previousRoute = this.incomingRoutingQueues.get(remoteJid) || Promise.resolve();
@ -57,156 +176,213 @@ export class WhatsappService {
await nextRoute;
}
private getPersistFilePath() {
return path.join(process.cwd(), 'whatsapp-chats-persist.json');
}
private async loadPersistentChats(): Promise<any> {
try {
const filepath = this.getPersistFilePath();
if (!fs.existsSync(filepath)) {
return {};
}
const data = fs.readFileSync(filepath, 'utf-8');
return JSON.parse(data);
} catch (err) {
this.logger.error('Erro ao ler chats persistentes:', err);
return {};
}
}
private async savePersistentChats(chats: any): Promise<void> {
try {
const filepath = this.getPersistFilePath();
fs.writeFileSync(filepath, JSON.stringify(chats, null, 2), 'utf-8');
} catch (err) {
this.logger.error('Erro ao salvar chats persistentes:', err);
}
}
private async addOrUpdatePersistentChat(chatId: string, data: { name?: string, preview?: string, timestamp?: number, unreadCount?: number, lastMessageFromMe?: boolean }) {
if (chatId === 'status@broadcast' || chatId.endsWith('@g.us')) return;
const chats = await this.loadPersistentChats();
const existing = chats[chatId] || {};
const isNumber = (val: string) => /^\d+$/.test(val);
let finalName = existing.name || data.name || chatId.split('@')[0];
if (data.name && !isNumber(data.name)) {
finalName = data.name;
}
if (isNumber(finalName) && this.status === 'CONNECTED') {
try {
const contact = await this.client.getContactById(chatId);
if (contact) {
if (contact.name && !isNumber(contact.name)) {
finalName = contact.name;
} else if (contact.pushname && !isNumber(contact.pushname)) {
finalName = contact.pushname;
}
}
} catch (err) {
// Ignorar erros na consulta do Puppeteer
}
}
chats[chatId] = {
id: {
server: chatId.split('@')[1] || 'c.us',
user: chatId.split('@')[0],
_serialized: chatId
},
name: finalName,
isGroup: false,
isReadOnly: false,
unreadCount: data.unreadCount !== undefined ? data.unreadCount : (existing.unreadCount || 0),
timestamp: data.timestamp || existing.timestamp || Math.floor(Date.now() / 1000),
archived: false,
pinned: false,
isLocked: false,
isMuted: false,
preview: data.preview || existing.preview || '',
lastMessageFromMe: data.lastMessageFromMe !== undefined ? data.lastMessageFromMe : Boolean(existing.lastMessageFromMe)
};
await this.savePersistentChats(chats);
}
getStatus() {
return 'CONNECTED';
return this.status;
}
getCurrentQr() {
return this.currentQr;
}
async getChats() {
const rows = await this.mensagensRepository.findChats();
return Promise.all(rows.map(async (row: any) => {
const chatId = row.chat_id;
const assignment = await this.assignmentService.getAssignment(chatId);
const contactProfile = await this.getCustomerContact(chatId);
if (this.status !== 'CONNECTED') return [];
let liveChats: any[] = [];
try {
liveChats = await this.retryWhatsappRead(() => this.client.getChats());
} catch (err) {
if (this.isTransientWhatsappFrameError(err)) {
this.logger.warn(`WhatsApp Web ainda estabilizando ao carregar chats: ${this.getErrorMessage(err)}`);
} else {
this.logger.error('Erro ao chamar client.getChats():', err);
}
}
const persistentChatsObj = await this.loadPersistentChats();
const mergedChatsMap = new Map<string, any>();
// Adiciona os chats persistentes locais primeiro
Object.values(persistentChatsObj).forEach((c: any) => {
mergedChatsMap.set(c.id._serialized, c);
});
// Adiciona/Mescla com os chats em tempo real do Puppeteer
liveChats.forEach((c: any) => {
if (!c.isGroup && c.id.server === 'c.us') {
const serializedId = c.id._serialized;
const existingPersistent = persistentChatsObj[serializedId] || {};
const isNumber = (val: string) => /^\d+$/.test(val);
let finalName = c.name || existingPersistent.name || c.id.user;
if (isNumber(c.name) && existingPersistent.name && !isNumber(existingPersistent.name)) {
finalName = existingPersistent.name;
}
mergedChatsMap.set(serializedId, {
id: c.id,
name: finalName,
isGroup: c.isGroup,
isReadOnly: c.isReadOnly || false,
unreadCount: c.unreadCount !== undefined ? c.unreadCount : (existingPersistent.unreadCount || 0),
timestamp: c.timestamp || existingPersistent.timestamp || Math.floor(Date.now() / 1000),
archived: c.archived || false,
pinned: c.pinned || false,
isLocked: c.isLocked || false,
isMuted: c.isMuted || false,
preview: c.lastMessage ? (c.lastMessage.body || '[Mídia]') : (existingPersistent.preview || ''),
lastMessageFromMe: c.lastMessage?.fromMe !== undefined ? Boolean(c.lastMessage.fromMe) : Boolean(existingPersistent.lastMessageFromMe)
});
}
});
const conversas = Array.from(mergedChatsMap.values());
// Ordenar chats pelo timestamp mais recente
conversas.sort((a, b) => b.timestamp - a.timestamp);
// Buscar todas as atribuições para enriquecer os chats
return Promise.all(conversas.map(async chat => {
const assignment = await this.assignmentService.getAssignment(chat.id._serialized);
const contactProfile = await this.getCustomerContact(chat.id._serialized);
const phone = contactProfile?.phone || await this.resolveContactPhone(chat.id._serialized);
return {
id: { _serialized: chatId, user: chatId, server: 'c.us' },
name: contactProfile?.name || row.sender_name || chatId,
preview: row.preview || '',
timestamp: Number(row.timestamp),
unreadCount: 0,
lastMessageFromMe: Boolean(row.last_message_from_me),
contactProfile: contactProfile ?? { chat_id: chatId, phone: chatId, name: null, company: null, note: null },
assignment: assignment || null,
...chat,
name: contactProfile?.name || chat.name,
contactProfile: contactProfile
? { ...contactProfile, phone }
: {
chat_id: chat.id._serialized,
phone,
name: null,
company: null,
note: null,
},
assignment: assignment || null
};
}));
}
async getChatMessages(chatId: string) {
const rows = await this.mensagensRepository.findByChatId(chatId);
return rows.map((row: any) => ({
id: { _serialized: row.wamid, user: row.wamid, server: 'c.us' },
body: row.body ?? '',
fromMe: Boolean(row.from_me),
timestamp: Number(row.timestamp),
hasMedia: Boolean(row.media_path || row.type !== 'text'),
media: null,
mediaUrl: row.media_path ? this.mediaStorageService.getUrl(row.media_path) : null,
mediaMimeType: row.media_mime_type ?? null,
chatId: row.chat_id,
status: row.status,
}));
private async resolveContactPhone(chatId: string) {
try {
const lidPhone = await this.resolveLidPhone(chatId);
if (lidPhone) return lidPhone;
const contact = await this.retryWhatsappRead(() => this.client.getContactById(chatId));
const phone =
(contact as any)?.number ||
(contact as any)?.phoneNumber ||
(contact as any)?.id?.user ||
'';
if (phone && !String(phone).includes('@') && !chatId.includes(`${phone}@lid`)) {
return String(phone);
}
const formattedNumber = await contact.getFormattedNumber().catch(() => '');
return formattedNumber || (chatId.endsWith('@lid') ? '' : chatId.split('@')[0]);
} catch {
return chatId.endsWith('@lid') ? '' : chatId.split('@')[0];
}
}
async getMessageMedia(_chatId: string, _messageId: string) {
throw new NotImplementedException('Busca de mídia não disponível na Meta API ainda.');
}
private async resolveLidPhone(chatId: string) {
if (!chatId.endsWith('@lid')) return '';
async sendMediaMessage(
to: string,
buffer: Buffer,
mimeType: string,
senderName?: string,
caption?: string,
filename?: string,
): Promise<any> {
const { access_token: token, phone_number_id: phoneNumberId, api_version: apiVersion } =
await this.configService.getConfig();
try {
const page = (this.client as any).pupPage;
if (!page) return '';
if (!token || !phoneNumberId) {
throw new Error('Token e Phone Number ID do WhatsApp não configurados. Acesse Admin → Canais e Integração → WhatsApp → Configurar.');
const phone = await this.retryWhatsappRead(() =>
page.evaluate(async (serializedChatId: string) => {
try {
const result = await (window as any).WWebJS?.enforceLidAndPnRetrieval?.(serializedChatId);
return result?.phone?._serialized || result?.phone?.user || '';
} catch {
return '';
}
}, chatId),
);
return phone ? String(phone).split('@')[0] : '';
} catch {
return '';
}
const mediaPath = await this.mediaStorageService.upload(buffer, mimeType);
const mediaType = this.mimeTypeToMediaType(mimeType);
const form = new FormData();
form.append('file', new Blob([new Uint8Array(buffer)], { type: mimeType }), `media`);
form.append('type', mimeType);
form.append('messaging_product', 'whatsapp');
const uploadResponse = await fetch(
`https://graph.facebook.com/${apiVersion}/${phoneNumberId}/media`,
{ method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form },
);
if (!uploadResponse.ok) {
const error = await uploadResponse.json().catch(() => ({}));
throw new Error(`Meta API erro ao fazer upload de mídia: ${JSON.stringify(error)}`);
}
const { id: mediaId } = await uploadResponse.json() as any;
const mediaPayload: any = { id: mediaId };
if (caption && mediaType !== 'audio') mediaPayload.caption = senderName ? `*Atendente: ${senderName}*\n\n${caption}` : caption;
if (mediaType === 'document' && filename) mediaPayload.filename = filename;
const messageResponse = await fetch(
`https://graph.facebook.com/${apiVersion}/${phoneNumberId}/messages`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: mediaType,
[mediaType]: mediaPayload,
}),
},
);
if (!messageResponse.ok) {
const error = await messageResponse.json().catch(() => ({}));
throw new Error(`Meta API erro ao enviar mídia para ${to}: ${JSON.stringify(error)}`);
}
const messageResult = await messageResponse.json() as any;
const wamid: string | undefined = messageResult?.messages?.[0]?.id;
this.logger.log(`[Meta] Mídia (${mediaType}) enviada para ${to}: ${wamid}`);
const senderLabel = senderName ?? 'Sistema';
const bodyText = caption
? (senderName ? `*Atendente: ${senderLabel}*\n\n${caption}` : caption)
: (filename || `[${mediaType}]`);
const timestamp = Math.floor(Date.now() / 1000);
if (wamid) {
await this.mensagensRepository.save({
wamid,
chatId: to,
fromMe: true,
type: mediaType,
body: bodyText,
senderName: senderLabel,
status: 'sent',
timestamp,
mediaPath,
mediaMimeType: mimeType,
mediaFilename: filename,
});
}
this.gateway.emitNewMessage({
from: to,
body: bodyText,
timestamp,
isGroupMsg: false,
id: wamid || '',
fromMe: true,
notifyName: senderLabel,
hasMedia: true,
mediaUrl: this.mediaStorageService.getUrl(mediaPath),
mediaMimeType: mimeType,
mediaFilename: filename ?? null,
});
return messageResult;
}
private mimeTypeToMediaType(mimeType: string): string {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType.startsWith('video/')) return 'video';
return 'document';
}
private async getCustomerContact(chatId: string) {
@ -221,66 +397,122 @@ export class WhatsappService {
private async getBotMessageVariables(chatId: string, msg: any) {
const contactProfile = await this.getCustomerContact(chatId);
const notifyName = String(msg?.['_data']?.notifyName || '').trim();
const pushName = String(msg?.['_data']?.pushName || '').trim();
const phone = contactProfile?.phone || await this.resolveContactPhone(chatId);
const fallbackName = notifyName || pushName || '';
return {
nome: contactProfile?.name || notifyName,
telefone: contactProfile?.phone || chatId,
nome: contactProfile?.name || fallbackName,
telefone: phone,
};
}
async sendMessage(to: string, message: string, _media?: { data: string; mimetype: string; filename?: string }, senderName?: string) {
const { access_token: token, phone_number_id: phoneNumberId, api_version: apiVersion } =
await this.configService.getConfig();
async getChatMessages(chatId: string) {
if (this.status !== 'CONNECTED') return [];
if (!token || !phoneNumberId) {
throw new Error('Token e Phone Number ID do WhatsApp não configurados. Acesse Admin → Canais e Integração → WhatsApp → Configurar.');
}
const body = senderName ? `*Atendente: ${senderName}*\n\n${message}` : message;
const response = await fetch(
`https://graph.facebook.com/${apiVersion}/${phoneNumberId}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'text',
text: { preview_url: false, body },
}),
},
);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
this.logger.error(`[Meta] Falha ao enviar mensagem para ${to}:`, error);
throw new Error(`Meta API erro ${response.status}: ${JSON.stringify(error)}`);
}
const result = await response.json() as any;
const wamid = result?.messages?.[0]?.id;
this.logger.log(`[Meta] Mensagem enviada para ${to}: ${wamid}`);
if (wamid) {
await this.mensagensRepository.save({
wamid,
chatId: to,
fromMe: true,
type: 'text',
body: body,
senderName: senderName ?? 'Sistema',
status: 'sent',
timestamp: Math.floor(Date.now() / 1000),
try {
const messages = await this.retryWhatsappRead(async () => {
const chat = await this.client.getChatById(chatId);
return chat.fetchMessages({ limit: 50 });
});
this.chatMessagesCache.set(chatId, messages);
return messages;
} catch (err) {
const cachedMessages = this.chatMessagesCache.get(chatId) || [];
if (this.isTransientWhatsappFrameError(err)) {
this.logger.warn(`WhatsApp Web ainda estabilizando ao carregar mensagens de ${chatId}. Retornando cache local (${cachedMessages.length}).`);
} else {
this.logger.error(`Erro ao carregar mensagens de ${chatId}. Retornando cache local (${cachedMessages.length}).`, err);
}
return cachedMessages;
}
}
async getMessageMedia(chatId: string, messageId: string) {
if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado');
this.logger.log(`Buscando mídia do chat ${chatId}, mensagem ${messageId}`);
const messages = await this.retryWhatsappRead(async () => {
const chat = await this.client.getChatById(chatId);
return chat.fetchMessages({ limit: 50 });
});
const msg = messages.find(m => m.id._serialized === messageId);
if (msg && msg.hasMedia) {
this.logger.log(`Baixando mídia para mensagem ${messageId}...`);
const media = await msg.downloadMedia();
if (media) {
return {
mimetype: media.mimetype,
data: media.data,
filename: media.filename || 'arquivo'
};
}
}
throw new Error('Mídia não encontrada para esta mensagem');
}
private async retryWhatsappRead<T>(operation: () => Promise<T>, retries = 1): Promise<T> {
try {
return await operation();
} catch (err) {
if (retries <= 0 || !this.isTransientWhatsappFrameError(err)) {
throw err;
}
await this.sleep(350);
return this.retryWhatsappRead(operation, retries - 1);
}
}
private sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private isTransientWhatsappFrameError(err: unknown) {
const message = this.getErrorMessage(err).toLowerCase();
return (
message.includes('detached frame') ||
message.includes('execution context was destroyed') ||
message.includes('most likely because of a navigation')
);
}
private getErrorMessage(err: unknown) {
if (err instanceof Error) return err.message;
return String(err || '');
}
async sendMessage(to: string, message: string, media?: { data: string; mimetype: string; filename?: string }, senderName?: string) {
if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado');
const canSendMessage = await this.assignmentService.canSendAgentMessage(to);
if (!canSendMessage) {
throw new Error('Aguarde o cliente responder antes de enviar novas mensagens.');
}
const outboundMessage = this.formatOutboundMessage(message, senderName);
let sentMsg;
if (media) {
this.logger.log(`Enviando mídia para ${to}: ${media.filename} (${media.mimetype})`);
const messageMedia = new MessageMedia(media.mimetype, media.data, media.filename);
sentMsg = await this.client.sendMessage(to, messageMedia, { caption: outboundMessage });
} else {
sentMsg = await this.client.sendMessage(to, outboundMessage);
}
// Sincronizar na persistência também!
await this.addOrUpdatePersistentChat(to, {
name: to.split('@')[0],
preview: media ? `[Mídia: ${media.filename || 'Arquivo'}]` : message,
timestamp: Math.floor(Date.now() / 1000),
unreadCount: 0,
lastMessageFromMe: true
});
await this.assignmentService.clearTransferNote(to);
return result;
return sentMsg;
}
async startAttendance(
@ -290,93 +522,43 @@ export class WhatsappService {
areaId?: number | null,
variables?: Record<string, string | null | undefined>,
) {
const template = await this.whatsappTemplateService.getTemplateById(templateId);
if (!template) throw new Error('Template de WhatsApp não encontrado');
if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado');
if (template.meta_template_name && template.meta_status === 'APPROVED') {
await this.sendTemplateMessage(to, template, variables);
} else {
const renderedContent = this.renderTemplateContent(template.content, variables);
await this.sendMessage(to, renderedContent);
const phone = to.replace(/@c\.us$/i, '');
const numberId = await this.client.getNumberId(phone).catch(() => null);
if (!numberId) {
throw new Error(`O número ${phone} não está registrado no WhatsApp.`);
}
const resolvedTo = numberId._serialized;
const template = await this.whatsappTemplateService.getTemplateById(templateId);
if (!template) {
throw new Error('Template de WhatsApp nao encontrado');
}
const assignment = await this.assignmentService.assignChat(to, userId, areaId || null);
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to);
const renderedContent = this.renderTemplateContent(template.content, variables);
const sentMessage = await this.sendMessage(resolvedTo, renderedContent);
const chatId = this.getSentMessageChatId(sentMessage, resolvedTo);
const assignment = await this.assignmentService.assignChat(chatId, userId, areaId || null);
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(chatId);
return {
chatId: to,
template,
messageId: null,
chatId,
template: { ...template, content: renderedContent },
messageId: sentMessage?.id?._serialized || null,
assignment: lockedAssignment || assignment,
};
}
private async sendTemplateMessage(
to: string,
template: any,
variables?: Record<string, string | null | undefined>,
) {
const { access_token: token, phone_number_id: phoneNumberId, api_version: apiVersion } =
await this.configService.getConfig();
private getSentMessageChatId(sentMessage: any, fallbackChatId: string) {
const remote =
sentMessage?.id?.remote ||
sentMessage?._data?.id?.remote ||
sentMessage?.to ||
sentMessage?.from ||
fallbackChatId;
if (!token || !phoneNumberId) throw new Error('WhatsApp não configurado.');
const bodyVars = Array.isArray(template.body_variables) ? template.body_variables : [];
const components: any[] = [];
if (bodyVars.length > 0) {
const params = bodyVars.map((v: any) => ({
type: 'text',
text: variables?.[v.label] ?? variables?.[String(v.index)] ?? v.example ?? '',
}));
components.push({ type: 'body', parameters: params });
}
const payload = {
messaging_product: 'whatsapp',
to,
type: 'template',
template: {
name: template.meta_template_name,
language: { code: template.language || 'pt_BR' },
...(components.length > 0 ? { components } : {}),
},
};
const response = await fetch(
`https://graph.facebook.com/${apiVersion}/${phoneNumberId}/messages`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
this.logger.error(`[Meta] Falha ao enviar template para ${to}:`, error);
throw new Error(`Meta API erro ${response.status}: ${JSON.stringify(error)}`);
}
const result = (await response.json()) as any;
const wamid = result?.messages?.[0]?.id;
this.logger.log(`[Meta] Template "${template.meta_template_name}" enviado para ${to}: ${wamid}`);
if (wamid) {
await this.mensagensRepository.save({
wamid,
chatId: to,
fromMe: true,
type: 'template',
body: `[Template: ${template.name}]`,
senderName: 'Sistema',
status: 'sent',
timestamp: Math.floor(Date.now() / 1000),
});
}
await this.assignmentService.clearTransferNote(to);
return result;
return String(remote || fallbackChatId);
}
private renderTemplateContent(content: string, variables?: Record<string, string | null | undefined>) {
@ -390,152 +572,19 @@ export class WhatsappService {
});
}
private formatOutboundMessage(message: string, senderName?: string) {
const cleanMessage = (message || '').trim();
const cleanSenderName = (senderName || '').trim();
async handleMetaWebhookEvent(body: any): Promise<void> {
if (body?.object !== 'whatsapp_business_account') return;
for (const entry of body?.entry ?? []) {
for (const change of entry?.changes ?? []) {
const value = change.value;
if (change.field === 'message_template_status_update') {
await this.whatsappTemplateService.handleTemplateStatusUpdate(value);
continue;
}
if (change.field !== 'messages') continue;
if (value?.messages?.length) {
for (const message of value.messages) {
await this.handleMetaIncomingMessage(message, value.contacts ?? []);
}
}
if (value?.statuses?.length) {
for (const status of value.statuses) {
this.logger.log(`[Meta] Status de mensagem: ${status.id}${status.status} (destinatário: ${status.recipient_id})`);
await this.mensagensRepository.updateStatus(status.id, status.status);
}
}
}
}
}
private async handleMetaIncomingMessage(message: any, contacts: any[]): Promise<void> {
const from: string = message.from;
const messageId: string = message.id;
const timestamp: number = parseInt(message.timestamp, 10);
const messageType: string = message.type ?? 'text';
const messageBody: string = message.text?.body?.trim() ?? '';
const contact = contacts.find((c) => c.wa_id === from);
const contactName: string = contact?.profile?.name || from;
if (this.processedIncomingMessages.has(messageId)) return;
this.processedIncomingMessages.add(messageId);
if (this.processedIncomingMessages.size > 1000) {
const [oldest] = this.processedIncomingMessages;
this.processedIncomingMessages.delete(oldest);
if (!cleanSenderName) {
return cleanMessage;
}
this.logger.log(`[Meta] Mensagem recebida de ${from} (${contactName}): ${messageBody || `[${messageType}]`}`);
let mediaPath: string | undefined;
let mediaMimeType: string | undefined;
let mediaFilename: string | undefined;
if ((MEDIA_TYPES as readonly string[]).includes(messageType)) {
const mediaObj = message[messageType];
if (mediaObj?.id) {
try {
const { buffer, mimeType } = await this.downloadMetaMedia(mediaObj.id, mediaObj.mime_type);
mediaPath = await this.mediaStorageService.upload(buffer, mimeType);
mediaMimeType = mimeType;
mediaFilename = mediaObj.filename || undefined;
this.logger.log(`[Meta] Mídia salva: ${mediaPath}`);
} catch (err) {
this.logger.error(`[Meta] Falha ao baixar mídia ${mediaObj.id} de ${from}:`, err);
}
}
if (!cleanMessage) {
return '';
}
const bodyToSave = messageBody || mediaFilename || undefined;
await this.mensagensRepository.save({
wamid: messageId,
chatId: from,
fromMe: false,
type: messageType,
body: bodyToSave,
senderName: contactName,
timestamp,
mediaPath,
mediaMimeType,
mediaFilename,
});
this.gateway.emitNewMessage({
from,
body: messageBody,
timestamp,
isGroupMsg: false,
id: messageId,
fromMe: false,
notifyName: contactName,
hasMedia: !!mediaPath,
mediaUrl: mediaPath ? this.mediaStorageService.getUrl(mediaPath) : null,
mediaMimeType: mediaMimeType ?? null,
mediaFilename: mediaFilename ?? null,
});
if (messageBody || mediaPath) {
await this.assignmentService.markCustomerReplied(from);
}
if (!messageBody) {
this.logger.log(`[Meta] Triagem ignorada para ${from}: mensagem de mídia sem texto.`);
return;
}
const fakeMsg = {
reply: async (text: string) => {
await this.sendMessage(from, text);
},
_data: { notifyName: contactName },
};
await this.enqueueIncomingRoute(from, fakeMsg, messageBody, messageId);
}
private async downloadMetaMedia(mediaId: string, webhookMimeType?: string): Promise<{ buffer: Buffer; mimeType: string }> {
const { access_token: token, api_version: apiVersion } = await this.configService.getConfig();
if (!token) throw new Error('Token do WhatsApp não configurado.');
const metaResponse = await fetch(
`https://graph.facebook.com/${apiVersion}/${mediaId}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!metaResponse.ok) {
throw new Error(`Meta API erro ao buscar URL de mídia ${mediaId}: ${metaResponse.status}`);
}
const metaData = await metaResponse.json() as any;
const downloadUrl: string = metaData.url;
const mimeType: string = webhookMimeType || metaData.mime_type || 'application/octet-stream';
const fileResponse = await fetch(downloadUrl, {
headers: { Authorization: `Bearer ${token}` },
});
if (!fileResponse.ok) {
throw new Error(`Falha ao baixar arquivo de mídia: ${fileResponse.status}`);
}
const arrayBuffer = await fileResponse.arrayBuffer();
return { buffer: Buffer.from(arrayBuffer), mimeType };
return `*Atendente: ${cleanSenderName}*\n\n${cleanMessage}`;
}
}