feat: enrich AI stream with replyToMessageId, sourceGroupName, organizationId + tenant/source filters

- Extract quotedPlatformMsgId from Baileys contextInfo.stanzaId in normalizer
- Thread quotedPlatformMsgId through IngestJobData → ingest processor → Message record
- Widen ingest processor group/tenant selects to include name and organizationId
- Add replyToMessageId, sourceGroupName, organizationId to StreamMessagePayload and XADD
- Update StreamRecord, parseFields, and streamMessages in AI stream service with new fields
- Add optional ?tenant= and ?source= SSE filters; cursor advances for filtered entries
  so Last-Event-ID resume remains accurate across reconnects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:07:42 +05:30
parent 5a0f7aa58f
commit e598073dc7
7 changed files with 39 additions and 15 deletions
@@ -24,13 +24,15 @@ export class AiStreamController {
@Sse('stream') @Sse('stream')
stream( stream(
@Query('after') after?: string, @Query('after') after?: string,
@Query('tenant') tenant?: string,
@Query('source') source?: string,
@Headers('last-event-id') lastEventId?: string, @Headers('last-event-id') lastEventId?: string,
): Observable<MessageEvent> { ): Observable<MessageEvent> {
// Last-Event-ID (set automatically by EventSource on reconnect) wins so the // Last-Event-ID (set automatically by EventSource on reconnect) wins so the
// client resumes exactly where it dropped. Otherwise honour ?after=, default // client resumes exactly where it dropped. Otherwise honour ?after=, default
// to '$' = only new messages from now on. // to '$' = only new messages from now on.
const cursor = lastEventId || after || '$'; const cursor = lastEventId || after || '$';
return this.service.streamMessages(cursor); return this.service.streamMessages(cursor, { tenantId: tenant, sourceGroupId: source });
} }
@Post('drafts') @Post('drafts')
@@ -18,6 +18,9 @@ interface StreamRecord {
senderJid: string; senderJid: string;
senderName: string | null; senderName: string | null;
sourceGroupId: string; sourceGroupId: string;
sourceGroupName: string | null;
organizationId: string | null;
replyToMessageId: string | null;
tags: string[]; tags: string[];
effectiveAction: string | null; effectiveAction: string | null;
traceId: string | null; traceId: string | null;
@@ -46,7 +49,7 @@ export class AiStreamService {
* '0' → replay the entire retained stream (~50k messages) then follow live * '0' → replay the entire retained stream (~50k messages) then follow live
* '<id>' → resume strictly after that entry (used on reconnect) * '<id>' → resume strictly after that entry (used on reconnect)
*/ */
streamMessages(afterId: string): Observable<MessageEvent> { streamMessages(afterId: string, filters?: { tenantId?: string; sourceGroupId?: string }): Observable<MessageEvent> {
return new Observable<MessageEvent>((subscriber) => { return new Observable<MessageEvent>((subscriber) => {
const redis = new Redis(this.redisUrl, { maxRetriesPerRequest: null }); const redis = new Redis(this.redisUrl, { maxRetriesPerRequest: null });
let active = true; let active = true;
@@ -70,8 +73,11 @@ export class AiStreamService {
for (const [, entries] of res) { for (const [, entries] of res) {
for (const [id, fields] of entries) { for (const [id, fields] of entries) {
const record = parseFields(fields);
cursor = id; cursor = id;
subscriber.next({ id, data: parseFields(fields) } as MessageEvent); if (filters?.tenantId && record.tenantId !== filters.tenantId) continue;
if (filters?.sourceGroupId && record.sourceGroupId !== filters.sourceGroupId) continue;
subscriber.next({ id, data: record } as MessageEvent);
} }
} }
} catch (err) { } catch (err) {
@@ -142,6 +148,9 @@ function parseFields(fields: string[]): StreamRecord {
senderJid: obj.senderJid ?? '', senderJid: obj.senderJid ?? '',
senderName: obj.senderName || null, senderName: obj.senderName || null,
sourceGroupId: obj.sourceGroupId ?? '', sourceGroupId: obj.sourceGroupId ?? '',
sourceGroupName: obj.sourceGroupName || null,
organizationId: obj.organizationId || null,
replyToMessageId: obj.replyToMessageId || null,
tags, tags,
effectiveAction: obj.effectiveAction || null, effectiveAction: obj.effectiveAction || null,
traceId: obj.traceId || null, traceId: obj.traceId || null,
+1
View File
@@ -203,6 +203,7 @@ async function bootstrap() {
content: msg.content, content: msg.content,
tags, tags,
effectiveAction: finalAction ?? undefined, effectiveAction: finalAction ?? undefined,
quotedPlatformMsgId: msg.quotedPlatformMsgId,
}, },
{ attempts: 3, backoff: { type: 'exponential', delay: 1000 } }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
); );
+6 -2
View File
@@ -19,7 +19,7 @@ export async function processIngestJob(
// Defensive: drop messages from non-CLAIMED groups // Defensive: drop messages from non-CLAIMED groups
const group = await prisma.group.findUnique({ const group = await prisma.group.findUnique({
where: { id: job.sourceGroupId }, where: { id: job.sourceGroupId },
select: { claimStatus: true }, select: { claimStatus: true, name: true },
}); });
if (!group || group.claimStatus !== 'CLAIMED') { if (!group || group.claimStatus !== 'CLAIMED') {
return; return;
@@ -28,7 +28,7 @@ export async function processIngestJob(
// Safety net: drop if tenant is inactive or paused // Safety net: drop if tenant is inactive or paused
const tenant = await prisma.tenant.findUnique({ const tenant = await prisma.tenant.findUnique({
where: { id: job.tenantId }, where: { id: job.tenantId },
select: { isActive: true, isForwardingPaused: true }, select: { isActive: true, isForwardingPaused: true, organizationId: true },
}); });
if (!tenant || !tenant.isActive || tenant.isForwardingPaused) { if (!tenant || !tenant.isActive || tenant.isForwardingPaused) {
return; return;
@@ -86,6 +86,7 @@ export async function processIngestJob(
content: job.content, content: job.content,
tags: job.tags, tags: job.tags,
status: initialStatus, status: initialStatus,
quotedPlatformMsgId: job.quotedPlatformMsgId,
}, },
update: {}, update: {},
}); });
@@ -100,6 +101,9 @@ export async function processIngestJob(
senderJid: job.senderJid, senderJid: job.senderJid,
senderName: job.senderName, senderName: job.senderName,
sourceGroupId: job.sourceGroupId, sourceGroupId: job.sourceGroupId,
sourceGroupName: group.name,
organizationId: tenant.organizationId ?? undefined,
replyToMessageId: job.quotedPlatformMsgId,
tags: job.tags, tags: job.tags,
effectiveAction: job.effectiveAction ?? null, effectiveAction: job.effectiveAction ?? null,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -13,6 +13,9 @@ export interface StreamMessagePayload {
senderJid: string; senderJid: string;
senderName?: string; senderName?: string;
sourceGroupId: string; sourceGroupId: string;
sourceGroupName?: string;
organizationId?: string;
replyToMessageId?: string;
tags: string[]; tags: string[];
effectiveAction: string | null; effectiveAction: string | null;
traceId?: string; traceId?: string;
@@ -40,6 +43,9 @@ export async function publishToMessageStream(
'senderJid', payload.senderJid, 'senderJid', payload.senderJid,
'senderName', payload.senderName ?? '', 'senderName', payload.senderName ?? '',
'sourceGroupId', payload.sourceGroupId, 'sourceGroupId', payload.sourceGroupId,
'sourceGroupName', payload.sourceGroupName ?? '',
'organizationId', payload.organizationId ?? '',
'replyToMessageId', payload.replyToMessageId ?? '',
'tags', JSON.stringify(payload.tags), 'tags', JSON.stringify(payload.tags),
'effectiveAction', payload.effectiveAction ?? '', 'effectiveAction', payload.effectiveAction ?? '',
'traceId', payload.traceId ?? '', 'traceId', payload.traceId ?? '',
+1
View File
@@ -40,6 +40,7 @@ export function normalizeMessage(
senderName: msg.pushName ?? undefined, senderName: msg.pushName ?? undefined,
content, content,
accountId, accountId,
quotedPlatformMsgId: msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? undefined,
}; };
} }
+1
View File
@@ -70,6 +70,7 @@ export interface IngestJobData {
content: string; content: string;
tags: string[]; tags: string[];
effectiveAction?: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT' | 'P1'; effectiveAction?: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT' | 'P1';
quotedPlatformMsgId?: string;
} }
export interface ForwardJobData { export interface ForwardJobData {