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:
@@ -24,13 +24,15 @@ export class AiStreamController {
|
||||
@Sse('stream')
|
||||
stream(
|
||||
@Query('after') after?: string,
|
||||
@Query('tenant') tenant?: string,
|
||||
@Query('source') source?: string,
|
||||
@Headers('last-event-id') lastEventId?: string,
|
||||
): Observable<MessageEvent> {
|
||||
// Last-Event-ID (set automatically by EventSource on reconnect) wins so the
|
||||
// client resumes exactly where it dropped. Otherwise honour ?after=, default
|
||||
// to '$' = only new messages from now on.
|
||||
const cursor = lastEventId || after || '$';
|
||||
return this.service.streamMessages(cursor);
|
||||
return this.service.streamMessages(cursor, { tenantId: tenant, sourceGroupId: source });
|
||||
}
|
||||
|
||||
@Post('drafts')
|
||||
|
||||
@@ -18,6 +18,9 @@ interface StreamRecord {
|
||||
senderJid: string;
|
||||
senderName: string | null;
|
||||
sourceGroupId: string;
|
||||
sourceGroupName: string | null;
|
||||
organizationId: string | null;
|
||||
replyToMessageId: string | null;
|
||||
tags: string[];
|
||||
effectiveAction: string | null;
|
||||
traceId: string | null;
|
||||
@@ -46,7 +49,7 @@ export class AiStreamService {
|
||||
* '0' → replay the entire retained stream (~50k messages) then follow live
|
||||
* '<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) => {
|
||||
const redis = new Redis(this.redisUrl, { maxRetriesPerRequest: null });
|
||||
let active = true;
|
||||
@@ -70,8 +73,11 @@ export class AiStreamService {
|
||||
|
||||
for (const [, entries] of res) {
|
||||
for (const [id, fields] of entries) {
|
||||
const record = parseFields(fields);
|
||||
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) {
|
||||
@@ -142,6 +148,9 @@ function parseFields(fields: string[]): StreamRecord {
|
||||
senderJid: obj.senderJid ?? '',
|
||||
senderName: obj.senderName || null,
|
||||
sourceGroupId: obj.sourceGroupId ?? '',
|
||||
sourceGroupName: obj.sourceGroupName || null,
|
||||
organizationId: obj.organizationId || null,
|
||||
replyToMessageId: obj.replyToMessageId || null,
|
||||
tags,
|
||||
effectiveAction: obj.effectiveAction || null,
|
||||
traceId: obj.traceId || null,
|
||||
|
||||
@@ -203,6 +203,7 @@ async function bootstrap() {
|
||||
content: msg.content,
|
||||
tags,
|
||||
effectiveAction: finalAction ?? undefined,
|
||||
quotedPlatformMsgId: msg.quotedPlatformMsgId,
|
||||
},
|
||||
{ attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function processIngestJob(
|
||||
// Defensive: drop messages from non-CLAIMED groups
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { id: job.sourceGroupId },
|
||||
select: { claimStatus: true },
|
||||
select: { claimStatus: true, name: true },
|
||||
});
|
||||
if (!group || group.claimStatus !== 'CLAIMED') {
|
||||
return;
|
||||
@@ -28,7 +28,7 @@ export async function processIngestJob(
|
||||
// Safety net: drop if tenant is inactive or paused
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: job.tenantId },
|
||||
select: { isActive: true, isForwardingPaused: true },
|
||||
select: { isActive: true, isForwardingPaused: true, organizationId: true },
|
||||
});
|
||||
if (!tenant || !tenant.isActive || tenant.isForwardingPaused) {
|
||||
return;
|
||||
@@ -86,6 +86,7 @@ export async function processIngestJob(
|
||||
content: job.content,
|
||||
tags: job.tags,
|
||||
status: initialStatus,
|
||||
quotedPlatformMsgId: job.quotedPlatformMsgId,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
@@ -100,6 +101,9 @@ export async function processIngestJob(
|
||||
senderJid: job.senderJid,
|
||||
senderName: job.senderName,
|
||||
sourceGroupId: job.sourceGroupId,
|
||||
sourceGroupName: group.name,
|
||||
organizationId: tenant.organizationId ?? undefined,
|
||||
replyToMessageId: job.quotedPlatformMsgId,
|
||||
tags: job.tags,
|
||||
effectiveAction: job.effectiveAction ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface StreamMessagePayload {
|
||||
senderJid: string;
|
||||
senderName?: string;
|
||||
sourceGroupId: string;
|
||||
sourceGroupName?: string;
|
||||
organizationId?: string;
|
||||
replyToMessageId?: string;
|
||||
tags: string[];
|
||||
effectiveAction: string | null;
|
||||
traceId?: string;
|
||||
@@ -34,15 +37,18 @@ export async function publishToMessageStream(
|
||||
STREAM_KEY,
|
||||
'MAXLEN', '~', String(STREAM_MAX_LEN),
|
||||
'*',
|
||||
'messageId', payload.messageId,
|
||||
'tenantId', payload.tenantId,
|
||||
'content', payload.content,
|
||||
'senderJid', payload.senderJid,
|
||||
'senderName', payload.senderName ?? '',
|
||||
'sourceGroupId', payload.sourceGroupId,
|
||||
'tags', JSON.stringify(payload.tags),
|
||||
'effectiveAction', payload.effectiveAction ?? '',
|
||||
'traceId', payload.traceId ?? '',
|
||||
'timestamp', payload.timestamp,
|
||||
'messageId', payload.messageId,
|
||||
'tenantId', payload.tenantId,
|
||||
'content', payload.content,
|
||||
'senderJid', payload.senderJid,
|
||||
'senderName', payload.senderName ?? '',
|
||||
'sourceGroupId', payload.sourceGroupId,
|
||||
'sourceGroupName', payload.sourceGroupName ?? '',
|
||||
'organizationId', payload.organizationId ?? '',
|
||||
'replyToMessageId', payload.replyToMessageId ?? '',
|
||||
'tags', JSON.stringify(payload.tags),
|
||||
'effectiveAction', payload.effectiveAction ?? '',
|
||||
'traceId', payload.traceId ?? '',
|
||||
'timestamp', payload.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export function normalizeMessage(
|
||||
senderName: msg.pushName ?? undefined,
|
||||
content,
|
||||
accountId,
|
||||
quotedPlatformMsgId: msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user