good forst commit

This commit is contained in:
2026-06-09 02:02:40 +05:30
parent 801c1d7121
commit 249d759e6a
215 changed files with 15425 additions and 1240 deletions
+304
View File
@@ -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 };
}
}