Compare commits
1 Commits
dev
...
v1.0.0-dem
| Author | SHA1 | Date | |
|---|---|---|---|
| dd23402238 |
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
1802
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 *;
|
||||
`,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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(
|
||||
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' };
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user