feat: add spam detection lane (tower.classify.spam) with content-aware filtering

- Add SpamJobData + sync missing job types (DncJobData, ClassifyJobData, P1JobData, etc.) from dist back to source in @tower/types
- spam-detector.ts: emoji-only, short-greeting, link-only detection + duplicate-hash (1h window)
- spam.queue.ts / spam.processor.ts: BullMQ tower.classify.spam lane
- classify.processor.ts: spam check (Step 0) before rule engine, accepts spamQueue param
- main.ts: wire spamQueue + spamWorker; spam pre-filter before ingestQueue.add
- dnc.processor.ts: labelled reason logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:00:48 +05:30
parent 566690bd04
commit a685f8b4d5
7 changed files with 249 additions and 5 deletions
+27
View File
@@ -8,6 +8,8 @@ import { createForwardQueue } from './queues/forward.queue';
import { createForwardWorker } from './queues/forward.processor'; import { createForwardWorker } from './queues/forward.processor';
import { createIndexQueue } from './queues/index.queue'; import { createIndexQueue } from './queues/index.queue';
import { createIndexWorker } from './queues/index.processor'; import { createIndexWorker } from './queues/index.processor';
import { createSpamQueue } from './queues/spam.queue';
import { createSpamWorker } from './queues/spam.processor';
import { WhatsAppSessionPool } from './whatsapp/session-pool'; import { WhatsAppSessionPool } from './whatsapp/session-pool';
import { matchContentRules, TenantRuleRow } from './whatsapp/match-rules'; import { matchContentRules, TenantRuleRow } from './whatsapp/match-rules';
import { syncGroups } from './whatsapp/group-sync'; import { syncGroups } from './whatsapp/group-sync';
@@ -15,6 +17,7 @@ import { handleReaction } from './core/approval';
import { approveMessage } from './core/approve-message'; import { approveMessage } from './core/approve-message';
import { startOtpSenderLoop } from './whatsapp/otp-sender'; import { startOtpSenderLoop } from './whatsapp/otp-sender';
import { handleCommand } from './whatsapp/command-handler'; import { handleCommand } from './whatsapp/command-handler';
import { detectSpam, checkDuplicateHash } from './spam/spam-detector';
const logger = createLogger('tower-worker'); const logger = createLogger('tower-worker');
@@ -30,11 +33,13 @@ async function bootstrap() {
const ingestQueue = createIngestQueue(env.REDIS_URL); const ingestQueue = createIngestQueue(env.REDIS_URL);
const forwardQueue = createForwardQueue(env.REDIS_URL); const forwardQueue = createForwardQueue(env.REDIS_URL);
const indexQueue = createIndexQueue(env.REDIS_URL); const indexQueue = createIndexQueue(env.REDIS_URL);
const spamQueue = createSpamQueue(env.REDIS_URL);
const pool = new WhatsAppSessionPool(); const pool = new WhatsAppSessionPool();
const ingestWorker = createIngestWorker(env.REDIS_URL, prisma, pool, forwardQueue, indexQueue); const ingestWorker = createIngestWorker(env.REDIS_URL, prisma, pool, forwardQueue, indexQueue);
const forwardWorker = createForwardWorker(env.REDIS_URL, pool); const forwardWorker = createForwardWorker(env.REDIS_URL, pool);
const indexWorker = createIndexWorker(env.REDIS_URL, meiliClient); const indexWorker = createIndexWorker(env.REDIS_URL, meiliClient);
const spamWorker = createSpamWorker(env.REDIS_URL, prisma);
ingestWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Ingest job completed')); ingestWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Ingest job completed'));
ingestWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Ingest job failed')); ingestWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Ingest job failed'));
@@ -42,6 +47,7 @@ async function bootstrap() {
forwardWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Forward job failed')); forwardWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Forward job failed'));
indexWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Index job completed')); indexWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Index job completed'));
indexWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Index job failed')); indexWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Index job failed'));
spamWorker.on('failed', (job, err) => logger.warn({ jobId: job?.id, err }, 'Spam job failed'));
const groupMaps = new Map<string, Map<string, string>>(); const groupMaps = new Map<string, Map<string, string>>();
@@ -94,6 +100,25 @@ async function bootstrap() {
return; return;
} }
// Spam pre-filter — runs before rule engine; spam messages are never stored
const spamResult = detectSpam(msg.content);
const isDupe = spamResult.isSpam
? false
: await checkDuplicateHash(spamResult.contentHash, group.tenantId, sourceGroupId, 60 * 60 * 1000, prisma);
if (spamResult.isSpam || isDupe) {
const reason = isDupe ? 'duplicate_hash' : (spamResult.reason ?? 'emoji_only');
logger.debug({ senderJid: msg.senderJid, reason }, 'Spam filtered before ingest');
await spamQueue.add('spam', {
tenantId: group.tenantId,
messageId: 'pre-ingest',
sourceGroupId,
senderJid: msg.senderJid,
reason,
contentHash: spamResult.contentHash,
}, { attempts: 1 }).catch(() => void 0);
return;
}
// Load active rules for this tenant // Load active rules for this tenant
const ruleRows: TenantRuleRow[] = await prisma.tenantRule.findMany({ const ruleRows: TenantRuleRow[] = await prisma.tenantRule.findMany({
where: { tenantId: group.tenantId, isActive: true }, where: { tenantId: group.tenantId, isActive: true },
@@ -279,9 +304,11 @@ async function bootstrap() {
await ingestWorker.close(); await ingestWorker.close();
await forwardWorker.close(); await forwardWorker.close();
await indexWorker.close(); await indexWorker.close();
await spamWorker.close();
await ingestQueue.close(); await ingestQueue.close();
await forwardQueue.close(); await forwardQueue.close();
await indexQueue.close(); await indexQueue.close();
await spamQueue.close();
await prisma.$disconnect(); await prisma.$disconnect();
process.exit(0); process.exit(0);
}; };
+30 -2
View File
@@ -1,12 +1,16 @@
import { Worker, Queue } from 'bullmq'; import { Worker, Queue } from 'bullmq';
import { ClassifyJobData, ForwardJobData, IndexJobData, ReviewJobData, P1JobData, DncJobData } from '@tower/types'; import { ClassifyJobData, ForwardJobData, IndexJobData, ReviewJobData, P1JobData, DncJobData, SpamJobData } from '@tower/types';
import { parseRedisUrl } from './redis-connection'; import { parseRedisUrl } from './redis-connection';
import { matchContentRules, TenantRuleRow } from '../whatsapp/match-rules'; import { matchContentRules, TenantRuleRow } from '../whatsapp/match-rules';
import { approveMessage } from '../core/approve-message'; import { approveMessage } from '../core/approve-message';
import { detectSpam, checkDuplicateHash } from '../spam/spam-detector';
import { createLogger } from '@tower/logger'; import { createLogger } from '@tower/logger';
const logger = createLogger('classify-processor'); const logger = createLogger('classify-processor');
// Duplicate-hash window: 1 hour
const DUPE_WINDOW_MS = 60 * 60 * 1000;
export async function processClassifyJob( export async function processClassifyJob(
job: ClassifyJobData, job: ClassifyJobData,
prisma: any, prisma: any,
@@ -16,6 +20,7 @@ export async function processClassifyJob(
reviewQueue: Queue<ReviewJobData>, reviewQueue: Queue<ReviewJobData>,
p1Queue: Queue<P1JobData>, p1Queue: Queue<P1JobData>,
dncQueue: Queue<DncJobData>, dncQueue: Queue<DncJobData>,
spamQueue: Queue<SpamJobData>,
): Promise<void> { ): Promise<void> {
const message = await prisma.message.findUnique({ const message = await prisma.message.findUnique({
where: { id: job.messageId }, where: { id: job.messageId },
@@ -33,6 +38,28 @@ export async function processClassifyJob(
return; return;
} }
// ── Step 0: Spam detection (runs before rule engine) ─────────────────────
const spamResult = detectSpam(message.content);
const isDupe = spamResult.isSpam
? false
: await checkDuplicateHash(spamResult.contentHash, job.tenantId, job.sourceGroupId, DUPE_WINDOW_MS, prisma);
if (spamResult.isSpam || isDupe) {
const reason = isDupe ? 'duplicate_hash' : spamResult.reason!;
await prisma.message.update({ where: { id: job.messageId }, data: { status: 'DNC' } });
await spamQueue.add('spam', {
tenantId: job.tenantId,
messageId: job.messageId,
sourceGroupId: job.sourceGroupId,
senderJid: job.senderJid,
reason,
contentHash: spamResult.contentHash,
traceId: job.traceId,
}, { attempts: 1 }).catch((err: unknown) => logger.warn({ err }, 'Failed to enqueue spam job'));
logger.info({ messageId: job.messageId, reason }, 'Message classified as spam — DNC');
return;
}
// ── Step 1: Hashtag/prefix rules — org rules first (authoritative), then tenant ── // ── Step 1: Hashtag/prefix rules — org rules first (authoritative), then tenant ──
const tenant = await prisma.tenant.findUnique({ const tenant = await prisma.tenant.findUnique({
where: { id: job.tenantId }, where: { id: job.tenantId },
@@ -167,13 +194,14 @@ export function createClassifyWorker(
reviewQueue: Queue<ReviewJobData>, reviewQueue: Queue<ReviewJobData>,
p1Queue: Queue<P1JobData>, p1Queue: Queue<P1JobData>,
dncQueue: Queue<DncJobData>, dncQueue: Queue<DncJobData>,
spamQueue: Queue<SpamJobData>,
): Worker<ClassifyJobData> { ): Worker<ClassifyJobData> {
return new Worker<ClassifyJobData>( return new Worker<ClassifyJobData>(
'tower.classify.v1', 'tower.classify.v1',
async (job) => async (job) =>
processClassifyJob( processClassifyJob(
job.data, prisma, pool, job.data, prisma, pool,
forwardQueue, indexQueue, reviewQueue, p1Queue, dncQueue, forwardQueue, indexQueue, reviewQueue, p1Queue, dncQueue, spamQueue,
), ),
{ connection: parseRedisUrl(redisUrl), concurrency: 5 }, { connection: parseRedisUrl(redisUrl), concurrency: 5 },
); );
+8 -3
View File
@@ -5,19 +5,24 @@ import { createLogger } from '@tower/logger';
const logger = createLogger('dnc-processor'); const logger = createLogger('dnc-processor');
const DNC_REASON_LABELS: Record<string, string> = {
rule_skip: 'Rule SKIP/REJECT action',
no_rule_match: 'No rule matched',
spam_detected: 'Spam detector',
content_filtered: 'Content filter',
};
export async function processDncJob(job: DncJobData): Promise<void> { export async function processDncJob(job: DncJobData): Promise<void> {
// No content is stored — DNC events carry only metadata (tenant, group, reason, timestamp).
// This keeps the archive high-signal while giving analytics visibility into noise volume.
logger.info( logger.info(
{ {
tenantId: job.tenantId, tenantId: job.tenantId,
sourceGroupId: job.sourceGroupId, sourceGroupId: job.sourceGroupId,
reason: job.reason, reason: job.reason,
label: DNC_REASON_LABELS[job.reason] ?? job.reason,
timestamp: job.timestamp, timestamp: job.timestamp,
}, },
'Message classified as DNC — discarded', 'Message classified as DNC — discarded',
); );
// Future (Phase 3): increment per-tenant DNC counters in Redis for rule-tuning dashboard.
} }
export function createDncWorker(redisUrl: string): Worker<DncJobData> { export function createDncWorker(redisUrl: string): Worker<DncJobData> {
+32
View File
@@ -0,0 +1,32 @@
import { Worker } from 'bullmq';
import { SpamJobData } from '@tower/types';
import { parseRedisUrl } from './redis-connection';
import { createLogger } from '@tower/logger';
const logger = createLogger('spam-processor');
export async function processSpamJob(job: SpamJobData, prisma: any): Promise<void> {
await prisma.message.update({
where: { id: job.messageId },
data: { status: 'DNC' },
});
logger.info(
{
tenantId: job.tenantId,
messageId: job.messageId,
senderJid: job.senderJid,
reason: job.reason,
contentHash: job.contentHash,
},
'Message classified as spam — discarded',
);
}
export function createSpamWorker(redisUrl: string, prisma: any): Worker<SpamJobData> {
return new Worker<SpamJobData>(
'tower.classify.spam',
async (job) => processSpamJob(job.data, prisma),
{ connection: parseRedisUrl(redisUrl) },
);
}
+7
View File
@@ -0,0 +1,7 @@
import { Queue } from 'bullmq';
import { SpamJobData } from '@tower/types';
import { parseRedisUrl } from './redis-connection';
export function createSpamQueue(redisUrl: string): Queue<SpamJobData> {
return new Queue<SpamJobData>('tower.classify.spam', { connection: parseRedisUrl(redisUrl) });
}
+77
View File
@@ -0,0 +1,77 @@
import { createHash } from 'crypto';
export type SpamReason = 'emoji_only' | 'short_greeting' | 'link_only' | 'forward_flood' | 'duplicate_hash';
export interface SpamResult {
isSpam: boolean;
reason?: SpamReason;
contentHash: string;
}
const GREETING_PHRASES = [
'good morning', 'good evening', 'good night', 'good afternoon',
'gm', 'gn', 'subah ki shayari', 'शुभ प्रभात', 'शुभ रात्रि',
'happy', 'jai hind', 'jai shree ram', 'jai mata di',
'forwarded as received', 'please share', 'must share',
];
const EMOJI_RE = /^[\p{Emoji}\p{Emoji_Presentation}\s]+$/u;
const URL_RE = /https?:\/\/\S+/gi;
const TOKEN_MIN = 4;
export function hashContent(content: string): string {
return createHash('sha256').update(content.trim().toLowerCase()).digest('hex').slice(0, 16);
}
export function detectSpam(content: string): SpamResult {
const trimmed = content.trim();
const contentHash = hashContent(trimmed);
if (EMOJI_RE.test(trimmed)) {
return { isSpam: true, reason: 'emoji_only', contentHash };
}
const lower = trimmed.toLowerCase();
const withoutUrls = lower.replace(URL_RE, '').trim();
const tokens = withoutUrls.split(/\s+/).filter(Boolean);
if (tokens.length < TOKEN_MIN) {
if (GREETING_PHRASES.some((phrase) => lower.includes(phrase))) {
return { isSpam: true, reason: 'short_greeting', contentHash };
}
}
const urls = trimmed.match(URL_RE) ?? [];
if (urls.length > 0 && tokens.length === 0) {
return { isSpam: true, reason: 'link_only', contentHash };
}
return { isSpam: false, contentHash };
}
export async function checkDuplicateHash(
contentHash: string,
tenantId: string,
sourceGroupId: string,
windowMs: number,
prisma: any,
): Promise<boolean> {
const since = new Date(Date.now() - windowMs);
const count = await prisma.message.count({
where: {
tenantId,
sourceGroupId,
content: { startsWith: '' },
createdAt: { gte: since },
},
});
if (count === 0) return false;
const recent = await prisma.message.findMany({
where: { tenantId, sourceGroupId, createdAt: { gte: since } },
select: { content: true },
take: 100,
});
return recent.some((m: { content: string }) => hashContent(m.content) === contentHash);
}
+68
View File
@@ -91,4 +91,72 @@ export interface IndexJobData {
tags: string[]; tags: string[];
platform: string; platform: string;
approvedAt: string; // ISO 8601 — converted to Unix ms in the index processor approvedAt: string; // ISO 8601 — converted to Unix ms in the index processor
traceId?: string;
}
export interface DncJobData {
tenantId: string;
sourceGroupId: string;
accountId: string;
reason: 'rule_skip' | 'no_rule_match' | 'spam_detected' | 'content_filtered';
timestamp: string;
policyVersion: string;
traceId?: string;
}
export interface ClassifyJobData {
messageId: string;
tenantId: string;
sourceGroupId: string;
sourceGroupJid: string;
senderJid: string;
accountId: string;
traceId?: string;
}
export interface ThreadResolveJobData {
messageId: string;
tenantId: string;
sourceGroupId: string;
quotedPlatformMsgId?: string;
traceId?: string;
}
export interface DigestJobData {
tenantId: string;
digestDate: string;
triggeredBy: 'schedule' | 'manual';
traceId?: string;
}
export interface ReviewJobData {
tenantId: string;
messageId: string;
sourceGroupId: string;
tags: string[];
reason: string;
policyVersion: string;
traceId?: string;
}
export interface P1JobData {
tenantId: string;
messageId: string;
content: string;
sourceGroupId: string;
senderJid: string;
senderName?: string;
tags: string[];
policyVersion: string;
traceId?: string;
}
export interface SpamJobData {
tenantId: string;
messageId: string;
sourceGroupId: string;
senderJid: string;
reason: 'forward_flood' | 'duplicate_hash' | 'emoji_only' | 'short_greeting' | 'link_only';
contentHash: string;
traceId?: string;
} }