Skip to content

Commit 6e7bfa5

Browse files
authored
feat(dashboard, api-service): Slack quickstart flow (#10728)
1 parent d47278d commit 6e7bfa5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2051
-276
lines changed

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

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,10 @@ import { ListAgentsCommand } from './usecases/list-agents/list-agents.command';
5252
import { ListAgents } from './usecases/list-agents/list-agents.usecase';
5353
import { RemoveAgentIntegrationCommand } from './usecases/remove-agent-integration/remove-agent-integration.command';
5454
import { RemoveAgentIntegration } from './usecases/remove-agent-integration/remove-agent-integration.usecase';
55-
import { UpdateAgentIntegrationCommand } from './usecases/update-agent-integration/update-agent-integration.command';
56-
import { UpdateAgentIntegration } from './usecases/update-agent-integration/update-agent-integration.usecase';
5755
import { UpdateAgentCommand } from './usecases/update-agent/update-agent.command';
5856
import { UpdateAgent } from './usecases/update-agent/update-agent.usecase';
57+
import { UpdateAgentIntegrationCommand } from './usecases/update-agent-integration/update-agent-integration.command';
58+
import { UpdateAgentIntegration } from './usecases/update-agent-integration/update-agent-integration.usecase';
5959

6060
@ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION)
6161
@ApiCommonResponses()
@@ -84,10 +84,7 @@ export class AgentsController {
8484
description: 'Creates an agent scoped to the current environment. The identifier must be unique per environment.',
8585
})
8686
@RequirePermissions(PermissionsEnum.AGENT_WRITE)
87-
createAgent(
88-
@UserSession() user: UserSessionData,
89-
@Body() body: CreateAgentRequestDto
90-
): Promise<AgentResponseDto> {
87+
createAgent(@UserSession() user: UserSessionData, @Body() body: CreateAgentRequestDto): Promise<AgentResponseDto> {
9188
return this.createAgentUsecase.execute(
9289
CreateAgentCommand.create({
9390
userId: user._id,
@@ -96,6 +93,7 @@ export class AgentsController {
9693
name: body.name,
9794
identifier: body.identifier,
9895
description: body.description,
96+
active: body.active,
9997
})
10098
);
10199
}
@@ -108,10 +106,7 @@ export class AgentsController {
108106
'Returns a cursor-paginated list of agents for the current environment. Use **after**, **before**, **limit**, **orderBy**, and **orderDirection** query parameters.',
109107
})
110108
@RequirePermissions(PermissionsEnum.AGENT_READ)
111-
listAgents(
112-
@UserSession() user: UserSessionData,
113-
@Query() query: ListAgentsQueryDto
114-
): Promise<ListAgentsResponseDto> {
109+
listAgents(@UserSession() user: UserSessionData, @Query() query: ListAgentsQueryDto): Promise<ListAgentsResponseDto> {
115110
return this.listAgentsUsecase.execute(
116111
ListAgentsCommand.create({
117112
user,
@@ -132,7 +127,8 @@ export class AgentsController {
132127
@ApiResponse(AgentIntegrationResponseDto, 201)
133128
@ApiOperation({
134129
summary: 'Link integration to agent',
135-
description: 'Creates a link between an agent (by identifier) and an integration (by integration **identifier**, not the internal _id).',
130+
description:
131+
'Creates a link between an agent (by identifier) and an integration (by integration **identifier**, not the internal _id).',
136132
})
137133
@ApiNotFoundResponse({
138134
description: 'The agent or integration was not found.',
@@ -287,6 +283,7 @@ export class AgentsController {
287283
identifier,
288284
name: body.name,
289285
description: body.description,
286+
active: body.active,
290287
behavior: body.behavior,
291288
})
292289
);

apps/api/src/app/agents/dtos/agent-integration-response.dto.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ApiProperty } from '@nestjs/swagger';
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
22
import { ChannelTypeEnum } from '@novu/shared';
33

44
/** Picked integration fields embedded on an agent–integration link response. */
@@ -40,6 +40,9 @@ export class AgentIntegrationResponseDto {
4040
@ApiProperty()
4141
_organizationId: string;
4242

43+
@ApiPropertyOptional({ description: 'Set when the agent–integration link has been used (e.g. first credential resolution).' })
44+
connectedAt?: string | null;
45+
4346
@ApiProperty()
4447
createdAt: string;
4548

apps/api/src/app/agents/dtos/agent-response.dto.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export class AgentResponseDto {
1919
@ApiPropertyOptional({ type: AgentBehaviorDto })
2020
behavior?: AgentBehaviorDto;
2121

22+
@ApiProperty()
23+
active: boolean;
24+
2225
@ApiProperty()
2326
_environmentId: string;
2427

apps/api/src/app/agents/dtos/create-agent-request.dto.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
22
import { SLUG_IDENTIFIER_REGEX, slugIdentifierFormatMessage } from '@novu/shared';
3-
import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';
3+
import { IsBoolean, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';
44

55
export class CreateAgentRequestDto {
66
@ApiProperty()
@@ -20,4 +20,9 @@ export class CreateAgentRequestDto {
2020
@IsString()
2121
@IsOptional()
2222
description?: string;
23+
24+
@ApiPropertyOptional({ default: true })
25+
@IsBoolean()
26+
@IsOptional()
27+
active?: boolean;
2328
}

apps/api/src/app/agents/dtos/update-agent-request.dto.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ApiPropertyOptional } from '@nestjs/swagger';
2-
import { IsOptional, IsString, ValidateNested } from 'class-validator';
32
import { Type } from 'class-transformer';
3+
import { IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator';
44

55
import { AgentBehaviorDto } from './agent-behavior.dto';
66

@@ -15,6 +15,11 @@ export class UpdateAgentRequestDto {
1515
@IsOptional()
1616
description?: string;
1717

18+
@ApiPropertyOptional()
19+
@IsBoolean()
20+
@IsOptional()
21+
active?: boolean;
22+
1823
@ApiPropertyOptional({ type: AgentBehaviorDto })
1924
@ValidateNested()
2025
@Type(() => AgentBehaviorDto)

apps/api/src/app/agents/mappers/agent-response.mapper.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export function toAgentResponse(agent: AgentEntity): AgentResponseDto {
88
name: agent.name,
99
identifier: agent.identifier,
1010
description: agent.description,
11+
active: agent.active,
1112
behavior: agent.behavior,
1213
_environmentId: agent._environmentId,
1314
_organizationId: agent._organizationId,
@@ -33,7 +34,6 @@ export function toAgentIntegrationResponse(
3334
link: AgentIntegrationEntity,
3435
integration: Pick<IntegrationEntity, '_id' | 'identifier' | 'name' | 'providerId' | 'channel' | 'active'>
3536
): AgentIntegrationResponseDto {
36-
3737
return {
3838
_id: link._id,
3939
_agentId: link._agentId,
@@ -47,6 +47,7 @@ export function toAgentIntegrationResponse(
4747
},
4848
_environmentId: link._environmentId,
4949
_organizationId: link._organizationId,
50+
connectedAt: link.connectedAt ?? null,
5051
createdAt: link.createdAt,
5152
updatedAt: link.updatedAt,
5253
};

apps/api/src/app/agents/services/agent-config-resolver.service.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
2-
import { decryptCredentials, FeatureFlagsService } from '@novu/application-generic';
2+
import { decryptCredentials, FeatureFlagsService, PinoLogger } from '@novu/application-generic';
33
import {
44
AgentIntegrationRepository,
55
AgentRepository,
@@ -46,7 +46,8 @@ export class AgentConfigResolver {
4646
private readonly agentRepository: AgentRepository,
4747
private readonly agentIntegrationRepository: AgentIntegrationRepository,
4848
private readonly integrationRepository: IntegrationRepository,
49-
private readonly channelConnectionRepository: ChannelConnectionRepository
49+
private readonly channelConnectionRepository: ChannelConnectionRepository,
50+
private readonly logger: PinoLogger
5051
) {}
5152

5253
async resolve(agentId: string, integrationIdentifier: string): Promise<ResolvedAgentConfig> {
@@ -108,6 +109,17 @@ export class AgentConfigResolver {
108109
connectionAccessToken = connection.auth.accessToken;
109110
}
110111

112+
if (agentIntegration.connectedAt == null) {
113+
await this.agentIntegrationRepository.updateOne(
114+
{
115+
_id: agentIntegration._id,
116+
_environmentId: environmentId,
117+
_organizationId: organizationId,
118+
},
119+
{ $set: { connectedAt: new Date() } }
120+
);
121+
}
122+
111123
return {
112124
platform,
113125
credentials,

apps/api/src/app/agents/services/agent-inbound-handler.service.ts

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
import { Injectable } from '@nestjs/common';
1+
import { forwardRef, Inject, Injectable } from '@nestjs/common';
22
import { PinoLogger } from '@novu/application-generic';
33
import { ConversationActivitySenderTypeEnum, ConversationParticipantTypeEnum, ConversationRepository, SubscriberRepository } from '@novu/dal';
44
import type { Message, Thread } from 'chat';
55
import { AgentEventEnum } from '../dtos/agent-event.enum';
6+
import { HandleAgentReplyCommand } from '../usecases/handle-agent-reply/handle-agent-reply.command';
7+
import { HandleAgentReply } from '../usecases/handle-agent-reply/handle-agent-reply.usecase';
68
import { ResolvedAgentConfig } from './agent-config-resolver.service';
79
import { AgentConversationService } from './agent-conversation.service';
810
import { AgentSubscriberResolver } from './agent-subscriber-resolver.service';
9-
import { type BridgeAction, BridgeExecutorService } from './bridge-executor.service';
11+
import { type BridgeAction, BridgeExecutorService, NoBridgeUrlError } from './bridge-executor.service';
12+
13+
const ONBOARDING_NO_BRIDGE_REPLY_MARKDOWN = `*You're connected to Novu*
14+
15+
Your bot is linked successfully. Go back to the *Novu dashboard* to complete onboarding.`;
1016

1117
@Injectable()
1218
export class AgentInboundHandler {
@@ -16,7 +22,9 @@ export class AgentInboundHandler {
1622
private readonly conversationService: AgentConversationService,
1723
private readonly conversationRepository: ConversationRepository,
1824
private readonly bridgeExecutor: BridgeExecutorService,
19-
private readonly subscriberRepository: SubscriberRepository
25+
private readonly subscriberRepository: SubscriberRepository,
26+
@Inject(forwardRef(() => HandleAgentReply))
27+
private readonly handleAgentReply: HandleAgentReply
2028
) {}
2129

2230
async handle(
@@ -111,19 +119,39 @@ export class AgentInboundHandler {
111119
this.conversationService.getHistory(config.environmentId, conversation._id),
112120
]);
113121

114-
await this.bridgeExecutor.execute({
115-
event,
116-
config,
117-
conversation,
118-
subscriber,
119-
history,
120-
message,
121-
platformContext: {
122-
threadId: thread.id,
123-
channelId: thread.channelId,
124-
isDM: thread.isDM,
125-
},
126-
});
122+
try {
123+
await this.bridgeExecutor.execute({
124+
event,
125+
config,
126+
conversation,
127+
subscriber,
128+
history,
129+
message,
130+
platformContext: {
131+
threadId: thread.id,
132+
channelId: thread.channelId,
133+
isDM: thread.isDM,
134+
},
135+
});
136+
} catch (err) {
137+
if (err instanceof NoBridgeUrlError) {
138+
await this.handleAgentReply.execute(
139+
HandleAgentReplyCommand.create({
140+
userId: 'system',
141+
environmentId: config.environmentId,
142+
organizationId: config.organizationId,
143+
conversationId: conversation._id,
144+
agentIdentifier: agentId,
145+
integrationIdentifier: config.integrationIdentifier,
146+
reply: { text: ONBOARDING_NO_BRIDGE_REPLY_MARKDOWN },
147+
})
148+
);
149+
150+
return;
151+
}
152+
153+
throw err;
154+
}
127155
}
128156

129157
async handleAction(

apps/api/src/app/agents/services/bridge-executor.service.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ export interface AgentBridgeRequest {
103103
action: BridgeAction | null;
104104
}
105105

106+
export class NoBridgeUrlError extends Error {
107+
constructor(agentIdentifier: string) {
108+
super(`No bridge URL configured for agent ${agentIdentifier}`);
109+
this.name = 'NoBridgeUrlError';
110+
}
111+
}
112+
106113
@Injectable()
107114
export class BridgeExecutorService {
108115
constructor(
@@ -119,7 +126,7 @@ export class BridgeExecutorService {
119126

120127
const bridgeUrl = await this.resolveBridgeUrl(config.environmentId, config.organizationId, agentIdentifier, event);
121128
if (!bridgeUrl) {
122-
return;
129+
throw new NoBridgeUrlError(agentIdentifier);
123130
}
124131

125132
const secretKey = await this.getDecryptedSecretKey.execute(
@@ -133,6 +140,10 @@ export class BridgeExecutorService {
133140
this.logger.error(err, `[agent:${agentIdentifier}] Bridge delivery failed after ${MAX_RETRIES + 1} attempts`);
134141
});
135142
} catch (err) {
143+
if (err instanceof NoBridgeUrlError) {
144+
throw err;
145+
}
146+
136147
this.logger.error(err, `[agent:${agentIdentifier}] Bridge setup failed — skipping bridge call`);
137148
}
138149
}

apps/api/src/app/agents/services/chat-sdk.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
1+
import { BadRequestException, forwardRef, Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
22
import { PinoLogger } from '@novu/application-generic';
33
import type { Chat, Message, Thread } from 'chat';
44
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
@@ -42,6 +42,7 @@ export class ChatSdkService implements OnModuleDestroy {
4242
constructor(
4343
private readonly logger: PinoLogger,
4444
private readonly agentConfigResolver: AgentConfigResolver,
45+
@Inject(forwardRef(() => AgentInboundHandler))
4546
private readonly inboundHandler: AgentInboundHandler
4647
) {
4748
this.instances = new LRUCache<string, Chat>({

0 commit comments

Comments
 (0)