Skip to content

Commit b6aff9d

Browse files
authored
feat(api-service): Conversational Agents — full inbound/outbound pipeline with bridge executor fixes NV-7346 (#10692)
1 parent 11ac21f commit b6aff9d

36 files changed

+2671
-156
lines changed

apps/api/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
},
4545
"dependencies": {
4646
"@aws-sdk/client-secrets-manager": "^3.971.0",
47+
"@chat-adapter/slack": "^4.25.0",
48+
"@chat-adapter/state-redis": "^4.25.0",
49+
"@chat-adapter/teams": "^4.25.0",
50+
"@chat-adapter/whatsapp": "^4.25.0",
4751
"@godaddy/terminus": "^4.12.1",
4852
"@google-cloud/storage": "^6.2.3",
4953
"@nestjs/axios": "3.0.3",
@@ -82,6 +86,7 @@
8286
"bcrypt": "^5.0.0",
8387
"body-parser": "^2.2.1",
8488
"bull": "^4.2.1",
89+
"chat": "^4.25.0",
8590
"class-transformer": "0.5.1",
8691
"class-validator": "0.14.1",
8792
"clickhouse-migrations": "^1.2.0",

apps/api/src/app.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ const enterpriseImports = (): Array<Type | DynamicModule | Promise<DynamicModule
8383
modules.push(require('@novu/ee-ai')?.AiModule);
8484
}
8585

86+
if (require('@novu/ee-api')?.ConversationsModule) {
87+
modules.push(require('@novu/ee-api')?.ConversationsModule);
88+
}
89+
8690
modules.push(SupportModule);
8791
modules.push(OutboundWebhooksModule.forRoot());
8892
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
Body,
3+
Controller,
4+
HttpCode,
5+
HttpException,
6+
HttpStatus,
7+
Param,
8+
Post,
9+
Req,
10+
Res,
11+
UseGuards,
12+
} from '@nestjs/common';
13+
import { ApiExcludeController } from '@nestjs/swagger';
14+
import { UserSessionData } from '@novu/shared';
15+
import { Request, Response } from 'express';
16+
import { RequireAuthentication } from '../auth/framework/auth.decorator';
17+
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
18+
import { UserSession } from '../shared/framework/user.decorator';
19+
import { AgentReplyPayloadDto } from './dtos/agent-reply-payload.dto';
20+
import { AgentConversationEnabledGuard } from './guards/agent-conversation-enabled.guard';
21+
import { ChatSdkService } from './services/chat-sdk.service';
22+
import { HandleAgentReplyCommand, Signal } from './usecases/handle-agent-reply/handle-agent-reply.command';
23+
import { HandleAgentReply } from './usecases/handle-agent-reply/handle-agent-reply.usecase';
24+
25+
@Controller('/agents')
26+
@UseGuards(AgentConversationEnabledGuard)
27+
@ApiExcludeController()
28+
export class AgentsWebhookController {
29+
constructor(
30+
private chatSdkService: ChatSdkService,
31+
private handleAgentReplyUsecase: HandleAgentReply
32+
) {}
33+
34+
@Post('/:agentId/reply')
35+
@HttpCode(HttpStatus.OK)
36+
@RequireAuthentication()
37+
@ExternalApiAccessible()
38+
async handleAgentReply(
39+
@UserSession() user: UserSessionData,
40+
@Param('agentId') agentId: string,
41+
@Body() body: AgentReplyPayloadDto
42+
) {
43+
return this.handleAgentReplyUsecase.execute(
44+
HandleAgentReplyCommand.create({
45+
userId: user._id,
46+
environmentId: user.environmentId,
47+
organizationId: user.organizationId,
48+
conversationId: body.conversationId,
49+
agentIdentifier: agentId,
50+
integrationIdentifier: body.integrationIdentifier,
51+
reply: body.reply,
52+
update: body.update,
53+
resolve: body.resolve,
54+
signals: body.signals as Signal[],
55+
})
56+
);
57+
}
58+
59+
@Post('/:agentId/webhook/:integrationIdentifier')
60+
@HttpCode(HttpStatus.OK)
61+
async handleInboundWebhook(
62+
@Param('agentId') agentId: string,
63+
@Param('integrationIdentifier') integrationIdentifier: string,
64+
@Req() req: Request,
65+
@Res() res: Response
66+
) {
67+
try {
68+
console.log('handleInboundWebhook', agentId, integrationIdentifier);
69+
await this.chatSdkService.handleWebhook(agentId, integrationIdentifier, req, res);
70+
console.log('handleInboundWebhook success');
71+
} catch (err) {
72+
console.log(err);
73+
if (err instanceof HttpException) {
74+
res.status(err.getStatus()).json(err.getResponse());
75+
} else {
76+
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: 'Internal server error' });
77+
}
78+
}
79+
}
80+
}

