import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { randomUUID } from 'crypto'; import { PrismaService } from '../../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AuditAction } from '../audit/audit.types'; import * as QRCode from 'qrcode'; import type { BotInitiateResponse, BotQrResponse, BotRevealResponse, BotStatus, BotSummary } from '@tower/types'; const PAIRING_TTL_MS = 5 * 60 * 1000; @Injectable() export class BotService { private readonly logger = new Logger(BotService.name); constructor( private readonly prisma: PrismaService, private readonly config: ConfigService, private readonly audit: AuditService, ) {} async initiate(tenantId: string, adminId: string, displayName?: string): Promise { const existing = await this.prisma.account.findFirst({ where: { isBot: true, status: { in: ['PAIRING', 'ACTIVE', 'DISCONNECTED'] } }, }); if (existing) { throw new ConflictException('A bot is already configured. Remove it before pairing a new one.'); } const sessionBase = this.config.get('WHATSAPP_SESSION_PATH', './sessions'); const uid = randomUUID(); const pairingToken = randomUUID(); const expiresAt = new Date(Date.now() + PAIRING_TTL_MS); const account = await this.prisma.account.create({ data: { platform: 'whatsapp', jid: `pending_${uid}@placeholder`, sessionPath: `${sessionBase}/${uid}`, displayName: displayName ?? null, status: 'PAIRING', isBot: true, pairingToken, pairingExpiresAt: expiresAt, }, }); await this.prisma.tenantBot.create({ data: { tenantId, accountId: account.id, isActive: true }, }); await this.audit.log({ tenantId, actorId: adminId, action: AuditAction.BOT_INITIATED, resourceType: 'Account', resourceId: account.id, payload: { displayName: account.displayName }, }); return { pairingToken, expiresAt: expiresAt.toISOString(), qrDataUrl: null, }; } async getQr(tenantId: string, pairingToken: string): Promise { const account = await this.prisma.account.findFirst({ where: { pairingToken, tenants: { some: { tenantId } } }, }); if (!account) { throw new NotFoundException('Pairing token not found for this tenant'); } if (account.pairingExpiresAt && account.pairingExpiresAt < new Date()) { return { status: account.status as BotStatus, qrDataUrl: null, pairingToken: account.pairingToken ?? '', expiresAt: account.pairingExpiresAt.toISOString(), }; } if (!account.qrCode) { return { status: account.status as BotStatus, qrDataUrl: null, pairingToken: account.pairingToken ?? '', expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString(), }; } const qrDataUrl = await QRCode.toDataURL(account.qrCode); return { status: account.status as BotStatus, qrDataUrl, pairingToken: account.pairingToken ?? '', expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString(), }; } async get(tenantId: string): Promise<{ bot: BotSummary | null; shared: boolean; sharedBotId?: string }> { const own = await this.prisma.account.findFirst({ where: { tenants: { some: { tenantId } } }, orderBy: { createdAt: 'asc' }, }); if (own) { return { bot: this.toSummary(own), shared: false }; } // No own bot — is there a shared bot we could attach to? const shared = await this.prisma.account.findFirst({ where: { isBot: true, status: { in: ['ACTIVE', 'DISCONNECTED', 'PAIRING'] }, tenants: { some: {} }, }, orderBy: { createdAt: 'asc' }, }); if (shared) { return { bot: null, shared: true, sharedBotId: shared.id }; } return { bot: null, shared: false }; } async attach(tenantId: string, adminId: string, accountId: string): Promise<{ bot: BotSummary }> { const account = await this.prisma.account.findUnique({ where: { id: accountId } }); if (!account || !account.isBot) throw new NotFoundException('Bot not found'); if (account.status === 'BANNED') { throw new ConflictException('Bot is banned and cannot be shared'); } const existing = await this.prisma.tenantBot.findUnique({ where: { tenantId_accountId: { tenantId, accountId } }, }); if (!existing) { await this.prisma.tenantBot.create({ data: { tenantId, accountId, isActive: true }, }); await this.audit.log({ tenantId, actorId: adminId, action: AuditAction.BOT_ACCESS_GRANTED, resourceType: 'Account', resourceId: accountId, payload: { reason: 'tenant attached to shared bot' }, }); } return { bot: this.toSummary(account) }; } private toSummary(account: any): BotSummary { return { id: account.id, platform: account.platform, jid: account.status === 'ACTIVE' ? account.jid : null, displayName: account.displayName, status: account.status as BotStatus, isBot: account.isBot, createdAt: account.createdAt.toISOString(), updatedAt: account.updatedAt.toISOString(), }; } /** * Find the least-loaded ACTIVE bot and assign it to the tenant. * Returns null if no bot is available in the pool. * Idempotent — skips if the tenant already has a TenantBot. */ async assignBotToTenant(tenantId: string): Promise { const existing = await this.prisma.tenantBot.findFirst({ where: { tenantId }, include: { account: true }, }); if (existing) { return this.toSummary(existing.account); } const candidates = await this.prisma.account.findMany({ where: { isBot: true, status: 'ACTIVE' }, include: { _count: { select: { tenants: true } } }, }); if (candidates.length === 0) { this.logger.warn({ tenantId }, 'No ACTIVE bot available to assign'); return null; } const best = candidates.reduce((a, b) => a._count.tenants <= b._count.tenants ? a : b, ); await this.prisma.tenantBot.create({ data: { tenantId, accountId: best.id, isActive: true }, }); this.logger.log({ tenantId, accountId: best.id, tenantCount: best._count.tenants }, 'Bot auto-assigned'); return this.toSummary(best); } async reveal(tenantId: string, adminId: string): Promise { const account = await this.prisma.account.findFirst({ where: { tenants: { some: { tenantId } } }, }); if (!account || account.status !== 'ACTIVE') { throw new NotFoundException('No active bot to reveal'); } await this.audit.log({ tenantId, actorId: adminId, action: AuditAction.BOT_REVEALED, resourceType: 'Account', resourceId: account.id, payload: { jid: account.jid }, }); return { jid: account.jid, revealedAt: new Date().toISOString() }; } // --------------------------------------------------------------------------- // Super admin bot management // --------------------------------------------------------------------------- async listAll(): Promise { const bots = await this.prisma.account.findMany({ where: { isBot: true }, orderBy: { createdAt: 'desc' }, include: { _count: { select: { tenants: true } } }, }); return bots.map((b) => ({ id: b.id, jid: b.status === 'ACTIVE' ? b.jid : null, displayName: b.displayName, status: b.status, platform: b.platform, tenantCount: b._count.tenants, createdAt: b.createdAt.toISOString(), updatedAt: b.updatedAt.toISOString(), })); } async superInitiate(displayName?: string): Promise<{ pairingToken: string; expiresAt: string }> { const sessionBase = this.config.get('WHATSAPP_SESSION_PATH', './sessions'); const uid = randomUUID(); const pairingToken = randomUUID(); const expiresAt = new Date(Date.now() + PAIRING_TTL_MS); await this.prisma.account.create({ data: { platform: 'whatsapp', jid: `pending_${uid}@placeholder`, sessionPath: `${sessionBase}/${uid}`, displayName: displayName ?? null, status: 'PAIRING', isBot: true, pairingToken, pairingExpiresAt: expiresAt, }, }); return { pairingToken, expiresAt: expiresAt.toISOString() }; } async superGetQr(pairingToken: string): Promise { const account = await this.prisma.account.findFirst({ where: { pairingToken }, }); if (!account) throw new NotFoundException('Pairing token not found'); if (account.pairingExpiresAt && account.pairingExpiresAt < new Date()) { return { status: account.status, qrDataUrl: null, pairingToken, expiresAt: account.pairingExpiresAt.toISOString() }; } if (!account.qrCode) { return { status: account.status, qrDataUrl: null, pairingToken, expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString() }; } const qrDataUrl = await QRCode.toDataURL(account.qrCode); return { status: account.status, qrDataUrl, pairingToken, expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString() }; } async assignTenant(tenantId: string, accountId: string): Promise { const account = await this.prisma.account.findUnique({ where: { id: accountId } }); if (!account || !account.isBot) throw new NotFoundException('Bot not found'); if (account.status !== 'ACTIVE') throw new ConflictException('Bot is not ACTIVE'); const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant) throw new NotFoundException('Tenant not found'); const existing = await this.prisma.tenantBot.findFirst({ where: { tenantId } }); if (existing) throw new ConflictException('Tenant already has a bot assigned'); await this.prisma.tenantBot.create({ data: { tenantId, accountId: account.id, isActive: true }, }); return { ok: true, accountId: account.id, jid: account.jid }; } async superRemove(accountId: string): Promise<{ ok: true }> { const account = await this.prisma.account.findUnique({ where: { id: accountId }, include: { _count: { select: { tenants: true } } }, }); if (!account || !account.isBot) throw new NotFoundException('Bot not found'); if (account._count.tenants > 0) { throw new ConflictException(`Cannot remove bot — ${account._count.tenants} tenant(s) still assigned. Reassign them first.`); } await this.prisma.account.delete({ where: { id: accountId } }); return { ok: true }; } }