diff --git a/apps/worker/src/ai/llm-client.ts b/apps/worker/src/ai/llm-client.ts new file mode 100644 index 0000000..ea5c8c8 --- /dev/null +++ b/apps/worker/src/ai/llm-client.ts @@ -0,0 +1,33 @@ +const OPENROUTER_BASE = 'https://openrouter.ai/api/v1'; +const DEFAULT_MODEL = 'google/gemini-3.5-flash'; + +export async function callLLM( + prompt: string, + apiKey: string, + model = DEFAULT_MODEL, +): Promise { + const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + 'HTTP-Referer': 'https://tower.insignia.app', + 'X-Title': 'TOWER Community Platform', + }, + body: JSON.stringify({ + model, + messages: [{ role: 'user', content: prompt }], + temperature: 0.1, + }), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`OpenRouter error ${res.status}: ${body}`); + } + + const data = (await res.json()) as { choices: Array<{ message: { content: string } }> }; + const content = data.choices?.[0]?.message?.content; + if (!content) throw new Error('OpenRouter returned empty content'); + return content; +} diff --git a/apps/worker/src/digest/digest-generator.d.ts b/apps/worker/src/digest/digest-generator.d.ts new file mode 100644 index 0000000..bc02e33 --- /dev/null +++ b/apps/worker/src/digest/digest-generator.d.ts @@ -0,0 +1,5 @@ +export interface DigestResult { + text: string; + messageIds: string[]; +} +export declare function generateDigest(tenantId: string, tenantName: string, prisma: any, digestDate: Date): Promise; diff --git a/apps/worker/src/digest/digest-generator.js b/apps/worker/src/digest/digest-generator.js new file mode 100644 index 0000000..be52328 --- /dev/null +++ b/apps/worker/src/digest/digest-generator.js @@ -0,0 +1,61 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.generateDigest = generateDigest; +const MAX_MESSAGES = 20; +const PREVIEW_LENGTH = 120; +function formatDate(date) { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }); +} +async function generateDigest(tenantId, tenantName, prisma, digestDate) { + const from = new Date(digestDate); + const to = new Date(digestDate.getTime() + 24 * 60 * 60 * 1000); + const messages = await prisma.message.findMany({ + where: { + tenantId, + status: { in: ['APPROVED', 'DISTRIBUTED'] }, + updatedAt: { gte: from, lt: to }, + }, + include: { + sourceGroup: { select: { name: true } }, + }, + orderBy: { updatedAt: 'asc' }, + take: MAX_MESSAGES, + }); + if (messages.length === 0) + return null; + const groups = new Map(); + for (const msg of messages) { + const tag = msg.tags?.[0] ?? 'General'; + if (!groups.has(tag)) + groups.set(tag, []); + groups.get(tag).push(msg); + } + const dateLabel = formatDate(digestDate); + const lines = [ + `📋 *Daily Digest — ${tenantName}*`, + `_${dateLabel}_`, + '', + ]; + for (const [tag, msgs] of groups) { + const sectionHeader = tag === 'General' ? '*📌 General*' : `*🏷️ ${tag}*`; + lines.push(sectionHeader); + for (const msg of msgs) { + const preview = msg.content.slice(0, PREVIEW_LENGTH).replace(/\n+/g, ' '); + const groupName = msg.sourceGroup?.name ?? 'Unknown Group'; + lines.push(`• ${preview} _(${groupName})_`); + } + lines.push(''); + } + const groupCount = new Set(messages.map((m) => m.sourceGroupId)).size; + lines.push(`_${messages.length} message${messages.length !== 1 ? 's' : ''} from ${groupCount} group${groupCount !== 1 ? 's' : ''} | TOWER_`); + return { + text: lines.join('\n'), + messageIds: messages.map((m) => m.id), + }; +} +//# sourceMappingURL=digest-generator.js.map \ No newline at end of file diff --git a/apps/worker/src/digest/digest-generator.js.map b/apps/worker/src/digest/digest-generator.js.map new file mode 100644 index 0000000..cbdf4e4 --- /dev/null +++ b/apps/worker/src/digest/digest-generator.js.map @@ -0,0 +1 @@ +{"version":3,"file":"digest-generator.js","sourceRoot":"","sources":["digest-generator.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/apps/worker/src/digest/digest-generator.ts b/apps/worker/src/digest/digest-generator.ts new file mode 100644 index 0000000..eda9fad --- /dev/null +++ b/apps/worker/src/digest/digest-generator.ts @@ -0,0 +1,75 @@ +export interface DigestResult { + text: string; + messageIds: string[]; +} + +const MAX_MESSAGES = 20; +const PREVIEW_LENGTH = 120; + +function formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }); +} + +export async function buildTemplateDigest( + tenantId: string, + tenantName: string, + prisma: any, + digestDate: Date, +): Promise { + const from = new Date(digestDate); + const to = new Date(digestDate.getTime() + 24 * 60 * 60 * 1000); + + const messages = await prisma.message.findMany({ + where: { + tenantId, + status: { in: ['APPROVED', 'DISTRIBUTED'] }, + updatedAt: { gte: from, lt: to }, + }, + include: { + sourceGroup: { select: { name: true } }, + }, + orderBy: { updatedAt: 'asc' }, + take: MAX_MESSAGES, + }); + + if (messages.length === 0) return null; + + // Group messages by first tag; untagged go under "General" + const groups = new Map(); + for (const msg of messages) { + const tag = msg.tags?.[0] ?? 'General'; + if (!groups.has(tag)) groups.set(tag, []); + groups.get(tag)!.push(msg); + } + + const dateLabel = formatDate(digestDate); + const lines: string[] = [ + `📋 *Daily Digest — ${tenantName}*`, + `_${dateLabel}_`, + '', + ]; + + for (const [tag, msgs] of groups) { + const sectionHeader = tag === 'General' ? '*📌 General*' : `*🏷️ ${tag}*`; + lines.push(sectionHeader); + for (const msg of msgs) { + const preview = msg.content.slice(0, PREVIEW_LENGTH).replace(/\n+/g, ' '); + const groupName = msg.sourceGroup?.name ?? 'Unknown Group'; + lines.push(`• ${preview} _(${groupName})_`); + } + lines.push(''); + } + + const groupCount = new Set(messages.map((m: any) => m.sourceGroupId)).size; + lines.push(`_${messages.length} message${messages.length !== 1 ? 's' : ''} from ${groupCount} group${groupCount !== 1 ? 's' : ''} | TOWER_`); + + return { + text: lines.join('\n'), + messageIds: messages.map((m: any) => m.id), + }; +} diff --git a/apps/worker/src/digest/digest-scheduler.ts b/apps/worker/src/digest/digest-scheduler.ts new file mode 100644 index 0000000..e0ea0ff --- /dev/null +++ b/apps/worker/src/digest/digest-scheduler.ts @@ -0,0 +1,70 @@ +import { Queue } from 'bullmq'; +import { DigestJobData } from '@tower/types'; +import { createLogger } from '@tower/logger'; + +const TICK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +function todayMidnightUtc(): Date { + const now = new Date(); + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); +} + +function isScheduleReached(scheduleHour: number, scheduleMinute: number): boolean { + const now = new Date(); + const currentMinutes = now.getUTCHours() * 60 + now.getUTCMinutes(); + const scheduledMinutes = scheduleHour * 60 + scheduleMinute; + return currentMinutes >= scheduledMinutes; +} + +function hasAlreadySentToday(lastSentAt: Date | null, todayMidnight: Date): boolean { + if (!lastSentAt) return false; + return lastSentAt >= todayMidnight; +} + +export function startDigestScheduler( + prisma: any, + digestQueue: Queue, + logger: ReturnType, +): void { + let running = false; + + const tick = async (): Promise => { + if (running) return; + running = true; + try { + const configs = await prisma.digestConfig.findMany({ + where: { isActive: true }, + }); + + const todayMidnight = todayMidnightUtc(); + + for (const config of configs) { + try { + if (!isScheduleReached(config.scheduleHour, config.scheduleMinute)) continue; + if (hasAlreadySentToday(config.lastSentAt, todayMidnight)) continue; + + await digestQueue.add('digest', { + tenantId: config.tenantId, + digestDate: todayMidnight.toISOString(), + triggeredBy: 'schedule', + }, { + attempts: 3, + backoff: { type: 'exponential', delay: 2000 }, + }); + + logger.info({ tenantId: config.tenantId }, 'Digest job enqueued by scheduler'); + } catch (err) { + logger.error({ err, tenantId: config.tenantId }, 'Failed to enqueue digest job for tenant'); + } + } + } catch (err) { + logger.error({ err }, 'digest-scheduler tick failed'); + } finally { + running = false; + } + }; + + setTimeout(() => void tick(), 10_000); + setInterval(() => void tick(), TICK_INTERVAL_MS); + logger.info(`digest-scheduler loop scheduled (every ${TICK_INTERVAL_MS / 60_000}m)`); +} diff --git a/apps/worker/src/expire/expire-scheduler.ts b/apps/worker/src/expire/expire-scheduler.ts new file mode 100644 index 0000000..9124081 --- /dev/null +++ b/apps/worker/src/expire/expire-scheduler.ts @@ -0,0 +1,34 @@ +import { createLogger } from '@tower/logger'; + +const SIX_HOURS_MS = 6 * 60 * 60 * 1000; + +export function startExpireScheduler(prisma: any, logger = createLogger('expire-scheduler')): void { + let running = false; + + const tick = async () => { + if (running) return; + running = true; + try { + const now = new Date(); + const result = await prisma.message.deleteMany({ + where: { + expiresAt: { lt: now }, + status: { in: ['RAW', 'DNC'] }, + }, + }); + if (result.count > 0) { + logger.info({ deleted: result.count }, 'Expired RAW/DNC messages purged'); + } + } catch (err) { + logger.error({ err }, 'Expire scheduler tick failed'); + } finally { + running = false; + } + }; + + // Run once at startup to clear any messages that expired while the worker was down + tick().catch((err: unknown) => logger.error({ err }, 'Expire scheduler initial tick failed')); + + setInterval(tick, SIX_HOURS_MS); + logger.info('Expire scheduler started (6h interval)'); +} diff --git a/apps/worker/src/outbox/outbox-listener.ts b/apps/worker/src/outbox/outbox-listener.ts new file mode 100644 index 0000000..64e8740 --- /dev/null +++ b/apps/worker/src/outbox/outbox-listener.ts @@ -0,0 +1,100 @@ +import { Client } from 'pg'; +import { Queue } from 'bullmq'; +import { ClassifyJobData, ThreadResolveJobData } from '@tower/types'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('outbox-listener'); +const MAX_ATTEMPTS = 5; + +async function processEvent( + event: { id: string; type: string; payload: unknown }, + classifyQueue: Queue, + threadResolveQueue: Queue, + prisma: any, +): Promise { + if (event.type === 'classify') { + await classifyQueue.add('classify', event.payload as ClassifyJobData, { + attempts: 3, + backoff: { type: 'exponential', delay: 1000 }, + }); + } else if (event.type === 'thread_resolve') { + await threadResolveQueue.add('thread-resolve', event.payload as ThreadResolveJobData, { + attempts: 2, + }); + } else { + logger.warn({ type: event.type, eventId: event.id }, 'Unknown outbox event type — skipping'); + } + await prisma.outboxEvent.delete({ where: { id: event.id } }); +} + +// One-time sweep at startup: drain events that arrived while the worker was offline +async function drainPending( + prisma: any, + classifyQueue: Queue, + threadResolveQueue: Queue, +): Promise { + const events = await prisma.outboxEvent.findMany({ + where: { attempts: { lt: MAX_ATTEMPTS } }, + orderBy: { createdAt: 'asc' }, + take: 500, + }); + + if (events.length > 0) { + logger.info({ count: events.length }, 'Draining pending outbox events from downtime'); + } + + for (const event of events) { + try { + await processEvent(event, classifyQueue, threadResolveQueue, prisma); + logger.info({ eventId: event.id, type: event.type }, 'Startup drain: processed'); + } catch (err) { + await prisma.outboxEvent.update({ + where: { id: event.id }, + data: { attempts: { increment: 1 }, lastError: String(err) }, + }); + logger.warn({ err, eventId: event.id }, 'Startup drain: event failed'); + } + } +} + +export async function startOutboxListener( + databaseUrl: string, + prisma: any, + classifyQueue: Queue, + threadResolveQueue: Queue, +): Promise { + await drainPending(prisma, classifyQueue, threadResolveQueue); + + // Dedicated pg connection for LISTEN — Prisma does not support LISTEN/NOTIFY + const pgClient = new Client({ connectionString: databaseUrl }); + await pgClient.connect(); + await pgClient.query('LISTEN outbox_event'); + + pgClient.on('notification', (msg) => { + const eventId = msg.payload; + if (!eventId) return; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + prisma.outboxEvent + .findUnique({ where: { id: eventId } }) + .then(async (event: { id: string; type: string; payload: unknown; attempts: number } | null) => { + if (!event || event.attempts >= MAX_ATTEMPTS) return; + try { + await processEvent(event, classifyQueue, threadResolveQueue, prisma); + logger.info({ eventId, type: event.type }, 'Outbox event processed'); + } catch (err) { + await prisma.outboxEvent.update({ + where: { id: event.id }, + data: { attempts: { increment: 1 }, lastError: String(err) }, + }); + logger.warn({ err, eventId }, 'Outbox event failed — incremented attempts'); + } + }) + .catch((err: unknown) => logger.error({ err, eventId }, 'Outbox notification handler error')); + }); + + pgClient.on('error', (err) => logger.error({ err }, 'Outbox pg client error')); + + logger.info('Outbox listener ready'); + return pgClient; +} diff --git a/apps/worker/src/queues/classify.processor.ts b/apps/worker/src/queues/classify.processor.ts new file mode 100644 index 0000000..574df9c --- /dev/null +++ b/apps/worker/src/queues/classify.processor.ts @@ -0,0 +1,180 @@ +import { Worker, Queue } from 'bullmq'; +import { ClassifyJobData, ForwardJobData, IndexJobData, ReviewJobData, P1JobData, DncJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; +import { matchContentRules, TenantRuleRow } from '../whatsapp/match-rules'; +import { approveMessage } from '../core/approve-message'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('classify-processor'); + +export async function processClassifyJob( + job: ClassifyJobData, + prisma: any, + pool: any, + forwardQueue: Queue, + indexQueue: Queue, + reviewQueue: Queue, + p1Queue: Queue, + dncQueue: Queue, +): Promise { + const message = await prisma.message.findUnique({ + where: { id: job.messageId }, + select: { id: true, content: true, tags: true, status: true, tenantId: true, sourceGroupId: true, senderJid: true, platform: true, senderName: true, policyVersion: true }, + }); + + if (!message) { + logger.warn({ messageId: job.messageId }, 'Message not found — skipping classify'); + return; + } + + // Idempotent guard — only process RAW messages + if (message.status !== 'RAW') { + logger.debug({ messageId: job.messageId, status: message.status }, 'Message already classified — skipping'); + return; + } + + // ── Step 1: Hashtag/prefix rules — org rules first (authoritative), then tenant ── + const tenant = await prisma.tenant.findUnique({ + where: { id: job.tenantId }, + select: { organizationId: true }, + }); + + const [orgRuleRows, tenantRuleRows] = await Promise.all([ + tenant?.organizationId + ? (prisma.orgRule.findMany({ + where: { organizationId: tenant.organizationId, isActive: true }, + select: { id: true, matchType: true, matchValue: true, action: true, priority: true }, + orderBy: { priority: 'asc' }, + }) as Promise) + : Promise.resolve([] as TenantRuleRow[]), + prisma.tenantRule.findMany({ + where: { tenantId: job.tenantId, isActive: true }, + select: { id: true, matchType: true, matchValue: true, action: true, priority: true }, + orderBy: { priority: 'asc' }, + }) as Promise, + ]); + + const orgResult = matchContentRules(message.content, orgRuleRows); + const { tags, effectiveAction } = orgResult.effectiveAction + ? orgResult + : matchContentRules(message.content, tenantRuleRows); + + if (effectiveAction === 'SKIP' || effectiveAction === 'REJECT') { + await prisma.message.update({ where: { id: job.messageId }, data: { status: 'DNC', tags } }); + await dncQueue.add('dnc', { + tenantId: job.tenantId, + sourceGroupId: job.sourceGroupId, + accountId: job.accountId, + reason: 'rule_skip', + timestamp: new Date().toISOString(), + policyVersion: 'v1', + }, { attempts: 1 }).catch((err: unknown) => logger.warn({ err }, 'Failed to enqueue DNC job')); + logger.info({ messageId: job.messageId, effectiveAction }, 'Message DNC by rule'); + return; + } + + if (effectiveAction === 'P1') { + await prisma.message.update({ where: { id: job.messageId }, data: { status: 'PENDING', expiresAt: null, tags } }); + await p1Queue.add('p1', { + tenantId: job.tenantId, + messageId: job.messageId, + content: message.content, + sourceGroupId: job.sourceGroupId, + senderJid: job.senderJid, + senderName: message.senderName, + tags, + policyVersion: 'v1', + }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } }).catch((err: unknown) => + logger.error({ err, messageId: job.messageId }, 'Failed to enqueue P1 job'), + ); + logger.warn({ messageId: job.messageId, tags }, 'P1 urgent message — admin review required'); + return; + } + + if (effectiveAction === 'FLAG') { + await prisma.message.update({ where: { id: job.messageId }, data: { status: 'PENDING', expiresAt: null, tags } }); + await reviewQueue.add('review', { + tenantId: job.tenantId, + messageId: job.messageId, + sourceGroupId: job.sourceGroupId, + tags, + reason: 'flagged_by_rule', + policyVersion: 'v1', + }, { attempts: 1 }).catch((err: unknown) => + logger.warn({ err, messageId: job.messageId }, 'Failed to enqueue review job'), + ); + logger.info({ messageId: job.messageId, tags }, 'Message flagged by rule → PENDING'); + return; + } + + if (effectiveAction === 'AUTO_APPROVE') { + // Downgrade to FLAG if sender is not a group admin + let isAdmin = false; + try { + const meta = await pool.getGroupMeta?.(job.sourceGroupJid, job.accountId) ?? + (pool.get?.(job.accountId) ? await pool.get(job.accountId).groupMetadata(job.sourceGroupJid).catch(() => null) : null); + isAdmin = (meta?.participants ?? []).some( + (p: any) => p.jid === job.senderJid && (p.admin === 'admin' || p.admin === 'superadmin'), + ); + } catch { + isAdmin = false; + } + + await prisma.message.update({ where: { id: job.messageId }, data: { status: 'PENDING', expiresAt: null, tags } }); + + if (isAdmin) { + const result = await approveMessage(job.messageId, job.tenantId, 'system', prisma); + if (result) { + for (const fwd of result.forwardJobs) { + await forwardQueue.add('forward', fwd, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } }); + } + await indexQueue.add('index', result.indexDoc, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } }); + logger.info({ messageId: job.messageId, forwardCount: result.forwardJobs.length }, 'Message auto-approved'); + } + } else { + await reviewQueue.add('review', { + tenantId: job.tenantId, + messageId: job.messageId, + sourceGroupId: job.sourceGroupId, + tags, + reason: 'auto_approve_downgraded', + policyVersion: 'v1', + }, { attempts: 1 }).catch((err: unknown) => logger.warn({ err }, 'Failed to enqueue review job')); + logger.info({ messageId: job.messageId }, 'AUTO_APPROVE downgraded — sender not admin'); + } + return; + } + + // ── Step 2: No rule matched → DNC (expires naturally at 72h) ──────────── + await prisma.message.update({ where: { id: job.messageId }, data: { status: 'DNC' } }); + await dncQueue.add('dnc', { + tenantId: job.tenantId, + sourceGroupId: job.sourceGroupId, + accountId: job.accountId, + reason: 'no_rule_match', + timestamp: new Date().toISOString(), + policyVersion: 'v1', + }, { attempts: 1 }).catch((err: unknown) => logger.warn({ err }, 'Failed to enqueue DNC job')); + logger.debug({ messageId: job.messageId }, 'No rule matched — DNC'); +} + +export function createClassifyWorker( + redisUrl: string, + prisma: any, + pool: any, + forwardQueue: Queue, + indexQueue: Queue, + reviewQueue: Queue, + p1Queue: Queue, + dncQueue: Queue, +): Worker { + return new Worker( + 'tower.classify.v1', + async (job) => + processClassifyJob( + job.data, prisma, pool, + forwardQueue, indexQueue, reviewQueue, p1Queue, dncQueue, + ), + { connection: parseRedisUrl(redisUrl), concurrency: 5 }, + ); +} diff --git a/apps/worker/src/queues/classify.queue.ts b/apps/worker/src/queues/classify.queue.ts new file mode 100644 index 0000000..8a8c2ab --- /dev/null +++ b/apps/worker/src/queues/classify.queue.ts @@ -0,0 +1,7 @@ +import { Queue } from 'bullmq'; +import { ClassifyJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; + +export function createClassifyQueue(redisUrl: string): Queue { + return new Queue('tower.classify.v1', { connection: parseRedisUrl(redisUrl) }); +} diff --git a/apps/worker/src/queues/digest.processor.ts b/apps/worker/src/queues/digest.processor.ts new file mode 100644 index 0000000..7484373 --- /dev/null +++ b/apps/worker/src/queues/digest.processor.ts @@ -0,0 +1,120 @@ +import { Worker } from 'bullmq'; +import { DigestJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; +import { callLLM } from '../ai/llm-client'; +import { buildTemplateDigest } from '../digest/digest-generator'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('digest-processor'); + +const WINDOW_MS = 24 * 60 * 60 * 1000; + +function buildLLMPrompt(messages: Array<{ content: string; tags: string[]; status: string }>, tenantName: string, dateLabel: string): string { + const lines = messages.map((m, i) => { + const label = m.status === 'APPROVED' || m.status === 'DISTRIBUTED' ? '[curated]' : '[community]'; + return `${i + 1}. ${label} ${m.content.slice(0, 200)}`; + }); + return `You are a community digest writer for "${tenantName}", a South Asian diaspora community. +Write a friendly, concise WhatsApp digest summarizing these ${messages.length} community messages from ${dateLabel}. +Use WhatsApp markdown (*bold*, _italic_, bullet points with •). +Keep it under 800 characters. Group related items. Start with a greeting line. + +Messages: +${lines.join('\n')} + +Digest:`; +} + +export async function processDigestJob( + job: DigestJobData, + prisma: any, + pool: any, + openRouterApiKey: string | undefined, +): Promise { + const config = await prisma.digestConfig.findUnique({ + where: { tenantId: job.tenantId }, + include: { tenant: { select: { name: true } } }, + }); + + if (!config || !config.isActive) { + logger.info({ tenantId: job.tenantId }, 'Digest skipped — no active config'); + return; + } + + const digestDate = new Date(job.digestDate); + const windowStart = digestDate; + const windowEnd = new Date(digestDate.getTime() + WINDOW_MS); + + // Fetch APPROVED/DISTRIBUTED messages + AI-tagged RAW messages + const messages = await prisma.message.findMany({ + where: { + tenantId: job.tenantId, + OR: [ + { status: { in: ['APPROVED', 'DISTRIBUTED'] }, updatedAt: { gte: windowStart, lt: windowEnd } }, + { status: 'RAW', createdAt: { gte: windowStart, lt: windowEnd } }, + ], + }, + include: { sourceGroup: { select: { name: true } } }, + orderBy: { createdAt: 'asc' }, + take: 30, + }); + + if (messages.length === 0) { + await prisma.digestConfig.update({ where: { id: config.id }, data: { lastSentAt: new Date() } }); + logger.info({ tenantId: job.tenantId }, 'Digest skipped — no messages in window'); + return; + } + + let text: string; + + if (openRouterApiKey) { + try { + const dateLabel = digestDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }); + const prompt = buildLLMPrompt(messages, config.tenant.name, dateLabel); + text = await callLLM(prompt, openRouterApiKey); + } catch (err) { + logger.warn({ err, tenantId: job.tenantId }, 'LLM call failed — falling back to template'); + const fallback = await buildTemplateDigest(job.tenantId, config.tenant.name, prisma, digestDate); + if (!fallback) { + await prisma.digestConfig.update({ where: { id: config.id }, data: { lastSentAt: new Date() } }); + return; + } + text = fallback.text; + } + } else { + const fallback = await buildTemplateDigest(job.tenantId, config.tenant.name, prisma, digestDate); + if (!fallback) { + await prisma.digestConfig.update({ where: { id: config.id }, data: { lastSentAt: new Date() } }); + return; + } + text = fallback.text; + } + + await pool.sendMessage(config.targetAccountId, config.targetGroupJid, text); + + const messageIds = messages.map((m: any) => m.id); + + await prisma.$transaction([ + prisma.digest.upsert({ + where: { tenantId_digestDate: { tenantId: job.tenantId, digestDate } }, + create: { tenantId: job.tenantId, content: text, messageIds, digestDate }, + update: { content: text, messageIds, sentAt: new Date() }, + }), + prisma.digestConfig.update({ where: { id: config.id }, data: { lastSentAt: new Date() } }), + ]); + + logger.info({ tenantId: job.tenantId, messageCount: messageIds.length, triggeredBy: job.triggeredBy }, 'Digest sent'); +} + +export function createDigestWorker( + redisUrl: string, + prisma: any, + pool: any, + openRouterApiKey: string | undefined, +): Worker { + return new Worker( + 'tower.digest.generate.v1', + async (job) => processDigestJob(job.data, prisma, pool, openRouterApiKey), + { connection: parseRedisUrl(redisUrl) }, + ); +} diff --git a/apps/worker/src/queues/digest.queue.ts b/apps/worker/src/queues/digest.queue.ts new file mode 100644 index 0000000..f6e0f29 --- /dev/null +++ b/apps/worker/src/queues/digest.queue.ts @@ -0,0 +1,7 @@ +import { Queue } from 'bullmq'; +import { DigestJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; + +export function createDigestQueue(redisUrl: string): Queue { + return new Queue('tower.digest.generate.v1', { connection: parseRedisUrl(redisUrl) }); +} diff --git a/apps/worker/src/queues/dnc.processor.ts b/apps/worker/src/queues/dnc.processor.ts new file mode 100644 index 0000000..029add1 --- /dev/null +++ b/apps/worker/src/queues/dnc.processor.ts @@ -0,0 +1,29 @@ +import { Worker } from 'bullmq'; +import { DncJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('dnc-processor'); + +export async function processDncJob(job: DncJobData): Promise { + // 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( + { + tenantId: job.tenantId, + sourceGroupId: job.sourceGroupId, + reason: job.reason, + timestamp: job.timestamp, + }, + '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 { + return new Worker( + 'tower.policy.dnc.v1', + async (job) => processDncJob(job.data), + { connection: parseRedisUrl(redisUrl) }, + ); +} diff --git a/apps/worker/src/queues/dnc.queue.ts b/apps/worker/src/queues/dnc.queue.ts new file mode 100644 index 0000000..ead519c --- /dev/null +++ b/apps/worker/src/queues/dnc.queue.ts @@ -0,0 +1,7 @@ +import { Queue } from 'bullmq'; +import { DncJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; + +export function createDncQueue(redisUrl: string): Queue { + return new Queue('tower.policy.dnc.v1', { connection: parseRedisUrl(redisUrl) }); +} diff --git a/apps/worker/src/queues/p1.processor.ts b/apps/worker/src/queues/p1.processor.ts new file mode 100644 index 0000000..7dd8fd0 --- /dev/null +++ b/apps/worker/src/queues/p1.processor.ts @@ -0,0 +1,52 @@ +import { Worker } from 'bullmq'; +import { P1JobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('p1-processor'); + +export async function processP1Job(job: P1JobData, prisma: any): Promise { + // P1 urgent messages stay PENDING — admin must explicitly approve before multicast. + // Log at WARN so this is visible in any log aggregator without a dashboard. + logger.warn( + { + tenantId: job.tenantId, + messageId: job.messageId, + sourceGroupId: job.sourceGroupId, + tags: job.tags, + contentPreview: job.content.slice(0, 120), + }, + 'P1 URGENT — admin review required before multicast', + ); + + // Write a high-priority audit event so the admin portal can surface it distinctly. + await prisma.auditEvent + .create({ + data: { + tenantId: job.tenantId, + actorType: 'SYSTEM', + action: 'P1_REVIEW_REQUESTED', + resourceType: 'Message', + resourceId: job.messageId, + payload: { + sourceGroupId: job.sourceGroupId, + senderJid: job.senderJid, + tags: job.tags, + contentPreview: job.content.slice(0, 200), + }, + }, + }) + .catch((err: unknown) => + logger.error({ err, messageId: job.messageId }, 'Failed to write P1_REVIEW_REQUESTED audit event'), + ); + + // Phase 2: send an admin DM alert via the WhatsApp bot and/or push notification. +} + +export function createP1Worker(redisUrl: string, prisma: any): Worker { + return new Worker( + 'tower.urgent.p1.review.v1', + async (job) => processP1Job(job.data, prisma), + { connection: parseRedisUrl(redisUrl) }, + ); +} diff --git a/apps/worker/src/queues/p1.queue.ts b/apps/worker/src/queues/p1.queue.ts new file mode 100644 index 0000000..03ab405 --- /dev/null +++ b/apps/worker/src/queues/p1.queue.ts @@ -0,0 +1,7 @@ +import { Queue } from 'bullmq'; +import { P1JobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; + +export function createP1Queue(redisUrl: string): Queue { + return new Queue('tower.urgent.p1.review.v1', { connection: parseRedisUrl(redisUrl) }); +} diff --git a/apps/worker/src/queues/review.processor.ts b/apps/worker/src/queues/review.processor.ts new file mode 100644 index 0000000..196a0a2 --- /dev/null +++ b/apps/worker/src/queues/review.processor.ts @@ -0,0 +1,30 @@ +import { Worker } from 'bullmq'; +import { ReviewJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('review-processor'); + +export async function processReviewJob(job: ReviewJobData): Promise { + logger.info( + { + tenantId: job.tenantId, + messageId: job.messageId, + sourceGroupId: job.sourceGroupId, + tags: job.tags, + reason: job.reason, + }, + 'Message queued for admin review', + ); + // Phase 1: admin discovers pending messages via portal polling (GET /messages/pending). + // Phase 2: replace this processor with a WebSocket push or admin DM notification + // so the admin is alerted in real-time without polling. +} + +export function createReviewWorker(redisUrl: string): Worker { + return new Worker( + 'tower.review.requested.v1', + async (job) => processReviewJob(job.data), + { connection: parseRedisUrl(redisUrl) }, + ); +} diff --git a/apps/worker/src/queues/review.queue.ts b/apps/worker/src/queues/review.queue.ts new file mode 100644 index 0000000..574c13a --- /dev/null +++ b/apps/worker/src/queues/review.queue.ts @@ -0,0 +1,7 @@ +import { Queue } from 'bullmq'; +import { ReviewJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; + +export function createReviewQueue(redisUrl: string): Queue { + return new Queue('tower.review.requested.v1', { connection: parseRedisUrl(redisUrl) }); +} diff --git a/apps/worker/src/queues/thread-resolve.processor.ts b/apps/worker/src/queues/thread-resolve.processor.ts new file mode 100644 index 0000000..3437719 --- /dev/null +++ b/apps/worker/src/queues/thread-resolve.processor.ts @@ -0,0 +1,63 @@ +import { Worker } from 'bullmq'; +import { ThreadResolveJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('thread-resolve-processor'); + +export async function processThreadResolveJob( + job: ThreadResolveJobData, + prisma: any, +): Promise { + // Only quoted replies create/join threads to avoid thousands of 1-message threads + if (!job.quotedPlatformMsgId) return; + + let threadId: string; + + const quoted = await prisma.message.findFirst({ + where: { platformMsgId: job.quotedPlatformMsgId, sourceGroupId: job.sourceGroupId }, + select: { id: true, threadId: true }, + }); + + if (quoted) { + if (quoted.threadId) { + threadId = quoted.threadId; + } else { + const thread = await prisma.thread.create({ + data: { tenantId: job.tenantId, sourceGroupId: job.sourceGroupId }, + }); + threadId = thread.id; + // Backfill the quoted message into the thread + await prisma.message.update({ where: { id: quoted.id }, data: { threadId } }); + await prisma.thread.update({ + where: { id: threadId }, + data: { messageCount: { increment: 1 }, lastActivityAt: new Date() }, + }); + } + } else { + // Quoted message not in our DB (e.g. it was sent before TOWER was set up) + const thread = await prisma.thread.create({ + data: { tenantId: job.tenantId, sourceGroupId: job.sourceGroupId }, + }); + threadId = thread.id; + } + + await prisma.message.update({ where: { id: job.messageId }, data: { threadId } }); + await prisma.thread.update({ + where: { id: threadId }, + data: { messageCount: { increment: 1 }, lastActivityAt: new Date() }, + }); + + logger.info({ messageId: job.messageId, threadId }, 'Thread resolved'); +} + +export function createThreadResolveWorker( + redisUrl: string, + prisma: any, +): Worker { + return new Worker( + 'tower.thread.resolve.v1', + async (job) => processThreadResolveJob(job.data, prisma), + { connection: parseRedisUrl(redisUrl), concurrency: 3 }, + ); +} diff --git a/apps/worker/src/queues/thread-resolve.queue.ts b/apps/worker/src/queues/thread-resolve.queue.ts new file mode 100644 index 0000000..35ab210 --- /dev/null +++ b/apps/worker/src/queues/thread-resolve.queue.ts @@ -0,0 +1,7 @@ +import { Queue } from 'bullmq'; +import { ThreadResolveJobData } from '@tower/types'; +import { parseRedisUrl } from './redis-connection'; + +export function createThreadResolveQueue(redisUrl: string): Queue { + return new Queue('tower.thread.resolve.v1', { connection: parseRedisUrl(redisUrl) }); +} diff --git a/apps/worker/src/whatsapp/baileys-adapter.ts b/apps/worker/src/whatsapp/baileys-adapter.ts new file mode 100644 index 0000000..caef51b --- /dev/null +++ b/apps/worker/src/whatsapp/baileys-adapter.ts @@ -0,0 +1,202 @@ +import { ChannelAdapter, AdapterHandlers, AdapterGroupMeta, AdapterOutboundCommand } from '@tower/types'; +import { createLogger } from '@tower/logger'; +import { WhatsAppSessionPool } from './session-pool'; +import { syncGroups } from './group-sync'; +import { startOtpSenderLoop } from './otp-sender'; +import { handleCommand } from './command-handler'; + +const logger = createLogger('baileys-adapter'); + +export class BaileysAdapter implements ChannelAdapter { + readonly platform = 'whatsapp'; + + private groupMaps = new Map>(); + private accountPollTimer: ReturnType | null = null; + private groupSyncTimer: ReturnType | null = null; + + constructor( + private readonly prisma: any, + private readonly pool: WhatsAppSessionPool, + ) {} + + async start(handlers: AdapterHandlers): Promise { + const accounts = await this.prisma.account.findMany({ + where: { + isBot: true, + status: { in: ['ACTIVE', 'DISCONNECTED', 'PAIRING'] }, + platform: 'whatsapp', + }, + select: { id: true, sessionPath: true }, + }); + + if (accounts.length === 0) { + logger.warn('No bot accounts found — pair one via /settings/bot'); + } + + for (const account of accounts) { + await this.startAccount(account, handlers); + } + + this.startPollingLoops(handlers); + startOtpSenderLoop(this.prisma, this.pool, logger); + logger.info({ accountCount: accounts.length }, 'BaileysAdapter started'); + } + + private async startAccount( + account: { id: string; sessionPath: string }, + handlers: AdapterHandlers, + ): Promise { + this.groupMaps.set(account.id, new Map()); + try { + await this.pool.add( + account.id, + account.sessionPath, + async (msg, accountId) => { + logger.info( + { groupJid: msg.sourceGroupJid, senderJid: msg.senderJid, content: msg.content?.slice(0, 80) }, + 'Message received', + ); + + // Commands (STOP/START/PORTAL) run before the groupMap check so they work + // even if the group hasn't been synced yet. + await handleCommand(msg, accountId, this.prisma, this.pool).catch((err: unknown) => + logger.error({ err }, 'Command handler error'), + ); + + const groupMap = this.groupMaps.get(accountId); + if (!groupMap) { + logger.error({ accountId }, 'No group map for account — message dropped'); + return; + } + const sourceGroupId = groupMap.get(msg.sourceGroupJid); + if (!sourceGroupId) { + logger.warn({ jid: msg.sourceGroupJid, accountId }, 'Unknown group — skipping message'); + return; + } + + await handlers.onMessage({ + platformMsgId: msg.platformMsgId, + sourceGroupJid: msg.sourceGroupJid, + sourceGroupId, + senderJid: msg.senderJid, + senderName: msg.senderName ?? null, + content: msg.content, + accountId, + quotedPlatformMsgId: msg.quotedPlatformMsgId, + }); + }, + async (reaction) => { + if (handlers.onReaction) { + await handlers.onReaction(reaction).catch((err: unknown) => + logger.error({ err }, 'Reaction handler error'), + ); + } + }, + async (groups, accountId) => { + logger.info({ count: Object.keys(groups).length, accountId }, 'Syncing groups'); + const map = await syncGroups(groups, accountId, this.prisma, this.pool); + this.groupMaps.set(accountId, map); + }, + async (qr, accountId) => { + await this.prisma.account + .update({ where: { id: accountId }, data: { qrCode: qr, status: 'PAIRING' } }) + .catch((err: unknown) => logger.error({ accountId, err }, 'Failed to store QR in DB')); + logger.info({ accountId }, 'QR code updated'); + }, + async (status, accountId, jid?) => { + if (status === 'connected') { + await this.prisma.account + .update({ + where: { id: accountId }, + data: { qrCode: null, status: 'ACTIVE', ...(jid ? { jid } : {}) }, + }) + .catch((err: unknown) => logger.error({ accountId, err }, 'Failed to update account status')); + logger.info({ accountId, jid }, 'Account connected — QR cleared'); + } else if (status === 'logged_out') { + await this.prisma.account + .update({ where: { id: accountId }, data: { status: 'DISCONNECTED' } }) + .catch((err: unknown) => logger.error({ accountId, err }, 'Failed to update account status')); + logger.info({ accountId }, 'Account logged out — awaiting QR scan'); + } else if (status === 'disconnected') { + await this.prisma.account + .update({ where: { id: accountId }, data: { status: 'DISCONNECTED' } }) + .catch((err: unknown) => logger.error({ accountId, err }, 'Failed to update account status')); + logger.info({ accountId }, 'Account disconnected'); + } + }, + ); + } catch (err) { + logger.error({ accountId: account.id, err }, 'Failed to start session — skipping account'); + } + } + + async send(command: AdapterOutboundCommand): Promise { + await this.pool.sendMessage(command.accountId, command.toGroupJid, command.text); + } + + async getGroupMeta(groupJid: string, accountId: string): Promise { + const sock = this.pool.get(accountId); + if (!sock) return null; + try { + const meta = await sock.groupMetadata(groupJid); + return { + jid: groupJid, + name: meta.subject ?? groupJid, + participants: (meta.participants ?? []).map((p: any) => ({ + jid: p.id, + isAdmin: p.admin === 'admin' || p.admin === 'superadmin', + })), + }; + } catch { + return null; + } + } + + async stop(): Promise { + if (this.accountPollTimer) clearInterval(this.accountPollTimer); + if (this.groupSyncTimer) clearInterval(this.groupSyncTimer); + this.accountPollTimer = null; + this.groupSyncTimer = null; + await this.pool.closeAll(); + } + + private startPollingLoops(handlers: AdapterHandlers): void { + this.accountPollTimer = setInterval(async () => { + try { + const all = await this.prisma.account.findMany({ + where: { + isBot: true, + status: { in: ['ACTIVE', 'DISCONNECTED', 'PAIRING'] }, + platform: 'whatsapp', + }, + select: { id: true, sessionPath: true }, + }); + for (const account of all) { + if (!this.pool.get(account.id)) { + logger.info({ accountId: account.id }, 'New bot account detected — starting session'); + await this.startAccount(account, handlers); + } + } + } catch (err) { + logger.error({ err }, 'Error polling for new accounts'); + } + }, 30_000); + + this.groupSyncTimer = setInterval(async () => { + try { + for (const [accountId, sock] of this.pool.getAll()) { + try { + const groups = await sock.groupFetchAllParticipating(); + logger.info({ count: Object.keys(groups).length, accountId }, 'Periodic group re-sync'); + const map = await syncGroups(groups, accountId, this.prisma, this.pool); + this.groupMaps.set(accountId, map); + } catch (err) { + logger.warn({ accountId, err }, 'Periodic group re-sync failed'); + } + } + } catch (err) { + logger.error({ err }, 'Error in periodic group re-sync loop'); + } + }, 60_000); + } +}