228 lines
8.0 KiB
TypeScript
228 lines
8.0 KiB
TypeScript
/**
|
|
* SchedulerService — executa agendamentos de mensagens ricos.
|
|
*
|
|
* Tipos suportados:
|
|
* ONCE — executa uma vez em scheduledAt
|
|
* RECURRING — cron expression (cronExpr)
|
|
* EVENT — dispara por evento (BIRTHDAY, ANNIVERSARY, SIGNUP_DAYS)
|
|
* verificado diariamente às 08:00 contra dados dos contatos
|
|
*
|
|
* O serviço verifica a cada minuto agendamentos ONCE/RECURRING vencidos.
|
|
* Eventos são checados uma vez por dia via job separado.
|
|
*
|
|
* Para enviar, chama o socket Baileys diretamente via WhatsAppConnectionManager
|
|
* (sem passar pelo HTTP — evita latência e auth overhead).
|
|
*/
|
|
import { schedule as cronSchedule } from 'node-cron'
|
|
import { Cron } from 'croner'
|
|
import { prisma } from '../../infra/database/prisma'
|
|
import { logger } from '../../config/logger'
|
|
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
|
|
|
|
type Recipient = { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
|
|
|
|
export class SchedulerService {
|
|
private manager: WhatsAppConnectionManager
|
|
private minuteJob: ReturnType<typeof cronSchedule> | null = null
|
|
private eventJob: ReturnType<typeof cronSchedule> | null = null
|
|
|
|
constructor(manager: WhatsAppConnectionManager) {
|
|
this.manager = manager
|
|
}
|
|
|
|
start() {
|
|
// Tick a cada minuto — processa ONCE e RECURRING vencidos
|
|
this.minuteJob = cronSchedule('* * * * *', () => {
|
|
this.processTimeBased().catch((err) =>
|
|
logger.error({ err }, '[Scheduler] Erro no tick de minuto')
|
|
)
|
|
})
|
|
|
|
// Tick diário às 08:00 — processa agendamentos por EVENTO
|
|
this.eventJob = cronSchedule('0 8 * * *', () => {
|
|
this.processEventBased().catch((err) =>
|
|
logger.error({ err }, '[Scheduler] Erro no tick de evento')
|
|
)
|
|
})
|
|
|
|
logger.info('[Scheduler] Iniciado (minuto + evento diário)')
|
|
}
|
|
|
|
stop() {
|
|
this.minuteJob?.stop()
|
|
this.eventJob?.stop()
|
|
}
|
|
|
|
// ── Processa ONCE e RECURRING ──────────────────────────────────────────────
|
|
|
|
private async processTimeBased() {
|
|
const now = new Date()
|
|
|
|
const schedules = await prisma.scheduledMessage.findMany({
|
|
where: {
|
|
status: 'ACTIVE',
|
|
scheduleType: { in: ['ONCE', 'RECURRING'] },
|
|
OR: [
|
|
{ nextRunAt: { lte: now } },
|
|
{ nextRunAt: null, scheduledAt: { lte: now }, scheduleType: 'ONCE' },
|
|
],
|
|
},
|
|
})
|
|
|
|
for (const sched of schedules) {
|
|
await this.executeSchedule(sched)
|
|
}
|
|
}
|
|
|
|
// ── Processa EVENT (birthday, anniversary, signup) ─────────────────────────
|
|
|
|
private async processEventBased() {
|
|
const today = new Date()
|
|
const mm = String(today.getMonth() + 1).padStart(2, '0')
|
|
const dd = String(today.getDate()).padStart(2, '0')
|
|
const todayMmDd = `${mm}-${dd}`
|
|
|
|
const schedules = await prisma.scheduledMessage.findMany({
|
|
where: { status: 'ACTIVE', scheduleType: 'EVENT' },
|
|
})
|
|
|
|
for (const sched of schedules) {
|
|
const recipients = sched.recipients as Recipient[]
|
|
const matched: Recipient[] = []
|
|
|
|
for (const r of recipients) {
|
|
if (!r.jid) continue
|
|
|
|
if (sched.eventType === 'BIRTHDAY' && r.birthday) {
|
|
// birthday format: "YYYY-MM-DD" or "MM-DD"
|
|
const parts = r.birthday.split('-')
|
|
const bMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.birthday
|
|
if (bMmDd === todayMmDd) matched.push(r)
|
|
} else if (sched.eventType === 'ANNIVERSARY' && r.anniversary) {
|
|
const parts = r.anniversary.split('-')
|
|
const aMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.anniversary
|
|
if (aMmDd === todayMmDd) matched.push(r)
|
|
} else if (sched.eventType === 'SIGNUP_DAYS' && r.signupDate) {
|
|
// Dispara N dias após o signup — N fica em cronExpr como "30"
|
|
const daysAfter = parseInt(sched.cronExpr ?? '0', 10)
|
|
const signup = new Date(r.signupDate)
|
|
const diffDays = Math.floor((today.getTime() - signup.getTime()) / 86_400_000)
|
|
if (diffDays === daysAfter) matched.push(r)
|
|
}
|
|
}
|
|
|
|
if (matched.length > 0) {
|
|
await this.executeSchedule(sched, matched)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Executa um agendamento ─────────────────────────────────────────────────
|
|
|
|
private async executeSchedule(sched: any, overrideRecipients?: Recipient[]) {
|
|
const recipients: Recipient[] = overrideRecipients ?? (sched.recipients as Recipient[])
|
|
const sock = this.manager.getSocket(sched.instanceId)
|
|
|
|
if (!sock) {
|
|
logger.warn({ scheduleId: sched.id }, '[Scheduler] Socket não disponível — adiando')
|
|
return
|
|
}
|
|
|
|
let sentCount = 0
|
|
let failedCount = 0
|
|
const errors: string[] = []
|
|
|
|
for (const recipient of recipients) {
|
|
if (!recipient.jid) continue
|
|
try {
|
|
const payload = this.buildPayload(sched.payload as any, recipient)
|
|
await sock.sendMessage(recipient.jid, payload)
|
|
sentCount++
|
|
// Pequeno delay anti-ban
|
|
await new Promise((r) => setTimeout(r, 800 + Math.random() * 400))
|
|
} catch (err: any) {
|
|
failedCount++
|
|
errors.push(`${recipient.jid}: ${err?.message ?? err}`)
|
|
logger.warn({ err, jid: recipient.jid, scheduleId: sched.id }, '[Scheduler] Falha ao enviar')
|
|
}
|
|
}
|
|
|
|
// Persiste log
|
|
await prisma.scheduledMessageLog.create({
|
|
data: {
|
|
scheduleId: sched.id,
|
|
status: failedCount === 0 ? 'SUCCESS' : sentCount === 0 ? 'FAILED' : 'PARTIAL',
|
|
sentCount,
|
|
failedCount,
|
|
error: errors.length ? errors.join('\n') : null,
|
|
},
|
|
})
|
|
|
|
// Calcula próximo nextRunAt ou marca como DONE
|
|
if (sched.scheduleType === 'ONCE') {
|
|
await prisma.scheduledMessage.update({
|
|
where: { id: sched.id },
|
|
data: {
|
|
status: 'DONE',
|
|
lastRunAt: new Date(),
|
|
runCount: { increment: 1 },
|
|
nextRunAt: null,
|
|
},
|
|
})
|
|
} else if (sched.scheduleType === 'RECURRING' && sched.cronExpr) {
|
|
const next = this.nextCronDate(sched.cronExpr)
|
|
await prisma.scheduledMessage.update({
|
|
where: { id: sched.id },
|
|
data: {
|
|
lastRunAt: new Date(),
|
|
nextRunAt: next,
|
|
runCount: { increment: 1 },
|
|
},
|
|
})
|
|
} else {
|
|
// EVENT — apenas registra execução
|
|
await prisma.scheduledMessage.update({
|
|
where: { id: sched.id },
|
|
data: { lastRunAt: new Date(), runCount: { increment: 1 } },
|
|
})
|
|
}
|
|
|
|
logger.info({ scheduleId: sched.id, sentCount, failedCount }, '[Scheduler] Agendamento executado')
|
|
}
|
|
|
|
// ── Monta payload Baileys com substituição de variáveis ────────────────────
|
|
// Variáveis: {{name}}, {{birthday}}, etc.
|
|
|
|
private buildPayload(raw: any, recipient: Recipient): any {
|
|
const replace = (str: string) =>
|
|
str
|
|
.replace(/\{\{name\}\}/gi, recipient.name ?? '')
|
|
.replace(/\{\{birthday\}\}/gi, recipient.birthday ?? '')
|
|
.replace(/\{\{anniversary\}\}/gi, recipient.anniversary ?? '')
|
|
|
|
const recurse = (node: any): any => {
|
|
if (typeof node === 'string') return replace(node)
|
|
if (Array.isArray(node)) return node.map(recurse)
|
|
if (node && typeof node === 'object') {
|
|
const out: any = {}
|
|
for (const k of Object.keys(node)) out[k] = recurse(node[k])
|
|
return out
|
|
}
|
|
return node
|
|
}
|
|
|
|
return recurse(raw)
|
|
}
|
|
|
|
// ── Calcula próxima data de um cron ───────────────────────────────────────
|
|
|
|
private nextCronDate(expr: string): Date | null {
|
|
try {
|
|
const job = new Cron(expr, { paused: true })
|
|
return job.nextRun() ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
}
|