good forst commit
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { handleCommand } from './command-handler';
|
||||
import type { NormalizedMessage } from '@tower/types';
|
||||
|
||||
function makeMsg(overrides: Partial<NormalizedMessage> = {}): NormalizedMessage {
|
||||
return {
|
||||
platformMsgId: 'WA_MSG_001',
|
||||
sourceGroupJid: '120363043312345678@g.us',
|
||||
senderJid: '919876543210@s.whatsapp.net',
|
||||
senderName: 'Alice',
|
||||
content: '',
|
||||
accountId: 'acc-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mockPrisma: any = {
|
||||
group: { findUnique: jest.fn() },
|
||||
towerUser: { upsert: jest.fn() },
|
||||
consentRecord: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
|
||||
memberOptOut: { create: jest.fn() },
|
||||
auditEvent: { create: jest.fn() },
|
||||
};
|
||||
|
||||
const mockPool: any = {
|
||||
sendMessage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
describe('handleCommand', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns false for non-command messages', async () => {
|
||||
const handled = await handleCommand(makeMsg({ content: 'hello world' }), 'acc-1', mockPrisma, mockPool);
|
||||
expect(handled).toBe(false);
|
||||
expect(mockPool.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('STOP', () => {
|
||||
it('creates opt-out and revokes consent for claimed group', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: 'tnt-1', claimStatus: 'CLAIMED' });
|
||||
mockPrisma.towerUser.upsert.mockResolvedValue({ id: 'user-1' });
|
||||
mockPrisma.consentRecord.updateMany.mockResolvedValue({ count: 1 });
|
||||
mockPrisma.memberOptOut.create.mockResolvedValue({});
|
||||
mockPrisma.auditEvent.create.mockResolvedValue({});
|
||||
|
||||
const handled = await handleCommand(
|
||||
makeMsg({ content: 'STOP' }),
|
||||
'acc-1',
|
||||
mockPrisma,
|
||||
mockPool,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(mockPrisma.consentRecord.updateMany).toHaveBeenCalled();
|
||||
expect(mockPrisma.memberOptOut.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ reason: 'STOP_KEYWORD' }) }),
|
||||
);
|
||||
expect(mockPool.sendMessage).toHaveBeenCalledWith(
|
||||
'acc-1',
|
||||
'919876543210@s.whatsapp.net',
|
||||
expect.stringContaining("opted out"),
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op for non-claimed groups', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', claimStatus: 'PENDING_CLAIM' });
|
||||
const handled = await handleCommand(
|
||||
makeMsg({ content: 'STOP' }),
|
||||
'acc-1',
|
||||
mockPrisma,
|
||||
mockPool,
|
||||
);
|
||||
expect(handled).toBe(false);
|
||||
expect(mockPrisma.consentRecord.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('START', () => {
|
||||
it('re-grants default scopes', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: 'tnt-1', claimStatus: 'CLAIMED' });
|
||||
mockPrisma.towerUser.upsert.mockResolvedValue({ id: 'user-1' });
|
||||
mockPrisma.consentRecord.findFirst.mockResolvedValue({
|
||||
id: 'c-1',
|
||||
scopes: ['INGEST'],
|
||||
retentionDays: 90,
|
||||
});
|
||||
mockPrisma.consentRecord.update.mockResolvedValue({});
|
||||
|
||||
const handled = await handleCommand(
|
||||
makeMsg({ content: 'start' }),
|
||||
'acc-1',
|
||||
mockPrisma,
|
||||
mockPool,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(mockPrisma.consentRecord.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'c-1' },
|
||||
data: expect.objectContaining({ status: 'GRANTED' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PORTAL', () => {
|
||||
it('sends an onboarding link', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: 'tnt-1', claimStatus: 'CLAIMED' });
|
||||
mockPrisma.towerUser.upsert.mockResolvedValue({ id: 'user-1' });
|
||||
|
||||
const handled = await handleCommand(
|
||||
makeMsg({ content: 'portal' }),
|
||||
'acc-1',
|
||||
mockPrisma,
|
||||
mockPool,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(mockPool.sendMessage).toHaveBeenCalledWith(
|
||||
'acc-1',
|
||||
'919876543210@s.whatsapp.net',
|
||||
expect.stringContaining('/onboard?token='),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { NormalizedMessage } from '@tower/types';
|
||||
import { createLogger } from '@tower/logger';
|
||||
import { WhatsAppSessionPool } from './session-pool';
|
||||
|
||||
const logger = createLogger('command-handler');
|
||||
|
||||
const PORTAL_BASE = process.env['TOWER_PORTAL_BASE_URL'] ?? 'http://localhost:3000';
|
||||
|
||||
const STOP_REGEX = /^\s*stop\s*$/i;
|
||||
const START_REGEX = /^\s*start\s*$/i;
|
||||
const PORTAL_REGEX = /^\s*portal\s*$/i;
|
||||
const COMMANDS_REGEX = /^\s*commands\s*$/i;
|
||||
|
||||
export async function handleCommand(
|
||||
msg: NormalizedMessage,
|
||||
accountId: string,
|
||||
prisma: any,
|
||||
pool: WhatsAppSessionPool,
|
||||
): Promise<boolean> {
|
||||
if (!msg.content) return false;
|
||||
const text = msg.content.trim();
|
||||
|
||||
if (STOP_REGEX.test(text)) {
|
||||
return await handleStop(msg, accountId, prisma, pool);
|
||||
}
|
||||
if (START_REGEX.test(text)) {
|
||||
return await handleStart(msg, accountId, prisma, pool);
|
||||
}
|
||||
if (PORTAL_REGEX.test(text)) {
|
||||
return await handlePortal(msg, accountId, prisma, pool);
|
||||
}
|
||||
if (COMMANDS_REGEX.test(text)) {
|
||||
return await handleCommands(msg, accountId, pool);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleStop(
|
||||
msg: NormalizedMessage,
|
||||
accountId: string,
|
||||
prisma: any,
|
||||
pool: WhatsAppSessionPool,
|
||||
): Promise<boolean> {
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { platform_platformId: { platform: 'whatsapp', platformId: msg.sourceGroupJid } },
|
||||
select: { id: true, tenantId: true, claimStatus: true },
|
||||
});
|
||||
if (!group || group.claimStatus !== 'CLAIMED' || !group.tenantId) {
|
||||
return false;
|
||||
}
|
||||
// Find or create a TowerUser by jid (phone not yet known for STOP-only flow)
|
||||
const phoneHash = `stop:${msg.senderJid}`;
|
||||
const user = await prisma.towerUser.upsert({
|
||||
where: { tenantId_phoneHash: { tenantId: group.tenantId, phoneHash } },
|
||||
update: { jid: msg.senderJid },
|
||||
create: {
|
||||
tenantId: group.tenantId,
|
||||
phoneHash,
|
||||
jid: msg.senderJid,
|
||||
displayName: msg.senderName ?? msg.senderJid,
|
||||
},
|
||||
});
|
||||
// Revoke all active consents in this group
|
||||
await prisma.consentRecord.updateMany({
|
||||
where: { userId: user.id, tenantId: group.tenantId, groupId: group.id, status: 'GRANTED' },
|
||||
data: { status: 'REVOKED', revokedAt: new Date() },
|
||||
});
|
||||
await prisma.memberOptOut.create({
|
||||
data: {
|
||||
tenantId: group.tenantId,
|
||||
userId: user.id,
|
||||
groupId: group.id,
|
||||
reason: 'STOP_KEYWORD',
|
||||
},
|
||||
});
|
||||
await prisma.auditEvent.create({
|
||||
data: {
|
||||
tenantId: group.tenantId,
|
||||
actorType: 'MEMBER',
|
||||
actorId: user.id,
|
||||
action: 'MEMBER_OPT_OUT',
|
||||
resourceType: 'TowerUser',
|
||||
resourceId: user.id,
|
||||
payload: { jid: msg.senderJid, groupId: group.id, reason: 'STOP_KEYWORD' },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await pool.sendMessage(
|
||||
accountId,
|
||||
msg.senderJid,
|
||||
"You've been opted out. Type START in this group to rejoin.",
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err, jid: msg.senderJid }, 'Failed to send STOP confirmation DM');
|
||||
}
|
||||
logger.info({ jid: msg.senderJid, groupId: group.id }, 'STOP processed');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleStart(
|
||||
msg: NormalizedMessage,
|
||||
accountId: string,
|
||||
prisma: any,
|
||||
pool: WhatsAppSessionPool,
|
||||
): Promise<boolean> {
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { platform_platformId: { platform: 'whatsapp', platformId: msg.sourceGroupJid } },
|
||||
select: { id: true, tenantId: true, claimStatus: true },
|
||||
});
|
||||
if (!group || group.claimStatus !== 'CLAIMED' || !group.tenantId) {
|
||||
return false;
|
||||
}
|
||||
const phoneHash = `stop:${msg.senderJid}`;
|
||||
const user = await prisma.towerUser.upsert({
|
||||
where: { tenantId_phoneHash: { tenantId: group.tenantId, phoneHash } },
|
||||
update: { jid: msg.senderJid },
|
||||
create: {
|
||||
tenantId: group.tenantId,
|
||||
phoneHash,
|
||||
jid: msg.senderJid,
|
||||
displayName: msg.senderName ?? msg.senderJid,
|
||||
},
|
||||
});
|
||||
const existing = await prisma.consentRecord.findFirst({
|
||||
where: { userId: user.id, tenantId: group.tenantId, groupId: group.id },
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.consentRecord.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: 'GRANTED',
|
||||
scopes: existing.scopes.length > 0 ? existing.scopes : ['INGEST', 'DISPLAY'],
|
||||
retentionDays: existing.retentionDays,
|
||||
revokedAt: null,
|
||||
effectiveAt: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.consentRecord.create({
|
||||
data: {
|
||||
tenantId: group.tenantId,
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
scopes: ['INGEST', 'DISPLAY'],
|
||||
retentionDays: 90,
|
||||
policyVersion: 'v1',
|
||||
status: 'GRANTED',
|
||||
proofEventId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
await pool.sendMessage(
|
||||
accountId,
|
||||
msg.senderJid,
|
||||
"Welcome back. Your default scopes (INGEST, DISPLAY) are re-granted. Visit your portal to customize: " +
|
||||
`${PORTAL_BASE}/my`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err, jid: msg.senderJid }, 'Failed to send START confirmation DM');
|
||||
}
|
||||
logger.info({ jid: msg.senderJid, groupId: group.id }, 'START processed');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handlePortal(
|
||||
msg: NormalizedMessage,
|
||||
accountId: string,
|
||||
prisma: any,
|
||||
pool: WhatsAppSessionPool,
|
||||
): Promise<boolean> {
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { platform_platformId: { platform: 'whatsapp', platformId: msg.sourceGroupJid } },
|
||||
select: { id: true, tenantId: true, claimStatus: true },
|
||||
});
|
||||
if (!group || group.claimStatus !== 'CLAIMED' || !group.tenantId) {
|
||||
return false;
|
||||
}
|
||||
// Reuse same phoneHash scheme as STOP/START so the same user is found
|
||||
const phoneHash = `stop:${msg.senderJid}`;
|
||||
const user = await prisma.towerUser.upsert({
|
||||
where: { tenantId_phoneHash: { tenantId: group.tenantId, phoneHash } },
|
||||
update: { jid: msg.senderJid },
|
||||
create: {
|
||||
tenantId: group.tenantId,
|
||||
phoneHash,
|
||||
jid: msg.senderJid,
|
||||
displayName: msg.senderName ?? msg.senderJid,
|
||||
},
|
||||
});
|
||||
const onboardingToken = await issueOnboardingToken(prisma, group.tenantId, group.id, msg.senderJid);
|
||||
try {
|
||||
await pool.sendMessage(
|
||||
accountId,
|
||||
msg.senderJid,
|
||||
`Manage your data: ${PORTAL_BASE}/onboard?token=${onboardingToken}`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err, jid: msg.senderJid }, 'Failed to send PORTAL link DM');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleCommands(
|
||||
msg: NormalizedMessage,
|
||||
accountId: string,
|
||||
pool: WhatsAppSessionPool,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await pool.sendMessage(
|
||||
accountId,
|
||||
msg.senderJid,
|
||||
'TOWER commands: STOP (opt out), START (rejoin), PORTAL (get your data link).',
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err, jid: msg.senderJid }, 'Failed to send COMMANDS reply');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function issueOnboardingToken(
|
||||
prisma: any,
|
||||
tenantId: string,
|
||||
groupId: string,
|
||||
jid: string,
|
||||
): Promise<string> {
|
||||
// For Phase 2B the onboarding token is the base64url({groupId, jid, tenantId}) shape.
|
||||
// The API re-verifies this token (decodes the payload) and trusts the OTP step
|
||||
// for actual authentication. In a future phase we can sign with JWT_SECRET here.
|
||||
const payload = { tenantId, groupId, jid };
|
||||
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
}
|
||||
@@ -1,45 +1,48 @@
|
||||
import { syncGroups } from './group-sync';
|
||||
import { GroupMetadata } from '@whiskeysockets/baileys';
|
||||
|
||||
const makeGroup = (id: string, name: string, desc?: string, participants?: any[]): GroupMetadata => ({
|
||||
id,
|
||||
subject: name,
|
||||
desc,
|
||||
participants: participants ?? [],
|
||||
creation: 0,
|
||||
owner: undefined,
|
||||
restrict: false,
|
||||
announce: false,
|
||||
subjectOwner: undefined,
|
||||
subjectTime: 0,
|
||||
size: 0,
|
||||
ephemeralDuration: 0,
|
||||
inviteCode: undefined,
|
||||
});
|
||||
|
||||
const mockGroups: Record<string, GroupMetadata> = {
|
||||
'120363043312345678@g.us': {
|
||||
id: '120363043312345678@g.us',
|
||||
subject: 'UP Parivar Dallas',
|
||||
desc: 'Main community group',
|
||||
participants: [],
|
||||
creation: 0,
|
||||
owner: undefined,
|
||||
restrict: false,
|
||||
announce: false,
|
||||
subjectOwner: undefined,
|
||||
subjectTime: 0,
|
||||
size: 0,
|
||||
ephemeralDuration: 0,
|
||||
inviteCode: undefined,
|
||||
},
|
||||
'999999999@g.us': {
|
||||
id: '999999999@g.us',
|
||||
subject: 'Events Committee',
|
||||
desc: undefined,
|
||||
participants: [],
|
||||
creation: 0,
|
||||
owner: undefined,
|
||||
restrict: false,
|
||||
announce: false,
|
||||
subjectOwner: undefined,
|
||||
subjectTime: 0,
|
||||
size: 0,
|
||||
ephemeralDuration: 0,
|
||||
inviteCode: undefined,
|
||||
},
|
||||
'120363043312345678@g.us': makeGroup('120363043312345678@g.us', 'UP Parivar Dallas', 'Main community group', [
|
||||
{ id: 'superadmin@s.whatsapp.net', admin: 'superadmin' },
|
||||
{ id: 'admin@s.whatsapp.net', admin: 'admin' },
|
||||
]),
|
||||
'999999999@g.us': makeGroup('999999999@g.us', 'Events Committee', undefined, [
|
||||
{ id: 'creator@s.whatsapp.net', admin: 'superadmin' },
|
||||
]),
|
||||
};
|
||||
|
||||
const mockPrisma = {
|
||||
group: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
upsert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
tenant: { findFirst: jest.fn().mockResolvedValue({ id: 'tenant-system' }) },
|
||||
groupClaimToken: { create: jest.fn().mockResolvedValue({ id: 'tok_1' }) },
|
||||
auditEvent: { create: jest.fn().mockResolvedValue(undefined) },
|
||||
};
|
||||
|
||||
const mockPool = {
|
||||
sendMessage: jest.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
|
||||
describe('syncGroups', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
@@ -48,59 +51,187 @@ describe('syncGroups', () => {
|
||||
.mockResolvedValueOnce({ id: 'db-group-1' })
|
||||
.mockResolvedValueOnce({ id: 'db-group-2' });
|
||||
|
||||
const result = await syncGroups(mockGroups, 'account-1', mockPrisma as any);
|
||||
const result = await syncGroups(mockGroups, 'account-1', mockPrisma as any, mockPool);
|
||||
|
||||
expect(mockPrisma.group.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(result.get('120363043312345678@g.us')).toBe('db-group-1');
|
||||
expect(result.get('999999999@g.us')).toBe('db-group-2');
|
||||
});
|
||||
|
||||
it('calls upsert with correct create payload', async () => {
|
||||
it('upserts with no claimStatus, isActive, and accountId', async () => {
|
||||
mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' });
|
||||
|
||||
await syncGroups(
|
||||
{ '120363043312345678@g.us': mockGroups['120363043312345678@g.us'] },
|
||||
'account-1',
|
||||
mockPrisma as any,
|
||||
);
|
||||
|
||||
expect(mockPrisma.group.upsert).toHaveBeenCalledWith({
|
||||
where: { platform_platformId: { platform: 'whatsapp', platformId: '120363043312345678@g.us' } },
|
||||
create: {
|
||||
platform: 'whatsapp',
|
||||
platformId: '120363043312345678@g.us',
|
||||
name: 'UP Parivar Dallas',
|
||||
description: 'Main community group',
|
||||
isActive: true,
|
||||
accountId: 'account-1',
|
||||
},
|
||||
update: {
|
||||
name: 'UP Parivar Dallas',
|
||||
description: 'Main community group',
|
||||
accountId: 'account-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles groups with no description', async () => {
|
||||
mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-2' });
|
||||
|
||||
await syncGroups(
|
||||
{ '999999999@g.us': mockGroups['999999999@g.us'] },
|
||||
'account-1',
|
||||
mockPrisma as any,
|
||||
mockPool,
|
||||
);
|
||||
|
||||
expect(mockPrisma.group.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({ description: undefined, accountId: 'account-1' }),
|
||||
where: { platform_platformId: { platform: 'whatsapp', platformId: '120363043312345678@g.us' } },
|
||||
create: expect.objectContaining({
|
||||
isActive: true,
|
||||
accountId: 'account-1',
|
||||
}),
|
||||
update: expect.objectContaining({
|
||||
name: 'UP Parivar Dallas',
|
||||
accountId: 'account-1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// No claimStatus or claimExpiresAt in upsert
|
||||
expect(mockPrisma.group.upsert.mock.calls[0][0].create).not.toHaveProperty('claimStatus');
|
||||
});
|
||||
|
||||
it('sends intro message AND DMs claim link to superadmin on new group', async () => {
|
||||
mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' });
|
||||
|
||||
await syncGroups(
|
||||
{ '120363043312345678@g.us': mockGroups['120363043312345678@g.us'] },
|
||||
'account-1',
|
||||
mockPrisma as any,
|
||||
mockPool,
|
||||
);
|
||||
|
||||
// Intro message to group
|
||||
expect(mockPool.sendMessage).toHaveBeenCalledWith(
|
||||
'account-1',
|
||||
'120363043312345678@g.us',
|
||||
expect.stringContaining("I'm TOWER"),
|
||||
);
|
||||
|
||||
// Claim link DM to superadmin
|
||||
expect(mockPool.sendMessage).toHaveBeenCalledWith(
|
||||
'account-1',
|
||||
'superadmin@s.whatsapp.net',
|
||||
expect.stringContaining('tower.app/claim-group?token='),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates GroupClaimToken for new group', async () => {
|
||||
mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' });
|
||||
|
||||
await syncGroups(
|
||||
{ '120363043312345678@g.us': mockGroups['120363043312345678@g.us'] },
|
||||
'account-1',
|
||||
mockPrisma as any,
|
||||
mockPool,
|
||||
);
|
||||
|
||||
expect(mockPrisma.groupClaimToken.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
groupId: 'db-group-1',
|
||||
creatorJid: 'superadmin@s.whatsapp.net',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty map when given empty groups', async () => {
|
||||
const result = await syncGroups({}, 'account-1', mockPrisma as any);
|
||||
it('emits GROUP_CLAIM_TOKEN_SENT audit event', async () => {
|
||||
mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' });
|
||||
|
||||
await syncGroups(
|
||||
{ '120363043312345678@g.us': mockGroups['120363043312345678@g.us'] },
|
||||
'account-1',
|
||||
mockPrisma as any,
|
||||
mockPool,
|
||||
);
|
||||
|
||||
expect(mockPrisma.auditEvent.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: 'GROUP_CLAIM_TOKEN_SENT',
|
||||
resourceType: 'Group',
|
||||
resourceId: 'db-group-1',
|
||||
actorType: 'SYSTEM',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT send claim link when group is already claimed (bot re-added)', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'db-group-1', tenantId: 'tnt-A', accountId: 'old-account' });
|
||||
mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' });
|
||||
|
||||
await syncGroups(
|
||||
{ '120363043312345678@g.us': mockGroups['120363043312345678@g.us'] },
|
||||
'account-1',
|
||||
mockPrisma as any,
|
||||
mockPool,
|
||||
);
|
||||
|
||||
// Intro still sent
|
||||
expect(mockPool.sendMessage).toHaveBeenCalledWith(
|
||||
'account-1',
|
||||
'120363043312345678@g.us',
|
||||
expect.any(String),
|
||||
);
|
||||
|
||||
// But no token generated
|
||||
expect(mockPrisma.groupClaimToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty map when given empty groups', async () => {
|
||||
const result = await syncGroups({}, 'account-1', mockPrisma as any, mockPool);
|
||||
expect(result.size).toBe(0);
|
||||
expect(mockPrisma.group.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- removal / re-add detection -------------------------------------------
|
||||
|
||||
it('marks groups inactive and emits GROUP_BOT_REMOVED when bot is removed', async () => {
|
||||
mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-current' });
|
||||
mockPrisma.group.findMany
|
||||
.mockResolvedValueOnce([{ id: 'db-group-stale', platformId: 'old-group@g.us', tenantId: 'tnt-A', name: 'Old Group' }])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await syncGroups(mockGroups, 'account-1', mockPrisma as any, mockPool);
|
||||
|
||||
expect(mockPrisma.group.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'db-group-stale' }, data: { isActive: false } }),
|
||||
);
|
||||
expect(mockPrisma.auditEvent.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: 'GROUP_BOT_REMOVED',
|
||||
tenantId: 'tnt-A',
|
||||
resourceId: 'db-group-stale',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks groups active and emits GROUP_BOT_RE_ADDED when bot is re-added', async () => {
|
||||
mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' });
|
||||
mockPrisma.group.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: 'db-group-returned', platformId: '120363043312345678@g.us', tenantId: 'tnt-A', name: 'Returned Group' }]);
|
||||
|
||||
await syncGroups(mockGroups, 'account-1', mockPrisma as any, mockPool);
|
||||
|
||||
expect(mockPrisma.group.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'db-group-returned' }, data: { isActive: true } }),
|
||||
);
|
||||
expect(mockPrisma.auditEvent.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: 'GROUP_BOT_RE_ADDED',
|
||||
tenantId: 'tnt-A',
|
||||
resourceId: 'db-group-returned',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not touch any groups when all current groups are accounted for', async () => {
|
||||
mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' });
|
||||
mockPrisma.group.findMany.mockResolvedValue([]);
|
||||
|
||||
await syncGroups(mockGroups, 'account-1', mockPrisma as any, mockPool);
|
||||
|
||||
expect(mockPrisma.group.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { GroupMetadata } from '@whiskeysockets/baileys';
|
||||
import { createLogger } from '@tower/logger';
|
||||
import { WhatsAppSessionPool } from './session-pool';
|
||||
|
||||
const logger = createLogger('group-sync');
|
||||
|
||||
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
const INTRO_MESSAGE =
|
||||
"Hi, I'm TOWER. I'll be archiving messages from this group.";
|
||||
|
||||
const CLAIM_DM = (groupName: string, token: string) =>
|
||||
`TOWER was added to "${groupName}". Claim it here (one-time, expires 48h): tower.app/claim-group?token=${token}`;
|
||||
|
||||
export async function syncGroups(
|
||||
groups: Record<string, GroupMetadata>,
|
||||
accountId: string,
|
||||
prisma: any,
|
||||
pool: WhatsAppSessionPool,
|
||||
): Promise<Map<string, string>> {
|
||||
const jidToDbId = new Map<string, string>();
|
||||
|
||||
for (const [jid, meta] of Object.entries(groups)) {
|
||||
const existing = await prisma.group.findUnique({
|
||||
where: { platform_platformId: { platform: 'whatsapp', platformId: jid } },
|
||||
select: { id: true, tenantId: true, accountId: true },
|
||||
});
|
||||
|
||||
const group = await prisma.group.upsert({
|
||||
where: { platform_platformId: { platform: 'whatsapp', platformId: jid } },
|
||||
create: {
|
||||
@@ -28,8 +44,121 @@ export async function syncGroups(
|
||||
},
|
||||
});
|
||||
jidToDbId.set(jid, group.id);
|
||||
|
||||
// New or re-added group — send intro + DM claim link to superadmin
|
||||
if (!existing || existing.accountId !== accountId) {
|
||||
logger.info({ jid, groupId: group.id, name: meta.subject }, 'New group detected — sending intro');
|
||||
|
||||
// Intro message to group
|
||||
try {
|
||||
await pool.sendMessage(accountId, jid, INTRO_MESSAGE);
|
||||
} catch (err) {
|
||||
logger.warn({ jid, err }, 'Failed to post intro message');
|
||||
}
|
||||
|
||||
// If the group is already claimed (bot re-added), don't generate a new token
|
||||
if (existing?.tenantId) {
|
||||
logger.info({ jid, groupId: group.id, tenantId: existing.tenantId }, 'Group already claimed — skipping token');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find superadmin (the group creator)
|
||||
const superadmin = meta.participants?.find((p: any) => p.admin === 'superadmin');
|
||||
if (!superadmin) {
|
||||
logger.warn({ jid, groupId: group.id }, 'No superadmin found in group metadata — cannot send claim link');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate claim token
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await prisma.groupClaimToken.create({
|
||||
data: {
|
||||
groupId: group.id,
|
||||
token,
|
||||
creatorJid: superadmin.id,
|
||||
expiresAt: new Date(Date.now() + TOKEN_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
// DM the superadmin
|
||||
try {
|
||||
await pool.sendMessage(accountId, superadmin.id, CLAIM_DM(meta.subject, token));
|
||||
logger.info({ jid, groupId: group.id, superadminJid: superadmin.id }, 'Claim link DM sent');
|
||||
} catch (err) {
|
||||
logger.warn({ jid, superadminJid: superadmin.id, err }, 'Failed to DM claim link to superadmin');
|
||||
}
|
||||
|
||||
// Audit event
|
||||
const auditTenantId = await firstTenantId(prisma);
|
||||
await prisma.auditEvent
|
||||
.create({
|
||||
data: {
|
||||
tenantId: auditTenantId ?? 'system',
|
||||
actorType: 'SYSTEM',
|
||||
action: 'GROUP_CLAIM_TOKEN_SENT',
|
||||
resourceType: 'Group',
|
||||
resourceId: group.id,
|
||||
payload: { jid, name: meta.subject, superadminJid: superadmin.id },
|
||||
},
|
||||
})
|
||||
.catch((err: unknown) => logger.warn({ err }, 'Failed to write GROUP_CLAIM_TOKEN_SENT audit event'));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Detect removed groups ------------------------------------------------
|
||||
const currentJids = new Set(Object.keys(groups));
|
||||
const activeDbGroups = await prisma.group.findMany({
|
||||
where: { accountId, isActive: true },
|
||||
select: { id: true, platformId: true, tenantId: true, name: true },
|
||||
});
|
||||
|
||||
const removedGroups = activeDbGroups.filter((g: any) => !currentJids.has(g.platformId));
|
||||
for (const g of removedGroups) {
|
||||
logger.info({ groupId: g.id, name: g.name, platformId: g.platformId }, 'Bot removed from group');
|
||||
await prisma.group.update({ where: { id: g.id }, data: { isActive: false } });
|
||||
await prisma.auditEvent
|
||||
.create({
|
||||
data: {
|
||||
tenantId: g.tenantId ?? (await firstTenantId(prisma)) ?? 'system',
|
||||
actorType: 'SYSTEM',
|
||||
action: 'GROUP_BOT_REMOVED',
|
||||
resourceType: 'Group',
|
||||
resourceId: g.id,
|
||||
payload: { name: g.name, platformId: g.platformId },
|
||||
},
|
||||
})
|
||||
.catch((err: unknown) => logger.warn({ err }, 'Failed to write GROUP_BOT_REMOVED audit event'));
|
||||
}
|
||||
|
||||
// --- Detect re-added groups -----------------------------------------------
|
||||
const inactiveDbGroups = await prisma.group.findMany({
|
||||
where: { accountId, isActive: false },
|
||||
select: { id: true, platformId: true, tenantId: true, name: true },
|
||||
});
|
||||
|
||||
const reAddedGroups = inactiveDbGroups.filter((g: any) => currentJids.has(g.platformId));
|
||||
for (const g of reAddedGroups) {
|
||||
logger.info({ groupId: g.id, name: g.name, platformId: g.platformId }, 'Bot re-added to group');
|
||||
await prisma.group.update({ where: { id: g.id }, data: { isActive: true } });
|
||||
await prisma.auditEvent
|
||||
.create({
|
||||
data: {
|
||||
tenantId: g.tenantId ?? (await firstTenantId(prisma)) ?? 'system',
|
||||
actorType: 'SYSTEM',
|
||||
action: 'GROUP_BOT_RE_ADDED',
|
||||
resourceType: 'Group',
|
||||
resourceId: g.id,
|
||||
payload: { name: g.name, platformId: g.platformId },
|
||||
},
|
||||
})
|
||||
.catch((err: unknown) => logger.warn({ err }, 'Failed to write GROUP_BOT_RE_ADDED audit event'));
|
||||
}
|
||||
|
||||
logger.info({ count: jidToDbId.size, accountId }, 'Groups synced');
|
||||
return jidToDbId;
|
||||
}
|
||||
|
||||
async function firstTenantId(prisma: any): Promise<string | null> {
|
||||
const t = await prisma.tenant.findFirst({ select: { id: true } });
|
||||
return t?.id ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { matchContentRules, matchReactionRules, TenantRuleRow } from './match-rules';
|
||||
|
||||
const makeRule = (overrides: Partial<TenantRuleRow> = {}): TenantRuleRow => ({
|
||||
id: 'r1',
|
||||
matchType: 'HASHTAG',
|
||||
matchValue: '#gooo',
|
||||
action: 'FLAG',
|
||||
priority: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('matchContentRules', () => {
|
||||
it('matches hashtag at start of message', () => {
|
||||
const result = matchContentRules('#gooo hello', [makeRule()]);
|
||||
expect(result.tags).toContain('#gooo');
|
||||
expect(result.effectiveAction).toBe('FLAG');
|
||||
});
|
||||
|
||||
it('matches hashtag in middle of message', () => {
|
||||
const result = matchContentRules('hello #gooo world', [makeRule()]);
|
||||
expect(result.tags).toContain('#gooo');
|
||||
});
|
||||
|
||||
it('matches hashtag at end of message', () => {
|
||||
const result = matchContentRules('hello #gooo', [makeRule()]);
|
||||
expect(result.tags).toContain('#gooo');
|
||||
});
|
||||
|
||||
it('matches hashtag with punctuation after', () => {
|
||||
const result = matchContentRules('check #gooo, please', [makeRule()]);
|
||||
expect(result.tags).toContain('#gooo');
|
||||
});
|
||||
|
||||
it('does not match hashtag as part of another word', () => {
|
||||
const result = matchContentRules('this is #goooogle', [makeRule()]);
|
||||
expect(result.tags).not.toContain('#gooo');
|
||||
});
|
||||
|
||||
it('does not match hashtag attached to preceding text', () => {
|
||||
const result = matchContentRules('foo#gooo bar', [makeRule()]);
|
||||
expect(result.tags).not.toContain('#gooo');
|
||||
});
|
||||
|
||||
it('matches PREFIX rule when text starts with prefix', () => {
|
||||
const result = matchContentRules('/tower help', [
|
||||
makeRule({ matchType: 'PREFIX', matchValue: '/tower', action: 'FLAG' }),
|
||||
]);
|
||||
expect(result.tags).toContain('/tower...');
|
||||
});
|
||||
|
||||
it('does not match PREFIX when text does not start with it', () => {
|
||||
const result = matchContentRules('do /tower help', [
|
||||
makeRule({ matchType: 'PREFIX', matchValue: '/tower', action: 'FLAG' }),
|
||||
]);
|
||||
expect(result.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no matches when no rules match', () => {
|
||||
const result = matchContentRules('hello world', [makeRule()]);
|
||||
expect(result.tags).toEqual([]);
|
||||
expect(result.effectiveAction).toBeNull();
|
||||
});
|
||||
|
||||
it('SKIP takes precedence over FLAG', () => {
|
||||
const result = matchContentRules('#gooo event', [
|
||||
makeRule({ matchValue: '#gooo', action: 'FLAG', priority: 0 }),
|
||||
makeRule({ id: 'r2', matchValue: '#gooo', action: 'SKIP', priority: 1, matchType: 'HASHTAG' }),
|
||||
]);
|
||||
expect(result.effectiveAction).toBe('SKIP');
|
||||
});
|
||||
|
||||
it('REJECT takes precedence over AUTO_APPROVE', () => {
|
||||
const result = matchContentRules('#gooo event', [
|
||||
makeRule({ matchValue: '#gooo', action: 'AUTO_APPROVE', priority: 0 }),
|
||||
makeRule({ id: 'r2', matchValue: '#gooo', action: 'REJECT', priority: 1, matchType: 'HASHTAG' }),
|
||||
]);
|
||||
expect(result.effectiveAction).toBe('REJECT');
|
||||
});
|
||||
|
||||
it('AUTO_APPROVE takes precedence over FLAG', () => {
|
||||
const result = matchContentRules('#gooo event', [
|
||||
makeRule({ matchValue: '#gooo', action: 'FLAG', priority: 0 }),
|
||||
makeRule({ id: 'r2', matchValue: '#gooo', action: 'AUTO_APPROVE', priority: 1, matchType: 'HASHTAG' }),
|
||||
]);
|
||||
expect(result.effectiveAction).toBe('AUTO_APPROVE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchReactionRules', () => {
|
||||
it('matches reaction emoji', () => {
|
||||
const result = matchReactionRules('⭐', [
|
||||
makeRule({ matchType: 'REACTION_EMOJI', matchValue: '⭐', action: 'AUTO_APPROVE' }),
|
||||
]);
|
||||
expect(result).toEqual({ action: 'AUTO_APPROVE' });
|
||||
});
|
||||
|
||||
it('returns null when no reaction rule matches', () => {
|
||||
const result = matchReactionRules('👍', [
|
||||
makeRule({ matchType: 'REACTION_EMOJI', matchValue: '⭐', action: 'AUTO_APPROVE' }),
|
||||
]);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* DB-driven rule matching — replaces the old hardcoded tag-detector.ts.
|
||||
* Loads active TenantRule rows from the DB and matches them against
|
||||
* message content (for HASHTAG / PREFIX rules) or reaction emoji (for REACTION_EMOJI rules).
|
||||
*/
|
||||
|
||||
export interface TenantRuleRow {
|
||||
id: string;
|
||||
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI';
|
||||
matchValue: string;
|
||||
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
tags: string[];
|
||||
effectiveAction: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT' | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match message content against a set of HASHTAG and PREFIX rules.
|
||||
* Returns detected tags and the most important effective action.
|
||||
* SKIP and REJECT take precedence over AUTO_APPROVE and FLAG.
|
||||
* Within the same precedence tier, the highest priority (lowest number) wins.
|
||||
*/
|
||||
export function matchContentRules(
|
||||
text: string,
|
||||
rules: TenantRuleRow[],
|
||||
): MatchResult {
|
||||
const tags: string[] = [];
|
||||
const relevantRules = rules.filter(
|
||||
(r) => r.matchType === 'HASHTAG' || r.matchType === 'PREFIX',
|
||||
);
|
||||
|
||||
let effectiveAction: MatchResult['effectiveAction'] = null;
|
||||
|
||||
// Action precedence: SKIP > REJECT > AUTO_APPROVE > FLAG
|
||||
const actionRank: Record<string, number> = {
|
||||
SKIP: 4,
|
||||
REJECT: 3,
|
||||
AUTO_APPROVE: 2,
|
||||
FLAG: 1,
|
||||
};
|
||||
|
||||
for (const rule of relevantRules) {
|
||||
let matched = false;
|
||||
|
||||
if (rule.matchType === 'HASHTAG') {
|
||||
// Match the hashtag as a standalone token.
|
||||
// `\b` doesn't work here because `#` is a non-word character,
|
||||
// so `\b#gooo` fails at the start of a message. Use `(?:^|\W)`
|
||||
// to allow start-of-string or a non-word char before the hashtag.
|
||||
const escaped = escapeRegex(rule.matchValue);
|
||||
const pattern = new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i');
|
||||
if (pattern.test(text)) {
|
||||
tags.push(rule.matchValue);
|
||||
matched = true;
|
||||
}
|
||||
} else if (rule.matchType === 'PREFIX') {
|
||||
// Match if text starts with the prefix
|
||||
if (text.trimStart().startsWith(rule.matchValue)) {
|
||||
tags.push(`${rule.matchValue}...`);
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
const rank = actionRank[rule.action] ?? 0;
|
||||
const currentRank = effectiveAction ? (actionRank[effectiveAction] ?? 0) : 0;
|
||||
if (rank > currentRank) {
|
||||
effectiveAction = rule.action;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tags, effectiveAction };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a reaction emoji against REACTION_EMOJI rules.
|
||||
* Returns the first matching rule action, or null.
|
||||
*/
|
||||
/**
|
||||
* Strip Unicode variation selectors (U+FE0F–U+FE0F) and other
|
||||
* formatting codepoints that can differ between platforms.
|
||||
*/
|
||||
function stripVariationSelectors(s: string): string {
|
||||
return [...s].filter((c) => c.codePointAt(0) !== 0xfe0f).join('');
|
||||
}
|
||||
|
||||
export function matchReactionRules(
|
||||
emoji: string,
|
||||
rules: TenantRuleRow[],
|
||||
): { action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT' } | null {
|
||||
const reactionRules = rules.filter((r) => r.matchType === 'REACTION_EMOJI');
|
||||
const cleanEmoji = stripVariationSelectors(emoji);
|
||||
|
||||
for (const rule of reactionRules) {
|
||||
if (cleanEmoji === stripVariationSelectors(rule.matchValue)) {
|
||||
return { action: rule.action };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export function normalizeMessage(
|
||||
export function normalizeReaction(
|
||||
msg: proto.IWebMessageInfo,
|
||||
accountId: string,
|
||||
selfJid?: string,
|
||||
): NormalizedReaction | null {
|
||||
const key = msg.key;
|
||||
if (!key) return null;
|
||||
@@ -53,8 +54,7 @@ export function normalizeReaction(
|
||||
const remoteJid = key.remoteJid ?? '';
|
||||
// only group messages (group JIDs end with @g.us)
|
||||
if (!remoteJid.endsWith('@g.us')) return null;
|
||||
// skip our own outgoing messages
|
||||
if (key.fromMe) return null;
|
||||
// Allow fromMe reactions — admin may star from the connected account
|
||||
|
||||
const reaction = msg.message?.reactionMessage;
|
||||
if (!reaction) return null;
|
||||
@@ -62,8 +62,8 @@ export function normalizeReaction(
|
||||
const targetMsgId = reaction.key?.id;
|
||||
if (!targetMsgId) return null;
|
||||
|
||||
// Ensure reactorJid is not empty (group message must have a participant)
|
||||
const reactorJid = key.participant;
|
||||
// For fromMe reactions Baileys uses LID internally; use selfJid (PN format) to match TOWER_ADMIN_JIDS
|
||||
const reactorJid = key.fromMe ? (selfJid ?? key.participant) : key.participant;
|
||||
if (!reactorJid) return null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { WhatsAppSessionPool } from './session-pool';
|
||||
import { createLogger } from '@tower/logger';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
const OTP_MESSAGE = (code: string): string =>
|
||||
`Your TOWER verification code is ${code}. It expires in 5 minutes. Reply STOP to opt out.`;
|
||||
|
||||
export function startOtpSenderLoop(
|
||||
prisma: any,
|
||||
pool: WhatsAppSessionPool,
|
||||
logger: ReturnType<typeof createLogger>,
|
||||
): void {
|
||||
let running = false;
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
const pending = await prisma.otpChallenge.findMany({
|
||||
where: { sentAt: null, expiresAt: { gt: new Date() } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
for (const challenge of pending) {
|
||||
const accounts = await prisma.account.findMany({
|
||||
where: { isBot: true, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (accounts.length === 0) {
|
||||
logger.warn({ challengeId: challenge.id }, 'No active bot — OTP delivery deferred');
|
||||
continue;
|
||||
}
|
||||
const account = accounts[0];
|
||||
try {
|
||||
await pool.sendMessage(account.id, challenge.jid, OTP_MESSAGE(challenge.code));
|
||||
await prisma.otpChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
data: { sentAt: new Date() },
|
||||
});
|
||||
logger.info({ challengeId: challenge.id, jid: challenge.jid }, 'OTP sent');
|
||||
} catch (err) {
|
||||
logger.error({ err, challengeId: challenge.id }, 'Failed to send OTP');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'otp-sender tick failed');
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => void tick(), 3_000);
|
||||
setInterval(() => void tick(), POLL_INTERVAL_MS);
|
||||
logger.info(`otp-sender loop scheduled (every ${POLL_INTERVAL_MS / 1000}s)`);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export async function createWhatsAppSession(
|
||||
version,
|
||||
auth: state,
|
||||
printQRInTerminal: false,
|
||||
logger: logger as any,
|
||||
logger: { level: 'silent', trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {}, child: () => ({ level: 'silent', trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {}, child: () => ({} as any) }) } as any,
|
||||
});
|
||||
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
@@ -91,10 +91,11 @@ export async function createWhatsAppSession(
|
||||
});
|
||||
|
||||
sock.ev.on('messages.upsert', ({ messages, type }) => {
|
||||
logger.info({ type, count: messages.length }, 'messages.upsert received');
|
||||
if (type !== 'notify') return;
|
||||
for (const msg of messages) {
|
||||
if (msg.message?.reactionMessage) {
|
||||
const reaction = normalizeReaction(msg, accountId);
|
||||
const reaction = normalizeReaction(msg, accountId, sock.user?.id);
|
||||
if (reaction) {
|
||||
void Promise.resolve(onReaction(reaction)).catch((err) =>
|
||||
logger.error({ err }, 'Error processing reaction'),
|
||||
|
||||
Reference in New Issue
Block a user