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')
|
@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,
|
||||||
|
|||||||
@@ -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 } },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -34,15 +37,18 @@ export async function publishToMessageStream(
|
|||||||
STREAM_KEY,
|
STREAM_KEY,
|
||||||
'MAXLEN', '~', String(STREAM_MAX_LEN),
|
'MAXLEN', '~', String(STREAM_MAX_LEN),
|
||||||
'*',
|
'*',
|
||||||
'messageId', payload.messageId,
|
'messageId', payload.messageId,
|
||||||
'tenantId', payload.tenantId,
|
'tenantId', payload.tenantId,
|
||||||
'content', payload.content,
|
'content', payload.content,
|
||||||
'senderJid', payload.senderJid,
|
'senderJid', payload.senderJid,
|
||||||
'senderName', payload.senderName ?? '',
|
'senderName', payload.senderName ?? '',
|
||||||
'sourceGroupId', payload.sourceGroupId,
|
'sourceGroupId', payload.sourceGroupId,
|
||||||
'tags', JSON.stringify(payload.tags),
|
'sourceGroupName', payload.sourceGroupName ?? '',
|
||||||
'effectiveAction', payload.effectiveAction ?? '',
|
'organizationId', payload.organizationId ?? '',
|
||||||
'traceId', payload.traceId ?? '',
|
'replyToMessageId', payload.replyToMessageId ?? '',
|
||||||
'timestamp', payload.timestamp,
|
'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,
|
senderName: msg.pushName ?? undefined,
|
||||||
content,
|
content,
|
||||||
accountId,
|
accountId,
|
||||||
|
quotedPlatformMsgId: msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user