apps/api/src/app/agents/agents.controller.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import {
1010
Patch,
1111
Post,
1212
Query,
13+
UseGuards,
1314
UseInterceptors,
1415
} from '@nestjs/common';
15-
import { ApiOperation, ApiTags } from '@nestjs/swagger';
16+
import { ApiExcludeController, ApiOperation } from '@nestjs/swagger';
1617
import { RequirePermissions } from '@novu/application-generic';
1718
import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, UserSessionData } from '@novu/shared';
1819
import { RequireAuthentication } from '../auth/framework/auth.decorator';
@@ -36,6 +37,7 @@ import {
3637
UpdateAgentIntegrationRequestDto,
3738
UpdateAgentRequestDto,
3839
} from './dtos';
40+
import { AgentConversationEnabledGuard } from './guards/agent-conversation-enabled.guard';
3941
import { AddAgentIntegrationCommand } from './usecases/add-agent-integration/add-agent-integration.command';
4042
import { AddAgentIntegration } from './usecases/add-agent-integration/add-agent-integration.usecase';
4143
import { CreateAgentCommand } from './usecases/create-agent/create-agent.command';
@@ -59,7 +61,8 @@ import { UpdateAgent } from './usecases/update-agent/update-agent.usecase';
5961
@ApiCommonResponses()
6062
@Controller('/agents')
6163
@UseInterceptors(ClassSerializerInterceptor)
62-
@ApiTags('Agents')
64+
@UseGuards(AgentConversationEnabledGuard)
65+
@ApiExcludeController()
6366
@RequireAuthentication()
6467
export class AgentsController {
6568
constructor(
Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,39 @@
11
import { Module } from '@nestjs/common';
2+
import {
3+
ChannelConnectionRepository,
4+
ChannelEndpointRepository,
5+
ConversationActivityRepository,
6+
ConversationRepository,
7+
} from '@novu/dal';
28

39
import { AuthModule } from '../auth/auth.module';
410
import { SharedModule } from '../shared/shared.module';
511
import { AgentsController } from './agents.controller';
12+
import { AgentsWebhookController } from './agents-webhook.controller';
13+
import { AgentConversationService } from './services/agent-conversation.service';
14+
import { AgentCredentialService } from './services/agent-credential.service';
15+
import { AgentInboundHandler } from './services/agent-inbound-handler.service';
16+
import { AgentSubscriberResolver } from './services/agent-subscriber-resolver.service';
17+
import { BridgeExecutorService } from './services/bridge-executor.service';
18+
import { ChatSdkService } from './services/chat-sdk.service';
619
import { USE_CASES } from './usecases';
720

821
@Module({
922
imports: [SharedModule, AuthModule],
10-
controllers: [AgentsController],
11-
providers: [...USE_CASES],
12-
exports: [...USE_CASES],
23+
controllers: [AgentsController, AgentsWebhookController],
24+
providers: [
25+
...USE_CASES,
26+
ChannelConnectionRepository,
27+
ChannelEndpointRepository,
28+
ConversationRepository,
29+
ConversationActivityRepository,
30+
AgentCredentialService,
31+
AgentSubscriberResolver,
32+
AgentConversationService,
33+
AgentInboundHandler,
34+
BridgeExecutorService,
35+
ChatSdkService,
36+
],
37+
exports: [...USE_CASES, ChatSdkService],
1338
})
1439
export class AgentsModule {}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export enum AgentEventEnum {
2+
ON_START = 'onStart',
3+
ON_MESSAGE = 'onMessage',
4+
ON_ACTION = 'onAction',
5+
ON_RESOLVE = 'onResolve',
6+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export enum AgentPlatformEnum {
2+
SLACK = 'slack',
3+
WHATSAPP = 'whatsapp',
4+
TEAMS = 'teams',
5+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import { Type } from 'class-transformer';
3+
import {
4+
IsArray,
5+
IsDefined,
6+
IsIn,
7+
IsNotEmpty,
8+
IsObject,
9+
IsOptional,
10+
IsString,
11+
MaxLength,
12+
ValidateNested,
13+
} from 'class-validator';
14+
15+
const SIGNAL_TYPES = ['metadata', 'trigger'] as const;
16+
17+
export class TextContentDto {
18+
@ApiProperty()
19+
@IsString()
20+
@IsNotEmpty()
21+
@MaxLength(40_000)
22+
text: string;
23+
}
24+
25+
export class ResolveDto {
26+
@ApiPropertyOptional()
27+
@IsOptional()
28+
@IsString()
29+
summary?: string;
30+
}
31+
32+
export class SignalDto {
33+
@ApiProperty({ enum: SIGNAL_TYPES })
34+
@IsString()
35+
@IsIn(SIGNAL_TYPES)
36+
type: (typeof SIGNAL_TYPES)[number];
37+
38+
@ApiPropertyOptional()
39+
@IsOptional()
40+
@IsString()
41+
key?: string;
42+
43+
@ApiPropertyOptional()
44+
@IsOptional()
45+
value?: unknown;
46+
47+
@ApiPropertyOptional()
48+
@IsOptional()
49+
@IsString()
50+
workflowId?: string;
51+
52+
@ApiPropertyOptional()
53+
@IsOptional()
54+
@IsString()
55+
to?: string;
56+
57+
@ApiPropertyOptional()
58+
@IsOptional()
59+
@IsObject()
60+
payload?: Record<string, unknown>;
61+
}
62+
63+
export class AgentReplyPayloadDto {
64+
@ApiProperty()
65+
@IsString()
66+
@IsNotEmpty()
67+
conversationId: string;
68+
69+
@ApiProperty()
70+
@IsString()
71+
@IsNotEmpty()
72+
integrationIdentifier: string;
73+
74+
@ApiPropertyOptional({ type: TextContentDto })
75+
@IsOptional()
76+
@IsObject()
77+
@ValidateNested()
78+
@Type(() => TextContentDto)
79+
reply?: TextContentDto;
80+
81+
@ApiPropertyOptional({ type: TextContentDto })
82+
@IsOptional()
83+
@IsObject()
84+
@ValidateNested()
85+
@Type(() => TextContentDto)
86+
update?: TextContentDto;
87+
88+
@ApiPropertyOptional({ type: ResolveDto })
89+
@IsOptional()
90+
@IsObject()
91+
@ValidateNested()
92+
@Type(() => ResolveDto)
93+
resolve?: ResolveDto;
94+
95+
@ApiPropertyOptional({ type: [SignalDto] })
96+
@IsOptional()
97+
@IsArray()
98+
@ValidateNested({ each: true })
99+
@Type(() => SignalDto)
100+
signals?: SignalDto[];
101+
}

apps/api/src/app/agents/e2e/agents.e2e.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ describe('Agents API - /agents #novu-v2', () => {
88
const agentRepository = new AgentRepository();
99
const agentIntegrationRepository = new AgentIntegrationRepository();
1010

11+
before(() => {
12+
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED = 'true';
13+
});
14+
1115
beforeEach(async () => {
1216
session = new UserSession();
1317
await session.initialize();
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { CanActivate, ExecutionContext, Injectable, NotFoundException } from '@nestjs/common';
2+
import { FeatureFlagsService } from '@novu/application-generic';
3+
import { FeatureFlagsKeysEnum, UserSessionData } from '@novu/shared';
4+
5+
@Injectable()
6+
export class AgentConversationEnabledGuard implements CanActivate {
7+
constructor(private readonly featureFlagsService: FeatureFlagsService) {}
8+
9+
async canActivate(context: ExecutionContext): Promise<boolean> {
10+
const request = context.switchToHttp().getRequest();
11+
const user: UserSessionData | undefined = request.user;
12+
13+
if (!user?.organizationId || !user?.environmentId) {
14+
return true;
15+
}
16+
17+
const isEnabled = await this.featureFlagsService.getFlag({
18+
key: FeatureFlagsKeysEnum.IS_CONVERSATIONAL_AGENTS_ENABLED,
19+
defaultValue: false,
20+
organization: { _id: user.organizationId },
21+
environment: { _id: user.environmentId },
22+
});
23+
24+
if (!isEnabled) {
25+
throw new NotFoundException();
26+
}
27+
28+
return true;
29+
}
30+
}

0 commit comments

Comments
 (0)