305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
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<BotInitiateResponse> {
|
|
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<string>('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<BotQrResponse> {
|
|
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<BotSummary | null> {
|
|
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<BotRevealResponse> {
|
|
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<any[]> {
|
|
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<string>('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<any> {
|
|
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<any> {
|
|
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 };
|
|
}
|
|
}
|