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
@@ -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='),
);
});
});
});
+232
View File
@@ -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');
}
+193 -62
View File
@@ -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();
});
});
+129
View File
@@ -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();
});
});
+109
View File
@@ -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+FE0FU+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, '\\$&');
}
+4 -4
View File
@@ -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 {
+57
View File
@@ -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)`);
}
+3 -2
View File
@@ -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'),