Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .source
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
forwardRef,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PinoLogger, shortId } from '@novu/application-generic';
import {
AgentRepository,
ConversationActivityRepository,
ConversationActivityTypeEnum,
ConversationChannel,
Expand All @@ -10,11 +18,11 @@ import {
SubscriberRepository,
} from '@novu/dal';
import { AgentEventEnum } from '../../dtos/agent-event.enum';
import type { ReplyContentDto } from '../../dtos/agent-reply-payload.dto';
import { AgentConfigResolver, ResolvedAgentConfig } from '../../services/agent-config-resolver.service';
import { AgentConversationService } from '../../services/agent-conversation.service';
import { BridgeExecutorService } from '../../services/bridge-executor.service';
import { ChatSdkService } from '../../services/chat-sdk.service';
import type { ReplyContentDto } from '../../dtos/agent-reply-payload.dto';
import { HandleAgentReplyCommand } from './handle-agent-reply.command';

@Injectable()
Expand All @@ -23,6 +31,7 @@ export class HandleAgentReply {
private readonly conversationRepository: ConversationRepository,
private readonly activityRepository: ConversationActivityRepository,
private readonly subscriberRepository: SubscriberRepository,
private readonly agentRepository: AgentRepository,
@Inject(forwardRef(() => ChatSdkService))
private readonly chatSdkService: ChatSdkService,
private readonly bridgeExecutor: BridgeExecutorService,
Expand Down Expand Up @@ -54,7 +63,16 @@ export class HandleAgentReply {
const channel = this.getPrimaryChannel(conversation);

if (command.update) {
await this.deliverMessage(command, conversation, channel, command.update, ConversationActivityTypeEnum.UPDATE);
const agentName = await this.resolveValidatedAgentNameForDelivery(command, conversation);

await this.deliverMessage(
command,
conversation,
channel,
command.update,
ConversationActivityTypeEnum.UPDATE,
agentName
);

return { status: 'update_sent' };
}
Expand All @@ -65,7 +83,16 @@ export class HandleAgentReply {
: null;

if (command.reply) {
await this.deliverMessage(command, conversation, channel, command.reply, ConversationActivityTypeEnum.MESSAGE);
const agentName = await this.resolveValidatedAgentNameForDelivery(command, conversation);

await this.deliverMessage(
command,
conversation,
channel,
command.reply,
ConversationActivityTypeEnum.MESSAGE,
agentName
);

this.removeAckReaction(config!, conversation, channel).catch((err) => {
this.logger.warn(err, `[agent:${command.agentIdentifier}] Failed to remove ack reaction`);
Expand All @@ -83,6 +110,30 @@ export class HandleAgentReply {
return { status: 'ok' };
}

private async resolveValidatedAgentNameForDelivery(
command: HandleAgentReplyCommand,
conversation: ConversationEntity
): Promise<string | undefined> {
const agent = await this.agentRepository.findOne(
{
_environmentId: command.environmentId,
_organizationId: command.organizationId,
identifier: command.agentIdentifier,
},
{ _id: 1, name: 1, identifier: 1 }
);

if (!agent) {
throw new NotFoundException('Agent not found');
}

if (String(agent._id) !== conversation._agentId) {
throw new ForbiddenException('Agent identifier does not match this conversation');
}

return agent.name;
}

private getPrimaryChannel(conversation: ConversationEntity): ConversationChannel {
const channel = conversation.channels[0];
if (!channel?.serializedThread) {
Expand All @@ -97,7 +148,8 @@ export class HandleAgentReply {
conversation: ConversationEntity,
channel: ConversationChannel,
content: ReplyContentDto,
type: ConversationActivityTypeEnum
type: ConversationActivityTypeEnum,
agentName?: string
): Promise<void> {
const textFallback = this.extractTextFallback(content);

Expand All @@ -116,8 +168,9 @@ export class HandleAgentReply {
integrationId: channel._integrationId,
platformThreadId: channel.platformThreadId,
agentId: command.agentIdentifier,
senderName: agentName,
content: textFallback,
richContent: (content.card || content.files?.length) ? (content as Record<string, unknown>) : undefined,
richContent: content.card || content.files?.length ? (content as Record<string, unknown>) : undefined,
type,
environmentId: command.environmentId,
organizationId: command.organizationId,
Expand Down Expand Up @@ -150,7 +203,8 @@ export class HandleAgentReply {
signals: HandleAgentReplyCommand['signals']
): Promise<void> {
const metadataSignals = (signals ?? []).filter(
(s): s is Extract<NonNullable<HandleAgentReplyCommand['signals']>[number], { type: 'metadata' }> => s.type === 'metadata'
(s): s is Extract<NonNullable<HandleAgentReplyCommand['signals']>[number], { type: 'metadata' }> =>
s.type === 'metadata'
);

if (metadataSignals.length) {
Expand Down
164 changes: 164 additions & 0 deletions apps/dashboard/src/api/conversations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { getDateRangeInMs, type IEnvironment } from '@novu/shared';
import { get } from './api.client';

export type ConversationFilters = {
dateRange?: string;
subscriberId?: string;
provider?: string[];
conversationId?: string;
status?: string;
};

export type ParticipantSubscriberData = {
firstName?: string;
lastName?: string;
avatar?: string;
subscriberId: string;
};

export type ParticipantAgentData = {
name: string;
identifier: string;
};

export type ConversationParticipantDto = {
type: string;
id: string;
subscriber?: ParticipantSubscriberData | null;
agent?: ParticipantAgentData | null;
};

export type ConversationChannelDto = {
platform: string;
_integrationId: string;
platformThreadId: string;
};

export type ConversationDto = {
_id: string;
identifier: string;
_agentId: string;
participants?: ConversationParticipantDto[];
channels?: ConversationChannelDto[];
status: string;
title: string;
metadata: Record<string, unknown>;
_environmentId: string;
_organizationId: string;
createdAt: string;
lastActivityAt: string;
};

export type ConversationsListResponse = {
data: ConversationDto[];
page: number;
totalCount: number;
pageSize: number;
hasMore: boolean;
};

export function getConversationsList({
environment,
page,
limit,
filters,
signal,
}: {
environment: IEnvironment;
page: number;
limit: number;
filters?: ConversationFilters;
signal?: AbortSignal;
}): Promise<ConversationsListResponse> {
const searchParams = new URLSearchParams();
searchParams.append('page', page.toString());
searchParams.append('limit', limit.toString());

if (filters?.status) {
searchParams.append('status', filters.status);
}

if (filters?.subscriberId) {
searchParams.append('subscriberId', filters.subscriberId);
}

if (filters?.dateRange) {
const after = new Date(Date.now() - getDateRangeInMs(filters.dateRange));
searchParams.append('after', after.toISOString());
}

if (filters?.provider?.length) {
for (const p of filters.provider) {
searchParams.append('provider', p);
}
}

if (filters?.conversationId) {
searchParams.append('conversationId', filters.conversationId);
}

return get<ConversationsListResponse>(`/conversations?${searchParams.toString()}`, {
environment,
signal,
});
}

export type ConversationActivityDto = {
_id: string;
identifier: string;
_conversationId: string;
type: 'message' | 'update' | 'signal';
content: string;
platform: string;
_integrationId: string;
platformThreadId: string;
senderType: 'subscriber' | 'platform_user' | 'agent' | 'system';
senderId: string;
senderName?: string;
platformMessageId?: string;
signalData?: { type: string; payload?: Record<string, unknown> };
_environmentId: string;
_organizationId: string;
createdAt: string;
};

export type ConversationActivitiesResponse = {
data: ConversationActivityDto[];
page: number;
totalCount: number;
pageSize: number;
hasMore: boolean;
};

/** `conversationIdentifier` is the public `identifier` field — the API resolves by identifier, not Mongo `_id`. */
export function getConversation(
conversationIdentifier: string,
environment: IEnvironment
): Promise<ConversationDto> {
return get<ConversationDto>(`/conversations/${encodeURIComponent(conversationIdentifier)}`, {
environment,
});
}

export function getConversationActivities({
conversationIdentifier,
environment,
page = 0,
limit = 50,
signal,
}: {
conversationIdentifier: string;
environment: IEnvironment;
page?: number;
limit?: number;
signal?: AbortSignal;
}): Promise<ConversationActivitiesResponse> {
const searchParams = new URLSearchParams();
searchParams.append('page', page.toString());
searchParams.append('limit', limit.toString());

return get<ConversationActivitiesResponse>(
`/conversations/${encodeURIComponent(conversationIdentifier)}/activities?${searchParams.toString()}`,
{ environment, signal }
);
}
14 changes: 14 additions & 0 deletions apps/dashboard/src/components/conversations/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CONVERSATIONAL_PROVIDERS } from '@novu/shared';
import { ConversationFiltersData } from '@/types/conversation';

export const PROVIDER_OPTIONS = CONVERSATIONAL_PROVIDERS.filter((p) => !p.comingSoon).map((p) => ({
label: p.displayName,
value: p.providerId,
}));

export const defaultConversationFilters: ConversationFiltersData = {
dateRange: '24h',
subscriberId: '',
provider: [],
conversationId: '',
} as const;
Loading
Loading