feat: add worker queue fabric — classify, dnc, p1, review, digest, thread-resolve, outbox, AI modules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface DigestResult {
|
||||||
|
text: string;
|
||||||
|
messageIds: string[];
|
||||||
|
}
|
||||||
|
export declare function generateDigest(tenantId: string, tenantName: string, prisma: any, digestDate: Date): Promise<DigestResult | null>;
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"digest-generator.js","sourceRoot":"","sources":["digest-generator.ts"],"names":[],"mappings":"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"}
|
||||||
@@ -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<DigestResult | null> {
|
||||||
|
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<string, typeof messages>();
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<DigestJobData>,
|
||||||
|
logger: ReturnType<typeof createLogger>,
|
||||||
|
): void {
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
const tick = async (): Promise<void> => {
|
||||||
|
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)`);
|
||||||
|
}
|
||||||
@@ -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)');
|
||||||
|
}
|
||||||
@@ -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<ClassifyJobData>,
|
||||||
|
threadResolveQueue: Queue<ThreadResolveJobData>,
|
||||||
|
prisma: any,
|
||||||
|
): Promise<void> {
|
||||||
|
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<ClassifyJobData>,
|
||||||
|
threadResolveQueue: Queue<ThreadResolveJobData>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<ClassifyJobData>,
|
||||||
|
threadResolveQueue: Queue<ThreadResolveJobData>,
|
||||||
|
): Promise<Client> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<ForwardJobData>,
|
||||||
|
indexQueue: Queue<IndexJobData>,
|
||||||
|
reviewQueue: Queue<ReviewJobData>,
|
||||||
|
p1Queue: Queue<P1JobData>,
|
||||||
|
dncQueue: Queue<DncJobData>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<TenantRuleRow[]>)
|
||||||
|
: 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<TenantRuleRow[]>,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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<ForwardJobData>,
|
||||||
|
indexQueue: Queue<IndexJobData>,
|
||||||
|
reviewQueue: Queue<ReviewJobData>,
|
||||||
|
p1Queue: Queue<P1JobData>,
|
||||||
|
dncQueue: Queue<DncJobData>,
|
||||||
|
): Worker<ClassifyJobData> {
|
||||||
|
return new Worker<ClassifyJobData>(
|
||||||
|
'tower.classify.v1',
|
||||||
|
async (job) =>
|
||||||
|
processClassifyJob(
|
||||||
|
job.data, prisma, pool,
|
||||||
|
forwardQueue, indexQueue, reviewQueue, p1Queue, dncQueue,
|
||||||
|
),
|
||||||
|
{ connection: parseRedisUrl(redisUrl), concurrency: 5 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { ClassifyJobData } from '@tower/types';
|
||||||
|
import { parseRedisUrl } from './redis-connection';
|
||||||
|
|
||||||
|
export function createClassifyQueue(redisUrl: string): Queue<ClassifyJobData> {
|
||||||
|
return new Queue<ClassifyJobData>('tower.classify.v1', { connection: parseRedisUrl(redisUrl) });
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<DigestJobData> {
|
||||||
|
return new Worker<DigestJobData>(
|
||||||
|
'tower.digest.generate.v1',
|
||||||
|
async (job) => processDigestJob(job.data, prisma, pool, openRouterApiKey),
|
||||||
|
{ connection: parseRedisUrl(redisUrl) },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { DigestJobData } from '@tower/types';
|
||||||
|
import { parseRedisUrl } from './redis-connection';
|
||||||
|
|
||||||
|
export function createDigestQueue(redisUrl: string): Queue<DigestJobData> {
|
||||||
|
return new Queue<DigestJobData>('tower.digest.generate.v1', { connection: parseRedisUrl(redisUrl) });
|
||||||
|
}
|
||||||
@@ -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<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(
|
||||||
|
{
|
||||||
|
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<DncJobData> {
|
||||||
|
return new Worker<DncJobData>(
|
||||||
|
'tower.policy.dnc.v1',
|
||||||
|
async (job) => processDncJob(job.data),
|
||||||
|
{ connection: parseRedisUrl(redisUrl) },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { DncJobData } from '@tower/types';
|
||||||
|
import { parseRedisUrl } from './redis-connection';
|
||||||
|
|
||||||
|
export function createDncQueue(redisUrl: string): Queue<DncJobData> {
|
||||||
|
return new Queue<DncJobData>('tower.policy.dnc.v1', { connection: parseRedisUrl(redisUrl) });
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
// 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<P1JobData> {
|
||||||
|
return new Worker<P1JobData>(
|
||||||
|
'tower.urgent.p1.review.v1',
|
||||||
|
async (job) => processP1Job(job.data, prisma),
|
||||||
|
{ connection: parseRedisUrl(redisUrl) },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { P1JobData } from '@tower/types';
|
||||||
|
import { parseRedisUrl } from './redis-connection';
|
||||||
|
|
||||||
|
export function createP1Queue(redisUrl: string): Queue<P1JobData> {
|
||||||
|
return new Queue<P1JobData>('tower.urgent.p1.review.v1', { connection: parseRedisUrl(redisUrl) });
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<ReviewJobData> {
|
||||||
|
return new Worker<ReviewJobData>(
|
||||||
|
'tower.review.requested.v1',
|
||||||
|
async (job) => processReviewJob(job.data),
|
||||||
|
{ connection: parseRedisUrl(redisUrl) },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { ReviewJobData } from '@tower/types';
|
||||||
|
import { parseRedisUrl } from './redis-connection';
|
||||||
|
|
||||||
|
export function createReviewQueue(redisUrl: string): Queue<ReviewJobData> {
|
||||||
|
return new Queue<ReviewJobData>('tower.review.requested.v1', { connection: parseRedisUrl(redisUrl) });
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
// 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<ThreadResolveJobData> {
|
||||||
|
return new Worker<ThreadResolveJobData>(
|
||||||
|
'tower.thread.resolve.v1',
|
||||||
|
async (job) => processThreadResolveJob(job.data, prisma),
|
||||||
|
{ connection: parseRedisUrl(redisUrl), concurrency: 3 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { ThreadResolveJobData } from '@tower/types';
|
||||||
|
import { parseRedisUrl } from './redis-connection';
|
||||||
|
|
||||||
|
export function createThreadResolveQueue(redisUrl: string): Queue<ThreadResolveJobData> {
|
||||||
|
return new Queue<ThreadResolveJobData>('tower.thread.resolve.v1', { connection: parseRedisUrl(redisUrl) });
|
||||||
|
}
|
||||||
@@ -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<string, Map<string, string>>();
|
||||||
|
private accountPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private groupSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: any,
|
||||||
|
private readonly pool: WhatsAppSessionPool,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async start(handlers: AdapterHandlers): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.pool.sendMessage(command.accountId, command.toGroupJid, command.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupMeta(groupJid: string, accountId: string): Promise<AdapterGroupMeta | null> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user