good forst commit
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user