From a9ecde07fe9d7cad34e2a4bb6e2356503cdb07b5 Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Thu, 16 Apr 2026 17:26:04 +0300 Subject: [PATCH] refactor(api-service): consolidate agent protocol types to single source of truth Eliminate duplicate type definitions across packages/framework and apps/api. The framework package now owns all agent protocol types (AgentBridgeRequest, AgentMessage, AgentConversation, Signal, etc.) and the API imports from it instead of maintaining local copies. Adds discriminated union validation for SignalDto and exports all protocol types from the framework's public API. Made-with: Cursor --- .../src/app/agents/dtos/agent-event.enum.ts | 7 +- .../agents/dtos/agent-reply-payload.dto.ts | 28 +++- .../services/agent-inbound-handler.service.ts | 10 +- .../services/bridge-executor.service.ts | 130 +++--------------- .../handle-agent-reply.command.ts | 7 +- packages/framework/src/index.ts | 15 ++ .../framework/src/resources/agent/index.ts | 3 + .../providers/conversational-providers.ts | 3 +- 8 files changed, 71 insertions(+), 132 deletions(-) diff --git a/apps/api/src/app/agents/dtos/agent-event.enum.ts b/apps/api/src/app/agents/dtos/agent-event.enum.ts index f00cc60e737..84fc00ee42e 100644 --- a/apps/api/src/app/agents/dtos/agent-event.enum.ts +++ b/apps/api/src/app/agents/dtos/agent-event.enum.ts @@ -1,6 +1 @@ -export enum AgentEventEnum { - ON_MESSAGE = 'onMessage', - ON_ACTION = 'onAction', - ON_RESOLVE = 'onResolve', - ON_REACTION = 'onReaction', -} +export { AgentEventEnum } from '@novu/framework'; diff --git a/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts b/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts index 11e085269f5..a2b8e6bd499 100644 --- a/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts +++ b/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts @@ -13,14 +13,31 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; +import type { FileRef } from '@novu/framework'; + +export type { FileRef } from '@novu/framework'; const SIGNAL_TYPES = ['metadata', 'trigger'] as const; -export interface FileRef { - filename: string; - mimeType?: string; - data?: string; - url?: string; +@ValidatorConstraint({ name: 'isValidSignal', async: false }) +export class IsValidSignal implements ValidatorConstraintInterface { + validate(signal: SignalDto): boolean { + if (!signal?.type) return false; + + if (signal.type === 'metadata') { + return typeof signal.key === 'string' && signal.key.length > 0 && signal.value !== undefined; + } + + if (signal.type === 'trigger') { + return typeof signal.workflowId === 'string' && signal.workflowId.length > 0; + } + + return false; + } + + defaultMessage(): string { + return 'metadata signals require key + value; trigger signals require workflowId.'; + } } @ValidatorConstraint({ name: 'isValidReplyContent', async: false }) @@ -146,6 +163,7 @@ export class AgentReplyPayloadDto { @IsOptional() @IsArray() @ValidateNested({ each: true }) + @Validate(IsValidSignal, { each: true }) @Type(() => SignalDto) signals?: SignalDto[]; } diff --git a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts index b3e8cebf67f..561aed44963 100644 --- a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts +++ b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts @@ -14,12 +14,8 @@ import { HandleAgentReply } from '../usecases/handle-agent-reply/handle-agent-re import { ResolvedAgentConfig } from './agent-config-resolver.service'; import { AgentConversationService } from './agent-conversation.service'; import { AgentSubscriberResolver } from './agent-subscriber-resolver.service'; -import { - type BridgeAction, - BridgeExecutorService, - type BridgeReaction, - NoBridgeUrlError, -} from './bridge-executor.service'; +import type { AgentAction } from '@novu/framework'; +import { BridgeExecutorService, type BridgeReaction, NoBridgeUrlError } from './bridge-executor.service'; const ONBOARDING_NO_BRIDGE_REPLY_MARKDOWN = `*You're connected to Novu* @@ -263,7 +259,7 @@ export class AgentInboundHandler { agentId: string, config: ResolvedAgentConfig, thread: Thread, - action: BridgeAction, + action: AgentAction, userId: string ): Promise { const subscriberId = await this.subscriberResolver diff --git a/apps/api/src/app/agents/services/bridge-executor.service.ts b/apps/api/src/app/agents/services/bridge-executor.service.ts index 39eeb7e9742..afdc317441f 100644 --- a/apps/api/src/app/agents/services/bridge-executor.service.ts +++ b/apps/api/src/app/agents/services/bridge-executor.service.ts @@ -5,26 +5,24 @@ import { GetDecryptedSecretKeyCommand, PinoLogger, } from '@novu/application-generic'; -import { - ConversationActivityEntity, - ConversationActivitySenderTypeEnum, - ConversationActivityTypeEnum, - ConversationEntity, - SubscriberEntity, -} from '@novu/dal'; +import { ConversationActivityEntity, ConversationEntity, SubscriberEntity } from '@novu/dal'; +import type { + AgentAction, + AgentBridgeRequest, + AgentConversation, + AgentHistoryEntry, + AgentMessage, + AgentPlatformContext, + AgentReaction, + AgentSubscriber, +} from '@novu/framework'; +import { AgentEventEnum } from '@novu/framework'; import type { Message } from 'chat'; -import { AgentEventEnum } from '../dtos/agent-event.enum'; import { ResolvedAgentConfig } from './agent-config-resolver.service'; const MAX_RETRIES = 2; const RETRY_BASE_DELAY_MS = 500; -export interface BridgePlatformContext { - threadId: string; - channelId: string; - isDM: boolean; -} - export interface BridgeReaction { emoji: string; added: boolean; @@ -39,95 +37,11 @@ export interface BridgeExecutorParams { subscriber: SubscriberEntity | null; history: ConversationActivityEntity[]; message: Message | null; - platformContext: BridgePlatformContext; - action?: BridgeAction; + platformContext: AgentPlatformContext; + action?: AgentAction; reaction?: BridgeReaction; } -interface BridgeMessageAuthor { - userId: string; - fullName: string; - userName: string; - isBot: boolean | 'unknown'; -} - -interface BridgeAttachment { - type: string; - url?: string; - name?: string; - mimeType?: string; - size?: number; -} - -interface BridgeMessage { - text: string; - platformMessageId: string; - author: BridgeMessageAuthor; - timestamp: string; - attachments?: BridgeAttachment[]; -} - -export interface BridgeAction { - actionId: string; - value?: string; -} - -interface BridgeConversation { - identifier: string; - status: string; - metadata: Record; - messageCount: number; - createdAt: string; - lastActivityAt: string; -} - -interface BridgeSubscriber { - subscriberId: string; - firstName?: string; - lastName?: string; - email?: string; - phone?: string; - avatar?: string; - locale?: string; - data?: Record; -} - -interface BridgeHistoryEntry { - role: ConversationActivitySenderTypeEnum; - type: ConversationActivityTypeEnum; - content: string; - richContent?: Record; - senderName?: string; - signalData?: { type: string; payload?: Record }; - createdAt: string; -} - -interface BridgeReactionPayload { - messageId: string; - emoji: { name: string }; - added: boolean; - message: BridgeMessage | null; -} - -export interface AgentBridgeRequest { - version: 1; - timestamp: string; - deliveryId: string; - event: AgentEventEnum; - agentId: string; - replyUrl: string; - conversationId: string; - integrationIdentifier: string; - message: BridgeMessage | null; - conversation: BridgeConversation; - subscriber: BridgeSubscriber | null; - history: BridgeHistoryEntry[]; - platform: string; - platformContext: BridgePlatformContext; - action: BridgeAction | null; - reaction: BridgeReactionPayload | null; -} - export class NoBridgeUrlError extends Error { constructor(agentIdentifier: string) { super(`No bridge URL configured for agent ${agentIdentifier}`); @@ -269,7 +183,7 @@ export class BridgeExecutorService { deliveryId = `${conversation._id}:${event}`; } - const payload: AgentBridgeRequest = { + return { version: 1, timestamp, deliveryId, @@ -287,12 +201,10 @@ export class BridgeExecutorService { action: action ?? null, reaction: reaction ? this.mapReaction(reaction) : null, }; - - return payload; } - private mapMessage(message: Message): BridgeMessage { - const mapped: BridgeMessage = { + private mapMessage(message: Message): AgentMessage { + const mapped: AgentMessage = { text: message.text, platformMessageId: message.id, author: { @@ -317,7 +229,7 @@ export class BridgeExecutorService { return mapped; } - private mapConversation(conversation: ConversationEntity): BridgeConversation { + private mapConversation(conversation: ConversationEntity): AgentConversation { return { identifier: conversation.identifier, status: conversation.status, @@ -328,7 +240,7 @@ export class BridgeExecutorService { }; } - private mapSubscriber(subscriber: SubscriberEntity | null): BridgeSubscriber | null { + private mapSubscriber(subscriber: SubscriberEntity | null): AgentSubscriber | null { if (!subscriber) { return null; } @@ -345,7 +257,7 @@ export class BridgeExecutorService { }; } - private mapReaction(reaction: BridgeReaction): BridgeReactionPayload { + private mapReaction(reaction: BridgeReaction): AgentReaction { return { messageId: reaction.messageId, emoji: { name: reaction.emoji }, @@ -354,7 +266,7 @@ export class BridgeExecutorService { }; } - private mapHistory(activities: ConversationActivityEntity[]): BridgeHistoryEntry[] { + private mapHistory(activities: ConversationActivityEntity[]): AgentHistoryEntry[] { return [...activities].reverse().map((activity) => ({ role: activity.senderType, type: activity.type, diff --git a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts index 8873f45a508..6ba3eee43e8 100644 --- a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts +++ b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts @@ -1,8 +1,11 @@ import { Type } from 'class-transformer'; import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import type { Signal } from '@novu/framework'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { ReplyContentDto } from '../../dtos/agent-reply-payload.dto'; +export type { Signal } from '@novu/framework'; + export class HandleAgentReplyCommand extends EnvironmentWithUserCommand { @IsString() @IsNotEmpty() @@ -34,7 +37,3 @@ export class HandleAgentReplyCommand extends EnvironmentWithUserCommand { @IsArray() signals?: Signal[]; } - -export type Signal = - | { type: 'metadata'; key: string; value: unknown } - | { type: 'trigger'; workflowId: string; to?: string; payload?: Record }; diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 2d22ce69993..fe8a6ff1daa 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -3,17 +3,32 @@ export { CronExpression } from './constants'; export { NovuRequestHandler, type ServeHandlerOptions } from './handler'; export type { Agent, + AgentAction, + AgentAttachment, + AgentBridgeRequest, AgentContext, + AgentConversation, AgentHandlers, + AgentHistoryEntry, + AgentMessage, + AgentMessageAuthor, + AgentPlatformContext, AgentReaction, + AgentReplyPayload, + AgentSubscriber, CardChild, CardElement, FileRef, MessageContent, + MetadataSignal, + ReplyContent, + Signal, + TriggerSignal, } from './resources'; export { Actions, agent, + AgentEventEnum, Button, Card, CardLink, diff --git a/packages/framework/src/resources/agent/index.ts b/packages/framework/src/resources/agent/index.ts index efad3dd9439..30eac7a88a9 100644 --- a/packages/framework/src/resources/agent/index.ts +++ b/packages/framework/src/resources/agent/index.ts @@ -30,6 +30,9 @@ export type { AgentSubscriber, FileRef, MessageContent, + MetadataSignal, ReplyContent, + Signal, + TriggerSignal, } from './agent.types'; export { AgentEventEnum } from './agent.types'; diff --git a/packages/shared/src/consts/providers/conversational-providers.ts b/packages/shared/src/consts/providers/conversational-providers.ts index c89ab4ab698..4107cf1af82 100644 --- a/packages/shared/src/consts/providers/conversational-providers.ts +++ b/packages/shared/src/consts/providers/conversational-providers.ts @@ -1,4 +1,4 @@ -import { ChatProviderIdEnum } from '../../types'; +import { ChatProviderIdEnum, InAppProviderIdEnum } from '../../types'; export type ConversationalProvider = { providerId: string; @@ -7,6 +7,7 @@ export type ConversationalProvider = { }; export const CONVERSATIONAL_PROVIDERS: ConversationalProvider[] = [ + { providerId: InAppProviderIdEnum.Novu, displayName: 'Dashboard' }, { providerId: ChatProviderIdEnum.Slack, displayName: 'Slack' }, { providerId: ChatProviderIdEnum.MsTeams, displayName: 'MS Teams', comingSoon: true }, { providerId: ChatProviderIdEnum.WhatsAppBusiness, displayName: 'WhatsApp Business' },