Compare commits
41 Commits
demo/apres
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d4893be23f | |||
| a7ab0cc84c | |||
| 263840674d | |||
| 83f2319ca1 | |||
| 7617480242 | |||
| abfcbf2aee | |||
| 85d89ae934 | |||
| 2d6e3da322 | |||
| 812bedf363 | |||
| 980b36fadd | |||
| 28201f9197 | |||
| 23c87b59b7 | |||
| 38cbc7d276 | |||
| 4b83817328 | |||
| 332dabf33f | |||
| b1ee393bc2 | |||
| 97a12d700a | |||
| 30ec6e4b5e | |||
| 98df003cd7 | |||
| aa8745b541 | |||
| ad835b5b1d | |||
| 3f1535b960 | |||
| c988a84f97 | |||
| 84f8ca8e3f | |||
| 4ab3af503b | |||
| 9514d9df4d | |||
| f0044fb8e5 | |||
| d0cc9be277 | |||
| 197458e2a7 | |||
| 5e9f328932 | |||
| 224594918d | |||
| 876ab6ec8b | |||
| fd46eade07 | |||
| ee7b94a699 | |||
| b11e9bb9a3 | |||
| 33e06669a0 | |||
| cfd6f2deae | |||
| d39a03c05f | |||
| 79b55be7ef | |||
| 779d64b2f0 | |||
| 590748c0d9 |
@ -12,6 +12,7 @@ DB_NAME=omnichannel
|
|||||||
|
|
||||||
# HTTP/JWT
|
# HTTP/JWT
|
||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
BASE_URL=http://localhost:3001
|
||||||
JWT_SECRET=change-this-long-random-secret
|
JWT_SECRET=change-this-long-random-secret
|
||||||
JWT_EXPIRES_IN=8h
|
JWT_EXPIRES_IN=8h
|
||||||
|
|
||||||
|
|||||||
@ -1,24 +1,70 @@
|
|||||||
name: Deploy Dev
|
name: Deploy Dev — Backend
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: self-hosted
|
name: Deploy para VM Dev
|
||||||
|
runs-on: omnichannel-uat
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /opt/omnichannel
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Atualizar código na VM Dev
|
- name: Checkout
|
||||||
run: |
|
uses: actions/checkout@v4
|
||||||
cd /opt/omnichannel/backend
|
|
||||||
git pull origin dev
|
|
||||||
|
|
||||||
- name: Copiar env
|
- name: Preflight
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cp /home/desenvolvimento/.envs/omnichannel/backend.env.development /opt/omnichannel/backend/.env.development
|
set -euo pipefail
|
||||||
|
echo "Runner: $(hostname)"
|
||||||
|
docker --version
|
||||||
|
docker compose version
|
||||||
|
|
||||||
- name: Rebuild container
|
- name: Verificar secrets obrigatórios
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ENV_DEV: ${{ secrets.ENV_DEV }}
|
||||||
run: |
|
run: |
|
||||||
cd /opt/omnichannel
|
set -euo pipefail
|
||||||
docker compose up -d --build backend
|
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
|
||||||
|
|
||||||
|
- name: Escrever .env.development
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ENV_DEV: ${{ secrets.ENV_DEV }}
|
||||||
|
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
|
||||||
|
|||||||
70
.gitea/workflows/deploy-prod.yml
Normal file
70
.gitea/workflows/deploy-prod.yml
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
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
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["npm", "run", "dev"]
|
CMD ["npm", "run", "dev:docker"]
|
||||||
|
|||||||
1802
package-lock.json
generated
1802
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,13 +7,15 @@
|
|||||||
"start": "cross-env NODE_ENV=production node dist/main.js",
|
"start": "cross-env NODE_ENV=production node dist/main.js",
|
||||||
"predev": "node scripts/ensure-port-free.js",
|
"predev": "node scripts/ensure-port-free.js",
|
||||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||||
"start:dev": "npm run dev"
|
"start:dev": "npm run dev",
|
||||||
|
"dev:docker": "cross-env NODE_ENV=development nest start --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.1.19",
|
"@nestjs/common": "^11.1.19",
|
||||||
"@nestjs/core": "^11.1.19",
|
"@nestjs/core": "^11.1.19",
|
||||||
"@nestjs/platform-express": "^11.1.19",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
"@nestjs/platform-socket.io": "^11.1.21",
|
"@nestjs/platform-socket.io": "^11.1.21",
|
||||||
|
"@nestjs/serve-static": "^5.0.5",
|
||||||
"@nestjs/swagger": "^11.4.4",
|
"@nestjs/swagger": "^11.4.4",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.21",
|
"@nestjs/websockets": "^11.1.21",
|
||||||
@ -28,7 +30,6 @@
|
|||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"whatsapp-web.js": "^1.34.7",
|
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"winston-daily-rotate-file": "^5.0.0"
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
import * as path from 'path';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { DatabaseModule } from './infra/database/database.module';
|
import { DatabaseModule } from './infra/database/database.module';
|
||||||
import { AdminModule } from './modules/admin/admin.module';
|
import { AdminModule } from './modules/admin/admin.module';
|
||||||
@ -19,6 +21,10 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
|
|||||||
limit: Number(process.env.RATE_LIMIT_MAX || 300),
|
limit: Number(process.env.RATE_LIMIT_MAX || 300),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
ServeStaticModule.forRoot({
|
||||||
|
rootPath: path.resolve(process.cwd(), 'uploads'),
|
||||||
|
serveRoot: '/uploads',
|
||||||
|
}),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
|||||||
@ -351,6 +351,8 @@ export class AttendanceAssignmentRepository {
|
|||||||
routing_attempts = EXCLUDED.routing_attempts,
|
routing_attempts = EXCLUDED.routing_attempts,
|
||||||
last_routed_message_id = EXCLUDED.last_routed_message_id,
|
last_routed_message_id = EXCLUDED.last_routed_message_id,
|
||||||
last_bot_sent_at = CURRENT_TIMESTAMP,
|
last_bot_sent_at = CURRENT_TIMESTAMP,
|
||||||
|
conversation_started_at = CURRENT_TIMESTAMP,
|
||||||
|
expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours',
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
`,
|
`,
|
||||||
@ -390,6 +392,8 @@ export class AttendanceAssignmentRepository {
|
|||||||
triage_audience_id = EXCLUDED.triage_audience_id,
|
triage_audience_id = EXCLUDED.triage_audience_id,
|
||||||
triage_intent_id = EXCLUDED.triage_intent_id,
|
triage_intent_id = EXCLUDED.triage_intent_id,
|
||||||
triage_step = EXCLUDED.triage_step,
|
triage_step = EXCLUDED.triage_step,
|
||||||
|
conversation_started_at = CURRENT_TIMESTAMP,
|
||||||
|
expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours',
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
`,
|
`,
|
||||||
@ -423,6 +427,8 @@ export class AttendanceAssignmentRepository {
|
|||||||
triage_step = NULL,
|
triage_step = NULL,
|
||||||
triage_builder_version_id = EXCLUDED.triage_builder_version_id,
|
triage_builder_version_id = EXCLUDED.triage_builder_version_id,
|
||||||
triage_builder_node_id = EXCLUDED.triage_builder_node_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
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
`,
|
`,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsObject,
|
IsObject,
|
||||||
@ -127,12 +128,33 @@ export class CreateWhatsappTemplateDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(40)
|
@MaxLength(40)
|
||||||
requestedByRole?: string;
|
category?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(40)
|
@MaxLength(10)
|
||||||
category?: string;
|
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 {
|
export class UpdateWhatsappTemplateDto {
|
||||||
@ -154,4 +176,72 @@ export class UpdateWhatsappTemplateDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(40)
|
@MaxLength(40)
|
||||||
category?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/modules/whatsapp/media/adapters/local-disk.adapter.ts
Normal file
48
src/modules/whatsapp/media/adapters/local-disk.adapter.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/modules/whatsapp/media/media-storage.service.ts
Normal file
20
src/modules/whatsapp/media/media-storage.service.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/modules/whatsapp/repositories/mensagens.repository.ts
Normal file
61
src/modules/whatsapp/repositories/mensagens.repository.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
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,108 +25,199 @@ export class WhatsappTemplateRepository {
|
|||||||
content: string;
|
content: string;
|
||||||
category: string;
|
category: string;
|
||||||
areaId?: number | null;
|
areaId?: number | null;
|
||||||
status: string;
|
language: string;
|
||||||
requestedByRole: string;
|
metaTemplateName: string;
|
||||||
adminApprovedAtSql: 'NULL' | 'CURRENT_TIMESTAMP';
|
headerType?: string | null;
|
||||||
metaSubmittedAtSql: 'NULL' | 'CURRENT_TIMESTAMP';
|
headerText?: string | null;
|
||||||
|
footerText?: string | null;
|
||||||
|
buttonsJson: string | null;
|
||||||
|
bodyVariablesJson: string | null;
|
||||||
}) {
|
}) {
|
||||||
return this.database.query(
|
return this.database.query(
|
||||||
`
|
`
|
||||||
INSERT INTO whatsapp_templates (
|
INSERT INTO whatsapp_templates (
|
||||||
name,
|
name, content, category, area_id, language,
|
||||||
content,
|
meta_template_name, header_type, header_text, footer_text,
|
||||||
category,
|
buttons, body_variables, status, updated_at
|
||||||
area_id,
|
|
||||||
status,
|
|
||||||
requested_by_role,
|
|
||||||
admin_approved_at,
|
|
||||||
meta_submitted_at,
|
|
||||||
meta_approved_at,
|
|
||||||
updated_at
|
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, ${input.adminApprovedAtSql}, ${input.metaSubmittedAtSql}, NULL, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, 'draft', CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT (name) DO UPDATE SET
|
ON CONFLICT (name) DO UPDATE SET
|
||||||
content = EXCLUDED.content,
|
content = EXCLUDED.content,
|
||||||
category = EXCLUDED.category,
|
category = EXCLUDED.category,
|
||||||
area_id = EXCLUDED.area_id,
|
area_id = EXCLUDED.area_id,
|
||||||
status = EXCLUDED.status,
|
language = EXCLUDED.language,
|
||||||
requested_by_role = EXCLUDED.requested_by_role,
|
meta_template_name = EXCLUDED.meta_template_name,
|
||||||
admin_approved_at = EXCLUDED.admin_approved_at,
|
header_type = EXCLUDED.header_type,
|
||||||
meta_submitted_at = EXCLUDED.meta_submitted_at,
|
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,
|
meta_approved_at = NULL,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`,
|
`,
|
||||||
[input.name, input.content, input.category, input.areaId || null, input.status, input.requestedByRole],
|
[
|
||||||
|
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,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTemplate(id: number, input: { name: string; content: string; category: string; areaId?: number | null }) {
|
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;
|
||||||
|
},
|
||||||
|
) {
|
||||||
return this.database.query(
|
return this.database.query(
|
||||||
`
|
`
|
||||||
UPDATE whatsapp_templates
|
UPDATE whatsapp_templates SET
|
||||||
SET
|
|
||||||
name = $1,
|
name = $1,
|
||||||
content = $2,
|
content = $2,
|
||||||
category = $3,
|
category = $3,
|
||||||
area_id = $4,
|
area_id = $4,
|
||||||
status = 'meta_review',
|
language = $5,
|
||||||
admin_approved_at = CURRENT_TIMESTAMP,
|
meta_template_name = $6,
|
||||||
meta_submitted_at = CURRENT_TIMESTAMP,
|
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,
|
meta_approved_at = NULL,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $5
|
WHERE id = $12
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`,
|
`,
|
||||||
[input.name, input.content, input.category, input.areaId || null, id],
|
[
|
||||||
|
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,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
approveTemplateByAdmin(id: number) {
|
updateMetaInfo(
|
||||||
|
id: number,
|
||||||
|
info: { metaTemplateId?: string; metaTemplateName?: string; metaStatus: string },
|
||||||
|
) {
|
||||||
return this.database.query(
|
return this.database.query(
|
||||||
`
|
`UPDATE whatsapp_templates SET
|
||||||
UPDATE whatsapp_templates
|
meta_template_id = COALESCE($2, meta_template_id),
|
||||||
SET
|
meta_template_name = COALESCE($3, meta_template_name),
|
||||||
status = 'meta_review',
|
meta_status = $4,
|
||||||
admin_approved_at = CURRENT_TIMESTAMP,
|
|
||||||
meta_submitted_at = CURRENT_TIMESTAMP,
|
meta_submitted_at = CURRENT_TIMESTAMP,
|
||||||
meta_approved_at = NULL,
|
status = 'pending',
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *
|
RETURNING *`,
|
||||||
`,
|
[id, info.metaTemplateId || null, info.metaTemplateName || null, info.metaStatus],
|
||||||
[id],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
rejectTemplateByAdmin(id: number) {
|
async updateMetaStatusByName(metaName: string, metaStatus: string, metaId: string): Promise<number> {
|
||||||
return this.database.query(
|
const result = await this.database.query(
|
||||||
`
|
`UPDATE whatsapp_templates SET
|
||||||
UPDATE whatsapp_templates
|
meta_status = $2,
|
||||||
SET
|
meta_template_id = COALESCE($3, meta_template_id),
|
||||||
status = 'rejected',
|
meta_approved_at = CASE WHEN $2::text = 'APPROVED' THEN CURRENT_TIMESTAMP ELSE meta_approved_at END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1
|
WHERE meta_template_name = $1`,
|
||||||
RETURNING *
|
[metaName, metaStatus, metaId || null],
|
||||||
`,
|
|
||||||
[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) {
|
deleteTemplate(id: number) {
|
||||||
return this.database.query('DELETE FROM whatsapp_templates WHERE id = $1', [id]);
|
return this.database.query('DELETE FROM whatsapp_templates WHERE id = $1 RETURNING meta_template_name', [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'
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/modules/whatsapp/whatsapp-admin.controller.ts
Normal file
37
src/modules/whatsapp/whatsapp-admin.controller.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/modules/whatsapp/whatsapp-config.service.ts
Normal file
91
src/modules/whatsapp/whatsapp-config.service.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
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,65 +1,303 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository';
|
import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository';
|
||||||
|
import { WhatsappConfigService } from './whatsapp-config.service';
|
||||||
|
import { CreateWhatsappTemplateDto, UpdateWhatsappTemplateDto } from './dto/whatsapp.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WhatsappTemplateService {
|
export class WhatsappTemplateService {
|
||||||
constructor(private readonly whatsappTemplateRepository: WhatsappTemplateRepository) {}
|
private readonly logger = new Logger(WhatsappTemplateService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly repo: WhatsappTemplateRepository,
|
||||||
|
private readonly configService: WhatsappConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async getTemplates() {
|
async getTemplates() {
|
||||||
await this.whatsappTemplateRepository.refreshMetaApprovals();
|
const result = await this.repo.listTemplates();
|
||||||
const result = await this.whatsappTemplateRepository.listTemplates();
|
|
||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTemplateById(id: number) {
|
async getTemplateById(id: number) {
|
||||||
await this.whatsappTemplateRepository.refreshMetaApprovals();
|
const result = await this.repo.getTemplateById(id);
|
||||||
const result = await this.whatsappTemplateRepository.getTemplateById(id);
|
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveTemplate(name: string, content: string, areaId?: number | null, requestedByRole = 'admin', category = 'UTILITY') {
|
async saveTemplate(dto: CreateWhatsappTemplateDto) {
|
||||||
const isSupervisor = requestedByRole === 'supervisor';
|
const metaName = this.toMetaName(dto.name);
|
||||||
const result = await this.whatsappTemplateRepository.saveTemplate({
|
const result = await this.repo.saveTemplate({
|
||||||
name,
|
name: dto.name,
|
||||||
content,
|
content: dto.content,
|
||||||
category: this.normalizeTemplateCategory(category),
|
category: this.normalizeCategory(dto.category),
|
||||||
areaId,
|
areaId: dto.areaId ?? null,
|
||||||
status: isSupervisor ? 'admin_review' : 'meta_review',
|
language: dto.language || 'pt_BR',
|
||||||
requestedByRole,
|
metaTemplateName: metaName,
|
||||||
adminApprovedAtSql: isSupervisor ? 'NULL' : 'CURRENT_TIMESTAMP',
|
headerType: dto.headerType || null,
|
||||||
metaSubmittedAtSql: isSupervisor ? 'NULL' : 'CURRENT_TIMESTAMP',
|
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,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
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 result.rows[0];
|
return { success: true, metaId: data.id, metaName, status: data.status || 'PENDING' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTemplate(id: number, name: string, content: string, areaId?: number | null, category = 'UTILITY') {
|
async syncFromMeta() {
|
||||||
const result = await this.whatsappTemplateRepository.updateTemplate(id, {
|
const config = await this.configService.getConfig();
|
||||||
name,
|
if (!config.access_token || !config.waba_id) {
|
||||||
content,
|
return { synced: 0, imported: 0, error: 'WhatsApp não configurado com WABA ID' };
|
||||||
areaId,
|
}
|
||||||
category: this.normalizeTemplateCategory(category),
|
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.rows[0];
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async approveTemplateByAdmin(id: number) {
|
this.logger.log(`[Meta Templates] Sync concluído: ${synced} atualizado(s), ${imported} importado(s)`);
|
||||||
const result = await this.whatsappTemplateRepository.approveTemplateByAdmin(id);
|
return { synced, imported };
|
||||||
return result.rows[0];
|
} 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 rejectTemplateByAdmin(id: number) {
|
async handleTemplateStatusUpdate(data: {
|
||||||
const result = await this.whatsappTemplateRepository.rejectTemplateByAdmin(id);
|
event?: string;
|
||||||
return result.rows[0];
|
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 deleteTemplate(id: number) {
|
async deleteTemplate(id: number) {
|
||||||
await this.whatsappTemplateRepository.deleteTemplate(id);
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeTemplateCategory(category?: string) {
|
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 {
|
||||||
const normalized = String(category || 'UTILITY').trim().toUpperCase();
|
const normalized = String(category || 'UTILITY').trim().toUpperCase();
|
||||||
return ['UTILITY', 'MARKETING', 'AUTHENTICATION'].includes(normalized) ? normalized : 'UTILITY';
|
return ['UTILITY', 'MARKETING', 'AUTHENTICATION'].includes(normalized) ? normalized : 'UTILITY';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,23 @@
|
|||||||
import { Controller, Get, Post, Body, Delete, Param, ForbiddenException, Query, RawBody } from '@nestjs/common';
|
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 { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { WhatsappService } from './whatsapp.service';
|
import { WhatsappService } from './whatsapp.service';
|
||||||
import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service';
|
import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service';
|
||||||
@ -12,6 +31,7 @@ import {
|
|||||||
UpdateWhatsappTemplateDto,
|
UpdateWhatsappTemplateDto,
|
||||||
} from './dto/whatsapp.dto';
|
} from './dto/whatsapp.dto';
|
||||||
import { WhatsappTemplateService } from './whatsapp-template.service';
|
import { WhatsappTemplateService } from './whatsapp-template.service';
|
||||||
|
import { WhatsappConfigService } from './whatsapp-config.service';
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
|
|
||||||
|
|
||||||
@ -23,32 +43,24 @@ export class WhatsappController {
|
|||||||
private readonly whatsappService: WhatsappService,
|
private readonly whatsappService: WhatsappService,
|
||||||
private readonly assignmentService: AttendanceAssignmentService,
|
private readonly assignmentService: AttendanceAssignmentService,
|
||||||
private readonly whatsappTemplateService: WhatsappTemplateService,
|
private readonly whatsappTemplateService: WhatsappTemplateService,
|
||||||
|
private readonly configService: WhatsappConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Consulta status da conexao WhatsApp' })
|
||||||
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.' })
|
@ApiResponse({ status: 200, description: 'Status atual da conexao retornado.' })
|
||||||
@Get('status')
|
@Get('status')
|
||||||
getStatus() {
|
getStatus() {
|
||||||
return { status: this.whatsappService.getStatus() };
|
return { status: this.whatsappService.getStatus() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Lista conversas do WhatsApp' })
|
||||||
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.' })
|
@ApiResponse({ status: 200, description: 'Lista de conversas retornada.' })
|
||||||
@Get('chats')
|
@Get('chats')
|
||||||
async getChats() {
|
async getChats() {
|
||||||
return this.whatsappService.getChats();
|
return this.whatsappService.getChats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Lista mensagens de uma conversa' })
|
||||||
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.' })
|
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
||||||
@ApiResponse({ status: 200, description: 'Mensagens do chat retornadas.' })
|
@ApiResponse({ status: 200, description: 'Mensagens do chat retornadas.' })
|
||||||
@Get('messages/:chatId')
|
@Get('messages/:chatId')
|
||||||
@ -56,244 +68,158 @@ export class WhatsappController {
|
|||||||
return this.whatsappService.getChatMessages(chatId);
|
return this.whatsappService.getChatMessages(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Baixa midia de uma mensagem' })
|
||||||
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: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
||||||
@ApiParam({ name: 'messageId', description: 'Identificador da mensagem com midia.' })
|
@ApiParam({ name: 'messageId', description: 'Identificador da mensagem com midia.' })
|
||||||
@ApiResponse({ status: 200, description: 'Midia da mensagem retornada.' })
|
|
||||||
@Get('media/:chatId/:messageId')
|
@Get('media/:chatId/:messageId')
|
||||||
async getMessageMedia(@Param('chatId') chatId: string, @Param('messageId') messageId: string) {
|
async getMessageMedia(@Param('chatId') chatId: string, @Param('messageId') messageId: string) {
|
||||||
return this.whatsappService.getMessageMedia(chatId, messageId);
|
return this.whatsappService.getMessageMedia(chatId, messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Envia mensagem no WhatsApp' })
|
||||||
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.' })
|
@ApiResponse({ status: 201, description: 'Mensagem enviada.' })
|
||||||
@Post('send')
|
@Post('send')
|
||||||
async sendMessage(@Body() body: SendWhatsappMessageDto) {
|
async sendMessage(@Body() body: SendWhatsappMessageDto) {
|
||||||
return this.whatsappService.sendMessage(body.to, body.message, body.media, body.senderName);
|
return this.whatsappService.sendMessage(body.to, body.message, body.media, body.senderName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Envia mídia no WhatsApp' })
|
||||||
summary: 'Inicia atendimento ativo',
|
@ApiResponse({ status: 201, description: 'Mídia enviada.' })
|
||||||
description: 'Abre uma conversa ativa usando um template aprovado e registra o atendimento para acompanhamento no painel.',
|
@Post('send-media')
|
||||||
})
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
@ApiBody({
|
limits: { fileSize: 100 * 1024 * 1024 },
|
||||||
schema: {
|
fileFilter: (_req, file, cb) => {
|
||||||
type: 'object',
|
if (ALLOWED_MEDIA_TYPES.has(file.mimetype)) {
|
||||||
required: ['to', 'templateId', 'userId'],
|
cb(null, true);
|
||||||
properties: {
|
} else {
|
||||||
to: { type: 'string', example: '5511999999999' },
|
cb(new BadRequestException(`Tipo de arquivo não suportado: ${file.mimetype}`), false);
|
||||||
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.' })
|
@ApiResponse({ status: 201, description: 'Atendimento ativo iniciado.' })
|
||||||
@Post('start-attendance')
|
@Post('start-attendance')
|
||||||
async startAttendance(@Body() body: StartAttendanceDto) {
|
async startAttendance(@Body() body: StartAttendanceDto) {
|
||||||
return this.whatsappService.startAttendance(body.to, body.templateId, body.userId, body.areaId, body.variables);
|
return this.whatsappService.startAttendance(body.to, body.templateId, body.userId, body.areaId, body.variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Assume uma conversa' })
|
||||||
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')
|
@Post('assign')
|
||||||
async assignChat(@Body() body: AssignChatDto) {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Transfere uma conversa' })
|
||||||
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')
|
@Post('transfer')
|
||||||
async transferChat(@Body() body: TransferChatDto) {
|
async transferChat(@Body() body: TransferChatDto) {
|
||||||
return this.assignmentService.transferChat(body);
|
return this.assignmentService.transferChat(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Libera conversa assumida' })
|
||||||
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.' })
|
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
||||||
@Delete('release/:chatId')
|
@Delete('release/:chatId')
|
||||||
async releaseChat(@Param('chatId') chatId: string) {
|
async releaseChat(@Param('chatId') chatId: string) {
|
||||||
return this.assignmentService.releaseChat(chatId);
|
return this.assignmentService.releaseChat(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Fecha atendimento' })
|
||||||
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')
|
@Post('close')
|
||||||
async closeChat(@Body() body: CloseChatDto) {
|
async closeChat(@Body() body: CloseChatDto) {
|
||||||
return this.assignmentService.closeChat(body.chatId, body.userId);
|
return this.assignmentService.closeChat(body.chatId, body.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Consulta atribuicao do chat' })
|
||||||
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.' })
|
@ApiParam({ name: 'chatId', description: 'Identificador do chat no WhatsApp.' })
|
||||||
@Get('assignment/:chatId')
|
@Get('assignment/:chatId')
|
||||||
async getAssignment(@Param('chatId') chatId: string) {
|
async getAssignment(@Param('chatId') chatId: string) {
|
||||||
return this.assignmentService.getAssignment(chatId);
|
return this.assignmentService.getAssignment(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
// ---- Templates HSM ----
|
||||||
summary: 'Lista templates de atendimento',
|
|
||||||
description: 'Retorna os templates cadastrados para abertura ativa e mensagens padronizadas.',
|
@ApiOperation({ summary: 'Lista templates HSM' })
|
||||||
})
|
|
||||||
@Get('templates')
|
@Get('templates')
|
||||||
async getTemplates() {
|
async getTemplates() {
|
||||||
return this.whatsappTemplateService.getTemplates();
|
return this.whatsappTemplateService.getTemplates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Cria template HSM' })
|
||||||
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')
|
@Post('templates')
|
||||||
async saveTemplate(@Body() body: CreateWhatsappTemplateDto) {
|
async saveTemplate(@Body() body: CreateWhatsappTemplateDto) {
|
||||||
return this.whatsappTemplateService.saveTemplate(body.name, body.content, body.areaId, body.requestedByRole, body.category);
|
return this.whatsappTemplateService.saveTemplate(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Sincroniza status de templates com a Meta' })
|
||||||
summary: 'Atualiza template de mensagem',
|
@Post('templates/sync-meta')
|
||||||
description: 'Altera nome, conteudo, area ou categoria de um template existente.',
|
async syncTemplatesFromMeta() {
|
||||||
})
|
return this.whatsappTemplateService.syncFromMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Atualiza template HSM existente' })
|
||||||
@ApiParam({ name: 'id', description: 'ID do template.' })
|
@ApiParam({ name: 'id', description: 'ID do template.' })
|
||||||
@Post('templates/update/:id')
|
@Post('templates/update/:id')
|
||||||
async updateTemplate(@Param('id') id: string, @Body() body: UpdateWhatsappTemplateDto) {
|
async updateTemplate(@Param('id') id: string, @Body() body: UpdateWhatsappTemplateDto) {
|
||||||
return this.whatsappTemplateService.updateTemplate(Number(id), body.name, body.content, body.areaId, body.category);
|
return this.whatsappTemplateService.updateTemplate(Number(id), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Envia template para aprovação da Meta' })
|
||||||
summary: 'Aprova template pelo admin',
|
|
||||||
description: 'Marca um template como aprovado para uso nos fluxos de atendimento.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'ID do template.' })
|
@ApiParam({ name: 'id', description: 'ID do template.' })
|
||||||
@Post('templates/approve-admin/:id')
|
@Post('templates/:id/submit-meta')
|
||||||
async approveTemplateByAdmin(@Param('id') id: string) {
|
async submitTemplateToMeta(@Param('id') id: string) {
|
||||||
return this.whatsappTemplateService.approveTemplateByAdmin(Number(id));
|
return this.whatsappTemplateService.submitToMeta(Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Remove template' })
|
||||||
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.' })
|
@ApiParam({ name: 'id', description: 'ID do template.' })
|
||||||
@Delete('templates/:id')
|
@Delete('templates/:id')
|
||||||
async deleteTemplate(@Param('id') id: string) {
|
async deleteTemplate(@Param('id') id: string) {
|
||||||
return this.whatsappTemplateService.deleteTemplate(Number(id));
|
return this.whatsappTemplateService.deleteTemplate(Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Webhook Meta ----
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Verificação do webhook Meta' })
|
@ApiOperation({ summary: 'Verificação do webhook Meta' })
|
||||||
@Get('webhook')
|
@Get('webhook')
|
||||||
verifyWebhook(
|
async verifyWebhook(
|
||||||
@Query('hub.mode') mode: string,
|
@Query('hub.mode') mode: string,
|
||||||
@Query('hub.verify_token') token: string,
|
@Query('hub.verify_token') token: string,
|
||||||
@Query('hub.challenge') challenge: string,
|
@Query('hub.challenge') challenge: string,
|
||||||
) {
|
) {
|
||||||
if (mode === 'subscribe' && token === process.env.META_WEBHOOK_TOKEN) {
|
const config = await this.configService.getConfig();
|
||||||
|
const expected = config.webhook_token;
|
||||||
|
if (mode === 'subscribe' && expected && token === expected) {
|
||||||
return challenge;
|
return challenge;
|
||||||
}
|
}
|
||||||
throw new ForbiddenException('Token inválido');
|
throw new ForbiddenException('Token inválido');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Recebe eventos do webhook Meta' })
|
@ApiOperation({ summary: 'Recebe eventos do webhook Meta' })
|
||||||
@Post('webhook')
|
@Post('webhook')
|
||||||
async receiveWebhook(@Body() body: any) {
|
async receiveWebhook(@Body() body: any) {
|
||||||
console.log('Webhook Meta:', JSON.stringify(body, null, 2));
|
await this.whatsappService.handleMetaWebhookEvent(body);
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export class WhatsappGateway implements OnGatewayInit, OnGatewayConnection, OnGa
|
|||||||
|
|
||||||
handleConnection(client: Socket) {
|
handleConnection(client: Socket) {
|
||||||
this.logger.log(`Client connected: ${client.id} (${client.data.user?.username || 'usuario desconhecido'})`);
|
this.logger.log(`Client connected: ${client.id} (${client.data.user?.username || 'usuario desconhecido'})`);
|
||||||
|
client.emit('status', 'CONNECTED');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: Socket) {
|
handleDisconnect(client: Socket) {
|
||||||
|
|||||||
@ -2,16 +2,32 @@ import { Module } from '@nestjs/common';
|
|||||||
import { WhatsappService } from './whatsapp.service';
|
import { WhatsappService } from './whatsapp.service';
|
||||||
import { WhatsappGateway } from './whatsapp.gateway';
|
import { WhatsappGateway } from './whatsapp.gateway';
|
||||||
import { WhatsappController } from './whatsapp.controller';
|
import { WhatsappController } from './whatsapp.controller';
|
||||||
|
import { WhatsappAdminController } from './whatsapp-admin.controller';
|
||||||
import { AttendanceModule } from '../attendance/attendance.module';
|
import { AttendanceModule } from '../attendance/attendance.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ContactsModule } from '../contacts/contacts.module';
|
import { ContactsModule } from '../contacts/contacts.module';
|
||||||
import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository';
|
import { WhatsappTemplateRepository } from './repositories/whatsapp-template.repository';
|
||||||
import { WhatsappTemplateService } from './whatsapp-template.service';
|
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({
|
@Module({
|
||||||
imports: [AttendanceModule, AuthModule, ContactsModule],
|
imports: [AttendanceModule, AuthModule, ContactsModule],
|
||||||
providers: [WhatsappService, WhatsappGateway, WhatsappTemplateService, WhatsappTemplateRepository],
|
providers: [
|
||||||
controllers: [WhatsappController],
|
WhatsappService,
|
||||||
|
WhatsappGateway,
|
||||||
|
WhatsappTemplateService,
|
||||||
|
WhatsappTemplateRepository,
|
||||||
|
MensagensRepository,
|
||||||
|
WhatsappConfigRepository,
|
||||||
|
WhatsappConfigService,
|
||||||
|
MediaStorageService,
|
||||||
|
LocalDiskAdapter,
|
||||||
|
],
|
||||||
|
controllers: [WhatsappController, WhatsappAdminController],
|
||||||
exports: [WhatsappService],
|
exports: [WhatsappService],
|
||||||
})
|
})
|
||||||
export class WhatsappModule {}
|
export class WhatsappModule {}
|
||||||
|
|||||||
@ -1,149 +1,30 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, NotImplementedException } from '@nestjs/common';
|
||||||
import { Client, LocalAuth, MessageMedia } from 'whatsapp-web.js';
|
|
||||||
import { WhatsappGateway } from './whatsapp.gateway';
|
import { WhatsappGateway } from './whatsapp.gateway';
|
||||||
import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service';
|
import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service';
|
||||||
import { ContactsService } from '../contacts/contacts.service';
|
import { ContactsService } from '../contacts/contacts.service';
|
||||||
import { WhatsappTemplateService } from './whatsapp-template.service';
|
import { WhatsappTemplateService } from './whatsapp-template.service';
|
||||||
import * as fs from 'fs';
|
import { MensagensRepository } from './repositories/mensagens.repository';
|
||||||
import * as path from 'path';
|
import { WhatsappConfigService } from './whatsapp-config.service';
|
||||||
|
import { MediaStorageService } from './media/media-storage.service';
|
||||||
|
|
||||||
|
const MEDIA_TYPES = ['image', 'audio', 'video', 'document', 'sticker'] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WhatsappService implements OnModuleInit {
|
export class WhatsappService {
|
||||||
private client: Client;
|
|
||||||
private readonly logger = new Logger(WhatsappService.name);
|
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 processedIncomingMessages = new Set<string>();
|
||||||
private readonly incomingRoutingQueues = new Map<string, Promise<void>>();
|
private readonly incomingRoutingQueues = new Map<string, Promise<void>>();
|
||||||
private readonly chatMessagesCache = new Map<string, any[]>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly gateway: WhatsappGateway,
|
private readonly gateway: WhatsappGateway,
|
||||||
private readonly assignmentService: AttendanceAssignmentService,
|
private readonly assignmentService: AttendanceAssignmentService,
|
||||||
private readonly contactsService: ContactsService,
|
private readonly contactsService: ContactsService,
|
||||||
private readonly whatsappTemplateService: WhatsappTemplateService,
|
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) {
|
private async enqueueIncomingRoute(remoteJid: string, msg: any, messageBody: string, messageId: string) {
|
||||||
const previousRoute = this.incomingRoutingQueues.get(remoteJid) || Promise.resolve();
|
const previousRoute = this.incomingRoutingQueues.get(remoteJid) || Promise.resolve();
|
||||||
|
|
||||||
@ -176,213 +57,156 @@ export class WhatsappService implements OnModuleInit {
|
|||||||
await nextRoute;
|
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() {
|
getStatus() {
|
||||||
return this.status;
|
return 'CONNECTED';
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentQr() {
|
|
||||||
return this.currentQr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChats() {
|
async getChats() {
|
||||||
if (this.status !== 'CONNECTED') return [];
|
const rows = await this.mensagensRepository.findChats();
|
||||||
|
return Promise.all(rows.map(async (row: any) => {
|
||||||
let liveChats: any[] = [];
|
const chatId = row.chat_id;
|
||||||
try {
|
const assignment = await this.assignmentService.getAssignment(chatId);
|
||||||
liveChats = await this.retryWhatsappRead(() => this.client.getChats());
|
const contactProfile = await this.getCustomerContact(chatId);
|
||||||
} 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 {
|
return {
|
||||||
...chat,
|
id: { _serialized: chatId, user: chatId, server: 'c.us' },
|
||||||
name: contactProfile?.name || chat.name,
|
name: contactProfile?.name || row.sender_name || chatId,
|
||||||
contactProfile: contactProfile
|
preview: row.preview || '',
|
||||||
? { ...contactProfile, phone }
|
timestamp: Number(row.timestamp),
|
||||||
: {
|
unreadCount: 0,
|
||||||
chat_id: chat.id._serialized,
|
lastMessageFromMe: Boolean(row.last_message_from_me),
|
||||||
phone,
|
contactProfile: contactProfile ?? { chat_id: chatId, phone: chatId, name: null, company: null, note: null },
|
||||||
name: null,
|
assignment: assignment || null,
|
||||||
company: null,
|
|
||||||
note: null,
|
|
||||||
},
|
|
||||||
assignment: assignment || null
|
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveContactPhone(chatId: string) {
|
async getChatMessages(chatId: string) {
|
||||||
try {
|
const rows = await this.mensagensRepository.findByChatId(chatId);
|
||||||
const lidPhone = await this.resolveLidPhone(chatId);
|
return rows.map((row: any) => ({
|
||||||
if (lidPhone) return lidPhone;
|
id: { _serialized: row.wamid, user: row.wamid, server: 'c.us' },
|
||||||
|
body: row.body ?? '',
|
||||||
const contact = await this.retryWhatsappRead(() => this.client.getContactById(chatId));
|
fromMe: Boolean(row.from_me),
|
||||||
const phone =
|
timestamp: Number(row.timestamp),
|
||||||
(contact as any)?.number ||
|
hasMedia: Boolean(row.media_path || row.type !== 'text'),
|
||||||
(contact as any)?.phoneNumber ||
|
media: null,
|
||||||
(contact as any)?.id?.user ||
|
mediaUrl: row.media_path ? this.mediaStorageService.getUrl(row.media_path) : null,
|
||||||
'';
|
mediaMimeType: row.media_mime_type ?? null,
|
||||||
|
chatId: row.chat_id,
|
||||||
if (phone && !String(phone).includes('@') && !chatId.includes(`${phone}@lid`)) {
|
status: row.status,
|
||||||
return String(phone);
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedNumber = await contact.getFormattedNumber().catch(() => '');
|
async getMessageMedia(_chatId: string, _messageId: string) {
|
||||||
return formattedNumber || (chatId.endsWith('@lid') ? '' : chatId.split('@')[0]);
|
throw new NotImplementedException('Busca de mídia não disponível na Meta API ainda.');
|
||||||
} catch {
|
|
||||||
return chatId.endsWith('@lid') ? '' : chatId.split('@')[0];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveLidPhone(chatId: string) {
|
async sendMediaMessage(
|
||||||
if (!chatId.endsWith('@lid')) return '';
|
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 {
|
if (!token || !phoneNumberId) {
|
||||||
const page = (this.client as any).pupPage;
|
throw new Error('Token e Phone Number ID do WhatsApp não configurados. Acesse Admin → Canais e Integração → WhatsApp → Configurar.');
|
||||||
if (!page) return '';
|
|
||||||
|
|
||||||
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),
|
|
||||||
|
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 },
|
||||||
);
|
);
|
||||||
|
|
||||||
return phone ? String(phone).split('@')[0] : '';
|
if (!uploadResponse.ok) {
|
||||||
} catch {
|
const error = await uploadResponse.json().catch(() => ({}));
|
||||||
return '';
|
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) {
|
private async getCustomerContact(chatId: string) {
|
||||||
@ -397,122 +221,66 @@ export class WhatsappService implements OnModuleInit {
|
|||||||
private async getBotMessageVariables(chatId: string, msg: any) {
|
private async getBotMessageVariables(chatId: string, msg: any) {
|
||||||
const contactProfile = await this.getCustomerContact(chatId);
|
const contactProfile = await this.getCustomerContact(chatId);
|
||||||
const notifyName = String(msg?.['_data']?.notifyName || '').trim();
|
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 {
|
return {
|
||||||
nome: contactProfile?.name || fallbackName,
|
nome: contactProfile?.name || notifyName,
|
||||||
telefone: phone,
|
telefone: contactProfile?.phone || chatId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChatMessages(chatId: string) {
|
async sendMessage(to: string, message: string, _media?: { data: string; mimetype: string; filename?: string }, senderName?: string) {
|
||||||
if (this.status !== 'CONNECTED') return [];
|
const { access_token: token, phone_number_id: phoneNumberId, api_version: apiVersion } =
|
||||||
|
await this.configService.getConfig();
|
||||||
|
|
||||||
try {
|
if (!token || !phoneNumberId) {
|
||||||
const messages = await this.retryWhatsappRead(async () => {
|
throw new Error('Token e Phone Number ID do WhatsApp não configurados. Acesse Admin → Canais e Integração → WhatsApp → Configurar.');
|
||||||
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) {
|
const body = senderName ? `*Atendente: ${senderName}*\n\n${message}` : message;
|
||||||
if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado');
|
|
||||||
|
|
||||||
this.logger.log(`Buscando mídia do chat ${chatId}, mensagem ${messageId}`);
|
const response = await fetch(
|
||||||
const messages = await this.retryWhatsappRead(async () => {
|
`https://graph.facebook.com/${apiVersion}/${phoneNumberId}/messages`,
|
||||||
const chat = await this.client.getChatById(chatId);
|
{
|
||||||
return chat.fetchMessages({ limit: 50 });
|
method: 'POST',
|
||||||
});
|
headers: {
|
||||||
const msg = messages.find(m => m.id._serialized === messageId);
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
if (msg && msg.hasMedia) {
|
},
|
||||||
this.logger.log(`Baixando mídia para mensagem ${messageId}...`);
|
body: JSON.stringify({
|
||||||
const media = await msg.downloadMedia();
|
messaging_product: 'whatsapp',
|
||||||
if (media) {
|
recipient_type: 'individual',
|
||||||
return {
|
to,
|
||||||
mimetype: media.mimetype,
|
type: 'text',
|
||||||
data: media.data,
|
text: { preview_url: false, body },
|
||||||
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')
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getErrorMessage(err: unknown) {
|
const result = await response.json() as any;
|
||||||
if (err instanceof Error) return err.message;
|
const wamid = result?.messages?.[0]?.id;
|
||||||
return String(err || '');
|
this.logger.log(`[Meta] Mensagem enviada para ${to}: ${wamid}`);
|
||||||
}
|
|
||||||
|
|
||||||
async sendMessage(to: string, message: string, media?: { data: string; mimetype: string; filename?: string }, senderName?: string) {
|
if (wamid) {
|
||||||
if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado');
|
await this.mensagensRepository.save({
|
||||||
const canSendMessage = await this.assignmentService.canSendAgentMessage(to);
|
wamid,
|
||||||
if (!canSendMessage) {
|
chatId: to,
|
||||||
throw new Error('Aguarde o cliente responder antes de enviar novas mensagens.');
|
fromMe: true,
|
||||||
}
|
type: 'text',
|
||||||
|
body: body,
|
||||||
const outboundMessage = this.formatOutboundMessage(message, senderName);
|
senderName: senderName ?? 'Sistema',
|
||||||
|
status: 'sent',
|
||||||
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),
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
unreadCount: 0,
|
|
||||||
lastMessageFromMe: true
|
|
||||||
});
|
});
|
||||||
await this.assignmentService.clearTransferNote(to);
|
}
|
||||||
|
|
||||||
return sentMsg;
|
await this.assignmentService.clearTransferNote(to);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async startAttendance(
|
async startAttendance(
|
||||||
@ -523,33 +291,92 @@ export class WhatsappService implements OnModuleInit {
|
|||||||
variables?: Record<string, string | null | undefined>,
|
variables?: Record<string, string | null | undefined>,
|
||||||
) {
|
) {
|
||||||
const template = await this.whatsappTemplateService.getTemplateById(templateId);
|
const template = await this.whatsappTemplateService.getTemplateById(templateId);
|
||||||
if (!template) {
|
if (!template) throw new Error('Template de WhatsApp não encontrado');
|
||||||
throw new Error('Template de WhatsApp nao encontrado');
|
|
||||||
|
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 renderedContent = this.renderTemplateContent(template.content, variables);
|
const assignment = await this.assignmentService.assignChat(to, userId, areaId || null);
|
||||||
const sentMessage = await this.sendMessage(to, renderedContent);
|
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to);
|
||||||
const chatId = this.getSentMessageChatId(sentMessage, to);
|
|
||||||
const assignment = await this.assignmentService.assignChat(chatId, userId, areaId || null);
|
|
||||||
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(chatId);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chatId,
|
chatId: to,
|
||||||
template: { ...template, content: renderedContent },
|
template,
|
||||||
messageId: sentMessage?.id?._serialized || null,
|
messageId: null,
|
||||||
assignment: lockedAssignment || assignment,
|
assignment: lockedAssignment || assignment,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSentMessageChatId(sentMessage: any, fallbackChatId: string) {
|
private async sendTemplateMessage(
|
||||||
const remote =
|
to: string,
|
||||||
sentMessage?.id?.remote ||
|
template: any,
|
||||||
sentMessage?._data?.id?.remote ||
|
variables?: Record<string, string | null | undefined>,
|
||||||
sentMessage?.to ||
|
) {
|
||||||
sentMessage?.from ||
|
const { access_token: token, phone_number_id: phoneNumberId, api_version: apiVersion } =
|
||||||
fallbackChatId;
|
await this.configService.getConfig();
|
||||||
|
|
||||||
return String(remote || 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderTemplateContent(content: string, variables?: Record<string, string | null | undefined>) {
|
private renderTemplateContent(content: string, variables?: Record<string, string | null | undefined>) {
|
||||||
@ -563,19 +390,152 @@ export class WhatsappService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatOutboundMessage(message: string, senderName?: string) {
|
|
||||||
const cleanMessage = (message || '').trim();
|
|
||||||
const cleanSenderName = (senderName || '').trim();
|
|
||||||
|
|
||||||
if (!cleanSenderName) {
|
async handleMetaWebhookEvent(body: any): Promise<void> {
|
||||||
return cleanMessage;
|
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 (!cleanMessage) {
|
if (change.field !== 'messages') continue;
|
||||||
return '';
|
|
||||||
|
if (value?.messages?.length) {
|
||||||
|
for (const message of value.messages) {
|
||||||
|
await this.handleMetaIncomingMessage(message, value.contacts ?? []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `*Atendente: ${cleanSenderName}*\n\n${cleanMessage}`;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user